PAYATHON 2026

如何让地图中的可拖动棋子随父级 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 会暂时写入像素位置,结束时恢复百分比即可;如果还要求拖动过程中同步改变窗口尺寸,应额外处理拖动状态和边界更新。

备注:内容仅供参考。