Compare commits
No commits in common. "28066c7fff1719fe1750e0250439c0a7be3cc443" and "6da2284eade733b4abe0723c92b7e42ef54bec8f" have entirely different histories.
28066c7fff
...
6da2284ead
|
|
@ -5,7 +5,6 @@ import './md-link';
|
||||||
import './md-pins';
|
import './md-pins';
|
||||||
import './md-bg';
|
import './md-bg';
|
||||||
import './md-deck';
|
import './md-deck';
|
||||||
import './md-commander/index';
|
|
||||||
|
|
||||||
// 导出组件
|
// 导出组件
|
||||||
export { Article } from './Article';
|
export { Article } from './Article';
|
||||||
|
|
@ -18,11 +17,3 @@ export { FileTreeNode, HeadingNode } from './FileTree';
|
||||||
export type { DiceProps } from './md-dice';
|
export type { DiceProps } from './md-dice';
|
||||||
export type { TableProps } from './md-table';
|
export type { TableProps } from './md-table';
|
||||||
export type { BgProps } from './md-bg';
|
export type { BgProps } from './md-bg';
|
||||||
export type {
|
|
||||||
MdCommanderProps,
|
|
||||||
MdCommanderCommand,
|
|
||||||
MdCommanderOption,
|
|
||||||
MdCommanderOptionType,
|
|
||||||
CommanderEntry,
|
|
||||||
CompletionItem,
|
|
||||||
} from './md-commander';
|
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
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<CommanderEntriesProps> = (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 (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class="commander-entries flex-1 overflow-auto p-3 bg-white space-y-2"
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.entries().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-gray-400 text-center py-8">暂无命令执行记录</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={props.entries()}>
|
|
||||||
{(entry) => (
|
|
||||||
<div class="border-l-2 border-gray-300 pl-3 py-1">
|
|
||||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
|
||||||
<span
|
|
||||||
class="font-mono cursor-pointer hover:text-blue-600 hover:underline"
|
|
||||||
onClick={() => handleCommandClick(entry.command)}
|
|
||||||
title="点击复制到输入框"
|
|
||||||
>
|
|
||||||
{entry.command}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{entry.timestamp.toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class={`text-sm whitespace-pre-wrap ${getResultClass(entry.result.type)}`}
|
|
||||||
innerHTML={entry.result.isHtml ? entry.result.message : undefined}
|
|
||||||
>
|
|
||||||
{!entry.result.isHtml && entry.result.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import {type Component, For, Show} from "solid-js";
|
|
||||||
import type { CompletionItem } from "./types";
|
|
||||||
|
|
||||||
export interface CommanderInputProps {
|
|
||||||
placeholder?: string;
|
|
||||||
inputValue: () => string;
|
|
||||||
onInput: (e: Event) => void;
|
|
||||||
onKeyDown: (e: KeyboardEvent) => void;
|
|
||||||
onFocus: () => void;
|
|
||||||
onBlur: () => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
showCompletions: () => boolean;
|
|
||||||
completions: () => CompletionItem[];
|
|
||||||
selectedCompletion: () => number;
|
|
||||||
onSelectCompletion: (idx: number) => void;
|
|
||||||
onAcceptCompletion: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CommanderInput: Component<CommanderInputProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center border-b border-gray-300 bg-gray-50 px-3 py-2">
|
|
||||||
<span class="text-gray-500 mr-2">❯</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={props.inputValue()}
|
|
||||||
onInput={props.onInput}
|
|
||||||
onKeyDown={props.onKeyDown}
|
|
||||||
onFocus={props.onFocus}
|
|
||||||
onBlur={props.onBlur}
|
|
||||||
placeholder={props.placeholder || "输入命令..."}
|
|
||||||
class="flex-1 bg-transparent outline-none text-gray-800"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={props.onSubmit}
|
|
||||||
class="ml-2 px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
执行
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 自动补全下拉框 - 向上弹出 */}
|
|
||||||
<Show when={props.showCompletions() && props.completions().length > 0}>
|
|
||||||
<div class="absolute left-0 bottom-full w-full bg-white border border-gray-300 shadow-lg max-h-48 overflow-auto mb-1">
|
|
||||||
<For each={props.completions()}>{(comp, idx) => (
|
|
||||||
<div
|
|
||||||
class={`px-3 py-2 cursor-pointer flex justify-between items-center ${
|
|
||||||
idx() === props.selectedCompletion()
|
|
||||||
? "bg-blue-100"
|
|
||||||
: "hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
props.onSelectCompletion(idx());
|
|
||||||
props.onAcceptCompletion();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class={`text-xs px-1 rounded ${
|
|
||||||
comp.type === "command"
|
|
||||||
? "bg-purple-100 text-purple-700"
|
|
||||||
: comp.type === "option"
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: "bg-gray-100 text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{comp.type}
|
|
||||||
</span>
|
|
||||||
<span class="font-mono text-sm">{comp.label}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={comp.description}>
|
|
||||||
<span class="text-xs text-gray-500">{comp.description}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export type {
|
|
||||||
DieResult,
|
|
||||||
DicePool,
|
|
||||||
RollResult,
|
|
||||||
DicePoolResult,
|
|
||||||
Token,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export { tokenize, parse } from "./parser";
|
|
||||||
export { rollDie, rollDicePool, applyModifier, roll } from "./roller";
|
|
||||||
export { testDiceEngine } from "./test";
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
import type { Token, DicePool } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 词法分析器 - 将输入字符串转换为令牌流
|
|
||||||
*/
|
|
||||||
export function tokenize(input: string): Token[] {
|
|
||||||
const tokens: Token[] = [];
|
|
||||||
let i = 0;
|
|
||||||
const str = input.replace(/\s+/g, ""); // 移除所有空格
|
|
||||||
|
|
||||||
while (i < str.length) {
|
|
||||||
const char = str[i];
|
|
||||||
|
|
||||||
// 修饰符 kh/kl/dh/dl - 必须在检查 'd' 骰子之前检查
|
|
||||||
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 });
|
|
||||||
continue;
|
|
||||||
} 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 });
|
|
||||||
continue;
|
|
||||||
} 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 });
|
|
||||||
continue;
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数字字面量
|
|
||||||
if (/\d/.test(char)) {
|
|
||||||
let numStr = "";
|
|
||||||
while (i < str.length && /\d/.test(str[i])) {
|
|
||||||
numStr += str[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
const num = parseInt(numStr);
|
|
||||||
|
|
||||||
// 检查是否是 NdX 格式
|
|
||||||
if (str[i] === "d" || str[i] === "D") {
|
|
||||||
i++; // 跳过 'd'
|
|
||||||
let sidesStr = "";
|
|
||||||
while (i < str.length && /\d/.test(str[i])) {
|
|
||||||
sidesStr += str[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
const sides = parseInt(sidesStr);
|
|
||||||
tokens.push({ type: "dice", count: num, sides });
|
|
||||||
} else {
|
|
||||||
tokens.push({ type: "number", value: num });
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单独的 dX 格式(等同于 1dX)
|
|
||||||
if (char === "d" || char === "D") {
|
|
||||||
i++;
|
|
||||||
let sidesStr = "";
|
|
||||||
while (i < str.length && /\d/.test(str[i])) {
|
|
||||||
sidesStr += str[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if (sidesStr.length > 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未知字符,跳过
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = tokens[i];
|
|
||||||
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") {
|
|
||||||
// 数字作为 0 面骰子(固定值)
|
|
||||||
dice.push({
|
|
||||||
sides: 0,
|
|
||||||
value: token.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<Token, { type: "modifier" }>;
|
|
||||||
modifier = { type: mod.modType, count: mod.count };
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pool: { dice, modifier, isNegative: sign < 0 },
|
|
||||||
nextIndex: i,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
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 diceToRoll = pool.dice.filter((die) => die.sides > 0);
|
|
||||||
const fixedNumbers = pool.dice.filter((die) => die.sides === 0);
|
|
||||||
|
|
||||||
// 只掷真正的骰子
|
|
||||||
const rolls: DieResult[] = diceToRoll.map((die) => ({
|
|
||||||
sides: die.sides,
|
|
||||||
value: rollDie(die.sides),
|
|
||||||
isNegative: die.isNegative,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 添加固定数字
|
|
||||||
const allRolls: DieResult[] = [
|
|
||||||
...rolls,
|
|
||||||
...fixedNumbers.map((die) => ({
|
|
||||||
sides: 0,
|
|
||||||
value: die.value,
|
|
||||||
isNegative: die.isNegative,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
// 应用修饰符(只对有面数的骰子生效)
|
|
||||||
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
|
|
||||||
|
|
||||||
// 固定数字总是保留
|
|
||||||
const finalKeptRolls = [...keptRolls, ...allRolls.filter((r) => r.sides === 0)];
|
|
||||||
|
|
||||||
// 计算小计
|
|
||||||
const subtotal = finalKeptRolls.reduce((sum, roll) => {
|
|
||||||
return sum + (roll.isNegative ? -roll.value : roll.value);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pool,
|
|
||||||
rolls: allRolls,
|
|
||||||
keptRolls: finalKeptRolls,
|
|
||||||
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;
|
|
||||||
const higher = sorted.slice(-count);
|
|
||||||
const rest = sorted.slice(0, -count);
|
|
||||||
if (modifier.type === "kh") {
|
|
||||||
return { keptRolls: higher, droppedRolls: rest };
|
|
||||||
} else {
|
|
||||||
return { keptRolls: rest, droppedRolls: higher };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "kl": // Keep Lowest - 保留最小的 N 个
|
|
||||||
case "dl": { // Drop Lowest - 丢弃最小的 N 个
|
|
||||||
const count = modifier.count;
|
|
||||||
const lower = sorted.slice(0, count);
|
|
||||||
const rest = sorted.slice(count);
|
|
||||||
if (modifier.type === "kl") {
|
|
||||||
return { keptRolls: lower, droppedRolls: rest };
|
|
||||||
} else {
|
|
||||||
return { keptRolls: rest, droppedRolls: lower };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行完整的掷骰
|
|
||||||
*/
|
|
||||||
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 rollSpans: string[] = [];
|
|
||||||
for (const result of poolResults) {
|
|
||||||
// 构建骰子结果 HTML
|
|
||||||
for (const roll of result.rolls) {
|
|
||||||
const isKept = result.keptRolls.some((kept) => kept === roll);
|
|
||||||
if (roll.sides === 0) {
|
|
||||||
// 固定数字,不显示括号
|
|
||||||
rollSpans.push(`<strong>${roll.isNegative ? '-' : ''}${roll.value}</strong>`);
|
|
||||||
} else if (isKept) {
|
|
||||||
rollSpans.push(`<strong>[${roll.value}]</strong>`);
|
|
||||||
} else {
|
|
||||||
rollSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rollsStr = rollSpans.join(" + ");
|
|
||||||
const detail = `<strong>${total}</strong> = ${rollsStr}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
pools: poolResults,
|
|
||||||
total,
|
|
||||||
detail,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
/**
|
|
||||||
* 骰子引擎类型定义
|
|
||||||
*
|
|
||||||
* 支持语法:
|
|
||||||
* - 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 };
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export { useCommander, defaultCommands, parseInput, getCompletions, getResultClass } from './useCommander';
|
|
||||||
export type { UseCommanderReturn } from './useCommander';
|
|
||||||
export { useDiceRoller } from './useDiceRoller';
|
|
||||||
export * from './dice-engine';
|
|
||||||
|
|
@ -1,441 +0,0 @@
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import {
|
|
||||||
MdCommanderCommand,
|
|
||||||
CommanderEntry,
|
|
||||||
CompletionItem,
|
|
||||||
} from "../types";
|
|
||||||
import { useDiceRoller } from "./useDiceRoller";
|
|
||||||
|
|
||||||
// ==================== 默认命令 ====================
|
|
||||||
|
|
||||||
export const defaultCommands: Record<string, MdCommanderCommand> = {
|
|
||||||
help: {
|
|
||||||
command: "help",
|
|
||||||
description: "显示帮助信息或特定命令的帮助",
|
|
||||||
options: {
|
|
||||||
cmd: {
|
|
||||||
option: "cmd",
|
|
||||||
description: "要查询的命令名",
|
|
||||||
type: "enum",
|
|
||||||
values: [], // 运行时填充
|
|
||||||
},
|
|
||||||
},
|
|
||||||
handler: (args) => {
|
|
||||||
if (args.cmd) {
|
|
||||||
return {
|
|
||||||
message: `命令:${args.cmd}\n描述:${defaultCommands[args.cmd]?.description || "无描述"}`,
|
|
||||||
type: "info",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const cmdList = Object.keys(defaultCommands).join(", ");
|
|
||||||
return {
|
|
||||||
message: `可用命令:${cmdList}`,
|
|
||||||
type: "info",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
clear: {
|
|
||||||
command: "clear",
|
|
||||||
description: "清空命令历史",
|
|
||||||
handler: () => ({
|
|
||||||
message: "命令历史已清空",
|
|
||||||
type: "success",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
roll: {
|
|
||||||
command: "roll",
|
|
||||||
description: "掷骰子 - 支持骰池、修饰符和组合",
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: "formula",
|
|
||||||
description: "骰子公式,如 3d6+{d4,d8}kh2-5",
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
handler: (args) => {
|
|
||||||
const formula = args.params.formula || "1d6";
|
|
||||||
const { rollSimple } = useDiceRoller();
|
|
||||||
const result = rollSimple(formula);
|
|
||||||
return {
|
|
||||||
message: result.text,
|
|
||||||
isHtml: result.isHtml,
|
|
||||||
type: result.text.startsWith("错误") ? "error" : "success",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
|
||||||
|
|
||||||
export function parseInput(input: string, commands?: Record<string, MdCommanderCommand>): {
|
|
||||||
command?: string;
|
|
||||||
params: Record<string, string>;
|
|
||||||
options: Record<string, string>;
|
|
||||||
incompleteParam?: { index: number; value: string };
|
|
||||||
} {
|
|
||||||
const result: {
|
|
||||||
command?: string;
|
|
||||||
params: Record<string, string>;
|
|
||||||
options: Record<string, string>;
|
|
||||||
incompleteParam?: { index: number; value: string };
|
|
||||||
} = {
|
|
||||||
params: {},
|
|
||||||
options: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return result;
|
|
||||||
|
|
||||||
const parts = trimmed.split(/\s+/);
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
// 获取命令
|
|
||||||
if (parts[0] && !parts[0].startsWith("-")) {
|
|
||||||
result.command = parts[0];
|
|
||||||
i = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取命令的参数定义
|
|
||||||
const cmd = result.command ? commands?.[result.command] : undefined;
|
|
||||||
const paramDefs = cmd?.parameters || [];
|
|
||||||
let paramIndex = 0;
|
|
||||||
|
|
||||||
// 解析参数和选项
|
|
||||||
while (i < parts.length) {
|
|
||||||
const part = parts[i];
|
|
||||||
|
|
||||||
if (part.startsWith("--")) {
|
|
||||||
// 选项 --key=value 或 --key value
|
|
||||||
const eqIndex = part.indexOf("=");
|
|
||||||
if (eqIndex !== -1) {
|
|
||||||
const key = part.slice(2, eqIndex);
|
|
||||||
const value = part.slice(eqIndex + 1);
|
|
||||||
result.options[key] = value;
|
|
||||||
} else {
|
|
||||||
const key = part.slice(2);
|
|
||||||
if (i + 1 < parts.length && !parts[i + 1].startsWith("-")) {
|
|
||||||
result.options[key] = parts[i + 1];
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
// 未完成的选项
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (part.startsWith("-") && part.length === 2) {
|
|
||||||
// 短选项 -k value
|
|
||||||
const key = part.slice(1);
|
|
||||||
if (i + 1 < parts.length && !parts[i + 1].startsWith("-")) {
|
|
||||||
result.options[key] = parts[i + 1];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 位置参数
|
|
||||||
if (paramIndex < paramDefs.length) {
|
|
||||||
const paramDef = paramDefs[paramIndex];
|
|
||||||
result.params[paramDef.name] = part;
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有未完成的位置参数
|
|
||||||
if (paramIndex < paramDefs.length) {
|
|
||||||
const lastPart = parts[parts.length - 1];
|
|
||||||
if (lastPart && !lastPart.startsWith("-")) {
|
|
||||||
result.incompleteParam = {
|
|
||||||
index: paramIndex,
|
|
||||||
value: lastPart,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCompletions(
|
|
||||||
input: string,
|
|
||||||
commands: Record<string, MdCommanderCommand>,
|
|
||||||
): CompletionItem[] {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
|
|
||||||
if (!trimmed || /^\s*$/.test(trimmed)) {
|
|
||||||
return Object.values(commands).map((cmd) => ({
|
|
||||||
label: cmd.command,
|
|
||||||
type: "command",
|
|
||||||
description: cmd.description,
|
|
||||||
insertText: cmd.command,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseInput(trimmed, commands);
|
|
||||||
|
|
||||||
if (!parsed.command || !commands[parsed.command]) {
|
|
||||||
const commandCompletions = Object.values(commands)
|
|
||||||
.filter((cmd) => cmd.command.startsWith(parsed.command || ""))
|
|
||||||
.map((cmd) => ({
|
|
||||||
label: cmd.command,
|
|
||||||
type: "command" as "command",
|
|
||||||
description: cmd.description,
|
|
||||||
insertText: cmd.command,
|
|
||||||
}));
|
|
||||||
if (commandCompletions.length > 0) {
|
|
||||||
return commandCompletions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = commands[parsed.command!];
|
|
||||||
if (!cmd) return [];
|
|
||||||
|
|
||||||
// 检查是否需要参数补全
|
|
||||||
const paramDefs = cmd.parameters || [];
|
|
||||||
const usedParams = Object.keys(parsed.params);
|
|
||||||
|
|
||||||
// 如果还有未填的参数,提供参数值补全
|
|
||||||
if (paramDefs.length > usedParams.length) {
|
|
||||||
const paramDef = paramDefs[usedParams.length];
|
|
||||||
if (paramDef.type === "enum" && paramDef.values) {
|
|
||||||
const currentValue = parsed.incompleteParam?.value || "";
|
|
||||||
return paramDef.values
|
|
||||||
.filter((v) => v.startsWith(currentValue))
|
|
||||||
.map((v) => ({
|
|
||||||
label: v,
|
|
||||||
type: "value",
|
|
||||||
description: paramDef.description,
|
|
||||||
insertText: v,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// 其他类型的参数,显示提示
|
|
||||||
if (parsed.incompleteParam) {
|
|
||||||
return [{
|
|
||||||
label: `<${paramDef.name}>`,
|
|
||||||
type: "value",
|
|
||||||
description: `${paramDef.type}${paramDef.required !== false ? " (必填)" : ""}: ${paramDef.description || ""}`,
|
|
||||||
insertText: "",
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选项补全
|
|
||||||
if (!cmd.options) return [];
|
|
||||||
|
|
||||||
const usedOptions = Object.keys(parsed.options);
|
|
||||||
return Object.values(cmd.options)
|
|
||||||
.filter((opt) => !usedOptions.includes(opt.option))
|
|
||||||
.map((opt) => ({
|
|
||||||
label: `--${opt.option}`,
|
|
||||||
type: "option",
|
|
||||||
description: opt.description,
|
|
||||||
insertText: `--${opt.option}=${opt.type === "boolean" ? "" : ""}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getResultClass(
|
|
||||||
type?: "success" | "error" | "warning" | "info",
|
|
||||||
): string {
|
|
||||||
switch (type) {
|
|
||||||
case "success":
|
|
||||||
return "text-green-600";
|
|
||||||
case "error":
|
|
||||||
return "text-red-600";
|
|
||||||
case "warning":
|
|
||||||
return "text-yellow-600";
|
|
||||||
case "info":
|
|
||||||
default:
|
|
||||||
return "text-blue-600";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Commander Hook ====================
|
|
||||||
|
|
||||||
export interface UseCommanderReturn {
|
|
||||||
inputValue: () => string;
|
|
||||||
entries: () => CommanderEntry[];
|
|
||||||
showCompletions: () => boolean;
|
|
||||||
completions: () => CompletionItem[];
|
|
||||||
selectedCompletion: () => number;
|
|
||||||
isFocused: () => boolean;
|
|
||||||
setInputValue: (v: string) => void;
|
|
||||||
setEntries: (updater: (prev: CommanderEntry[]) => CommanderEntry[]) => void;
|
|
||||||
setShowCompletions: (v: boolean) => void;
|
|
||||||
setSelectedCompletion: (v: number | ((prev: number) => number)) => void;
|
|
||||||
setIsFocused: (v: boolean) => void;
|
|
||||||
handleCommand: () => void;
|
|
||||||
updateCompletions: () => void;
|
|
||||||
acceptCompletion: () => void;
|
|
||||||
commands: Record<string, MdCommanderCommand>;
|
|
||||||
historyIndex: () => number;
|
|
||||||
setHistoryIndex: (v: number) => void;
|
|
||||||
commandHistory: () => string[];
|
|
||||||
navigateHistory: (direction: 'up' | 'down') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommander(
|
|
||||||
customCommands?: Record<string, MdCommanderCommand>,
|
|
||||||
): UseCommanderReturn {
|
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
|
||||||
const [entries, setEntries] = createSignal<CommanderEntry[]>([]);
|
|
||||||
const [showCompletions, setShowCompletions] = createSignal(false);
|
|
||||||
const [completions, setCompletions] = createSignal<CompletionItem[]>([]);
|
|
||||||
const [selectedCompletion, setSelectedCompletion] = createSignal(0);
|
|
||||||
const [isFocused, setIsFocused] = createSignal(false);
|
|
||||||
|
|
||||||
// 命令历史
|
|
||||||
const [commandHistory, setCommandHistory] = createSignal<string[]>([]);
|
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(-1);
|
|
||||||
|
|
||||||
const commands = { ...defaultCommands, ...customCommands };
|
|
||||||
|
|
||||||
// 更新 help 命令的选项值
|
|
||||||
if (commands.help?.options?.cmd) {
|
|
||||||
commands.help.options.cmd.values = Object.keys(commands).filter(
|
|
||||||
(k) => k !== "help",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCommand = () => {
|
|
||||||
const input = inputValue().trim();
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
const parsed = parseInput(input, commands);
|
|
||||||
const commandName = parsed.command;
|
|
||||||
const cmd = commands[commandName!];
|
|
||||||
|
|
||||||
let result: { message: string; type?: "success" | "error" | "warning" | "info"; isHtml?: boolean };
|
|
||||||
|
|
||||||
if (!cmd) {
|
|
||||||
result = { message: `未知命令:${commandName}`, type: "error" };
|
|
||||||
} else if (cmd.handler) {
|
|
||||||
try {
|
|
||||||
result = cmd.handler({ params: parsed.params, options: parsed.options });
|
|
||||||
} catch (e) {
|
|
||||||
result = {
|
|
||||||
message: `执行错误:${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
type: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = { message: `命令 ${commandName} 已执行(无处理器)`, type: "info" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEntry: CommanderEntry = {
|
|
||||||
id: Date.now().toString() + Math.random().toString(36).slice(2),
|
|
||||||
command: input,
|
|
||||||
args: parsed.options,
|
|
||||||
result,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setEntries((prev) => [...prev, newEntry]);
|
|
||||||
|
|
||||||
// 添加到命令历史
|
|
||||||
setCommandHistory((prev) => [...prev, input]);
|
|
||||||
setHistoryIndex(-1); // 重置历史索引
|
|
||||||
|
|
||||||
setInputValue("");
|
|
||||||
setShowCompletions(false);
|
|
||||||
|
|
||||||
if (commandName === "clear") {
|
|
||||||
setEntries([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCompletions = () => {
|
|
||||||
const input = inputValue();
|
|
||||||
const comps = getCompletions(input, commands);
|
|
||||||
setCompletions(comps);
|
|
||||||
setShowCompletions(comps.length > 0 && isFocused());
|
|
||||||
setSelectedCompletion(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const acceptCompletion = () => {
|
|
||||||
const idx = selectedCompletion();
|
|
||||||
const comp = completions()[idx];
|
|
||||||
if (!comp) return;
|
|
||||||
|
|
||||||
const input = inputValue();
|
|
||||||
const parsed = parseInput(input, commands);
|
|
||||||
|
|
||||||
let newValue: string;
|
|
||||||
if (comp.type === "command") {
|
|
||||||
newValue = comp.insertText + " ";
|
|
||||||
} else if (comp.type === "option") {
|
|
||||||
const base = parsed.command || "";
|
|
||||||
const existingOptions = Object.entries(parsed.options)
|
|
||||||
.map(([k, v]) => `--${k}=${v}`)
|
|
||||||
.join(" ");
|
|
||||||
newValue = `${base} ${existingOptions}${existingOptions ? " " : ""}${comp.insertText}`;
|
|
||||||
} else if (comp.type === "value") {
|
|
||||||
// 参数值补全
|
|
||||||
const cmd = parsed.command ? commands[parsed.command] : null;
|
|
||||||
const paramDefs = cmd?.parameters || [];
|
|
||||||
const usedParams = Object.keys(parsed.params);
|
|
||||||
|
|
||||||
if (paramDefs.length > usedParams.length) {
|
|
||||||
// 当前参数的补全
|
|
||||||
const base = parsed.command || "";
|
|
||||||
const existingParams = Object.values(parsed.params).join(" ");
|
|
||||||
const existingOptions = Object.entries(parsed.options)
|
|
||||||
.map(([k, v]) => `--${k}=${v}`)
|
|
||||||
.join(" ");
|
|
||||||
newValue = `${base} ${existingParams}${existingParams ? " " : ""}${comp.insertText}${existingOptions ? " " + existingOptions : ""}`;
|
|
||||||
} else {
|
|
||||||
newValue = input;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newValue = input;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputValue(newValue.trim());
|
|
||||||
setShowCompletions(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigateHistory = (direction: 'up' | 'down') => {
|
|
||||||
const history = commandHistory();
|
|
||||||
if (history.length === 0) return;
|
|
||||||
|
|
||||||
let newIndex = historyIndex();
|
|
||||||
if (direction === 'up') {
|
|
||||||
// 向上浏览历史(更早的命令)
|
|
||||||
if (newIndex < history.length - 1) {
|
|
||||||
newIndex++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 向下浏览历史(更新的命令)
|
|
||||||
if (newIndex > 0) {
|
|
||||||
newIndex--;
|
|
||||||
} else {
|
|
||||||
// 回到当前输入
|
|
||||||
setInputValue("");
|
|
||||||
setHistoryIndex(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHistoryIndex(newIndex);
|
|
||||||
// 从历史末尾获取命令(最新的在前)
|
|
||||||
setInputValue(history[history.length - 1 - newIndex]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
inputValue,
|
|
||||||
entries,
|
|
||||||
showCompletions,
|
|
||||||
completions,
|
|
||||||
selectedCompletion,
|
|
||||||
isFocused,
|
|
||||||
setInputValue,
|
|
||||||
setEntries,
|
|
||||||
setShowCompletions,
|
|
||||||
setSelectedCompletion,
|
|
||||||
setIsFocused,
|
|
||||||
handleCommand,
|
|
||||||
updateCompletions,
|
|
||||||
acceptCompletion,
|
|
||||||
commands,
|
|
||||||
historyIndex,
|
|
||||||
setHistoryIndex,
|
|
||||||
commandHistory,
|
|
||||||
navigateHistory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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 格式结果
|
|
||||||
* 格式:[1] [2] [3] = <strong>6</strong>
|
|
||||||
*/
|
|
||||||
function rollSimple(formula: string): { text: string; isHtml?: boolean } {
|
|
||||||
try {
|
|
||||||
const result = rollDice(formula);
|
|
||||||
return {
|
|
||||||
text: result.detail,
|
|
||||||
isHtml: true, // detail 包含 HTML
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
text: `错误:${e instanceof Error ? e.message : String(e)}`,
|
|
||||||
isHtml: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
roll,
|
|
||||||
rollSimple,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
import { customElement, noShadowDOM } from "solid-element";
|
|
||||||
import { onMount, onCleanup } from "solid-js";
|
|
||||||
import { useCommander } from "./hooks/useCommander";
|
|
||||||
import { CommanderInput } from "./CommanderInput";
|
|
||||||
import { CommanderEntries } from "./CommanderEntries";
|
|
||||||
import type { MdCommanderProps } from "./types";
|
|
||||||
|
|
||||||
customElement<MdCommanderProps>(
|
|
||||||
"md-commander",
|
|
||||||
{ placeholder: "", class: "", height: "" },
|
|
||||||
(props, { element }) => {
|
|
||||||
noShadowDOM();
|
|
||||||
|
|
||||||
const commander = useCommander(props.commands);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (commander.showCompletions() && commander.completions().length > 0) {
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.setSelectedCompletion(
|
|
||||||
(prev) => (prev + 1) % commander.completions().length,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.setSelectedCompletion(
|
|
||||||
(prev) => (prev - 1 + commander.completions().length) % commander.completions().length,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.acceptCompletion();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
commander.setShowCompletions(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 补全未打开时,使用上下键浏览历史
|
|
||||||
if (e.key === "ArrowUp" && !commander.showCompletions()) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.navigateHistory("up");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowDown" && !commander.showCompletions()) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.navigateHistory("down");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
commander.handleCommand();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const heightStyle = () => props.height || "400px";
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (!element?.contains(e.target as Node)) {
|
|
||||||
commander.setShowCompletions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("click", handleClickOutside);
|
|
||||||
onCleanup(() => document.removeEventListener("click", handleClickOutside));
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`md-commander flex flex-col border border-gray-300 rounded-lg overflow-hidden ${props.class || ""}`}
|
|
||||||
style={{ height: heightStyle() }}
|
|
||||||
>
|
|
||||||
{/* 命令执行结果 */}
|
|
||||||
<CommanderEntries
|
|
||||||
entries={commander.entries}
|
|
||||||
onCommandClick={(cmd) => commander.setInputValue(cmd)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 命令输入框 */}
|
|
||||||
<div class="relative border-t border-gray-300">
|
|
||||||
<CommanderInput
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
inputValue={commander.inputValue}
|
|
||||||
onInput={(e) => {
|
|
||||||
commander.setInputValue((e.target as HTMLInputElement).value);
|
|
||||||
commander.updateCompletions();
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={() => {
|
|
||||||
commander.setIsFocused(true);
|
|
||||||
commander.updateCompletions();
|
|
||||||
}}
|
|
||||||
onBlur={() => commander.setIsFocused(false)}
|
|
||||||
onSubmit={commander.handleCommand}
|
|
||||||
showCompletions={commander.showCompletions}
|
|
||||||
completions={commander.completions}
|
|
||||||
selectedCompletion={commander.selectedCompletion}
|
|
||||||
onSelectCompletion={commander.setSelectedCompletion}
|
|
||||||
onAcceptCompletion={commander.acceptCompletion}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import type { Component } from "solid-js";
|
|
||||||
|
|
||||||
export interface MdCommanderProps {
|
|
||||||
placeholder?: string;
|
|
||||||
class?: string;
|
|
||||||
height?: string;
|
|
||||||
commands?: Record<string, MdCommanderCommand>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdCommanderCommand {
|
|
||||||
command: string;
|
|
||||||
description?: string;
|
|
||||||
parameters?: MdCommanderParameter[];
|
|
||||||
options?: Record<string, MdCommanderOption>;
|
|
||||||
handler?: (args: {
|
|
||||||
params: Record<string, string>;
|
|
||||||
options: Record<string, string>;
|
|
||||||
}) => {
|
|
||||||
message: string;
|
|
||||||
type?: "success" | "error" | "info" | "warning";
|
|
||||||
isHtml?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MdCommanderParameter {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
type: MdCommanderOptionType;
|
|
||||||
required?: boolean;
|
|
||||||
default?: string;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
values?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MdCommanderOptionType =
|
|
||||||
| "string"
|
|
||||||
| "number"
|
|
||||||
| "enum"
|
|
||||||
| "tag"
|
|
||||||
| "boolean";
|
|
||||||
|
|
||||||
export interface MdCommanderOption {
|
|
||||||
option: string;
|
|
||||||
description?: string;
|
|
||||||
type: MdCommanderOptionType;
|
|
||||||
required?: boolean;
|
|
||||||
default?: any;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
values?: string[];
|
|
||||||
EntryComponent?: Component<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommanderEntry {
|
|
||||||
id: string;
|
|
||||||
command: string;
|
|
||||||
args: Record<string, any>;
|
|
||||||
result: {
|
|
||||||
message: string;
|
|
||||||
type?: "success" | "error" | "warning" | "info";
|
|
||||||
isHtml?: boolean;
|
|
||||||
};
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompletionItem {
|
|
||||||
label: string;
|
|
||||||
type: "command" | "option" | "value";
|
|
||||||
description?: string;
|
|
||||||
insertText: string;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue