import { events } from "../core/events.ts";
import { COLUMNS } from "../core/board.js";
import { game, STATE } from "../core/game.ts";
import { HIDES_ITEMS, PLAYER } from "../core/flags.ts";
const POPUP_DURATION_MS = 2000;
/**
* Cell popup messages — mirrors the Java "popup menu" feature.
*
* When a non-modal message fires, a small label appears above the relevant
* cell on the board and fades out after 4 seconds.
*/
/**
* Look up the `.cell` span at (x, y). Cannot index `boardEl.children`
* directly — `#board` also contains non-cell children (e.g.
* `#loading-overlay`), which would throw off a positional index.
*/
function _cellEl(boardEl, x, y) {
return boardEl.querySelectorAll(".cell")[y * COLUMNS + x];
}
function _syncBoardScale(boardEl) {
const w = boardEl.getBoundingClientRect().width;
if (w > 0) {
document.documentElement.style.setProperty(
"--board-cell-w",
`${w / COLUMNS}px`,
);
}
}
export function initPopupMessages() {
const boardEl = document.getElementById("board");
const popupLayer = document.getElementById("popup-layer");
if (!boardEl || !popupLayer) return;
_syncBoardScale(boardEl);
const ro = new ResizeObserver(() => _syncBoardScale(boardEl));
ro.observe(boardEl);
events.onMessage((cell, text) => {
if (!text || !cell) return;
_showFadePopup(boardEl, popupLayer, text, cell.x, cell.y);
});
events.onHandleInventoryMessaging(() => {
const cell = game.board?.getCurrentCell();
if (!cell) return;
const text = _buildItemListText(cell);
// Only remove item-list popups, not terrain/trigger message popups
for (const el of popupLayer.querySelectorAll(
".cell-popup[data-item-list]",
)) {
el.remove();
}
if (text) {
_showFadePopup(boardEl, popupLayer, text, cell.x, cell.y, {
isItemList: true,
});
}
});
events.onModalMessage((msg) => {
// Win dialogs (.html) are handled by dialogs.js — skip those here
if (!msg || msg.endsWith(".html")) return;
const cell = game.board?.getCurrentCell();
if (!cell) return;
_showModalPopup(boardEl, popupLayer, msg, cell.x, cell.y);
});
}
/**
* Show a modal (blocking) positional popup. Freezes the game until ESC/Enter.
* Positioning mirrors Java's CellMessageModalPanel.center(): starts right of cell,
* then clamps to viewport bounds.
*/
function _showModalPopup(boardEl, popupLayer, text, cellX, cellY) {
const cellEl = _cellEl(boardEl, cellX, cellY);
if (!cellEl) return;
const popup = document.createElement("div");
popup.className = "cell-popup-modal";
popup.innerHTML = text;
const hint = document.createElement("span");
hint.className = "popup-dismiss-hint";
hint.textContent = "ESC/ENTER to continue…";
popup.appendChild(hint);
// Measure before positioning
popup.style.visibility = "hidden";
popup.style.left = "0";
popup.style.top = "0";
popupLayer.appendChild(popup);
const cellRect = cellEl.getBoundingClientRect();
const layerRect = popupLayer.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = cellRect.right + 6;
let top = cellRect.top;
// If too far left, move to left:5 and shift above the cell
if (left < 5) {
left = 5;
top -= popupRect.height;
}
// If extends past right edge, shift left
if (left + popupRect.width > vw) {
left -= left + popupRect.width - vw + 5;
}
// If extends past bottom, shift up
if (top + popupRect.height > vh) {
top -= top + popupRect.height - vh + 5;
}
popup.style.left = `${left - layerRect.left}px`;
popup.style.top = `${top - layerRect.top}px`;
popup.style.visibility = "";
// Freeze the game while the popup is shown
const prevState = game.state;
game.state = STATE.ANIMATING;
function dismiss(e) {
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
popup.remove();
game.state = prevState;
document.removeEventListener("keydown", dismiss, { capture: true });
}
}
document.addEventListener("keydown", dismiss, { capture: true });
}
/**
* Show a fade-out popup anchored near a cell.
* Uses an 8-position strategy (matching Java's MessageManager) to find the
* first position that stays in the viewport, avoids other popups, and avoids
* the player's adjacent cells.
* Returns the popup element so callers can remove it early if needed.
* @param {HTMLElement} boardEl
* @param {HTMLElement} popupLayer
* @param {string} text
* @param {number} cellX
* @param {number} cellY
* @param {{isItemList: boolean}} [options]
* @returns {HTMLElement|null}
*/
function _showFadePopup(
boardEl,
popupLayer,
text,
cellX,
cellY,
{ isItemList = false } = {},
) {
const cellEl = _cellEl(boardEl, cellX, cellY);
if (!cellEl) return null;
const popup = document.createElement("div");
popup.className = "cell-popup";
if (isItemList) popup.dataset.itemList = "true";
popup.innerHTML = text;
// Append hidden so we can measure its rendered size
popup.style.visibility = "hidden";
popup.style.left = "0";
popup.style.top = "0";
popupLayer.appendChild(popup);
_positionPopup(popup, cellEl, popupLayer, boardEl, cellX, cellY);
popup.style.visibility = "";
setTimeout(() => popup.remove(), POPUP_DURATION_MS);
return popup;
}
/**
* Position a popup using the 8-position strategy from Java's MessageManager.
* Tries positions in the same order as Java, picks the first valid one.
*/
function _positionPopup(popup, cellEl, popupLayer, boardEl, cellX, cellY) {
const cellRect = cellEl.getBoundingClientRect();
const layerRect = popupLayer.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const cL = cellRect.left;
const cT = cellRect.top;
const cR = cellRect.right;
const cB = cellRect.bottom;
const pH = popupRect.height;
const pW = popupRect.width;
// 8 positions in the same priority order as Java's POSITIONS array
const candidates = [
{ left: cR + 6, top: cT }, // RightDown
{ left: cR + 6, top: cB - pH + 3 }, // RightUp
{ left: cL - pW - 1, top: cT }, // LeftDown
{ left: cL - pW - 1, top: cB - pH + 3 }, // LeftUp
{ left: cL + 3, top: cB + 7 }, // BelowRight
{ left: cL - pW - 3, top: cB + 7 }, // BelowLeft
{ left: cL - pW - 3, top: cT - pH - 3 }, // AboveLeft
{ left: cL, top: cT - pH - 3 }, // AboveRight
];
// Existing popup rects for overlap checking
const peerRects = [
...popupLayer.querySelectorAll(".cell-popup, .cell-popup-modal"),
]
.filter((el) => el !== popup)
.map((el) => el.getBoundingClientRect());
// Player-adjacent cell rects (only checked when message is not from player's cell)
const playerCell = game.board?.getCurrentCell();
const adjacentRects =
playerCell && (cellX !== playerCell.x || cellY !== playerCell.y)
? _getPlayerAdjacentRects(boardEl, playerCell.x, playerCell.y)
: [];
let chosenLeft = candidates[0].left;
let chosenTop = candidates[0].top;
let found = false;
for (const { left, top } of candidates) {
const right = left + pW;
const bottom = top + pH;
// Viewport bounds (mirrors Java: left > 5, bottom < window height)
if (left < 5 || right > vw || top < 0 || bottom > vh) continue;
const r = { left, top, right, bottom };
// No overlap with peer popups
if (peerRects.some((p) => _rectsOverlap(r, p))) continue;
// No overlap with player's adjacent cells
if (adjacentRects.some((a) => _rectsOverlap(r, a))) continue;
chosenLeft = left;
chosenTop = top;
found = true;
break;
}
// Fallback: use last candidate; hide any peer popups that overlap us
if (!found) {
const last = candidates[candidates.length - 1];
chosenLeft = last.left;
chosenTop = last.top;
const r = {
left: chosenLeft,
top: chosenTop,
right: chosenLeft + pW,
bottom: chosenTop + pH,
};
for (const el of popupLayer.querySelectorAll(
".cell-popup, .cell-popup-modal",
)) {
if (el !== popup && _rectsOverlap(r, el.getBoundingClientRect())) {
el.remove();
}
}
}
popup.style.left = `${chosenLeft - layerRect.left}px`;
popup.style.top = `${chosenTop - layerRect.top}px`;
}
/** AABB overlap test — mirrors Java's CellMessagePanel.overlaps(). */
function _rectsOverlap(a, b) {
return !(
a.bottom < b.top ||
a.top > b.bottom ||
a.right < b.left ||
a.left > b.right
);
}
/**
* Returns viewport DOMRects for the 8 cells adjacent to the player.
* Mirrors Java's current.getAdjacentCells(null) check in positionDoesNotOverlap().
*/
function _getPlayerAdjacentRects(boardEl, px, py) {
const rects = [];
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue;
const ax = px + dx;
const ay = py + dy;
if (ax < 0 || ay < 0) continue;
const el = _cellEl(boardEl, ax, ay);
if (el) rects.push(el.getBoundingClientRect());
}
}
return rects;
}
/**
* Build the HTML text for the item-list popup, mirroring Java's
* MessageManager.displayItemsAtCurrentCell().
* Returns null if the cell has no items.
* @param {Cell} cell
* @returns {string|null}
*/
function _buildItemListText(cell) {
if (cell.isBagEmpty) return null;
// Group items by name (matches Java Bag entry list order)
const groups = [];
const groupMap = new Map();
for (const item of cell.items) {
if (groupMap.has(item.name)) {
groupMap.get(item.name).count++;
} else {
const entry = { item, count: 1 };
groupMap.set(item.name, entry);
groups.push(entry);
}
}
const header =
groups.length > 10
? "Use 'r' to rotate items in list"
: "Use 'p' or # to pick up item";
const len = Math.min(groups.length, 10);
const lines = [header];
for (let i = 0; i < len; i++) {
const j = i < 9 ? i + 1 : 0; // 1–9 then 0 for the 10th
const { item, count } = groups[i];
const sym = item.symbol;
const fg = sym?.color?.hex ?? "inherit";
const bg = sym?.background?.hex ?? "transparent";
const countStr = count > 1 ? ` (x${count})` : "";
lines.push(
`${j}. <span style="color:${fg};background:${bg}">${sym?.entity ?? "?"}</span> ${item.name}${countStr}`,
);
}
return lines.join("<br>");
}
// ── Cell hover info popup ─────────────────────────────────────────────────────
/**
* Show a brief legend popup when the player hovers over a cell.
* Mirrors the Java CellInfoPanel behaviour: 600 ms delay to show,
* auto-hides after 2100 ms or on mouseout/mousedown.
*/
export function initCellHoverPopup() {
const boardEl = document.getElementById("board");
const popupLayer = document.getElementById("popup-layer");
if (!boardEl || !popupLayer) return;
let showTimer = null;
let hideTimer = null;
let lastCell = null;
let popup = null;
function hidePopup() {
if (popup) {
popup.remove();
popup = null;
}
}
function showPopup(cell) {
hidePopup();
const text = _buildCellHoverText(cell);
if (!text) return;
popup = _showFadePopup(boardEl, popupLayer, text, cell.x, cell.y);
}
boardEl.addEventListener("mouseover", (e) => {
if (game.state !== STATE.PLAYING) return;
if (document.querySelector("dialog[open]")) return;
const span =
e.target.closest?.(".cell") ??
(e.target.classList.contains("cell") ? e.target : null);
if (!span) return;
const x = parseInt(span.dataset.x, 10);
const y = parseInt(span.dataset.y, 10);
if (isNaN(x) || isNaN(y)) return;
const cell = game.board?.cells?.[x]?.[y];
if (!cell) return;
// Same cell — don't reset the timer (handles animated-cell mouseover storms)
if (cell === lastCell) return;
clearTimeout(showTimer);
clearTimeout(hideTimer);
hidePopup();
lastCell = cell;
showTimer = setTimeout(() => {
showPopup(cell);
hideTimer = setTimeout(() => {
hidePopup();
lastCell = null;
}, 2100);
}, 600);
});
boardEl.addEventListener("mouseout", (e) => {
// Only clear when the mouse leaves the board entirely
if (!boardEl.contains(e.relatedTarget)) {
clearTimeout(showTimer);
clearTimeout(hideTimer);
hidePopup();
lastCell = null;
}
});
boardEl.addEventListener("mousedown", () => {
clearTimeout(showTimer);
clearTimeout(hideTimer);
hidePopup();
lastCell = null;
});
}
/**
* Build the HTML legend for a hovered cell: terrain, then agent (if any),
* then the topmost item (unless the terrain has HIDES_ITEMS).
* Mirrors Java's CellInfoPanel.renderCellInfo().
* @param {Cell} cell
* @returns {string|null}
*/
function _buildCellHoverText(cell) {
const terrain = cell.getApparentTerrain?.() ?? cell.terrain;
if (!terrain) return null;
const outside = game.board?.outside ?? false;
const lines = [_renderPieceLabel(terrain, outside)];
if (cell.agent && !cell.agent.is(PLAYER)) {
lines.push(_renderPieceLabel(cell.agent, outside));
}
if (!cell.isBagEmpty && terrain.not(HIDES_ITEMS)) {
const topItem = cell.items[cell.items.length - 1];
const count = cell.items.filter((i) => i.name === topItem.name).length;
const countStr = count > 1 ? ` (x${count})` : "";
lines.push(_renderPieceLabel(topItem, outside) + countStr);
}
return lines.join("<br>");
}
/**
* Render a colored symbol + name label for one piece.
* @param {Piece} piece
* @param {boolean} outside
* @returns {string}
*/
function _renderPieceLabel(piece, outside) {
const sym = piece.symbol;
const fg = sym?.getColor?.(outside)?.hex ?? sym?.color?.hex ?? "inherit";
const bg =
sym?.getBackground?.(outside)?.hex ?? sym?.background?.hex ?? "transparent";
return `<span style="color:${fg};background:${bg}">${sym?.entity ?? "?"}</span> ${piece.name}`;
}