235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
/**
|
|
* StatsView — table view of all stat key-value pairs.
|
|
*
|
|
* Groups stats by scope (global, then per-player). Shows computed values
|
|
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
|
|
*/
|
|
|
|
import { Component, For, createMemo, Show } from "solid-js";
|
|
import { useJournalStream } from "../stores/journalStream";
|
|
import { useJournalCompletions } from "./completions";
|
|
import { fullKey, modifierLabel } from "./stat-helpers";
|
|
import type { StatDef } from "./completions";
|
|
|
|
export const StatsView: Component = () => {
|
|
const stream = useJournalStream();
|
|
const comp = useJournalCompletions();
|
|
|
|
const statDefs = createMemo(() => comp.data.stats);
|
|
const values = createMemo(() => stream.stats);
|
|
|
|
/** Build a lookup: fullKey -> StatDef */
|
|
const defMap = createMemo(() => {
|
|
const map = new Map<string, StatDef>();
|
|
for (const def of statDefs()) {
|
|
const fk = fullKey(def, stream.myName);
|
|
map.set(fk, def);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
/** Compute the effective value of a stat (base + modifiers) */
|
|
function computedValue(key: string): string {
|
|
const def = defMap().get(key);
|
|
if (!def) return values()[key] ?? "";
|
|
|
|
const base = values()[key] ?? def.default ?? "";
|
|
const baseNum = parseFloat(base);
|
|
|
|
if (def.type === "number" || def.type === "derived") {
|
|
// Sum modifiers that target this key (fullKey matching)
|
|
const modKeys: string[] = [];
|
|
for (const [fk, d] of defMap()) {
|
|
if (
|
|
d.type === "modifier" &&
|
|
d.target &&
|
|
fullKeyTarget(d.target, d, def)
|
|
) {
|
|
modKeys.push(fk);
|
|
}
|
|
}
|
|
let total = isNaN(baseNum) ? 0 : baseNum;
|
|
for (const mk of modKeys) {
|
|
const mv = parseFloat(values()[mk] ?? defMap().get(mk)?.default ?? "0");
|
|
if (!isNaN(mv)) total += mv;
|
|
}
|
|
return String(total);
|
|
}
|
|
|
|
return base || "—";
|
|
}
|
|
|
|
/** Check if a modifier's target matches a given def (scoped correctly). */
|
|
function fullKeyTarget(
|
|
target: string,
|
|
modDef: StatDef,
|
|
targetDef: StatDef,
|
|
): boolean {
|
|
if (modDef.scope === targetDef.scope) {
|
|
return (
|
|
fullKey({ ...modDef, key: target }, stream.myName) ===
|
|
fullKey(targetDef, stream.myName)
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Group stats by scope */
|
|
const groups = createMemo(() => {
|
|
const result: {
|
|
scope: string;
|
|
label: string;
|
|
entries: { fullKey: string; def: StatDef }[];
|
|
}[] = [];
|
|
const globalEntries: { fullKey: string; def: StatDef }[] = [];
|
|
const playerEntries: { fullKey: string; def: StatDef }[] = [];
|
|
|
|
for (const def of statDefs()) {
|
|
const fk = fullKey(def, stream.myName);
|
|
if (def.scope === "global") {
|
|
globalEntries.push({ fullKey: fk, def });
|
|
} else {
|
|
playerEntries.push({ fullKey: fk, def });
|
|
}
|
|
}
|
|
|
|
if (globalEntries.length > 0) {
|
|
result.push({ scope: "global", label: "全局", entries: globalEntries });
|
|
}
|
|
|
|
if (playerEntries.length > 0) {
|
|
result.push({
|
|
scope: "player",
|
|
label: `玩家 (${stream.myName})`,
|
|
entries: playerEntries,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
return (
|
|
<div class="h-full overflow-y-auto bg-gray-50">
|
|
<Show
|
|
when={statDefs().length > 0}
|
|
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>
|
|
</div>
|
|
);
|
|
};
|