Compare commits

..

No commits in common. "c38d9bdc4f4da72bbd4338f48d1759b243ba4489" and "a4cfcde613c4bf616654bf8df1d45e11861a8051" have entirely different histories.

12 changed files with 234 additions and 814 deletions

View File

@ -15,7 +15,6 @@ import {
processBlocks, processBlocks,
type ProcessedBlocks, type ProcessedBlocks,
} from "../completions/block-processor.js"; } from "../completions/block-processor.js";
import type { StatSheet } from "../completions/types.js";
interface ContentIndex { interface ContentIndex {
[path: string]: string; [path: string]: string;
@ -84,24 +83,6 @@ function getBestIP(): string {
return best || "localhost"; return best || "localhost";
} }
/**
* Extract the <title> from an SVG string, or null if absent.
*/
function extractSvgTitle(svg: string): string | null {
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
return m ? m[1].trim() : null;
}
/**
* Derive a human-readable label from a filename.
* "character-sheet" -> "Character Sheet"
*/
function labelFromId(id: string): string {
return id
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** /**
* .md * .md
*/ */
@ -114,7 +95,6 @@ export function scanDirectory(dir: string): {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
sparkTables: [], sparkTables: [],
statSheets: [],
}; };
function scan(currentPath: string, relativePath: string) { function scan(currentPath: string, relativePath: string) {
@ -133,8 +113,7 @@ export function scanDirectory(dir: string): {
} else if ( } else if (
entry.endsWith(".md") || entry.endsWith(".md") ||
entry.endsWith(".csv") || entry.endsWith(".csv") ||
entry.endsWith(".yarn") || entry.endsWith(".yarn")
entry.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(fullPath, "utf-8"); const content = readFileSync(fullPath, "utf-8");
@ -144,17 +123,6 @@ export function scanDirectory(dir: string): {
blocks.stats.push(...result.blocks.stats); blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates); blocks.statTemplates.push(...result.blocks.statTemplates);
blocks.sparkTables.push(...result.blocks.sparkTables); blocks.sparkTables.push(...result.blocks.sparkTables);
} else if (entry.endsWith(".sheet.svg")) {
index[normalizedRelPath] = content;
const id = entry.replace(/\.sheet\.svg$/i, "");
const title = extractSvgTitle(content);
const sheet: StatSheet = {
id,
label: title || labelFromId(id),
svg: content,
source: normalizedRelPath,
};
blocks.statSheets.push(sheet);
} else { } else {
index[normalizedRelPath] = content; index[normalizedRelPath] = content;
} }
@ -313,7 +281,6 @@ export function createContentServer(
stats: [], stats: [],
statTemplates: [], statTemplates: [],
sparkTables: [], sparkTables: [],
statSheets: [],
}; };
let completionsIndex: CompletionsPayload = { let completionsIndex: CompletionsPayload = {
dice: [], dice: [],
@ -321,14 +288,13 @@ export function createContentServer(
sparkTables: [], sparkTables: [],
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
/** Re-scan completions from current content index and collected blocks */ /** Re-scan completions from current content index and collected blocks */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks); completionsIndex = scanCompletions(contentIndex, collectedBlocks);
console.log( console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length} sheets=${completionsIndex.statSheets.length}`, `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
); );
} }
@ -353,8 +319,7 @@ export function createContentServer(
if ( if (
path.endsWith(".md") || path.endsWith(".md") ||
path.endsWith(".csv") || path.endsWith(".csv") ||
path.endsWith(".yarn") || path.endsWith(".yarn")
path.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(path, "utf-8"); const content = readFileSync(path, "utf-8");
@ -368,11 +333,6 @@ export function createContentServer(
recomputeCompletions(); recomputeCompletions();
} else { } else {
contentIndex[relPath] = content; contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
recomputeCompletions();
}
} }
console.log(`[新增] ${relPath}`); console.log(`[新增] ${relPath}`);
} catch (e) { } catch (e) {
@ -384,8 +344,7 @@ export function createContentServer(
if ( if (
path.endsWith(".md") || path.endsWith(".md") ||
path.endsWith(".csv") || path.endsWith(".csv") ||
path.endsWith(".yarn") || path.endsWith(".yarn")
path.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(path, "utf-8"); const content = readFileSync(path, "utf-8");
@ -398,11 +357,6 @@ export function createContentServer(
recomputeCompletions(); recomputeCompletions();
} else { } else {
contentIndex[relPath] = content; contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
recomputeCompletions();
}
} }
console.log(`[更新] ${relPath}`); console.log(`[更新] ${relPath}`);
} catch (e) { } catch (e) {
@ -414,13 +368,12 @@ export function createContentServer(
if ( if (
path.endsWith(".md") || path.endsWith(".md") ||
path.endsWith(".csv") || path.endsWith(".csv") ||
path.endsWith(".yarn") || path.endsWith(".yarn")
path.endsWith(".svg")
) { ) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath]; delete contentIndex[relPath];
console.log(`[删除] ${relPath}`); console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) { if (relPath.endsWith(".md")) {
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
recomputeCompletions(); recomputeCompletions();

View File

@ -12,7 +12,6 @@ import {
parseStatYaml, parseStatYaml,
parseStatCsv, parseStatCsv,
parseTemplateCsv, parseTemplateCsv,
parseStatModifiers,
type StatDef, type StatDef,
type StatTemplate, type StatTemplate,
} from "./stat-parser.js"; } from "./stat-parser.js";
@ -22,7 +21,7 @@ import {
resolveBlockAs, resolveBlockAs,
parseSparkTableCsv, parseSparkTableCsv,
} from "./block-scanner.js"; } from "./block-scanner.js";
import type { SparkTableCompletion, StatSheet } from "./types.js"; import type { SparkTableCompletion } from "./types.js";
// Re-export shared pieces for convenience // Re-export shared pieces for convenience
export { export {
@ -41,7 +40,6 @@ export interface ProcessedBlocks {
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
statSheets: StatSheet[];
} }
export interface BlockResult { export interface BlockResult {
@ -82,7 +80,6 @@ export function processBlocks(
stats: [], stats: [],
statTemplates: [], statTemplates: [],
sparkTables: [], sparkTables: [],
statSheets: [],
}; };
const stripped = content.replace( const stripped = content.replace(
@ -114,14 +111,6 @@ export function processBlocks(
); );
} }
if (attrs.role === "stat-modifiers") {
const id = attrs.id || `_mod_${contentHash(body)}`;
const result = parseStatModifiers(body, fileRelativePath, id, "player", attrs.extra["label"]);
blocks.stats.push(result.statDef);
blocks.stats.push(...result.modifierDefs);
blocks.statTemplates.push(result.template);
}
if (attrs.role === "spark-table") { if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, fileRelativePath, slugger); const parsed = parseSparkTableCsv(body, fileRelativePath, slugger);
if (parsed) blocks.sparkTables.push(parsed); if (parsed) blocks.sparkTables.push(parsed);

View File

@ -14,7 +14,6 @@ export type {
SparkTableCompletion, SparkTableCompletion,
StatDef, StatDef,
StatTemplate, StatTemplate,
StatSheet,
} from "./types.js"; } from "./types.js";
/** /**
@ -34,6 +33,5 @@ export function scanCompletions(
sparkTables: blocks.sparkTables, sparkTables: blocks.sparkTables,
stats: blocks.stats, stats: blocks.stats,
statTemplates: blocks.statTemplates, statTemplates: blocks.statTemplates,
statSheets: blocks.statSheets,
}; };
} }

View File

@ -186,15 +186,15 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Template CSV parser // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Parse a ```csv role=stat-template block. * Parse a ```csv role=stat-template file=xxx block.
* *
* The first header is the dice notation (e.g. "1d10"). * The first header is the dice notation (e.g. "1d10").
* The second header is "label". * The second header is "label".
* Remaining headers are modifier keys (bare names, no prefix). * Remaining headers are modifier keys.
*/ */
export function parseTemplateCsv( export function parseTemplateCsv(
csv: string, csv: string,
@ -207,8 +207,10 @@ export function parseTemplateCsv(
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
if (headers.length < 2) return { name, notation: "", entries: [], source }; if (headers.length < 2) return { name, notation: "", entries: [], source };
const notation = headers[0]; const notation = headers[0]; // e.g. "1d10"
const labelIdx = headers.indexOf("label"); const labelIdx = headers.indexOf("label");
// All columns after the notation & label are modifier keys
const modKeys = headers.filter((h, i) => i !== 0 && i !== labelIdx); const modKeys = headers.filter((h, i) => i !== 0 && i !== labelIdx);
const entries: TemplateEntry[] = []; const entries: TemplateEntry[] = [];
@ -239,127 +241,6 @@ export function parseTemplateCsv(
return { name, notation, entries, source }; return { name, notation, entries, source };
} }
// ---------------------------------------------------------------------------
// Stat-modifiers parser
// ---------------------------------------------------------------------------
/** Result of parsing a stat-modifiers block. */
export interface StatModifiersResult {
/** The template stat def (type: template) */
statDef: StatDef;
/** Modifier stat defs (type: modifier, one per value column) */
modifierDefs: StatDef[];
/** The template table with prefixed modifier keys */
template: StatTemplate;
}
/**
* Parse a ```csv role=stat-modifiers block.
*
* Auto-generates a template stat + modifier stat defs from a single CSV.
*
* The first header is the dice notation (e.g. "1d10").
* The second header is "label".
* Remaining headers are bare target names (e.g. "mind", "heart").
*
* Generated modifier keys: {id}_{column} (e.g. "age_mind").
* Template entries internally map: column {id}_{column}.
*/
export function parseStatModifiers(
csv: string,
source: string,
id: string,
scope: "player" | "global" = "player",
label?: string,
): StatModifiersResult {
const statLabel = label || id;
const lines = csv.trim().split(/\r?\n/);
const emptyTemplate: StatTemplate = {
name: id,
notation: "",
entries: [],
source,
};
if (lines.length === 0) {
return {
statDef: { key: id, label: statLabel, type: "template", scope, template: id, source },
modifierDefs: [],
template: emptyTemplate,
};
}
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
if (headers.length < 2) {
return {
statDef: { key: id, label: statLabel, type: "template", scope, template: id, source },
modifierDefs: [],
template: emptyTemplate,
};
}
const notation = headers[0];
const labelIdx = headers.indexOf("label");
// Bare column names (e.g. "mind", "heart") — these become targets
const bareColumns = headers.filter((h, i) => i !== 0 && i !== labelIdx);
// Generate modifier stat defs: key = {id}_{column}, target = column
const modifierDefs: StatDef[] = bareColumns.map((col) => ({
key: `${id}_${col}`,
label: col,
type: "modifier" as const,
scope,
target: col,
source,
}));
// Build template entries with prefixed modifier keys
const entries: TemplateEntry[] = [];
for (let i = 1; i < lines.length; i++) {
const row = lines[i].trim();
if (!row || row.startsWith("#")) continue;
const cols = splitCsvRow(row);
if (cols.length === 0) continue;
const range = cols[0]?.trim();
if (!range) continue;
const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range;
const modifiers: Record<string, string> = {};
for (const col of bareColumns) {
const colIdx = headers.indexOf(col);
if (colIdx !== -1 && colIdx < cols.length) {
const val = cols[colIdx].trim();
if (val) {
modifiers[`${id}_${col}`] = val;
}
}
}
entries.push({ range, label, modifiers });
}
const template: StatTemplate = { name: id, notation, entries, source };
const statDef: StatDef = {
key: id,
label: statLabel,
type: "template",
scope,
template: id,
source,
};
return { statDef, modifierDefs, template };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function splitCsvRow(row: string): string[] { export function splitCsvRow(row: string): string[] {
const cols: string[] = []; const cols: string[] = [];
let current = ""; let current = "";

View File

@ -6,18 +6,6 @@ import type { StatDef, StatTemplate } from "./stat-parser.js";
export type { StatDef, StatTemplate }; export type { StatDef, StatTemplate };
/** A stat sheet discovered from a *.sheet.svg file */
export interface StatSheet {
/** Unique id — filename sans .sheet.svg */
id: string;
/** Display label — from <title> or derived from filename */
label: string;
/** Raw SVG content */
svg: string;
/** Source file path for attribution */
source: string;
}
/** A single dice expression found in a markdown file */ /** A single dice expression found in a markdown file */
export interface DiceCompletion { export interface DiceCompletion {
/** Display text shown in the dropdown */ /** Display text shown in the dropdown */
@ -58,7 +46,6 @@ export interface CompletionsPayload {
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
/** /**

View File

@ -1,121 +0,0 @@
/**
* SheetView renders a stat sheet SVG with live reactive text bindings.
*
* Parses the SVG once via DOMParser, then uses a Solid effect to
* update only <text> elements containing ${key} templates whenever
* the stats store changes.
*/
import { Component, createMemo, createEffect, createRoot, onCleanup } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions";
import { fullKey } from "./stat-helpers";
import type { StatDef } from "./completions";
import { parseSheet } from "./stat-sheet";
export const SheetView: Component<{ sheetId: string }> = (props) => {
const stream = useJournalStream();
const comp = useJournalCompletions();
const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.stats);
/** Build a lookup: bare key -> StatDef (for scope-aware template resolution) */
const bareDefMap = createMemo(() => {
const map = new Map<string, StatDef>();
for (const def of statDefs()) {
if (!map.has(def.key)) {
map.set(def.key, def);
}
}
return map;
});
/**
* Resolve a bare key from an SVG template to its runtime value.
* Uses StatDef scoping when available, falls back to player-first, global-second.
*/
function resolveBareKey(bareKey: string): string {
const bareDef = bareDefMap().get(bareKey);
if (bareDef) {
const fk = fullKey(bareDef, stream.myName);
return values()[fk] ?? bareDef.default ?? "";
}
const pk = `${stream.myName}:${bareKey}`;
if (values()[pk] !== undefined) return values()[pk];
return values()[bareKey] ?? "";
}
const sheet = createMemo(() => {
const s = comp.data.statSheets.find((sh) => sh.id === props.sheetId);
return s ?? null;
});
let containerRef: HTMLDivElement | undefined;
let disposeEffect: (() => void) | undefined;
createEffect(() => {
const s = sheet();
const container = containerRef;
disposeEffect?.();
disposeEffect = undefined;
if (!container || !s) return;
container.innerHTML = "";
const parsed = parseSheet(s.svg);
// Force full-width, auto-height
parsed.svgElement.setAttribute("width", "100%");
parsed.svgElement.removeAttribute("height");
parsed.svgElement.style.display = "block";
container.appendChild(parsed.svgElement);
// Center template nodes so text stays centered as values change length
for (const tpl of parsed.templates) {
const el = tpl.el;
try {
const bbox = el.getBBox();
const cx = bbox.x + bbox.width / 2;
el.setAttribute("text-anchor", "middle");
el.setAttribute("x", String(cx));
} catch {
// getBBox may fail if the node has no layout — skip silently
}
}
if (parsed.templates.length === 0) return;
const dispose = createRoot((disposer) => {
createEffect(() => {
const v = values();
for (const tpl of parsed.templates) {
let result = tpl.template;
for (const key of tpl.keys) {
result = result.replace(`\${${key}}`, resolveBareKey(key));
}
tpl.el.textContent = result;
}
});
return disposer;
});
disposeEffect = dispose;
});
onCleanup(() => disposeEffect?.());
const fallback = (
<p class="text-center text-gray-400 text-xs py-8">: {props.sheetId}</p>
);
return (
<div class="w-full h-full overflow-y-auto p-2">
{sheet() ? (
<div ref={containerRef} class="w-full" />
) : (
fallback
)}
</div>
);
};

View File

@ -1,21 +1,15 @@
/** /**
* StatsView table view of all stat key-value pairs, plus optional * StatsView table view of all stat key-value pairs
* stat sheet rendering from *.sheet.svg files.
* *
* Groups stats by scope (global, then per-player). Shows computed values * Groups stats by scope (global, then per-player). Shows computed values
* (base + modifiers) and inline roll buttons for stats with roll/table/formula. * (base + modifiers) and inline roll buttons for stats with roll/table/formula.
*
* A floating button at bottom-right opens a sheet picker dropdown.
* Selecting a sheet delegates to SheetView.
*/ */
import { Component, For, createMemo, createSignal, Show } from "solid-js"; import { Component, For, createMemo, Show } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { useJournalStream } from "../stores/journalStream"; import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions"; import { useJournalCompletions } from "./completions";
import { fullKey, modifierLabel } from "./stat-helpers"; import { fullKey } from "./stat-helpers";
import type { StatDef } from "./completions"; import type { StatDef } from "./completions";
import { SheetView } from "./SheetView";
export const StatsView: Component = () => { export const StatsView: Component = () => {
const stream = useJournalStream(); const stream = useJournalStream();
@ -23,15 +17,6 @@ export const StatsView: Component = () => {
const statDefs = createMemo(() => comp.data.stats); const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.stats); const values = createMemo(() => stream.stats);
const sheets = createMemo(() => comp.data.statSheets);
/** Currently selected sheet id, or null for table view. Persisted in URL. */
const [searchParams, setSearchParams] = useSearchParams<{ sheet?: string }>();
const selectedSheetId = () => searchParams.sheet || null;
const setSelectedSheetId = (id: string | null) => {
setSearchParams({ sheet: id || undefined });
};
const [pickerOpen, setPickerOpen] = createSignal(false);
/** Build a lookup: fullKey -> StatDef */ /** Build a lookup: fullKey -> StatDef */
const defMap = createMemo(() => { const defMap = createMemo(() => {
@ -124,191 +109,126 @@ export const StatsView: Component = () => {
}); });
return ( return (
<div class="h-full overflow-y-auto bg-gray-50 relative"> <div class="h-full overflow-y-auto bg-gray-50">
<Show <Show
when={selectedSheetId()} when={statDefs().length > 0}
fallback={ fallback={
<Show <p class="text-center text-gray-400 text-xs py-8">
when={statDefs().length > 0} 使 ```yaml role=stat 代码块定义属性。
fallback={ </p>
<p class="text-center text-gray-400 text-xs py-8">
使 ```yaml role=stat 代码块定义属性。
</p>
}
>
<For each={groups()}>
{(group) => (
<div class="mb-3">
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
<span class="text-xs font-semibold text-gray-600">
{group.label}
</span>
</div>
<div class="divide-y divide-gray-100">
<For each={group.entries}>
{({ fullKey: fk, def }) => {
const val = () => values()[fk];
const comp = () => computedValue(fk);
const hasModifiers = () => {
for (const [mfk, md] of defMap()) {
if (
md.type === "modifier" &&
md.target &&
fullKeyTarget(md.target, md, def)
) {
return true;
}
}
return false;
};
const canRoll = () =>
def.roll ||
def.formula ||
def.type === "enum" ||
def.type === "template";
return (
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-sm text-gray-800 truncate">
{modifierLabel(def, statDefs())}
</span>
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
{def.key}
</span>
<Show when={def.type === "modifier"}>
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
{def.target}
</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show
when={val() !== undefined}
fallback={
<span class="text-sm text-gray-300">
{def.default ?? "—"}
</span>
}
>
<span class="text-sm font-mono text-gray-700">
{val()}
</span>
</Show>
<Show
when={
hasModifiers() &&
comp() !== (val() ?? def.default ?? "")
}
>
<span class="text-xs text-gray-400">
= {comp()}
</span>
</Show>
<Show when={canRoll()}>
<button
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
title="掷骰"
onClick={() => {
const textarea =
document.querySelector<HTMLTextAreaElement>(
"#journal-input-textarea",
);
if (textarea) {
const nativeInputValueSetter =
Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(
textarea,
`/stat roll ${def.key}`,
);
textarea.dispatchEvent(
new Event("input", { bubbles: true }),
);
textarea.focus();
}
}}
>
🎲
</button>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</Show>
} }
> >
<SheetView sheetId={selectedSheetId()!} /> <For each={groups()}>
</Show> {(group) => (
<div class="mb-3">
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
<span class="text-xs font-semibold text-gray-600">
{group.label}
</span>
</div>
<div class="divide-y divide-gray-100">
<For each={group.entries}>
{({ fullKey: fk, def }) => {
const val = () => values()[fk];
const comp = () => computedValue(fk);
const hasModifiers = () => {
for (const [mfk, md] of defMap()) {
if (
md.type === "modifier" &&
md.target &&
fullKeyTarget(md.target, md, def)
) {
return true;
}
}
return false;
};
const canRoll = () =>
def.roll ||
def.formula ||
def.type === "enum" ||
def.type === "template";
{/* Sheet picker — only show if sheets are available */} return (
<Show when={sheets().length > 0}> <div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
<div class="absolute bottom-3 right-3"> <div class="flex-1 min-w-0 flex items-center gap-1.5">
{/* Dropdown trigger */} <span class="text-sm text-gray-800 truncate">
<button {def.label}
class="bg-white border border-gray-300 rounded-full w-8 h-8 flex items-center justify-center shadow-sm hover:bg-gray-50 transition-colors text-gray-500" </span>
onClick={() => setPickerOpen((v) => !v)} <span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
title="选择属性卡" {def.key}
> </span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <Show when={def.type === "modifier"}>
<rect x="3" y="3" width="18" height="18" rx="2" /> <span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
<line x1="9" y1="3" x2="9" y2="21" /> {def.target}
</svg> </span>
</button> </Show>
</div>
{/* Dropdown menu */} <div class="flex items-center gap-2 shrink-0">
<Show when={pickerOpen()}> <Show
<div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50"> when={val() !== undefined}
<button fallback={
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${ <span class="text-sm text-gray-300">
selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700" {def.default ?? "—"}
}`} </span>
onClick={() => { }
setSelectedSheetId(null); >
setPickerOpen(false); <span class="text-sm font-mono text-gray-700">
}} {val()}
> </span>
📋 </Show>
</button>
<div class="border-t border-gray-100 my-1" /> <Show
<For each={sheets()}> when={
{(sheet) => ( hasModifiers() &&
<button comp() !== (val() ?? def.default ?? "")
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${ }
selectedSheetId() === sheet.id ? "text-blue-600 font-medium" : "text-gray-700" >
}`} <span class="text-xs text-gray-400">
onClick={() => { = {comp()}
setSelectedSheetId(sheet.id); </span>
setPickerOpen(false); </Show>
}}
> <Show when={canRoll()}>
{sheet.label} <button
</button> class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
)} title="掷骰"
</For> onClick={() => {
const textarea =
document.querySelector<HTMLTextAreaElement>(
"#journal-input-textarea",
);
if (textarea) {
const nativeInputValueSetter =
Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(
textarea,
`/stat roll ${def.key}`,
);
textarea.dispatchEvent(
new Event("input", { bubbles: true }),
);
textarea.focus();
}
}}
>
🎲
</button>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div> </div>
</Show> )}
</For>
{/* Backdrop to close picker */}
<Show when={pickerOpen()}>
<div
class="fixed inset-0 z-40"
onClick={() => setPickerOpen(false)}
/>
</Show>
</div>
</Show> </Show>
</div> </div>
); );
}; };

View File

@ -19,17 +19,15 @@ import {
parseStatYaml, parseStatYaml,
parseStatCsv, parseStatCsv,
parseTemplateCsv, parseTemplateCsv,
parseStatModifiers,
} from "../../cli/completions/stat-parser"; } from "../../cli/completions/stat-parser";
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser"; import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
import type { StatSheet } from "../../cli/completions/types";
import { import {
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
parseBlockAttrs, parseBlockAttrs,
parseSparkTableCsv, parseSparkTableCsv,
} from "../../cli/completions/block-scanner"; } from "../../cli/completions/block-scanner";
export type { StatDef, StatTemplate, StatSheet }; export type { StatDef, StatTemplate };
// ------------------- Types (mirrors CLI) ------------------- // ------------------- Types (mirrors CLI) -------------------
@ -59,7 +57,6 @@ export interface JournalCompletions {
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
export type CompletionsState = export type CompletionsState =
@ -89,7 +86,6 @@ async function tryServer(): Promise<JournalCompletions | null> {
statTemplates: Array.isArray(data.statTemplates) statTemplates: Array.isArray(data.statTemplates)
? data.statTemplates ? data.statTemplates
: [], : [],
statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
}; };
} catch { } catch {
return null; return null;
@ -157,14 +153,6 @@ async function scanClientSide(): Promise<JournalCompletions> {
statTemplates.push(parseTemplateCsv(body, filePath, name)); statTemplates.push(parseTemplateCsv(body, filePath, name));
} }
if (attrs.role === "stat-modifiers") {
const id = attrs.id || `_mod_${filePath}_${stats.length}`;
const result = parseStatModifiers(body, filePath, id, "player", attrs.extra["label"]);
stats.push(result.statDef);
stats.push(...result.modifierDefs);
statTemplates.push(result.template);
}
if (attrs.role === "spark-table") { if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, filePath, slugger); const parsed = parseSparkTableCsv(body, filePath, slugger);
if (parsed) sparkTables.push(parsed); if (parsed) sparkTables.push(parsed);
@ -172,46 +160,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
} }
} }
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] }; return { dice, links, sparkTables, stats, statTemplates };
}
// ------------------- Stat sheet helpers -------------------
function extractSvgTitle(svg: string): string | null {
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
return m ? m[1].trim() : null;
}
function labelFromId(id: string): string {
return id
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
async function scanStatSheets(): Promise<StatSheet[]> {
const paths = await getPathsByExtension("svg");
const sheets: StatSheet[] = [];
for (const filePath of paths) {
if (!/\.sheet\.svg$/i.test(filePath)) continue;
try {
const content = await getIndexedData(filePath);
if (!content) continue;
const fileName = filePath.split("/").filter(Boolean).pop() || filePath;
const id = fileName.replace(/\.sheet\.svg$/i, "");
const title = extractSvgTitle(content);
sheets.push({
id,
label: title || labelFromId(id),
svg: content,
source: filePath,
});
} catch {
// skip unreadable files
}
}
return sheets;
} }
// ------------------- Init (runs eagerly at import time) ------------------- // ------------------- Init (runs eagerly at import time) -------------------
@ -228,8 +177,7 @@ const _initPromise: Promise<void> = (async () => {
// 2. Fall back to client-side scan (dev/browser mode) // 2. Fall back to client-side scan (dev/browser mode)
try { try {
const data = await scanClientSide(); const data = await scanClientSide();
data.statSheets = await scanStatSheets(); if (data.dice.length > 0 || data.links.length > 0) {
if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
setCompletionsState({ status: "loaded", data }); setCompletionsState({ status: "loaded", data });
} else { } else {
setCompletionsState({ status: "empty" }); setCompletionsState({ status: "empty" });
@ -280,7 +228,6 @@ export function useJournalCompletions(): {
sparkTables: [], sparkTables: [],
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}, },
}; };
} }

View File

@ -18,36 +18,6 @@ export function fullKey(def: StatDef, playerName: string): string {
return def.scope === "player" ? `${playerName}:${def.key}` : def.key; return def.scope === "player" ? `${playerName}:${def.key}` : def.key;
} }
/**
* Derive a display label for a modifier stat.
*
* If the key follows the `{parent}_{target}` convention (from stat-modifiers),
* returns `{parent_label}/{target_label}` by looking up both defs.
* Otherwise falls back to the def's own label.
*/
export function modifierLabel(
def: StatDef,
statDefs: StatDef[],
): string {
if (def.type !== "modifier") return def.label;
// Try to split key as parent_target
const lastUnderscore = def.key.lastIndexOf("_");
if (lastUnderscore === -1) return def.label;
const parentKey = def.key.slice(0, lastUnderscore);
const targetKey = def.key.slice(lastUnderscore + 1);
const parentDef = statDefs.find((d) => d.key === parentKey);
const targetDef = statDefs.find((d) => d.key === targetKey);
if (parentDef && targetDef) {
return `${parentDef.label}/${targetDef.label}`;
}
return def.label;
}
/** Find a stat def by its full runtime key. */ /** Find a stat def by its full runtime key. */
export function findStatDef( export function findStatDef(
fk: string, fk: string,
@ -170,11 +140,20 @@ export function resolveTemplateSet(
const entry = tpl.entries.find((e) => e.label === value); const entry = tpl.entries.find((e) => e.label === value);
if (!entry || Object.keys(entry.modifiers).length === 0) return null; if (!entry || Object.keys(entry.modifiers).length === 0) return null;
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
const resolved: Record<string, string> = {}; const resolved: Record<string, string> = {};
for (const [mk, mv] of Object.entries(entry.modifiers)) { for (const [mk, mv] of Object.entries(entry.modifiers)) {
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk; const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
resolved[modFullKey] = mv; const currentVal = runtimeStats[modFullKey];
const currentNum =
currentVal !== undefined ? parseFloat(currentVal) : lookup(mk);
const delta = parseFloat(mv);
if (!isNaN(currentNum) && !isNaN(delta)) {
resolved[modFullKey] = String(currentNum + delta);
} else {
resolved[modFullKey] = mv;
}
} }
return resolved; return resolved;
@ -239,12 +218,22 @@ export function resolveStatRoll(
} }
// Resolve modifier keys to full keys (same scope as def) // Resolve modifier keys to full keys (same scope as def)
// Template values are absolute — set the modifier stat directly. // Modifier values like "+20" / "-10" are applied relative to current value
// The modifier stat (type: modifier) adds to its target in StatsView.
const resolvedModifiers: Record<string, string> = {}; const resolvedModifiers: Record<string, string> = {};
for (const [mk, mv] of Object.entries(entry.modifiers)) { for (const [mk, mv] of Object.entries(entry.modifiers)) {
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk; const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
resolvedModifiers[modFullKey] = mv;
// Compute absolute value: current + delta
const currentVal = runtimeStats[modFullKey];
const currentNum =
currentVal !== undefined ? parseFloat(currentVal) : lookup(mk);
const delta = parseFloat(mv);
if (!isNaN(currentNum) && !isNaN(delta)) {
resolvedModifiers[modFullKey] = String(currentNum + delta);
} else {
// If we can't compute relative, fall back to raw value
resolvedModifiers[modFullKey] = mv;
}
} }
return { return {

View File

@ -1,89 +0,0 @@
/**
* Stat sheet template engine parses *.sheet.svg files and extracts
* text nodes containing ${key} template patterns.
*
* Walks <text> elements. If a <text> has <tspan> children, we bind to
* each <tspan> individually (preserving their position attributes).
* If a <text> has no <tspan> children, we bind to the <text> directly.
*/
/** Regex matching ${key} template patterns (captures the key name). */
const TEMPLATE_RE = /\$\{(\w+)\}/g;
/** A single template binding targeting a specific text-bearing leaf node. */
export interface TextTemplate {
/** The DOM node to update — a <text> (no children) or a <tspan>. */
el: SVGTextContentElement;
/** Original text content with ${key} placeholders. */
template: string;
/** Bare key names extracted from the template. */
keys: string[];
}
/** The result of parsing a stat sheet SVG string. */
export interface ParsedSheet {
svgElement: SVGSVGElement;
templates: TextTemplate[];
}
/**
* Check if a text node's content has template patterns, and if so,
* register a binding.
*/
function scanNode(el: Element, templates: TextTemplate[]): void {
const content = el.textContent;
if (!content) return;
TEMPLATE_RE.lastIndex = 0;
const keys: string[] = [];
let m: RegExpExecArray | null;
while ((m = TEMPLATE_RE.exec(content)) !== null) {
keys.push(m[1]);
}
if (keys.length > 0) {
templates.push({
el: el as SVGTextContentElement,
template: content,
keys,
});
}
}
/**
* Parse an SVG string into a live DOM element and extract all template
* bindings from <text> and <tspan> leaf nodes.
*/
export function parseSheet(svgString: string): ParsedSheet {
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, "image/svg+xml");
const svgElement = doc.documentElement as unknown as SVGSVGElement;
if (!svgElement || svgElement.tagName !== "svg") {
return {
svgElement: document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
) as SVGSVGElement,
templates: [],
};
}
const templates: TextTemplate[] = [];
for (const textEl of svgElement.querySelectorAll("text")) {
const tspans = textEl.querySelectorAll("tspan");
if (tspans.length > 0) {
// Has children — bind to each <tspan> individually
for (const tspan of tspans) {
scanNode(tspan, templates);
}
} else {
// Leaf <text> — bind directly
scanNode(textEl, templates);
}
}
return { svgElement, templates };
}

View File

@ -50,7 +50,7 @@ function ensureIndexLoaded(): Promise<void> {
try { try {
const context = import.meta.webpackContext("../../content", { const context = import.meta.webpackContext("../../content", {
recursive: true, recursive: true,
regExp: /\.md|\.yarn|\.csv|\.svg$/i, regExp: /\.md|\.yarn|\.csv$/i,
}); });
const keys = context.keys(); const keys = context.keys();
const index: FileIndex = {}; const index: FileIndex = {};
@ -94,7 +94,7 @@ async function scanDirectory(
prefix = "", prefix = "",
): Promise<FileIndex> { ): Promise<FileIndex> {
const index: FileIndex = {}; const index: FileIndex = {};
const acceptedExt = /\.(md|yarn|csv|svg)$/i; const acceptedExt = /\.(md|yarn|csv)$/i;
for await (const [name, entry] of (handle as any).entries()) { for await (const [name, entry] of (handle as any).entries()) {
if (entry.kind === "directory") { if (entry.kind === "directory") {

View File

@ -7,48 +7,46 @@ syntax: '```yaml role=stat'
props: props:
- name: 定义文件 - name: 定义文件
type: — type: —
desc: 在任意 .md 文档中使用 ```yaml role=stat 或 ```csv role=stat 代码块定义属性 desc: 在任意 .md 文档中使用 yaml role=stat 代码块定义属性
- name: 命令 - name: 命令
type: — type: —
desc: /stat set key=value | /stat del key | /stat roll key desc: /stat set key=value | /stat del key | /stat roll key
- name: 属性类型 - name: 属性类型
type: — type: —
desc: number, string, enum, modifier, derived, template desc: number, string, enum, modifier, derived
- name: 权限 - name: 权限
type: — type: —
desc: GM 可修改所有属性,玩家只能修改自己的属性 desc: GM 可修改所有属性,玩家只能修改自己的属性 (玩家名:xxx)
--- ---
## 概述 ## 概述
属性系统允许你在文档中定义角色属性,通过命令设置和掷骰 属性系统允许你在文档中定义角色属性(如力量、生命值、技能等)
并在 Journal 面板的属性视图中查看当前值。 通过命令设置和掷骰,并在 Journal 面板的属性视图中查看当前值。
属性分为两层: 属性分为两层:
- **定义**Schema在 markdown 文档的 ` ```yaml role=stat ` 或 ` ```csv role=stat ` 代码块中定义 - **定义**Schema在 markdown 文档的 ` ```yaml role=stat ` 代码块中定义
- **值**State通过 `/stat set/del/roll` 命令在游戏过程中动态修改 - **值**State通过 `/stat set/del/roll` 命令在游戏过程中动态修改
## 代码块语法 ## 属性类型
所有属性相关代码块使用统一的属性语法: | 类型 | 说明 | 支持掷骰 |
```lang role=xxx [id=xxx]
| 属性 | 说明 | 示例 |
|---|---|---| |---|---|---|
| `lang` | 代码语言 | `yaml`, `csv` | | `number` | 数值,可声明 `roll` 公式 | ✅ |
| `role` | 块用途 | `stat`(属性定义), `stat-template`(模板表) | | `string` | 自由文本 | ❌ |
| `id` | 引用标识 | 模板名称,如 `id=年龄` | | `enum` | 枚举选项,掷骰随机选择 | ✅ |
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
| `derived` | 通过公式从其他属性计算 | ✅ |
| `template` | 查表属性,掷骰匹配范围并应用修饰符 | ✅ |
代码块默认会被**剥离**(不渲染到页面),仅用于数据提取。 ## 属性定义语法
如需保留为可见代码块,添加 `as=codeblock`
## 属性定义
支持两种格式:**YAML**(适合复杂属性)和 **CSV**(适合同质列表)。 支持两种格式:**YAML**(适合复杂属性)和 **CSV**(适合同质列表)。
### YAML 格式 ### YAML 格式
`.md` 文档中插入 ` ```yaml role=stat ` 代码块:
```yaml role=stat ```yaml role=stat
- key: strength - key: strength
scope: player scope: player
@ -102,6 +100,9 @@ props:
### CSV 格式 ### CSV 格式
对于同质属性列表(如多个 `number` 类型的属性CSV 更紧凑。
插入 ` ```csv role=stat ` 代码块:
```csv role=stat ```csv role=stat
key,label,type,roll key,label,type,roll
mind,心智,number,2d10+20 mind,心智,number,2d10+20
@ -121,43 +122,38 @@ CSV 列说明:
| `default` | — | — | 默认值 | | `default` | — | — | 默认值 |
| `roll` | — | — | 掷骰公式 | | `roll` | — | — | 掷骰公式 |
| `target` | — | — | modifier 的目标 key | | `target` | — | — | modifier 的目标 key |
| `template` | — | — | template 类型引用的模板 id |
| `formula` | — | — | derived 的计算公式 | | `formula` | — | — | derived 的计算公式 |
| `options` | — | — | enum 选项,用 `\|` 分隔 | | `options` | — | — | enum 选项,用 `\|` 分隔 |
### 属性类型 示例 — enum 属性:
| 类型 | 说明 | 支持掷骰 | ```csv role=stat
|---|---|---| key,label,type,options
| `number` | 数值,可声明 `roll` 公式 | ✅ | weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
| `string` | 自由文本 | ❌ | ```
| `enum` | 枚举选项,掷骰随机选择 | ✅ |
| `modifier` | 修饰值,自动加到 `target` 属性上 | ❌ |
| `derived` | 通过公式从其他属性计算 | ✅ |
| `template` | 查表属性,掷骰匹配范围并应用修饰符 | ✅ |
### 关键字段说明 ### 关键语法说明
- **`key`**:唯一标识符。使用纯名字(如 `strength`),不用加玩家前缀 - **`key`**:唯一标识符。使用纯名字(如 `strength`),不用加玩家前缀
- **`scope`**`player` 或 `global`。`player` 表示每个玩家各自独立的值,运行时 key 为 `玩家名:strength``global` 表示所有玩家共享 - **`scope`**`player` 或 `global`。`player` 表示每个玩家各自独立的值,运行时 key 为 `玩家名:strength``global` 表示所有玩家共享
- **`label`**:在属性视图中显示的名称 - **`label`**:在属性视图中显示的名称
- **`type`**:属性类型(见上表)
- **`default`**:默认值,在未通过命令设置时使用 - **`default`**:默认值,在未通过命令设置时使用
- **`roll`**:掷骰公式(`number` 类型支持引用其他属性值bare key自动同 scope 解析) - **`roll`**:掷骰公式(仅 `number` 类型),支持引用其他属性值(使用 bare key自动同 scope 解析)
- **`target`**`modifier` 类型的目标属性 key - **`target`**`modifier` 类型的目标属性 keybare key同 scope 内解析)
- **`options`**`enum` 类型的选项列表YAML 支持多行 `- value` 语法CSV 用 `|` 分隔 - **`options`**`enum` 类型的选项列表,支持多行 `- value` 语法
- **`formula`**`derived` 类型的计算公式,支持 `+ - * / floor() ceil() round()` - **`formula`**`derived` 类型的计算公式,支持 `+ - * / floor() ceil() round()`,属性引用使用 bare key
- **`template`**`template` 类型引用的模板 id对应 `id=xxx` 的 stat-template 块)
### 作用域 ### 作用域说明
`scope: player` 的属性在运行时自动加上玩家名前缀。Alice 连接时,`strength` 的实际 key 是 `alice:strength` `scope: player` 的属性在运行时自动加上玩家名前缀。例如 Alice 连接时,`strength` 的实际 key 是 `alice:strength`
在公式(`roll`、`formula`)和 `target` 中,使用 bare key 即可,系统自动在相同 scope 内查找: 在公式(`roll`、`formula`)和 `target` 中,使用 bare key 即可,系统自动在相同 scope 内查找:
```yaml role=stat ```yaml role=stat
- key: attack - key: attack
scope: player scope: player
roll: "1d20 + attack" # attack 自动解析为 alice:attack roll: "1d20 + attack" # attack 自动解析为 alice:attack
- key: str_mod - key: str_mod
scope: player scope: player
@ -166,7 +162,7 @@ CSV 列说明:
## 命令 ## 命令
所有命令在 Journal 输入框中输入,前缀为 `/stat` 所有命令在 Journal 输入框中输入,前缀为 `/stat`。命令中使用 bare key
| 命令 | 示例 | 说明 | | 命令 | 示例 | 说明 |
|---|---|---| |---|---|---|
@ -179,32 +175,31 @@ CSV 列说明:
- **`number` + `roll`**:解析公式中的属性引用,掷骰,结果写入属性值 - **`number` + `roll`**:解析公式中的属性引用,掷骰,结果写入属性值
- **`enum`**:从选项列表中随机选择一项 - **`enum`**:从选项列表中随机选择一项
- **`derived` + `formula`**:计算公式,结果写入属性值 - **`derived` + `formula`**:计算公式,结果写入属性值
- **`template`**:掷模板头部骰子,匹配范围,应用修饰符
例如 `/stat roll attack` 例如 `/stat roll attack`
1. 在当前玩家 scope 下查找 `attack` 的定义 1. 在当前玩家 scope 下查找 `attack` 的定义
2. 解析 `roll` 公式 `1d20 + attack`,将 `attack` 替换为当前值(含修饰符)→ `1d20 + 3` 2. 解析 `roll` 公式 `1d20 + attack`,将 `attack` 替换为当前值(含修饰符)→ `1d20 + 3`
3. 掷骰 → `15` 3. 掷骰 → 如结果 `15`
4. 将 `15` 写入 `alice:attack`,同步到所有客户端 4. 将 `15` 写入 `alice:attack`,同步到所有连接的客户端
## 属性视图 ## 属性视图
在 Journal 面板顶部点击 **属性** 标签切换视图: 在 Journal 面板顶部点击 **属性** 标签切换视图:
- 属性按 scope 分组(全局 + 玩家) - 属性按 scope 分组(全局属性 + 玩家属性
- 显示属性名、当前值、默认值 - 显示属性名、当前值、默认值
- 修饰符自动合并显示 - 修饰符自动合并显示`strength = 16` 时,`str_mod` 自动加到 `strength` 上)
- 可掷骰的行显示 🎲 按钮 - 有掷骰属性的行显示 🎲 按钮,点击可快速掷骰
## 权限 ## 权限
- **GM**:可修改所有属性 - **GM**:可修改所有属性
- **玩家**:只能修改 `scope: player` 的属性 - **玩家**:只能修改自己 scope 下的属性(`scope: player` 的属性)
- **观察者**:不能修改任何属性 - **观察者**:不能修改任何属性
## 修饰符modifier ## 修饰符modifier详解
修饰符自动加到 `target` 属性上: 修饰符类型的属性会自动加到 `target` 属性上:
```yaml role=stat ```yaml role=stat
- key: strength - key: strength
@ -218,12 +213,12 @@ CSV 列说明:
target: strength target: strength
``` ```
`/stat set str_mod=3` 后,`strength` 的计算值`10 + 3 = 13` 如果 `/stat set str_mod=3`,则 `strength` 的**计算值**`10 + 3 = 13`
多个修饰符指向同一目标时累加。 多个修饰符指向同一目标时累加。
## 派生属性derived ## 派生属性derived详解
通过公式从其他属性计算: 派生属性通过公式从其他属性计算:
```yaml role=stat ```yaml role=stat
- key: hp_max - key: hp_max
@ -232,81 +227,50 @@ CSV 列说明:
formula: "strength * 2 + 10" formula: "strength * 2 + 10"
``` ```
公式引用其他属性时自动使用计算值(含修饰符)。 公式引用其他属性时,会自动使用计算值(含修饰符)。
支持的函数:`floor(x)`, `ceil(x)`, `round(x)` 支持的函数:`floor(x)`, `ceil(x)`, `round(x)`
## 模板属性template ## 模板属性template详解
模板属性用于查表掷骰(年龄表、职业表等) 模板属性用于查表掷骰,例如年龄表、职业表等
### 方式一stat-modifiers推荐 模板在独立的 CSV 代码块中定义,使用 `role=stat-template``id=模板名`
**第一列是骰子表达式**(如 `1d10`),第二列是 `label`,其余列为修饰符:
一个代码块同时生成模板 stat 和修饰符 stat defs
```csv id=年龄 role=stat-modifiers
1d10,label,mind,heart,strength,speed
1-3,青少年,-10,+20,,
4-7,成年,,-10,,+20
8-9,老年,,+20,-10,
10,换躯者,,,+30,-10
```
这会自动生成:
- `age`type: template, template: 年龄)
- `age_mind`type: modifier, target: mind
- `age_heart`type: modifier, target: heart
- `age_strength`type: modifier, target: strength
- `age_speed`type: modifier, target: speed
修饰符键名规则:`{id}_{列名}`,目标为列名本身。
### 方式二:手动定义
分别定义模板表和修饰符 stat
```csv id=年龄 role=stat-template ```csv id=年龄 role=stat-template
1d10,label,age_mind,age_heart,age_strength,age_speed 1d10,label,heart,mind,strength,speed
1-3,青少年,-10,+20,, 1-3,青少年,+20,-10,,
4-7,成年,,-10,,+20 4-7,成年,,-10,,+20
8-9,老年,,+20,-10, 8-9,老年,,+20,-10,
10,换躯者,,,+30,-10 10,换躯者,,,+30,-10
``` ```
- **第一列header**:骰子表达式,如 `1d10`、`2d6`
- **第一列rows**:匹配范围,支持 `1-3`(区间)、`4`(精确)、`1-3,5`(多个)
- **`label` 列**:显示名称
- **其余列**:修饰符键名,值为变化量(正数加、负数减、空表示不修改)
然后在属性定义中引用模板:
```yaml role=stat ```yaml role=stat
- key: age_mind
type: modifier
target: mind
- key: age_heart
type: modifier
target: heart
- key: age - key: age
scope: player
label: 年龄
type: template type: template
template: 年龄 template: 年龄
``` ```
### 使用 `/stat roll age` 时:
1. 掷 `1d10`(模板头部的骰子表达式)
2. 匹配第一列获取条目
3. 发布 `set age=青少年`
4. 同时发布 `set alice:heart=52`(原始值 +20、`set alice:mind=22`-10
### 使用 修饰符值中的 `+`/`-` 前缀表示相对调整。如果 modifiers 值是 `+20`,会在当前值基础上加 20如果是 `-10`,会减 10。
- `/stat roll age` — 掷 `1d10`,匹配范围,应用修饰符
- `/stat set age=换躯者` — 直接设置,同样应用修饰符
`/stat roll age``/stat set age=青少年` 时:
1. 发布 `set age=青少年`
2. 同时发布 `set alice:age_mind=-10`、`set alice:age_heart=+20` 等
修饰符值中的 `+`/`-` 前缀表示相对调整,基于当前值计算。
### 模板表语法
- **第一列header**:骰子表达式,如 `1d10`
- **第一列rows**:匹配范围,`1-3`(区间)、`4`(精确)、`1-3,5`(多个)
- **`label`**:显示名称
- **其余列**:修饰符键名,值为变化量(`+`加、`-`减、空不修改)
## 掷骰公式中的属性引用 ## 掷骰公式中的属性引用
`roll` 字段中的标识符自动替换为当前属性值: `roll` 字段中的标识符会自动替换为当前属性值:
```yaml role=stat ```yaml role=stat
- key: attack - key: attack
@ -314,4 +278,6 @@ CSV 列说明:
type: number type: number
default: 0 default: 0
roll: "1d20 + attack + str_mod" roll: "1d20 + attack + str_mod"
``` ```
`/stat roll alice:attack` 时,`alice:attack` 和 `alice:str_mod` 会被替换为当前值后再掷骰。