All checks were successful
Release Creation / build (release) Successful in 55s
153 lines
5.4 KiB
JavaScript
153 lines
5.4 KiB
JavaScript
/**
|
||
* Free Dice Tray — injected into the Foundry chat sidebar.
|
||
*
|
||
* Provides a compact bar for GM and players to roll any standard die (d4–d30)
|
||
* or its exploding variant (dXx) without needing an actor sheet.
|
||
* Supports selecting how many dice to roll (1–9).
|
||
*/
|
||
|
||
/** Standard dice available in Lethal Fantasy */
|
||
const DICE_TYPES = ["d4", "d6", "d8", "d10", "d12", "d20", "d30"]
|
||
|
||
/**
|
||
* Inject the dice tray bar into the ChatLog HTML.
|
||
* Called from `Hooks.on("renderChatLog", ...)`.
|
||
*
|
||
* @param {Application} _chatLog
|
||
* @param {HTMLElement|jQuery} html
|
||
*/
|
||
export function injectDiceTray(_chatLog, html) {
|
||
const el = (html instanceof HTMLElement) ? html : (html[0] ?? html)
|
||
if (!el?.querySelector) return
|
||
if (el.querySelector(".lf-dice-tray")) return
|
||
|
||
const bar = document.createElement("div")
|
||
bar.className = "lf-dice-tray"
|
||
|
||
const diceButtons = DICE_TYPES.map(d =>
|
||
`<button type="button" class="lf-dt-die-btn" data-die="${d}" title="${d.toUpperCase()}">${d.toUpperCase()}</button>`
|
||
).join("")
|
||
|
||
const countOptions = Array.from({ length: 9 }, (_, i) =>
|
||
`<option value="${i + 1}">${i + 1}</option>`
|
||
).join("")
|
||
|
||
bar.innerHTML = `
|
||
<div class="lf-dt-row">
|
||
<span class="lf-dt-label"><i class="fa-solid fa-dice"></i></span>
|
||
<select class="lf-dt-count" title="${game.i18n.localize("LETHALFANTASY.DiceTray.CountTitle")}">
|
||
${countOptions}
|
||
</select>
|
||
<div class="lf-dt-dice">${diceButtons}</div>
|
||
<label class="lf-dt-explode-label" title="${game.i18n.localize("LETHALFANTASY.DiceTray.ExplodeTitle")}">
|
||
<input type="checkbox" class="lf-dt-explode" />
|
||
<i class="fa-solid fa-explosion"></i>
|
||
</label>
|
||
</div>
|
||
`
|
||
|
||
bar.addEventListener("click", async ev => {
|
||
const btn = ev.target.closest(".lf-dt-die-btn")
|
||
if (!btn) return
|
||
ev.stopPropagation()
|
||
|
||
const dieType = btn.dataset.die
|
||
const count = parseInt(bar.querySelector(".lf-dt-count").value) || 1
|
||
const explode = bar.querySelector(".lf-dt-explode").checked
|
||
|
||
try {
|
||
await rollFreeDie(dieType, count, explode)
|
||
} catch (err) {
|
||
console.error("Lethal Fantasy | Dice Tray error:", err)
|
||
ui.notifications?.error("Dice Tray roll failed — see console")
|
||
}
|
||
})
|
||
|
||
const anchor = el.querySelector(".chat-form")
|
||
?? el.querySelector(".chat-message-form")
|
||
?? el.querySelector("form")
|
||
if (anchor) {
|
||
anchor.parentElement.insertBefore(bar, anchor)
|
||
} else {
|
||
el.appendChild(bar)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Roll one or more dice of the given type and post the result to chat.
|
||
* For exploding dice, follows the Lethal Fantasy rule: each exploded reroll
|
||
* contributes (result − 1) to the total, same as all other system rolls.
|
||
*
|
||
* @param {string} dieType Die face, e.g. "d20"
|
||
* @param {number} count Number of dice to roll (1–9)
|
||
* @param {boolean} explode Whether to use the exploding variant (max triggers reroll at −1)
|
||
* @returns {Promise<void>}
|
||
*/
|
||
export async function rollFreeDie(dieType, count = 1, explode = false) {
|
||
const sides = parseInt(dieType.replace("d", "")) || 20
|
||
const baseFormula = `1d${sides}`
|
||
const label = explode
|
||
? `${count}${dieType.toUpperCase()}E`
|
||
: `${count}${dieType.toUpperCase()}`
|
||
|
||
const dieLabel = dieType.toUpperCase()
|
||
const dieChips = []
|
||
let total = 0
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const r0 = await new Roll(baseFormula).evaluate()
|
||
if (game?.dice3d) await game.dice3d.showForRoll(r0, game.user, true)
|
||
let diceResult = r0.dice[0].results[0].result
|
||
dieChips.push({ label: dieLabel, value: diceResult, exploded: false })
|
||
total += diceResult
|
||
|
||
if (explode) {
|
||
while (diceResult === sides) {
|
||
const rx = await new Roll(baseFormula).evaluate()
|
||
if (game?.dice3d) await game.dice3d.showForRoll(rx, game.user, true)
|
||
diceResult = rx.dice[0].results[0].result
|
||
const contrib = diceResult - 1
|
||
dieChips.push({ label: `${dieLabel}-1`, value: contrib, exploded: true })
|
||
total += contrib
|
||
}
|
||
}
|
||
}
|
||
|
||
const resultHtml = dieChips.map(chip => {
|
||
const isMax = !chip.exploded && chip.value === sides
|
||
const isMin = chip.value === 1
|
||
const explodeIcon = chip.exploded ? `<i class="fa-solid fa-burst lf-dt-explode-icon"></i>` : ""
|
||
const classes = ["lf-frc-die-chip", isMax ? "lf-frc-max" : "", isMin ? "lf-frc-min" : ""].filter(Boolean).join(" ")
|
||
return `<div class="${classes}">
|
||
<span class="lf-frc-die-type">${chip.label}</span>
|
||
<span class="lf-frc-die-sep">→</span>
|
||
<span class="lf-frc-die-val">${chip.value}${explodeIcon}</span>
|
||
</div>`
|
||
}).join("")
|
||
|
||
const totalLabel = game.i18n.localize("LETHALFANTASY.Label.total").toUpperCase()
|
||
const content = `
|
||
<div class="lf-free-roll-card">
|
||
<div class="lf-frc-header">
|
||
<i class="fa-solid fa-dice"></i>
|
||
<span class="lf-frc-title-text">${game.i18n.localize("LETHALFANTASY.DiceTray.ChatTitle")}</span>
|
||
<span class="lf-frc-badge">${label}</span>
|
||
</div>
|
||
<div class="lf-frc-dice">${resultHtml}</div>
|
||
<div class="lf-frc-total-bar">
|
||
<span class="lf-frc-total-label">${totalLabel}</span>
|
||
<span class="lf-frc-total-value">${total}</span>
|
||
</div>
|
||
</div>
|
||
`
|
||
|
||
const rollMode = game.settings.get("core", "rollMode")
|
||
const msgData = {
|
||
speaker: ChatMessage.getSpeaker(),
|
||
content,
|
||
sound: CONFIG.sounds.dice,
|
||
}
|
||
ChatMessage.applyRollMode(msgData, rollMode)
|
||
await ChatMessage.create(msgData)
|
||
}
|