chore: bootstrap nana-story M0
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import App from "./App.vue";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the canonical demo PlayerView", async () => {
|
||||
const wrapper = mount(App);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain("听娜娜讲故事");
|
||||
expect(wrapper.text()).toContain("娜娜");
|
||||
expect(wrapper.text()).toContain("你真的会在天亮前回来吗?");
|
||||
expect(wrapper.get('textarea[aria-label="自由输入"]')).toBeDefined();
|
||||
});
|
||||
|
||||
it("copies an editable suggestion into the composer", async () => {
|
||||
const wrapper = mount(App);
|
||||
await flushPromises();
|
||||
await wrapper.get(".suggestions button").trigger("click");
|
||||
|
||||
const composer = wrapper.get<HTMLTextAreaElement>("textarea");
|
||||
expect(composer.element.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { useDemo } from "./app/useDemo";
|
||||
|
||||
const { appInfo, error, latestBeat, loading, pack, playerView } = useDemo();
|
||||
const draft = ref("");
|
||||
|
||||
const sceneLabel = computed(() => {
|
||||
if (!playerView.value) return "载入场景";
|
||||
return `${playerView.value.sceneTitle} · ${pack.value?.displayName ?? ""}`;
|
||||
});
|
||||
|
||||
function useSuggestion(suggestion: { draft: string }) {
|
||||
draft.value = suggestion.draft;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">∞</span>
|
||||
<span>{{ appInfo?.name ?? "听娜娜讲故事" }}</span>
|
||||
</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>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section v-if="loading" class="state-screen">正在载入故事……</section>
|
||||
<section v-else-if="error" class="state-screen state-screen--error">
|
||||
<strong>内容载入失败</strong>
|
||||
<span>{{ error }}</span>
|
||||
</section>
|
||||
|
||||
<section v-else-if="playerView" class="stage">
|
||||
<div class="stage-sky" />
|
||||
<div class="station-sign">旧青川站</div>
|
||||
<div class="character" aria-label="娜娜">
|
||||
<div class="character-hair" />
|
||||
<div class="character-face">
|
||||
<i class="eye eye--left" />
|
||||
<i class="eye eye--right" />
|
||||
<i class="mouth" />
|
||||
</div>
|
||||
<div class="character-coat" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="suggestions" aria-label="行动建议">
|
||||
<button
|
||||
v-for="suggestion in playerView.suggestions"
|
||||
:key="suggestion.id"
|
||||
type="button"
|
||||
@click="useSuggestion(suggestion)"
|
||||
>
|
||||
{{ suggestion.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="composer" @submit.prevent>
|
||||
<textarea
|
||||
v-model="draft"
|
||||
aria-label="自由输入"
|
||||
placeholder="说些什么,或者描述你的行动……"
|
||||
rows="2"
|
||||
/>
|
||||
<button class="continue-button" type="button">继续</button>
|
||||
<button class="send-button" type="submit" :disabled="draft.trim().length === 0">
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<footer class="statusline">
|
||||
<span>{{ playerView.nodeId }}</span>
|
||||
<span>{{ playerView.branchId }}</span>
|
||||
<span>关系与判定已隐藏</span>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import initialView from "../../fixtures/player-view/initial.json";
|
||||
import type { AppInfo, DemoPackSummary, PlayerView } from "@contracts";
|
||||
|
||||
function isTauri(): boolean {
|
||||
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||
}
|
||||
|
||||
export async function getAppInfo(): Promise<AppInfo> {
|
||||
if (!isTauri()) {
|
||||
return {
|
||||
name: "听娜娜讲故事",
|
||||
version: "0.1.0",
|
||||
contractVersion: 1
|
||||
};
|
||||
}
|
||||
return invoke<AppInfo>("get_app_info");
|
||||
}
|
||||
|
||||
export async function getDemoPlayerView(): Promise<PlayerView> {
|
||||
if (!isTauri()) {
|
||||
return initialView as PlayerView;
|
||||
}
|
||||
return invoke<PlayerView>("get_demo_player_view");
|
||||
}
|
||||
|
||||
export async function getDemoPackSummary(): Promise<DemoPackSummary> {
|
||||
if (!isTauri()) {
|
||||
return {
|
||||
id: "nana.demo.station",
|
||||
displayName: "天亮之前",
|
||||
revision: "0.1.0",
|
||||
characters: 1,
|
||||
worldBooks: 1,
|
||||
personas: 1,
|
||||
plotModules: 1,
|
||||
itemSpecs: 1
|
||||
};
|
||||
}
|
||||
return invoke<DemoPackSummary>("get_demo_pack_summary");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import initialView from "../../fixtures/player-view/initial.json";
|
||||
|
||||
describe("PlayerView security boundary", () => {
|
||||
it("does not expose hidden runtime fields", () => {
|
||||
const serialized = JSON.stringify(initialView);
|
||||
|
||||
for (const forbidden of [
|
||||
'"roll"',
|
||||
'"target"',
|
||||
'"hidden_facts"',
|
||||
'"world_flags"',
|
||||
'"checks"',
|
||||
'"relationship_states"'
|
||||
]) {
|
||||
expect(serialized).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows only qualitative relationship bands", () => {
|
||||
for (const [key, value] of Object.entries(initialView.relationship)) {
|
||||
if (key === "updatedAtNode") continue;
|
||||
expect(typeof value).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import type { AppInfo, DemoPackSummary, PlayerView } from "@contracts";
|
||||
|
||||
import { getAppInfo, getDemoPackSummary, getDemoPlayerView } from "./bridge";
|
||||
|
||||
export function useDemo() {
|
||||
const appInfo = ref<AppInfo | null>(null);
|
||||
const pack = ref<DemoPackSummary | null>(null);
|
||||
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);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
[appInfo.value, pack.value, playerView.value] = await Promise.all([
|
||||
getAppInfo(),
|
||||
getDemoPackSummary(),
|
||||
getDemoPlayerView()
|
||||
]);
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : String(reason);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
appInfo,
|
||||
error,
|
||||
latestBeat,
|
||||
loading,
|
||||
pack,
|
||||
playerView
|
||||
};
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import "./styles/main.css";
|
||||
|
||||
createApp(App).use(createPinia()).mount("#app");
|
||||
@@ -0,0 +1,377 @@
|
||||
:root {
|
||||
color: #f3f2ef;
|
||||
background: #0b0e16;
|
||||
font-family:
|
||||
Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
min-width: 960px;
|
||||
height: 100%;
|
||||
min-height: 640px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #111522;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
height: 58px;
|
||||
padding: 0 22px;
|
||||
color: rgb(245 241 234 / 88%);
|
||||
background: rgb(11 14 22 / 86%);
|
||||
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
color: #e5b7a5;
|
||||
border: 1px solid rgb(229 183 165 / 48%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.scene-label {
|
||||
color: rgb(244 239 232 / 62%);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.top-actions button,
|
||||
.suggestions button {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.top-actions button {
|
||||
padding: 7px 13px;
|
||||
color: rgb(245 241 234 / 72%);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
height: calc(100% - 58px);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(12 17 31 / 4%), rgb(8 11 19 / 88%)),
|
||||
linear-gradient(115deg, #18283c 0%, #26364b 46%, #111722 100%);
|
||||
}
|
||||
|
||||
.stage::before,
|
||||
.stage::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stage::before {
|
||||
inset: 0;
|
||||
opacity: 0.35;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
104deg,
|
||||
transparent 0 28px,
|
||||
rgb(210 230 244 / 16%) 29px,
|
||||
transparent 31px 52px
|
||||
);
|
||||
transform: translateY(-15%);
|
||||
}
|
||||
|
||||
.stage::after {
|
||||
right: -10%;
|
||||
bottom: 31%;
|
||||
width: 64%;
|
||||
height: 28%;
|
||||
background: linear-gradient(90deg, transparent, rgb(233 172 124 / 20%));
|
||||
filter: blur(40px);
|
||||
}
|
||||
|
||||
.stage-sky {
|
||||
position: absolute;
|
||||
inset: 0 0 44%;
|
||||
background:
|
||||
radial-gradient(circle at 68% 30%, rgb(247 194 145 / 18%), transparent 24%),
|
||||
linear-gradient(180deg, #15243b, transparent);
|
||||
}
|
||||
|
||||
.station-sign {
|
||||
position: absolute;
|
||||
top: 16%;
|
||||
left: 10%;
|
||||
padding: 14px 24px;
|
||||
color: rgb(229 220 204 / 65%);
|
||||
font-family: "Noto Serif SC", serif;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.35em;
|
||||
background: rgb(26 31 40 / 70%);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
|
||||
.character {
|
||||
position: absolute;
|
||||
right: 16%;
|
||||
bottom: 22%;
|
||||
width: 280px;
|
||||
height: 510px;
|
||||
filter: drop-shadow(0 34px 36px rgb(0 0 0 / 40%));
|
||||
}
|
||||
|
||||
.character-hair,
|
||||
.character-face,
|
||||
.character-coat {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.character-hair {
|
||||
top: 20px;
|
||||
width: 204px;
|
||||
height: 250px;
|
||||
background: #28242d;
|
||||
border-radius: 52% 48% 44% 48% / 36% 40% 60% 64%;
|
||||
box-shadow:
|
||||
-42px 80px 0 -25px #28242d,
|
||||
48px 90px 0 -28px #211f27;
|
||||
}
|
||||
|
||||
.character-face {
|
||||
top: 62px;
|
||||
z-index: 2;
|
||||
width: 142px;
|
||||
height: 176px;
|
||||
background: linear-gradient(145deg, #f4d5c4, #dfb39f);
|
||||
border-radius: 48% 48% 46% 46% / 40% 42% 58% 60%;
|
||||
}
|
||||
|
||||
.eye,
|
||||
.mouth {
|
||||
position: absolute;
|
||||
display: block;
|
||||
background: #4c3838;
|
||||
}
|
||||
|
||||
.eye {
|
||||
top: 82px;
|
||||
width: 18px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.eye--left {
|
||||
left: 31px;
|
||||
}
|
||||
|
||||
.eye--right {
|
||||
right: 31px;
|
||||
}
|
||||
|
||||
.mouth {
|
||||
bottom: 35px;
|
||||
left: 50%;
|
||||
width: 24px;
|
||||
height: 7px;
|
||||
border-radius: 0 0 50% 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.character-coat {
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
width: 260px;
|
||||
height: 320px;
|
||||
background: linear-gradient(115deg, #c9baa6, #817a72);
|
||||
border-radius: 46% 46% 7% 7% / 20% 20% 5% 5%;
|
||||
clip-path: polygon(20% 0, 80% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
|
||||
.chapter-chip {
|
||||
position: absolute;
|
||||
top: 7%;
|
||||
left: 50%;
|
||||
padding: 6px 14px;
|
||||
color: rgb(245 239 231 / 62%);
|
||||
font-family: "Noto Serif SC", serif;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 999px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.dialogue-panel {
|
||||
position: absolute;
|
||||
right: 7%;
|
||||
bottom: 116px;
|
||||
left: 7%;
|
||||
z-index: 10;
|
||||
min-height: 126px;
|
||||
padding: 20px 26px;
|
||||
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;
|
||||
box-shadow: 0 22px 56px rgb(0 0 0 / 38%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.speaker-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.speaker-row strong {
|
||||
color: #f0c4b2;
|
||||
font-family: "Noto Serif SC", serif;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.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%);
|
||||
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
right: 8%;
|
||||
bottom: 255px;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.suggestions button {
|
||||
padding: 8px 13px;
|
||||
color: rgb(245 241 234 / 72%);
|
||||
background: rgb(15 19 29 / 64%);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.composer {
|
||||
position: absolute;
|
||||
right: 7%;
|
||||
bottom: 42px;
|
||||
left: 7%;
|
||||
z-index: 12;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
min-height: 58px;
|
||||
padding: 13px 16px;
|
||||
resize: none;
|
||||
color: rgb(250 247 242 / 92%);
|
||||
outline: none;
|
||||
background: rgb(12 15 24 / 86%);
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
min-width: 72px;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.continue-button {
|
||||
color: rgb(245 241 234 / 75%);
|
||||
background: rgb(24 28 39 / 88%);
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
color: #201918;
|
||||
background: #e7b8a4;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.statusline {
|
||||
position: absolute;
|
||||
right: 8%;
|
||||
bottom: 15px;
|
||||
left: 8%;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: rgb(242 238 231 / 28%);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.state-screen {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: calc(100% - 58px);
|
||||
place-content: center;
|
||||
gap: 8px;
|
||||
color: rgb(245 241 234 / 70%);
|
||||
}
|
||||
|
||||
.state-screen--error {
|
||||
color: #f1b5ad;
|
||||
}
|
||||
Reference in New Issue
Block a user