Files
noname-shuying/mengsan/ui/hud/index.js
T
2026-10-23 00:38:05 +08:00

280 lines
19 KiB
JavaScript

import { collectCombatStatuses } from './status-model.js';
import { createOverlayLayout } from './overlays.js';
// Native cards, players and controls stay under their original parents.
export function mountBattleHUD(player, battle, env) {
const { ui, get, _status, document, window, assetBase } = env;
const arena = ui.arena;
if (!arena || !ui.me || !ui.control || !battle.session.active) throw new Error('梦三 HUD 缺少原生界面容器');
const handRoot = ui.me, controlRoot = ui.control;
const overlays = createOverlayLayout(arena, handRoot, ui, player);
const restores = [], styled = new WeakMap(), hiddenMarks = new Set();
let disposed = false, frozen = false, frame = null, dialog = null, previousFocus = null, resize, observer;
const ownClass = (node, name) => {
if (!node || node.classList.contains(name)) return;
node.classList.add(name); restores.push(() => node.classList.remove(name));
};
const style = (node, key, value) => {
let values = styled.get(node);
if (!values) styled.set(node, values = new Map());
if (!values.has(key)) {
const old = node.style.getPropertyValue(key), priority = node.style.getPropertyPriority(key);
const record = { last: null }; values.set(key, record);
restores.push(() => {
if (node.style.getPropertyValue(key) !== record.last) return;
if (old) node.style.setProperty(key, old, priority); else node.style.removeProperty(key);
});
}
const record = values.get(key);
if (record.requested !== value || node.style.getPropertyValue(key) !== record.last) {
node.style.setProperty(key, value, 'important');
record.requested = value;
// CSSOM rounds long decimal pixel values; compare the actual stored value on release.
record.last = node.style.getPropertyValue(key);
}
};
const text = (node, value) => { if (node.textContent !== String(value)) node.textContent = String(value); };
const create = (tag, className, parent, label) => {
const node = document.createElement(tag); node.className = className;
if (label != null) node.textContent = label;
if (tag === 'button') node.type = 'button';
parent?.appendChild(node); return node;
};
const plain = value => String(value || '').replace(/<[^>]*>/g, '');
const nameOf = target => plain(get.translation(target.name || target.playerid || '角色'));
const root = create('section', 'mengsan-hud-shuying', arena);
root.setAttribute('aria-label', '梦三战斗操作区');
const info = create('div', 'mengsan-hud-info-shuying', root);
const name = create('strong', 'mengsan-hud-name-shuying', info, nameOf(player));
const hp = create('span', 'mengsan-hud-hp-shuying', info);
const healthTrack = create('div', 'mengsan-hud-health-track-shuying', info);
healthTrack.setAttribute('aria-hidden', 'true');
const healthFill = create('span', '', healthTrack);
const block = create('span', 'mengsan-hud-block-shuying', info), energy = create('strong', 'mengsan-hud-energy-shuying', info);
const center = create('div', 'mengsan-hud-center-shuying', root);
const statuses = create('div', 'mengsan-hud-statuses-shuying', center);
statuses.setAttribute('aria-label', '玩家当前状态');
const link = create('link', '', null); link.rel = 'stylesheet'; link.href = `${assetBase}/ui/hud/style.css`;
const closeDialog = () => {
if (!dialog) return;
const current = dialog; dialog = null;
if (current.open) current.close(); current.remove();
if (previousFocus?.isConnected) previousFocus.focus();
};
const openDetails = target => {
if (disposed || !battle.session.active) return;
closeDialog(); previousFocus = document.activeElement;
dialog = create('dialog', 'mengsan-hud-dialog-shuying', document.body);
const current = dialog;
create('h2', '', dialog, `${nameOf(target)} · 当前状态`);
const close = create('button', 'mengsan-hud-dialog-close-shuying', dialog, '关闭'); close.addEventListener('click', closeDialog);
const entries = collectCombatStatuses(target, battle);
for (const entry of entries) {
const row = create('section', 'mengsan-hud-detail-row-shuying', dialog);
create('strong', '', row, `${entry.name}${entry.value == null ? '' : ` ${entry.value}${entry.unit ? ` ${entry.unit}` : ''}`}`);
create('p', '', row, entry.detail);
}
if (!entries.length) create('p', '', dialog, '暂无已登记状态。');
// Unknown/native marks retain their engine intro and are never hidden by the HUD.
dialog.addEventListener('cancel', event => { event.preventDefault(); closeDialog(); });
dialog.addEventListener('close', () => { if (dialog === current) closeDialog(); });
for (const type of ['pointerdown', 'pointerup', 'click', 'touchstart', 'touchend', 'keydown']) dialog.addEventListener(type, event => event.stopPropagation());
dialog.showModal(); close.focus();
};
let statusSignature = null;
function renderStatuses() {
const entries = collectCombatStatuses(player, battle);
const columns = layout === 'narrow' ? 1 : 2;
const rows = Math.max(1, Math.floor((statuses.clientHeight || 80) / 38));
const maximum = Math.max(0, columns * rows - (entries.length > columns * rows ? 1 : 0));
const signature = JSON.stringify([maximum, entries]);
if (signature !== statusSignature) {
statusSignature = signature; statuses.replaceChildren();
center.hidden = entries.length === 0;
for (const entry of entries.slice(0, maximum)) {
const button = create('button', 'mengsan-hud-status-shuying', statuses);
button.setAttribute('aria-label', `${entry.name} ${entry.value ?? ''} ${entry.unit},查看状态详情`);
if (entry.icon) {
const image = create('img', '', button); image.src = `${assetBase}/assets/status/${entry.icon}`; image.alt = '';
image.addEventListener('error', () => image.remove(), { once: true });
}
create('span', '', button, entry.name);
if (entry.value != null) create('b', '', button, entry.value);
button.addEventListener('click', () => openDetails(player));
}
if (entries.length > maximum) {
const more = create('button', 'mengsan-hud-status-shuying', statuses, `+${entries.length - maximum}`);
more.setAttribute('aria-label', '查看全部状态'); more.addEventListener('click', () => openDetails(player));
}
}
const wanted = new Set();
for (const target of [player]) {
for (const entry of collectCombatStatuses(target, battle)) {
for (const skill of entry.markSkills) {
const mark = target.marks?.[skill]; if (mark?.classList) wanted.add(mark);
}
}
}
for (const mark of hiddenMarks) if (!wanted.has(mark)) { mark.classList.remove('mengsan-hud-represented-mark-shuying'); hiddenMarks.delete(mark); }
for (const mark of wanted) if (!mark.classList.contains('mengsan-hud-represented-mark-shuying')) { mark.classList.add('mengsan-hud-represented-mark-shuying'); hiddenMarks.add(mark); }
}
let layout = null;
const hudHeightFor = () => layout === 'short' ? 188 : layout === 'narrow' ? 288 : 274;
const oldLayout = arena.getAttribute('data-mengsan-hud-layout');
restores.push(() => {
if (arena.getAttribute('data-mengsan-hud-layout') !== layout) return;
if (oldLayout === null) arena.removeAttribute('data-mengsan-hud-layout'); else arena.setAttribute('data-mengsan-hud-layout', oldLayout);
});
function skinActor(target) {
// Decade nests these in camp/hp wraps; direct-child .hp/.name selectors miss them.
for (const key of ['campWrap', 'hpWrap', 'hp', 'identity', 'framebg', 'count', 'name', 'name2', 'nameol']) {
ownClass(target.node?.[key], 'mengsan-hud-replaced-node-shuying');
}
ownClass(target.node?.avatar, 'mengsan-hud-portrait-shuying');
ownClass(target.node?.equips, 'mengsan-hud-equips-shuying');
if (target === player) ownClass(target.node?.equips, 'mengsan-hud-self-equips-shuying');
}
function syncActors() {
const width = arena.clientWidth, height = arena.clientHeight;
const narrow = width <= 600 && height >= 480;
const short = height <= 500 && !narrow;
layout = short ? 'short' : narrow ? 'narrow' : 'wide';
if (arena.getAttribute('data-mengsan-hud-layout') !== layout) arena.setAttribute('data-mengsan-hud-layout', layout);
const hudHeight = hudHeightFor();
style(arena, '--mengsan-hud-height', `${hudHeight}px`);
const large = width >= 1000;
style(arena, '--mengsan-hud-left', short ? '216px' : narrow ? '116px' : large ? '352px' : '292px');
style(arena, '--mengsan-hud-right', short ? '132px' : narrow ? '116px' : '184px');
style(arena, '--mengsan-profile-info-left', short ? '108px' : narrow ? '22px' : large ? '200px' : '160px');
style(arena, '--mengsan-profile-width', short ? '172px' : narrow ? '76px' : large ? '200px' : '130px');
skinActor(player);
// Inline important dimensions also beat skins with #arena > .player !important rules.
const selfBottom = narrow ? 180 : 24;
const selfHeight = short || narrow ? 104 : large ? 196 : 148;
const infoTop = narrow ? hudHeight - selfBottom + 16 : hudHeight - selfBottom - selfHeight + (short ? 0 : 20);
style(arena, '--mengsan-profile-info-top', `${infoTop}px`);
for (const [key, value] of Object.entries({left: short ? '24px' : narrow ? '22px' : '40px', right:'auto', top:'auto',
bottom:`${selfBottom}px`, width:short ? '76px' : narrow ? '76px' : large ? '144px' : '104px',
height:`${selfHeight}px`, transform:'none'})) style(player, key, value);
style(arena, '--mengsan-side-dock-bottom', `${selfBottom + selfHeight + 12}px`);
style(arena, '--mengsan-relic-dock-bottom', `${selfBottom + selfHeight + (narrow ? -4 : 12)}px`);
style(arena, '--mengsan-skill-ceiling', `${Math.min(hudHeight - 36, selfBottom + selfHeight)}px`);
style(arena, '--mengsan-status-height', short ? '52px' : narrow ? '84px' : '160px');
}
function syncExternalEquipment() {
const equips = player.node?.equips;
if (!equips?.isConnected || equips.parentNode === player) return;
// An optional skin can keep the same real equipment nodes in a separate arena wrapper.
ownClass(ui.equipSolts, 'mengsan-hud-equip-host-shuying');
const host = equips.offsetParent || arena, h = host.getBoundingClientRect(), a = arena.getBoundingClientRect(), p = player.getBoundingClientRect();
const sx = h.width / host.offsetWidth || 1, sy = h.height / host.offsetHeight || sx;
const arenaScale = a.width / arena.clientWidth || 1;
const width = Math.max(0, p.width - 12 * arenaScale);
const equipHeight = Math.min(100 * arenaScale, p.height * .58);
for (const [key, value] of Object.entries({position:'absolute',left:`${(p.left + 6 * arenaScale - h.left) / sx}px`,right:'auto',bottom:'auto',
top:`${(p.bottom - 6 * arenaScale - equipHeight - h.top) / sy}px`,width:`${width / sx}px`,height:`${equipHeight / sy}px`,'max-height':`${equipHeight / sy}px`})) style(equips, key, value);
}
function placeControl(node, bottom, maxHeight) {
ownClass(node, 'mengsan-hud-external-control-shuying');
const host = node.offsetParent || arena, a = arena.getBoundingClientRect(), h = host.getBoundingClientRect();
const scale = h.width / host.offsetWidth || 1, arenaScale = a.width / arena.clientWidth || 1;
const rail = layout === 'wide' ? 142 : layout === 'short' ? 112 : 96;
for (const [key, value] of Object.entries({position:'absolute',left:'auto',top:'auto',transform:'none',
right:`${(h.right - a.right + (layout === 'wide' ? 24 : 16) * arenaScale) / scale}px`, bottom:`${(h.bottom - a.bottom + bottom * arenaScale) / scale}px`,
width:`${rail * arenaScale / scale}px`, 'max-height':`${maxHeight * arenaScale / scale}px`})) style(node, key, value);
}
function syncControls() {
// The actual action controls stay horizontal above the hand; skill references get their own rail.
const candidates = [...new Set([ui.skills, ui.skills2, ui.skills3, ui.skillControl, ui.gskillControl])]
.filter(node => node?.isConnected && node !== ui.control);
const external = candidates.filter(node => !candidates.some(other => other !== node && other.contains(node)));
const hudHeight = hudHeightFor();
const rail = layout === 'wide' ? 142 : layout === 'short' ? 112 : 96;
const actionBottom = layout === 'narrow' ? hudHeight - 48 : hudHeight + 8;
const compactActions = arena.clientWidth < 1000;
for (const [key,value] of Object.entries({position:'absolute',left:compactActions?'16px':'var(--mengsan-hud-left)',right:compactActions?'16px':'var(--mengsan-hud-right)',top:'auto',bottom:`${actionBottom}px`,width:'auto','max-height':'none',transform:'none'})) style(ui.control,key,value);
for (const group of ui.control.children) if (group.classList.contains('control')) {
if (external.includes(group)) {
ownClass(group,'mengsan-hud-native-skills-shuying');
for (const button of group.children) if (button.tagName === 'DIV') style(button, 'margin', '0px');
continue;
}
for (const [key, value] of Object.entries({position:'relative',left:'auto',right:'auto',top:'auto',bottom:'auto',transform:'none'})) style(group, key, value);
}
let bottom = 20;
const ceiling = parseFloat(arena.style.getPropertyValue('--mengsan-skill-ceiling'));
external.forEach((node, index) => {
if (node === ui.skillControl || node === ui.gskillControl) ownClass(node, 'mengsan-hud-skin-skills-shuying');
placeControl(node, bottom, Math.max(24, (ceiling - bottom - 6 * (external.length - index - 1)) / (external.length - index)));
bottom += node.offsetHeight + 6;
});
for (const node of [ui.confirm,_status.event?.endButton]) {
if (!node?.isConnected || ui.control.contains(node)) continue;
ownClass(node,'mengsan-hud-external-action-shuying');
const host=node.offsetParent||arena,a=arena.getBoundingClientRect(),h=host.getBoundingClientRect();
const scale=h.width/host.offsetWidth||1,zoom=a.width/arena.clientWidth||1;
const center=(parseFloat(arena.style.getPropertyValue('--mengsan-hud-left'))+arena.clientWidth-parseFloat(arena.style.getPropertyValue('--mengsan-hud-right')))/2;
const side=node===ui.confirm?-1:1;
for(const [key,value] of Object.entries({position:'absolute',left:`${(a.left+center*zoom-h.left)/scale+side*80}px`,right:'auto',top:'auto',bottom:`${(h.bottom-a.bottom+actionBottom*zoom)/scale}px`,width:`${Math.min(rail,140)}px`,transform:'translateX(-50%)'}))style(node,key,value);
}
}
function syncNotifications() {
const maxTop = arena.clientHeight - hudHeightFor() - 48;
// Decade positions string popups at player.top + 80, which now intersects our footer.
for (const popup of arena.querySelectorAll(':scope > .damage.skill-popup')) {
if(!popup.classList.contains('mengsan-hud-notification-shuying') &&
(Math.abs(popup.offsetLeft-player.offsetLeft)>1 || Math.abs(popup.offsetTop-player.offsetTop-80)>1))continue;
ownClass(popup, 'mengsan-hud-notification-shuying');
if (popup.offsetTop > maxTop) style(popup, 'top', `${Math.max(0, maxTop)}px`);
const maxLeft = Math.max(0, arena.clientWidth - 160);
style(popup, 'left', `${Math.max(12, Math.min(maxLeft, popup.offsetLeft))}px`);
}
}
function refresh() {
if (disposed || frozen) return;
if (!battle.session.active || !arena.isConnected || !handRoot.isConnected || ui.me !== handRoot || ui.control !== controlRoot) { dispose(); return; }
text(name, nameOf(player)); text(hp, `生命 ${player.hp} / ${player.maxHp}`); text(block, `格挡 ${player.hujia || 0}`);
healthFill.style.width = `${Math.max(0, Math.min(100, player.maxHp > 0 ? player.hp / player.maxHp * 100 : 0))}%`;
text(energy, `费用 ${player.storage.mengsanEnergy_shuying ?? 0} / ${player.storage.mengsanMaxEnergy_shuying ?? 0}`);
syncActors(); syncExternalEquipment(); renderStatuses(); syncControls(); syncNotifications();
overlays.refresh({ footerHeight:hudHeightFor(), status:_status });
}
function schedule() {
if (disposed || frozen || document.hidden || frame !== null) return;
frame = window.requestAnimationFrame(() => { frame = null; refresh(); });
}
const timer = window.setInterval(schedule, 200);
function freeze() {
if(disposed || frozen)return;
frozen=true;window.clearInterval(timer);
if(frame!==null){window.cancelAnimationFrame(frame);frame=null;}
observer?.disconnect();resize?.disconnect();closeDialog();overlays.dispose();
ownClass(arena,'mengsan-hud-frozen-shuying');
}
function dispose() {
if (disposed) return; disposed = true;
window.clearInterval(timer); if (frame !== null) window.cancelAnimationFrame(frame);
observer?.disconnect(); resize?.disconnect(); window.removeEventListener('resize', schedule);
document.removeEventListener('visibilitychange', schedule);
overlays.dispose();
closeDialog();
for (const mark of hiddenMarks) mark.classList.remove('mengsan-hud-represented-mark-shuying'); hiddenMarks.clear();
for (const restore of restores.reverse()) restore();
root.remove(); link.remove();
}
ownClass(document.body, 'mengsan-hud-active-shuying'); ownClass(arena, 'mengsan-hud-arena-shuying');
ownClass(ui.me, 'mengsan-hud-hand-shuying'); ownClass(ui.control, 'mengsan-hud-controls-shuying');
ownClass(ui.mebg, 'mengsan-hud-mebg-shuying'); ownClass(player, 'mengsan-hud-self-shuying');
// Panel events must not reach native selection/drag handlers.
for (const type of ['pointerdown', 'pointerup', 'click', 'touchstart', 'touchend', 'contextmenu']) root.addEventListener(type, event => event.stopPropagation());
link.addEventListener('load', schedule);
link.addEventListener('error', () => { env.log?.('梦三 HUD 样式加载失败,保留原生布局'); dispose(); });
document.head.appendChild(link);
observer = new window.MutationObserver(schedule); observer.observe(ui.control, { childList: true, subtree: true });
observer.observe(arena, { childList: true });
if(ui.window && ui.window!==arena) observer.observe(ui.window,{childList:true});
if (window.ResizeObserver) { resize = new window.ResizeObserver(schedule); resize.observe(arena); }
window.addEventListener('resize', schedule); document.addEventListener('visibilitychange', schedule);
const api = { refresh, freeze, dispose }; battle.session.ownResource(api, dispose); refresh(); return api;
}