Files
Commonwealth-Online-Public/ui/views/CommonwealthOnline/pause-menu/script.js
T
andrew 38b6011de8 Pause menu: shared map blur, PC back, Xbox glyphs
Wrap map art and markers in a new .map-world so blur, pan and zoom animate together; add a PC-facing top-left ESC BACK button and a map-mode prompt bar using Xbox PNG glyphs. Implement JS glyph-tinting, map-mode enter/exit logic, and pan/zoom handlers; update styles for .map-world, markers, prompt bars, and the PC back button. Add Xbox icon assets and update deploy/stage scripts to copy them. Update changelog and dev-log entries to document the changes.
2026-07-04 17:41:29 +12:00

442 lines
14 KiB
JavaScript

(function () {
"use strict";
// ── Compute UI scale (fixes browser incompatibility with nested calc/min/clamp in custom props) ──
// Some browsers don't resolve nested calc()/min()/clamp() inside custom properties to a number,
// leaving them as formula strings. That breaks calc(Npx * var(--co-ui-scale)) throughout the sheet.
// Instead, we compute the scale in JS and set it as a numeric value, which works everywhere.
function updateScale() {
var MIN_SCALE = 0.5;
var MAX_SCALE = 1.6;
var BASELINE_WIDTH = 1920;
var BASELINE_HEIGHT = 1080;
var scale = Math.min(
window.innerWidth / BASELINE_WIDTH,
window.innerHeight / BASELINE_HEIGHT
);
scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
document.documentElement.style.setProperty("--co-ui-scale", String(scale));
}
updateScale();
window.addEventListener("resize", updateScale);
// ── Tint Xbox prompt glyphs to pause-menu amber (same approach as browser) ──
// White pixels become --co-amber; black stays black; transparent stays transparent.
var GLYPH_WHITE_THRESHOLD = 200;
var GLYPH_BLACK_THRESHOLD = 40;
var glyphTintCache = {};
function getAmberTint() {
var style = getComputedStyle(document.documentElement);
var amber = style.getPropertyValue("--co-amber").trim() || "#c9a84a";
var hex = amber.replace("#", "");
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
return {
r: parseInt(hex.slice(0, 2), 16) || 201,
g: parseInt(hex.slice(2, 4), 16) || 168,
b: parseInt(hex.slice(4, 6), 16) || 74
};
}
function classifyGlyphPixel(r, g, b) {
if (r >= GLYPH_WHITE_THRESHOLD && g >= GLYPH_WHITE_THRESHOLD && b >= GLYPH_WHITE_THRESHOLD) {
return "white";
}
if (r <= GLYPH_BLACK_THRESHOLD && g <= GLYPH_BLACK_THRESHOLD && b <= GLYPH_BLACK_THRESHOLD) {
return "black";
}
return "edge";
}
function tintGlyphImageData(imageData, color) {
var data = imageData.data;
for (var i = 0; i < data.length; i += 4) {
if (data[i + 3] < 4) {
data[i + 3] = 0;
continue;
}
var kind = classifyGlyphPixel(data[i], data[i + 1], data[i + 2]);
if (kind === "white") {
data[i] = color.r;
data[i + 1] = color.g;
data[i + 2] = color.b;
} else if (kind === "black") {
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
} else {
var lum = (data[i] + data[i + 1] + data[i + 2]) / (3 * 255);
data[i] = Math.round(lum * color.r);
data[i + 1] = Math.round(lum * color.g);
data[i + 2] = Math.round(lum * color.b);
}
}
return imageData;
}
function tintGlyphImage(url, color, done) {
var key = url + "|" + color.r + "," + color.g + "," + color.b;
if (glyphTintCache[key]) {
done(glyphTintCache[key]);
return;
}
var img = new Image();
img.onload = function () {
var canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
var ctx = canvas.getContext("2d");
if (!ctx) {
done(null);
return;
}
ctx.drawImage(img, 0, 0);
try {
ctx.putImageData(
tintGlyphImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), color),
0,
0
);
} catch (err) {
// file:// or cross-origin can taint the canvas; keep the original white glyph.
done(null);
return;
}
var dataUrl = canvas.toDataURL("image/png");
glyphTintCache[key] = dataUrl;
done(dataUrl);
};
img.onerror = function () {
done(null);
};
img.src = url;
}
function hydratePromptGlyphs() {
var color = getAmberTint();
var imgs = document.querySelectorAll(".prompt__glyph-img[data-glyph-src]");
for (var i = 0; i < imgs.length; i++) {
(function (img) {
var src = img.getAttribute("data-glyph-src");
if (!src) {
return;
}
tintGlyphImage(src, color, function (dataUrl) {
if (dataUrl) {
img.src = dataUrl;
}
});
})(imgs[i]);
}
}
hydratePromptGlyphs();
var menu = document.getElementById("menu");
if (!menu) {
return;
}
var items = Array.prototype.slice.call(menu.querySelectorAll(".menu-item"));
var selectedIndex = -1; // No default selection
function setSelected(index) {
if (index >= items.length) {
return;
}
selectedIndex = index < 0 ? -1 : index;
items.forEach(function (item, i) {
var isSelected = i === selectedIndex;
item.classList.toggle("is-selected", isSelected);
item.setAttribute("aria-selected", isSelected ? "true" : "false");
item.tabIndex = isSelected ? 0 : -1;
});
}
function activateSelected() {
var item = items[selectedIndex];
if (!item) {
return;
}
var action = item.getAttribute("data-action");
console.log("[CO Pause Menu] Selected:", action);
if (action === "resume") {
console.log("[CO Pause Menu] Resume game (concept)");
setSelected(-1);
} else if (action === "map") {
enterMapMode();
} else if (action === "quit") {
console.log("[CO Pause Menu] Quit to main menu (concept)");
setSelected(-1);
}
}
function moveSelection(delta) {
var next = selectedIndex + delta;
if (next < 0) {
next = items.length - 1;
} else if (next >= items.length) {
next = 0;
}
setSelected(next);
}
items.forEach(function (item, index) {
item.addEventListener("click", function () {
setSelected(index);
activateSelected();
});
});
document.addEventListener("keydown", function (event) {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
moveSelection(-1);
break;
case "ArrowDown":
event.preventDefault();
moveSelection(1);
break;
case "Enter":
case " ":
event.preventDefault();
activateSelected();
break;
case "Escape":
event.preventDefault();
exitMapMode();
break;
default:
break;
}
});
if (selectedIndex >= 0) {
setSelected(selectedIndex);
}
// ── Map mode state management ───────────────────────────────────
var isMapMode = false;
function enterMapMode() {
isMapMode = true;
console.log("[CO Pause Menu] Entering map mode");
var leftPanel = document.querySelector(".left-panel");
var mapWorld = document.getElementById("mapWorld");
var pausePromptBar = document.getElementById("pausePromptBar");
var mapPromptBar = document.getElementById("mapPromptBar");
var mapBackButton = document.getElementById("mapBackButton");
var serverPanel = document.querySelector(".server-panel");
setSelected(-1);
if (leftPanel) leftPanel.classList.add("is-hidden");
if (serverPanel) serverPanel.style.display = "none";
if (mapWorld) {
mapWorld.classList.remove("is-blurred");
mapWorld.classList.add("is-interactive");
}
if (pausePromptBar) pausePromptBar.hidden = true;
if (mapPromptBar) mapPromptBar.hidden = false;
if (mapBackButton) {
mapBackButton.classList.add("is-visible");
mapBackButton.setAttribute("aria-hidden", "false");
}
}
function exitMapMode() {
if (!isMapMode) {
return;
}
isMapMode = false;
console.log("[CO Pause Menu] Exiting map mode");
var leftPanel = document.querySelector(".left-panel");
var mapWorld = document.getElementById("mapWorld");
var pausePromptBar = document.getElementById("pausePromptBar");
var mapPromptBar = document.getElementById("mapPromptBar");
var mapBackButton = document.getElementById("mapBackButton");
var serverPanel = document.querySelector(".server-panel");
if (mapBackButton) {
mapBackButton.classList.remove("is-visible");
mapBackButton.setAttribute("aria-hidden", "true");
}
if (leftPanel) leftPanel.classList.remove("is-hidden");
if (serverPanel) serverPanel.style.display = "block";
if (mapWorld) {
mapWorld.classList.add("is-blurred");
mapWorld.classList.remove("is-interactive");
}
if (pausePromptBar) pausePromptBar.hidden = false;
if (mapPromptBar) mapPromptBar.hidden = true;
setSelected(-1);
}
// ── Blur map initially ────────────────────────────────────────
var initialMapWorld = document.getElementById("mapWorld");
if (initialMapWorld) {
initialMapWorld.classList.add("is-blurred");
}
// ── Map mode PC back button handler ───────────────────────────
var mapBackButton = document.getElementById("mapBackButton");
if (mapBackButton) {
mapBackButton.addEventListener("click", function (event) {
event.stopPropagation();
exitMapMode();
});
}
// ── Map pan/zoom (concept preview of future Fallout 76-style navigation) ──
// Scroll to zoom, click-drag to pan once zoomed in. Real implementation
// will eventually read controller right-stick input via F4SE instead.
var pauseScreen = document.getElementById("pauseScreen");
var mapWorld = document.getElementById("mapWorld");
if (pauseScreen && mapWorld) {
var MAX_ZOOM = 4;
var ZOOM_STEP = 0.18;
var zoom = 1;
var MIN_ZOOM = 1; // Will be recalculated after map loads
var panX = 0;
var panY = 0;
var isDragging = false;
var lastX = 0;
var lastY = 0;
// Load the map image to measure its intrinsic dimensions, so we can
// calculate the actual rendered bounds with background-size: contain
// and set the dynamic MIN_ZOOM to prevent seeing past edges.
var mapImg = new Image();
var mapNaturalWidth = 1024;
var mapNaturalHeight = 768;
var mapDisplayWidth = 1024;
var mapDisplayHeight = 768;
mapImg.onload = function () {
mapNaturalWidth = mapImg.naturalWidth;
mapNaturalHeight = mapImg.naturalHeight;
updateMapDisplayBounds();
};
mapImg.src = "assets/map.png";
function updateMapDisplayBounds() {
// Given the map's intrinsic size and the viewport size, calculate where
// the map actually renders with background-size: contain.
var viewportWidth = window.innerWidth;
var viewportHeight = window.innerHeight;
var mapAspect = mapNaturalWidth / mapNaturalHeight;
var viewportAspect = viewportWidth / viewportHeight;
if (mapAspect > viewportAspect) {
// Map is wider: fit by width
mapDisplayWidth = viewportWidth;
mapDisplayHeight = viewportWidth / mapAspect;
} else {
// Map is taller: fit by height
mapDisplayHeight = viewportHeight;
mapDisplayWidth = viewportHeight * mapAspect;
}
// Calculate dynamic MIN_ZOOM so that even at full zoom-out, the map
// fills the viewport and you can't see past the edges.
// At zoom level Z, the map renders at (mapDisplayWidth * Z) x (mapDisplayHeight * Z).
// To fill the viewport: Z >= max(viewportWidth / mapDisplayWidth, viewportHeight / mapDisplayHeight)
MIN_ZOOM = Math.max(
viewportWidth / mapDisplayWidth,
viewportHeight / mapDisplayHeight
);
// If already zoomed in below the new MIN_ZOOM (shouldn't happen on init),
// snap back to MIN_ZOOM.
if (zoom < MIN_ZOOM) {
zoom = MIN_ZOOM;
panX = 0;
panY = 0;
applyMapTransform();
}
}
updateMapDisplayBounds();
window.addEventListener("resize", updateMapDisplayBounds);
function applyMapTransform() {
pauseScreen.style.setProperty("--map-zoom", String(zoom));
pauseScreen.style.setProperty("--map-pan-x", panX + "px");
pauseScreen.style.setProperty("--map-pan-y", panY + "px");
}
function clampPan() {
// At the current zoom level, the map renders at (mapDisplayWidth * zoom) x (mapDisplayHeight * zoom).
// The viewport is window.innerWidth x window.innerHeight. Pan offsets are clamped so that the
// map's edges never go beyond the viewport edges.
var zoomedMapWidth = mapDisplayWidth * zoom;
var zoomedMapHeight = mapDisplayHeight * zoom;
var viewportWidth = window.innerWidth;
var viewportHeight = window.innerHeight;
// If the zoomed map is smaller than the viewport, no panning is allowed.
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));
}
mapWorld.addEventListener(
"wheel",
function (event) {
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();
}
applyMapTransform();
},
{ passive: false }
);
mapWorld.addEventListener("mousedown", function (event) {
if (zoom <= MIN_ZOOM) {
return;
}
isDragging = true;
lastX = event.clientX;
lastY = event.clientY;
pauseScreen.classList.add("is-panning");
});
window.addEventListener("mousemove", function (event) {
if (!isDragging) {
return;
}
panX += event.clientX - lastX;
panY += event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyMapTransform();
});
window.addEventListener("mouseup", function () {
isDragging = false;
pauseScreen.classList.remove("is-panning");
});
}
})();