minigame 2 add more design 1.5

This commit is contained in:
2026-05-04 15:20:32 +00:00
parent f6ec5f1742
commit 5692ab21ad
9 changed files with 1197 additions and 36 deletions
+2 -2
View File
@@ -71,9 +71,9 @@ server {
try_files $uri =404;
access_log off;
}
# Game — API ต้องรับ body ใหญ่ (รูปพื้นหลัง base64) จึงตั้ง 20M
# Game — API ต้องรับ body ใหญ่ (แมป + runway 5 ช่อง base64 / grid) — 20M มักไม่พอ
location /Game/api/ {
client_max_body_size 20M;
client_max_body_size 80M;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
File diff suppressed because one or more lines are too long
+26 -2
View File
@@ -309,6 +309,30 @@
<button type="button" id="editor-bg-scroll-reset-assets">คืนรูปเริ่มจากเซิร์ฟ / Reset to bundled</button>
</div>
</div>
<div id="editor-gauntlet-runway-bg-block" class="editor-bg-scroll-block" hidden>
<p class="legend" style="margin:0.5rem 0 0.35rem;line-height:1.45"><strong>พื้นหลังรันเวย์แนวนอน</strong> (เฉพาะฉากรหัส <code>mno9kb07</code> + โหมดพรมแดง) — รูป 1 = จุดเริ่ม (START) แล้วเลื่อนไปวนรูป 2–4 ระหว่างเล่น · พอหมดเวลาหรือเหลือผู้เล่นคนสุดท้าย (หลายคน) จะซ่อนอุปสรรค์แล้วแสดงรูปที่ 5 (FINISH) · บันทึกเป็น <code>gauntletCrownRunwayBg</code> ในแมป · <em>English:</em> Horizontal strip: start → loop images 24 → finish; obstacles hidden on end.</p>
<div class="editor-card__row editor-card__row--wrap" style="align-items:center;gap:0.75rem 1rem">
<label class="editor-bg-scroll-check"><input type="checkbox" id="editor-gauntlet-runway-bg-enabled" checked> เปิดเลื่อน / Enable runway BG</label>
<label>ความเร็ว px/s <input type="number" id="editor-gauntlet-runway-bg-speed" min="8" max="400" step="4" value="48" style="width:5rem" title="ความเร็วเลื่อนแนวนอน (จอ)"></label>
<label title="จุดเกิด (คอลัมน์ซ้ายสุด) เทียบตำแหน่งในแถบรูป START — เก็บเป็น runwaySpawnAlignFrac ในแมป">จุดเกิด vs START (0.020.95) <input type="number" id="editor-gauntlet-runway-bg-align-frac" min="0.02" max="0.95" step="0.01" value="0.32" style="width:4.2rem"></label>
</div>
<div class="editor-card__row editor-card__row--wrap" style="margin-top:0.35rem">
<label>รูปเริ่ม (1) <input type="file" id="editor-gauntlet-runway-bg-start-file" accept="image/*"></label>
<label>รูปลูป (2) <input type="file" id="editor-gauntlet-runway-bg-loop2-file" accept="image/*"></label>
<label>รูปลูป (3) <input type="file" id="editor-gauntlet-runway-bg-loop3-file" accept="image/*"></label>
<label>รูปลูป (4) <input type="file" id="editor-gauntlet-runway-bg-loop4-file" accept="image/*"></label>
<label>รูปจบ (5) <input type="file" id="editor-gauntlet-runway-bg-finish-file" accept="image/*"></label>
<button type="button" id="editor-gauntlet-runway-bg-reset-assets">คืนรูปเริ่มจากเซิร์ฟ / Reset to bundled</button>
</div>
<div id="editor-gauntlet-runway-bg-previews" class="legend" style="margin-top:0.45rem;display:flex;flex-wrap:wrap;gap:0.45rem 0.6rem;align-items:flex-end" aria-label="พรีวิวรูปรันเวย์ที่ใช้จริง (รวมค่าเริ่มจากเซิร์ฟถ้ายังไม่อัปโหลด)">
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">1 START</figcaption><img id="editor-gauntlet-runway-bg-prev-0" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">2 LOOP</figcaption><img id="editor-gauntlet-runway-bg-prev-1" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">3 LOOP</figcaption><img id="editor-gauntlet-runway-bg-prev-2" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">4 LOOP</figcaption><img id="editor-gauntlet-runway-bg-prev-3" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">5 FINISH</figcaption><img id="editor-gauntlet-runway-bg-prev-4" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
</div>
<p id="editor-gauntlet-runway-bg-upload-summary" class="legend" style="margin:0.4rem 0 0;line-height:1.5;font-size:0.82rem" aria-live="polite"></p>
</div>
</section>
<section class="editor-card editor-card--help" aria-labelledby="editor-card-help-label">
@@ -334,8 +358,8 @@
<p class="legend" id="editor-status">บันทึกแล้วจะได้รหัสฉาก ใช้รหัสนี้ตอนสร้างห้องในล็อบบี้</p>
</div>
</div>
<script src="js/version.js?v=0.0169"></script>
<script src="js/editor.js?v=20260503-mega-virus-ftp-dir"></script>
<script src="js/version.js?v=0.0249"></script>
<script src="js/editor.js?v=0.0249"></script>
<div class="version-tag">v —</div>
</body>
</html>
@@ -0,0 +1,2 @@
Place start.png, loop2.png, loop3.png, loop4.png, finish.png here for defaults (or set images in editor).
If files are missing, play.js draws colored placeholder strips so the runway still scrolls until you upload real assets.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

+536 -5
View File
@@ -485,6 +485,387 @@
return { enabled: !!(en && en.checked), pxPerLayer, maxPx };
}
/** พื้นหลังแนวนอน start + วน 2–4 + finish — เฉพาะแมป Last Light mno9kb07 (Editor + map JSON: gauntletCrownRunwayBg) */
const EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID = 'mno9kb07';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png';
const EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png';
let editorGauntletRunwayBgConfig = {
enabled: true,
speedPxPerSec: 48,
runwaySpawnAlignFrac: 0.32,
startImage: null,
loopImage2: null,
loopImage3: null,
loopImage4: null,
finishImage: null,
};
let editorGauntletRunwayBgImgs = [null, null, null, null, null];
let editorGauntletRunwayBgScrollPx = 0;
let editorGauntletRunwayBgRaf = null;
let editorGauntletRunwayBgLastTs = 0;
/** ชื่อไฟล์ล่าสุดที่ผู้ใช้เลือก (ช่อง input จะว่างหลังอ่านไฟล์ — ใช้โชว์สถานะแทน) */
let editorGauntletRunwayBgFileNames = {
startImage: '',
loopImage2: '',
loopImage3: '',
loopImage4: '',
finishImage: '',
};
const RUNWAY_PICK_MAX_W = 2048;
const RUNWAY_PICK_MAX_H = 1152;
const RUNWAY_PICK_COMPRESS_MIN_LEN = 650000;
/** ลดขนาด data URL ก่อนเก็บใน JSON — กันบันทึกไม่ขึ้นเมื่อรวมแมปใหญ่เกิน client_max_body_size */
function compressRunwayDataUrlIfLarge(dataUrl, done) {
if (typeof dataUrl !== 'string' || dataUrl.indexOf('data:image') !== 0) {
done(dataUrl);
return;
}
const im = new Image();
im.onload = () => {
try {
const nw = im.naturalWidth;
const nh = im.naturalHeight;
const needShrink = dataUrl.length > RUNWAY_PICK_COMPRESS_MIN_LEN
|| nw > RUNWAY_PICK_MAX_W
|| nh > RUNWAY_PICK_MAX_H;
if (!needShrink || nw < 1 || nh < 1) {
done(dataUrl);
return;
}
let w = nw;
let h = nh;
const sc = Math.min(1, RUNWAY_PICK_MAX_W / w, RUNWAY_PICK_MAX_H / h);
w = Math.max(1, Math.round(w * sc));
h = Math.max(1, Math.round(h * sc));
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const g = c.getContext('2d');
if (!g) {
done(dataUrl);
return;
}
g.imageSmoothingEnabled = true;
g.drawImage(im, 0, 0, w, h);
let best = c.toDataURL('image/jpeg', 0.84);
try {
const wu = c.toDataURL('image/webp', 0.84);
if (wu.length < best.length) best = wu;
} catch (e1) { /* webp ไม่รองรับ */ }
done(best.length < dataUrl.length ? best : dataUrl);
} catch (e) {
done(dataUrl);
}
};
im.onerror = () => { done(dataUrl); };
im.src = dataUrl;
}
function isEditorGauntletRunwayBgMap() {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
return mapId === EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID && gt === 'gauntlet';
}
function stopEditorGauntletRunwayBgRaf() {
if (editorGauntletRunwayBgRaf != null) {
cancelAnimationFrame(editorGauntletRunwayBgRaf);
editorGauntletRunwayBgRaf = null;
}
editorGauntletRunwayBgLastTs = 0;
}
function syncEditorGauntletRunwayBgBlockVisibility() {
const block = document.getElementById('editor-gauntlet-runway-bg-block');
if (!block) return;
/** โชว์ทันทีจากรหัสใน URL — ไม่ผูกกับค่า gameType ตอนก่อน fetch (เดิมเป็น zep ทำให้บล็อกถูกซ่อนจนเหมือนไม่มีฟีเจอร์) */
const show = mapId === EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID;
if (!show) stopEditorGauntletRunwayBgRaf();
block.hidden = !show;
}
function editorGauntletRunwayBgEligibleFromMap(m) {
const gt = (m && m.gameType != null && m.gameType !== '') ? m.gameType : (gameTypeEl ? gameTypeEl.value : gameType);
return mapId === EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID && gt === 'gauntlet';
}
function editorGauntletRunwayBgStripWidthsAtCh(chR) {
const ch0 = Math.max(1, Math.round(chR));
const wOf = (im) => {
if (!im || !im.complete || !im.naturalWidth || !im.naturalHeight) return 0;
return Math.max(1, Math.round(im.naturalWidth * (ch0 / im.naturalHeight)));
};
return {
w0: wOf(editorGauntletRunwayBgImgs[0]),
w1: wOf(editorGauntletRunwayBgImgs[1]),
w2: wOf(editorGauntletRunwayBgImgs[2]),
w3: wOf(editorGauntletRunwayBgImgs[3]),
};
}
function isEditorGauntletRunwayBgDrawing() {
if (!isEditorGauntletRunwayBgMap() || !editorGauntletRunwayBgConfig.enabled) return false;
for (let i = 0; i < 5; i++) {
const im = editorGauntletRunwayBgImgs[i];
if (!im || !im.complete || !im.naturalWidth) return false;
}
return true;
}
function drawRunwayHSliceEditor(ctx, img, worldX0, segW, scrollLeft, cwR, chR) {
if (!img || !img.complete || !img.naturalWidth || !img.naturalHeight || segW <= 0) return;
const natW = img.naturalWidth;
const natH = img.naturalHeight;
const v0 = scrollLeft;
const v1 = scrollLeft + cwR;
const o0 = Math.max(worldX0, v0);
const o1 = Math.min(worldX0 + segW, v1);
if (o1 <= o0) return;
const u0 = ((o0 - worldX0) / segW) * natW;
const u1 = ((o1 - worldX0) / segW) * natW;
const dx = o0 - scrollLeft;
const dw = o1 - o0;
ctx.drawImage(img, u0, 0, u1 - u0, natH, dx, 0, dw, chR);
}
function drawEditorGauntletRunwayScrollingBackground(cw, ch) {
const start = editorGauntletRunwayBgImgs[0];
const L2 = editorGauntletRunwayBgImgs[1];
const L3 = editorGauntletRunwayBgImgs[2];
const L4 = editorGauntletRunwayBgImgs[3];
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const { w0, w1, w2, w3 } = editorGauntletRunwayBgStripWidthsAtCh(chR);
const cycle = w1 + w2 + w3;
const S = editorGauntletRunwayBgScrollPx;
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, cwR, chR);
if (w0 > 0) drawRunwayHSliceEditor(ctx, start, 0, w0, S, cwR, chR);
if (cycle <= 0) {
ctx.restore();
return;
}
const ws = [w1, w2, w3];
const ims = [L2, L3, L4];
const uTotal = Math.max(0, S - w0);
let cycN = Math.floor(uTotal / cycle);
let uRem = uTotal - cycN * cycle;
let si = 0;
while (si < 3 && uRem >= ws[si]) {
uRem -= ws[si];
si++;
}
if (si >= 3) {
cycN++;
si = 0;
uRem = 0;
}
let acc = 0;
for (let k = 0; k < si; k++) acc += ws[k];
let curLeft = w0 + cycN * cycle + acc;
let guard = 0;
let idx = si;
while (curLeft < S + cwR && guard++ < 500) {
drawRunwayHSliceEditor(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR);
curLeft += ws[idx];
idx = (idx + 1) % 3;
}
ctx.restore();
}
function editorGauntletRunwayBgTick(ts) {
editorGauntletRunwayBgRaf = null;
if (!isEditorGauntletRunwayBgDrawing()) return;
if (!editorGauntletRunwayBgLastTs) editorGauntletRunwayBgLastTs = ts;
const dt = Math.min(64, Math.max(0, ts - editorGauntletRunwayBgLastTs));
editorGauntletRunwayBgLastTs = ts;
editorGauntletRunwayBgScrollPx += (editorGauntletRunwayBgConfig.speedPxPerSec || 48) * (dt / 1000);
draw();
editorGauntletRunwayBgRaf = requestAnimationFrame(editorGauntletRunwayBgTick);
}
function startEditorGauntletRunwayBgRaf() {
if (!isEditorGauntletRunwayBgDrawing()) return;
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgLastTs = 0;
editorGauntletRunwayBgRaf = requestAnimationFrame(editorGauntletRunwayBgTick);
}
function loadEditorGauntletRunwayBgImages(s0, s1, s2, s3, s4, onDone) {
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgImgs = [null, null, null, null, null];
let pending = 5;
const doneOne = () => {
pending--;
if (pending <= 0 && typeof onDone === 'function') onDone();
};
const srcs = [s0, s1, s2, s3, s4];
for (let i = 0; i < 5; i++) {
const im = new Image();
const idx = i;
im.onload = () => {
editorGauntletRunwayBgImgs[idx] = im;
doneOne();
};
im.onerror = () => { doneOne(); };
im.src = srcs[i];
}
}
function applyGauntletCrownRunwayBgFromMap(m) {
syncEditorGauntletRunwayBgBlockVisibility();
if (mapId !== EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) {
editorGauntletRunwayBgConfig = {
enabled: true,
speedPxPerSec: 48,
runwaySpawnAlignFrac: 0.32,
startImage: null,
loopImage2: null,
loopImage3: null,
loopImage4: null,
finishImage: null,
};
editorGauntletRunwayBgFileNames = { startImage: '', loopImage2: '', loopImage3: '', loopImage4: '', finishImage: '' };
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgImgs = [null, null, null, null, null];
return;
}
editorGauntletRunwayBgFileNames = { startImage: '', loopImage2: '', loopImage3: '', loopImage4: '', finishImage: '' };
const raw = m && m.gauntletCrownRunwayBg && typeof m.gauntletCrownRunwayBg === 'object' ? m.gauntletCrownRunwayBg : {};
const sp = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48));
const afRaw = Number(raw.runwaySpawnAlignFrac);
const af = Number.isFinite(afRaw) ? Math.max(0.02, Math.min(0.95, afRaw)) : 0.32;
editorGauntletRunwayBgConfig = {
enabled: raw.enabled !== false,
speedPxPerSec: sp,
runwaySpawnAlignFrac: af,
startImage: typeof raw.startImage === 'string' && raw.startImage.length ? raw.startImage : null,
loopImage2: typeof raw.loopImage2 === 'string' && raw.loopImage2.length ? raw.loopImage2 : null,
loopImage3: typeof raw.loopImage3 === 'string' && raw.loopImage3.length ? raw.loopImage3 : null,
loopImage4: typeof raw.loopImage4 === 'string' && raw.loopImage4.length ? raw.loopImage4 : null,
finishImage: typeof raw.finishImage === 'string' && raw.finishImage.length ? raw.finishImage : null,
};
const eligible = editorGauntletRunwayBgEligibleFromMap(m || {});
if (!editorGauntletRunwayBgConfig.enabled) {
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgImgs = [null, null, null, null, null];
syncEditorGauntletRunwayBgUiFromConfig();
draw();
return;
}
const s0 = editorGauntletRunwayBgConfig.startImage || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START;
const s1 = editorGauntletRunwayBgConfig.loopImage2 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2;
const s2 = editorGauntletRunwayBgConfig.loopImage3 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3;
const s3 = editorGauntletRunwayBgConfig.loopImage4 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4;
const s4 = editorGauntletRunwayBgConfig.finishImage || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH;
editorGauntletRunwayBgScrollPx = 0;
loadEditorGauntletRunwayBgImages(s0, s1, s2, s3, s4, () => {
syncEditorGauntletRunwayBgUiFromConfig();
draw();
stopEditorGauntletRunwayBgRaf();
if (eligible && editorGauntletRunwayBgConfig.enabled) startEditorGauntletRunwayBgRaf();
});
}
function syncEditorGauntletRunwayBgUiFromConfig() {
const en = document.getElementById('editor-gauntlet-runway-bg-enabled');
const sp = document.getElementById('editor-gauntlet-runway-bg-speed');
const af = document.getElementById('editor-gauntlet-runway-bg-align-frac');
if (en) en.checked = !!editorGauntletRunwayBgConfig.enabled;
if (sp) sp.value = String(editorGauntletRunwayBgConfig.speedPxPerSec || 48);
if (af) {
const v = editorGauntletRunwayBgConfig.runwaySpawnAlignFrac;
af.value = String(Number.isFinite(v) ? Math.max(0.02, Math.min(0.95, v)) : 0.32);
}
syncEditorGauntletRunwayBgUploadSummary();
syncEditorGauntletRunwayBgThumbnails();
}
function syncEditorGauntletRunwayBgThumbnails() {
if (mapId !== EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) return;
const keys = ['startImage', 'loopImage2', 'loopImage3', 'loopImage4', 'finishImage'];
const defaults = [
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH,
];
const labels = ['START', 'LOOP', 'LOOP', 'LOOP', 'FINISH'];
for (let i = 0; i < 5; i++) {
const el = document.getElementById('editor-gauntlet-runway-bg-prev-' + i);
if (!el) continue;
const custom = editorGauntletRunwayBgConfig[keys[i]];
const src = (typeof custom === 'string' && custom.length) ? custom : defaults[i];
el.alt = 'Runway ' + labels[i] + ' — slot ' + (i + 1);
el.src = src;
}
}
function describeRunwaySlotStoredValue(val, fileName) {
if (!val || typeof val !== 'string' || !val.length) {
return { th: 'ยังไม่อัปโหลด — เกมใช้พื้นสีชั่วคราว', en: 'Not set — game uses soft color strip' };
}
if (val.startsWith('data:image')) {
const fn = (fileName && String(fileName).trim()) ? ` · ${fileName.trim()}` : '';
return { th: `อัปโหลดแล้ว (เก็บในฉาก)${fn}`, en: `Saved in map (data URL)${fn}` };
}
const short = val.length > 56 ? (val.slice(0, 54) + '…') : val;
return { th: `ตั้งจากแมป/URL: ${short}`, en: `From map/URL: ${short}` };
}
function syncEditorGauntletRunwayBgUploadSummary() {
const el = document.getElementById('editor-gauntlet-runway-bg-upload-summary');
if (!el) return;
if (mapId !== EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) {
el.textContent = '';
return;
}
const keys = ['startImage', 'loopImage2', 'loopImage3', 'loopImage4', 'finishImage'];
const labels = ['1 เริ่ม', '2 ลูป', '3 ลูป', '4 ลูป', '5 จบ'];
const parts = [];
for (let i = 0; i < keys.length; i++) {
const d = describeRunwaySlotStoredValue(editorGauntletRunwayBgConfig[keys[i]], editorGauntletRunwayBgFileNames[keys[i]]);
parts.push(`${labels[i]}: ${d.th} / ${d.en}`);
}
const tail = '\n\nพรีวิวรูปแถวด้านบน = สิ่งที่เกมจะใช้ (รวมรูปเซิร์ฟถ้าช่องว่าง)\n'
+ 'Thumbnails above = what the game uses (bundled defaults if slot empty).\n\n'
+ 'หลังเลือกไฟล์ ช่องจะกลับเป็น «No file chosen» เป็นปกติของเบราว์เซอร์\n'
+ 'After pick, the file box clears — use thumbnails + this text.\n\n'
+ 'ต้องกด บันทึกฉาก ถึงจะไปโชว์ในเกมจริง · Click Save scene for play mode.';
el.textContent = 'สถานะรูปรันเวย์ · Runway images\n' + parts.join('\n') + tail;
}
function readGauntletCrownRunwayBgForSave() {
/** บันทึกจากรหัสแมป — ไม่ผูกกับ dropdown โหมดอย่างเดียว (เดิมถ้าไม่ใช่ gauntlet จะไม่ส่ง → รีเฟรชแล้วหาย) · ส่งครบทุกคีย์เพราะ PUT แทนที่ทั้ง object */
if (mapId !== EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) return undefined;
const en = document.getElementById('editor-gauntlet-runway-bg-enabled');
const spEl = document.getElementById('editor-gauntlet-runway-bg-speed');
const sp = Math.max(8, Math.min(400, Math.floor(Number(spEl && spEl.value)) || 48));
const s = (k) => {
const v = editorGauntletRunwayBgConfig[k];
return (typeof v === 'string' && v.length) ? v : null;
};
const afEl = document.getElementById('editor-gauntlet-runway-bg-align-frac');
const afv = Math.max(0.02, Math.min(0.95, Number(afEl && afEl.value) || 0.32));
return {
enabled: !!(en && en.checked),
speedPxPerSec: sp,
runwaySpawnAlignFrac: afv,
startImage: s('startImage'),
loopImage2: s('loopImage2'),
loopImage3: s('loopImage3'),
loopImage4: s('loopImage4'),
finishImage: s('finishImage'),
};
}
function readEditorBgScrollConfigForSave() {
if (!isEditorScrollBgMap()) return null;
const en = document.getElementById('editor-bg-scroll-enabled');
@@ -1318,6 +1699,7 @@
}
function toggleFroggerUI() {
syncEditorGauntletRunwayBgBlockVisibility();
syncStackTowerCamBlockVisibilityOnly();
const froggerWrap = document.getElementById('frogger-lanes-wrap');
const hint = document.getElementById('game-type-hint');
@@ -1675,6 +2057,8 @@
if (gtDraw === 'balloon_boss') ensureBalloonBossPlayerSlots();
if (isEditorScrollBgDrawing()) {
drawEditorScrollingBackground(canvas.width, canvas.height);
} else if (isEditorGauntletRunwayBgDrawing()) {
drawEditorGauntletRunwayScrollingBackground(canvas.width, canvas.height);
} else if (isEditorStackTowerBgDrawing()) {
drawStackTowerScrollingBackground(canvas.width, canvas.height);
} else if (backgroundImageImg && backgroundImageImg.complete && backgroundImageImg.naturalWidth) {
@@ -2599,7 +2983,110 @@
});
}
})();
(function wireEditorGauntletRunwayBgUi() {
const en = document.getElementById('editor-gauntlet-runway-bg-enabled');
const sp = document.getElementById('editor-gauntlet-runway-bg-speed');
if (en) {
en.addEventListener('change', () => {
editorGauntletRunwayBgConfig.enabled = !!en.checked;
if (editorGauntletRunwayBgConfig.enabled) {
applyGauntletCrownRunwayBgFromMap({
gameType: 'gauntlet',
gauntletCrownRunwayBg: {
enabled: true,
speedPxPerSec: editorGauntletRunwayBgConfig.speedPxPerSec,
runwaySpawnAlignFrac: editorGauntletRunwayBgConfig.runwaySpawnAlignFrac,
startImage: editorGauntletRunwayBgConfig.startImage,
loopImage2: editorGauntletRunwayBgConfig.loopImage2,
loopImage3: editorGauntletRunwayBgConfig.loopImage3,
loopImage4: editorGauntletRunwayBgConfig.loopImage4,
finishImage: editorGauntletRunwayBgConfig.finishImage,
},
});
} else {
stopEditorGauntletRunwayBgRaf();
editorGauntletRunwayBgImgs = [null, null, null, null, null];
draw();
}
});
}
if (sp) {
sp.addEventListener('change', () => {
editorGauntletRunwayBgConfig.speedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(sp.value)) || 48));
});
}
const afIn = document.getElementById('editor-gauntlet-runway-bg-align-frac');
if (afIn) {
afIn.addEventListener('change', () => {
editorGauntletRunwayBgConfig.runwaySpawnAlignFrac = Math.max(0.02, Math.min(0.95, Number(afIn.value) || 0.32));
});
}
function wireFile(id, key) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const pickedName = file.name || '';
const r = new FileReader();
r.onload = () => {
compressRunwayDataUrlIfLarge(r.result, (finalUrl) => {
editorGauntletRunwayBgConfig[key] = finalUrl;
editorGauntletRunwayBgFileNames[key] = pickedName;
syncEditorGauntletRunwayBgThumbnails();
const s0 = editorGauntletRunwayBgConfig.startImage || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START;
const s1 = editorGauntletRunwayBgConfig.loopImage2 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2;
const s2 = editorGauntletRunwayBgConfig.loopImage3 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3;
const s3 = editorGauntletRunwayBgConfig.loopImage4 || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4;
const s4 = editorGauntletRunwayBgConfig.finishImage || EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH;
editorGauntletRunwayBgScrollPx = 0;
loadEditorGauntletRunwayBgImages(s0, s1, s2, s3, s4, () => {
syncEditorGauntletRunwayBgUploadSummary();
syncEditorGauntletRunwayBgThumbnails();
draw();
if (editorGauntletRunwayBgConfig.enabled) startEditorGauntletRunwayBgRaf();
});
});
};
r.readAsDataURL(file);
e.target.value = '';
});
}
wireFile('editor-gauntlet-runway-bg-start-file', 'startImage');
wireFile('editor-gauntlet-runway-bg-loop2-file', 'loopImage2');
wireFile('editor-gauntlet-runway-bg-loop3-file', 'loopImage3');
wireFile('editor-gauntlet-runway-bg-loop4-file', 'loopImage4');
wireFile('editor-gauntlet-runway-bg-finish-file', 'finishImage');
const btnR = document.getElementById('editor-gauntlet-runway-bg-reset-assets');
if (btnR) {
btnR.addEventListener('click', () => {
editorGauntletRunwayBgConfig.startImage = null;
editorGauntletRunwayBgConfig.loopImage2 = null;
editorGauntletRunwayBgConfig.loopImage3 = null;
editorGauntletRunwayBgConfig.loopImage4 = null;
editorGauntletRunwayBgConfig.finishImage = null;
editorGauntletRunwayBgFileNames = { startImage: '', loopImage2: '', loopImage3: '', loopImage4: '', finishImage: '' };
editorGauntletRunwayBgScrollPx = 0;
loadEditorGauntletRunwayBgImages(
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_START,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP2,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP3,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_LOOP4,
EDITOR_GAUNTLET_RUNWAY_BG_DEFAULT_FINISH,
() => {
syncEditorGauntletRunwayBgUiFromConfig();
draw();
if (editorGauntletRunwayBgConfig.enabled) startEditorGauntletRunwayBgRaf();
},
);
});
}
})();
syncEditorGauntletRunwayBgBlockVisibility();
if (isEditorScrollBgMap()) applyEditorBgScrollFromMap({});
if (isEditorGauntletRunwayBgMap()) applyGauntletCrownRunwayBgFromMap({});
if (isEditorStackTowerMissionMap()) applyStackTowerCameraFollowFromMap({});
else {
stopStackTowerBgScrollRaf();
@@ -2688,8 +3175,14 @@
isSpawnMode = true;
document.getElementById('btn-spawn').textContent = 'คลิกช่องที่ต้องการ';
});
if (gameTypeEl) gameTypeEl.addEventListener('change', () => {
gameType = gameTypeEl.value;
if (gameTypeEl) gameTypeEl.addEventListener('change', () => {
gameType = gameTypeEl.value;
syncEditorGauntletRunwayBgBlockVisibility();
if (mapId === EDITOR_GAUNTLET_RUNWAY_BG_MAP_ID) {
stopEditorGauntletRunwayBgRaf();
if (isEditorGauntletRunwayBgMap() && editorGauntletRunwayBgConfig.enabled) startEditorGauntletRunwayBgRaf();
draw();
}
if (drawModeEl && drawModeEl.value === 'startGame' && gameType !== 'lobby') drawModeEl.value = 'wall';
if (drawModeEl && drawModeEl.value === 'spawnArea' && gameType === 'frogger') drawModeEl.value = 'wall';
if (drawModeEl && (drawModeEl.value === 'quizTrue' || drawModeEl.value === 'quizFalse') && gameType !== 'quiz') drawModeEl.value = 'wall';
@@ -2907,6 +3400,8 @@
if (stcSave !== undefined) body.stackTowerCameraFollow = stcSave;
const stBgSave = readStackTowerBgScrollForSave();
if (stBgSave !== undefined) body.stackTowerBgScroll = stBgSave;
const gcrSave = readGauntletCrownRunwayBgForSave();
if (gcrSave !== undefined) body.gauntletCrownRunwayBg = gcrSave;
body.lanes = gameType === 'frogger' ? lanes : [];
sanitizeGauntletPlayerSpawns();
body.gauntletPlayerSpawns = gameType === 'gauntlet'
@@ -2924,8 +3419,36 @@
}
const url = mapId ? BASE + '/api/maps/' + mapId : BASE + '/api/maps';
const method = mapId ? 'PUT' : 'POST';
fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(r => r.json())
let payloadStr;
try {
payloadStr = JSON.stringify(body);
} catch (err) {
if (statusEl) statusEl.textContent = 'แปลงฉากเป็น JSON ไม่ได้ — รูป/ข้อมูลใหญ่เกิน · Cannot stringify map JSON';
return;
}
const payloadBytes = (typeof TextEncoder !== 'undefined')
? new TextEncoder().encode(payloadStr).length
: new Blob([payloadStr]).size;
const maxSaveBytes = 78 * 1024 * 1024;
if (payloadBytes > maxSaveBytes) {
if (statusEl) {
statusEl.textContent = 'ข้อมูลฉาก ~' + Math.round(payloadBytes / (1024 * 1024)) + 'MB ใหญ่เกิน ' + Math.round(maxSaveBytes / (1024 * 1024))
+ 'MB — ลดรูปรันเวย์/คลังกริด หรือให้แอดมินเพิ่ม client_max_body_size · Map payload too large';
}
return;
}
fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: payloadStr })
.then((r) => {
if (!r.ok) {
return r.text().then((t) => {
const hint = r.status === 413
? ' (413 = body ใหญ่เกิน nginx — เพิ่ม client_max_body_size ใน /Game/api/ หรือลดรูป)'
: '';
throw new Error('HTTP ' + r.status + hint + (t ? ' ' + t.slice(0, 120) : ''));
});
}
return r.json();
})
.then(data => {
if (data.ok) {
const id = data.mapId || mapId;
@@ -2948,7 +3471,13 @@
if (!mapId) window.history.replaceState({}, '', 'editor.html' + buildEditorSearch(id));
} else statusEl.textContent = data.error || 'บันทึกไม่ได้';
})
.catch(() => { statusEl.textContent = 'บันทึกไม่ได้'; });
.catch((err) => {
if (statusEl) {
statusEl.textContent = 'บันทึกไม่ได้'
+ (err && err.message ? ' — ' + err.message : '')
+ ' · ถ้าเป็น 413 ให้ reload nginx หลังเพิ่ม client_max_body_size';
}
});
});
const cellColorWrap = document.getElementById('cell-color-wrap');
@@ -3221,6 +3750,8 @@
}
gameType = m.gameType || 'zep';
if (gameTypeEl) gameTypeEl.value = gameType;
syncEditorGauntletRunwayBgBlockVisibility();
applyGauntletCrownRunwayBgFromMap(m);
applyStackTowerCameraFollowFromMap(m);
lanes = (m.lanes && m.lanes.length) ? m.lanes.map(l => ({ ...l })) : [];
if (gameType === 'gauntlet' || gameType === 'stack' || gameType === 'quiz_carry' || gameType === 'quiz_battle' || gameType === 'jump_survive' || gameType === 'space_shooter' || gameType === 'balloon_boss') lanes = [];
+589 -23
View File
@@ -418,6 +418,374 @@
playScrollBgPx += (playScrollBgSpeedPxPerSec || 56) * dtSec;
}
/** Last Light (mno9kb07) — พื้นหลังแนวนอน: start → วน loop 24 → จบแล้ว finish + ซ่อนอุปสรรค (gauntletCrownRunwayBg ในแมป) */
const GAUNTLET_CROWN_RUNWAY_BG_MAP_ID = 'mno9kb07';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png';
let gauntletCrownRunwayBgImgs = [null, null, null, null, null];
let gauntletCrownRunwayBgOff = false;
let gauntletCrownRunwayBgSpeedPxPerSec = 48;
let gauntletCrownRunwayBgScrollPx = 0;
let gauntletCrownRunwayBgFinishLatched = false;
let gauntletCrownRunwayBgInitialTotalPeers = null;
/** พรมแดง mno9kb07 — จำกัดขอบขวาของตัว (ขอบขวา footprint ไม่เกินสัดส่วนนี้ของความกว้างแมปเป็นช่อง) */
const GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC = 0.8;
const GAUNTLET_CROWN_SCORE_POP_URL = BASE + '/img/gauntlet-assets/score-.png';
let gauntletCrownScorePenaltyImg = null;
function ensureGauntletCrownScorePenaltyImgPlay() {
if (gauntletCrownScorePenaltyImg) return gauntletCrownScorePenaltyImg;
const im = new Image();
im.src = GAUNTLET_CROWN_SCORE_POP_URL;
gauntletCrownScorePenaltyImg = im;
return im;
}
let lastGauntletCrownRunwayBgTickMs = 0;
/** จัด offset เลื่อนรันเวย์ให้จุดเกิดตรงกับงานศิลป์ START (รูป 1) — รีเซ็ตตอน howto / โหลดแมปใหม่ */
let gauntletCrownRunwaySpawnScrollSnapped = false;
function gauntletCrownRunwayBgMapEligiblePlay() {
return !!(mapData && mapData.gameType === 'gauntlet' && (currentPlayMapId() || '').trim() === GAUNTLET_CROWN_RUNWAY_BG_MAP_ID);
}
function gauntletCrownRunwayBgNatDims(im) {
if (!im) return null;
if (im.tagName === 'CANVAS' && im.width > 0 && im.height > 0) return { nw: im.width, nh: im.height };
if (im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) return { nw: im.naturalWidth, nh: im.naturalHeight };
return null;
}
function gauntletCrownRunwayBgImageSlotReadyPlay(im) {
return !!gauntletCrownRunwayBgNatDims(im);
}
function gauntletCrownRunwayBgStripWidthsAtCh(chR) {
const ch0 = Math.max(1, Math.round(chR));
const wOf = (im) => {
const d = gauntletCrownRunwayBgNatDims(im);
if (!d) return 0;
return Math.max(1, Math.round(d.nw * (ch0 / d.nh)));
};
return {
w0: wOf(gauntletCrownRunwayBgImgs[0]),
w1: wOf(gauntletCrownRunwayBgImgs[1]),
w2: wOf(gauntletCrownRunwayBgImgs[2]),
w3: wOf(gauntletCrownRunwayBgImgs[3]),
w4: wOf(gauntletCrownRunwayBgImgs[4]),
};
}
function gauntletCrownRunwayBgImagesReadyPlay() {
for (let i = 0; i < 5; i++) {
if (!gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) return false;
}
return true;
}
/** ถ้า URL บนเซิร์ฟยังไม่มีไฟล์ (404) ให้แผ่นเลื่อนได้ — พื้นผิวนุ่ม ไม่ใส่ข้อความใหญ่ (กันเหมือน debug) */
function ensureGauntletCrownRunwayBgPlaceholdersPlay() {
if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return;
const W = 960;
const H = 540;
const hues = [352, 210, 165, 225, 38];
for (let i = 0; i < 5; i++) {
if (gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) continue;
const c = document.createElement('canvas');
c.width = W;
c.height = H;
const g = c.getContext('2d');
const h0 = hues[i];
const grd = g.createLinearGradient(0, 0, W, H * 0.9);
grd.addColorStop(0, `hsl(${h0},26%,20%)`);
grd.addColorStop(0.45, `hsl(${h0},18%,14%)`);
grd.addColorStop(1, `hsl(${h0},12%,10%)`);
g.fillStyle = grd;
g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(255,255,255,0.06)';
g.lineWidth = 1;
for (let x = 0; x < W; x += 48) {
g.beginPath();
g.moveTo(x, 0);
g.lineTo(x + H * 0.04, H);
g.stroke();
}
const vg = g.createRadialGradient(W * 0.45, H * 0.35, 20, W * 0.5, H * 0.55, W * 0.85);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.38)');
g.fillStyle = vg;
g.fillRect(0, 0, W, H);
g.fillStyle = 'rgba(255,255,255,0.1)';
g.font = '10px system-ui,sans-serif';
g.textAlign = 'right';
g.fillText(String(i + 1), W - 10, H - 8);
gauntletCrownRunwayBgImgs[i] = c;
}
}
function gauntletCrownRunwayBgDrawActivePlay() {
if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return false;
return gauntletCrownRunwayBgImagesReadyPlay();
}
function gauntletCrownRunwayBgHideObstaclesPlay() {
return gauntletCrownRunwayBgDrawActivePlay() && gauntletCrownRunwayBgFinishLatched;
}
function drawImageCoverCanvasPlay(ctx, img, cwR, chR) {
const d = gauntletCrownRunwayBgNatDims(img);
if (!d) return;
const iw = d.nw;
const ih = d.nh;
if (!iw || !ih) return;
const scale = Math.max(cwR / iw, chR / ih);
const dw = iw * scale;
const dh = ih * scale;
const dx = (cwR - dw) / 2;
const dy = (chR - dh) / 2;
ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
}
function drawRunwayHSlicePlay(ctx, img, worldX0, segW, scrollLeft, cwR, chR) {
const d0 = gauntletCrownRunwayBgNatDims(img);
if (!d0 || segW <= 0) return;
const natW = d0.nw;
const natH = d0.nh;
const v0 = scrollLeft;
const v1 = scrollLeft + cwR;
const o0 = Math.max(worldX0, v0);
const o1 = Math.min(worldX0 + segW, v1);
if (o1 <= o0) return;
const u0 = ((o0 - worldX0) / segW) * natW;
const u1 = ((o1 - worldX0) / segW) * natW;
const dx = o0 - scrollLeft;
const dw = o1 - o0;
/** ทาบแนวตั้งเล็กน้อยกันช่องว่างย่อย 1px (seam) ระหว่างแถบลูป */
ctx.drawImage(img, u0, 0, u1 - u0, natH, dx - 0.25, 0, dw + 0.55, chR);
}
/** วาดแถบรันเวย์ในระบบพิกัด X = ความกว้างมองเห็น (cwR) × สูงแถบ chR · scrollView = จุดเริ่ม viewport ในโลกรันเวย์ */
function drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView) {
const start = gauntletCrownRunwayBgImgs[0];
const L2 = gauntletCrownRunwayBgImgs[1];
const L3 = gauntletCrownRunwayBgImgs[2];
const L4 = gauntletCrownRunwayBgImgs[3];
const { w0, w1, w2, w3 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
const cycle = w1 + w2 + w3;
const S = scrollView;
ctx.imageSmoothingEnabled = true;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, cwR, chR);
if (w0 > 0) drawRunwayHSlicePlay(ctx, start, 0, w0, S, cwR, chR);
if (cycle <= 0) return;
const ws = [w1, w2, w3];
const ims = [L2, L3, L4];
const uTotal = Math.max(0, S - w0);
let cycN = Math.floor(uTotal / cycle);
let uRem = uTotal - cycN * cycle;
let si = 0;
while (si < 3 && uRem >= ws[si]) {
uRem -= ws[si];
si++;
}
if (si >= 3) {
cycN++;
si = 0;
uRem = 0;
}
let acc = 0;
for (let k = 0; k < si; k++) acc += ws[k];
let curLeft = w0 + cycN * cycle + acc;
let guard = 0;
let idx = si;
while (curLeft < S + cwR && guard++ < 500) {
drawRunwayHSlicePlay(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR);
curLeft += ws[idx];
idx = (idx + 1) % 3;
}
}
/**
* @param {number} canvasW
* @param {number} canvasH
* @param {null|{ worldMinX:number, worldMaxX:number, camX:number, camY:number, zDraw:number, mapWpx:number, mapHpx:number }} worldAlign าม จะคล+สเกลใหตรงกรดแมป (/งกดขวางตรงพนท)
*/
function drawGauntletCrownRunwayBgFullCanvasPlay(canvasW, canvasH, worldAlign) {
const finish = gauntletCrownRunwayBgImgs[4];
const align = worldAlign && typeof worldAlign === 'object'
&& Number.isFinite(worldAlign.worldMinX)
&& Number.isFinite(worldAlign.worldMaxX)
&& Number.isFinite(worldAlign.camX)
&& Number.isFinite(worldAlign.camY)
&& Number.isFinite(worldAlign.zDraw)
&& Number.isFinite(worldAlign.mapWpx)
&& Number.isFinite(worldAlign.mapHpx);
if (gauntletCrownRunwayBgFinishLatched && gauntletCrownRunwayBgImageSlotReadyPlay(finish)) {
if (align) {
const { camX, camY, zDraw, mapWpx, mapHpx } = worldAlign;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, canvasW, canvasH);
ctx.save();
/* เมทริกซ์เดียวกับ worldToScreen + คลิปโลก [0,map] — รูปจบครอบทั้งแผนที่ที่ world origin */
ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw);
ctx.beginPath();
ctx.rect(0, 0, mapWpx, mapHpx);
ctx.clip();
drawImageCoverCanvasPlay(ctx, finish, Math.max(1, Math.round(mapWpx)), Math.max(1, Math.round(mapHpx)));
ctx.restore();
} else {
drawImageCoverCanvasPlay(ctx, finish, Math.max(1, Math.round(canvasW)), Math.max(1, Math.round(canvasH)));
}
return;
}
/* ใช้ความกว้างมองเห็นแบบ float ให้เท่ากับ canvas.width/zDraw — ห้าม Math.round ไม่งั้นซูมแล้วแถบ runway กว้างไม่ตรง worldToScreen (แผนหลุดจากตัว) */
const cwR = align
? Math.max(1e-6, worldAlign.worldMaxX - worldAlign.worldMinX)
: Math.max(1, Math.round(canvasW));
const chR = align
? Math.max(1, Math.round(worldAlign.mapHpx))
: Math.max(1, Math.round(canvasH));
const scrollView = align
? (gauntletCrownRunwayBgScrollPx + worldAlign.worldMinX)
: gauntletCrownRunwayBgScrollPx;
if (align) {
const { camX, camY, zDraw, mapWpx, mapHpx, worldMinX } = worldAlign;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, canvasW, canvasH);
ctx.save();
ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw);
ctx.beginPath();
ctx.rect(0, 0, mapWpx, mapHpx);
ctx.clip();
ctx.translate(worldMinX, 0);
drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
ctx.restore();
return;
}
ctx.save();
drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
ctx.restore();
}
function gauntletCrownRunwayAliveCountPlay() {
let n = 0;
if (!me.gauntletEliminated) n++;
others.forEach((o, id) => {
if (isPreviewBotId(id)) return;
if (!o.gauntletEliminated) n++;
});
return n;
}
function ensureGauntletCrownRunwayInitialPeerSnapshotPlay() {
if (gauntletCrownRunwayBgInitialTotalPeers != null) return;
if (!gauntletCrownRunwayBgMapEligiblePlay()) return;
if (gauntletCrownPregamePhase !== 'live') return;
let n = 1;
others.forEach((o, id) => {
if (isPreviewBotId(id)) return;
n++;
});
gauntletCrownRunwayBgInitialTotalPeers = n;
}
function gauntletCrownRunwayShouldFinishPlay() {
if (!gauntletCrownRunwayBgDrawActivePlay()) return false;
if (!mapData || gauntletCrownPregamePhase !== 'live') return false;
ensureGauntletCrownRunwayInitialPeerSnapshotPlay();
if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs) && Date.now() >= gauntletEndsAtMs) return true;
const tot = gauntletCrownRunwayBgInitialTotalPeers || 1;
if (tot >= 2 && gauntletCrownRunwayAliveCountPlay() <= 1) return true;
return false;
}
function resetGauntletCrownRunwaySpawnScrollSnap() {
gauntletCrownRunwaySpawnScrollSnapped = false;
}
/**
* gauntletCrownRunwayBgScrollPx ครงแรกใหคอลมนเก (ายส) ตรง ~runwaySpawnAlignFrac ของความกวางแถบ START (w0)
* ในแมป: gauntletCrownRunwayBg.runwaySpawnAlignFrac 0.020.95 (าเร 0.32)
* Anchor: คอลมนายสดระหวางชองเกดในแมปกบตำแหนงจรงของทกคนในรอบ (รวม me + บอทพร) me resolve ไปคอลมนายกว min ของชองวาด แต runway ดแคองวาด จะเหนตวลอยซายของพรมแดง
* Use min(editor spawn column, floor of every alive entity x) so runway START art stays tied to where characters actually stand.
*/
function trySnapGauntletCrownRunwayScrollToSpawnsPlay() {
if (gauntletCrownRunwaySpawnScrollSnapped) return;
if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff || !mapData) return;
if (!gauntletCrownRunwayBgImagesReadyPlay()) return;
const h = mapData.height || 15;
const ts = mapData.tileSize || 32;
const mapHpx = Math.max(1, Math.round(h * ts));
const { w0 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
if (!(w0 > 0)) return;
const slots = collectGauntletSpawnSlotsPlay(mapData);
let minTX = Infinity;
if (slots.length) minTX = Math.min(...slots.map((s) => s.x));
if (Number.isFinite(me.x)) minTX = Math.min(minTX, Math.floor(me.x));
others.forEach((o, id) => {
if (!o || o.gauntletEliminated) return;
if (Number.isFinite(o.x)) minTX = Math.min(minTX, Math.floor(o.x));
});
if (!Number.isFinite(minTX) || minTX === Infinity) minTX = 1;
const anchorWorldX = (Math.max(0, minTX) + 0.5) * ts;
const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
const frac = Math.max(0.02, Math.min(0.95, Number(raw.runwaySpawnAlignFrac) || 0.32));
gauntletCrownRunwayBgScrollPx = frac * w0 - anchorWorldX;
gauntletCrownRunwaySpawnScrollSnapped = true;
}
function reloadGauntletCrownRunwayBgFromMap() {
gauntletCrownRunwayBgImgs = [null, null, null, null, null];
gauntletCrownRunwayBgScrollPx = 0;
gauntletCrownRunwayBgFinishLatched = false;
gauntletCrownRunwayBgInitialTotalPeers = null;
lastGauntletCrownRunwayBgTickMs = 0;
resetGauntletCrownRunwaySpawnScrollSnap();
if (!gauntletCrownRunwayBgMapEligiblePlay()) return;
const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
gauntletCrownRunwayBgOff = raw.enabled === false;
gauntletCrownRunwayBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48));
if (gauntletCrownRunwayBgOff) return;
const srcs = [
(typeof raw.startImage === 'string' && raw.startImage.length) ? raw.startImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START,
(typeof raw.loopImage2 === 'string' && raw.loopImage2.length) ? raw.loopImage2 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2,
(typeof raw.loopImage3 === 'string' && raw.loopImage3.length) ? raw.loopImage3 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3,
(typeof raw.loopImage4 === 'string' && raw.loopImage4.length) ? raw.loopImage4 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4,
(typeof raw.finishImage === 'string' && raw.finishImage.length) ? raw.finishImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH,
];
let pending = 5;
const bump = () => {
pending--;
if (pending <= 0) {
ensureGauntletCrownRunwayBgPlaceholdersPlay();
try { draw(); } catch (e) { /* ignore */ }
}
};
for (let i = 0; i < 5; i++) {
const im = new Image();
const idx = i;
im.onload = () => {
gauntletCrownRunwayBgImgs[idx] = im;
bump();
};
im.onerror = () => { bump(); };
im.src = srcs[i];
}
}
function tickGauntletCrownRunwayBgPlay(dtSec) {
if (!gauntletCrownRunwayBgDrawActivePlay()) return;
/** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */
if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return;
if (gauntletCrownRunwayBgFinishLatched) return;
if (gauntletCrownRunwayShouldFinishPlay()) {
gauntletCrownRunwayBgFinishLatched = true;
return;
}
gauntletCrownRunwayBgScrollPx += (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec;
}
/** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */
const STACK_TOWER_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png';
const STACK_TOWER_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png';
@@ -582,7 +950,7 @@
/** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */
let mapGridImageImgs = [];
let mapGridImageHeldImgs = [];
let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null };
let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null, gauntletCrownPenaltyFxUntil: 0 };
const others = new Map();
/** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
const STACK_PREVIEW_HUD_LOG_MAX = 8;
@@ -3394,6 +3762,12 @@
const im = grabBtnTeardown.querySelector('img');
if (im) im.src = '/Game/img/quiz-carry/btn-grab.png';
}
const gchJumpTeardown = document.getElementById('gauntlet-crown-jump-btn');
if (gchJumpTeardown) {
gchJumpTeardown.classList.add('is-hidden');
gchJumpTeardown.setAttribute('aria-hidden', 'true');
gchJumpTeardown.style.pointerEvents = '';
}
}
function setupPlayQuizUi() {
@@ -3516,6 +3890,69 @@
if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true;
return false;
}
/**
* Last Light (crown heist): center camera on the whole party bbox instead of only `me`
* reduces empty void on the side when you fall behind and keeps bots + you in frame together when possible.
*/
function getGauntletCrownHeistGroupCameraCenterPxPlay(tileSizeRef, canvasW, canvasH, zDrawVal) {
if (!isGauntletCrownHeistMapPlay() || !mapData) return null;
const md = mapData;
const { cw, ch } = getCharacterFootprintWH(md);
const ww = md.width || 20;
const hh = md.height || 15;
const mapWpx = ww * tileSizeRef;
const mapHpx = hh * tileSizeRef;
const halfW = canvasW / (2 * zDrawVal);
const halfH = canvasH / (2 * zDrawVal);
const ents = [];
if (!me.gauntletEliminated && Number.isFinite(me.x) && Number.isFinite(me.y)) {
ents.push({ cx: (me.x + cw * 0.5) * tileSizeRef, cy: (me.y + ch * 0.5) * tileSizeRef });
}
others.forEach((o, id) => {
if (!o || o.gauntletEliminated) return;
if (!previewFillBots && isPreviewBotId(id)) return;
if (!Number.isFinite(o.x) || !Number.isFinite(o.y)) return;
ents.push({ cx: (o.x + cw * 0.5) * tileSizeRef, cy: (o.y + ch * 0.5) * tileSizeRef });
});
if (!ents.length) return null;
let minCx = Infinity;
let maxCx = -Infinity;
let sumCy = 0;
for (let i = 0; i < ents.length; i++) {
const e = ents[i];
minCx = Math.min(minCx, e.cx);
maxCx = Math.max(maxCx, e.cx);
sumCy += e.cy;
}
let px = (minCx + maxCx) * 0.5;
let py = sumCy / ents.length;
const minCamX = halfW;
const maxCamX = Math.max(minCamX, mapWpx - halfW);
px = Math.max(minCamX, Math.min(maxCamX, px));
const minCamY = halfH;
const maxCamY = Math.max(minCamY, mapHpx - halfH);
py = Math.max(minCamY, Math.min(maxCamY, py));
return { px, py };
}
/** Preview bots only — full tail-off loss needs server support for real humans */
const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40;
function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) {
if (!previewFillBots || !isGauntletCrownHeistMapPlay() || !mapData) return;
if (gauntletCrownPregamePhase !== 'live') return;
const halfW = canvas.width / (2 * zDrawVal);
const worldMinX = camX - halfW;
const gate = worldMinX - GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX;
const { cw } = getCharacterFootprintWH(mapData);
const ts = mapData.tileSize || 32;
others.forEach((o, id) => {
if (!isPreviewBotId(id) || !o || o.gauntletEliminated) return;
const rightWorld = (Number(o.x) + cw) * ts;
if (rightWorld < gate) o.gauntletEliminated = true;
});
}
function isStack() { return mapData && mapData.gameType === 'stack'; }
function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; }
@@ -6615,6 +7052,33 @@
}
}
/** Gauntlet (รวมพรมแดง): กระโดด — ใช้ร่วมกับ Space/W/↑ และปุ่ม UI */
function tryRequestGauntletJumpPlay() {
if (!mapData || !isGauntlet() || isChatFocused()) return false;
if (isGauntletCrownPregameBlockingPlay()) return false;
if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return false;
const now = Date.now();
if (now - lastGauntletJumpKey < 200) return false;
lastGauntletJumpKey = now;
socket.emit('gauntlet-jump');
return true;
}
function syncGauntletCrownJumpButton() {
const btn = document.getElementById('gauntlet-crown-jump-btn');
if (!btn) return;
const show = !!(mapData && isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated);
if (!show) {
btn.classList.add('is-hidden');
btn.setAttribute('aria-hidden', 'true');
btn.style.pointerEvents = 'none';
return;
}
btn.classList.remove('is-hidden');
btn.setAttribute('aria-hidden', 'false');
btn.style.pointerEvents = '';
}
function resetQuizCarryPlayState() {
quizCarryPregameActive = false;
hideQuizCarryPregameOverlay();
@@ -7720,6 +8184,7 @@
gauntletCrownRunHeldRemote = !!payload.gauntletCrownRunHeld;
if (!gauntletCrownRunHeldRemote && usesCrownLobbyShellPlay()) {
gauntletCrownPregamePhase = 'live';
trySnapGauntletCrownRunwayScrollToSpawnsPlay();
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) {
@@ -8110,12 +8575,20 @@
o.gauntletScore = 0;
} else {
o.gauntletScore = Math.max(0, (o.gauntletScore || 0) - 10);
o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
px = Math.max(0, px - 1);
}
} else {
px = Math.max(0, px - 1);
}
}
if (crown && mapData) {
const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
if (mx != null && Number.isFinite(mx)) {
const maxPx = Math.floor(mx + 1e-6);
px = Math.min(px, maxPx);
}
}
if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--;
o.tx = px;
o.ty = py;
@@ -8251,6 +8724,10 @@
gauntletCrownCountdownTimer = null;
}
gauntletCrownPregamePhase = 'countdown';
if (previewFillBots && mapData && mapData.gameType === 'gauntlet') {
applyGauntletPreviewSpawnLayout(false);
}
trySnapGauntletCrownRunwayScrollToSpawnsPlay();
const howto = document.getElementById('gauntlet-crown-howto-overlay');
if (howto) howto.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
@@ -8284,11 +8761,13 @@
socket.emit('gauntlet-crown-begin-run', (res) => {
if (res && res.ok) {
gauntletCrownPregamePhase = 'live';
trySnapGauntletCrownRunwayScrollToSpawnsPlay();
if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
}
});
} else {
gauntletCrownPregamePhase = 'live';
trySnapGauntletCrownRunwayScrollToSpawnsPlay();
if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
}
};
@@ -8323,6 +8802,7 @@
gauntletCrownCountdownTimer = null;
}
gauntletCrownPregamePhase = 'howto';
resetGauntletCrownRunwaySpawnScrollSnap();
const ov = document.getElementById('gauntlet-crown-howto-overlay');
if (!ov) return;
gauntletCrownHowtoVisible = true;
@@ -11308,6 +11788,7 @@
}
reloadPlayScrollBgFromMap();
reloadStackTowerScrollBgFromMap();
reloadGauntletCrownRunwayBgFromMap();
normalizeGridImageCellsOnMap(mapData);
loadMapGridImages();
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
@@ -11970,6 +12451,7 @@
const pid = String(p.id);
const mid = String(myId);
if (pid === mid) {
const prevSc = Math.max(0, Number(me.gauntletScore) || 0);
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) me.tx = px;
@@ -11979,11 +12461,17 @@
meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
meGauntletJumpVis = meGauntletJumpTicks;
const sc = Number(p.gauntletScore);
me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
me.gauntletScore = newSc;
if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
me.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
}
me.gauntletEliminated = !!p.gauntletEliminated;
clampGauntletCrownHeistEntityXInPlacePlay(me);
} else {
const o = others.get(p.id) ?? others.get(pid);
if (o) {
const prevSc = Math.max(0, Number(o.gauntletScore) || 0);
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) o.tx = px;
@@ -11993,8 +12481,13 @@
o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
const sc = Number(p.gauntletScore);
o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
o.gauntletScore = newSc;
if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
}
o.gauntletEliminated = !!p.gauntletEliminated;
clampGauntletCrownHeistEntityXInPlacePlay(o);
}
}
});
@@ -12007,9 +12500,12 @@
socket.on('gauntlet-ended', (data) => {
cancelQuizCarryResultEndAfterTimeup();
gauntletEndsAtMs = null;
gauntletCrownRunwayBgFinishLatched = false;
gauntletCrownRunwayBgInitialTotalPeers = null;
gauntletCrownRunHeldRemote = false;
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
resetGauntletCrownRunwaySpawnScrollSnap();
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
@@ -12096,6 +12592,7 @@
}
reloadPlayScrollBgFromMap();
reloadStackTowerScrollBgFromMap();
reloadGauntletCrownRunwayBgFromMap();
normalizeGridImageCellsOnMap(mapData);
loadMapGridImages();
if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) {
@@ -12436,12 +12933,6 @@
syncQuizCarryEmbedCountdownLayout();
}
// ให้ scroll wheel ใช้ซูมเข้า/ออกแบบง่าย ๆ
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.1 : 0.9;
zoom = Math.max(0.7, Math.min(2.5, zoom * factor));
}, { passive: false });
canvas.addEventListener('click', (e) => {
if (!mapData || !isStack() || isChatFocused()) return;
tryHumanStackDrop();
@@ -12492,12 +12983,30 @@
return { cw, ch, colW, colH };
}
function getGauntletCrownHeistMaxEntityXPlay(md) {
if (!md || !isGauntletCrownHeistMapPlay()) return null;
const w = md.width || 20;
const { cw } = getCharacterFootprintWH(md);
const capRight = GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC * w - cw;
const fullMapMax = Math.max(0, w - cw + 0.999);
return Math.max(0, Math.min(fullMapMax, capRight));
}
function clampGauntletCrownHeistEntityXInPlacePlay(ent) {
if (!ent || !mapData || !isGauntletCrownHeistMapPlay()) return;
const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
if (mx == null || !Number.isFinite(mx)) return;
if (Number.isFinite(ent.x)) ent.x = Math.max(0, Math.min(mx, ent.x));
if (ent.tx != null && Number.isFinite(ent.tx)) ent.tx = Math.max(0, Math.min(mx, ent.tx));
}
/** กันตัวละคร footprint ล้นออกนอกแมป — เดิมใช้ w-0.01 ทำให้ cw/ch>1 ชนขอบแล้วตรรกะเดินค้าง */
function clampPlayEntityFootprintToMap(oEnt, md) {
if (!oEnt || !md) return;
const w = md.width || 20, h = md.height || 15;
const { cw, ch } = getCharacterFootprintWH(md);
const maxX = Math.max(0, w - cw + 0.999);
let maxX = Math.max(0, w - cw + 0.999);
const crownMx = getGauntletCrownHeistMaxEntityXPlay(md);
if (crownMx != null) maxX = Math.min(maxX, crownMx);
const maxY = Math.max(0, h - ch + 0.999);
if (!Number.isFinite(oEnt.x)) oEnt.x = 0.5;
if (!Number.isFinite(oEnt.y)) oEnt.y = 0.5;
@@ -13954,6 +14463,8 @@
function draw() {
if (!mapData) return;
if (isGauntletCrownHeistMapPlay()) ensureGauntletCrownScorePenaltyImgPlay();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const w = mapData.width, h = mapData.height;
const stackCam = isStack() ? getStackCameraCentersPx() : null;
@@ -13970,6 +14481,11 @@
camY = mhPx * 0.5;
zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
}
const gauntletGroupCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zDraw);
if (gauntletGroupCam) {
camX = gauntletGroupCam.px;
camY = gauntletGroupCam.py;
}
const halfW = canvas.width / (2 * zDraw);
const halfH = canvas.height / (2 * zDraw);
@@ -13988,6 +14504,18 @@
ctx.fillStyle = '#0a0e22';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawPlayScrollBgFullCanvas(canvas.width, canvas.height);
} else if (gauntletCrownRunwayBgDrawActivePlay()) {
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGauntletCrownRunwayBgFullCanvasPlay(canvas.width, canvas.height, {
worldMinX,
worldMaxX,
camX,
camY,
zDraw: zDraw,
mapWpx,
mapHpx,
});
} else if (stackTowerScrollBgDrawActive()) {
ctx.fillStyle = '#0a0e22';
ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -14343,7 +14871,9 @@
drawStackMinigame(ctx, worldToScreen, zDraw);
}
const gauntletObsDraw = isGauntlet() ? getGauntletObsDrawPositionsAt(performance.now()) : [];
const gauntletObsDraw = (isGauntlet() && !gauntletCrownRunwayBgHideObstaclesPlay())
? getGauntletObsDrawPositionsAt(performance.now())
: [];
if (isGauntlet() && gauntletObsDraw.length) {
const stx = Math.max(0, Math.floor(worldMinX / tileSize));
const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
@@ -14453,7 +14983,7 @@
ctx.restore();
}
function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload) {
function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload, crownPenaltyFxUntil) {
const { cw, ch } = getCharacterFootprintWH(mapData);
const air = Number(gauntletAirTicks) || 0;
let liftWorldY = 0;
@@ -14509,6 +15039,18 @@
? (nameBase + ' · ' + gauntletScoreLabel)
: nameBase;
ctx.fillText(withScore, sx, sy + 10);
if (isGauntletCrownHeistMapPlay()
&& typeof crownPenaltyFxUntil === 'number'
&& crownPenaltyFxUntil > timeMs) {
const penImg = ensureGauntletCrownScorePenaltyImgPlay();
if (penImg && penImg.complete && penImg.naturalWidth > 0) {
const ref = Math.max(18, r * 2);
const scale = Math.min(1.15, ref / Math.max(penImg.naturalWidth, penImg.naturalHeight, 1));
const dw = penImg.naturalWidth * scale;
const dh = penImg.naturalHeight * scale;
ctx.drawImage(penImg, 0, 0, penImg.naturalWidth, penImg.naturalHeight, sx - dw * 0.5, spriteTopY - dh - 5, dw, dh);
}
}
if (isQuizCarry() && quizCarrySignPayload) {
drawQuizCarryHeldSignBoard(
sx, sy, halfSpriteW, spriteTopY,
@@ -14548,7 +15090,12 @@
me.playTint = mt;
}
const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)'));
const meWalking = isFrogger() ? false : (isGauntlet() ? (meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08) : !!me.isWalking);
const crownRunStripLive = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live';
const meWalking = isFrogger()
? false
: (isGauntlet()
? ((crownRunStripLive && !me.gauntletEliminated) || meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08)
: !!me.isWalking);
const drawRows = [];
others.forEach((o, id) => {
if (!o) return;
@@ -14572,7 +15119,7 @@
const o = row.o;
const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id);
const otherWalk = isGauntlet()
? ((o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08)
? ((crownRunStripLive && !o.gauntletEliminated) || (o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08)
: (isPreviewBotId(id)
? !!o.botIsWalking
: !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02)));
@@ -14582,13 +15129,13 @@
const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
if (botOut) ctx.save();
if (botOut) ctx.globalAlpha = 0.4;
drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o));
drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o), isGauntletCrownHeistMapPlay() ? (o.gauntletCrownPenaltyFxUntil || 0) : 0);
if (botOut) ctx.restore();
} else {
if (isJumpSurvive() && jumpSurviveEliminated) ctx.save();
if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction;
drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me));
drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me), isGauntletCrownHeistMapPlay() ? (me.gauntletCrownPenaltyFxUntil || 0) : 0);
if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore();
}
});
@@ -14694,6 +15241,7 @@
drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs);
syncPlayCyberHud();
syncQuizCarryGrabButton();
syncGauntletCrownJumpButton();
}
document.addEventListener('keydown', (e) => {
@@ -14741,12 +15289,7 @@
if (isGauntlet() && mapData && !isChatFocused()) {
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
e.preventDefault();
if (isGauntletCrownPregameBlockingPlay()) return;
if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return;
const now = Date.now();
if (now - lastGauntletJumpKey < 200) return;
lastGauntletJumpKey = now;
socket.emit('gauntlet-jump');
tryRequestGauntletJumpPlay();
return;
}
if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
@@ -14880,13 +15423,19 @@
return;
}
if (isGauntlet()) {
{
const nowBg = performance.now();
const dtBg = Math.min(0.06, lastGauntletCrownRunwayBgTickMs ? (nowBg - lastGauntletCrownRunwayBgTickMs) / 1000 : 0.016);
lastGauntletCrownRunwayBgTickMs = nowBg;
tickGauntletCrownRunwayBgPlay(dtBg);
}
if (isGauntletCrownPregameBlockingPlay()) {
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
me.isWalking = meGauntletJumpTicks > 0;
me.isWalking = (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated) || meGauntletJumpTicks > 0;
const stepGauntletJumpVis = (vis, tgt) => {
const t = Number(tgt) || 0;
let v = Number(vis) || 0;
@@ -14905,6 +15454,13 @@
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0;
o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0);
});
clampPlayEntityFootprintToMap(me, mapData);
others.forEach((o) => {
if (o) clampPlayEntityFootprintToMap(o, mapData);
});
const zGa = zoom;
const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGa);
if (gCam) maybeEliminateGauntletPreviewBotsTailOffCameraPlay(gCam.px, gCam.py, zGa);
draw();
requestAnimationFrame(tick);
return;
@@ -15066,6 +15622,16 @@
});
})();
(function wireGauntletCrownJumpButton() {
const btn = document.getElementById('gauntlet-crown-jump-btn');
if (!btn) return;
btn.addEventListener('click', (ev) => {
ev.preventDefault();
if (!mapData || !isGauntlet() || isChatFocused()) return;
tryRequestGauntletJumpPlay();
});
})();
(function wireGauntletCrownHowtoPrimary() {
const btn = document.getElementById('btn-gch-ready');
if (!btn) return;
+1 -1
View File
@@ -1,6 +1,6 @@
// ทุกครั้งที่มีการเพิ่มหรือเปลี่ยน ให้เพิ่ม v +0.0001
// หลังแก้ค่าแล้วต้อง copy ไป path ที่ Nginx ชี้ (หรือรัน copy-frogger-files-only.sh) ถึงจะเห็นบนเว็บ
window.APP_VERSION = '0.0233';
window.APP_VERSION = '0.0249';
document.addEventListener('DOMContentLoaded', function () {
var t = document.querySelector('.version-tag');
if (t) t.textContent = 'v ' + window.APP_VERSION;
+40 -2
View File
@@ -153,6 +153,41 @@
pointer-events: none;
user-select: none;
}
/* Last Light (mno9kb07) — ปุ่มกระโดดมุมขวาล่าง · เทียบ Space / W / ↑ */
#gauntlet-crown-jump-btn {
position: absolute;
right: max(10px, env(safe-area-inset-right, 0px));
bottom: max(10px, env(safe-area-inset-bottom, 0px));
z-index: 59;
width: min(14vh, 16vw, 200px);
height: min(14vh, 16vw, 200px);
padding: 0;
margin: 0;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
opacity: 1;
transition: transform 0.15s ease, filter 0.15s ease;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
box-shadow: none;
filter: drop-shadow(0 0 12px rgba(94, 234, 255, 0.35));
}
#gauntlet-crown-jump-btn.is-hidden {
display: none !important;
}
#gauntlet-crown-jump-btn:active {
transform: scale(0.96);
}
#gauntlet-crown-jump-btn img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
user-select: none;
}
#quiz-game-overlay {
position: fixed;
left: 50%;
@@ -2472,6 +2507,9 @@
<button type="button" id="quiz-carry-grab-btn" class="is-hidden" aria-label="หยิบหรือส่งคำตอบ (Grab)" title="หยิบ / ส่งคำตอบ — เหมือนกด F">
<img src="/Game/img/quiz-carry/btn-grab.png" alt="" width="256" height="256" decoding="async" />
</button>
<button type="button" id="gauntlet-crown-jump-btn" class="is-hidden" aria-label="กระโดด (Jump)" title="กระโดด — เหมือน Space / W / ↑" aria-hidden="true">
<img src="/Game/img/gauntlet-assets/btn-jump.png" alt="" width="256" height="256" decoding="async" />
</button>
<div id="quiz-map-question-panel" class="is-hidden" aria-hidden="true">
<img id="quiz-map-q-feedback-icon" class="quiz-map-q-feedback-icon is-hidden" alt="" width="64" height="64" decoding="async" />
<div id="quiz-map-question-kicker" class="quiz-map-question-kicker is-hidden" aria-hidden="true"></div>
@@ -2675,8 +2713,8 @@
</div>
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0233"></script>
<script src="js/play.js?v=0.0233"></script>
<script src="js/version.js?v=0.0249"></script>
<script src="js/play.js?v=0.0249"></script>
<div class="version-tag">v —</div>
</body>
</html>