Game: stack tower camera follow (mnn93hpi) + editor controls

Persist stackTowerCameraFollow on map JSON; pan camera up with stack height in play.js. Editor UI only for map id mnn93hpi + gameType stack. Bump play/editor script cache versions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-03 04:37:08 +00:00
parent 16826d0c6e
commit b005d5f56e
4 changed files with 110 additions and 11 deletions
+13 -1
View File
@@ -263,6 +263,18 @@
<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> · Stack Tower) — เลื่อนมุมมองขึ้นเมื่อสแต็กสูงขึ้น · <em>English:</em> Camera pans up as blocks stack; saved 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-cam-enabled" checked> เปิดตามชั้น / Follow stack height</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>
</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">
@@ -307,7 +319,7 @@
</div>
</div>
<script src="js/version.js?v=0.0169"></script>
<script src="js/editor.js?v=20260502-towerblock-ftp-hint"></script>
<script src="js/editor.js?v=20260427-stack-tower-cam"></script>
<div class="version-tag">v —</div>
</body>
</html>
+48
View File
@@ -236,6 +236,45 @@
if (dir) dir.value = editorBgScrollConfig.scrollDirection === 'down' ? 'down' : 'up';
}
/** กล้องตามความสูงหอ — เฉพาะแมป Tower mission mnn93hpi (Editor + map JSON) */
const EDITOR_STACK_TOWER_CAM_MAP_ID = 'mnn93hpi';
function isEditorStackTowerMissionMap() {
const gt = gameTypeEl ? gameTypeEl.value : gameType;
return mapId === EDITOR_STACK_TOWER_CAM_MAP_ID && gt === 'stack';
}
function syncStackTowerCamBlockVisibilityOnly() {
const block = document.getElementById('editor-stack-tower-cam-block');
if (!block) return;
block.hidden = !isEditorStackTowerMissionMap();
}
function applyStackTowerCameraFollowFromMap(m) {
const block = document.getElementById('editor-stack-tower-cam-block');
if (block) block.hidden = !isEditorStackTowerMissionMap();
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 };
}
function readEditorBgScrollConfigForSave() {
if (!isEditorScrollBgMap()) return null;
const en = document.getElementById('editor-bg-scroll-enabled');
@@ -1069,6 +1108,7 @@
}
function toggleFroggerUI() {
syncStackTowerCamBlockVisibilityOnly();
const froggerWrap = document.getElementById('frogger-lanes-wrap');
const hint = document.getElementById('game-type-hint');
const legEl = document.getElementById('frogger-lanes-legend');
@@ -2347,6 +2387,11 @@
}
})();
if (isEditorScrollBgMap()) applyEditorBgScrollFromMap({});
if (isEditorStackTowerMissionMap()) applyStackTowerCameraFollowFromMap({});
else {
const stBlock0 = document.getElementById('editor-stack-tower-cam-block');
if (stBlock0) stBlock0.hidden = true;
}
document.getElementById('btn-new').addEventListener('click', initGrid);
document.getElementById('btn-spawn').addEventListener('click', () => {
@@ -2568,6 +2613,8 @@
if (backgroundImage) body.backgroundImage = backgroundImage;
const ebsSave = readEditorBgScrollConfigForSave();
if (ebsSave) body.editorBgScroll = ebsSave;
const stcSave = readStackTowerCameraFollowForSave();
if (stcSave !== undefined) body.stackTowerCameraFollow = stcSave;
body.lanes = gameType === 'frogger' ? lanes : [];
sanitizeGauntletPlayerSpawns();
body.gauntletPlayerSpawns = gameType === 'gauntlet'
@@ -2882,6 +2929,7 @@
}
gameType = m.gameType || 'zep';
if (gameTypeEl) gameTypeEl.value = gameType;
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 = [];
if (gameType === 'frogger') {
+48 -9
View File
@@ -6409,9 +6409,22 @@
};
}
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 };
}
function getStackCameraCentersPx() {
const w = mapData.width || 20, h = mapData.height || 15;
const ts = tileSize;
const mapHpx = h * ts;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
let cx = (w / 2) * ts;
@@ -6426,6 +6439,20 @@
cx = rel.cx * ts;
cy = rel.cy * ts;
}
if (isStackTowerMissionUiMapPlay() && stackMini) {
const cfg = getStackTowerCameraFollowPlayConfig();
if (cfg.enabled && cfg.pxPerLayer > 0) {
const n = stackMini.layers ? stackMini.layers.length : 0;
let layerCount = n;
if (stackFall) {
const dur = Math.max(1, stackFall.dur || 400);
const u = Math.min(1, Math.max(0, (performance.now() - stackFall.t0) / dur));
layerCount = n + u * 0.9;
}
const shift = Math.min(cfg.maxPx, layerCount * cfg.pxPerLayer);
cy = Math.max(ts * 1.5, Math.min(mapHpx - ts * 2, cy - shift));
}
}
return { px: cx, py: cy };
}
@@ -6710,6 +6737,9 @@
: 0.16 * offN * (hit.perfect ? 0.1 : 1);
const isHumanPreview = !!(previewActor && previewActor.human);
const botIdPreview = previewActor && previewActor.botId ? previewActor.botId : null;
const cableTopYRel = Math.max(0, m.swingWorldY - tileSize * 6);
const craneXRel = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
const releaseRopeAng = Math.atan2(m.swingWorldY - cableTopYRel, swingXW - craneXRel);
stackFall = {
t0: performance.now(),
dur,
@@ -6723,6 +6753,9 @@
sloppyN: offN,
previewHumanDrop: isHumanPreview,
previewBotId: botIdPreview,
releaseRopeAng,
releaseAttachWorldX: swingXW,
releaseAttachWorldY: m.swingWorldY,
};
return true;
}
@@ -6964,21 +6997,27 @@
const cxMid = blockLeftWorld + twWorld / 2;
const [tcx, tcy] = worldToScreen(craneX, cableTopY);
const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld);
drawStackSlingSegmentPlay(ctx, tcx, tcy, bcx, bcy, zoom);
const slingAng = Math.atan2(bcy - tcy, bcx - tcx);
const swingTiltRad = Math.PI / 2 - slingAng;
let slingBx = bcx;
let slingBy = bcy;
if (stackFall && stackFall.releaseAttachWorldX != null) {
const p = worldToScreen(stackFall.releaseAttachWorldX, stackFall.releaseAttachWorldY);
slingBx = p[0];
slingBy = p[1];
}
drawStackSlingSegmentPlay(ctx, tcx, tcy, slingBx, slingBy, zoom);
const ropeAng = stackFall && stackFall.releaseRopeAng != null
? stackFall.releaseRopeAng
: Math.atan2(bcy - tcy, bcx - tcx);
const swingTiltRad = ropeAng - Math.PI / 2;
const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss;
ctx.fillStyle = hitMissTint ? 'rgba(247, 118, 190, 0.88)' : 'rgba(125, 207, 255, 0.95)';
ctx.strokeStyle = hitMissTint ? 'rgba(255, 180, 210, 0.98)' : 'rgba(192, 202, 245, 0.98)';
const cxW = blockLeftWorld + twWorld / 2;
const cyW = blockTopWorld + stripH * 0.5;
const [cxs, cys] = worldToScreen(cxW, cyW);
ctx.save();
ctx.translate(cxs, cys);
ctx.translate(bcx, bcy);
ctx.rotate(fallTiltRad + swingTiltRad);
ctx.fillRect(-twWorld * zoom / 2, -drawStripPx / 2, twWorld * zoom, drawStripPx);
ctx.fillRect(-twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx);
ctx.lineWidth = 2;
ctx.strokeRect(-twWorld * zoom / 2, -drawStripPx / 2, twWorld * zoom, drawStripPx);
ctx.strokeRect(-twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx);
ctx.lineWidth = 1;
ctx.restore();
}
+1 -1
View File
@@ -2009,7 +2009,7 @@
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0184"></script>
<script src="js/play.js?v=0.258"></script>
<script src="js/play.js?v=0.266"></script>
<div class="version-tag">v —</div>
</body>
</html>