feat: make demo story screen interactive
This commit is contained in:
+104
-7
@@ -1,25 +1,122 @@
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import App from "./App.vue";
|
||||
|
||||
async function mountLoadedApp(): Promise<VueWrapper> {
|
||||
const wrapper = mount(App, { attachTo: document.body });
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the canonical demo PlayerView", async () => {
|
||||
const wrapper = mount(App);
|
||||
await flushPromises();
|
||||
const wrapper = await mountLoadedApp();
|
||||
|
||||
expect(wrapper.text()).toContain("听娜娜讲故事");
|
||||
expect(wrapper.text()).toContain("娜娜");
|
||||
expect(wrapper.text()).toContain("雨水沿着废弃站台的铁棚落下");
|
||||
expect(wrapper.text()).toContain("你真的会在天亮前回来吗?");
|
||||
expect(wrapper.get('textarea[aria-label="自由输入"]')).toBeDefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("copies an editable suggestion into the composer", async () => {
|
||||
const wrapper = mount(App);
|
||||
await flushPromises();
|
||||
it("copies an editable suggestion without submitting it", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const startingNode = wrapper.get(".statusline span").text();
|
||||
|
||||
await wrapper.get(".suggestions button").trigger("click");
|
||||
|
||||
const composer = wrapper.get<HTMLTextAreaElement>("textarea");
|
||||
expect(composer.element.value.length).toBeGreaterThan(0);
|
||||
expect(wrapper.get(".statusline span").text()).toBe(startingNode);
|
||||
expect(wrapper.find(".turn-status").text()).toBe("");
|
||||
expect(wrapper.find(".beat--action").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("submits a speak_or_act intent, clears the draft, and exposes a busy state", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
|
||||
|
||||
await composer.setValue("我答应你,天亮前一定回来。");
|
||||
await wrapper.get("form.composer").trigger("submit");
|
||||
|
||||
expect(composer.element.value).toBe("");
|
||||
expect(wrapper.get(".send-button").text()).toBe("发送中…");
|
||||
expect(composer.attributes("disabled")).toBeDefined();
|
||||
expect(wrapper.get(".continue-button").attributes("disabled")).toBeDefined();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain("我答应你,天亮前一定回来。");
|
||||
expect(wrapper.get(".statusline span").text()).toBe("node_002");
|
||||
});
|
||||
expect(wrapper.get(".send-button").text()).toBe("发送");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("submits Continue through the same turn path with no player speech", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
|
||||
await wrapper.get(".continue-button").trigger("click");
|
||||
expect(wrapper.get(".continue-button").text()).toBe("继续中…");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain("一阵风越过站台");
|
||||
});
|
||||
expect(wrapper.findAll(".beat--action")).toHaveLength(0);
|
||||
expect(wrapper.get(".statusline span").text()).toBe("node_002");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("opens mutually exclusive, PlayerView-only record and relationship panels", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const [recordsButton, relationshipButton] = wrapper.findAll(".top-actions button");
|
||||
|
||||
await recordsButton.trigger("click");
|
||||
expect(wrapper.findAll('[role="dialog"]')).toHaveLength(1);
|
||||
expect(wrapper.get("#records-panel").text()).toContain("旧手电筒");
|
||||
expect(wrapper.get("#records-panel").text()).toContain("停摆的站钟");
|
||||
expect(wrapper.get("#records-panel").text()).toContain("还没有被接受的许诺");
|
||||
|
||||
await relationshipButton.trigger("click");
|
||||
expect(wrapper.findAll('[role="dialog"]')).toHaveLength(1);
|
||||
expect(wrapper.find("#records-panel").exists()).toBe(false);
|
||||
|
||||
const relationshipText = wrapper.get("#relationship-panel").text();
|
||||
expect(relationshipText).toContain("好感");
|
||||
expect(relationshipText).toContain("渐暖");
|
||||
expect(relationshipText).not.toMatch(/\d/);
|
||||
for (const forbidden of ["roll", "target", "hidden_facts", "world_flags", "checks"]) {
|
||||
expect(wrapper.text()).not.toContain(forbidden);
|
||||
}
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("selects a rewind node without claiming to persist a branch", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
const historyButton = wrapper.findAll(".top-actions button")[2];
|
||||
|
||||
await historyButton.trigger("click");
|
||||
expect(wrapper.find(".rewind-placeholder").exists()).toBe(false);
|
||||
|
||||
const nodeButton = wrapper.get(".history-list button");
|
||||
await nodeButton.trigger("click");
|
||||
expect(nodeButton.attributes("aria-pressed")).toBe("true");
|
||||
expect(wrapper.get(".rewind-placeholder").text()).toContain("从这里继续(尚未接入)");
|
||||
expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeDefined();
|
||||
expect(wrapper.get(".rewind-placeholder").text()).toContain("不会更改当前线路");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("closes the active panel with Escape", async () => {
|
||||
const wrapper = await mountLoadedApp();
|
||||
await wrapper.findAll(".top-actions button")[0].trigger("click");
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[role="dialog"]').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
+260
-15
@@ -1,19 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
import { useDemo } from "./app/useDemo";
|
||||
|
||||
const { appInfo, error, latestBeat, loading, pack, playerView } = useDemo();
|
||||
type PanelName = "records" | "relationship" | "history";
|
||||
|
||||
const {
|
||||
appInfo,
|
||||
busy,
|
||||
error,
|
||||
lastSubmittedIntent,
|
||||
loading,
|
||||
pack,
|
||||
playerView,
|
||||
submitTurn,
|
||||
turnError
|
||||
} = useDemo();
|
||||
const draft = ref("");
|
||||
const activePanel = ref<PanelName | null>(null);
|
||||
const selectedHistoryNode = ref<string | null>(null);
|
||||
const closeButton = ref<HTMLButtonElement | null>(null);
|
||||
|
||||
const sceneLabel = computed(() => {
|
||||
if (!playerView.value) return "载入场景";
|
||||
return `${playerView.value.sceneTitle} · ${pack.value?.displayName ?? ""}`;
|
||||
});
|
||||
|
||||
const recentBeats = computed(() => playerView.value?.beats.slice(-5) ?? []);
|
||||
|
||||
const relationshipLabels = {
|
||||
affinity: "好感",
|
||||
trust: "信赖",
|
||||
hope: "希望",
|
||||
respect: "尊重",
|
||||
intimacy: "亲密",
|
||||
attachment: "依恋"
|
||||
} as const;
|
||||
|
||||
const relationshipBandLabels = {
|
||||
distant: "疏远",
|
||||
guarded: "保留",
|
||||
warming: "渐暖",
|
||||
close: "亲近",
|
||||
bonded: "深厚"
|
||||
} as const;
|
||||
|
||||
const certaintyLabels = {
|
||||
suspected: "推测",
|
||||
reported: "听闻",
|
||||
confirmed: "确认"
|
||||
} as const;
|
||||
|
||||
const promiseStatusLabels = {
|
||||
proposed: "待回应",
|
||||
accepted: "已接受",
|
||||
fulfilled: "已履行",
|
||||
broken: "已背弃",
|
||||
released: "已解除",
|
||||
impossible: "无法完成"
|
||||
} as const;
|
||||
|
||||
const placementLabels = {
|
||||
bag: "随身",
|
||||
worn: "穿戴",
|
||||
hand: "手持",
|
||||
scene: "场景中",
|
||||
hidden: "收起"
|
||||
} as const;
|
||||
|
||||
function useSuggestion(suggestion: { draft: string }) {
|
||||
draft.value = suggestion.draft;
|
||||
}
|
||||
|
||||
async function openPanel(panel: PanelName) {
|
||||
activePanel.value = activePanel.value === panel ? null : panel;
|
||||
if (activePanel.value) {
|
||||
await nextTick();
|
||||
closeButton.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
activePanel.value = null;
|
||||
}
|
||||
|
||||
async function sendDraft() {
|
||||
const input = draft.value.trim();
|
||||
if (!input || busy.value) return;
|
||||
|
||||
draft.value = "";
|
||||
await submitTurn("speak_or_act", input);
|
||||
}
|
||||
|
||||
async function continueStory() {
|
||||
if (busy.value || !playerView.value?.canContinue) return;
|
||||
await submitTurn("continue", "");
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && activePanel.value) {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", handleEscape));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,9 +116,30 @@ function useSuggestion(suggestion: { draft: string }) {
|
||||
</div>
|
||||
<div class="scene-label">{{ sceneLabel }}</div>
|
||||
<nav class="top-actions" aria-label="故事工具">
|
||||
<button type="button">持有物</button>
|
||||
<button type="button">关系</button>
|
||||
<button type="button">回溯</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls="records-panel"
|
||||
:aria-expanded="activePanel === 'records'"
|
||||
@click="openPanel('records')"
|
||||
>
|
||||
持有物
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls="relationship-panel"
|
||||
:aria-expanded="activePanel === 'relationship'"
|
||||
@click="openPanel('relationship')"
|
||||
>
|
||||
关系
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls="history-panel"
|
||||
:aria-expanded="activePanel === 'history'"
|
||||
@click="openPanel('history')"
|
||||
>
|
||||
回溯
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -52,12 +164,13 @@ function useSuggestion(suggestion: { draft: string }) {
|
||||
|
||||
<div class="chapter-chip">第一幕 · 雨夜车站</div>
|
||||
|
||||
<article class="dialogue-panel">
|
||||
<div class="speaker-row">
|
||||
<strong>{{ latestBeat?.speaker ?? playerView.characterName }}</strong>
|
||||
<span>雨声很近,钟声还没有响。</span>
|
||||
</div>
|
||||
<p>{{ latestBeat?.text }}</p>
|
||||
<article class="dialogue-panel" aria-label="最近演出">
|
||||
<ol class="beat-list">
|
||||
<li v-for="beat in recentBeats" :key="beat.id" :class="`beat beat--${beat.kind}`">
|
||||
<strong v-if="beat.speaker">{{ beat.speaker }}</strong>
|
||||
<p>{{ beat.text }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
<div class="suggestions" aria-label="行动建议">
|
||||
@@ -65,30 +178,162 @@ function useSuggestion(suggestion: { draft: string }) {
|
||||
v-for="suggestion in playerView.suggestions"
|
||||
:key="suggestion.id"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="useSuggestion(suggestion)"
|
||||
>
|
||||
{{ suggestion.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="composer" @submit.prevent>
|
||||
<form class="composer" aria-label="玩家输入" @submit.prevent="sendDraft">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
aria-label="自由输入"
|
||||
placeholder="说些什么,或者描述你的行动……"
|
||||
rows="2"
|
||||
:disabled="busy"
|
||||
/>
|
||||
<button class="continue-button" type="button">继续</button>
|
||||
<button class="send-button" type="submit" :disabled="draft.trim().length === 0">
|
||||
发送
|
||||
<button
|
||||
class="continue-button"
|
||||
type="button"
|
||||
:disabled="busy || !playerView.canContinue"
|
||||
@click="continueStory"
|
||||
>
|
||||
{{ busy && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
|
||||
</button>
|
||||
<button class="send-button" type="submit" :disabled="busy || draft.trim().length === 0">
|
||||
{{ busy && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="turn-status" aria-live="polite">
|
||||
<span v-if="busy">故事正在回应,请稍候。</span>
|
||||
<span v-else-if="turnError">请求未完成:{{ turnError }}</span>
|
||||
</p>
|
||||
|
||||
<footer class="statusline">
|
||||
<span>{{ playerView.nodeId }}</span>
|
||||
<span>{{ playerView.branchId }}</span>
|
||||
<span>关系与判定已隐藏</span>
|
||||
</footer>
|
||||
|
||||
<div v-if="activePanel" class="panel-scrim" @click.self="closePanel">
|
||||
<aside
|
||||
:id="`${activePanel}-panel`"
|
||||
class="side-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="`${activePanel}-title`"
|
||||
>
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<span class="panel-eyebrow">当前故事</span>
|
||||
<h2 :id="`${activePanel}-title`">
|
||||
{{
|
||||
activePanel === "records"
|
||||
? "随身记录"
|
||||
: activePanel === "relationship"
|
||||
? `与${playerView.characterName}的关系`
|
||||
: "故事回溯"
|
||||
}}
|
||||
</h2>
|
||||
</div>
|
||||
<button ref="closeButton" class="panel-close" type="button" aria-label="关闭面板" @click="closePanel">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="activePanel === 'records'" class="panel-content records-panel">
|
||||
<section aria-labelledby="inventory-title">
|
||||
<h3 id="inventory-title">持有物</h3>
|
||||
<ul v-if="playerView.inventory.length" class="card-list">
|
||||
<li v-for="item in playerView.inventory" :key="item.instanceId">
|
||||
<div class="card-title">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ placementLabels[item.placement] }} · ×{{ item.quantity }}</span>
|
||||
</div>
|
||||
<p>{{ item.description }}</p>
|
||||
<small>状态:{{ item.condition }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty-copy">目前没有随身持有物。</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="knowledge-title">
|
||||
<h3 id="knowledge-title">已知线索</h3>
|
||||
<ul v-if="playerView.knowledge.length" class="card-list">
|
||||
<li v-for="record in playerView.knowledge" :key="record.id">
|
||||
<div class="card-title">
|
||||
<strong>{{ record.title }}</strong>
|
||||
<span>{{ certaintyLabels[record.certainty] }}</span>
|
||||
</div>
|
||||
<p>{{ record.summary }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty-copy">还没有确认的线索。</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="promises-title">
|
||||
<h3 id="promises-title">重要许诺</h3>
|
||||
<ul v-if="playerView.promises.length" class="card-list">
|
||||
<li v-for="promise in playerView.promises" :key="promise.id">
|
||||
<div class="card-title">
|
||||
<strong>{{ promise.content }}</strong>
|
||||
<span>{{ promiseStatusLabels[promise.status] }}</span>
|
||||
</div>
|
||||
<small>{{ promise.weight === "major" ? "重要许诺" : "普通许诺" }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty-copy">你们之间还没有被接受的许诺。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activePanel === 'relationship'" class="panel-content relationship-panel">
|
||||
<p class="panel-intro">关系只呈现她此刻对你的大致感受,不显示精确数值。</p>
|
||||
<dl class="relationship-list">
|
||||
<div
|
||||
v-for="(label, key) in relationshipLabels"
|
||||
:key="key"
|
||||
class="relationship-row"
|
||||
>
|
||||
<dt>{{ label }}</dt>
|
||||
<dd :data-band="playerView.relationship[key]">
|
||||
{{ relationshipBandLabels[playerView.relationship[key]] }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="panel-note">
|
||||
{{
|
||||
playerView.relationship.updatedAtNode
|
||||
? "最近在一个场景节点后发生了变化。"
|
||||
: "当前场景尚未结算新的关系变化。"
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel-content history-panel">
|
||||
<p class="panel-intro">选择一个故事节点查看回溯入口。当前原型不会创建或保存新分支。</p>
|
||||
<ol class="history-list">
|
||||
<li v-for="node in playerView.history" :key="node.id">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ selected: selectedHistoryNode === node.id }"
|
||||
:aria-pressed="selectedHistoryNode === node.id"
|
||||
@click="selectedHistoryNode = node.id"
|
||||
>
|
||||
<span>{{ node.label }}</span>
|
||||
<small>{{ node.isCurrent ? "当前节点" : "旧节点" }}</small>
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
<div v-if="selectedHistoryNode" class="rewind-placeholder" aria-live="polite">
|
||||
<span>已选择:{{ selectedHistoryNode }}</span>
|
||||
<button type="button" disabled>从这里继续(尚未接入)</button>
|
||||
<small>这里只展示入口,不会更改当前线路。</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import initialView from "../../fixtures/player-view/initial.json";
|
||||
import type { PlayerView, TurnRequest } from "@contracts";
|
||||
|
||||
import { submitDemoTurn } from "./turnAdapter";
|
||||
|
||||
function request(intent: TurnRequest["intent"], input: string): TurnRequest {
|
||||
return {
|
||||
storyId: initialView.storyId,
|
||||
branchId: initialView.branchId,
|
||||
expectedNodeId: initialView.nodeId,
|
||||
actionId: `test_${intent}`,
|
||||
intent,
|
||||
input
|
||||
};
|
||||
}
|
||||
|
||||
describe("local turn adapter", () => {
|
||||
it("preserves literal player input without inventing additional player speech", async () => {
|
||||
const literalInput = "我把手电筒放在长椅上。";
|
||||
const result = await submitDemoTurn(
|
||||
request("speak_or_act", literalInput),
|
||||
initialView as PlayerView
|
||||
);
|
||||
const playerBeats = result.playerView.beats.filter((beat) => beat.speaker === "你");
|
||||
|
||||
expect(playerBeats).toHaveLength(1);
|
||||
expect(playerBeats[0]?.text).toBe(literalInput);
|
||||
});
|
||||
|
||||
it("accepts an empty Continue intent without creating a player beat", async () => {
|
||||
const result = await submitDemoTurn(request("continue", ""), initialView as PlayerView);
|
||||
|
||||
expect(result.committedNodeId).toBe("node_002");
|
||||
expect(result.playerView.beats.some((beat) => beat.speaker === "你")).toBe(false);
|
||||
expect(result.playerView.beats.at(-1)?.speaker).toBe("娜娜");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PlayerView, PresentationBeat, TurnRequest, TurnResult } from "@contracts";
|
||||
|
||||
const DEMO_LATENCY_MS = 120;
|
||||
|
||||
function nextNodeId(currentNodeId: string): string {
|
||||
const match = currentNodeId.match(/^(.*?)(\d+)$/);
|
||||
if (!match) return `${currentNodeId}_next`;
|
||||
|
||||
const [, prefix, digits] = match;
|
||||
return `${prefix}${String(Number(digits) + 1).padStart(digits.length, "0")}`;
|
||||
}
|
||||
|
||||
function replyBeats(request: TurnRequest): PresentationBeat[] {
|
||||
if (request.intent === "continue") {
|
||||
return [
|
||||
{
|
||||
id: `${request.actionId}_rain`,
|
||||
kind: "narration",
|
||||
speaker: null,
|
||||
text: "一阵风越过站台,雨点敲在铁棚上,短暂盖过了远处的水声。",
|
||||
visual: null
|
||||
},
|
||||
{
|
||||
id: `${request.actionId}_nana`,
|
||||
kind: "dialogue",
|
||||
speaker: "娜娜",
|
||||
text: "如果你还没想好,就先听一会儿雨吧。",
|
||||
visual: {
|
||||
character: "nana",
|
||||
expression: "guarded",
|
||||
pose: null,
|
||||
scene: null
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${request.actionId}_player`,
|
||||
kind: "action",
|
||||
speaker: "你",
|
||||
// The demo repeats only the player's literal input. It never invents player speech or intent.
|
||||
text: request.input,
|
||||
visual: null
|
||||
},
|
||||
{
|
||||
id: `${request.actionId}_nana`,
|
||||
kind: "dialogue",
|
||||
speaker: "娜娜",
|
||||
text: "娜娜静静听完,攥着外套的手稍微松开了一些。“我知道了。”",
|
||||
visual: {
|
||||
character: "nana",
|
||||
expression: "uneasy",
|
||||
pose: "holding_coat",
|
||||
scene: null
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-only M0 adapter. It deliberately mirrors the future runtime boundary:
|
||||
* App -> TurnRequest -> TurnResult. No Tauri command or persisted branch is implied.
|
||||
*/
|
||||
export async function submitDemoTurn(
|
||||
request: TurnRequest,
|
||||
currentView: PlayerView
|
||||
): Promise<TurnResult> {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, DEMO_LATENCY_MS));
|
||||
|
||||
const committedNodeId = nextNodeId(currentView.nodeId);
|
||||
const previousHistory = currentView.history.map((node) => ({ ...node, isCurrent: false }));
|
||||
|
||||
return {
|
||||
committedNodeId,
|
||||
playerView: {
|
||||
...currentView,
|
||||
nodeId: committedNodeId,
|
||||
beats: [...currentView.beats, ...replyBeats(request)].slice(-8),
|
||||
history: [
|
||||
...previousHistory,
|
||||
{
|
||||
id: committedNodeId,
|
||||
parentId: currentView.nodeId,
|
||||
branchId: currentView.branchId,
|
||||
label: request.intent === "continue" ? "雨声中的停顿" : "回应娜娜",
|
||||
isCurrent: true
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
+42
-6
@@ -1,8 +1,9 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import type { AppInfo, DemoPackSummary, PlayerView } from "@contracts";
|
||||
import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts";
|
||||
|
||||
import { getAppInfo, getDemoPackSummary, getDemoPlayerView } from "./bridge";
|
||||
import { submitDemoTurn } from "./turnAdapter";
|
||||
|
||||
export function useDemo() {
|
||||
const appInfo = ref<AppInfo | null>(null);
|
||||
@@ -10,8 +11,9 @@ export function useDemo() {
|
||||
const playerView = ref<PlayerView | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const latestBeat = computed(() => playerView.value?.beats.at(-1) ?? null);
|
||||
const busy = ref(false);
|
||||
const turnError = ref<string | null>(null);
|
||||
const lastSubmittedIntent = ref<TurnIntent | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -27,12 +29,46 @@ export function useDemo() {
|
||||
}
|
||||
});
|
||||
|
||||
async function submitTurn(intent: "speak_or_act", input: string): Promise<void>;
|
||||
async function submitTurn(intent: "continue", input?: ""): Promise<void>;
|
||||
async function submitTurn(intent: "speak_or_act" | "continue", input = ""): Promise<void> {
|
||||
const currentView = playerView.value;
|
||||
const normalizedInput = input.trim();
|
||||
|
||||
if (!currentView || busy.value || (intent === "speak_or_act" && !normalizedInput)) return;
|
||||
|
||||
const request: TurnRequest = {
|
||||
storyId: currentView.storyId,
|
||||
branchId: currentView.branchId,
|
||||
expectedNodeId: currentView.nodeId,
|
||||
actionId: `demo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
intent,
|
||||
input: intent === "continue" ? "" : normalizedInput
|
||||
};
|
||||
|
||||
busy.value = true;
|
||||
turnError.value = null;
|
||||
lastSubmittedIntent.value = intent;
|
||||
|
||||
try {
|
||||
const result = await submitDemoTurn(request, currentView);
|
||||
playerView.value = result.playerView;
|
||||
} catch (reason) {
|
||||
turnError.value = reason instanceof Error ? reason.message : String(reason);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appInfo,
|
||||
busy,
|
||||
error,
|
||||
latestBeat,
|
||||
lastSubmittedIntent,
|
||||
loading,
|
||||
pack,
|
||||
playerView
|
||||
playerView,
|
||||
submitTurn,
|
||||
turnError
|
||||
};
|
||||
}
|
||||
|
||||
+339
-18
@@ -96,6 +96,18 @@ button {
|
||||
color: rgb(245 241 234 / 72%);
|
||||
}
|
||||
|
||||
.top-actions button[aria-expanded="true"] {
|
||||
color: #f1c7b5;
|
||||
background: rgb(231 184 164 / 12%);
|
||||
border-color: rgb(231 184 164 / 42%);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid #f0c1ae;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
height: calc(100% - 58px);
|
||||
@@ -254,8 +266,9 @@ button {
|
||||
bottom: 116px;
|
||||
left: 7%;
|
||||
z-index: 10;
|
||||
min-height: 126px;
|
||||
padding: 20px 26px;
|
||||
height: 154px;
|
||||
padding: 17px 24px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(100deg, rgb(13 17 27 / 94%), rgb(24 26 34 / 84%));
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 14px;
|
||||
@@ -263,30 +276,54 @@ button {
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.speaker-row {
|
||||
.beat-list {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0 8px 0 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
scrollbar-color: rgb(231 184 164 / 30%) transparent;
|
||||
}
|
||||
|
||||
.speaker-row strong {
|
||||
.beat {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.beat strong {
|
||||
color: #f0c4b2;
|
||||
font-family: "Noto Serif SC", serif;
|
||||
font-size: 19px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speaker-row span {
|
||||
color: rgb(241 237 231 / 40%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dialogue-panel p {
|
||||
max-width: 72ch;
|
||||
margin: 14px 0 0;
|
||||
color: rgb(250 247 242 / 90%);
|
||||
.beat p {
|
||||
margin: 0;
|
||||
color: rgb(250 247 242 / 88%);
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.75;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.beat--narration,
|
||||
.beat--system {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.beat--narration p,
|
||||
.beat--system p {
|
||||
color: rgb(245 241 234 / 58%);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.beat--action strong,
|
||||
.beat--action p {
|
||||
color: rgb(211 224 231 / 70%);
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
@@ -327,6 +364,11 @@ button {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.composer textarea:disabled,
|
||||
.composer button:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
min-width: 72px;
|
||||
cursor: pointer;
|
||||
@@ -350,6 +392,17 @@ button {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.turn-status {
|
||||
position: absolute;
|
||||
right: 18%;
|
||||
bottom: 18px;
|
||||
z-index: 14;
|
||||
margin: 0;
|
||||
color: rgb(241 196 178 / 72%);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.statusline {
|
||||
position: absolute;
|
||||
right: 8%;
|
||||
@@ -375,3 +428,271 @@ button {
|
||||
.state-screen--error {
|
||||
color: #f1b5ad;
|
||||
}
|
||||
|
||||
.panel-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgb(4 6 11 / 46%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: flex;
|
||||
width: min(480px, 48vw);
|
||||
min-width: 400px;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
color: rgb(247 243 237 / 88%);
|
||||
background:
|
||||
linear-gradient(160deg, rgb(31 35 46 / 98%), rgb(14 17 26 / 98%)),
|
||||
#151923;
|
||||
border-left: 1px solid rgb(255 255 255 / 12%);
|
||||
box-shadow: -28px 0 70px rgb(0 0 0 / 42%);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 26px 28px 19px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 9%);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 5px 0 0;
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
font-size: 23px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.panel-eyebrow {
|
||||
color: rgb(239 196 177 / 65%);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
color: rgb(245 241 234 / 62%);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 1px solid rgb(255 255 255 / 11%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
padding: 24px 28px 34px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-content section + section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.panel-content h3 {
|
||||
margin: 0 0 12px;
|
||||
color: rgb(245 241 234 / 56%);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.card-list,
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.card-list li {
|
||||
padding: 15px 16px;
|
||||
background: rgb(255 255 255 / 4%);
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-title strong {
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-title span,
|
||||
.card-list small {
|
||||
color: rgb(240 193 173 / 66%);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.card-list p {
|
||||
margin: 8px 0;
|
||||
color: rgb(245 241 234 / 62%);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.empty-copy,
|
||||
.panel-intro,
|
||||
.panel-note {
|
||||
color: rgb(245 241 234 / 48%);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.empty-copy {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
background: rgb(255 255 255 / 3%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.panel-intro {
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.relationship-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.relationship-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
background: rgb(255 255 255 / 4%);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.relationship-row dt {
|
||||
color: rgb(245 241 234 / 56%);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.relationship-row dd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
color: #efc3b0;
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
}
|
||||
|
||||
.relationship-row dd::before {
|
||||
width: 52px;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: linear-gradient(90deg, rgb(239 195 176 / 18%), rgb(239 195 176 / 75%));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.history-list::before {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
left: 12px;
|
||||
width: 1px;
|
||||
content: "";
|
||||
background: rgb(231 184 164 / 28%);
|
||||
}
|
||||
|
||||
.history-list li {
|
||||
position: relative;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.history-list li::before {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
content: "";
|
||||
background: #252a36;
|
||||
border: 2px solid rgb(231 184 164 / 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.history-list button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 15px;
|
||||
cursor: pointer;
|
||||
color: rgb(245 241 234 / 72%);
|
||||
text-align: left;
|
||||
background: rgb(255 255 255 / 3%);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.history-list button.selected {
|
||||
color: #f2c8b6;
|
||||
background: rgb(231 184 164 / 9%);
|
||||
border-color: rgb(231 184 164 / 35%);
|
||||
}
|
||||
|
||||
.history-list small {
|
||||
color: rgb(245 241 234 / 38%);
|
||||
}
|
||||
|
||||
.rewind-placeholder {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin-top: 22px;
|
||||
padding: 16px;
|
||||
background: rgb(7 9 15 / 36%);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.rewind-placeholder span {
|
||||
color: rgb(245 241 234 / 55%);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rewind-placeholder button {
|
||||
padding: 11px 14px;
|
||||
color: rgb(26 20 20 / 64%);
|
||||
background: rgb(231 184 164 / 54%);
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.rewind-placeholder small {
|
||||
color: rgb(245 241 234 / 34%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user