Compare commits
3 Commits
45c8722af8
...
c10b088fb1
| Author | SHA1 | Date |
|---|---|---|
|
|
c10b088fb1 | |
|
|
1ef41f04fc | |
|
|
9a41f541e9 |
|
|
@ -4,6 +4,7 @@ import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
|||
import { createReadStream } from "fs";
|
||||
import { join, resolve, extname, sep, relative, dirname } from "path";
|
||||
import { watch } from "chokidar";
|
||||
import { networkInterfaces } from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
import { createJournalServer } from "../journal.js";
|
||||
import {
|
||||
|
|
@ -49,6 +50,35 @@ function getMimeType(filePath: string): string {
|
|||
return MIME_TYPES[ext] || "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best network IP for display (prefer LAN IPv4, fallback to localhost).
|
||||
*/
|
||||
function getBestIP(): string {
|
||||
const interfaces = networkInterfaces();
|
||||
let best: string | null = null;
|
||||
|
||||
for (const [, addrs] of Object.entries(interfaces)) {
|
||||
if (!addrs) continue;
|
||||
for (const addr of addrs) {
|
||||
if (addr.family !== "IPv4" || addr.internal) continue;
|
||||
// Prefer a 192.168.x.x or 10.x.x.x address
|
||||
if (addr.address.startsWith("192.168.")) {
|
||||
best = addr.address;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
!best &&
|
||||
(addr.address.startsWith("10.") || addr.address.startsWith("172."))
|
||||
) {
|
||||
best = addr.address;
|
||||
}
|
||||
}
|
||||
if (best?.startsWith("192.168.")) break;
|
||||
}
|
||||
|
||||
return best || "localhost";
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录内的 .md 文件,生成索引
|
||||
*/
|
||||
|
|
@ -320,11 +350,16 @@ export function createContentServer(
|
|||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
||||
const bestIP = getBestIP();
|
||||
const displayHost = host === "0.0.0.0" ? bestIP : host;
|
||||
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
||||
console.log(`本机访问:http://localhost:${port}`);
|
||||
if (bestIP !== "localhost") {
|
||||
console.log(`局域网访问:http://${bestIP}:${port}`);
|
||||
}
|
||||
console.log(`内容目录:${contentDir}`);
|
||||
console.log(`静态资源目录:${distPath}`);
|
||||
console.log(`Journal 连接:ws://${displayHost}:${port}\n`);
|
||||
console.log(`Journal 连接:ws://${bestIP}:${port}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import {
|
|||
onMount,
|
||||
} from "solid-js";
|
||||
import { docEntries, type DocEntry } from "./doc-data";
|
||||
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
|
||||
|
||||
type DocSet = "directives" | "journal";
|
||||
type AnyEntry = DocEntry | JournalDocEntry;
|
||||
|
||||
export interface DocDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -14,17 +18,42 @@ export interface DocDialogProps {
|
|||
}
|
||||
|
||||
const DocDialog: Component<DocDialogProps> = (props) => {
|
||||
const [docSet, setDocSet] = createSignal<DocSet>("directives");
|
||||
const [selectedTag, setSelectedTag] = createSignal(docEntries[0]?.tag ?? "");
|
||||
const [dropdownOpen, setDropdownOpen] = createSignal(false);
|
||||
let dropdownRef!: HTMLDivElement;
|
||||
|
||||
const currentEntries = () =>
|
||||
docSet() === "directives"
|
||||
? (docEntries as AnyEntry[])
|
||||
: (journalDocEntries as AnyEntry[]);
|
||||
|
||||
const selectedEntry = () =>
|
||||
docEntries.find((e) => e.tag === selectedTag()) ?? docEntries[0];
|
||||
currentEntries().find((e) => e.tag === selectedTag()) ??
|
||||
currentEntries()[0];
|
||||
|
||||
const docSetLabel = () =>
|
||||
docSet() === "directives" ? "指令组件文档" : "Journal 服务器文档";
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") props.onClose();
|
||||
};
|
||||
|
||||
const switchDocSet = (set: DocSet) => {
|
||||
setDocSet(set);
|
||||
setDropdownOpen(false);
|
||||
const entries = set === "directives" ? docEntries : journalDocEntries;
|
||||
setSelectedTag(entries[0]?.tag ?? "");
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
|
|
@ -41,7 +70,47 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||
>
|
||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
||||
<h2 class="text-lg font-bold text-gray-900">指令组件文档</h2>
|
||||
<div ref={dropdownRef} class="relative">
|
||||
<button
|
||||
onClick={() => setDropdownOpen((v) => !v)}
|
||||
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
|
||||
>
|
||||
{docSetLabel()}
|
||||
<span class="text-gray-400 text-sm">▾</span>
|
||||
</button>
|
||||
<Show when={dropdownOpen()}>
|
||||
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-50 py-1">
|
||||
<button
|
||||
onClick={() => switchDocSet("directives")}
|
||||
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||
docSet() === "directives"
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span>📖</span>
|
||||
<span>指令组件文档</span>
|
||||
<Show when={docSet() === "directives"}>
|
||||
<span class="ml-auto text-blue-500">✓</span>
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchDocSet("journal")}
|
||||
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||
docSet() === "journal"
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span>📋</span>
|
||||
<span>Journal 服务器文档</span>
|
||||
<Show when={docSet() === "journal"}>
|
||||
<span class="ml-auto text-blue-500">✓</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
||||
|
|
@ -53,7 +122,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<nav class="w-48 shrink-0 border-r border-gray-200 overflow-y-auto p-3 bg-gray-50">
|
||||
<For each={docEntries}>
|
||||
<For each={currentEntries()}>
|
||||
{(entry) => (
|
||||
<button
|
||||
onClick={() => setSelectedTag(entry.tag)}
|
||||
|
|
@ -64,7 +133,12 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||
}`}
|
||||
>
|
||||
<span class="mr-2">{entry.icon}</span>
|
||||
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
|
||||
<Show
|
||||
when={docSet() === "directives"}
|
||||
fallback={<span class="text-xs">{entry.title}</span>}
|
||||
>
|
||||
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -83,7 +157,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
|||
};
|
||||
|
||||
/** Single entry documentation content */
|
||||
const DocContent: Component<{ entry: DocEntry }> = (props) => {
|
||||
const DocContent: Component<{ entry: AnyEntry }> = (props) => {
|
||||
const e = props.entry;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import yaml from "js-yaml";
|
||||
|
||||
export interface JournalDocEntry {
|
||||
tag: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
syntax: string;
|
||||
props: { name: string; type: string; default?: string; desc: string }[];
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return yaml.load(match[1]) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBody(raw: string): string {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||
return match ? match[1] : raw;
|
||||
}
|
||||
|
||||
function parseEntry(raw: string): JournalDocEntry | null {
|
||||
const fm = parseFrontmatter(raw);
|
||||
if (!fm) return null;
|
||||
return {
|
||||
tag: fm.tag as string,
|
||||
icon: fm.icon as string,
|
||||
title: fm.title as string,
|
||||
description: fm.description as string,
|
||||
syntax: fm.syntax as string,
|
||||
props: (fm.props as JournalDocEntry["props"]) ?? [],
|
||||
body: getBody(raw),
|
||||
};
|
||||
}
|
||||
|
||||
import journalGmRaw from "../doc-entries/journal-gm.md";
|
||||
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
||||
|
||||
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw];
|
||||
|
||||
let _entries: JournalDocEntry[] | null = null;
|
||||
|
||||
function loadJournalDocEntries(): JournalDocEntry[] {
|
||||
if (_entries) return _entries;
|
||||
_entries = rawDocuments.map(parseEntry).filter(Boolean) as JournalDocEntry[];
|
||||
return _entries;
|
||||
}
|
||||
|
||||
export const journalDocEntries = loadJournalDocEntries();
|
||||
|
|
@ -68,7 +68,7 @@ export const ComposePanel: Component = () => {
|
|||
disabled={sending()}
|
||||
class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{sending() ? "..." : "Send"}
|
||||
{sending() ? "..." : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export const ConnectionBar: Component = () => {
|
|||
};
|
||||
|
||||
const handleDeleteSession = (id: string) => {
|
||||
if (confirm(`Delete session "${id}"? This cannot be undone.`)) {
|
||||
if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
|
||||
deleteSession(id);
|
||||
}
|
||||
};
|
||||
|
|
@ -41,7 +41,7 @@ export const ConnectionBar: Component = () => {
|
|||
await hydrateFromServer(id);
|
||||
setSessionId(id);
|
||||
} catch (e) {
|
||||
setError("Failed to load session");
|
||||
setError("无法加载会话");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ export const ConnectionBar: Component = () => {
|
|||
{/* Status row */}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span class="text-xs text-gray-500">connected</span>
|
||||
<span class="text-xs text-gray-500">已连接</span>
|
||||
<Show when={stream.sessionId}>
|
||||
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{stream.sessionName || stream.sessionId}
|
||||
|
|
@ -62,12 +62,12 @@ export const ConnectionBar: Component = () => {
|
|||
</Show>
|
||||
<div class="flex-1" />
|
||||
<Show when={stream.myName !== "gm"}>
|
||||
<span class="text-xs text-gray-400">Playing as {stream.myName}</span>
|
||||
<span class="text-xs text-gray-400">扮演 {stream.myName}</span>
|
||||
</Show>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
class="text-xs text-gray-400 hover:text-red-500"
|
||||
title="Disconnect"
|
||||
title="断开连接"
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
|
|
@ -77,12 +77,12 @@ export const ConnectionBar: Component = () => {
|
|||
<Show when={stream.myName === "gm"}>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-500">Sessions:</span>
|
||||
<span class="text-xs text-gray-500">会话列表:</span>
|
||||
<button
|
||||
onClick={() => setShowNewSession((v) => !v)}
|
||||
class="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
+ new
|
||||
+ 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ export const ConnectionBar: Component = () => {
|
|||
type="text"
|
||||
value={newSessionName()}
|
||||
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
||||
placeholder="Session name"
|
||||
placeholder="会话名称"
|
||||
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
|
||||
/>
|
||||
|
|
@ -100,7 +100,7 @@ export const ConnectionBar: Component = () => {
|
|||
onClick={handleCreateSession}
|
||||
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
|
||||
>
|
||||
Create
|
||||
Create Create
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -126,7 +126,7 @@ export const ConnectionBar: Component = () => {
|
|||
handleDeleteSession(id);
|
||||
}}
|
||||
class="text-gray-400 hover:text-red-500 text-xs"
|
||||
title="Delete session"
|
||||
title="删除会话"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ const SchemaFields: Component<{
|
|||
const shape = unwrapShape(props.schema);
|
||||
|
||||
if (!shape) {
|
||||
return <p class="text-xs text-gray-400">No fields</p>;
|
||||
return <p class="text-xs text-gray-400">无字段</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -98,7 +98,7 @@ const SchemaFields: Component<{
|
|||
checked={isSet()}
|
||||
onChange={toggleOptional}
|
||||
class="mt-1.5"
|
||||
title={`Include ${key}`}
|
||||
title={`包含 ${key}`}
|
||||
/>
|
||||
</Show>
|
||||
<div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}>
|
||||
|
|
@ -204,7 +204,7 @@ const FieldInput: Component<{
|
|||
}}
|
||||
disabled={props.disabled}
|
||||
class={inputClass}
|
||||
placeholder="comma-separated"
|
||||
placeholder="逗号分隔"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,20 +44,19 @@ function parseInput(raw: string): ParsedInput {
|
|||
if (raw.startsWith("/roll ")) {
|
||||
const notation = raw.slice("/roll ".length).trim();
|
||||
if (!notation)
|
||||
return { type: "roll", payload: {}, error: "Dice notation required" };
|
||||
return { type: "roll", payload: {}, error: "需要骰子表达式" };
|
||||
return { type: "roll", payload: { notation, label: notation } };
|
||||
}
|
||||
|
||||
if (raw.startsWith("/spark ")) {
|
||||
const key = raw.slice("/spark ".length).trim();
|
||||
if (!key)
|
||||
return { type: "spark", payload: {}, error: "Spark table key required" };
|
||||
if (!key) return { type: "spark", payload: {}, error: "需要种子表键名" };
|
||||
return { type: "spark", payload: { key } };
|
||||
}
|
||||
|
||||
if (raw.startsWith("/link ")) {
|
||||
const arg = raw.slice("/link ".length).trim();
|
||||
if (!arg) return { type: "link", payload: {}, error: "Path required" };
|
||||
if (!arg) return { type: "link", payload: {}, error: "需要路径" };
|
||||
const hashIdx = arg.indexOf("#");
|
||||
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
|
||||
const section =
|
||||
|
|
@ -67,7 +66,7 @@ function parseInput(raw: string): ParsedInput {
|
|||
|
||||
// /roll, /spark, or /link with no space — need to complete, don't send
|
||||
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
|
||||
return { type: "chat", payload: {}, error: "Complete the command" };
|
||||
return { type: "chat", payload: {}, error: "请补全命令" };
|
||||
}
|
||||
|
||||
return { type: "chat", payload: { text: raw } };
|
||||
|
|
@ -168,7 +167,7 @@ export const JournalInput: Component = () => {
|
|||
textareaRef?.focus();
|
||||
return;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to roll spark table");
|
||||
setError(e instanceof Error ? e.message : "种子表掷骰失败");
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -216,7 +215,7 @@ export const JournalInput: Component = () => {
|
|||
);
|
||||
return matches.length > 0
|
||||
? matches
|
||||
: [{ label: "Unknown command", kind: "no-results", insertText: "" }];
|
||||
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
|
||||
}
|
||||
|
||||
// After /roll — show dice suggestions
|
||||
|
|
@ -230,7 +229,7 @@ export const JournalInput: Component = () => {
|
|||
)
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0) {
|
||||
return [{ label: "No dice found", kind: "no-results", insertText: "" }];
|
||||
return [{ label: "未找到骰子", kind: "no-results", insertText: "" }];
|
||||
}
|
||||
return matches.map((d) => ({
|
||||
label: d.notation,
|
||||
|
|
@ -252,14 +251,14 @@ export const JournalInput: Component = () => {
|
|||
if (matches.length === 0) {
|
||||
return [
|
||||
{
|
||||
label: "No spark tables found",
|
||||
label: "未找到种子表",
|
||||
kind: "no-results",
|
||||
insertText: "",
|
||||
},
|
||||
];
|
||||
}
|
||||
return matches.map((s) => ({
|
||||
label: `${s.filePath} § ${s.slug} (${s.notation})`,
|
||||
label: `${s.slug} (${s.notation})`,
|
||||
kind: "value" as const,
|
||||
insertText: `/spark ${s.slug}`,
|
||||
}));
|
||||
|
|
@ -276,9 +275,7 @@ export const JournalInput: Component = () => {
|
|||
)
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0) {
|
||||
return [
|
||||
{ label: "No links found", kind: "no-results", insertText: "" },
|
||||
];
|
||||
return [{ label: "未找到链接", kind: "no-results", insertText: "" }];
|
||||
}
|
||||
return matches.map((l) => {
|
||||
const insert = l.section
|
||||
|
|
@ -459,7 +456,7 @@ export const JournalInput: Component = () => {
|
|||
ref={completionsRef}
|
||||
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
|
||||
>
|
||||
Loading completions…
|
||||
正在加载补全数据…
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={comp.state.status === "error"}>
|
||||
|
|
@ -467,7 +464,7 @@ export const JournalInput: Component = () => {
|
|||
ref={completionsRef}
|
||||
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
|
||||
>
|
||||
{(comp.state as any).message || "Failed to load completions"}
|
||||
{(comp.state as any).message || "无法加载补全数据"}
|
||||
</div>
|
||||
</Match>
|
||||
<Match
|
||||
|
|
@ -486,8 +483,7 @@ export const JournalInput: Component = () => {
|
|||
when={!isObserver()}
|
||||
fallback={
|
||||
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
|
||||
You are observing. Open this panel on another device to join as GM
|
||||
or player.
|
||||
你正在观察模式。在另一台设备上打开此面板以主持人或玩家身份加入。
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
@ -498,8 +494,8 @@ export const JournalInput: Component = () => {
|
|||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isGm()
|
||||
? "Type a message, or /roll, /spark, or /link..."
|
||||
: "Type a message..."
|
||||
? "输入消息,或使用 /roll、/spark、/link 命令..."
|
||||
: "输入消息..."
|
||||
}
|
||||
rows={2}
|
||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||
|
|
@ -520,7 +516,7 @@ export const JournalInput: Component = () => {
|
|||
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
Send
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
<button
|
||||
onClick={() => setShowInvite(true)}
|
||||
class="text-xs text-gray-400 hover:text-blue-600 px-1.5 py-0.5 rounded border border-dashed border-gray-300 hover:border-blue-400 whitespace-nowrap shrink-0"
|
||||
title="Invite a player"
|
||||
title="邀请玩家"
|
||||
>
|
||||
+ invite
|
||||
+ 邀请
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -161,9 +161,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||
<Show
|
||||
when={stream.connected && stream.sessionId}
|
||||
fallback={
|
||||
<h2 class="text-sm font-semibold text-gray-700 truncate">
|
||||
Disconnected
|
||||
</h2>
|
||||
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2>
|
||||
}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
|
|
@ -221,7 +219,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||
onInput={(e) =>
|
||||
setNewSessionName(e.currentTarget.value)
|
||||
}
|
||||
placeholder="Session name"
|
||||
placeholder="会话名称"
|
||||
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs"
|
||||
autofocus
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -242,7 +240,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||
onClick={() => setShowNewInput(true)}
|
||||
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
|
||||
>
|
||||
+ New session
|
||||
+ 新建会话
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -257,7 +255,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||
<button
|
||||
onClick={handleDisconnect}
|
||||
class="text-xs text-gray-400 hover:text-red-500 px-1"
|
||||
title="Disconnect"
|
||||
title="断开连接"
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
|
|
@ -265,7 +263,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
|||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
||||
title="Hide panel"
|
||||
title="隐藏面板"
|
||||
>
|
||||
▸
|
||||
</button>
|
||||
|
|
@ -332,16 +330,16 @@ const ConnectDialog: Component = () => {
|
|||
when={!autoJoined() || error()}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
{connecting() ? "Joining session…" : "Joined!"}
|
||||
{connecting() ? "正在加入会话…" : "已加入!"}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="w-full max-w-sm space-y-3">
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
Enter your name and role to join the session.
|
||||
请输入你的名字和角色来加入会话。
|
||||
<br />
|
||||
<span class="text-xs text-gray-400">
|
||||
Connecting to {window.location.host}
|
||||
连接到 {window.location.host}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
|
|
@ -349,7 +347,7 @@ const ConnectDialog: Component = () => {
|
|||
value={playerName()}
|
||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
||||
placeholder="Your name"
|
||||
placeholder="你的名字"
|
||||
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
autofocus
|
||||
/>
|
||||
|
|
@ -364,7 +362,7 @@ const ConnectDialog: Component = () => {
|
|||
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{r === "gm" ? "GM" : r === "player" ? "Player" : "Observer"}
|
||||
{r === "gm" ? "主持人" : r === "player" ? "玩家" : "观察者"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -374,7 +372,7 @@ const ConnectDialog: Component = () => {
|
|||
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
|
||||
hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{connecting() ? "Connecting..." : "Join"}
|
||||
{connecting() ? "连接中..." : "加入"}
|
||||
</button>
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||
|
|
@ -424,7 +422,7 @@ const InviteDialog: Component<InviteDialogProps> = (props) => {
|
|||
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/20">
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-800">Invite Player</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-800">邀请玩家</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||
|
|
@ -433,13 +431,13 @@ const InviteDialog: Component<InviteDialogProps> = (props) => {
|
|||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
Enter a player name and share the link. They will join as a player.
|
||||
输入玩家名字并分享链接,对方将以玩家身份加入。
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName()}
|
||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||
placeholder="Player name"
|
||||
placeholder="玩家名字"
|
||||
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
autofocus
|
||||
/>
|
||||
|
|
@ -459,7 +457,7 @@ const InviteDialog: Component<InviteDialogProps> = (props) => {
|
|||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
}`}
|
||||
>
|
||||
{copied() ? "Copied!" : "Copy"}
|
||||
{copied() ? "已复制!" : "复制"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const StreamMessageCard: Component<{
|
|||
<button
|
||||
onClick={() => revertLatest()}
|
||||
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
|
||||
title="Revert this message"
|
||||
title="撤回此消息"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
|
@ -87,7 +87,7 @@ const RevealedCard: Component<{ msg: StreamMessageType }> = (props) => {
|
|||
return (
|
||||
<div class="bg-gray-100 rounded-lg border border-gray-200 opacity-50 px-2.5 py-1.5">
|
||||
<span class="text-xs text-gray-400 line-through">
|
||||
{props.msg.sender} — reverted
|
||||
{props.msg.sender} — 已撤回
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export const StreamView: Component = () => {
|
|||
<Show when={messages().length === 0}>
|
||||
<p class="text-center text-gray-400 text-xs py-8">
|
||||
{stream.connected
|
||||
? "No messages yet. Start by posting something."
|
||||
: "Connect to a session to see messages."}
|
||||
? "暂无消息,发送点什么开始吧。"
|
||||
: "连接会话以查看消息。"}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export type ChatPayload = z.infer<typeof schema>;
|
|||
|
||||
registerMessageType<ChatPayload>({
|
||||
type: "chat",
|
||||
label: "Chat",
|
||||
label: "聊天",
|
||||
emitters: ["gm", "player"],
|
||||
schema,
|
||||
defaultPayload: () => ({ text: "" }),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export type IntentPayload = z.infer<typeof intentSchema>;
|
|||
|
||||
registerMessageType<IntentPayload>({
|
||||
type: "intent",
|
||||
label: "Declare Intent",
|
||||
label: "声明意图",
|
||||
emitters: ["player"],
|
||||
schema: intentSchema,
|
||||
defaultPayload: () => ({ description: "" }),
|
||||
|
|
@ -26,7 +26,7 @@ registerMessageType<IntentPayload>({
|
|||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">🙋</span>
|
||||
<span class="text-sm font-medium text-yellow-700">Intent</span>
|
||||
<span class="text-sm font-medium text-yellow-700">意图</span>
|
||||
</div>
|
||||
<p class="text-sm">{p.description}</p>
|
||||
{p.context && Object.keys(p.context).length > 0 && (
|
||||
|
|
@ -52,14 +52,14 @@ export type ResolutionPayload = z.infer<typeof resolutionSchema>;
|
|||
|
||||
registerMessageType<ResolutionPayload>({
|
||||
type: "resolution",
|
||||
label: "Resolve Intent",
|
||||
label: "裁决意图",
|
||||
emitters: ["gm"],
|
||||
schema: resolutionSchema,
|
||||
render: (p) => (
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">✅</span>
|
||||
<span class="text-sm font-medium text-green-700">Resolution</span>
|
||||
<span class="text-sm font-medium text-green-700">裁决</span>
|
||||
<span class="text-xs text-gray-400">re: {p.intentId}</span>
|
||||
</div>
|
||||
<p class="text-sm">{p.outcome}</p>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ const RevealLink: Component<LinkPayload> = (p) => {
|
|||
|
||||
registerMessageType<LinkPayload>({
|
||||
type: "link",
|
||||
label: "Link Article",
|
||||
label: "链接文章",
|
||||
emitters: ["gm"],
|
||||
schema,
|
||||
defaultPayload: () => ({ path: "/" }),
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function resolveRollPayload(raw: {
|
|||
|
||||
registerMessageType<RollPayload>({
|
||||
type: "roll",
|
||||
label: "Roll Dice",
|
||||
label: "掷骰",
|
||||
emitters: ["gm"],
|
||||
schema,
|
||||
defaultPayload: () => ({
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export async function resolveSparkPayload(raw: {
|
|||
|
||||
registerMessageType<SparkPayload>({
|
||||
type: "spark",
|
||||
label: "Spark Table",
|
||||
label: "种子表",
|
||||
emitters: ["gm"],
|
||||
schema,
|
||||
defaultPayload: () => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
tag: journal-gm
|
||||
icon: 🎭
|
||||
title: 主持人指南
|
||||
description: 作为 GM 创建会话、管理玩家、使用动态表单和消息类型。
|
||||
syntax: '点击导航栏 📋 图标打开 Journal 面板'
|
||||
props:
|
||||
- name: 会话管理
|
||||
type: —
|
||||
desc: 创建、切换、删除会话;通过邀请链接添加玩家
|
||||
- name: 消息类型
|
||||
type: —
|
||||
desc: 发送叙述、掷骰、表单等多种消息类型
|
||||
- name: 动态表单
|
||||
type: —
|
||||
desc: 创建自定义表单收集玩家输入
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
|
||||
2. 输入你的名字,选择"主持人"角色,点击"加入"
|
||||
3. 连接成功后,你可以创建新会话或选择已有会话
|
||||
|
||||
## 会话管理
|
||||
|
||||
作为 GM,你可以管理多个会话:
|
||||
|
||||
- **创建会话**:点击标题下方的会话名称 → 下拉菜单 → "+ 新建会话"
|
||||
- **切换会话**:点击会话名称,从下拉列表中选择
|
||||
- **邀请玩家**:点击玩家标签旁的 "+ 邀请",输入玩家名字,复制链接发送给玩家
|
||||
|
||||
玩家通过邀请链接加入后会自动以玩家身份连接。
|
||||
|
||||
## 消息类型
|
||||
|
||||
Journal 支持多种消息类型,通过输入框上方的标签切换:
|
||||
|
||||
- **叙述**:普通的文字描述,用于推进剧情
|
||||
- **掷骰**:发送掷骰结果,可附带公式
|
||||
- **表单**:创建动态表单,收集玩家选择或输入
|
||||
|
||||
## 动态表单
|
||||
|
||||
表单消息允许你创建交互式问卷:
|
||||
|
||||
- 添加文本输入、选择框、复选框等字段
|
||||
- 玩家提交后,GM 可以看到汇总结果
|
||||
- 适用于投票、决策、角色创建等场景
|
||||
|
||||
## 撤回消息
|
||||
|
||||
GM 可以撤回自己发送的最新消息,点击消息旁的撤回按钮即可。
|
||||
|
||||
## 连接状态
|
||||
|
||||
- 🟢 绿色:已连接
|
||||
- 🟡 黄色闪烁:连接中
|
||||
- 🔴 红色:连接错误
|
||||
- ⚪ 灰色:未连接
|
||||
|
||||
断开连接可点击标题栏的 ⏻ 按钮。
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
tag: journal-player
|
||||
icon: 🎲
|
||||
title: 玩家指南
|
||||
description: 作为玩家加入 GM 的会话,查看消息、提交表单、参与互动。
|
||||
syntax: '通过 GM 发送的邀请链接加入'
|
||||
props:
|
||||
- name: 加入方式
|
||||
type: —
|
||||
desc: 点击邀请链接自动加入,或手动输入名字和角色
|
||||
- name: 查看消息
|
||||
type: —
|
||||
desc: 实时查看 GM 和其他玩家的消息
|
||||
- name: 提交表单
|
||||
type: —
|
||||
desc: 填写 GM 发送的动态表单并提交
|
||||
---
|
||||
|
||||
## 加入会话
|
||||
|
||||
有两种方式加入 GM 的会话:
|
||||
|
||||
### 通过邀请链接(推荐)
|
||||
|
||||
GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接。
|
||||
点击链接即可自动加入,无需手动输入任何信息。
|
||||
|
||||
### 手动加入
|
||||
|
||||
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
|
||||
2. 输入你的名字
|
||||
3. 选择"玩家"角色
|
||||
4. 点击"加入"
|
||||
|
||||
## 查看消息
|
||||
|
||||
连接成功后,你可以在面板中看到:
|
||||
|
||||
- GM 发送的叙述消息
|
||||
- 掷骰结果
|
||||
- 其他玩家的消息
|
||||
- 动态表单
|
||||
|
||||
消息实时更新,无需刷新页面。
|
||||
|
||||
## 填写表单
|
||||
|
||||
当 GM 发送动态表单时:
|
||||
|
||||
1. 表单会显示在消息流中
|
||||
2. 根据表单类型填写(文本、选择、复选框等)
|
||||
3. 点击提交按钮发送你的回答
|
||||
|
||||
GM 可以看到所有玩家的提交结果。
|
||||
|
||||
## 角色标识
|
||||
|
||||
- 你的名字旁会显示蓝色"player"标签
|
||||
- 观察者显示灰色"observer"标签
|
||||
- GM 不显示角色标签
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 玩家无法创建或切换会话
|
||||
- 玩家无法撤回消息
|
||||
- 断开连接后重新加入会保留之前的消息记录
|
||||
|
|
@ -28,7 +28,7 @@ function tableToCSV(headers: string[], rows: string[][]): string {
|
|||
}
|
||||
|
||||
/** Spark table: first column header is a pure dice formula (d6, d20, etc.) */
|
||||
const SPARK_DICE_RE = /^d\d+$/i;
|
||||
const SPARK_DICE_RE = /^\d*d\d+$/i;
|
||||
|
||||
export default function markedTable(): MarkedExtension {
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in New Issue