Compare commits
6 Commits
a4cfcde613
...
c38d9bdc4f
| Author | SHA1 | Date |
|---|---|---|
|
|
c38d9bdc4f | |
|
|
fe87b1f80c | |
|
|
30d1c5dc1b | |
|
|
4f3b03d082 | |
|
|
ef71a43f0a | |
|
|
d83256e049 |
|
|
@ -15,6 +15,7 @@ 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;
|
||||||
|
|
@ -83,6 +84,24 @@ 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 文件,生成索引
|
||||||
*/
|
*/
|
||||||
|
|
@ -95,6 +114,7 @@ export function scanDirectory(dir: string): {
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
|
statSheets: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function scan(currentPath: string, relativePath: string) {
|
function scan(currentPath: string, relativePath: string) {
|
||||||
|
|
@ -113,7 +133,8 @@ 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");
|
||||||
|
|
@ -123,6 +144,17 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -281,6 +313,7 @@ export function createContentServer(
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
|
statSheets: [],
|
||||||
};
|
};
|
||||||
let completionsIndex: CompletionsPayload = {
|
let completionsIndex: CompletionsPayload = {
|
||||||
dice: [],
|
dice: [],
|
||||||
|
|
@ -288,13 +321,14 @@ 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}`,
|
`[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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,7 +353,8 @@ 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");
|
||||||
|
|
@ -333,6 +368,11 @@ 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) {
|
||||||
|
|
@ -344,7 +384,8 @@ 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");
|
||||||
|
|
@ -357,6 +398,11 @@ 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) {
|
||||||
|
|
@ -368,12 +414,13 @@ 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")) {
|
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
|
||||||
const rescan = scanDirectory(contentDir);
|
const rescan = scanDirectory(contentDir);
|
||||||
collectedBlocks = rescan.blocks;
|
collectedBlocks = rescan.blocks;
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
parseStatYaml,
|
parseStatYaml,
|
||||||
parseStatCsv,
|
parseStatCsv,
|
||||||
parseTemplateCsv,
|
parseTemplateCsv,
|
||||||
|
parseStatModifiers,
|
||||||
type StatDef,
|
type StatDef,
|
||||||
type StatTemplate,
|
type StatTemplate,
|
||||||
} from "./stat-parser.js";
|
} from "./stat-parser.js";
|
||||||
|
|
@ -21,7 +22,7 @@ import {
|
||||||
resolveBlockAs,
|
resolveBlockAs,
|
||||||
parseSparkTableCsv,
|
parseSparkTableCsv,
|
||||||
} from "./block-scanner.js";
|
} from "./block-scanner.js";
|
||||||
import type { SparkTableCompletion } from "./types.js";
|
import type { SparkTableCompletion, StatSheet } from "./types.js";
|
||||||
|
|
||||||
// Re-export shared pieces for convenience
|
// Re-export shared pieces for convenience
|
||||||
export {
|
export {
|
||||||
|
|
@ -40,6 +41,7 @@ export interface ProcessedBlocks {
|
||||||
stats: StatDef[];
|
stats: StatDef[];
|
||||||
statTemplates: StatTemplate[];
|
statTemplates: StatTemplate[];
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
|
statSheets: StatSheet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockResult {
|
export interface BlockResult {
|
||||||
|
|
@ -80,6 +82,7 @@ export function processBlocks(
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
|
statSheets: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripped = content.replace(
|
const stripped = content.replace(
|
||||||
|
|
@ -111,6 +114,14 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export type {
|
||||||
SparkTableCompletion,
|
SparkTableCompletion,
|
||||||
StatDef,
|
StatDef,
|
||||||
StatTemplate,
|
StatTemplate,
|
||||||
|
StatSheet,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,5 +34,6 @@ export function scanCompletions(
|
||||||
sparkTables: blocks.sparkTables,
|
sparkTables: blocks.sparkTables,
|
||||||
stats: blocks.stats,
|
stats: blocks.stats,
|
||||||
statTemplates: blocks.statTemplates,
|
statTemplates: blocks.statTemplates,
|
||||||
|
statSheets: blocks.statSheets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,15 +186,15 @@ export function parseStatCsv(csv: string, source: string): StatDef[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Template CSV parser
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a ```csv role=stat-template file=xxx block.
|
* Parse a ```csv role=stat-template 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.
|
* Remaining headers are modifier keys (bare names, no prefix).
|
||||||
*/
|
*/
|
||||||
export function parseTemplateCsv(
|
export function parseTemplateCsv(
|
||||||
csv: string,
|
csv: string,
|
||||||
|
|
@ -207,10 +207,8 @@ 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]; // e.g. "1d10"
|
const notation = headers[0];
|
||||||
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[] = [];
|
||||||
|
|
@ -241,6 +239,127 @@ 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 = "";
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,18 @@ 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 */
|
||||||
|
|
@ -46,6 +58,7 @@ export interface CompletionsPayload {
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
stats: StatDef[];
|
stats: StatDef[];
|
||||||
statTemplates: StatTemplate[];
|
statTemplates: StatTemplate[];
|
||||||
|
statSheets: StatSheet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
/**
|
/**
|
||||||
* StatsView — table view of all stat key-value pairs
|
* StatsView — table view of all stat key-value pairs, plus optional
|
||||||
|
* 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, Show } from "solid-js";
|
import { Component, For, createMemo, createSignal, 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 } from "./stat-helpers";
|
import { fullKey, modifierLabel } 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();
|
||||||
|
|
@ -17,6 +23,15 @@ 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(() => {
|
||||||
|
|
@ -109,126 +124,191 @@ export const StatsView: Component = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-full overflow-y-auto bg-gray-50">
|
<div class="h-full overflow-y-auto bg-gray-50 relative">
|
||||||
<Show
|
<Show
|
||||||
when={statDefs().length > 0}
|
when={selectedSheetId()}
|
||||||
fallback={
|
fallback={
|
||||||
<p class="text-center text-gray-400 text-xs py-8">
|
<Show
|
||||||
暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。
|
when={statDefs().length > 0}
|
||||||
</p>
|
fallback={
|
||||||
|
<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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={groups()}>
|
<SheetView sheetId={selectedSheetId()!} />
|
||||||
{(group) => (
|
</Show>
|
||||||
<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 (
|
{/* Sheet picker — only show if sheets are available */}
|
||||||
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
|
<Show when={sheets().length > 0}>
|
||||||
<div class="flex-1 min-w-0 flex items-center gap-1.5">
|
<div class="absolute bottom-3 right-3">
|
||||||
<span class="text-sm text-gray-800 truncate">
|
{/* Dropdown trigger */}
|
||||||
{def.label}
|
<button
|
||||||
</span>
|
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 class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
|
onClick={() => setPickerOpen((v) => !v)}
|
||||||
{def.key}
|
title="选择属性卡"
|
||||||
</span>
|
>
|
||||||
<Show when={def.type === "modifier"}>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
→{def.target}
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
</span>
|
</svg>
|
||||||
</Show>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
{/* Dropdown menu */}
|
||||||
<Show
|
<Show when={pickerOpen()}>
|
||||||
when={val() !== undefined}
|
<div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50">
|
||||||
fallback={
|
<button
|
||||||
<span class="text-sm text-gray-300">
|
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${
|
||||||
{def.default ?? "—"}
|
selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700"
|
||||||
</span>
|
}`}
|
||||||
}
|
onClick={() => {
|
||||||
>
|
setSelectedSheetId(null);
|
||||||
<span class="text-sm font-mono text-gray-700">
|
setPickerOpen(false);
|
||||||
{val()}
|
}}
|
||||||
</span>
|
>
|
||||||
</Show>
|
📋 表格
|
||||||
|
</button>
|
||||||
<Show
|
<div class="border-t border-gray-100 my-1" />
|
||||||
when={
|
<For each={sheets()}>
|
||||||
hasModifiers() &&
|
{(sheet) => (
|
||||||
comp() !== (val() ?? def.default ?? "")
|
<button
|
||||||
}
|
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">
|
}`}
|
||||||
= {comp()}
|
onClick={() => {
|
||||||
</span>
|
setSelectedSheetId(sheet.id);
|
||||||
</Show>
|
setPickerOpen(false);
|
||||||
|
}}
|
||||||
<Show when={canRoll()}>
|
>
|
||||||
<button
|
{sheet.label}
|
||||||
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
|
</button>
|
||||||
title="掷骰"
|
)}
|
||||||
onClick={() => {
|
</For>
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -19,15 +19,17 @@ 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 };
|
export type { StatDef, StatTemplate, StatSheet };
|
||||||
|
|
||||||
// ------------------- Types (mirrors CLI) -------------------
|
// ------------------- Types (mirrors CLI) -------------------
|
||||||
|
|
||||||
|
|
@ -57,6 +59,7 @@ export interface JournalCompletions {
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
stats: StatDef[];
|
stats: StatDef[];
|
||||||
statTemplates: StatTemplate[];
|
statTemplates: StatTemplate[];
|
||||||
|
statSheets: StatSheet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompletionsState =
|
export type CompletionsState =
|
||||||
|
|
@ -86,6 +89,7 @@ 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;
|
||||||
|
|
@ -153,6 +157,14 @@ 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);
|
||||||
|
|
@ -160,7 +172,46 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dice, links, sparkTables, stats, statTemplates };
|
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- 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) -------------------
|
||||||
|
|
@ -177,7 +228,8 @@ 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();
|
||||||
if (data.dice.length > 0 || data.links.length > 0) {
|
data.statSheets = await scanStatSheets();
|
||||||
|
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" });
|
||||||
|
|
@ -228,6 +280,7 @@ export function useJournalCompletions(): {
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
|
statSheets: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,36 @@ 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,
|
||||||
|
|
@ -140,20 +170,11 @@ 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;
|
||||||
const currentVal = runtimeStats[modFullKey];
|
resolved[modFullKey] = mv;
|
||||||
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;
|
||||||
|
|
@ -218,22 +239,12 @@ export function resolveStatRoll(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve modifier keys to full keys (same scope as def)
|
// Resolve modifier keys to full keys (same scope as def)
|
||||||
// Modifier values like "+20" / "-10" are applied relative to current value
|
// Template values are absolute — set the modifier stat directly.
|
||||||
|
// 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 {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
|
@ -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$/i,
|
regExp: /\.md|\.yarn|\.csv|\.svg$/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)$/i;
|
const acceptedExt = /\.(md|yarn|csv|svg)$/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") {
|
||||||
|
|
|
||||||
|
|
@ -7,46 +7,48 @@ syntax: '```yaml role=stat'
|
||||||
props:
|
props:
|
||||||
- name: 定义文件
|
- name: 定义文件
|
||||||
type: —
|
type: —
|
||||||
desc: 在任意 .md 文档中使用 yaml role=stat 代码块定义属性
|
desc: 在任意 .md 文档中使用 ```yaml role=stat 或 ```csv 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
|
desc: number, string, enum, modifier, derived, template
|
||||||
- name: 权限
|
- name: 权限
|
||||||
type: —
|
type: —
|
||||||
desc: GM 可修改所有属性,玩家只能修改自己的属性 (玩家名:xxx)
|
desc: GM 可修改所有属性,玩家只能修改自己的属性
|
||||||
---
|
---
|
||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
属性系统允许你在文档中定义角色属性(如力量、生命值、技能等),
|
属性系统允许你在文档中定义角色属性,通过命令设置和掷骰,
|
||||||
通过命令设置和掷骰,并在 Journal 面板的属性视图中查看当前值。
|
并在 Journal 面板的属性视图中查看当前值。
|
||||||
|
|
||||||
属性分为两层:
|
属性分为两层:
|
||||||
- **定义**(Schema):在 markdown 文档的 ` ```yaml role=stat ` 代码块中定义
|
- **定义**(Schema):在 markdown 文档的 ` ```yaml role=stat ` 或 ` ```csv role=stat ` 代码块中定义
|
||||||
- **值**(State):通过 `/stat set/del/roll` 命令在游戏过程中动态修改
|
- **值**(State):通过 `/stat set/del/roll` 命令在游戏过程中动态修改
|
||||||
|
|
||||||
## 属性类型
|
## 代码块语法
|
||||||
|
|
||||||
| 类型 | 说明 | 支持掷骰 |
|
所有属性相关代码块使用统一的属性语法:
|
||||||
|
|
||||||
|
```lang role=xxx [id=xxx]
|
||||||
|
|
||||||
|
| 属性 | 说明 | 示例 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `number` | 数值,可声明 `roll` 公式 | ✅ |
|
| `lang` | 代码语言 | `yaml`, `csv` |
|
||||||
| `string` | 自由文本 | ❌ |
|
| `role` | 块用途 | `stat`(属性定义), `stat-template`(模板表) |
|
||||||
| `enum` | 枚举选项,掷骰随机选择 | ✅ |
|
| `id` | 引用标识 | 模板名称,如 `id=年龄` |
|
||||||
| `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
|
||||||
|
|
@ -100,9 +102,6 @@ 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
|
||||||
|
|
@ -122,38 +121,43 @@ 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
|
|---|---|---|
|
||||||
weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
| `number` | 数值,可声明 `roll` 公式 | ✅ |
|
||||||
```
|
| `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(bare key,同 scope 内解析)
|
- **`target`**:`modifier` 类型的目标属性 key
|
||||||
- **`options`**:`enum` 类型的选项列表,支持多行 `- value` 语法
|
- **`options`**:`enum` 类型的选项列表,YAML 支持多行 `- value` 语法,CSV 用 `|` 分隔
|
||||||
- **`formula`**:`derived` 类型的计算公式,支持 `+ - * / floor() ceil() round()`,属性引用使用 bare key
|
- **`formula`**:`derived` 类型的计算公式,支持 `+ - * / floor() ceil() round()`
|
||||||
|
- **`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
|
||||||
|
|
@ -162,7 +166,7 @@ weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
所有命令在 Journal 输入框中输入,前缀为 `/stat`。命令中使用 bare key:
|
所有命令在 Journal 输入框中输入,前缀为 `/stat`:
|
||||||
|
|
||||||
| 命令 | 示例 | 说明 |
|
| 命令 | 示例 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -175,31 +179,32 @@ weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
||||||
- **`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 下的属性(`scope: player` 的属性)
|
- **玩家**:只能修改 `scope: player` 的属性
|
||||||
- **观察者**:不能修改任何属性
|
- **观察者**:不能修改任何属性
|
||||||
|
|
||||||
## 修饰符(modifier)详解
|
## 修饰符(modifier)
|
||||||
|
|
||||||
修饰符类型的属性会自动加到其 `target` 属性上:
|
修饰符自动加到 `target` 属性上:
|
||||||
|
|
||||||
```yaml role=stat
|
```yaml role=stat
|
||||||
- key: strength
|
- key: strength
|
||||||
|
|
@ -213,12 +218,12 @@ weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
||||||
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
|
||||||
|
|
@ -227,50 +232,81 @@ weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
||||||
formula: "strength * 2 + 10"
|
formula: "strength * 2 + 10"
|
||||||
```
|
```
|
||||||
|
|
||||||
公式中引用其他属性时,会自动使用其计算值(包含修饰符)。
|
公式引用其他属性时自动使用计算值(含修饰符)。
|
||||||
支持的函数:`floor(x)`, `ceil(x)`, `round(x)`。
|
支持的函数:`floor(x)`, `ceil(x)`, `round(x)`。
|
||||||
|
|
||||||
## 模板属性(template)详解
|
## 模板属性(template)
|
||||||
|
|
||||||
模板属性用于查表掷骰,例如年龄表、职业表等。
|
模板属性用于查表掷骰(年龄表、职业表等)。
|
||||||
|
|
||||||
模板在独立的 CSV 代码块中定义,使用 `role=stat-template` 和 `id=模板名`。
|
### 方式一:stat-modifiers(推荐)
|
||||||
**第一列是骰子表达式**(如 `1d10`),第二列是 `label`,其余列为修饰符:
|
|
||||||
|
|
||||||
```csv id=年龄 role=stat-template
|
一个代码块同时生成模板 stat 和修饰符 stat defs:
|
||||||
1d10,label,heart,mind,strength,speed
|
|
||||||
1-3,青少年,+20,-10,,
|
```csv id=年龄 role=stat-modifiers
|
||||||
|
1d10,label,mind,heart,strength,speed
|
||||||
|
1-3,青少年,-10,+20,,
|
||||||
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`(多个)
|
- `age`(type: template, template: 年龄)
|
||||||
- **`label` 列**:显示名称
|
- `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
|
||||||
|
1d10,label,age_mind,age_heart,age_strength,age_speed
|
||||||
|
1-3,青少年,-10,+20,,
|
||||||
|
4-7,成年,,-10,,+20
|
||||||
|
8-9,老年,,+20,-10,
|
||||||
|
10,换躯者,,,+30,-10
|
||||||
|
```
|
||||||
|
|
||||||
```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
|
||||||
|
|
@ -278,6 +314,4 @@ weather,天气,enum,晴天\|阴天\|雨天\|暴风雨
|
||||||
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` 会被替换为当前值后再掷骰。
|
|
||||||
Loading…
Reference in New Issue