Pause menu map: smooth zoom and pan with inertia
Replace snappy map zoom and drag behavior with smooth animations. Wheel zoom now eases toward the cursor, keeping the map point under the pointer fixed during scale. Drag pan eases toward the pointer and coasts with inertia on release, with edge clamps and friction damping. Animations cancel each other to prevent fighting. Update tab labels from full names to shorter versions (FALLOUT 4, MULTIPLAYER). Add dev log entry and vanilla settings integration plan.
This commit is contained in:
@@ -1109,13 +1109,32 @@
|
||||
if (pauseScreen && mapWorld) {
|
||||
var MAX_ZOOM = 4;
|
||||
var ZOOM_STEP = 0.18;
|
||||
var ZOOM_SMOOTHING = 0.18;
|
||||
var ZOOM_SNAP_EPSILON = 0.001;
|
||||
var PAN_SMOOTHING = 0.28;
|
||||
var PAN_SNAP_EPSILON = 0.15;
|
||||
var PAN_INERTIA_FRICTION = 0.92;
|
||||
var PAN_INERTIA_MIN_SPEED = 0.35;
|
||||
var PAN_VELOCITY_IDLE_MS = 80;
|
||||
var zoom = 1;
|
||||
var targetZoom = 1;
|
||||
var MIN_ZOOM = 1;
|
||||
var panX = 0;
|
||||
var panY = 0;
|
||||
var targetPanX = 0;
|
||||
var targetPanY = 0;
|
||||
var velocityX = 0;
|
||||
var velocityY = 0;
|
||||
var isDragging = false;
|
||||
var lastX = 0;
|
||||
var lastY = 0;
|
||||
var lastMoveTime = 0;
|
||||
var zoomRafId = 0;
|
||||
var panRafId = 0;
|
||||
var zoomFocusScreenX = 0;
|
||||
var zoomFocusScreenY = 0;
|
||||
var zoomFocusWorldX = 0;
|
||||
var zoomFocusWorldY = 0;
|
||||
|
||||
var mapImg = new Image();
|
||||
var mapNaturalWidth = 1024;
|
||||
@@ -1151,6 +1170,7 @@
|
||||
|
||||
if (zoom < MIN_ZOOM) {
|
||||
zoom = MIN_ZOOM;
|
||||
targetZoom = MIN_ZOOM;
|
||||
panX = 0;
|
||||
panY = 0;
|
||||
applyMapTransform();
|
||||
@@ -1166,15 +1186,148 @@
|
||||
pauseScreen.style.setProperty("--map-pan-y", panY + "px");
|
||||
}
|
||||
|
||||
function clampPan() {
|
||||
function getPanLimits() {
|
||||
var zoomedMapWidth = mapDisplayWidth * zoom;
|
||||
var zoomedMapHeight = mapDisplayHeight * zoom;
|
||||
var viewportWidth = window.innerWidth;
|
||||
var viewportHeight = window.innerHeight;
|
||||
var maxPanX = Math.max(0, (zoomedMapWidth - viewportWidth) / 2);
|
||||
var maxPanY = Math.max(0, (zoomedMapHeight - viewportHeight) / 2);
|
||||
panX = Math.max(-maxPanX, Math.min(maxPanX, panX));
|
||||
panY = Math.max(-maxPanY, Math.min(maxPanY, panY));
|
||||
return {
|
||||
maxX: Math.max(0, (zoomedMapWidth - viewportWidth) / 2),
|
||||
maxY: Math.max(0, (zoomedMapHeight - viewportHeight) / 2),
|
||||
};
|
||||
}
|
||||
|
||||
function clampPanValue(x, y) {
|
||||
var limits = getPanLimits();
|
||||
return {
|
||||
x: Math.max(-limits.maxX, Math.min(limits.maxX, x)),
|
||||
y: Math.max(-limits.maxY, Math.min(limits.maxY, y)),
|
||||
};
|
||||
}
|
||||
|
||||
function clampPan() {
|
||||
var clamped = clampPanValue(panX, panY);
|
||||
panX = clamped.x;
|
||||
panY = clamped.y;
|
||||
}
|
||||
|
||||
function clampTargetPan() {
|
||||
var clamped = clampPanValue(targetPanX, targetPanY);
|
||||
targetPanX = clamped.x;
|
||||
targetPanY = clamped.y;
|
||||
}
|
||||
|
||||
function stopZoomAnimation() {
|
||||
if (zoomRafId !== 0) {
|
||||
cancelAnimationFrame(zoomRafId);
|
||||
zoomRafId = 0;
|
||||
}
|
||||
targetZoom = zoom;
|
||||
}
|
||||
|
||||
function stopPanAnimation() {
|
||||
if (panRafId !== 0) {
|
||||
cancelAnimationFrame(panRafId);
|
||||
panRafId = 0;
|
||||
}
|
||||
targetPanX = panX;
|
||||
targetPanY = panY;
|
||||
velocityX = 0;
|
||||
velocityY = 0;
|
||||
}
|
||||
|
||||
function applyZoomFocusPan() {
|
||||
// Keep the focused map point under the cursor while scale changes
|
||||
// (transform-origin is center; pan is applied after scale).
|
||||
panX = zoomFocusScreenX - zoomFocusWorldX * zoom;
|
||||
panY = zoomFocusScreenY - zoomFocusWorldY * zoom;
|
||||
clampPan();
|
||||
targetPanX = panX;
|
||||
targetPanY = panY;
|
||||
}
|
||||
|
||||
function tickZoomAnimation() {
|
||||
zoomRafId = 0;
|
||||
|
||||
var zoomDelta = targetZoom - zoom;
|
||||
if (Math.abs(zoomDelta) <= ZOOM_SNAP_EPSILON) {
|
||||
zoom = targetZoom;
|
||||
} else {
|
||||
zoom += zoomDelta * ZOOM_SMOOTHING;
|
||||
}
|
||||
|
||||
applyZoomFocusPan();
|
||||
applyMapTransform();
|
||||
|
||||
if (zoom !== targetZoom) {
|
||||
zoomRafId = requestAnimationFrame(tickZoomAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
function startZoomAnimation() {
|
||||
stopPanAnimation();
|
||||
if (zoomRafId === 0) {
|
||||
zoomRafId = requestAnimationFrame(tickZoomAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
function tickPanAnimation() {
|
||||
panRafId = 0;
|
||||
var keepGoing = false;
|
||||
|
||||
if (isDragging) {
|
||||
var dragDx = targetPanX - panX;
|
||||
var dragDy = targetPanY - panY;
|
||||
if (Math.abs(dragDx) <= PAN_SNAP_EPSILON && Math.abs(dragDy) <= PAN_SNAP_EPSILON) {
|
||||
panX = targetPanX;
|
||||
panY = targetPanY;
|
||||
} else {
|
||||
panX += dragDx * PAN_SMOOTHING;
|
||||
panY += dragDy * PAN_SMOOTHING;
|
||||
keepGoing = true;
|
||||
}
|
||||
clampPan();
|
||||
} else {
|
||||
var nextX = panX + velocityX;
|
||||
var nextY = panY + velocityY;
|
||||
var clamped = clampPanValue(nextX, nextY);
|
||||
panX = clamped.x;
|
||||
panY = clamped.y;
|
||||
targetPanX = panX;
|
||||
targetPanY = panY;
|
||||
|
||||
if (panX !== nextX) {
|
||||
velocityX = 0;
|
||||
}
|
||||
if (panY !== nextY) {
|
||||
velocityY = 0;
|
||||
}
|
||||
|
||||
velocityX *= PAN_INERTIA_FRICTION;
|
||||
velocityY *= PAN_INERTIA_FRICTION;
|
||||
|
||||
if (
|
||||
Math.abs(velocityX) > PAN_INERTIA_MIN_SPEED ||
|
||||
Math.abs(velocityY) > PAN_INERTIA_MIN_SPEED
|
||||
) {
|
||||
keepGoing = true;
|
||||
} else {
|
||||
velocityX = 0;
|
||||
velocityY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
applyMapTransform();
|
||||
|
||||
if (keepGoing) {
|
||||
panRafId = requestAnimationFrame(tickPanAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
function startPanAnimation() {
|
||||
if (panRafId === 0) {
|
||||
panRafId = requestAnimationFrame(tickPanAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
mapWorld.addEventListener(
|
||||
@@ -1184,15 +1337,22 @@
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var delta = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom + delta));
|
||||
if (zoom === MIN_ZOOM) {
|
||||
panX = 0;
|
||||
panY = 0;
|
||||
} else {
|
||||
clampPan();
|
||||
|
||||
var nextTarget = Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min(MAX_ZOOM, targetZoom + (event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP))
|
||||
);
|
||||
if (nextTarget === targetZoom && zoom === targetZoom) {
|
||||
return;
|
||||
}
|
||||
applyMapTransform();
|
||||
|
||||
// Anchor on the map point currently under the cursor.
|
||||
zoomFocusScreenX = event.clientX - window.innerWidth / 2;
|
||||
zoomFocusScreenY = event.clientY - window.innerHeight / 2;
|
||||
zoomFocusWorldX = (zoomFocusScreenX - panX) / zoom;
|
||||
zoomFocusWorldY = (zoomFocusScreenY - panY) / zoom;
|
||||
targetZoom = nextTarget;
|
||||
startZoomAnimation();
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
@@ -1201,27 +1361,62 @@
|
||||
if (viewMode !== "map" || zoom <= MIN_ZOOM) {
|
||||
return;
|
||||
}
|
||||
stopZoomAnimation();
|
||||
isDragging = true;
|
||||
targetPanX = panX;
|
||||
targetPanY = panY;
|
||||
velocityX = 0;
|
||||
velocityY = 0;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
lastMoveTime = performance.now();
|
||||
pauseScreen.classList.add("is-panning");
|
||||
startPanAnimation();
|
||||
});
|
||||
|
||||
window.addEventListener("mousemove", function (event) {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
panX += event.clientX - lastX;
|
||||
panY += event.clientY - lastY;
|
||||
|
||||
var dx = event.clientX - lastX;
|
||||
var dy = event.clientY - lastY;
|
||||
var now = performance.now();
|
||||
|
||||
targetPanX += dx;
|
||||
targetPanY += dy;
|
||||
clampTargetPan();
|
||||
|
||||
// Blend recent movement into release velocity for inertia.
|
||||
velocityX = velocityX * 0.55 + dx * 0.45;
|
||||
velocityY = velocityY * 0.55 + dy * 0.45;
|
||||
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
clampPan();
|
||||
applyMapTransform();
|
||||
lastMoveTime = now;
|
||||
startPanAnimation();
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", function () {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
pauseScreen.classList.remove("is-panning");
|
||||
|
||||
// If the pointer stopped before release, do not coast.
|
||||
if (performance.now() - lastMoveTime > PAN_VELOCITY_IDLE_MS) {
|
||||
velocityX = 0;
|
||||
velocityY = 0;
|
||||
}
|
||||
|
||||
// Fold any remaining ease-catchup into the coast.
|
||||
velocityX += (targetPanX - panX) * 0.2;
|
||||
velocityY += (targetPanY - panY) * 0.2;
|
||||
targetPanX = panX;
|
||||
targetPanY = panY;
|
||||
startPanAnimation();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user