Restyled character creation dialog.

Added dialog factory.
This commit is contained in:
Aaron Lutomski
2026-05-29 21:14:23 -07:00
parent 9282cdb334
commit 46a5fb0cd6
43 changed files with 2340 additions and 1112 deletions
+83 -1
View File
@@ -14,11 +14,92 @@ import {
* @param {number} base
* @returns {string}
*/
function getActiveEffectModificationClass(current, base) {
export function getActiveEffectModificationClass(current, base) {
if (current == null || base == null || current === base) return "";
return current > base ? "ae-mod-increase" : "ae-mod-decrease";
}
/**
* Sheet color for roll-target modifiers (uses modifier sign, not clamped effective vs base).
* @param {number} modifier
* @returns {string}
*/
export function getRollTargetDisplayClassFromModifier(modifier) {
const numericModifier = Number(modifier) || 0;
if (!numericModifier) return "";
return numericModifier > 0 ? "ae-mod-increase" : "ae-mod-decrease";
}
/**
* Apply a roll-target Active Effect modifier with the same clamp rules as percentile rolls.
* @param {number} base
* @param {number} modifier
* @param {{ allowOver99?: boolean }} [options]
* @returns {number}
*/
export function clampPercentileRollTarget(
base,
modifier,
{ allowOver99 = false } = {},
) {
const numericBase = Number(base);
const numericModifier = Number(modifier) || 0;
if (!numericModifier || !Number.isFinite(numericBase)) return numericBase;
let target = Math.round(numericBase + numericModifier);
target = Math.max(1, target);
if (!allowOver99) {
target = Math.min(target, 99);
}
return target;
}
/**
* @param {object} entry
* @param {number} modifier
*/
function attachRollTargetDisplay(entry, modifier) {
const numericModifier = Number(modifier) || 0;
if (!numericModifier) {
entry.rollTargetDisplayClass = "";
return;
}
entry.rollTargetDisplayClass =
getRollTargetDisplayClassFromModifier(numericModifier);
}
/**
* Sheet display for agent roll-target Active Effects (skills, stats x5, SAN).
* @param {Actor} actor
* @returns {void}
*/
export function prepareAgentRollTargetDisplay(actor) {
if (actor.type !== "agent") return;
const system = actor.system;
const rollTarget = system.rollTarget;
if (!rollTarget) return;
const allSkillsMod = Number(rollTarget.allSkills) || 0;
const sanityMod = Number(rollTarget.sanity) || 0;
const statisticsMod = Number(rollTarget.statistics) || 0;
for (const skill of Object.values(system.skills ?? {})) {
attachRollTargetDisplay(skill, allSkillsMod);
}
for (const skill of Object.values(system.typedSkills ?? {})) {
attachRollTargetDisplay(skill, allSkillsMod);
}
attachRollTargetDisplay(system.sanity, sanityMod);
for (const stat of Object.values(system.statistics ?? {})) {
attachRollTargetDisplay(stat, statisticsMod);
}
}
/**
* Attach sheet display classes comparing prepared data to persisted source (pre–AE).
* @param {Actor} actor
@@ -82,6 +163,7 @@ export function refreshDerivedAfterActiveEffects(actor) {
const sourceStatistics = actor._source?.system?.statistics;
prepareStatisticsX5(system.statistics, sourceStatistics);
prepareAgentRollTargetDisplay(actor);
applyAgentResourceMaxBonuses(system, sourceStatistics);
setSkillTargetProficiencies(system.skills);
setSkillTargetProficiencies(system.typedSkills);
+176 -18
View File
@@ -75,7 +75,12 @@ const AGENT_SKILL_DEFAULTS = /** @type {Record<string, number>} */ ({
const BONUS_SKILL_COUNT = 8;
const BONUS_SKILL_INCREMENT = 20;
/** @type {ReadonlySet<string>} */
const BONUS_SKILL_CATALOG_EXCLUDED_FIXED = new Set(["unnatural"]);
const SKILL_CAP = 80;
/** Bonus picks that would waste this many points or more are rejected (e.g. 80 + 20% at cap). */
const MAX_ALLOWED_BONUS_WASTE = 20;
const TYPED_KEY_PATTERN =
/^(Art|Craft|Foreign Language|ForeignLanguage|Military Science|MilitaryScience|Pilot|Science|Other)\s*\(([^)]+)\)\s*$/i;
@@ -126,16 +131,6 @@ export function isChooseOneProfessionSkillKey(
);
}
/**
* @param {string} mapKey
* @param {Record<string, ProfessionSkillMeta>} automaticMeta
* @param {Record<string, ProfessionSkillMeta>} optionMeta
* @returns {ProfessionSkillMeta}
*/
export function getProfessionSkillMeta(mapKey, automaticMeta, optionMeta) {
return automaticMeta[mapKey] ?? optionMeta[mapKey] ?? {};
}
/**
* @param {object} params
* @param {string} params.group
@@ -393,6 +388,19 @@ export function buildSkillCatalog() {
return entries;
}
/**
* Skill catalog for character-creation bonus picks (excludes Unnatural).
*
* @returns {SkillCatalogEntry[]}
*/
export function buildBonusSkillCatalog() {
return buildSkillCatalog().filter(
(entry) =>
entry.ref.kind !== "fixed" ||
!BONUS_SKILL_CATALOG_EXCLUDED_FIXED.has(entry.ref.key),
);
}
/**
* @param {string} catalogId
* @param {string} [typedLabel]
@@ -420,6 +428,119 @@ export function catalogIdToSkillRef(
return null;
}
/**
* @param {ProfessionSkillRef} ref
* @returns {string}
*/
function getBonusTrackKey(ref) {
if (ref.kind === "fixed") return `fixed:${ref.key}`;
return formatProfessionSkillKey(ref);
}
/**
* @param {string} trackKey
* @returns {string}
*/
function getBonusTrackLabel(trackKey) {
if (trackKey.startsWith("fixed:")) {
const key = trackKey.slice(6);
return formatProfessionSkillLabel(
/** @type {FixedSkillRef} */ ({ kind: "fixed", key }),
);
}
const ref = parseProfessionSkillKey(trackKey);
if (ref) return formatProfessionSkillLabel(ref);
return trackKey;
}
/**
* @param {string} trackKey
* @param {Record<string, number>} baseFixed
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
* @param {Record<string, number>} defaults
* @returns {number}
*/
function getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults) {
if (trackKey.startsWith("fixed:")) {
const key = trackKey.slice(6);
return baseFixed[key] ?? defaults[key] ?? 0;
}
return baseTyped[trackKey]?.value ?? 0;
}
/**
* Bonus skill slots that would waste 20+ points or raise a skill already at the creation cap.
*
* @param {Record<string, number>} baseFixed
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
* @param {string[]} bonusCatalogIds
* @param {string[]} bonusTypedLabels
* @returns {string[]}
*/
export function collectBonusCapValidationErrors(
baseFixed,
baseTyped,
bonusCatalogIds,
bonusTypedLabels,
) {
const defaults = getAgentSkillDefaults();
/** @type {Record<string, number>} */
const counts = {};
/** @type {(string | null)[]} */
const slotTrackKeys = [];
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
const catalogId = bonusCatalogIds?.[i];
if (!catalogId) {
slotTrackKeys.push(null);
continue;
}
const ref = catalogIdToSkillRef(
catalogId,
bonusTypedLabels?.[i] ?? "",
);
if (!ref) {
slotTrackKeys.push(null);
continue;
}
const trackKey = getBonusTrackKey(ref);
slotTrackKeys.push(trackKey);
counts[trackKey] = (counts[trackKey] ?? 0) + 1;
}
/** @type {Set<string>} */
const violatingTrackKeys = new Set();
for (const [trackKey, count] of Object.entries(counts)) {
const base = getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults);
const final = base + count * BONUS_SKILL_INCREMENT;
const waste = Math.max(0, final - SKILL_CAP);
if (base >= SKILL_CAP || waste >= MAX_ALLOWED_BONUS_WASTE) {
violatingTrackKeys.add(trackKey);
}
}
/** @type {string[]} */
const errors = [];
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
const trackKey = slotTrackKeys[i];
if (!trackKey || !violatingTrackKeys.has(trackKey)) continue;
const base = getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults);
const count = counts[trackKey] ?? 0;
const final = base + count * BONUS_SKILL_INCREMENT;
const waste = Math.max(0, final - SKILL_CAP);
if (base >= SKILL_CAP) {
errors.push(`bonusAtCap:${i}|${trackKey}`);
} else {
errors.push(`bonusWaste:${i}|${waste}|${trackKey}`);
}
}
return errors;
}
/**
* @param {Record<string, number>} automaticSkills
* @param {Record<string, number>} optionSkills
@@ -634,6 +755,15 @@ export function computeSkillValues(
bondCount,
});
validationErrors.push(
...collectBonusCapValidationErrors(
baseFixed,
baseTyped,
bonusIds,
bonusLabels,
),
);
return {
fixedValues,
typedValues,
@@ -768,6 +898,8 @@ export function formatProfessionValidationMessages(
let needsBonusSkillType = false;
/** @type {Set<string>} */
const typedNameRequiredGroups = new Set();
/** @type {string[]} */
const bonusAtCapTrackKeys = [];
for (const code of errors) {
if (code === "optionPicks") {
@@ -782,11 +914,29 @@ export function formatProfessionValidationMessages(
needsBondRelationship = true;
continue;
}
const bonusAtCap = code.match(/^bonusAtCap:\d+\|(.+)$/);
if (bonusAtCap) {
bonusAtCapTrackKeys.push(bonusAtCap[1]);
continue;
}
const bonusWaste = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/);
if (bonusWaste) {
push(
game.i18n.format("DG.Profession.Dialog.BonusSkillWasteTooHigh", {
skill: getBonusTrackLabel(bonusWaste[2]),
waste: Number(bonusWaste[1]),
maxWaste: MAX_ALLOWED_BONUS_WASTE - 1,
}),
);
continue;
}
if (code.startsWith("bonusType")) {
needsBonusSkillType = true;
continue;
}
if (code.startsWith("bonus")) {
if (/^bonus\d+$/.test(code)) {
needsBonusSkill = true;
continue;
}
@@ -816,12 +966,6 @@ export function formatProfessionValidationMessages(
}),
);
}
if (needsBondName) {
push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired"));
}
if (needsBondRelationship) {
push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired"));
}
if (needsBonusSkill) {
push(
game.i18n.format("DG.Profession.Dialog.BonusSkillRequired", {
@@ -839,6 +983,20 @@ export function formatProfessionValidationMessages(
}),
);
}
if (needsBondName) {
push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired"));
}
if (needsBondRelationship) {
push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired"));
}
for (const trackKey of bonusAtCapTrackKeys) {
push(
game.i18n.format("DG.Profession.Dialog.BonusSkillAlreadyAtCap", {
skill: getBonusTrackLabel(trackKey),
}),
);
}
return messages;
}
@@ -915,4 +1073,4 @@ export function prepareProfessionSkillRows(skillMap, skillMeta = {}) {
return rows;
}
export { BONUS_SKILL_COUNT, BONUS_SKILL_INCREMENT, SKILL_CAP };
export { BONUS_SKILL_COUNT };
+2 -2
View File
@@ -94,8 +94,8 @@ export async function rollStatisticScores({
*/
export function buildRollStatsChatContent(rolls) {
const cells = rolls.map(
(entry) =>
`<td class="roll-stats-chat-value">${foundry.utils.escapeHTML(String(entry.total))}</td>`,
(entry, index) =>
`<td class="roll-stats-chat-value roll-stats-chat-value--${index % 2 === 0 ? "a" : "b"}">${foundry.utils.escapeHTML(String(entry.total))}</td>`,
);
const rows = [];
for (let i = 0; i < cells.length; i += 3) {
-15
View File
@@ -16,14 +16,6 @@ export default function registerHandlebarsHelpers() {
return outStr;
});
Handlebars.registerHelper("toLowerCase", (str) => {
try {
return str.toLowerCase();
} catch (error) {
return "";
}
});
Handlebars.registerHelper("toUpperCase", (str) => {
try {
return str.toUpperCase();
@@ -39,13 +31,6 @@ export default function registerHandlebarsHelpers() {
return opts.inverse(this);
});
Handlebars.registerHelper("if_not_eq", (a, b, opts) => {
if (a !== b) {
return opts.fn(this);
}
return opts.inverse(this);
});
Handlebars.registerHelper("if_gt", (a, b, trueVal, falseVal) => {
if (a > b) {
return trueVal;
+28 -4
View File
@@ -1,4 +1,7 @@
import { createDGChatMessage } from "../chat/dg-chat-card.js";
import {
createDGChatMessage,
createDGRollChatMessage,
} from "../chat/dg-chat-card.js";
/**
* @param {object} params
@@ -7,6 +10,8 @@ import { createDGChatMessage } from "../chat/dg-chat-card.js";
* @param {string} params.contentKey i18n key for message body.
* @param {string} params.labelKey i18n key for card subtitle.
* @param {Record<string, string|number>} params.i18nData
* @param {string|null} [params.extraContentKey] Optional i18n key appended on a new line after the main body.
* @param {Roll} [params.roll] When set, attaches the roll to the chat message for display and Dice So Nice.
* @returns {Promise<ChatMessage>}
*/
export async function createAgentResourceChatMessage({
@@ -15,13 +20,32 @@ export async function createAgentResourceChatMessage({
contentKey,
labelKey,
i18nData,
extraContentKey = null,
roll = null,
}) {
const content = game.i18n.format(contentKey, i18nData);
let content = game.i18n.format(contentKey, i18nData);
if (extraContentKey) {
content = `${content}<br>${game.i18n.format(extraContentKey, i18nData)}`;
}
const label = game.i18n.localize(labelKey);
const messageMode = game.settings.get("core", "messageMode");
if (roll) {
return createDGRollChatMessage({
actor,
token,
roll,
label,
content,
messageMode,
});
}
return createDGChatMessage({
actor,
token,
label: game.i18n.localize(labelKey),
label,
content,
messageMode: game.settings.get("core", "messageMode"),
messageMode,
});
}
+209
View File
@@ -0,0 +1,209 @@
import DG from "../config.js";
import { ROLL_TARGET_FIELD_KEYS } from "../active-effect/effect-fields.js";
import { clampPercentileRollTarget } from "./active-effect-derived.js";
import DGUtils from "./utility-functions.js";
/** @typedef {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"} RollTargetFieldKey */
/**
* @param {ActiveEffect} effect
* @returns {boolean}
*/
function isRollTargetEffectActive(effect) {
if (effect.disabled) return false;
if (effect.duration?.expired) return false;
if (effect.isSuppressed) return false;
return true;
}
/**
* @param {ActiveEffect} effect
* @returns {string}
*/
function getRollTargetEffectLabel(effect) {
if (effect.getFlag(DG.ID, "exhaustion")) {
return game.i18n.localize("DG.Physical.ExhaustionEffectName");
}
if (effect.name?.trim()) return effect.name;
const parent = effect.parent;
if (parent?.documentName === "Item") return parent.name ?? "";
return effect.name ?? "";
}
/**
* @param {Actor} actor
* @param {RollTargetFieldKey} rollTargetFieldKey
* @returns {Array<{ name: string, modifier: number }>}
*/
export function collectRollTargetContributions(actor, rollTargetFieldKey) {
if (actor.type !== "agent" || !ROLL_TARGET_FIELD_KEYS.includes(rollTargetFieldKey)) {
return [];
}
const contributions = [];
for (const effect of actor.appliedEffects ?? []) {
if (!isRollTargetEffectActive(effect)) continue;
const change = (effect.changes ?? []).find(
(entry) => entry.key === rollTargetFieldKey && entry.type === "add",
);
if (!change) continue;
const modifier = Number(change.value);
if (!Number.isFinite(modifier) || modifier === 0) continue;
contributions.push({
name: getRollTargetEffectLabel(effect),
modifier,
});
}
return contributions;
}
/**
* @param {number} value
* @returns {string}
*/
function formatTargetPercent(value) {
return `${Math.round(value)}%`;
}
/**
* @param {number} modifier
* @returns {string}
*/
function formatModifierPercent(modifier) {
return `${DGUtils.formatStringWithLeadingPlus(modifier)}%`;
}
/**
* @param {number} base
* @param {Array<{ name: string, modifier: number }>} contributions
* @param {{ allowOver99?: boolean }} [options]
* @returns {string}
*/
function buildRollTargetBreakdownTable(
base,
contributions,
{ allowOver99 = false } = {},
) {
const rows = [
[
game.i18n.localize("DG.Tooltip.RollTarget.BaseTarget"),
formatTargetPercent(base),
],
];
for (const { name, modifier } of contributions) {
rows.push([name, formatModifierPercent(modifier)]);
}
const totalModifier = contributions.reduce(
(sum, entry) => sum + entry.modifier,
0,
);
const finalTarget = clampPercentileRollTarget(base, totalModifier, {
allowOver99,
});
rows.push([
game.i18n.localize("DG.Tooltip.RollTarget.FinalTarget"),
formatTargetPercent(finalTarget),
]);
const body = rows
.map(
([label, value]) =>
`<tr><td>${foundry.utils.escapeHTML(label)}</td><td>${foundry.utils.escapeHTML(value)}</td></tr>`,
)
.join("");
return `<table class="dg-roll-target-tooltip"><tbody>${body}</tbody></table>`;
}
/**
* @param {number} base
* @returns {string}
*/
function buildRollTargetTargetLine(base) {
return game.i18n.format("DG.Tooltip.RollTarget.Target", {
value: Math.round(base),
});
}
/**
* @param {string} existingTooltip
* @param {Actor} actor
* @param {RollTargetFieldKey} rollTargetFieldKey
* @param {number} base
* @param {{ allowOver99?: boolean, showTargetWhenUnmodified?: boolean }} [options]
* @returns {string}
*/
export function appendRollTargetTooltipSection(
existingTooltip,
actor,
rollTargetFieldKey,
base,
options = {},
) {
if (actor.type !== "agent") return existingTooltip;
const { showTargetWhenUnmodified = true, ...tableOptions } = options;
const contributions = collectRollTargetContributions(
actor,
rollTargetFieldKey,
);
let appendix = "";
if (contributions.length > 0) {
appendix = buildRollTargetBreakdownTable(base, contributions, tableOptions);
} else if (showTargetWhenUnmodified) {
appendix = buildRollTargetTargetLine(base);
}
if (!appendix) return existingTooltip;
if (!existingTooltip) return appendix;
return `${existingTooltip}<br><br>${appendix}`;
}
/**
* Stat and sanity roll-target tooltips for the agent sheet left bar (not skills).
*
* @param {Actor} actor
* @returns {void}
*/
export function prepareAgentStatSanityTooltips(actor) {
if (actor.type !== "agent") return;
const system = actor.system;
for (const [key, stat] of Object.entries(system.statistics ?? {})) {
const base = Number(stat.x5) || 0;
const existing = game.i18n.localize(`DG.Attributes.Tooltip.${key}`);
stat.tooltip = appendRollTargetTooltipSection(
existing,
actor,
"system.rollTarget.statistics",
base,
{ allowOver99: base > 99 },
);
}
const sanity = system.sanity;
if (sanity) {
const existing = `${game.i18n.localize("DG.Tooltip.CurrentSanityPartOne")}${sanity.currentBreakingPoint}${game.i18n.localize("DG.Tooltip.CurrentSanityPartTwo")}`;
const base = Number(sanity.value) || 0;
sanity.tooltip = appendRollTargetTooltipSection(
existing,
actor,
"system.rollTarget.sanity",
base,
);
}
}
+18
View File
@@ -1,3 +1,21 @@
/**
* Splits a flat list into column arrays for vertical stacking (top-to-bottom per column).
*
* @template T
* @param {T[]} arr
* @param {number} numCols
* @returns {T[][]}
*/
export function splitIntoColumns(arr, numCols) {
if (numCols <= 1) return [arr];
const columns = Array.from({ length: numCols }, () => /** @type {T[]} */ ([]));
const perCol = Math.ceil(arr.length / numCols);
for (let col = 0; col < numCols; col++) {
columns[col] = arr.slice(col * perCol, (col + 1) * perCol);
}
return columns;
}
/**
* Reorders a flat array into column-major order for grid display.
*
+169
View File
@@ -0,0 +1,169 @@
/** @typedef {import("./profession-skills.js").ProfessionSkillRef} ProfessionSkillRef */
/** @typedef {"dialog" | "sheet"} SkillTooltipContext */
import { appendRollTargetTooltipSection } from "./roll-target-tooltip.js";
/**
* Descriptive skill tooltip only (no roll target or 0% appendix).
*
* @param {ProfessionSkillRef} ref
* @returns {string}
*/
export function getSkillDescriptiveTooltip(ref) {
if (ref.kind === "fixed") {
const key = `DG.Skills.Tooltip.${ref.key}`;
const localized = game.i18n.localize(key);
return localized === key ? "" : localized;
}
const key = `DG.TypeSkills.Tooltip.${ref.group}`;
const localized = game.i18n.localize(key);
return localized === key ? "" : localized;
}
/**
* Build a skill tooltip for character creation or the agent sheet.
*
* @param {SkillTooltipContext} context
* @param {Actor | null} actor
* @param {ProfessionSkillRef} ref
* @param {{ proficiency?: number }} [options]
* @returns {string}
*/
export function buildSkillTooltip(
context,
actor,
ref,
{ proficiency = 0 } = {},
) {
let tooltip = getSkillDescriptiveTooltip(ref);
if (ref.kind === "fixed" && !tooltip) {
tooltip = game.i18n.localize(`DG.Skills.Tooltip.${ref.key}`);
}
if (context === "dialog") return tooltip;
if (ref.kind === "fixed" && !proficiency) {
tooltip = tooltip.concat(
"<br><br>",
game.i18n.localize("DG.Tooltip.CannotRollSkillLabel"),
);
}
if (actor?.type === "agent") {
tooltip = appendRollTargetTooltipSection(
tooltip,
actor,
"system.rollTarget.allSkills",
Number(proficiency) || 0,
{ showTargetWhenUnmodified: false },
);
}
return tooltip;
}
/**
* Agent sheet special-training row tooltip (attribute-based, not ProfessionSkillRef).
*
* @param {Actor} actor
* @param {string} attributeTooltipKey i18n key for the underlying attribute/skill
* @param {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"} rollTargetFieldKey
* @param {number} base
* @returns {string}
*/
export function buildAgentSpecialTrainingTooltip(
actor,
attributeTooltipKey,
rollTargetFieldKey,
base,
) {
return appendRollTargetTooltipSection(
game.i18n.localize(attributeTooltipKey),
actor,
rollTargetFieldKey,
base,
{
allowOver99:
rollTargetFieldKey === "system.rollTarget.statistics" && base > 99,
showTargetWhenUnmodified: false,
},
);
}
/**
* Apply client skillTooltipDisplay setting (hover / hoverShift / never).
*
* @param {HTMLElement} root
* @returns {void}
*/
export function applySkillTooltipDisplayMode(root) {
const mode = game.settings.get("deltagreen", "skillTooltipDisplay");
if (mode !== "hoverShift" && mode !== "never") return;
const nodes = root.querySelectorAll("[data-tooltip],[title]");
if (mode === "never") {
nodes.forEach((el) => {
if (el.dataset.shiftTooltipInstalled === "true") return;
el.removeAttribute("data-tooltip");
el.removeAttribute("title");
el.dataset.shiftTooltipInstalled = "true";
});
return;
}
nodes.forEach((el) => {
if (el.dataset.shiftTooltipInstalled === "true") return;
let html = el.getAttribute("data-tooltip");
let isHtml = true;
if (!html) {
const title = el.getAttribute("title");
if (title) {
html = foundry.utils.escapeHTML(title);
isHtml = false;
}
}
if (!html) return;
el.removeAttribute("data-tooltip");
el.removeAttribute("title");
el.dataset.shiftTooltipInstalled = "true";
const opts = isHtml ? { html } : { text: html };
const show = () => game.tooltip.activate(el, opts);
const hide = () => game.tooltip.deactivate();
const onKey = (ev) => {
if (ev.key !== "Shift") return;
if (!document.body.contains(el)) {
window.removeEventListener("keydown", onKey);
window.removeEventListener("keyup", onKey);
return;
}
if (ev.type === "keydown") show();
else hide();
};
const onEnter = (ev) => {
if (ev.shiftKey) show();
window.addEventListener("keydown", onKey);
window.addEventListener("keyup", onKey);
};
const onLeave = () => {
hide();
window.removeEventListener("keydown", onKey);
window.removeEventListener("keyup", onKey);
};
el.addEventListener("pointerenter", onEnter, { passive: true });
el.addEventListener("pointerleave", onLeave, { passive: true });
});
}