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:
2026-07-04 23:27:02 +12:00
parent 0a07c502c1
commit 1af6bbd3d2
6 changed files with 506 additions and 20 deletions
@@ -100,7 +100,7 @@
aria-selected="false"
aria-controls="gameSettingsPane"
>
GAME SETTINGS
FALLOUT 4
</button>
<button
type="button"
@@ -111,7 +111,7 @@
aria-selected="true"
aria-controls="multiplayerSettingsPane"
>
MULTIPLAYER SETTINGS
MULTIPLAYER
</button>
</div>
+212 -17
View File
@@ -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();
});
}
@@ -79,7 +79,8 @@ body {
transform: translate(var(--map-pan-x, 0px), var(--map-pan-y, 0px))
scale(var(--map-zoom, 1));
transform-origin: center center;
transition: transform 160ms ease-out, filter 200ms ease-out;
/* Transform is driven by JS (smooth zoom / drag pan); only filter animates. */
transition: filter 200ms ease-out;
cursor: grab;
pointer-events: none;
}