// Skill Picker for Foundry VTT v14 + D&D5e // Selected token actor first, then user's assigned character fallback. // Centered panel, alphabetical skill list, large actor portrait. const token = canvas.tokens.controlled[0]; const actor = token?.actor ?? game.user.character; if (!actor) { return ui.notifications.warn("Select a token or assign a character to your user."); } if (game.system.id !== "dnd5e") { return ui.notifications.warn("This macro is written for the D&D5e system."); } // ----------------------------------------------------------------------------- // Icons // GitHub source: // https://github.com/intrinsical/tw-dnd/tree/6e5928176496f95d78874b273a840850a4317c70/icons/skill // // needs raw.githubusercontent.com. // ----------------------------------------------------------------------------- const ICON_BASE = "https://raw.githubusercontent.com/intrinsical/tw-dnd/6e5928176496f95d78874b273a840850a4317c70/icons/skill"; const ICON_FILE_BY_SKILL = { acr: "acrobatics", ani: "animal-handling", arc: "arcana", ath: "athletics", dec: "deception", his: "history", ins: "insight", itm: "intimidation", inv: "investigation", med: "medicine", nat: "nature", prc: "perception", prf: "performance", per: "persuasion", rel: "religion", slt: "sleight-of-hand", ste: "stealth", sur: "survival" }; // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const cssUrl = (value) => String(value ?? "").replace(/['"\\()]/g, "\\$&"); const labelForSkill = (key, skill) => { const config = CONFIG.DND5E?.skills?.[key]; const rawLabel = config?.label ?? config ?? skill?.label ?? key; return game.i18n.localize(rawLabel); }; const getPortrait = () => { return token?.document?.texture?.src ?? token?.texture?.src ?? actor.img ?? "icons/svg/mystery-man.svg"; }; // ----------------------------------------------------------------------------- // Build skill data alphabetically // ----------------------------------------------------------------------------- const skills = actor.system?.skills ?? {}; const skillData = Object.entries(skills) .map(([key, skill]) => { const label = labelForSkill(key, skill); const total = Number.isNumeric(skill?.total) ? skill.total : 0; const file = ICON_FILE_BY_SKILL[key] ?? key; return { key, label, mod: `${total >= 0 ? "+" : ""}${total}`, icon: `${ICON_BASE}/${file}.svg` }; }) .sort((a, b) => a.label.localeCompare(b.label)); if (!skillData.length) { return ui.notifications.warn(`${actor.name} has no skills available.`); } // ----------------------------------------------------------------------------- // Cleanup existing picker // ----------------------------------------------------------------------------- document.getElementById("intrinsical-skill-picker")?.remove(); document.getElementById("intrinsical-skill-picker-style")?.remove(); // ----------------------------------------------------------------------------- // CSS // ----------------------------------------------------------------------------- const style = document.createElement("style"); style.id = "intrinsical-skill-picker-style"; style.textContent = ` #intrinsical-skill-picker { position: fixed; inset: 0; z-index: 99999; display: grid; place-items: center; padding: 18px; background: radial-gradient(circle at center, rgba(135, 18, 18, 0.36), transparent 34%), linear-gradient(to bottom, rgba(7, 4, 5, 0.54), rgba(0, 0, 0, 0.76)); backdrop-filter: blur(5px); animation: isp-overlay-in 130ms ease-out forwards; } #intrinsical-skill-picker.closing { animation: isp-overlay-out 120ms ease-in forwards; } .isp-panel { width: min(820px, calc(100vw - 36px)); max-height: calc(100vh - 36px); overflow: hidden; color: #f5ead8; border: 1px solid rgba(186, 139, 78, 0.78); border-radius: 22px; background: radial-gradient(circle at top center, rgba(145, 27, 27, 0.34), transparent 38%), linear-gradient(145deg, rgba(28, 13, 13, 0.98), rgba(7, 8, 12, 0.99) 58%, rgba(12, 10, 8, 0.99)); box-shadow: 0 28px 90px rgba(0, 0, 0, 0.72), 0 0 44px rgba(131, 18, 18, 0.34), inset 0 0 30px rgba(255, 229, 178, 0.035); opacity: 0; transform: scale(0.94) translateY(8px); animation: isp-panel-in 180ms cubic-bezier(.16,1.18,.25,1) forwards; } #intrinsical-skill-picker.closing .isp-panel { animation: isp-panel-out 110ms ease-in forwards; } .isp-header { position: relative; display: grid; grid-template-columns: 112px 1fr auto; align-items: center; gap: 18px; padding: 18px 20px; border-bottom: 1px solid rgba(186, 139, 78, 0.34); background: radial-gradient(circle at 62px 50%, rgba(196, 41, 35, 0.24), transparent 120px), linear-gradient(to bottom, rgba(255, 240, 210, 0.055), rgba(255, 255, 255, 0)); } .isp-header::before { content: ""; position: absolute; inset: 0; pointer-events: none; opacity: 0.16; background: repeating-linear-gradient( 115deg, transparent 0px, transparent 9px, rgba(255, 255, 255, 0.05) 10px, transparent 12px ); mix-blend-mode: screen; } .isp-portrait-frame { position: relative; width: 112px; height: 112px; border-radius: 22px; padding: 5px; background: linear-gradient(145deg, rgba(230, 184, 104, 0.92), rgba(77, 31, 20, 0.88)), radial-gradient(circle at top, rgba(255, 234, 179, 0.4), transparent 55%); box-shadow: 0 0 0 1px rgba(0,0,0,0.6), 0 0 26px rgba(154, 22, 22, 0.42), inset 0 0 16px rgba(255, 240, 190, 0.12); } .isp-portrait-frame::after { content: ""; position: absolute; inset: -7px; border-radius: 27px; border: 1px solid rgba(191, 139, 78, 0.28); box-shadow: 0 0 22px rgba(168, 27, 27, 0.28); pointer-events: none; } .isp-portrait { width: 100%; height: 100%; border-radius: 17px; background-image: linear-gradient(to bottom, rgba(0,0,0,0.02), rgba(0,0,0,0.42)), var(--portrait); background-size: cover; background-position: center; border: 1px solid rgba(15, 8, 8, 0.92); box-shadow: inset 0 0 24px rgba(0,0,0,0.58), inset 0 0 0 1px rgba(255, 230, 177, 0.16); } .isp-title { font-family: var(--font-primary, serif); font-size: 34px; font-weight: 900; letter-spacing: 0.01em; line-height: 1; color: #f8ead3; text-transform: none; text-shadow: 0 2px 4px rgba(0,0,0,0.9), 0 0 14px rgba(154, 22, 22, 0.5); } .isp-subtitle { margin-top: 7px; font-size: 14px; font-weight: 800; color: rgba(229, 201, 160, 0.88); text-shadow: 0 1px 3px rgba(0,0,0,0.9); } .isp-close { position: relative; z-index: 1; width: 38px; height: 38px; border: 1px solid rgba(214, 176, 114, 0.28); border-radius: 12px; color: rgba(245, 234, 216, 0.82); background: rgba(255,255,255,0.055); cursor: pointer; display: grid; place-items: center; transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; } .isp-close:hover, .isp-close:focus { outline: none; background: rgba(145, 27, 27, 0.42); border-color: rgba(230, 184, 104, 0.62); transform: scale(1.06); } .isp-body { padding: 16px; max-height: calc(100vh - 186px); overflow-y: auto; scrollbar-width: thin; scrollbar-color: rgba(186, 139, 78, 0.7) rgba(0,0,0,0.28); } .isp-grid { display: grid; grid-template-columns: repeat(3, minmax(205px, 1fr)); gap: 11px; } .isp-skill { --delay: 0ms; min-height: 72px; display: grid; grid-template-columns: 46px 1fr auto; align-items: center; gap: 12px; padding: 11px 13px; border: 1px solid rgba(177, 128, 78, 0.24); border-radius: 16px; color: #f5ead8; background: radial-gradient(circle at top left, rgba(154, 32, 28, 0.20), transparent 48%), linear-gradient(145deg, rgba(34, 31, 34, 0.98), rgba(12, 14, 19, 0.98)); box-shadow: 0 11px 24px rgba(0,0,0,0.36), inset 0 0 16px rgba(255,255,255,0.025); cursor: pointer; opacity: 0; transform: translateY(7px); animation: isp-skill-in 180ms ease-out forwards; animation-delay: var(--delay); transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease, background 120ms ease; } .isp-skill:hover, .isp-skill:focus { outline: none; border-color: rgba(228, 177, 97, 0.86); background: radial-gradient(circle at top left, rgba(170, 35, 30, 0.36), transparent 50%), linear-gradient(145deg, rgba(56, 35, 34, 0.99), rgba(17, 17, 22, 0.99)); box-shadow: 0 0 22px rgba(160, 30, 27, 0.35), 0 15px 34px rgba(0,0,0,0.48), inset 0 0 18px rgba(255,255,255,0.04); transform: translateY(-2px); } .isp-skill:first-child { border-color: rgba(230, 184, 104, 0.82); box-shadow: 0 0 24px rgba(154, 32, 28, 0.32), 0 11px 24px rgba(0,0,0,0.36), inset 0 0 16px rgba(255,255,255,0.025); } .isp-icon-wrap { width: 46px; height: 46px; display: grid; place-items: center; border-radius: 13px; background: radial-gradient(circle at top, rgba(233, 206, 155, 0.11), transparent 60%), rgba(255,255,255,0.07); box-shadow: inset 0 0 14px rgba(255,255,255,0.035), 0 0 0 1px rgba(0,0,0,0.22); overflow: hidden; } .isp-icon { width: 34px; height: 34px; object-fit: contain; filter: brightness(0) invert(1); opacity: 0.92; } .isp-fallback-icon { font-size: 21px; color: rgba(245, 234, 216, 0.92); } .isp-name { min-width: 0; font-size: 15px; font-weight: 900; line-height: 1.12; text-align: left; color: #f8ead3; text-shadow: 0 1px 4px rgba(0,0,0,0.82); } .isp-mod { display: inline-grid; place-items: center; min-width: 44px; height: 30px; padding: 0 10px; border-radius: 999px; font-size: 14px; font-weight: 900; color: #f8ead3; background: linear-gradient(to bottom, rgba(110, 58, 39, 0.94), rgba(47, 34, 28, 0.94)); border: 1px solid rgba(224, 177, 101, 0.46); box-shadow: inset 0 0 10px rgba(255, 229, 178, 0.06), 0 0 0 1px rgba(0,0,0,0.24); } @keyframes isp-overlay-in { from { opacity: 0; } to { opacity: 1; } } @keyframes isp-overlay-out { from { opacity: 1; } to { opacity: 0; } } @keyframes isp-panel-in { to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes isp-panel-out { to { opacity: 0; transform: scale(0.96) translateY(4px); } } @keyframes isp-skill-in { to { opacity: 1; transform: translateY(0); } } @media (max-width: 860px) { .isp-panel { width: min(620px, calc(100vw - 28px)); } .isp-grid { grid-template-columns: repeat(2, minmax(205px, 1fr)); } } @media (max-width: 560px) { #intrinsical-skill-picker { padding: 10px; } .isp-panel { width: calc(100vw - 20px); border-radius: 18px; } .isp-header { grid-template-columns: 82px 1fr auto; gap: 12px; padding: 13px; } .isp-portrait-frame { width: 82px; height: 82px; border-radius: 18px; padding: 4px; } .isp-portrait { border-radius: 14px; } .isp-title { font-size: 24px; } .isp-subtitle { font-size: 12px; } .isp-body { padding: 11px; max-height: calc(100vh - 128px); } .isp-grid { grid-template-columns: 1fr; gap: 8px; } .isp-skill { min-height: 64px; grid-template-columns: 42px 1fr auto; } .isp-icon-wrap { width: 42px; height: 42px; } .isp-icon { width: 31px; height: 31px; filter: brightness(0) invert(1); opacity: 0.92; } } `; document.head.appendChild(style); // ----------------------------------------------------------------------------- // Markup // ----------------------------------------------------------------------------- const portrait = getPortrait(); const skillButtons = skillData.map((skill, index) => ` `).join(""); const overlay = document.createElement("div"); overlay.id = "intrinsical-skill-picker"; overlay.innerHTML = `
Choose Skill
${escapeHtml(actor.name)}
${skillButtons}
`; document.body.appendChild(overlay); // ----------------------------------------------------------------------------- // Behavior // ----------------------------------------------------------------------------- const cleanup = () => { overlay.remove(); style.remove(); document.removeEventListener("keydown", onKeydown); }; const closeMenu = () => { if (!document.body.contains(overlay)) return; overlay.classList.add("closing"); setTimeout(cleanup, 120); }; const rollSkill = async (skillKey) => { closeMenu(); if (typeof actor.rollSkill === "function") { return actor.rollSkill({ skill: skillKey }); } return ui.notifications.error("This actor does not support skill rolls."); }; const onKeydown = (event) => { if (event.key === "Escape") closeMenu(); }; document.addEventListener("keydown", onKeydown); overlay.addEventListener("click", (event) => { if (event.target === overlay) closeMenu(); }); overlay.querySelector(".isp-close")?.addEventListener("click", closeMenu); overlay.querySelectorAll(".isp-skill").forEach(button => { button.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); await rollSkill(button.dataset.skill); }); }); overlay.querySelector(".isp-skill")?.focus();