main web4.1

This commit is contained in:
2026-05-07 15:11:57 +00:00
parent 8b09c6d11f
commit 0f600e3b9b
7 changed files with 161 additions and 409 deletions
File diff suppressed because one or more lines are too long
+5
View File
@@ -1942,3 +1942,8 @@ body.room-lobby--quiz-active .lobby-b-extra-row {
.quiz-q-remove {
font-size: 0.8rem;
}
/* Stack Tower play — กัน #game-canvas ถูกกฎอื่นเป็น contain แล้วแถบดำรอบแคนวาส */
html.play-stack-tower-pixel-canvas #play-canvas-stage #game-canvas {
object-fit: cover !important;
}
+1 -29
View File
@@ -270,34 +270,6 @@
<label>รูปแผนที่ <input type="file" id="bg-image" accept="image/*"></label>
<button type="button" id="btn-clear-bg">ลบรูปพื้นหลัง</button>
</div>
<div id="editor-stack-tower-cam-block" class="editor-bg-scroll-block" hidden>
<p class="legend" style="margin:0.5rem 0 0.35rem;line-height:1.45"><strong>กล้องตามความสูงหอ</strong> (เฉพาะฉาก <code>mnn93hpi</code>) — เลื่อนมุมมอง<strong>โลก</strong>ขึ้นเมื่อสแต็กสูง บล็อก+พื้นหลังแผนที่ไป<strong>ด้วยกัน</strong> · แถบ intro/loop ด้านล่างยังเลื่อนอัตโนมัติแยกต่างหาก · <em>English:</em> World camera pans up with stack height (blocks move with scene); saved as <code>stackTowerCameraFollow</code>.</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-stack-tower-cam-enabled" checked> เปิดตามชั้น / Follow stack (camera)</label>
<label title="พิกเซลโลกต่อ 1 ชั้น">px ต่อชั้น
<input type="number" id="editor-stack-tower-cam-px-layer" min="0" max="80" step="1" value="12" style="width:4.2rem">
</label>
<label title="จำกัดการเลื่อนกล้องสูงสุด (พิกเซลโลก)">เลื่อนสูงสุด (px)
<input type="number" id="editor-stack-tower-cam-max-px" min="0" max="800" step="4" value="260" style="width:4.5rem">
</label>
</div>
<p class="legend" style="margin:0.65rem 0 0.35rem;line-height:1.45"><strong>พื้นหลัง intro + loop</strong> (เลื่อนแนวตั้ง เหมือนฉากยิงยาน) — รูปแรกแล้วต่อลูป · <em>English:</em> Vertical strip: intro then tiling loop; saved as <code>stackTowerBgScroll</code> in map JSON.</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-stack-tower-bg-enabled" checked> เปิดเลื่อนพื้นหลัง / Enable BG scroll</label>
<label>ความเร็ว px/s <input type="number" id="editor-stack-tower-bg-speed" min="8" max="400" step="4" value="40" style="width:5rem"></label>
<label>ทิศทาง
<select id="editor-stack-tower-bg-direction">
<option value="up" selected>ล่าง → บน</option>
<option value="down">บน → ล่าง</option>
</select>
</label>
</div>
<div class="editor-card__row editor-card__row--wrap" style="margin-top:0.35rem">
<label>รูปต้นทาง (intro) <input type="file" id="editor-stack-tower-bg-intro-file" accept="image/*"></label>
<label>รูปลูป (loop) <input type="file" id="editor-stack-tower-bg-loop-file" accept="image/*"></label>
<button type="button" id="editor-stack-tower-bg-reset-assets">คืนรูปเริ่มจากเซิร์ฟ / Reset to bundled</button>
</div>
</div>
<div id="editor-bg-scroll-block" class="editor-bg-scroll-block" hidden>
<p class="legend" style="margin:0.5rem 0 0.35rem;line-height:1.45"><strong>เลื่อนพื้นหลังแนวตั้ง</strong> (เฉพาะฉากรหัส <code>mnpz6rkp</code>) — รูป intro ต่อด้วย loop · บันทึกฉากแล้วหน้าเล่นใช้ค่าเดียวกัน · <em>English:</em> Intro + looping strip; saved in map JSON.</p>
<div class="editor-card__row editor-card__row--wrap" style="align-items:center;gap:0.75rem 1rem">
@@ -400,7 +372,7 @@
</div>
</div>
<script src="js/version.js?v=0.0258"></script>
<script src="js/editor.js?v=0.0321"></script>
<script src="js/editor.js?v=0.0331"></script>
<div class="version-tag">v —</div>
</body>
</html>
+11 -311
View File
@@ -248,7 +248,7 @@
if (dir) dir.value = editorBgScrollConfig.scrollDirection === 'down' ? 'down' : 'up';
}
/** กล้องตามความสูงหอ — เฉพาะแมป Tower mission mnn93hpi (Editor + map JSON) */
/** แมป Stack Tower ภารกิจ (mnn93hpi) — ใช้จับ HUD embed; ไม่มี UI เลื่อนกล้อง/แถบ BG ในเอดิเตอร์แล้ว · บันทึกฉากบังคับปิด scroll/cam ใน JSON */
const EDITOR_STACK_TOWER_CAM_MAP_ID = 'mnn93hpi';
function isEditorStackTowerMissionMap() {
@@ -257,244 +257,26 @@
}
function syncStackTowerCamBlockVisibilityOnly() {
const block = document.getElementById('editor-stack-tower-cam-block');
if (!block) return;
const show = isEditorStackTowerMissionMap();
if (!show) stopStackTowerBgScrollRaf();
block.hidden = !show;
/* บล็อก UI ถูกลบแล้ว */
}
const EDITOR_STACK_TOWER_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png';
const EDITOR_STACK_TOWER_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png';
let stackTowerBgScrollConfig = {
enabled: true,
speedPxPerSec: 40,
scrollDirection: 'up',
introImage: null,
loopImage: null,
};
let stackTowerBgScrollIntroImg = null;
let stackTowerBgScrollLoopImg = null;
let stackTowerBgScrollPx = 0;
let stackTowerBgScrollRaf = null;
let stackTowerBgScrollLastTs = 0;
function stopStackTowerBgScrollRaf() {
if (stackTowerBgScrollRaf != null) {
cancelAnimationFrame(stackTowerBgScrollRaf);
stackTowerBgScrollRaf = null;
}
stackTowerBgScrollLastTs = 0;
/* no-op — เก็บชื่อให้จุดเรียกเดิมไม่พัง */
}
function isEditorStackTowerBgDrawing() {
if (!isEditorStackTowerMissionMap() || !stackTowerBgScrollConfig.enabled) return false;
const a = stackTowerBgScrollIntroImg;
const b = stackTowerBgScrollLoopImg;
return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
}
function loadStackTowerBgScrollImagePair(introSrc, loopSrc, onDone) {
function applyStackTowerCameraFollowFromMap(_m) {
stopStackTowerBgScrollRaf();
stackTowerBgScrollIntroImg = null;
stackTowerBgScrollLoopImg = null;
let pending = 2;
const doneOne = () => {
pending--;
if (pending <= 0 && typeof onDone === 'function') onDone();
};
const im1 = new Image();
im1.onload = () => {
stackTowerBgScrollIntroImg = im1;
doneOne();
};
im1.onerror = () => { doneOne(); };
im1.src = introSrc;
const im2 = new Image();
im2.onload = () => {
stackTowerBgScrollLoopImg = im2;
doneOne();
};
im2.onerror = () => { doneOne(); };
im2.src = loopSrc;
}
function syncStackTowerBgScrollUiFromConfig() {
const en = document.getElementById('editor-stack-tower-bg-enabled');
const sp = document.getElementById('editor-stack-tower-bg-speed');
const dir = document.getElementById('editor-stack-tower-bg-direction');
if (en) en.checked = !!stackTowerBgScrollConfig.enabled;
if (sp) sp.value = String(stackTowerBgScrollConfig.speedPxPerSec || 40);
if (dir) dir.value = stackTowerBgScrollConfig.scrollDirection === 'down' ? 'down' : 'up';
}
function syncStackTowerBgScrollInitialToBottom() {
const intro = stackTowerBgScrollIntroImg;
if (!intro || !intro.complete || !intro.naturalWidth) return;
const cwR = Math.max(1, Math.round(canvas.width));
const chR = Math.max(1, Math.round(canvas.height));
const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
if (stackTowerBgScrollConfig.scrollDirection === 'down') {
stackTowerBgScrollPx = 0;
} else {
stackTowerBgScrollPx = Math.max(0, drawHIntro - chR);
}
}
function drawStackTowerScrollingBackground(cw, ch) {
const intro = stackTowerBgScrollIntroImg;
const loop = stackTowerBgScrollLoopImg;
if (!intro || !intro.complete || !loop || !loop.complete) return;
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const scaleI = cwR / intro.naturalWidth;
const drawHIntro = Math.round(intro.naturalHeight * scaleI);
const scaleL = cwR / loop.naturalWidth;
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
const S = stackTowerBgScrollPx;
const vp0 = S;
const vp1 = S + chR;
const yLimit = vp1 + drawHLoop * 2;
const flowDown = stackTowerBgScrollConfig.scrollDirection === 'down';
function stripTopToDestY(stripTop, drawH) {
if (!flowDown) return stripTop - S;
return S + chR - (stripTop + drawH);
}
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.beginPath();
ctx.rect(0, 0, cwR, chR);
ctx.clip();
if (vp0 < 0) {
let k = 1;
if (vp0 < -drawHLoop * 4) {
k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
}
for (; k < 50000; k++) {
const y0 = -k * drawHLoop;
if (y0 >= vp1 + drawHLoop) break;
if (y0 + drawHLoop <= vp0) continue;
const dy = Math.round(stripTopToDestY(y0, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
}
}
if (drawHIntro > vp0 && vp1 > 0) {
const dy = Math.round(stripTopToDestY(0, drawHIntro));
ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
}
let y = drawHIntro;
if (y < yLimit && vp1 > drawHIntro) {
if (vp0 > drawHIntro) {
const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
y = drawHIntro + Math.max(0, n - 1) * drawHLoop;
}
while (y < yLimit) {
const dy = Math.round(stripTopToDestY(y, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
y += drawHLoop;
}
}
ctx.restore();
}
function stackTowerBgScrollTick(ts) {
stackTowerBgScrollRaf = null;
if (!isEditorStackTowerBgDrawing()) return;
if (!stackTowerBgScrollLastTs) stackTowerBgScrollLastTs = ts;
const dt = Math.min(64, Math.max(0, ts - stackTowerBgScrollLastTs));
stackTowerBgScrollLastTs = ts;
stackTowerBgScrollPx += (stackTowerBgScrollConfig.speedPxPerSec || 40) * (dt / 1000);
draw();
stackTowerBgScrollRaf = requestAnimationFrame(stackTowerBgScrollTick);
}
function startStackTowerBgScrollRaf() {
if (!isEditorStackTowerBgDrawing()) return;
stopStackTowerBgScrollRaf();
stackTowerBgScrollLastTs = 0;
stackTowerBgScrollRaf = requestAnimationFrame(stackTowerBgScrollTick);
}
function applyStackTowerBgScrollFromMap(m) {
stopStackTowerBgScrollRaf();
stackTowerBgScrollIntroImg = null;
stackTowerBgScrollLoopImg = null;
stackTowerBgScrollPx = 0;
if (!isEditorStackTowerMissionMap()) {
stackTowerBgScrollConfig = {
enabled: true,
speedPxPerSec: 40,
scrollDirection: 'up',
introImage: null,
loopImage: null,
};
return;
}
const raw = m && m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
const sp = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 40));
const sd = String(raw.scrollDirection || raw.direction || 'up').toLowerCase();
stackTowerBgScrollConfig = {
enabled: raw.enabled !== false,
speedPxPerSec: sp,
scrollDirection: (sd === 'down' || sd === 'top' || sd === 'toptobottom') ? 'down' : 'up',
introImage: typeof raw.introImage === 'string' && raw.introImage.length ? raw.introImage : null,
loopImage: typeof raw.loopImage === 'string' && raw.loopImage.length ? raw.loopImage : null,
};
const introSrc = stackTowerBgScrollConfig.introImage || EDITOR_STACK_TOWER_BG_DEFAULT_INTRO;
const loopSrc = stackTowerBgScrollConfig.loopImage || EDITOR_STACK_TOWER_BG_DEFAULT_LOOP;
loadStackTowerBgScrollImagePair(introSrc, loopSrc, () => {
syncStackTowerBgScrollUiFromConfig();
syncStackTowerBgScrollInitialToBottom();
draw();
if (stackTowerBgScrollConfig.enabled) startStackTowerBgScrollRaf();
});
}
function readStackTowerBgScrollForSave() {
if (!isEditorStackTowerMissionMap()) return undefined;
const en = document.getElementById('editor-stack-tower-bg-enabled');
const spEl = document.getElementById('editor-stack-tower-bg-speed');
const sp = Math.max(8, Math.min(400, Math.floor(Number(spEl && spEl.value)) || 40));
const dirEl = document.getElementById('editor-stack-tower-bg-direction');
const out = {
enabled: !!(en && en.checked),
speedPxPerSec: sp,
scrollDirection: dirEl && dirEl.value === 'down' ? 'down' : 'up',
};
if (stackTowerBgScrollConfig.introImage) out.introImage = stackTowerBgScrollConfig.introImage;
if (stackTowerBgScrollConfig.loopImage) out.loopImage = stackTowerBgScrollConfig.loopImage;
return out;
}
function applyStackTowerCameraFollowFromMap(m) {
const block = document.getElementById('editor-stack-tower-cam-block');
if (block) block.hidden = !isEditorStackTowerMissionMap();
applyStackTowerBgScrollFromMap(m);
if (!isEditorStackTowerMissionMap()) return;
const raw = m && m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
const en = document.getElementById('editor-stack-tower-cam-enabled');
const pl = document.getElementById('editor-stack-tower-cam-px-layer');
const mx = document.getElementById('editor-stack-tower-cam-max-px');
if (en) en.checked = raw.enabled !== false;
const ppl = Math.max(0, Math.min(80, Math.floor(Number(raw.pxPerLayer)) || 12));
const mpx = Math.max(0, Math.min(800, Math.floor(Number(raw.maxPx)) || 260));
if (pl) pl.value = String(ppl);
if (mx) mx.value = String(mpx);
}
function readStackTowerCameraFollowForSave() {
if (!isEditorStackTowerMissionMap()) return undefined;
const en = document.getElementById('editor-stack-tower-cam-enabled');
const pl = document.getElementById('editor-stack-tower-cam-px-layer');
const mx = document.getElementById('editor-stack-tower-cam-max-px');
const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(pl && pl.value)) || 12));
const maxPx = Math.max(0, Math.min(800, Math.floor(Number(mx && mx.value)) || 260));
return { enabled: !!(en && en.checked), pxPerLayer, maxPx };
return { enabled: false, pxPerLayer: 12, maxPx: 260 };
}
function readStackTowerBgScrollForSave() {
if (!isEditorStackTowerMissionMap()) return undefined;
return { enabled: false, speedPxPerSec: 40, scrollDirection: 'down' };
}
/** พื้นหลังแนวนอน start + วน 2–4 + finish — เฉพาะแมป Last Light mno9kb07 (Editor + map JSON: gauntletCrownRunwayBg) */
@@ -2214,8 +1996,6 @@
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) {
ctx.drawImage(backgroundImageImg, 0, 0, canvas.width, canvas.height);
} else {
@@ -3298,87 +3078,7 @@
if (isEditorScrollBgMap()) applyEditorBgScrollFromMap({});
if (isEditorGauntletRunwayBgMap()) applyGauntletCrownRunwayBgFromMap({});
if (isEditorStackTowerMissionMap()) applyStackTowerCameraFollowFromMap({});
else {
stopStackTowerBgScrollRaf();
const stBlock0 = document.getElementById('editor-stack-tower-cam-block');
if (stBlock0) stBlock0.hidden = true;
}
(function wireStackTowerBgScrollUi() {
const en = document.getElementById('editor-stack-tower-bg-enabled');
const sp = document.getElementById('editor-stack-tower-bg-speed');
if (en) {
en.addEventListener('change', () => {
stackTowerBgScrollConfig.enabled = !!en.checked;
if (stackTowerBgScrollConfig.enabled) startStackTowerBgScrollRaf();
else stopStackTowerBgScrollRaf();
draw();
});
}
if (sp) {
sp.addEventListener('change', () => {
stackTowerBgScrollConfig.speedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(sp.value)) || 40));
});
}
const dirSel = document.getElementById('editor-stack-tower-bg-direction');
if (dirSel) {
dirSel.addEventListener('change', () => {
stackTowerBgScrollConfig.scrollDirection = dirSel.value === 'down' ? 'down' : 'up';
syncStackTowerBgScrollInitialToBottom();
draw();
if (stackTowerBgScrollConfig.enabled) startStackTowerBgScrollRaf();
});
}
const introF = document.getElementById('editor-stack-tower-bg-intro-file');
if (introF) {
introF.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => {
stackTowerBgScrollConfig.introImage = r.result;
loadStackTowerBgScrollImagePair(stackTowerBgScrollConfig.introImage, stackTowerBgScrollConfig.loopImage || EDITOR_STACK_TOWER_BG_DEFAULT_LOOP, () => {
syncStackTowerBgScrollInitialToBottom();
draw();
if (stackTowerBgScrollConfig.enabled) startStackTowerBgScrollRaf();
});
};
r.readAsDataURL(file);
e.target.value = '';
});
}
const loopF = document.getElementById('editor-stack-tower-bg-loop-file');
if (loopF) {
loopF.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => {
stackTowerBgScrollConfig.loopImage = r.result;
loadStackTowerBgScrollImagePair(stackTowerBgScrollConfig.introImage || EDITOR_STACK_TOWER_BG_DEFAULT_INTRO, stackTowerBgScrollConfig.loopImage, () => {
syncStackTowerBgScrollInitialToBottom();
draw();
if (stackTowerBgScrollConfig.enabled) startStackTowerBgScrollRaf();
});
};
r.readAsDataURL(file);
e.target.value = '';
});
}
const btnR = document.getElementById('editor-stack-tower-bg-reset-assets');
if (btnR) {
btnR.addEventListener('click', () => {
stackTowerBgScrollConfig.introImage = null;
stackTowerBgScrollConfig.loopImage = null;
loadStackTowerBgScrollImagePair(EDITOR_STACK_TOWER_BG_DEFAULT_INTRO, EDITOR_STACK_TOWER_BG_DEFAULT_LOOP, () => {
syncStackTowerBgScrollUiFromConfig();
syncStackTowerBgScrollInitialToBottom();
draw();
if (stackTowerBgScrollConfig.enabled) startStackTowerBgScrollRaf();
});
});
}
})();
else stopStackTowerBgScrollRaf();
document.getElementById('btn-new').addEventListener('click', initGrid);
document.getElementById('btn-spawn').addEventListener('click', () => {
+90 -62
View File
@@ -78,6 +78,7 @@
const base = Number.isFinite(b) ? Math.max(0.85, Math.min(3.2, b)) : 2;
const towerPost50 =
isStackTowerMissionUiMapPlay() &&
getStackTowerCameraFollowPlayConfig().enabled &&
stackTowerMissionPhase === 'live' &&
stackMini &&
Number.isFinite(Number(stackMini.progressPct)) &&
@@ -1006,11 +1007,11 @@
let stackTowerScrollBgIntroImg = null;
let stackTowerScrollBgLoopImg = null;
let stackTowerScrollBgPx = 0;
/** ค่า zDraw ครั้งแรกที่วาด BG — ใช้ scale พื้นหลังให้ขยายตาม zoom เหมือนฉากโลก */
let stackTowerScrollBgZoomRefPlay = 0;
let stackTowerScrollBgSpeedPxPerSec = 40;
let stackTowerScrollBgOff = false;
let stackTowerScrollBgFlowDown = false;
/** Sraw = px+boost+post ครั้งแรกที่คำนวณ world offset — ใช้หา delta แบบคาบ loop (อย่าใช้ fold(Sraw) ลบตรงๆ จะดันโลกขึ้นหลายร้อย px) */
let stackTowerScrollBgSrawWorldBaselinePlay = null;
function stackTowerScrollBgMapEligible() {
return !!(mapData && mapData.gameType === 'stack' && (currentPlayMapId() || '').trim() === STACK_TOWER_MISSION_MAP_ID);
@@ -1023,21 +1024,25 @@
return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
}
/** แถบ intro+loop เต็มจอ — เปิดเฉพาะเมื่อแมปตั้ง <code>stackTowerBgScroll.enabled: true</code> และรูปโหลดครบ (ไม่โหลด strip เมื่อปิด → ใช้รูปแผนที่ backgroundImage) */
function stackTowerScrollBgDrawActive() {
return stackTowerScrollBgImagesReady();
return stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff;
}
function reloadStackTowerScrollBgFromMap() {
stackTowerScrollBgIntroImg = null;
stackTowerScrollBgLoopImg = null;
stackTowerScrollBgPx = 0;
stackTowerScrollBgZoomRefPlay = 0;
stackTowerScrollBgSrawWorldBaselinePlay = null;
if (!stackTowerScrollBgMapEligible()) return;
const raw = mapData.stackTowerBgScroll && typeof mapData.stackTowerBgScroll === 'object' ? mapData.stackTowerBgScroll : {};
stackTowerScrollBgOff = raw.enabled === false;
stackTowerScrollBgOff = raw.enabled !== true;
stackTowerScrollBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 40));
const dirRaw = String(raw.scrollDirection || raw.direction || 'up').toLowerCase();
stackTowerScrollBgFlowDown = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
/* คีย์เวิร์ด down/top = ตัวเลือก "บน→ล่าง" ในเอดิเตอร์ — ใช้สูตรเลื่อนแบบ stripTop−S (ฉากไหลขึ้น) ไม่ใช่ S+chR−… (ไหลลง) เพราะผู้เล่นคาดว่าหอสูง = มองขึ้น */
const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
const rawIsDownKeyword = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
stackTowerScrollBgFlowDown = !rawIsDownKeyword;
if (stackTowerScrollBgOff) return;
const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : STACK_TOWER_SCROLL_BG_DEFAULT_INTRO;
const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : STACK_TOWER_SCROLL_BG_DEFAULT_LOOP;
let pending = 2;
@@ -1067,22 +1072,44 @@
if (stackTowerScrollBgFlowDown) {
stackTowerScrollBgPx = 0;
} else {
/* ขอบล่างของ intro ิดขอบล่างแคนวาสตอนเริ่ม — แม้ intro ต่ำกว่าจอก็ไม่ pin จากด้านบน */
/* ขอบล่าง intro ิดขอบล่างแคนวาส — คู่กับ stripTopS */
stackTowerScrollBgPx = drawHIntro - chR;
}
}
/** zDrawPan = zoom ตอนวาด stack — ผูกเลื่อนแถบ BG กับกล้องโลก (px จอ = boostWorld * z) */
/**
* บคาเลอนแนวต S (หนวยเดยวก drawStackTowerScrollBgFullCanvas) เขาชวงหนงคาบของ loop
* เม vp0 drawHIntro ปบนจอซำท drawHLoop; บค S อนสงเข worldToScreen px โตไมจำกดดนโลกหลดจอ
*/
function foldStackTowerScrollStripViewSForPhase(S, drawHIntro, drawHLoop) {
const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
const s = Number(S) || 0;
if (s < dI) return s;
return s - Math.floor((s - dI) / dH) * dH;
}
/**
* offset แนวตงจอใหโลกซงก draw ใช S = fold(Sraw) ใชความตางเฟส fold(Sraw)fold(Sraw0)
* เวลาเลอนครบคาบ loop fold วนกล offset ไมโตไมจำก · ามค fold(Sraw) วนเม Sraw0 งอย intro (baseline ไมเข loop ทำใหตร f0+mod ไมทำงานและเฟสหล)
*/
function stackTowerScrollWorldTotalYFromStripPlay(Sraw, drawHIntro, drawHLoop, Sraw0) {
if (Sraw0 == null) return 0;
const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
const s = Number(Sraw) || 0;
const s0 = Number(Sraw0) || 0;
const fs = foldStackTowerScrollStripViewSForPhase(s, dI, dH);
const fs0 = foldStackTowerScrollStripViewSForPhase(s0, dI, dH);
return fs - fs0;
}
/** zDrawPan ส่งต่อเพื่อคูณ boost กับ z — ไม่ scale แคนวาสทั้งแผง (เคย scale รอบกลางทำให้จอบางส่วนว่างเมื่อ z ≠ zRef) */
function drawStackTowerScrollBgFullCanvas(cw, ch, zDrawPan) {
const intro = stackTowerScrollBgIntroImg;
const loop = stackTowerScrollBgLoopImg;
if (!intro || !intro.complete || !loop || !loop.complete) return;
const zPan = Number(zDrawPan) > 0 ? zDrawPan : zoom;
if (!(stackTowerScrollBgZoomRefPlay > 0) && zPan > 0.01 && Number.isFinite(zPan)) {
stackTowerScrollBgZoomRefPlay = zPan;
}
const zRef = stackTowerScrollBgZoomRefPlay > 0 ? stackTowerScrollBgZoomRefPlay : zPan;
const bgZoomMul = Math.max(0.28, Math.min(4.25, zPan / zRef));
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const scaleI = cwR / intro.naturalWidth;
@@ -1091,10 +1118,11 @@
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
const camFollowScreen = isStackTowerMissionUiMapPlay() ? getStackTowerBgScrollHeightBoostPx() * zPan : 0;
const post50Scroll = isStackTowerMissionUiMapPlay() ? getStackTowerPost50BgScrollExtraPxPlay(chR) : 0;
const S = stackTowerScrollBgPx + camFollowScreen + post50Scroll;
const Sraw = stackTowerScrollBgPx + camFollowScreen + post50Scroll;
const S = foldStackTowerScrollStripViewSForPhase(Sraw, drawHIntro, drawHLoop);
const vp0 = S;
const vp1 = S + chR;
const yLimit = vp1 + drawHLoop * 2;
const yLimit = vp1 + drawHLoop * 96;
const flowDown = stackTowerScrollBgFlowDown;
function stripTopToDestY(stripTop, drawH) {
@@ -1107,9 +1135,6 @@
ctx.beginPath();
ctx.rect(0, 0, cwR, chR);
ctx.clip();
ctx.translate(cwR * 0.5, chR * 0.5);
ctx.scale(bgZoomMul, bgZoomMul);
ctx.translate(-cwR * 0.5, -chR * 0.5);
if (vp0 < 0) {
let k = 1;
@@ -1134,7 +1159,8 @@
if (y < yLimit && vp1 > drawHIntro) {
if (vp0 > drawHIntro) {
const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
y = drawHIntro + Math.max(0, n - 1) * drawHLoop;
/* ใช้ n ไม่ใช่ n−1 — เดิมทำให้แถบ loop เริ่มสูงกว่า vp0 หนึ่งความสูง จอล่างว่างดำเมื่อ S ใหญ่ */
y = drawHIntro + n * drawHLoop;
}
while (y < yLimit) {
const dy = Math.round(stripTopToDestY(y, drawHLoop));
@@ -1155,7 +1181,8 @@
const scaleL = cwR / loop.naturalWidth;
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
const skyScreenH = Math.min(chR, Math.ceil(-worldMinY * zDraw) + 4);
const rawScroll = stackTowerScrollBgPx + getStackTowerBgScrollHeightBoostPx() * zDraw
const stripPx = stackTowerScrollBgOff ? 0 : stackTowerScrollBgPx;
const rawScroll = stripPx + getStackTowerBgScrollHeightBoostPx() * zDraw
+ getStackTowerPost50BgScrollExtraPxPlay(chR);
const scroll = ((rawScroll % drawHLoop) + drawHLoop) % drawHLoop;
let y = -scroll;
@@ -1173,26 +1200,45 @@
function tickStackTowerScrollBg(dtSec) {
if (!stackTowerScrollBgMapEligible() || stackTowerScrollBgOff) return;
if (!stackTowerScrollBgImagesReady()) return;
stackTowerScrollBgPx += (stackTowerScrollBgSpeedPxPerSec || 40) * dtSec;
const spd = (stackTowerScrollBgSpeedPxPerSec || 40) * dtSec;
/* !flowDown (stripTopS): S เพิ่ม → dy ลด → ฉากไหลขึ้น — ห้ามใช้ −= (จะลด S แล้วศิลป์ไหลลง) */
stackTowerScrollBgPx += spd;
}
/**
* าลบจาก screen Y ใน worldToScreen งก drawStackTowerScrollBgFullCanvas (auto + boost ตามความสงช + post-50)
* าลบจาก screen Y ใน worldToScreen งก drawStackTowerScrollBgFullCanvas (auto + boost + post-50)
* แถบ BG วาด 1:1 บแคนวาส ามใช fold(Sraw) เปนคาลบตรงๆ (จะดนโลกขนหลายรอย px เหมอนครงลางจอวาง)
*/
function getStackTowerWorldLayerScrollScreenOffsetYPlay(zPan) {
if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
const zPn = Number(zPan) > 0 ? zPan : zoom;
const zRef = stackTowerScrollBgZoomRefPlay > 0 ? stackTowerScrollBgZoomRefPlay : zPn;
const bgZm = Math.max(0.28, Math.min(4.25, zPn / zRef));
const chPx = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 720));
const boostScreen = getStackTowerBgScrollHeightBoostPx() * zPn;
const post50Screen = getStackTowerPost50BgScrollExtraPxPlay(chPx);
let autoSigned = 0;
if (stackTowerScrollBgDrawActive() && stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff) {
autoSigned = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx;
const intro = stackTowerScrollBgIntroImg;
const loop = stackTowerScrollBgLoopImg;
let totalScreen = boostScreen + post50Screen;
if (stackTowerScrollBgDrawActive() && intro && intro.complete && intro.naturalWidth
&& loop && loop.complete && loop.naturalWidth && canvas && canvas.width) {
const cwR = Math.max(1, Math.round(canvas.width));
const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * (cwR / loop.naturalWidth)));
const SrawDraw = stackTowerScrollBgPx + boostScreen + post50Screen;
if (stackTowerScrollBgSrawWorldBaselinePlay == null) stackTowerScrollBgSrawWorldBaselinePlay = SrawDraw;
const S0 = stackTowerScrollBgSrawWorldBaselinePlay;
let stripY;
if (stackTowerScrollBgFlowDown) {
const Sview = foldStackTowerScrollStripViewSForPhase(SrawDraw, drawHIntro, drawHLoop);
stripY = (boostScreen + post50Screen) * 2 - Sview;
} else {
stripY = stackTowerScrollWorldTotalYFromStripPlay(SrawDraw, drawHIntro, drawHLoop, S0);
}
totalScreen = stripY;
} else if (stackTowerScrollBgDrawActive()) {
const px = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx;
totalScreen = px + boostScreen + post50Screen;
}
const totalScreen = autoSigned + boostScreen + post50Screen;
return totalScreen / bgZm;
return totalScreen;
}
/** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */
@@ -4228,8 +4274,12 @@
return x * x * (3 - 2 * x);
}
/** Tower live + progress ≥50%: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS */
/** Tower live + progress ≥50%: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS — ใช้เฉพาะเมื่อเปิดกล้องตามหอ (ไม่งั้นฉากธรรมดาไม่เลื่อน/ซูมหลัง 50%) */
function getStackTowerPost50AnimEasePlay() {
if (!getStackTowerCameraFollowPlayConfig().enabled) {
stackTowerPost50AnimStartMs = null;
return 0;
}
if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'live' || !stackMini) return 0;
const p = Number(stackMini.progressPct) || 0;
if (p < STACK_TOWER_POST50_PROGRESS_THRESH) {
@@ -8641,21 +8691,14 @@
};
}
/** ปิดการตามความสูงหอจาก JSON เสมอ — เซิร์ฟเคยบังคับ enabled:true ใน policy ทำให้ boost เลื่อนโลก */
function getStackTowerCameraFollowPlayConfig() {
const raw = mapData && mapData.stackTowerCameraFollow && typeof mapData.stackTowerCameraFollow === 'object'
? mapData.stackTowerCameraFollow
: {};
if (raw.enabled === false) {
return { enabled: false, pxPerLayer: 12, maxPx: 260 };
}
const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(raw.pxPerLayer)) || 12));
const maxPx = Math.max(0, Math.min(800, Math.floor(Number(raw.maxPx)) || 260));
return { enabled: raw.enabled !== false, pxPerLayer, maxPx };
return { enabled: false, pxPerLayer: 12, maxPx: 260 };
}
/** ยกจุดยึดเชือกขึ้นเมื่อหอสูง (y ลดลง = สูงขึ้นในโลก) — เฉพาะ mnn93hpi */
/** ยกจุดยึดเชือกขึ้นเมื่อหอสูง (y ลดลง = สูงขึ้นในโลก) — เฉพาะ mnn93hpi + เปิดกล้องตามหอ */
function getStackTowerSwingLiftWorldPx(layerCount, layerWorldHPx) {
if (!isStackTowerMissionUiMapPlay()) return 0;
if (!isStackTowerMissionUiMapPlay() || !getStackTowerCameraFollowPlayConfig().enabled) return 0;
const n = Math.floor(Number(layerCount) || 0);
if (n < STACK_TOWER_SWING_LIFT_FROM_LAYER) return 0;
const lh = Math.max(10, Number(layerWorldHPx) || Math.max(14, (tileSize || 32) * 0.3));
@@ -14563,8 +14606,8 @@
if (mapData && isStackTowerMissionUiMapPlay() && canvas) {
canvas.width = Math.max(320, STACK_TOWER_FIXED_RENDER_W);
canvas.height = Math.max(240, STACK_TOWER_FIXED_RENDER_H);
/* รีคำนวน BG scroll + zoom ref หลังขนาดบัฟเฟอร์คงที่ — กันรูปโหลดเร็วตอนแคนวาสยังไม่ล็อกแล้วไม่ซิงก์ใหม่ */
stackTowerScrollBgZoomRefPlay = 0;
/* รีคำนวน BG scroll หลังขนาดบัฟเฟอร์คงที่ — กันรูปโหลดเร็วตอนแคนวาสยังไม่ล็อกแล้วไม่ซิงก์ใหม่ */
stackTowerScrollBgSrawWorldBaselinePlay = null;
stackTowerScrollBgSyncInitialScrollToBottom();
syncQuizCarryEmbedCountdownLayout();
return;
@@ -16387,23 +16430,7 @@
camX = gauntletGroupCam.px;
camY = gauntletGroupCam.py;
}
if (isStackTowerMissionUiMapPlay() && stackMini && canvas && zDraw > 0) {
const nLay0 = stackMini.layers ? stackMini.layers.length : 0;
const hasMotion = !!(stackFall || stackMini.settling);
if (nLay0 === 0 && !hasMotion) {
const rawFrac = mapData && mapData.stackTowerFloorScreenFrac;
const frac = Number(rawFrac);
/** ค่าน้อย = พื้นขึ้นบนจอ (จุดเริ่มตรงฐานศิลป์) — แมป override ได้ที่ stackTowerFloorScreenFrac */
const screenFrac = Number.isFinite(frac)
? Math.max(0.38, Math.min(0.82, frac))
: 0.56;
const floorY0 = stackMini.floorWorldY;
const stScrollFloor = getStackTowerWorldLayerScrollScreenOffsetYPlay(zDraw);
const curScreen = (floorY0 - camY) * zDraw + canvas.height * 0.5 - stScrollFloor;
const desired = canvas.height * screenFrac;
camY += (curScreen - desired) / zDraw;
}
}
/* Stack Tower (mnn93hpi): ไม่เลื่อนกล้องตาม floorWorldY — ใช้ cam จาก getStackCameraCentersPx() เหมือน Stack ทั่วไป (วางบล็อกแล้วจอไม่ไหล) */
lastPlayZDrawForInput = zDraw;
const stackTowerWorldScrollScreenY = getStackTowerWorldLayerScrollScreenOffsetYPlay(zDraw);
const halfW = canvas.width / (2 * zDraw);
@@ -16457,7 +16484,8 @@
const srcW = ((bgMaxX - bgMinX) / mapWpx) * natW;
const srcH = ((bgMaxY - bgMinY) / mapHpx) * natH;
const destX = (bgMinX - camX) * zDraw + canvas.width / 2;
const destY = (bgMinY - camY) * zDraw + canvas.height / 2;
const destY = (bgMinY - camY) * zDraw + canvas.height / 2
- (isStackTowerMissionUiMapPlay() ? stackTowerWorldScrollScreenY : 0);
const destW = (bgMaxX - bgMinX) * zDraw;
const destH = (bgMaxY - bgMinY) * zDraw;
if (srcW > 0.25 && srcH > 0.25 && destW > 0.25 && destH > 0.25) {
+5 -4
View File
@@ -19,7 +19,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;800&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=20260427-canvas-pixel">
<link rel="stylesheet" href="css/style.css?v=20260506-stack-tower-cover">
<style>
html, body {
height: 100%;
@@ -127,7 +127,7 @@
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* Stack Tower (mnn93hpi): เต็มพื้นที่ stage ยังคงสัดส่วนบัฟเฟอร์ 16:9 (object-fit) */
/* Stack Tower (mnn93hpi): แคนวาสบัฟเฟอร์ 16:9 object-fit: cover เติม stage ไม่ให้แถบดำ (contain เคยทำขอบว่าง) */
html.play-stack-tower-pixel-canvas #play-canvas-stage.play-canvas-stage {
position: relative;
min-height: 0;
@@ -139,7 +139,8 @@
height: 100% !important;
max-width: none;
max-height: none;
object-fit: contain;
/* contain ทำให้บัฟเฟอร์ 16:9 ถูกย่อกลาง stage — ขอบบน/ล่างหรือซ้าย/ขวาว่างดำเหมือนจอเสีย; cover เติมเต็ม stage (อาจ crop ขอบเล็กน้อย) */
object-fit: cover;
object-position: center center;
touch-action: none;
image-rendering: pixelated;
@@ -3202,7 +3203,7 @@
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0306"></script>
<script src="js/play.js?v=0.0381"></script>
<script src="js/play.js?v=0.0406"></script>
<div class="version-tag">v —</div>
</body>
</html>
+48 -2
View File
@@ -1965,6 +1965,21 @@ function normalizeJumpSurviveHazardAreaOnMap(m) {
m.jumpSurviveHazardArea = rows;
}
/**
* แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดเลื่อนแนวตั้งทั้งแถบ BG และ offset กล้องตามหอใน JSON ที่ส่งให้ไคลเอนต์
* (เดิมบังคับ stackTowerCameraFollow.enabled=true ทำให้ play.js เลื่อนโลกทุกชั้น + อาการครึ่งจอดำ — แก้ให้ปิดเสมอ)
*/
function applyStackTowerMissionMapPlayPolicy(safeId, m) {
if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return;
const dirRaw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll.scrollDirection : 'down';
const dir = typeof dirRaw === 'string' && dirRaw.trim() ? String(dirRaw).trim() : 'down';
m.stackTowerBgScroll = { enabled: false, speedPxPerSec: 0, scrollDirection: dir };
const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12));
const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260));
m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx };
}
function loadMaps() {
try {
if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
@@ -1983,12 +1998,38 @@ function loadMaps() {
normalizeJumpSurviveHazardAreaOnMap(data);
normalizeQuizBattleDomeAreaOnMap(data);
normalizeQuizBattlePathAreaOnMap(data);
applyStackTowerMissionMapPlayPolicy(id, data);
maps.set(id, data);
} catch (e) { console.error('load map', f, e.message); }
}
} catch (e) { console.error('loadMaps', e.message); }
}
/**
* อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที
* (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า)
*/
function rehydrateSingleMapFromDisk(safeId) {
if (!safeId) return false;
const fp = path.join(MAPS_DIR, safeId + '.json');
if (!fs.existsSync(fp)) return false;
try {
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
normalizeQuizCarryLayersOnMap(data);
normalizeJumpSurvivePlatformAreaOnMap(data);
normalizeJumpSurvivePlatformVariantAreaOnMap(data);
normalizeJumpSurviveHazardAreaOnMap(data);
normalizeQuizBattleDomeAreaOnMap(data);
normalizeQuizBattlePathAreaOnMap(data);
applyStackTowerMissionMapPlayPolicy(safeId, data);
maps.set(safeId, data);
return true;
} catch (e) {
console.error('rehydrate map', safeId, e.message);
return false;
}
}
/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
function ensurePostCaseLobbyMapLoaded() {
if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
@@ -2024,9 +2065,13 @@ const server = http.createServer((req, res) => {
let url = req.url;
if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
if (url.startsWith(BASE_PATH + '/api/maps')) {
const id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
if (id.includes('?')) id = id.split('?')[0];
if (req.method === 'GET' && id) {
const m = maps.get(id);
const sid = safeMapId(decodeURIComponent(id));
if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
rehydrateSingleMapFromDisk(sid);
const m = maps.get(sid);
if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
}
@@ -2113,6 +2158,7 @@ const server = http.createServer((req, res) => {
normalizeJumpSurviveHazardAreaOnMap(m);
normalizeQuizBattleDomeAreaOnMap(m);
normalizeQuizBattlePathAreaOnMap(m);
applyStackTowerMissionMapPlayPolicy(id, m);
maps.set(id, m);
saveMap(id, m);
res.writeHead(200, { 'Content-Type': 'application/json' });