ttrpg-tools/src/components/dice.tsx

216 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { customElement, noShadowDOM } from "solid-element";
import { createSignal, onMount } from "solid-js";
interface RollResult {
total: number;
rolls: number[];
detail: string;
}
function rollDice(formula: string): RollResult {
const allRolls: number[] = [];
let total = 0;
const details: string[] = [];
// 解析骰子公式,支持 + 和 - 运算符
// 先按 + 和 - 分割,但保留运算符
const tokens = formula
.split(/([+-])/)
.map((t) => t.trim())
.filter((t) => t.length > 0);
let sign = 1;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token === "+") {
sign = 1;
continue;
}
if (token === "-") {
sign = -1;
continue;
}
// 处理 kh/kl/dh/dl 运算符
let processedToken = token;
let keepHigh = false;
let keepLow = false;
let dropHigh = false;
let dropLow = false;
let keepCount = 0;
let dropCount = 0;
const khMatch = token.match(/^(.+?)kh(\d+)$/i);
const klMatch = token.match(/^(.+?)kl(\d+)$/i);
const dhMatch = token.match(/^(.+?)dh(\d+)$/i);
const dlMatch = token.match(/^(.+?)dl(\d+)$/i);
if (khMatch) {
processedToken = khMatch[1];
keepHigh = true;
keepCount = parseInt(khMatch[2]);
} else if (klMatch) {
processedToken = klMatch[1];
keepLow = true;
keepCount = parseInt(klMatch[2]);
} else if (dhMatch) {
processedToken = dhMatch[1];
dropHigh = true;
dropCount = parseInt(dhMatch[2]);
} else if (dlMatch) {
processedToken = dlMatch[1];
dropLow = true;
dropCount = parseInt(dlMatch[2]);
}
const match = processedToken.match(/^(\d+)?d(\d+)$/i);
if (match) {
const count = parseInt(match[1] || "1");
const sides = parseInt(match[2]);
const rolls: number[] = [];
for (let j = 0; j < count; j++) {
rolls.push(Math.floor(Math.random() * sides) + 1);
}
let processedRolls = [...rolls];
let detail = `${count}d${sides}`;
// 处理 keep/drop 运算符
if (keepHigh || keepLow || dropHigh || dropLow) {
const sorted = [...processedRolls].sort((a, b) => a - b);
if (dropHigh && dropCount > 0) {
processedRolls = sorted.slice(0, sorted.length - dropCount);
detail += `d${dropCount}h`;
} else if (dropLow && dropCount > 0) {
processedRolls = sorted.slice(dropCount);
detail += `d${dropCount}l`;
} else if (keepHigh && keepCount > 0) {
processedRolls = sorted.slice(-keepCount);
detail += `kh${keepCount}`;
} else if (keepLow && keepCount > 0) {
processedRolls = sorted.slice(0, keepCount);
detail += `kl${keepCount}`;
}
}
const partTotal = processedRolls.reduce((a, b) => a + b, 0);
total += sign * partTotal;
allRolls.push(...processedRolls);
details.push(
`${detail}=[${processedRolls.join(",")}]${sign < 0 ? " (负)" : ""}`,
);
} else {
// 处理固定数字
const num = parseInt(token);
if (!isNaN(num)) {
total += sign * num;
details.push(`${num}${sign < 0 ? " (负)" : ""}`);
}
}
}
return {
total,
rolls: allRolls,
detail: details.join(" + "),
};
}
// 从 URL 参数获取骰子结果
function getDiceResultFromUrl(key: string): number | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const value = params.get(`dice-${key}`);
return value ? parseInt(value) : null;
}
function setDiceResultToUrl(key: string, value: number | null) {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
if (value === null) {
params.delete(`dice-${key}`);
} else {
params.set(`dice-${key}`, value.toString());
}
const newUrl = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ""}${window.location.hash}`;
window.history.pushState({}, "", newUrl);
}
customElement("md-dice", { key: "" }, (props, { element }) => {
noShadowDOM();
const [result, setResult] = createSignal<number | null>(null);
const [isRolled, setIsRolled] = createSignal(false);
const [rollDetail, setRollDetail] = createSignal<string>("");
// 从 element 的 textContent 获取骰子公式
const formula = element?.textContent?.trim() || "";
// 隐藏原始文本内容
if (element) {
element.textContent = "";
}
// 使用的 key如果没有提供则使用生成的 ID
const effectiveKey = () => props.key;
onMount(() => {
// 初始化时检查 URL 参数
if (effectiveKey()) {
const urlResult = getDiceResultFromUrl(effectiveKey());
if (urlResult !== null) {
setResult(urlResult);
setIsRolled(true);
}
}
});
const handleRoll = () => {
// 点击骰子图标:总是重 roll
const rollResult = rollDice(formula);
setResult(rollResult.total);
setRollDetail(rollResult.detail);
setIsRolled(true);
if (effectiveKey()) {
setDiceResultToUrl(effectiveKey(), rollResult.total);
}
};
const handleTextClick = () => {
// 点击文本:总是重置为公式
setResult(null);
setIsRolled(false);
setRollDetail("");
if (effectiveKey()) {
setDiceResultToUrl(effectiveKey(), null);
}
};
const displayText = () => (isRolled() ? `${result()}` : formula);
return (
<span class="inline-flex items-center px-1">
<span
onClick={(e) => {
e.preventDefault();
handleRoll();
}}
class="text-blue-600 hover:text-blue-800 cursor-pointer"
title={rollDetail() || "掷骰子"}
>
🎲
</span>
<a
onClick={handleTextClick}
class="cursor-pointer text-gray-700 hover:text-gray-900"
title={isRolled() ? "点击重置为公式" : formula}
>
{displayText()}
</a>
</span>
);
});