diff --git a/src/components/md-commander/CommanderEntries.tsx b/src/components/md-commander/CommanderEntries.tsx index b7ca10e..10489af 100644 --- a/src/components/md-commander/CommanderEntries.tsx +++ b/src/components/md-commander/CommanderEntries.tsx @@ -1,14 +1,38 @@ -import { type Component, For, Show } from "solid-js"; +import { type Component, For, Show, createEffect, on } from "solid-js"; import type { CommanderEntry } from "./types"; import { getResultClass } from "./hooks"; export interface CommanderEntriesProps { entries: () => CommanderEntry[]; + onCommandClick?: (command: string) => void; } export const CommanderEntries: Component = (props) => { + let containerRef: HTMLDivElement | undefined; + + // 当 entries 变化时自动滚动到底部 + createEffect( + on( + () => props.entries().length, + () => { + if (containerRef) { + containerRef.scrollTop = containerRef.scrollHeight; + } + }, + ), + ); + + const handleCommandClick = (command: string) => { + if (props.onCommandClick) { + props.onCommandClick(command); + } + }; + return ( -
+
0} fallback={ @@ -19,15 +43,22 @@ export const CommanderEntries: Component = (props) => { {(entry) => (
- {entry.command} + handleCommandClick(entry.command)} + title="点击复制到输入框" + > + {entry.command} + {entry.timestamp.toLocaleTimeString()}
- {entry.result.message} + {!entry.result.isHtml && entry.result.message}
)} diff --git a/src/components/md-commander/CommanderInput.tsx b/src/components/md-commander/CommanderInput.tsx index 79755eb..1429ed0 100644 --- a/src/components/md-commander/CommanderInput.tsx +++ b/src/components/md-commander/CommanderInput.tsx @@ -37,9 +37,9 @@ export const CommanderInput: Component = (props) => { 执行 - {/* 自动补全下拉框 */} + {/* 自动补全下拉框 - 向上弹出 */} 0}> -
+
{(comp, idx) => (
0) { + const sides = parseInt(sidesStr); + tokens.push({ type: "dice", count: 1, sides }); + } + continue; + } + + // 骰池开始 + if (char === "{") { + tokens.push({ type: "pool_start" }); + i++; + continue; + } + + // 骰池结束 + if (char === "}") { + tokens.push({ type: "pool_end" }); + i++; + continue; + } + + // 逗号 + if (char === ",") { + tokens.push({ type: "comma" }); + i++; + continue; + } + + // 加号 + if (char === "+") { + tokens.push({ type: "plus" }); + i++; + continue; + } + + // 减号 + if (char === "-") { + tokens.push({ type: "minus" }); + i++; + continue; + } + + // 修饰符 kh/kl/dh/dl + if (char === "k" || char === "d") { + const rest = str.slice(i).toLowerCase(); + if (rest.startsWith("kh")) { + i += 2; + let countStr = ""; + while (i < str.length && /\d/.test(str[i])) { + countStr += str[i]; + i++; + } + tokens.push({ type: "modifier", modType: "kh", count: parseInt(countStr) || 1 }); + } else if (rest.startsWith("kl")) { + i += 2; + let countStr = ""; + while (i < str.length && /\d/.test(str[i])) { + countStr += str[i]; + i++; + } + tokens.push({ type: "modifier", modType: "kl", count: parseInt(countStr) || 1 }); + } else if (rest.startsWith("dh")) { + i += 2; + let countStr = ""; + while (i < str.length && /\d/.test(str[i])) { + countStr += str[i]; + i++; + } + tokens.push({ type: "modifier", modType: "dh", count: parseInt(countStr) || 1 }); + } else if (rest.startsWith("dl")) { + i += 2; + let countStr = ""; + while (i < str.length && /\d/.test(str[i])) { + countStr += str[i]; + i++; + } + tokens.push({ type: "modifier", modType: "dl", count: parseInt(countStr) || 1 }); + } + continue; + } + + // 未知字符,跳过 + i++; + } + + return tokens; +} + +/** + * 语法分析器 - 将令牌流转换为骰池列表 + */ +export function parse(tokens: Token[]): DicePool[] { + const pools: DicePool[] = []; + let i = 0; + let currentSign = 1; // 当前符号,1 为正,-1 为负 + + while (i < tokens.length) { + const token = tokens[i]; + + // 处理运算符 + if (token.type === "plus") { + currentSign = 1; + i++; + continue; + } + + if (token.type === "minus") { + currentSign = -1; + i++; + continue; + } + + // 解析骰池 + const pool = parsePool(tokens, i, currentSign); + if (pool) { + pools.push(pool.pool); + i = pool.nextIndex; + currentSign = 1; // 重置符号 + } else { + i++; + } + } + + return pools; +} + +/** + * 解析单个骰池 + */ +function parsePool( + tokens: Token[], + startIndex: number, + sign: number +): { pool: DicePool; nextIndex: number } | null { + const dice: DicePool["dice"] = []; + let i = startIndex; + let modifier: DicePool["modifier"] = undefined; + + const token = tokens[i]; + if (!token) return null; + + // 骰池字面量 {d4, d6, ...} + if (token.type === "pool_start") { + i++; // 跳过 { + while (i < tokens.length && tokens[i].type !== "pool_end") { + if (tokens[i].type === "comma") { + i++; + continue; + } + + if (tokens[i].type === "dice") { + for (let j = 0; j < tokens[i].count; j++) { + dice.push({ + sides: tokens[i].sides, + value: 0, + isNegative: sign < 0, + }); + } + i++; + } else if (tokens[i].type === "number") { + // 数字作为 0 面骰子(固定值) + dice.push({ + sides: 0, + value: tokens[i].value, + isNegative: sign < 0, + }); + i++; + } else { + i++; + } + } + i++; // 跳过 } + } + // 单个骰子或数字 + else if (token.type === "dice") { + for (let j = 0; j < token.count; j++) { + dice.push({ + sides: token.sides, + value: 0, + isNegative: sign < 0, + }); + } + i++; + } else if (token.type === "number") { + dice.push({ + sides: 0, + value: token.value, + isNegative: sign < 0, + }); + i++; + } else { + return null; + } + + // 检查修饰符 + if (i < tokens.length && tokens[i].type === "modifier") { + const mod = tokens[i] as Extract; + modifier = { type: mod.modType, count: mod.count }; + i++; + } + + return { + pool: { dice, modifier, isNegative: sign < 0 }, + nextIndex: i, + }; +} diff --git a/src/components/md-commander/hooks/dice-engine/roller.ts b/src/components/md-commander/hooks/dice-engine/roller.ts new file mode 100644 index 0000000..f11a1c1 --- /dev/null +++ b/src/components/md-commander/hooks/dice-engine/roller.ts @@ -0,0 +1,136 @@ +import type { DicePool, DicePoolResult, DieResult, RollResult } from "./types"; +import { tokenize, parse } from "./parser"; + +/** + * 掷骰子 + */ +export function rollDie(sides: number): number { + if (sides === 0) return 0; // 固定数字 + return Math.floor(Math.random() * sides) + 1; +} + +/** + * 掷骰池 + */ +export function rollDicePool(pool: DicePool): DicePoolResult { + // 掷所有骰子 + const rolls: DieResult[] = pool.dice.map((die) => ({ + sides: die.sides, + value: rollDie(die.sides), + isNegative: die.isNegative, + })); + + // 应用修饰符 + const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier); + + // 计算小计 + const subtotal = keptRolls.reduce((sum, roll) => { + return sum + (roll.isNegative ? -roll.value : roll.value); + }, 0); + + return { + pool, + rolls, + keptRolls, + droppedRolls, + subtotal, + }; +} + +/** + * 应用修饰符 + */ +export function applyModifier( + rolls: DieResult[], + modifier: DicePool["modifier"] +): { keptRolls: DieResult[]; droppedRolls: DieResult[] } { + if (!modifier) { + return { keptRolls: rolls, droppedRolls: [] }; + } + + // 按值排序 + const sorted = [...rolls].sort((a, b) => a.value - b.value); + + switch (modifier.type) { + case "kh": // Keep Highest - 保留最大的 N 个 + case "dh": { // Drop Highest - 丢弃最大的 N 个 + const count = modifier.count; + if (modifier.type === "kh") { + const kept = sorted.slice(-count); + const dropped = sorted.slice(0, -count); + return { keptRolls: kept, droppedRolls: dropped }; + } else { + const kept = sorted.slice(0, -count); + const dropped = sorted.slice(-count); + return { keptRolls: kept, droppedRolls: dropped }; + } + } + case "kl": // Keep Lowest - 保留最小的 N 个 + case "dl": { // Drop Lowest - 丢弃最小的 N 个 + const count = modifier.count; + if (modifier.type === "kl") { + const kept = sorted.slice(0, count); + const dropped = sorted.slice(count); + return { keptRolls: kept, droppedRolls: dropped }; + } else { + const kept = sorted.slice(count); + const dropped = sorted.slice(0, count); + return { keptRolls: kept, droppedRolls: dropped }; + } + } + } +} + +/** + * 执行完整的掷骰 + */ +export function roll(formula: string): RollResult { + const tokens = tokenize(formula); + const pools = parse(tokens); + + const poolResults = pools.map((pool) => rollDicePool(pool)); + + const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0); + + // 生成详细表达式 - 使用 HTML 格式 + const parts: string[] = []; + for (const result of poolResults) { + const diceCount = result.pool.dice.filter((d) => d.sides > 0).length; + const fixedValue = result.pool.dice.find((d) => d.sides === 0)?.value || 0; + const modStr = result.pool.modifier + ? `${result.pool.modifier.type}${result.pool.modifier.count}` + : ""; + + // 骰子部分 + let dicePart: string; + if (diceCount > 0) { + const sides = result.pool.dice[0]?.sides || 0; + dicePart = `${diceCount}d${sides}${modStr}`; + } else { + dicePart = `${fixedValue}`; + } + + // 构建骰子结果 HTML + const rollSpans = result.rolls.map((roll) => { + const isKept = result.keptRolls.some( + (kept) => kept === roll + ); + if (isKept) { + return `[${roll.value}]`; + } else { + return `[${roll.value}]`; + } + }); + + const rollsStr = rollSpans.join(" "); + parts.push(`${dicePart} ${rollsStr} = ${result.subtotal}`); + } + + const detail = parts.join(" + "); + + return { + pools: poolResults, + total, + detail, + }; +} diff --git a/src/components/md-commander/hooks/dice-engine/test.ts b/src/components/md-commander/hooks/dice-engine/test.ts new file mode 100644 index 0000000..8ff11ca --- /dev/null +++ b/src/components/md-commander/hooks/dice-engine/test.ts @@ -0,0 +1,129 @@ +import { tokenize, parse } from "./parser"; +import { rollDicePool, roll } from "./roller"; +import type { DicePool } from "./types"; + +/** + * 骰子引擎测试 + * 在浏览器控制台运行:window.testDiceEngine() + */ + +export function testDiceEngine() { + console.log("=== 骰子引擎测试 ===\n"); + + // 测试用例 + const testCases: Array<{ + name: string; + formula: string; + expected: { + hasPools: number; + hasModifier?: boolean; + }; + }> = [ + { + name: "标准骰子 3d6", + formula: "3d6", + expected: { hasPools: 1 }, + }, + { + name: "单个骰子 d20", + formula: "d20", + expected: { hasPools: 1 }, + }, + { + name: "骰池字面量 {d4,d8}", + formula: "{d4,d8}", + expected: { hasPools: 1 }, + }, + { + name: "带修饰符 3d6kh2", + formula: "3d6kh2", + expected: { hasPools: 1, hasModifier: true }, + }, + { + name: "带修饰符 4d6dl1", + formula: "4d6dl1", + expected: { hasPools: 1, hasModifier: true }, + }, + { + name: "组合骰池 3d6+5", + formula: "3d6+5", + expected: { hasPools: 2 }, + }, + { + name: "负数组合 3d6-2", + formula: "3d6-2", + expected: { hasPools: 2 }, + }, + { + name: "复杂公式 3d6+{d4,d8}kh2-5", + formula: "3d6+{d4,d8}kh2-5", + expected: { hasPools: 4, hasModifier: true }, + }, + { + name: "多个骰池 {d6,d8}+{d10,d12}", + formula: "{d6,d8}+{d10,d12}", + expected: { hasPools: 2 }, + }, + { + name: "Keep Lowest 5d6kl3", + formula: "5d6kl3", + expected: { hasPools: 1, hasModifier: true }, + }, + { + name: "Drop Highest 4d6dh1", + formula: "4d6dh1", + expected: { hasPools: 1, hasModifier: true }, + }, + ]; + + let passed = 0; + let failed = 0; + + for (const tc of testCases) { + try { + // 测试词法分析 + const tokens = tokenize(tc.formula); + console.log(`\n公式:${tc.formula}`); + console.log(` 令牌:${tokens.length} 个`); + + // 测试语法分析 + const pools = parse(tokens); + console.log(` 骰池:${pools.length} 个`); + + // 测试掷骰 + const result = roll(tc.formula); + console.log(` 结果:${result.total}`); + console.log(` 详情:${result.detail}`); + + // 验证 + if (pools.length >= tc.expected.hasPools) { + console.log(` ✓ 通过`); + passed++; + } else { + console.log(` ✗ 失败:期望至少 ${tc.expected.hasPools} 个骰池,实际 ${pools.length} 个`); + failed++; + } + + if (tc.expected.hasModifier) { + const hasMod = pools.some((p) => p.modifier !== undefined); + if (hasMod) { + console.log(` ✓ 修饰符正确`); + } else { + console.log(` ✗ 修饰符缺失`); + failed++; + } + } + } catch (e) { + console.log(` ✗ 错误:${e instanceof Error ? e.message : String(e)}`); + failed++; + } + } + + console.log(`\n=== 测试完成:${passed} 通过,${failed} 失败 ===`); + return { passed, failed }; +} + +// 在浏览器环境下暴露到全局 +if (typeof window !== "undefined") { + (window as any).testDiceEngine = testDiceEngine; +} diff --git a/src/components/md-commander/hooks/dice-engine/types.ts b/src/components/md-commander/hooks/dice-engine/types.ts new file mode 100644 index 0000000..a516681 --- /dev/null +++ b/src/components/md-commander/hooks/dice-engine/types.ts @@ -0,0 +1,65 @@ +/** + * 骰子引擎类型定义 + * + * 支持语法: + * - 3d6: 标准骰子表示 + * - {d4, d8}: 骰池字面量 + * - kh2/dl1: 修饰符 (keep high / drop low) + * - +: 组合骰池 + * - -: 转换负数后组合 + * - 数字字面量:单一数字的骰池 + */ + +/** + * 单个骰子结果 + */ +export interface DieResult { + sides: number; // 骰子面数,0 表示固定数字 + value: number; // 掷骰结果 + isNegative: boolean; // 是否为负数 +} + +/** + * 骰池 + */ +export interface DicePool { + dice: DieResult[]; // 骰子列表(未掷骰前为模板) + modifier?: { + type: 'kh' | 'kl' | 'dh' | 'dl'; + count: number; + }; + isNegative: boolean; // 整个骰池是否为负 +} + +/** + * 掷骰结果 + */ +export interface RollResult { + pools: DicePoolResult[]; // 各骰池结果 + total: number; // 总和 + detail: string; // 详细表达式 +} + +/** + * 单个骰池的掷骰结果 + */ +export interface DicePoolResult { + pool: DicePool; // 原始骰池定义 + rolls: DieResult[]; // 掷骰结果 + keptRolls: DieResult[]; // 保留的骰子(应用修饰符后) + droppedRolls: DieResult[]; // 丢弃的骰子 + subtotal: number; // 该骰池小计 +} + +/** + * 解析后的令牌 + */ +export type Token = + | { type: 'number'; value: number } + | { type: 'dice'; count: number; sides: number } + | { type: 'pool_start' } + | { type: 'pool_end' } + | { type: 'comma' } + | { type: 'plus' } + | { type: 'minus' } + | { type: 'modifier'; modType: 'kh' | 'kl' | 'dh' | 'dl'; count: number }; diff --git a/src/components/md-commander/hooks/index.ts b/src/components/md-commander/hooks/index.ts index 0ccec30..0eb0b90 100644 --- a/src/components/md-commander/hooks/index.ts +++ b/src/components/md-commander/hooks/index.ts @@ -1,2 +1,4 @@ export { useCommander, defaultCommands, parseInput, getCompletions, getResultClass } from './useCommander'; export type { UseCommanderReturn } from './useCommander'; +export { useDiceRoller } from './useDiceRoller'; +export * from './dice-engine'; diff --git a/src/components/md-commander/hooks/useCommander.ts b/src/components/md-commander/hooks/useCommander.ts index 41642dd..057fb09 100644 --- a/src/components/md-commander/hooks/useCommander.ts +++ b/src/components/md-commander/hooks/useCommander.ts @@ -4,6 +4,7 @@ import { CommanderEntry, CompletionItem, } from "../types"; +import { useDiceRoller } from "./useDiceRoller"; // ==================== 默认命令 ==================== @@ -43,32 +44,23 @@ export const defaultCommands: Record = { }, roll: { command: "roll", - description: "掷骰子", + description: "掷骰子 - 支持骰池、修饰符和组合", options: { formula: { option: "formula", - description: "骰子公式,如 2d6+3", + description: "骰子公式,如 3d6+{d4,d8}kh2-5", type: "string", required: true, }, }, handler: (args) => { const formula = args.formula || "1d6"; - const match = formula.match(/^(\d+)?d(\d+)([+-]\d+)?$/i); - if (!match) { - return { message: `无效的骰子公式:${formula}`, type: "error" }; - } - const count = parseInt(match[1] || "1"); - const sides = parseInt(match[2]); - const modifier = parseInt(match[3] || "0"); - const rolls: number[] = []; - for (let i = 0; i < count; i++) { - rolls.push(Math.floor(Math.random() * sides) + 1); - } - const total = rolls.reduce((a, b) => a + b, 0) + modifier; + const { rollSimple } = useDiceRoller(); + const result = rollSimple(formula); return { - message: `${formula} = [${rolls.join(", ")}]${modifier !== 0 ? (modifier > 0 ? `+${modifier}` : modifier) : ""} = ${total}`, - type: "success", + message: result.text, + isHtml: result.isHtml, + type: result.text.startsWith("错误") ? "error" : "success", }; }, }, diff --git a/src/components/md-commander/hooks/useDiceRoller.ts b/src/components/md-commander/hooks/useDiceRoller.ts new file mode 100644 index 0000000..6b7ff2a --- /dev/null +++ b/src/components/md-commander/hooks/useDiceRoller.ts @@ -0,0 +1,86 @@ +import { roll as rollDice } from "./dice-engine"; + +export interface DiceRollerResult { + formula: string; + result: { + total: number; + detail: string; + pools: Array<{ + rolls: number[]; + keptRolls: number[]; + subtotal: number; + }>; + }; + success: boolean; + error?: string; +} + +/** + * 骰子引擎 Hook + * 提供高级骰子功能:骰池、修饰符、组合等 + */ +export function useDiceRoller() { + /** + * 执行掷骰 + * @param formula 骰子公式,支持: + * - 3d6: 标准骰子 + * - {d4, d8, 2d6}: 骰池字面量 + * - kh2/dl1: 修饰符 + * - +: 组合骰池 + * - -: 负数组合 + * - 5: 固定数字 + */ + function roll(formula: string): DiceRollerResult { + try { + const result = rollDice(formula); + + return { + formula, + result: { + total: result.total, + detail: result.detail, + pools: result.pools.map((pool) => ({ + rolls: pool.rolls.map((r) => r.value), + keptRolls: pool.keptRolls.map((r) => r.value), + subtotal: pool.subtotal, + })), + }, + success: true, + }; + } catch (e) { + return { + formula, + result: { + total: 0, + detail: "", + pools: [], + }, + success: false, + error: e instanceof Error ? e.message : String(e), + }; + } + } + + /** + * 简化版掷骰,返回 HTML 格式结果 + */ + function rollSimple(formula: string): { text: string; isHtml?: boolean } { + try { + const result = rollDice(formula); + return { + text: `${result.formula} = ${result.total} (${result.detail})`, + isHtml: true, // detail 包含 HTML + }; + } catch (e) { + return { + text: `错误:${e instanceof Error ? e.message : String(e)}`, + isHtml: false, + }; + } + } + + return { + roll, + rollSimple, + }; +} diff --git a/src/components/md-commander/index.tsx b/src/components/md-commander/index.tsx index b474699..08d73d8 100644 --- a/src/components/md-commander/index.tsx +++ b/src/components/md-commander/index.tsx @@ -1,23 +1,10 @@ import { customElement, noShadowDOM } from "solid-element"; import { onMount, onCleanup } from "solid-js"; -import { useCommander } from "./hooks"; +import { useCommander } from "./hooks/useCommander"; import { CommanderInput } from "./CommanderInput"; import { CommanderEntries } from "./CommanderEntries"; import type { MdCommanderProps } from "./types"; -export { CommanderInput } from './CommanderInput'; -export type { CommanderInputProps } from './CommanderInput'; -export { CommanderEntries } from './CommanderEntries'; -export type { CommanderEntriesProps } from './CommanderEntries'; -export type { - MdCommanderProps, - MdCommanderCommand, - MdCommanderOption, - MdCommanderOptionType, - CommanderEntry, - CompletionItem, -} from './types'; - customElement( "md-commander", { placeholder: "", class: "", height: "" }, @@ -73,14 +60,17 @@ customElement( return (
- {/* 命令执行结果 */} - + {/* 命令执行结果 */} + commander.setInputValue(cmd)} + /> {/* 命令输入框 */} -
+
) => { message: string; type?: "success" | "error" | "info" | "warning"; + isHtml?: boolean; }; } @@ -43,6 +44,7 @@ export interface CommanderEntry { result: { message: string; type?: "success" | "error" | "warning" | "info"; + isHtml?: boolean; }; timestamp: Date; }