Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- 特勤经验表 [Widget:SOExpCalc/dev](https://prts.wiki/w/Widget:SOExpCalc/dev)
- AudioPlayerV2 [Widget:AudioPlayerV2/dev](https://prts.wiki/w/Widget:AudioPlayerV2/dev)
- 道具一览 [Widget:ItemList/dev](https://prts.wiki/w/Widget:ItemList/dev)
- 剧情播放器 [Widget:StoryPlayer/dev](https://prts.wiki/w/Widget:StoryPlayer/dev)

## 贡献代码

Expand Down
4 changes: 2 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ const javascript = [
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-void": "error",
"no-void": ["error", { allowAsStatement: true }],
"no-with": "error",
"object-shorthand": [
"error",
Expand All @@ -139,7 +139,7 @@ const javascript = [
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"require-await": "error",
"require-await": "off",
"require-yield": "error",
"unicode-bom": ["error", "never"],
"use-isnan": [
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "vitest run",
"test:watch": "vitest",
"prepare": "simple-git-hooks"
},
"dependencies": {
Expand All @@ -29,6 +31,8 @@
"lodash-es": "^4.18.1",
"md5": "^2.3.0",
"naive-ui": "^2.44.1",
"@pixi/sound": "^6.0.1",
"pixi.js": "8.16.0",
"vue": "^3.5.39",
"vue-draggable-plus": "^0.6.1",
"workbox-routing": "^7.4.1",
Expand Down Expand Up @@ -59,6 +63,7 @@
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.7.0",
"happy-dom": "^18.0.1",
"prettier": "^3.9.4",
"rollup-plugin-visualizer": "^7.0.1",
"sass-embedded": "^1.100.0",
Expand All @@ -69,6 +74,7 @@
"typescript-eslint": "^8.62.1",
"unocss": "^66.7.4",
"vite": "^8.1.0",
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.5"
},
Expand Down
352 changes: 352 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions src/entries/StoryPlayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createApp } from "vue";

import "virtual:uno.css";
import StoryPlayer from "../widgets/StoryPlayer/index.vue";

const ele = document.querySelector<HTMLElement>("#root");
if (ele?.dataset?.path) {
createApp(StoryPlayer, { path: ele.dataset.path }).mount(ele);
} else {
console.error("data-path or ele not found", ele);
}
11 changes: 11 additions & 0 deletions src/entries/sentry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as Sentry from "@sentry/browser";

const feedback = Sentry.feedbackIntegration({
autoInject: false,
colorScheme: "system",
});

Sentry.init({
dsn: location.host.includes("prts")
? "https://73af36ee35564fe4946285b451a8405a@ingest.sentry.mooncell.wiki/4507366072188928"
Expand All @@ -12,6 +17,7 @@ Sentry.init({
Sentry.browserTracingIntegration(),
Sentry.httpClientIntegration(),
Sentry.contextLinesIntegration(),
feedback,
],

sampleRate: 0.01,
Expand Down Expand Up @@ -69,6 +75,11 @@ Sentry.init({
window.Sentry = {
showReportDialog: Sentry.showReportDialog,
captureException: Sentry.captureException,
showFeedback: async (tags) => {
const form = await feedback.createForm({ tags });
form.appendToDom();
form.open();
},
};

(window.RLQ = window.RLQ || []).push([
Expand Down
8 changes: 7 additions & 1 deletion src/mw.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
declare interface Window {
RLQ?: any[];
Sentry: Record<string, any>;
Sentry?: {
captureException?: (error: unknown) => string;
showFeedback?: (
tags?: Record<string, boolean | number | string>,
) => Promise<void>;
showReportDialog?: (...args: any[]) => void;
};
mw: any;
}
34 changes: 34 additions & 0 deletions src/widgets/StoryPlayer/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 本地静态贴图资源。
//
// 这些是 APK 内置的 UI 贴片(对话框黑条渐变、地区邮戳动画零件)。源仓库
// (arknights-story-player) 把它们放在 public/ui/ 下由 Vite 直接服务到 /ui/;
// 本仓库产物部署到 OSS(base = https://static.prts.wiki/widgets/production),
// 没有 public/ 直出机制,且引擎里写死的 /ui/ 绝对路径在 wiki 页面里会解析
// 错,必须随 widget 打包。
//
// 关键点:必须用 `new URL('./x.png', import.meta.url)` 而非 `import x from
// './x.png'`。本 widget 在 prts.wiki 页面里跨源加载(页面在 prts.wiki,脚本
// 在 dev server / OSS),普通 import 在 dev 下被解析成绝对路径
// `/src/.../*.png`,浏览器按「页面源」解析成 https://prts.wiki/src/... → 404。
// `import.meta.url` 以「当前模块」为基准,dev 下自然指向 dev server;build 下
// Vite 会把它替换成带 hash 的 OSS 资产 URL,小图还会内联成 base64 data URI。
//
// 注意:`new URL` 必须用字符串字面量作为第一参数直接内联,Vite 才能静态分析
// 出资产依赖并打包;不能用函数包装或变量拼接。

/** 对话框顶部/底部黑条渐变纹理 */
export const DIALOG_FRAME_URL = new URL(
"assets/ui/sprite_avg_cutscene.png",
import.meta.url,
).href;

/** 地区邮戳(animtext group_location_stamp)各零件贴图 */
export const STAMP_ASSETS = {
back_gradient: new URL("assets/ui/back_gradient.png", import.meta.url).href,
back_shadow: new URL("assets/ui/back_shadow.png", import.meta.url).href,
frame_inner: new URL("assets/ui/frame_inner.png", import.meta.url).href,
frame_outer: new URL("assets/ui/frame_outer.png", import.meta.url).href,
icon_back: new URL("assets/ui/icon_back.png", import.meta.url).href,
icon_comps: new URL("assets/ui/icon_comps.png", import.meta.url).href,
icon_start: new URL("assets/ui/icon_start.png", import.meta.url).href,
} as const;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/widgets/StoryPlayer/assets/ui/back_shadow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/widgets/StoryPlayer/assets/ui/frame_inner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/widgets/StoryPlayer/assets/ui/icon_back.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/widgets/StoryPlayer/assets/ui/icon_comps.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/widgets/StoryPlayer/assets/ui/icon_start.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
240 changes: 240 additions & 0 deletions src/widgets/StoryPlayer/components/LogAllList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { NText } from "naive-ui";

import type { LogAllDecisionRoute, LogAllEntry } from "../engine/logAll";

// 纯递归列表(无外壳):渲染 LogAllEntry 树。
// 外层全屏壳由 LogAllPanel 负责,避免递归实例各自渲染一层全屏遮罩。
const props = defineProps<{
entries: LogAllEntry[];
/** 当前屏幕正在显示的源行 lineNumber;命中时高亮并供外层滚动定位 */
activeLineIndex?: number | null;
/** 最近一次 decision 玩家所选 value;用于只高亮当前路径上的分支副本 */
decisionSelectValue?: number;
}>();

interface RouteGroup {
labels: string[];
values: number[];
entries: LogAllEntry[];
}

function hasSameEntries(left: LogAllEntry[], right: LogAllEntry[]): boolean {
return (
left.length === right.length &&
left.every((entry, index) => entry === right[index])
);
}

/**
* predicate(references="1;2") 等共享路径在 routes 中会分别投影给选项 1 和 2。
* 展示时把完整结果相同的路径重新合并,任何一处内容不同则保持独立。
*/
function groupRoutes(routes: LogAllDecisionRoute[]): RouteGroup[] {
const groups: RouteGroup[] = [];
for (const route of routes) {
const group = groups.find((candidate) =>
hasSameEntries(candidate.entries, route.entries),
);
if (group) {
group.labels.push(route.option.label);
group.values.push(route.option.value);
} else {
groups.push({
entries: route.entries,
labels: [route.option.label],
values: [route.option.value],
});
}
}
return groups;
}

function hasRouteContent(routes: LogAllDecisionRoute[]): boolean {
return routes.some((route) => route.entries.length > 0);
}

/** 某条 line entry 是否对应当前屏幕正在显示的源行 */
function isActiveLine(entry: LogAllEntry & { kind: "line" }): boolean {
return (
props.activeLineIndex != null && entry.lineIndex === props.activeLineIndex
);
}

/**
* 把 activeLineIndex 透传给子列表时,需要先按 decision 选择过滤:
* - decision 的 shared(选择前共同剧情)始终透传,因为无条件执行;
* - 某条 route 仅当它覆盖的选项包含玩家所选值时才透传,避免分支副本误高亮。
*/
function routeActiveLineIndex(group: RouteGroup): number | null {
if (
props.decisionSelectValue &&
group.values.includes(props.decisionSelectValue)
)
return props.activeLineIndex ?? null;
return null;
}
</script>

<template>
<ul v-if="entries.length > 0" class="m-0 list-none p-0 space-y-2.5">
<li v-for="(entry, i) in entries" :key="i" class="p-0">
<!-- 文本条目:对白 / 旁白 / sticker / subtitle / multiline -->
<div
v-if="entry.kind === 'line'"
:data-active-line="isActiveLine(entry) ? '' : undefined"
class="flex items-start leading-relaxed"
>
<NText
tag="span"
type="primary"
class="inline-block w-5 shrink-0 text-center"
:aria-label="isActiveLine(entry) ? '当前播放位置' : undefined"
:aria-hidden="isActiveLine(entry) ? undefined : true"
>
{{ isActiveLine(entry) ? "▶" : "" }}
</NText>
<NText
v-if="entry.speaker"
type="primary"
tag="strong"
class="mr-3 w-24 shrink-0 text-right"
>
{{ entry.speaker }}
</NText>
<span v-else class="mr-3 w-24 shrink-0" aria-hidden="true" />
<span class="min-w-0 flex-1">
<span
v-for="(span, si) in entry.spans"
:key="si"
:style="span.color ? { color: span.color } : undefined"
:class="
!span.color && entry.source === 'narration'
? 'opacity-75'
: undefined
"
>{{ span.text }}</span
>
</span>
</div>

<!-- 所有选项都立即汇合时,压成静态记录,不制造空折叠层。 -->
<div
v-else-if="!hasRouteContent(entry.routes)"
class="border-l-2 py-1 pl-3"
>
<div class="text-sm">
<NText type="primary" tag="strong" class="mr-2">剧情选择</NText>
<span
>「{{
entry.options.map((option) => option.label).join(" / ")
}}」</span
>
<NText depth="3" class="ml-2 text-xs">选择不影响后续剧情</NText>
</div>
<div v-if="entry.shared.length > 0" class="mt-2 pl-3">
<LogAllList
:entries="entry.shared"
:active-line-index="activeLineIndex"
:decision-select-value="decisionSelectValue"
/>
</div>
</div>

<!-- 有实际分叉时,折叠树保留嵌套选择的路径关系。 -->
<details v-else open class="group/decision border-l-2 pl-3">
<summary
class="cursor-pointer list-none py-1 text-sm font-bold marker:hidden"
>
<span
class="mr-2 inline-block opacity-60 transition-transform group-open/decision:rotate-90"
>▶</span
>
剧情选择
<NText depth="3" class="ml-1 text-xs font-normal">
({{ entry.options.length }} 项)
</NText>
</summary>

<div class="pb-1 pl-4 pt-2">
<div v-if="entry.shared.length > 0" class="mb-3 border-l pl-3">
<div
class="mb-1.5 text-[11px] font-bold tracking-[0.08em] opacity-60"
>
选择前的共同剧情
</div>
<LogAllList
:entries="entry.shared"
:active-line-index="activeLineIndex"
:decision-select-value="decisionSelectValue"
/>
</div>

<div class="space-y-2">
<template
v-for="(route, routeIndex) in groupRoutes(entry.routes)"
:key="routeIndex"
>
<details
v-if="route.entries.length > 0"
class="group/route border-l pl-3"
>
<summary
class="cursor-pointer list-none py-1 text-sm font-semibold marker:hidden"
>
<span
class="mr-2 inline-block opacity-55 transition-transform group-open/route:rotate-90"
>▶</span
>
选择「{{ route.labels.join(" / ") }}」
<span
v-if="route.labels.length > 1"
class="ml-1 text-xs font-normal opacity-60"
>
(相同去向)
</span>
</summary>
<div class="pb-2 pl-5 pt-1.5">
<LogAllList
:entries="route.entries"
:active-line-index="routeActiveLineIndex(route)"
:decision-select-value="decisionSelectValue"
/>
</div>
</details>
<div v-else class="border-l py-1 pl-3 text-sm opacity-75">
<span class="mr-2 inline-block w-3 text-center opacity-60"
>—</span
>
<span class="font-semibold"
>选择「{{ route.labels.join(" / ") }}」</span
>
<span class="ml-2 text-xs">无专属文本,继续后续剧情</span>
<span
v-if="route.labels.length > 1"
class="ml-1 text-xs opacity-60"
>
(相同去向)
</span>
</div>
</template>
</div>
</div>
</details>
</li>
</ul>
<NText v-else tag="p" depth="3" class="m-0 text-sm tracking-[0.03em]">
没有可显示的对话文本。
</NText>
</template>

<style scoped>
ul,
li {
list-style: none !important;
}

li::marker {
content: none !important;
}
</style>
Loading