如何让地图中的可拖动棋子随父级 div 等比例缩放并保持位置?
地图和棋子可以按同一套比例布局一起缩放:让 #map-board 使用响应式宽度并保持 1500:1000 的宽高比,棋子的尺寸、left 和 top 则使用百分比。拖动结束后,再把 jQuery UI 产生的像素坐标转换回比例坐标。
给父元素添加 position: relative 可以确定棋子的定位基准。还需要处理棋子的尺寸和坐标,才能让它在窗口变化时保持地图上的相对位置。
当前布局为什么不会一起缩放
现有代码将地图固定为 1500px × 1000px,棋子固定为 50px × 50px,坐标也使用像素,因此它们不会随可用宽度变化。
初始棋子的图片由 .tokendiv 包裹,left 和 top 设置在外层,draggable 却绑定在内部图片上;动态创建的棋子则直接将图片插入地图。这两种定位结构不一致,拖动时还可能出现外层位置与图片偏移叠加的问题。
可以把结构统一为:每个棋子都有一个绝对定位的 .tokendiv,拖动行为绑定在外层元素上,图片负责填满容器。
1. 让地图和棋子按比例布局
下面假设现有的 token.X、token.Y 保存的是相对于原始 1500 × 1000 地图左上角的坐标。如果保存的是页面坐标,需要先转换为地图内部坐标。
将地图和棋子的 Razor 标记调整为:
<div id="MapDiv">
<div id="map-board"
class="map-board"
style="background-image: url('@Model.MapImagePath');"
data-role="dm">
@foreach (var token in Model.Tokens.Where(t => t.Piece?.PieceType?.Name != "Map" && (t.X != 0 || t.Y != 0)))
{
<div class="tokendiv"
data-tokenid="@token.Id"
data-map-x="@token.X"
data-map-y="@token.Y"
style="z-index: @(token.ZIndex);">
<img src="@(string.IsNullOrEmpty(token.Piece?.ImagePath) ? "/images/default.png" : token.Piece.ImagePath)"
class="draggable-token"
id="token-placed-@token.Id"
data-tokenid="@token.Id"
draggable="false"
onerror="this.onerror=null; this.src='/images/default.png';" />
</div>
}
</div>
</div>
对应的 CSS:
#MapDiv {
width: 100%;
min-width: 0;
}
#map-board {
position: relative;
width: 100%;
max-width: 1500px;
height: auto;
aspect-ratio: 3 / 2;
padding: 0;
border: 0;
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
}
#map-board > .tokendiv {
position: absolute;
width: calc(100% / 30);
height: 5%;
padding: 0;
border: 0;
margin: 0;
}
#map-board .draggable-token {
display: block;
position: static;
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
}
棋子的尺寸按原始地图计算:
- 宽度为地图宽度的
50 / 1500,即1 / 30。 - 高度为地图高度的
50 / 1000,即5%。
地图保持 3 / 2 的宽高比,棋子也会保持正方形。地图缩小到 750 × 500 时,棋子对应为 25 × 25。
background-size: 100% 100% 会让背景铺满地图。示例沿用原始的 1500 × 1000 比例;如果背景图片的实际比例不同,应先确定棋盘比例,否则图片可能被拉伸。如果使用 contain 留出空白,还需要额外计算图片显示区域与棋子坐标的对应关系。
2. 初始化百分比位置,拖动后转换坐标
可以继续使用 1500 × 1000 的固定逻辑坐标系:显示时转换为百分比,拖动结束后再将当前显示像素转换回逻辑坐标。
例如,逻辑位置 (300, 200) 对应 left: 20%、top: 20%,地图缩放后仍落在同一地图位置。
用下面的代码替换原有同名变量、拖动配置和初始化逻辑。保留侧边栏创建请求、对话框等业务代码,并删除原来的 $('.draggable-token').draggable(...) 初始化,避免重复绑定。脚本应在地图标记之后加载,并确保 jQuery、jQuery UI 已加载。
const mapBoard = document.getElementById('map-board');
const isPlayerView = mapBoard.dataset.role === 'player';
const bc = new BroadcastChannel('map_channel');
const MAP_WIDTH = 1500;
const MAP_HEIGHT = 1000;
function setTokenPosition(token, x, y) {
token.dataset.mapX = String(x);
token.dataset.mapY = String(y);
token.style.left = `${(x / MAP_WIDTH) * 100}%`;
token.style.top = `${(y / MAP_HEIGHT) * 100}%`;
}
const draggablePieceInfo = {
containment: 'parent',
scroll: false,
stop: function (event, ui) {
const width = mapBoard.clientWidth;
const height = mapBoard.clientHeight;
if (width === 0 || height === 0) return;
const x = (ui.position.left / width) * MAP_WIDTH;
const y = (ui.position.top / height) * MAP_HEIGHT;
// jQuery UI 拖动时使用像素,结束后恢复为百分比。
setTokenPosition(this, x, y);
bc.postMessage({
action: 'tokenMoved',
tokenid: this.dataset.tokenid,
x,
y
});
// 如需持久化,向现有保存接口提交同一组逻辑坐标 x、y。
}
};
function initializeToken(token) {
const x = Number(token.dataset.mapX);
const y = Number(token.dataset.mapY);
setTokenPosition(token, x, y);
if (!isPlayerView) {
$(token).draggable(draggablePieceInfo);
}
}
$(function () {
mapBoard.querySelectorAll('.tokendiv').forEach(initializeToken);
});
if (isPlayerView) {
bc.onmessage = ({ data }) => {
if (!data || data.action !== 'tokenMoved') return;
if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) return;
const token = Array.from(
mapBoard.querySelectorAll('.tokendiv')
).find(element => element.dataset.tokenid === String(data.tokenid));
if (token) {
setTokenPosition(token, data.x, data.y);
}
};
}
拖动坐标使用 ui.position.left 和 ui.position.top。原代码中的 event.target.x、event.target.y 无法可靠地表达拖动元素相对于地图的位置,尤其是在拖动外层容器时。
示例中地图和棋子都没有 padding、border,这样显示区域与坐标计算使用的是同一个矩形。如果需要装饰边框,可以加在外面的容器上。
3. 动态创建的棋子也使用相同结构
原来的 makeNewToken 直接将图片插入地图,需要改为先创建 .tokendiv,再把图片放进去。下面沿用原代码中新棋子的初始位置 (100, 100),并将其作为逻辑坐标。
用这个函数替换原来的 makeNewToken,确保侧边栏请求成功后的调用能够访问它:
function makeNewToken(data) {
const sidebarPiece = Array.from(
document.querySelectorAll('.sidebar-piece')
).find(element => element.dataset.pieceid === String(data.pieceImageID));
if (!sidebarPiece) {
console.log('Could not make the token!');
return;
}
const token = document.createElement('div');
token.className = 'tokendiv';
token.dataset.tokenid = String(data.id);
token.dataset.mapX = '100';
token.dataset.mapY = '100';
token.style.zIndex = '99';
const tokenImg = sidebarPiece.cloneNode(true);
tokenImg.id = `token-placed-${data.id}`;
tokenImg.classList.remove('sidebar-piece');
tokenImg.classList.add('draggable-token');
tokenImg.dataset.tokenid = String(data.id);
tokenImg.draggable = false;
// 尺寸和位置统一由棋子容器及 CSS 控制。
['position', 'left', 'top', 'width', 'height', 'z-index'].forEach(
property => tokenImg.style.removeProperty(property)
);
token.appendChild(tokenImg);
mapBoard.appendChild(token);
initializeToken(token);
}
draggable="false" 用于关闭图片的浏览器原生拖放;外层容器仍由 jQuery UI 拖动。
保存和同步时需要注意什么
数据库、广播消息和页面渲染应使用相同的逻辑坐标。不同窗口里的地图显示宽度可能不同,如果直接同步当前窗口的像素值,棋子在接收窗口中就可能偏离原位置。
上面的广播示例只更新接收页面中已经存在的棋子。动态创建棋子还需要发送创建事件,并在接收端创建对应 DOM;原始代码中的这部分仍是注释,尚未实现。
创建请求当前提交的是 (0, 0),前端却将新棋子放在 (100, 100)。如果要保存创建后的显示位置,应统一这两处坐标。另外,原有 Razor 条件会排除 (0, 0) 的棋子;如果左上角也允许放棋子,就需要调整这个过滤条件,避免刷新后棋子消失。
采用百分比位置后,窗口尺寸变化通常不需要重新计算所有棋子。拖动期间 jQuery UI 会暂时写入像素位置,结束时恢复百分比即可;如果还要求拖动过程中同步改变窗口尺寸,应额外处理拖动状态和边界更新。
备注:内容仅供参考。
