From 36192506ca480d4bc8233911232c3ce513719279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Sat, 1 Aug 2026 15:34:11 +0800 Subject: [PATCH 1/9] init --- ...ss_2758e499-d255-4b7c-bb2a-6ef5e27cdb64.md | 64 + README.md | 1 + eslint.config.js | 39 + package.json | 6 + pnpm-lock.yaml | 352 ++ src/entries/StoryPlayer.ts | 11 + src/widgets/StoryPlayer/assets.ts | 34 + .../StoryPlayer/assets/ui/back_gradient.png | Bin 0 -> 1718 bytes .../StoryPlayer/assets/ui/back_shadow.png | Bin 0 -> 9534 bytes .../StoryPlayer/assets/ui/frame_inner.png | Bin 0 -> 171 bytes .../StoryPlayer/assets/ui/frame_outer.png | Bin 0 -> 2051 bytes .../StoryPlayer/assets/ui/icon_back.png | Bin 0 -> 1893 bytes .../StoryPlayer/assets/ui/icon_comps.png | Bin 0 -> 472 bytes .../StoryPlayer/assets/ui/icon_start.png | Bin 0 -> 158 bytes .../assets/ui/sprite_avg_cutscene.png | Bin 0 -> 191 bytes .../StoryPlayer/components/LogAllList.vue | 231 ++ .../StoryPlayer/components/LogAllPanel.vue | 60 + src/widgets/StoryPlayer/context.ts | 210 + src/widgets/StoryPlayer/engine/asset.ts | 74 + src/widgets/StoryPlayer/engine/audio.ts | 327 ++ .../StoryPlayer/engine/commandRegistry.ts | 44 + .../StoryPlayer/engine/createStoryPlayer.ts | 107 + src/widgets/StoryPlayer/engine/execution.ts | 41 + src/widgets/StoryPlayer/engine/font.ts | 47 + src/widgets/StoryPlayer/engine/logAll.ts | 319 ++ src/widgets/StoryPlayer/engine/parser.ts | 254 ++ src/widgets/StoryPlayer/engine/preload.ts | 333 ++ src/widgets/StoryPlayer/engine/renderer.ts | 4 + .../engine/rendering/PixiStoryRenderer.ts | 3630 +++++++++++++++++ .../engine/rendering/core/LayerGraph.ts | 42 + .../engine/rendering/core/SceneGeometry.ts | 213 + .../engine/rendering/core/ShakePath.ts | 87 + .../engine/rendering/core/TweenRunner.ts | 41 + .../engine/rendering/panels/AnimTextPanel.ts | 262 ++ .../rendering/panels/AvgDisplayPanel.ts | 184 + .../engine/rendering/panels/CgItemPanel.ts | 240 ++ .../engine/rendering/panels/DecisionPanel.ts | 82 + .../engine/rendering/panels/DialogPanel.ts | 190 + .../rendering/panels/FocusEffectPanel.ts | 105 + .../engine/rendering/panels/InterludePanel.ts | 217 + .../rendering/panels/SpellStickerPanel.ts | 119 + .../engine/rendering/panels/VideoPanel.ts | 104 + src/widgets/StoryPlayer/engine/richtext.ts | 75 + src/widgets/StoryPlayer/engine/runtime.ts | 2687 ++++++++++++ src/widgets/StoryPlayer/engine/showitem.ts | 33 + src/widgets/StoryPlayer/engine/types.ts | 595 +++ src/widgets/StoryPlayer/index.vue | 365 ++ templates/StoryPlayer.html | 1 + tests/asset.spec.ts | 49 + tests/cgItemPanel.spec.ts | 77 + tests/execution.spec.ts | 37 + tests/interlude.spec.ts | 83 + tests/logAll.spec.ts | 347 ++ tests/parser.spec.ts | 103 + tests/preload.spec.ts | 258 ++ tests/renderer.spec.ts | 524 +++ tests/rotate-tween.spec.ts | 34 + tests/runtime.spec.ts | 2688 ++++++++++++ tests/shake-path.spec.ts | 75 + tests/showitem.spec.ts | 17 + tests/spellStickerPanel.spec.ts | 63 + tsconfig.app.json | 7 +- tsconfig.engine.json | 33 + tsconfig.json | 1 + vite.config.ts | 1 + vitest.config.ts | 8 + 66 files changed, 16234 insertions(+), 1 deletion(-) create mode 100644 .zcode/plans/plan-sess_2758e499-d255-4b7c-bb2a-6ef5e27cdb64.md create mode 100644 src/entries/StoryPlayer.ts create mode 100644 src/widgets/StoryPlayer/assets.ts create mode 100644 src/widgets/StoryPlayer/assets/ui/back_gradient.png create mode 100644 src/widgets/StoryPlayer/assets/ui/back_shadow.png create mode 100644 src/widgets/StoryPlayer/assets/ui/frame_inner.png create mode 100644 src/widgets/StoryPlayer/assets/ui/frame_outer.png create mode 100644 src/widgets/StoryPlayer/assets/ui/icon_back.png create mode 100644 src/widgets/StoryPlayer/assets/ui/icon_comps.png create mode 100644 src/widgets/StoryPlayer/assets/ui/icon_start.png create mode 100644 src/widgets/StoryPlayer/assets/ui/sprite_avg_cutscene.png create mode 100644 src/widgets/StoryPlayer/components/LogAllList.vue create mode 100644 src/widgets/StoryPlayer/components/LogAllPanel.vue create mode 100644 src/widgets/StoryPlayer/context.ts create mode 100644 src/widgets/StoryPlayer/engine/asset.ts create mode 100644 src/widgets/StoryPlayer/engine/audio.ts create mode 100644 src/widgets/StoryPlayer/engine/commandRegistry.ts create mode 100644 src/widgets/StoryPlayer/engine/createStoryPlayer.ts create mode 100644 src/widgets/StoryPlayer/engine/execution.ts create mode 100644 src/widgets/StoryPlayer/engine/font.ts create mode 100644 src/widgets/StoryPlayer/engine/logAll.ts create mode 100644 src/widgets/StoryPlayer/engine/parser.ts create mode 100644 src/widgets/StoryPlayer/engine/preload.ts create mode 100644 src/widgets/StoryPlayer/engine/renderer.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/core/LayerGraph.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/core/ShakePath.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts create mode 100644 src/widgets/StoryPlayer/engine/rendering/panels/VideoPanel.ts create mode 100644 src/widgets/StoryPlayer/engine/richtext.ts create mode 100644 src/widgets/StoryPlayer/engine/runtime.ts create mode 100644 src/widgets/StoryPlayer/engine/showitem.ts create mode 100644 src/widgets/StoryPlayer/engine/types.ts create mode 100644 src/widgets/StoryPlayer/index.vue create mode 100644 templates/StoryPlayer.html create mode 100644 tests/asset.spec.ts create mode 100644 tests/cgItemPanel.spec.ts create mode 100644 tests/execution.spec.ts create mode 100644 tests/interlude.spec.ts create mode 100644 tests/logAll.spec.ts create mode 100644 tests/parser.spec.ts create mode 100644 tests/preload.spec.ts create mode 100644 tests/renderer.spec.ts create mode 100644 tests/rotate-tween.spec.ts create mode 100644 tests/runtime.spec.ts create mode 100644 tests/shake-path.spec.ts create mode 100644 tests/showitem.spec.ts create mode 100644 tests/spellStickerPanel.spec.ts create mode 100644 tsconfig.engine.json create mode 100644 vitest.config.ts diff --git a/.zcode/plans/plan-sess_2758e499-d255-4b7c-bb2a-6ef5e27cdb64.md b/.zcode/plans/plan-sess_2758e499-d255-4b7c-bb2a-6ef5e27cdb64.md new file mode 100644 index 00000000..4abe63ba --- /dev/null +++ b/.zcode/plans/plan-sess_2758e499-d255-4b7c-bb2a-6ef5e27cdb64.md @@ -0,0 +1,64 @@ +## 目标 +把 `../arknights-story-player` 的实现迁入本仓库(prts-widgets),按本仓库约定改造成一个小部件: +- **只搬「引擎 + 播放器壳」**(不含原 App.vue 里的故事选择器/lobby 阶段)。播放器由调用方通过 DOM `data-*` 指定故事。 +- **去掉 Sentry**(wiki 已全局加载 Sentry,本仓库约定不重新 init)。 +- 引擎代码放 `src/widgets/StoryPlayer/engine/`,引擎外的 `context.ts` 放 `src/widgets/StoryPlayer/context.ts`(保留原相对路径结构 `engine/` ← `../context`,使引擎内部 import 无需改动)。 +- **原 App.vue 里手写的 Vue 组件**(按钮、``、模式分段、LOG ALL 弹窗壳)改用 **naive-ui** 组件。 - -## 架构结论(已核实) -- 引擎(~9.3k 行)是框架无关纯 TS,仅依赖 `pixi.js` + `@pixi/sound`;资源 URL 全是绝对地址(`torappu.prts.wiki` / jsdelivr),`font.ts` 字体预加载走 JS。无 vue/naive-ui/sentry 引用 → 可整目录照搬。 -- 本仓库用 **UnoCSS presetUno**(即 presetWind3)→ 完整 Tailwind 默认色板 + 任意值工具类(`bg-[linear-gradient(...)]`/`shadow-[...]`/`aspect-video` 等已在仓库中使用)。原 App.vue/LogAll 里的 Tailwind 布局类可直接复用。 -- 本仓库 prettier 约定:**双引号 / 分号 / `trailingComma:"all"`**(与源仓库 antfu 风格相反)→ 搬入后必须重新格式化。 - -## 实施步骤 - -### 1. 依赖与构建配置 -- `package.json` → `dependencies` 增加 `pixi.js ^8.16.0`、`@pixi/sound ^6.0.1`(与源仓库同版本,peer 兼容)。 -- `vite.config.ts` → `manualChunks` 在 `howler` 那行后插入 `if (id.includes("pixi")) return "pixi";`(须在 `node_modules` 兜底分支之前,子串匹配 `pixi.js` 与 `@pixi/sound`)。 - -### 2. 搬入引擎(`src/widgets/StoryPlayer/`) -- 新建 `src/widgets/StoryPlayer/context.ts`:照搬 `../arknights-story-player/src/context.ts`。 -- 新建 `src/widgets/StoryPlayer/engine/`:照搬 `../arknights-story-player/src/engine/` 整目录(含 `rendering/core|panels` 子目录与 `renderer.ts` 兼容重导出)。 -- 每个文件搬入后跑 prettier/eslint `--fix` 统一到双引号+分号,修剩余 lint。引擎内部相对 import(`'../context'`、`'./parser'`、`'../../execution'` 等)因目录结构一致保持有效,不改。 -- 不搬 `main.ts`、`style.css`(全局 body 背景在 widget 上下文里不适用,字体预加载已由 `engine/font.ts` 用 JS 处理)。 - -### 3. 播放器壳组件 `src/widgets/StoryPlayer/index.vue`(新建,替代原 App.vue) -拆掉 selector/lobby 阶段,保留并改造播放器部分。逻辑(player ref / preloadAssets / syncState / onAdvance / onKeydown / setAutoPlayMode / setAutoPlaySpeedLevel / openLogAll 等)基本照搬,UI 改造如下: -- 顶层用 `` + `useTheme()` 包裹,沿用 AudioPlayerV2/ISEvents 的暗色适配约定。 -- 16:9 播放容器(`aspect-video`、host div、`@click/@keydown`)保留(pixi 挂载点,非表单组件)。 -- 「跳过片段」按钮 → `NButton`(type/ghost + `NIcon` 可选)。 -- 预加载/错误遮罩文字保留(纯展示,非表单)。 -- 播放控制条: - - 播放模式(手动/自动/快速)→ `NButtonGroup` + `NButton`(`v-for`,选中态用 `:type`)。 - - 播放速度 → `NSelect`(选项动态 `1..3` 或 `1..4` 档,`:value` 绑定 `buttonSpeedLevel`/`quickSpeedLevel`)。 - - 「LOG ALL」按钮 → `NButton`。 -- 状态行 `state: xxx` 保留。 -- props:`storyTxt: string`(故事 txt 路径,由 entry 传入)。删除 `storyGroups/cascader/fetchStoryList/selectedPath/viewMode==='selector'|'lobby'` 等 selector 相关逻辑。 -- 调用流程:mount 时若 `storyTxt` 有值 → 调 `fetchStoryScriptByPath` + `loadContextByPath` + `preloadDialogFont` + `createStoryPlayer` + mount host + `preloadContextAssets`(合并原 `onLoadStory`+`onStartPlay`+`preloadAssets`)。 - -### 4. LOG ALL 面板(新建 `src/widgets/StoryPlayer/components/LogAllPanel.vue`、`LogAllList.vue`) -- `LogAllList.vue`:**照搬**,保留 UnoCSS 类(递归分支树,无对应 naive-ui 表单组件,仅展示文本)。 -- `LogAllPanel.vue`:**重写外壳**为 ``,内部滚动容器 + `LogAllList` 保留,标题栏「关闭」改 `NButton`。滚动定位逻辑(watch activeLineIndex → scrollIntoView)照搬。 - -### 5. 入口 `src/entries/StoryPlayer.ts`(新建) -按本仓库 entry 约定(参考 `AudioPlayerV2.ts`/`SpineViewer.ts`): -- `import "virtual:uno.css"` + `createApp`。 -- 查询挂载点(如 `#story-player-root`),读取 `data-story-txt`,`createApp(StoryPlayer, { storyTxt }).mount(el)`。 -- 不引入 Sentry。 - -### 6. 模板与文档 -- 新建 `templates/StoryPlayer.html`,仿照其他模板:`
...`。 -- `README.md` → 子应用列表追加「剧情播放器 [Widget:StoryPlayer/dev]」一行。 - -### 7. 验证 -- `pnpm install`(拉 pixi 依赖)。 -- `pnpm exec vue-tsc -b` 类型检查(引擎需通过本仓库更严格 tsconfig,必要时修类型)。 -- `pnpm exec eslint --fix src/widgets/StoryPlayer src/entries/StoryPlayer.ts`。 -- `pnpm build` 通过(确认 pixi chunk 生成、无打包错误)。 -- 报告每个验证步骤的真实结果;不跳过、失败照实说明。 - -## 不做的事 -- 不搬故事选择器/lobby UI 与 `NCascader`。 -- 不引入 `@sentry/vue`,不在 entry 里 init Sentry。 -- 不动其他既有 widget/entry。 -- 不改全局 body 样式(`style.css` 丢弃)。 \ No newline at end of file diff --git a/src/widgets/StoryPlayer/components/LogAllList.vue b/src/widgets/StoryPlayer/components/LogAllList.vue index feeef0b8..503573c8 100644 --- a/src/widgets/StoryPlayer/components/LogAllList.vue +++ b/src/widgets/StoryPlayer/components/LogAllList.vue @@ -1,4 +1,6 @@ diff --git a/tests/runtime.spec.ts b/tests/runtime.spec.ts index b2ec76bf..16485d3c 100644 --- a/tests/runtime.spec.ts +++ b/tests/runtime.spec.ts @@ -2367,10 +2367,12 @@ describe("StoryRuntime", () => { ]); expect(renderer.subtitleClearCalls).toEqual([]); expect(runtime.getState()).toBe("waiting_input"); + expect(runtime.getDisplayedLineIndex()).toBe(1); await runtime.advance(); expect(renderer.subtitleClearCalls).toEqual([150]); expect(runtime.getState()).toBe("waiting_input"); + expect(runtime.getDisplayedLineIndex()).toBe(3); }); it("maps sticker and timer sticker commands", async () => { From 600ddb8051943bb8daa7cf73b59129857b59403f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Sun, 2 Aug 2026 19:00:01 +0800 Subject: [PATCH 3/9] fix: skip --- src/widgets/StoryPlayer/engine/runtime.ts | 94 ++++++++++++++++++----- src/widgets/StoryPlayer/index.vue | 12 +++ 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/src/widgets/StoryPlayer/engine/runtime.ts b/src/widgets/StoryPlayer/engine/runtime.ts index fde41c8e..c22d9ea7 100644 --- a/src/widgets/StoryPlayer/engine/runtime.ts +++ b/src/widgets/StoryPlayer/engine/runtime.ts @@ -97,6 +97,7 @@ const builtinCommandNames = [ "delay", "video", "skipnode", + "skiptothis", "background", "backgroundtween", "gridbg", @@ -286,6 +287,12 @@ function preprocessSkipNodes( return labels; } +function preprocessSkipToIndex(lines: ReturnType): number { + return lines.findIndex( + (line) => line.kind === "command" && line.command === "skiptothis", + ); +} + export class StoryRuntime { private readonly audio: StoryAudio; private readonly defaultSpeed: AutoSpeed; @@ -305,11 +312,14 @@ export class StoryRuntime { private pendingWait: InterruptibleWait | null = null; private pendingWaitId = 0; private processing = false; + private processingCompletion: Promise | null = null; + private resolveProcessingCompletion: (() => void) | null = null; private readonly renderer: StoryRenderer; private readonly sleep: (ms: number) => Promise; private currentSkipMode: SkipMode = "skip"; private readonly skipNodeLabels: SkipNodeLabel[]; private skipNodeQueueIndex = 0; + private readonly skipToIndex: number; /** Mirrors AVGShowItemPanel's single `_slotInUse`, which decides hideitem's return value. */ private itemSlotInUse = false; private theaterMode = false; @@ -349,6 +359,7 @@ export class StoryRuntime { this.audio = audio; this.sleep = options.sleep ?? sleepWithTimeout; this.skipNodeLabels = preprocessSkipNodes(this.lines); + this.skipToIndex = preprocessSkipToIndex(this.lines); this.onWarning = options.onWarning; for (const command of builtinCommandNames) this.commandRegistry.register(command, (line) => @@ -416,12 +427,12 @@ export class StoryRuntime { this.state !== "idle" && this.state !== "error" && this.state !== "finished"; - const tutorial = this.context.storyMetadata?.isTutorial ?? false; - return ( - running && - !tutorial && - (this.currentSkipMode !== "nofirstskip" || !this.firstRead) - ); + const hasSkipAnchor = + this.skipToIndex >= 0 || this.skipNodeLabels.length > 0; + // This widget exposes a segment-skip button rather than native's whole + // story StopStory action. Keep it disabled when the script has no remaining + // SkipToThis/skipnode destination at all. + return running && hasSkipAnchor; } async start(): Promise { @@ -479,25 +490,58 @@ export class StoryRuntime { async skipNode(): Promise { if (this.destroyed || !this.canSkipNode()) return; - const nextLabel = this.skipNodeLabels[this.skipNodeQueueIndex]; - if (nextLabel?.mode === "nofirstskip" && this.firstRead) { - // SkipStory writes the anchor's own index, but the command coroutine runs - // ++m_executeIndex before fetching, so playback resumes on the line after - // the anchor and the skipnode command itself is not re-executed. - this.cursor = nextLabel.lineIndex + 1; - this.skipNodeQueueIndex += 1; - this.currentSkipMode = "nofirstskip"; - } else { - this.cursor = this.lines.length; + const hadActiveProcessLoop = this.processing; + const activeProcessCompletion = this.processingCompletion; + let shouldResume = false; + + // m_executeIndex points at the command currently being waited on while our + // cursor already points at the next command, hence cursor <= anchorIndex is + // the native m_executeIndex < m_skipToIndex forward-only test. + if (this.skipToIndex >= 0 && this.cursor <= this.skipToIndex) { + this.cursor = this.skipToIndex + 1; + shouldResume = true; + } else if (this.skipToIndex >= 0) { + // SkipToThis is forward-only. Native does not fall back to skipnode or + // StopStory after playback has already crossed the anchor. + return; + } else if (this.skipToIndex < 0) { + const nextLabel = this.skipNodeLabels[this.skipNodeQueueIndex]; + if (nextLabel) { + this.skipNodeQueueIndex += 1; + this.currentSkipMode = nextLabel.mode; + } + + const tutorial = this.context.storyMetadata?.isTutorial ?? false; + const isSkippable = + !tutorial && + (this.currentSkipMode !== "nofirstskip" || !this.firstRead); + if (nextLabel && !isSkippable) { + // Native stores the anchor index and the coroutine's leading increment + // resumes at the following command. + this.cursor = nextLabel.lineIndex + 1; + shouldResume = true; + } else { + this.cursor = this.lines.length; + } } - this.interruptPendingWait(); + this.pendingInputEffect = null; await this.renderer.clearInterludes(); await this.renderer.clearSpellStickers(); - if (this.state === "waiting_input") { - this.cancelTyping(); + + this.cancelTyping(); + if (shouldResume) { + this.state = "running"; + } else if (!hadActiveProcessLoop) { this.state = "finished"; } + + // Establish the destination state before releasing a blocking executor. + // Its existing processLoop owns continuation; starting another loop would + // race it and can leave state="running" with no loop alive. + this.interruptPendingWait(); + if (hadActiveProcessLoop) await activeProcessCompletion; + else if (shouldResume) await this.processLoop(); } private getCurrentSpeed(): AutoSpeed { @@ -577,6 +621,9 @@ export class StoryRuntime { if (this.processing) return; this.processing = true; + this.processingCompletion = new Promise((resolve) => { + this.resolveProcessingCompletion = resolve; + }); try { while (!this.destroyed) { @@ -633,6 +680,9 @@ export class StoryRuntime { this.onWarning?.({ cursor: this.cursor, detail, type: "error" }); } finally { this.processing = false; + this.resolveProcessingCompletion?.(); + this.resolveProcessingCompletion = null; + this.processingCompletion = null; } } @@ -901,6 +951,12 @@ export class StoryRuntime { return "continue"; } + case "skiptothis": { + // Preprocess-only command. Native explicitly permits it to have no + // executor and silently advances when normal playback reaches it. + return "continue"; + } + case "background": { const image = toString(this.exactArg(args, "image")); const fadeMs = this.calculateFadeMs(this.exactArg(args, "fadetime")); diff --git a/src/widgets/StoryPlayer/index.vue b/src/widgets/StoryPlayer/index.vue index 0094e9f6..044e8225 100644 --- a/src/widgets/StoryPlayer/index.vue +++ b/src/widgets/StoryPlayer/index.vue @@ -241,6 +241,18 @@ async function onSkipNode(event?: Event): Promise { await player.skipNode(); syncState(); + if (state.value === "finished") { + player.destroy(); + player = null; + context = null; + preloadReady.value = false; + preloadProgress.value = 0; + viewMode.value = "lobby"; + if (timer) { + clearInterval(timer); + timer = null; + } + } } function onKeydown(event: KeyboardEvent): void { From 25c29c80d045a58dc5c7337fb3c94c1e751d53f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Sun, 2 Aug 2026 19:19:46 +0800 Subject: [PATCH 4/9] feat: nickname --- src/widgets/StoryPlayer/engine/logAll.ts | 25 +++++++++----- src/widgets/StoryPlayer/engine/runtime.ts | 33 +++++++++++++------ .../StoryPlayer/engine/textVariables.ts | 29 ++++++++++++++++ src/widgets/StoryPlayer/index.vue | 5 ++- 4 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 src/widgets/StoryPlayer/engine/textVariables.ts diff --git a/src/widgets/StoryPlayer/engine/logAll.ts b/src/widgets/StoryPlayer/engine/logAll.ts index 5da9af35..aac9f3b3 100644 --- a/src/widgets/StoryPlayer/engine/logAll.ts +++ b/src/widgets/StoryPlayer/engine/logAll.ts @@ -1,4 +1,5 @@ import { parseRichChars } from "./richtext"; +import { expandStoryText } from "./textVariables"; import type { ParsedLine } from "./types"; @@ -72,8 +73,11 @@ interface MultilineAccumulator { } /** 把含 ... 的文本拆成连续同色的 span */ -function toSpans(text: string): LogAllTextSpan[] { - const chars = parseRichChars(text); +function toSpans( + text: string, + variables: Record, +): LogAllTextSpan[] { + const chars = parseRichChars(expandStoryText(text, variables)); if (chars.length === 0) return []; const spans: LogAllTextSpan[] = []; @@ -123,7 +127,10 @@ function toStringList(value: unknown): string[] { * - 裸 [predicate] 弹栈到当前 decision 之外(对齐 runtime 结束分支模式)。 * 嵌套 decision 因此天然支持:每个 decision 独立持有自己的 shared/branches。 */ -export function buildLogAll(lines: readonly ParsedLine[]): LogAllEntry[] { +export function buildLogAll( + lines: readonly ParsedLine[], + variables: Record = {}, +): LogAllEntry[] { const root: LogAllEntry[] = []; let multilineAccum: MultilineAccumulator | null = null; /** 当前文本应 append 到的数组栈;栈底恒为 root */ @@ -141,8 +148,8 @@ export function buildLogAll(lines: readonly ParsedLine[]): LogAllEntry[] { currentTarget().push({ kind: "line", lineIndex: multilineAccum.lineIndex, - speaker: multilineAccum.name, - spans: toSpans(multilineAccum.text), + speaker: expandStoryText(multilineAccum.name, variables), + spans: toSpans(multilineAccum.text, variables), source: "multiline", }); multilineAccum = null; @@ -160,8 +167,8 @@ export function buildLogAll(lines: readonly ParsedLine[]): LogAllEntry[] { currentTarget().push({ kind: "line", lineIndex, - speaker, - spans: toSpans(text), + speaker: expandStoryText(speaker, variables), + spans: toSpans(text, variables), source, }); }; @@ -183,7 +190,9 @@ export function buildLogAll(lines: readonly ParsedLine[]): LogAllEntry[] { case "decision": { flushMultiline(); - const labels = toStringList(line.args.options); + const labels = toStringList(line.args.options).map((label) => + expandStoryText(label, variables), + ); const values = toNumberList(line.args.values); if (labels.length === 0 || values.length === 0) break; // 无效 decision,按 runtime 行为忽略(runtime 会 warn 并 continue) diff --git a/src/widgets/StoryPlayer/engine/runtime.ts b/src/widgets/StoryPlayer/engine/runtime.ts index c22d9ea7..7aa2019b 100644 --- a/src/widgets/StoryPlayer/engine/runtime.ts +++ b/src/widgets/StoryPlayer/engine/runtime.ts @@ -7,6 +7,7 @@ import { parseRichChars, richCharsToTaggedText, } from "./richtext"; +import { expandStoryText } from "./textVariables"; import type { Context } from "../context"; import type { RichChar } from "./richtext"; @@ -1971,7 +1972,7 @@ export class StoryRuntime { const position = parsedPosition ?? { x: 0, y: 0 }; await this.renderer.setAnimText({ block: toBoolean(this.exactArg(args, "block"), false), - content: line.trailingText, + content: this.translateText(line.trailingText), id: toString(this.exactArg(args, "id")), name: toString(this.exactArg(args, "name")), position, @@ -2245,7 +2246,7 @@ export class StoryRuntime { } case "subtitle": { - const text = toString(this.exactArg(args, "text")); + const text = this.translateText(toString(this.exactArg(args, "text"))); if (!text) { void this.renderer.clearSubtitle(150); @@ -2314,7 +2315,7 @@ export class StoryRuntime { fadeMs: showFadeMs, id, sizePx: toNumber(this.exactArg(args, "size"), 24), - text: toString(this.exactArg(args, "text")), + text: this.translateText(toString(this.exactArg(args, "text"))), widthPx: Math.min( toNumber(this.exactArg(args, "width"), 1280), 1280 - x, @@ -2373,7 +2374,7 @@ export class StoryRuntime { : this.renderer.setSpellSticker({ alpha: clamp(toNumber(this.exactArg(args, "alpha"), 1), 0, 1), angle: toOptionalNumber(this.exactArg(args, "angle")), - content: line.content, + content: this.translateText(line.content), id, style: toString(this.exactArg(args, "style"), "sami"), x: toOptionalNumber(this.exactArg(args, "x")), @@ -2435,7 +2436,9 @@ export class StoryRuntime { return "continue"; } - const options = toString(optionsValue).split(";"); + const options = toString(optionsValue) + .split(";") + .map((option) => this.translateText(option)); const values = toString(this.exactArg(args, "values")) .split(";") .map((value) => { @@ -2489,7 +2492,8 @@ export class StoryRuntime { ): void { this.cancelTyping(); - const richChars = parseRichChars(text); + const translatedSpeaker = this.translateText(speaker); + const richChars = parseRichChars(this.translateText(text)); this.currentMessageLength = richChars.length; this.currentTypingComplete = false; const initialDelayMs = this.getTypeWriterDelayMs(delayScale); @@ -2497,7 +2501,7 @@ export class StoryRuntime { const tagged = richCharsToTaggedText(richChars); const colors = collectColors(richChars); const ts = colors.length > 0 ? buildTagStyles(colors) : undefined; - this.renderer.setDialogue(speaker, tagged, ts); + this.renderer.setDialogue(translatedSpeaker, tagged, ts); this.onTypingComplete(); return; } @@ -2505,10 +2509,19 @@ export class StoryRuntime { const sessionId = ++this.typingSessionId; const colors = collectColors(richChars); const tagStyles = colors.length > 0 ? buildTagStyles(colors) : undefined; - this.typingSession = { id: sessionId, speaker, richChars, tagStyles }; - this.renderer.setDialogue(speaker, "", tagStyles); + this.typingSession = { + id: sessionId, + speaker: translatedSpeaker, + richChars, + tagStyles, + }; + this.renderer.setDialogue(translatedSpeaker, "", tagStyles); + + void this.runTyping(sessionId, translatedSpeaker, richChars, delayScale); + } - void this.runTyping(sessionId, speaker, richChars, delayScale); + private translateText(text: string): string { + return expandStoryText(text, this.context.audioVariables); } private getTypeWriterDelayMs(delayScale: number): number { diff --git a/src/widgets/StoryPlayer/engine/textVariables.ts b/src/widgets/StoryPlayer/engine/textVariables.ts new file mode 100644 index 00000000..752214ec --- /dev/null +++ b/src/widgets/StoryPlayer/engine/textVariables.ts @@ -0,0 +1,29 @@ +const TEXT_VARIABLE_RE = /\{@([a-zA-Z0-9_.]+)\}/g; +const BUILTIN_TEXT_VARIABLES: Record = { + nbs: "\u00A0", +}; + +export function getStoryNickname(): string { + const mediaWiki = ( + globalThis as { + mw?: { config?: { get?: (key: string) => unknown } }; + } + ).mw; + const name = mediaWiki?.config?.get?.("wgUserName"); + return typeof name === "string" && name + ? name.replace(/[Dd][Rr]\./, "") + : "博士"; +} + +export function expandStoryText( + text: string, + variables: Record = {}, +): string { + return text.replace(TEXT_VARIABLE_RE, (_match, rawName: string) => { + const name = rawName.toLowerCase(); + if (name === "nickname") return getStoryNickname(); + + const value = variables[name] ?? BUILTIN_TEXT_VARIABLES[name]; + return value == null ? "" : String(value); + }); +} diff --git a/src/widgets/StoryPlayer/index.vue b/src/widgets/StoryPlayer/index.vue index 044e8225..47e597a7 100644 --- a/src/widgets/StoryPlayer/index.vue +++ b/src/widgets/StoryPlayer/index.vue @@ -121,7 +121,10 @@ function openLogAll(): void { player?.setAutoPlayMode("default"); syncState(); } - logAllEntries.value = buildLogAll(parseStory(scriptText.value).lines); + logAllEntries.value = buildLogAll( + parseStory(scriptText.value).lines, + context?.audioVariables, + ); showLogAll.value = true; } From 064556bc8fe83ae198c00d5839cc37b61ff065e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Sun, 2 Aug 2026 23:36:53 +0800 Subject: [PATCH 5/9] update --- src/widgets/StoryPlayer/engine/asset.ts | 9 ++ src/widgets/StoryPlayer/engine/audio.ts | 33 +++-- .../StoryPlayer/engine/commandRegistry.ts | 11 +- src/widgets/StoryPlayer/engine/execution.ts | 8 ++ src/widgets/StoryPlayer/engine/font.ts | 12 +- src/widgets/StoryPlayer/engine/logAll.ts | 6 + src/widgets/StoryPlayer/engine/parser.ts | 28 +++- src/widgets/StoryPlayer/engine/preload.ts | 27 ++++ .../engine/rendering/PixiStoryRenderer.ts | 123 ++++++++++++++++-- .../engine/rendering/core/LayerGraph.ts | 8 +- .../engine/rendering/core/SceneGeometry.ts | 10 +- .../engine/rendering/core/ShakePath.ts | 6 +- .../engine/rendering/core/TweenRunner.ts | 5 + .../engine/rendering/panels/AnimTextPanel.ts | 7 +- .../rendering/panels/AvgDisplayPanel.ts | 5 + .../engine/rendering/panels/CgItemPanel.ts | 17 ++- .../engine/rendering/panels/DecisionPanel.ts | 5 + .../engine/rendering/panels/DialogPanel.ts | 6 + .../rendering/panels/FocusEffectPanel.ts | 7 +- .../engine/rendering/panels/InterludePanel.ts | 5 + .../rendering/panels/SpellStickerPanel.ts | 5 + .../engine/rendering/panels/VideoPanel.ts | 5 + src/widgets/StoryPlayer/engine/richtext.ts | 10 ++ src/widgets/StoryPlayer/engine/runtime.ts | 123 ++++++++++++++---- tests/runtime.spec.ts | 116 ++++++++++++++--- tests/textVariables.spec.ts | 34 +++++ 26 files changed, 544 insertions(+), 87 deletions(-) create mode 100644 tests/textVariables.spec.ts diff --git a/src/widgets/StoryPlayer/engine/asset.ts b/src/widgets/StoryPlayer/engine/asset.ts index 8fe2ca04..f738c4f0 100644 --- a/src/widgets/StoryPlayer/engine/asset.ts +++ b/src/widgets/StoryPlayer/engine/asset.ts @@ -1,5 +1,14 @@ const TORAPPU_ORIGIN = "https://torappu.prts.wiki"; +/** + * Native provenance: `Torappu.ResourceRouter.GetBackgroundPath`, `GetImagePath`, + * `GetCharacterPath`, `GetItemPath`, `GetMusicPath`, and `GetAudioPath`. + * + * Ports the AVG asset-family routing. HTTP URLs, extension conversion, and URL + * escaping are web delivery adaptations rather than native behavior. + * + */ + function normalizeImageKey(rawKey: string): string { return rawKey.trim().toLowerCase(); } diff --git a/src/widgets/StoryPlayer/engine/audio.ts b/src/widgets/StoryPlayer/engine/audio.ts index d5adf811..ff9957ae 100644 --- a/src/widgets/StoryPlayer/engine/audio.ts +++ b/src/widgets/StoryPlayer/engine/audio.ts @@ -22,10 +22,17 @@ export class HtmlStoryAudio implements StoryAudio { private destroyed = false; private musicAudio: ActivePlayback | null = null; private musicRequestId = 0; - // Native registers a channel in m_channels the moment the executor runs, so a - // musicvolume/soundvolume/stop that follows a play command in the same script - // always lands. Our playback is async, so the base volume lives here instead - // of only on the live IMediaInstance, mirroring m_currentBaseVolume. + /** + * Native provenance: `Torappu.AVG.CommonExecutors._ExecutePlayMusicCommand`, + * `_ExecuteMusicVolumeCommand`, `_ExecutePlaySoundCommand`, + * `_ExecuteSoundVolumeCommand`, `_ExecuteStopMusicCommand`, and + * `_ExecuteStopSoundCommand`; downstream `AudioManager` / `AudioChannel`. + * + * Ports persistent MUSIC and `avgsound_` base-volume state so a + * following volume/stop command observes an in-flight async web load. PIXI + * loading and browser playback are web adaptations of the native backend. + * + */ private musicBaseVolume = 1; private readonly soundBaseVolumes = new Map(); private readonly soundRequestIds = new Map(); @@ -77,8 +84,9 @@ export class HtmlStoryAudio implements StoryAudio { complete: introUrl ? () => { if (this.destroyed || this.musicAudio !== next) return; - // The loop half starts at whatever the channel's base volume is by - // then, so ducking applied during the intro is not undone. + // Native provenance: `AudioManager.PlayMusic` / `AudioChannel`. + // Preserve the current channel base volume while switching from + // intro to loop, so an intervening musicvolume command is retained. void this.playLoopMusic(mainUrl, identity, requestId); } : undefined, @@ -109,8 +117,9 @@ export class HtmlStoryAudio implements StoryAudio { const url = this.resolveAudioUrl(input.key); if (!url) return; - // Claim the channel synchronously so a soundvolume/stopsound on the very - // next line is not dropped while the asset is still loading. + // Native provenance: `CommonExecutors._GenAVGSoundChannelName` and + // `_ExecutePlaySoundCommand`. Ports deterministic channel ownership before + // playback begins, so a following soundvolume/stopsound targets this sound. const channel = this.soundChannelName(input.channel); const requestId = (this.soundRequestIds.get(channel) ?? 0) + 1; this.soundRequestIds.set(channel, requestId); @@ -157,7 +166,8 @@ export class HtmlStoryAudio implements StoryAudio { async stopSound(channel: string, fadeMs: number): Promise { const channelName = this.soundChannelName(channel); - // Cancel any playSound still waiting on its delay or on asset loading. + // Web adaptation: cancellation also covers pending PIXI loads. The native + // command resolves the same named channel through AudioManager. this.soundRequestIds.set( channelName, (this.soundRequestIds.get(channelName) ?? 0) + 1, @@ -186,8 +196,9 @@ export class HtmlStoryAudio implements StoryAudio { } async setMusicVolume(volume: number, fadeMs: number): Promise { - // The MUSIC channel's base volume is global state that outlives any single - // instance, so record it even while the playback is still loading. + // Native provenance: `CommonExecutors._ExecuteMusicVolumeCommand` and + // `AudioChannel.TweenVolume`. MUSIC base volume outlives a playback instance, + // so retain it while PIXI is still loading. this.musicBaseVolume = volume; if (!this.musicAudio) return; diff --git a/src/widgets/StoryPlayer/engine/commandRegistry.ts b/src/widgets/StoryPlayer/engine/commandRegistry.ts index f84cc543..18b04672 100644 --- a/src/widgets/StoryPlayer/engine/commandRegistry.ts +++ b/src/widgets/StoryPlayer/engine/commandRegistry.ts @@ -4,7 +4,16 @@ export type CommandExecutor = ( command: ParsedCommandLine, ) => TResult | Promise; -/** A command may have multiple subscribers, matching AVGController's executor model. */ +/** + * Native provenance: `Torappu.AVG.AVGController._InitExecutors`, + * `_GetCommandExecutors`, and `_ExecuteExecutor`. + * + * Ports the lower-cased command-key lookup and the fact that several active + * `ExecutorComponent.GetExecutors` registrations may subscribe to one command. + * Waiting/force-end policy remains in `StoryRuntime`; this is only the web + * dispatch container. + * + */ export class CommandRegistry { private readonly executors = new Map< string, diff --git a/src/widgets/StoryPlayer/engine/execution.ts b/src/widgets/StoryPlayer/engine/execution.ts index 9df51d37..90bc281c 100644 --- a/src/widgets/StoryPlayer/engine/execution.ts +++ b/src/widgets/StoryPlayer/engine/execution.ts @@ -6,6 +6,14 @@ export interface ExecutionHandle { end: (reason?: ExecutionEndReason) => void; } +/** + * Native provenance: `Torappu.AVG.CommandExecutorWrapper` completion and + * force-end lifecycle. + * + * Ports one-shot command completion with an explicit blocking flag. A Promise + * is the web adaptation of native finish callbacks and coroutine resumption. + * + */ export function createExecutionHandle( blocking: boolean, onEnd?: (reason: ExecutionEndReason) => void, diff --git a/src/widgets/StoryPlayer/engine/font.ts b/src/widgets/StoryPlayer/engine/font.ts index 5119d937..e68a75fa 100644 --- a/src/widgets/StoryPlayer/engine/font.ts +++ b/src/widgets/StoryPlayer/engine/font.ts @@ -1,15 +1,21 @@ +/** + * Asset provenance: the AVG default font configured by + * `Torappu.Resource.AbFontConfig` is `SourceHanSansCN-Bold`. + * + * This module is a web adaptation: it explicitly registers the exported font + * with the browser before PIXI measures dialogue text. Native loading uses a + * Unity Font asset and ResourceManager instead. + * + */ // 对话 UI 字体的显式预加载。 -// // 为什么不用纯 CSS @font-face:浏览器对 @font-face 是惰性加载, // 只有当文档里实际出现用该 family 名的 DOM 文本时才会发起请求。 // 我们的对话文本是 PIXI 在 canvas 里用 ctx.font 渲染的, // 浏览器不一定认为"文档使用了该字体",导致 F12 Network 看不到请求、 // 字体永远不会下载。 -// // 用 FontFace API 显式 fetch + register + load,可保证资源被请求, // 且返回的 Promise resolve 后 measureText 测量的是真实字体 metrics // (BestFit / 动态 Y 调整依赖准确测量)。 -// // DIALOG_FONT_FAMILY 是 PIXI 内部使用的逻辑名(仅出现在各面板 TextStyle 的 // fontFamily 里,无 CSS/DOM 引用),与实际加载的字体文件无关。当前加载的是 // 思源黑体 CN 的 Bold 字重,故 DIALOG_FONT_WEIGHT 为 700;family 名沿用历史 diff --git a/src/widgets/StoryPlayer/engine/logAll.ts b/src/widgets/StoryPlayer/engine/logAll.ts index aac9f3b3..3040b067 100644 --- a/src/widgets/StoryPlayer/engine/logAll.ts +++ b/src/widgets/StoryPlayer/engine/logAll.ts @@ -13,6 +13,12 @@ import type { ParsedLine } from "./types"; * * 因此 LogAll 把脚本整理为:顶层条目 + decision 节点(选项 + 共享段 + 各 predicate 分支段), * 与玩家实际能走到的路径一一对应,不展开未选择的分支也不引入嵌套怪癖。 + * + * Native provenance: `Torappu.AVG.DecisionCommandPredicator` and + * `Torappu.AVG.DecisionPanel._ExecuteDecision` / `_ExecutePredicate`. + * This static reader projection ports predicate gating only; the Log All tree, + * route expansion, and reader UI are web-only adaptations. + * */ export interface LogAllTextSpan { diff --git a/src/widgets/StoryPlayer/engine/parser.ts b/src/widgets/StoryPlayer/engine/parser.ts index 24565f43..e95aa974 100644 --- a/src/widgets/StoryPlayer/engine/parser.ts +++ b/src/widgets/StoryPlayer/engine/parser.ts @@ -6,9 +6,14 @@ import type { StoryMetadata, } from "./types"; -// Mirrors AVGParser.COMMAND_REGEX. Command names are normalized by the game, -// parameter keys are not. -// This is the literal Unity regex documented in avg-story-runtime-research. +/** + * Native provenance: `Torappu.AVG.AVGParser.cctor` and + * `Torappu.AVG.AVGParser._ParseCommand`. + * + * Ports the command-tag shape and lower-cased command names. Parameter keys + * deliberately retain source case, matching the native parameter dictionary. + * + */ const commandRegex = /^\[\s*(?:(.*?)\((.*)\)|(?:([.\|\w]*)|(.*)))\s*\]\s*(.*)/; // Param keys are case-sensitive in the native dictionary, so `[Name="X"]` is a // dialog line whose `name` lookup misses -- it renders without a speaker rather @@ -159,6 +164,15 @@ interface LogicalLine { raw: string; } +/** + * Native provenance: `Torappu.AVG.AVGParser._ReadNextBlock` and + * `Torappu.AVG.AVGParser.TryParse(string, List)`. + * + * Ports backslash continuation, comment/blank-line filtering, and physical + * first-line numbering. The web parser intentionally keeps its own lightweight + * argument parser rather than embedding Json.NET's complete error surface. + * + */ function logicalLines(source: string | readonly string[]): LogicalLine[] { const physicalLines = typeof source === "string" @@ -186,6 +200,9 @@ export function parseScript(source: string | readonly string[]): ParsedLine[] { const last = lines.at(-1); if (!(last?.kind === "command" && last.command === "endtip")) { const lineNumber = sourceLines.at(-1)?.lineNumber ?? 1; + // Native provenance: `Torappu.AVG.AVGParser.TryParse(string, List)` + // and `Torappu.AVG.AVGUtils.GenerateEndtipCommand`. Ports the implicit + // terminal command for a non-empty script. lines.push({ args: { block: true }, command: "endtip", @@ -240,8 +257,9 @@ export function parseStory(source: string | readonly string[]): { "dont_clear_gameobjectpool_onstart", ) === true, fitMode: fitMode === "BLACK_MASK" ? "BLACK_MASK" : "DEFAULT", - // TryParse reads Story.id out of the HEADER's `key` param; there is no - // `id` param, and all 2061 shipped HEADER lines write `key=`. + // Native provenance: `Torappu.AVG.AVGParser.TryParse(string, StoryParam, + // out Story)`. Ports the HEADER-to-Story metadata mapping: the story id + // comes from `key`, rather than the unrelated `id` parameter name. id: String(metadataValue(header?.args ?? {}, "key") ?? ""), isAutoable: typeof autoable === "boolean" ? autoable : !isTutorial && !isVideoOnly, diff --git a/src/widgets/StoryPlayer/engine/preload.ts b/src/widgets/StoryPlayer/engine/preload.ts index 507062f9..9b93d9b9 100644 --- a/src/widgets/StoryPlayer/engine/preload.ts +++ b/src/widgets/StoryPlayer/engine/preload.ts @@ -61,6 +61,16 @@ function addCharacterUrl(urls: Set, rawKey: string): void { urls.add(resolveAssetUrl(rawUrl)); } +/** + * Native provenance: the active panels' `IContainsResRefs` collectors, + * including `AVGShowItemPanel.InternalResRefCollector.GatherResRefs`, + * `AVGCgItemPanel.InternalResRefCollector.GatherResRefs`, and the image / + * background / character panel collectors. + * + * Ports which script commands declare image dependencies. A single eager PIXI + * batch substitutes for native asset-reference collection and loading. + * + */ function addScriptImageUrls(urls: Set, context: Context): void { for (const line of parseScript(context.scriptText ?? context.script)) { if (line.kind !== "command") continue; @@ -120,6 +130,14 @@ function addScriptImageUrls(urls: Set, context: Context): void { } } +/** + * Native provenance: `Torappu.AVG.InternalMusicRefCollector` and + * `Torappu.AVG.InternalSoundRefCollector.GatherResRefs`. + * + * Ports music key/intro and sound key discovery; browser preload timing and + * progress reporting are web-only behavior. + * + */ function addScriptAudioUrls(urls: Set, context: Context): void { for (const line of parseScript(context.scriptText ?? context.script)) { if (line.kind !== "command") continue; @@ -132,6 +150,15 @@ function addScriptAudioUrls(urls: Set, context: Context): void { } } +/** + * Native provenance: `Torappu.AVG.AVGCharacterslotPanel` character-reference + * resolution and `Torappu.ResourceRouter.GetCharacterPath`. + * + * Ports the story-facing name forms needed to identify character assets. The + * `character.json` link-map representation and face-overlay reconstruction are + * web asset-pipeline adaptations, not a direct native data structure. + * + */ function resolveCharacterSelection( context: Context, rawRef: string, diff --git a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts index 2a54a799..1d1ba5ed 100644 --- a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts +++ b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts @@ -150,6 +150,12 @@ interface CurtainRenderState { tweenSessionId: number; } +/** + * Web/PIXI renderer for the AVG command surfaces. Each command method ports + * the documented observable state and blocking boundaries, while containers, + * filters, and browser timing are adaptations rather than a Unity scene port. + * Command-specific native provenance is recorded at the behavior boundaries. + */ export class PixiStoryRenderer implements StoryRenderer { private app: Application | null = null; private readonly layers = new LayerGraph(); @@ -438,6 +444,11 @@ export class PixiStoryRenderer implements StoryRenderer { return this.decisionPanel.show(options, values); } + /** + * Port scope: `Torappu.AVG.AVGImagePanel._ExecuteImage` / `_LoadImage` for + * the `background` path: replacement, initial transform, cross-fade, and + * block boundary. PIXI roots replace the two Unity Image widgets. + */ async setBackground(key: string, input?: BackgroundInput): Promise { const texture = await this.textureForImageKey(key, "background"); if (!texture) return; @@ -494,6 +505,10 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port of `Torappu.AVG.AVGImagePanel._ExecuteImageTween`'s foreground + * transform semantics. Browser interpolation replaces the DOTween sequence. + */ async setBackgroundTween(input: BackgroundTweenInput): Promise { const root = this.backgroundRoot; if (!root || root.parent !== this.backgroundLayer) return; @@ -545,6 +560,11 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port scope: `Torappu.AVG.LargeBackgroundPanel._ExecuteGridBG` and + * `_LoadImage`, including all-or-nothing asset loading and replacement. + * `Container` composition is the Web adaptation of Unity RectTransforms. + */ async setGridBackground(input: GridBackgroundInput): Promise { const sessionId = ++this.gridBackgroundSessionId; this.largeBackgroundTweenSessionId += 1; @@ -618,6 +638,11 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Web-only legacy compatibility surface. The investigated client has no + * `largeimg` command or corresponding native executor, so this must not be + * represented as a port; it remains isolated from the real `image` path. + */ async setLargeImage(input: GridBackgroundInput): Promise { const sessionId = ++this.largeImageSessionId; this.largeImageTweenSessionId += 1; @@ -706,6 +731,11 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port scope: `Torappu.AVG.AVGImagePanel._ExecuteImage` / `_LoadImage` for + * the `image` path. Sprite replacement and fade behavior are preserved; + * PIXI geometry is an adaptation of the native Image/RectTransform pair. + */ async setImage(key: string, input?: BackgroundInput): Promise { const texture = await this.textureForImageKey(key, "image"); if (!texture) return; @@ -786,6 +816,11 @@ export class PixiStoryRenderer implements StoryRenderer { await this.interludePanel.run(input); } + /** + * Port scope: `Torappu.AVG.AVGCharacterCutinPanel._ExecuteCharacterCutin` + * and `AVGCharacterCutinSlot.Show`/hide fade styles. Crop expansion is + * reproduced with PIXI dimensions and masks rather than Unity UI widgets. + */ async setCharacterCutin(input: CharacterCutinInput): Promise { if (!this.app) return; @@ -1015,12 +1050,18 @@ export class PixiStoryRenderer implements StoryRenderer { } } + /** + * Port scope: `Torappu.AVG.AVGShowItemPanel._ExecuteShowItem` / `_ShowItem`. + * It retains the single-slot replacement and photo fade; PIXI drawing is an + * approximation of the serialized show-item prefab. + */ async showItem(input: ShowItemInput): Promise { const texture = await this.textureForImageKey(input.key, "image"); if (!texture) return; - // AVGShowItemPanel owns a single slot, so a second showitem replaces the - // first rather than stacking on top of it. + // `AVGShowItemPanel._ExecuteShowItem` owns one slot, so a second showitem + // replaces the first rather than stacking on it. See the command evidence + // above; PIXI children are only the storage adaptation. this.itemLayer.removeChildren(); const sprite = new Sprite(texture); @@ -1128,6 +1169,10 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port of `Torappu.AVG.AVGImagePanel._ExecuteImageTween` for the foreground + * sprite. PIXI interpolation substitutes for native DOTween. + */ async setImageTween(input: ImageTweenInput): Promise { if (!this.imageSprite) return; @@ -1180,6 +1225,10 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port of `Torappu.AVG.LargeBackgroundPanel._ExecuteImageTween` for + * `largebg`; it intentionally uses the panel's direct tween timing. + */ async setLargeBackgroundTween( input: LargeBackgroundTweenInput, ): Promise { @@ -1233,6 +1282,11 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Web-only companion to the legacy `setLargeImage` surface: the investigated + * client has no `largeimgtween` executor. It is intentionally not a native + * provenance claim. + */ async setLargeImageTween(input: LargeBackgroundTweenInput): Promise { const root = this.largeImageRoot; if (!root || root.parent !== this.imageLayer) return; @@ -1284,10 +1338,12 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port of `Torappu.AVG.AVGImagePanel._ExecuteImageRotate`: rotate the panel + * transform rather than its foreground Image, preserving angle across image + * swaps. `imageLayer` is the corresponding PIXI adaptation. + */ async setImageRotate(input: ImageRotateInput): Promise { - // _ExecuteImageRotate (0x1839B2290) rotates the panel's own _rectTransform, - // never the fore image, so the angle survives image swaps and stacks with the - // position/scale that imagetween writes onto the sprite. const target = this.imageLayer; const sessionId = ++this.imageRotateSessionId; const startAngle = target.angle; @@ -1320,6 +1376,11 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port scope: `Torappu.AVG.CharacterPanel._ExecuteCharacter` and its + * character-slot branch. Slot state and transitions are preserved; display + * hierarchy and texture composition are Web/PIXI adaptations. + */ async setCharacter(input: CharacterSlotInput): Promise { if (this.isCharacterSlotCommand(input)) { await this.setCharacterSlot(input); @@ -1575,6 +1636,11 @@ export class PixiStoryRenderer implements StoryRenderer { else await run; } + /** + * Port scope: `Torappu.AVG.CharacterPanel._ExecuteCharacterAction` and its + * move/jump/shake/zoom/exit handlers. Browser tween sampling adapts DOTween, + * but keeps the per-action state and completion boundary. + */ async runCharacterAction(input: CharacterActionInput): Promise { const state = this.characterSlots.get( this.normalizeCharacterSlot(input.slot), @@ -1822,6 +1888,11 @@ export class PixiStoryRenderer implements StoryRenderer { ); } + /** + * Port scope: `Torappu.AVG.AVGBlockerPanel._ExecuteBlocker`, including the + * zero-duration and idempotent inverse branches. A PIXI sprite stands in for + * the native blocker view. + */ async setBlocker(input: BlockerInput): Promise { const blocker = this.ensureBlocker(); if (input.style === "default" && input.image) { @@ -1856,9 +1927,8 @@ export class PixiStoryRenderer implements StoryRenderer { return; } - // _ExecuteBlocker assigns a literal -1 to the axis rather than negating the - // current value, so repeated inverse commands are idempotent; inverse=false - // never restores +1, only OnReset does. + // `AVGBlockerPanel._ExecuteBlocker` assigns literal -1 rather than negating + // the current axis: repeated inverse commands are idempotent, and only reset if (input.inverse) { if (input.style === "slider") blocker.scale.x = -1; else if (input.style === "verticalslider") blocker.scale.y = -1; @@ -1886,6 +1956,11 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port scope: `Torappu.AVG.AVGCurtainPanel._ExecuteCurtain`. It keeps the + * direction state, delayed size/alpha transition, and zero-duration branch; + * Graphics rectangles are a Web/PIXI adaptation of `AVGCurtain` widgets. + */ async setCurtain(input: CurtainInput): Promise { const direction = input.direction; const vector = this.resolveCurtainVector(direction); @@ -1942,6 +2017,11 @@ export class PixiStoryRenderer implements StoryRenderer { else void run; } + /** + * Port scope: `Torappu.AVG.AVGCameraEffect._ExecuteCameraEffect` for the + * grayscale/inverse branches and their completion behavior. A PIXI color + * filter substitutes for `AVGSceneEffectManager`'s camera post-process. + */ async setCameraEffect( effect: "Colorinverse" | "Grayscale", amount: number, @@ -2005,13 +2085,13 @@ export class PixiStoryRenderer implements StoryRenderer { case "char": { return [this.charLayer]; } - // ck_cg_1/2 are registered by the AVGImagePanel base class, i.e. the - // `image` command's fore/back images -- not by anything CG-related. + // `AVGImagePanel._PostDisplayKey` registers ck_cg_1/2 for `image`'s + // fore/back images, not CG objects. case "cg": { return [this.imageLayer]; } - // ck_lbg_1..4 are registered by LargeBackgroundPanel._PostDisplayKey over - // its `_images` list, which only the `largebg` command fills. + // `LargeBackgroundPanel._PostDisplayKey` registers ck_lbg_1..4 over its + // `_images` list, which only `largebg` fills. case "lbg": { return this.largeBackgroundRoot ? [this.largeBackgroundRoot] : []; } @@ -2030,6 +2110,11 @@ export class PixiStoryRenderer implements StoryRenderer { } } + /** + * Port of `Torappu.AVG.AVGCameraEffect._ExecuteCameraShake`: it moves the + * scene root, not independent visual layers. Path sampling is a Web/PIXI + * adaptation of DOTween's shake tween. + */ async shakeCamera(input: CameraShakeInput): Promise { this.stopCameraShake(); @@ -2066,6 +2151,11 @@ export class PixiStoryRenderer implements StoryRenderer { } } + /** + * Port scope: `Torappu.AVG.SubtitlePanel._ExecuteSubtitle` normal-playback + * semantics. PIXI text/typewriter scheduling adapts `AVGTypeWriterText`; + * playback/reader-mode executor variants are intentionally not represented. + */ async setSubtitle(input: SubtitleInput): Promise { const subtitle = this.subtitleText; if (!subtitle) return; @@ -2125,6 +2215,10 @@ export class PixiStoryRenderer implements StoryRenderer { }); } + /** + * Port scope: `Torappu.AVG.StickerPanel._ExecuteSticker` and its append, + * fade, and typewriter state. PIXI Text replaces the native sticker prefab. + */ async setSticker(input: StickerInput): Promise { const sticker = this.ensureStickerText(input.id); const wasActive = this.stickerRichChars.has(input.id); @@ -2190,6 +2284,11 @@ export class PixiStoryRenderer implements StoryRenderer { this.spellStickerPanel.show(input); } + /** + * Port scope: `Torappu.AVG.StickerPanel._ExcuteTimerSticker` (native spelling) + * and `AVGTimerView.RenderTimer`. Browser intervals and PIXI text adapt the + * native timer view; this command itself never supplies a block boundary. + */ async setTimerSticker(input: TimerStickerInput): Promise { const timer = this.ensureTimerStickerText(); this.clearTimerInterval(); diff --git a/src/widgets/StoryPlayer/engine/rendering/core/LayerGraph.ts b/src/widgets/StoryPlayer/engine/rendering/core/LayerGraph.ts index c407a154..b3e42c55 100644 --- a/src/widgets/StoryPlayer/engine/rendering/core/LayerGraph.ts +++ b/src/widgets/StoryPlayer/engine/rendering/core/LayerGraph.ts @@ -1,6 +1,12 @@ import { Container } from "pixi.js"; -/** Owns the stable canvas/layer graph; panels receive only the layer they render into. */ +/** + * Web/PIXI adaptation of the scene roots consumed by + * `Torappu.AVG.AVGCameraEffect._ExecuteCameraShake` and the AVG panels. + * It preserves the documented `SceneCanvas/panel_avg` sibling relationships, + * while flattening Unity canvases into PIXI containers rather than porting + * Unity's Canvas implementation. + */ export class LayerGraph { readonly background = new Container(); readonly avgDisplayBackground = new Container(); diff --git a/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts b/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts index 3fd48cff..7acab7e2 100644 --- a/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts +++ b/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts @@ -55,8 +55,9 @@ function repeat360(value: number): number { } /** - * AVGUtils.CreateRotateTween (0x1839786B0): the signed sweep handed to - * DORotate(..., RotateMode.LocalAxisAdd). + * Port of `Torappu.AVG.AVGUtils.CreateRotateTween`'s signed sweep for + * `AVGImagePanel._ExecuteImageRotate`; this only reproduces the angle choice, + * not DOTween's Unity transform tween. * * `inverse` is a direction switch, not just a sign for `circles`: with * `circles = 0` a clockwise rotation still rewrites any positive delta into @@ -142,8 +143,9 @@ export function buildGridBackgroundRoot( root.addChild(sprite); offsetY += height; } - // Native sizeDelta only sums the first two heights, but child placement - // continues through all N entries. Pivot mirrors that documented quirk. + // `LargeBackgroundPanel._ExecuteVerticalBG` sizes its RectTransform from + // the first two heights but still places every child. This PIXI pivot is + // the coordinate-system adaptation of that quirk. const pivotHeight = (input.solidHeights[0] ?? 0) + (input.solidHeights[1] ?? 0); root.pivot.set(width / 2, pivotHeight / 2); diff --git a/src/widgets/StoryPlayer/engine/rendering/core/ShakePath.ts b/src/widgets/StoryPlayer/engine/rendering/core/ShakePath.ts index 555eb189..7a8ced84 100644 --- a/src/widgets/StoryPlayer/engine/rendering/core/ShakePath.ts +++ b/src/widgets/StoryPlayer/engine/rendering/core/ShakePath.ts @@ -18,7 +18,11 @@ function randomRange(min: number, max: number, random: RandomSource): number { return min + (max - min) * random(); } -/** Build the waypoint array used by DOTween's vector-based Shake overload. */ +/** + * Ports the waypoint selection used by + * `Torappu.AVG.AVGCameraEffect._ExecuteCameraShake` through DOTween's vector + * shake overload. Sampling and scheduling below are a Web/PIXI adaptation. + */ export function buildShakePath( durationMs: number, input: ShakePathInput, diff --git a/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts b/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts index 21c237e0..1f35a784 100644 --- a/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts +++ b/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts @@ -2,6 +2,11 @@ import { browserAnimationClock } from "../../execution"; import type { AnimationClock } from "../../execution"; +/** + * Web-only animation adapter. AVG executors create DOTween sequences; callers + * preserve their documented command timing while this class uses browser frames + * instead of claiming a one-to-one native port. + */ export class TweenRunner { constructor( private readonly isAlive: () => boolean, diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts index dba5d751..f5d76158 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts @@ -35,7 +35,12 @@ function splitContent(content: string): string[] { ); } -/** Web reconstruction of AVG/AnimateText/group_location_stamp.prefab. */ +/** + * Port scope: `Torappu.AVG.AVGDisplayableExecutor._ExecuteAnimatedText` and + * the serialized `AVG/AnimateText/group_location_stamp` prefab's visible + * timeline. Sprite construction and keyframe playback are a Web/PIXI + * adaptation, not a port of Unity Animator internals. + */ export class AnimTextPanel { private readonly stamps: StampView[] = []; private sessionId = 0; diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts index e0754cad..1691fd3b 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts @@ -18,6 +18,11 @@ type Tween = ( complete?: () => void, ) => Promise; +/** + * Port scope: `Torappu.AVG.AVGDisplayableExecutor._ExecuteAVGDisplayable`. + * It retains id replacement, slot/style routing, and command timing; PIXI + * containers and the currently supported `bg` asset are Web adaptations. + */ export class AvgDisplayPanel { private readonly states = new Map(); diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts index 7c9ef018..1ce6848c 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts @@ -46,6 +46,11 @@ function rgb(color: { r: number; g: number; b: number }): number { return (channel(color.r) << 16) | (channel(color.g) << 8) | channel(color.b); } +/** + * Port scope: `Torappu.AVG.AVGCgItemPanel._ExecuteShowCgItem` and + * `_ExecuteHideCgItem`, including independent delayed tracks and clear-all's + * non-blocking completion. PIXI sprites substitute for `AVGShowItemCgSlot`. + */ export class CgItemPanel { private readonly states = new Map(); @@ -98,10 +103,9 @@ export class CgItemPanel { ); }; - // AVGShowItemCgSlot.Show drives Transform.localPosition through DOLocalMove, - // and _GenPosByRaw parses "x,y" without touching the sign. That is a - // different coordinate space from the sticker text view, which negates y - // before writing anchoredPosition -- do not carry that negation over here. + // `Torappu.AVG.AVGShowItemCgSlot.Show` drives Transform.localPosition and + // `_GenPosByRaw` keeps the raw y sign. PIXI uses sprite.position as the + // coordinate-system adaptation, so do not inherit sticker's UI-y inversion. if (input.positionFrom && input.positionTo) { if (input.positionDurationMs > 0) { sprite.position.set(input.positionFrom.x, input.positionFrom.y); @@ -156,7 +160,8 @@ export class CgItemPanel { } } - // Native's rfrom > 0 guard is intentional, despite being surprising. + // `AVGCgItemPanel._ExecuteShowCgItem` intentionally guards this branch + // with `rfrom > 0`; retain that behavior despite the surprising condition. if (input.rotationFrom <= 0) { if (input.rotationDurationMs > 0) { sprite.angle += input.rotationFrom; @@ -211,7 +216,7 @@ export class CgItemPanel { () => state.root.destroy({ children: true }), ); } - // Native clear-all calls FinishCommand immediately even when block=true. + // `_ExecuteHideCgItem` completes clear-all immediately even if `block=true`. } targets(key: string): Container[] { diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts index 043840ca..4e1ed019 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts @@ -5,6 +5,11 @@ import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; import type { Container as ContainerType } from "pixi.js"; +/** + * Web/PIXI presentation for `Torappu.AVG.DecisionPanel._ExecuteDecision`. + * The runtime owns predicate and skip policy; this class only blocks for a + * selected value and adapts native option widgets to browser pointer events. + */ export class DecisionPanel { private container: Container | null = null; private resolve: ((value: number) => void) | null = null; diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts index 5146d925..1e71a2b8 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts @@ -13,6 +13,12 @@ import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; import type { Container } from "pixi.js"; +/** + * Web/PIXI reconstruction of the visual surface used by + * `Torappu.AVG.DialogPanel._ExecuteDialog`. Command sequencing, typewriter, + * and click semantics remain in the runtime; this class adapts scene-authored + * Unity UI layout and text measurement to PIXI. + */ export class DialogPanel { private bottomGradient: Sprite | null = null; private dialogue: Text | null = null; diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts index a110b17b..3446155d 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts @@ -17,7 +17,12 @@ interface FocusState { type: string; } -/** Keeps focus state by logical channel and projects it onto the current PIXI display objects. */ +/** + * Port scope: `Torappu.AVG.AVGCameraEffect._ExecuteFocusout` and + * `_ExecuteFocusParam` channel state. Applying `BlurFilter` and + * `ColorMatrixFilter` per PIXI container is a Web adaptation of the native + * `AVGSceneEffectManager` post-processing pipeline. + */ export class FocusEffectPanel { private readonly states = new Map(); private readonly filteredTargets = new Set(); diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts index 48a8c6e9..31078b08 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts @@ -24,6 +24,11 @@ function lerp(from: number, to: number, progress: number): number { return from + (to - from) * progress; } +/** + * Port scope: `Torappu.AVG.AVGCharacterCutinPanel._ExecuteInterlude`, including + * channel replacement, template masking, and block timing. Containers/masks + * are a Web/PIXI adaptation of the native character-cutin prefab hierarchy. + */ export class InterludePanel { private readonly channels = new Map(); diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts index ccc41740..0629b235 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts @@ -19,6 +19,11 @@ function splitContent(content: string): [string, string] { return [parts.get(1) ?? "", parts.get(2) ?? ""]; } +/** + * Port scope: `Torappu.AVG.SpellStickerPanel._ExecuteSpellSticker` and + * `_ExecuteSpellStickerClear` state transitions. The two supported styles are + * lightweight PIXI approximations of their prefab visuals, not Animator ports. + */ export class SpellStickerPanel { private readonly orphans = new Set(); private readonly views = new Map(); diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/VideoPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/VideoPanel.ts index 80fbada8..45fb5ab3 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/VideoPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/VideoPanel.ts @@ -1,5 +1,10 @@ import type { Container } from "pixi.js"; +/** + * Web adaptation of `Torappu.AVG.AVGVideoPanel._ExecuteVideo` / `_PlayVideo`. + * It preserves full-screen UI suppression and completion timing, while native + * Unity video/player lifecycle is represented by an HTMLVideoElement. + */ export class VideoPanel { private active: HTMLVideoElement | null = null; private host: HTMLDivElement | null = null; diff --git a/src/widgets/StoryPlayer/engine/richtext.ts b/src/widgets/StoryPlayer/engine/richtext.ts index 1e599a09..23f5a705 100644 --- a/src/widgets/StoryPlayer/engine/richtext.ts +++ b/src/widgets/StoryPlayer/engine/richtext.ts @@ -5,6 +5,16 @@ export interface RichChar { const COLOR_TAG_RE = /]+)>([\s\S]*?)<\/color>/gi; +/** + * Native provenance: Unity `UnityEngine.UI.Text` rich-text handling as used by + * `Torappu.AVG.AVGTypeWriterText.BeginText` and the dialog/sticker/subtitle + * executors. + * + * Ports only `` spans into PIXI tag styles. Other Unity rich-text tags + * remain outside this focused web adaptation. + * + */ + export function parseRichChars(text: string): RichChar[] { const re = new RegExp(COLOR_TAG_RE.source, COLOR_TAG_RE.flags); const chars: RichChar[] = []; diff --git a/src/widgets/StoryPlayer/engine/runtime.ts b/src/widgets/StoryPlayer/engine/runtime.ts index 7aa2019b..2b7a19b1 100644 --- a/src/widgets/StoryPlayer/engine/runtime.ts +++ b/src/widgets/StoryPlayer/engine/runtime.ts @@ -276,8 +276,8 @@ function preprocessSkipNodes( const line = lines[index]; if (line.kind !== "command" || line.command !== "skipnode") continue; - // Native CalSkipMode only recognizes the exact lower-case sentinel; - // every other value, including a missing mode, maps to CAN_SKIP. + // Native port: Torappu.AVG.AVGStoryCache.CalSkipMode. Only the exact + // lower-case sentinel maps to FIRST_CANNOT_SKIP; every other value, const mode = toString(line.args.mode, "skip") === "nofirstskip" ? "nofirstskip" @@ -321,7 +321,10 @@ export class StoryRuntime { private readonly skipNodeLabels: SkipNodeLabel[]; private skipNodeQueueIndex = 0; private readonly skipToIndex: number; - /** Mirrors AVGShowItemPanel's single `_slotInUse`, which decides hideitem's return value. */ + /** + * Native port: Torappu.AVG.AVGShowItemPanel's single `_slotInUse`. + * It determines whether `_ExecuteHideItem` blocks. + */ private itemSlotInUse = false; private theaterMode = false; private theaterAutoPlayCache: AutoPlayState | null = null; @@ -556,12 +559,12 @@ export class StoryRuntime { } /** - * AVGUtils.CalculateFadetime (0x183977E80) = animateRatio * fadetime, in ms. + * Native port: Torappu.AVG.AVGUtils.CalculateFadetime, adapted from seconds + * to milliseconds. It applies animateRatio * fadetime. * * Only panels implementing IFadeTimeRatio route through it. AVGImagePanel does * (background / image / imagerotate); its tween executors bypass it, and the * whole LargeBackgroundPanel family (largebg / verticalbg / gridbg / - * largebgtween) bypasses it too. */ private calculateFadeMs(seconds: unknown, fallback = 0): number { return Math.max( @@ -866,9 +869,9 @@ export class StoryRuntime { switch (line.command) { case "endtip": { - // AVGStoryCache.shouldProcessEndtip (0x1839BBDB0) is a computed property: - // (autoPlayMode is button_auto or quick_play) && !isVideoOnly. In manual - // mode _ExecuteEndtip returns false without touching any UI. + // Native port: Torappu.AVG.DialogPanel._ExecuteEndtip and + // AVGStoryCache.shouldProcessEndtip. It only shows and blocks in auto + // play for non-video-only stories; manual mode is a no-op. const shouldProcessEndtip = (this.autoPlayMode === "button_auto" || this.autoPlayMode === "quick_play") && @@ -882,8 +885,9 @@ export class StoryRuntime { } case "dialog": { - // `[Dialog]`, `[name="X"]` and bare narration all reach _ExecuteDialog - // under the sentinel command name `dialog`; the multi-param forms + // Native port: Torappu.AVG.DialogPanel._ExecuteDialog. `[Dialog]`, + // `[name="X"]` and bare narration all reach it under the sentinel `dialog`; + // the multi-param forms // (`[name="X",avatarId=1]`, `[Delay=2]`, `[imagegroup=...]`) land here // too. Only the content-bearing branch resets multiline and blocks; an // empty content just hides the box and returns false. @@ -902,6 +906,8 @@ export class StoryRuntime { } case "delay": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteDelayCommand. + // Only `time` is read and is scaled by animateRatio. const durationMs = Math.max( 0, toNumber(this.exactArg(args, "time"), 0) * @@ -915,9 +921,8 @@ export class StoryRuntime { } case "video": { - // _PlayVideo (0x1839C3920) reads `url` first and uses it verbatim with no - // validation; only the `res` fallback goes through CheckVideoExist + - // GetVideoFullPath. IsMp4VideoPath has no call sites and gates nothing. + // Native port: Torappu.AVG.VideoPanel._ExecuteVideo / _PlayVideo. `url` + // takes priority and is used verbatim; only the `res` fallback is resolved const url = toString(this.exactArg(args, "url")); const res = toString(this.exactArg(args, "res")); if (!url && !res) { @@ -953,12 +958,14 @@ export class StoryRuntime { } case "skiptothis": { - // Preprocess-only command. Native explicitly permits it to have no - // executor and silently advances when normal playback reaches it. + // Native port: Torappu.AVG.AVGController._PreprocessCommands. This is a + // preprocess-only command with no executor, so normal playback advances return "continue"; } case "background": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImage as registered by + // BackgroundPanel. This covers its clear/load-failure and scaled-fade const image = toString(this.exactArg(args, "image")); const fadeMs = this.calculateFadeMs(this.exactArg(args, "fadetime")); const block = @@ -991,6 +998,7 @@ export class StoryRuntime { } case "backgroundtween": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImageTween. Duration is const duration = this.exactArg(args, "duration"); await this.renderer.setBackgroundTween({ @@ -1012,6 +1020,8 @@ export class StoryRuntime { } case "gridbg": { + // Native port: Torappu.AVG.LargeBackgroundPanel._ExecuteGridBG. This + // validates a 2×2 tile set and keeps fadetime unscaled. const imageGroup = toString(this.exactArg(args, "imagegroup")); const cgGroup = toString(this.exactArg(args, "cggroup")); const groupSelection = resolveGroupedAssetSelection( @@ -1075,6 +1085,8 @@ export class StoryRuntime { } case "verticalbg": { + // Native port: Torappu.AVG.LargeBackgroundPanel._ExecuteVerticalBG. + // It accepts one width and up to four vertically stacked tiles; fadetime const imageGroup = toString(this.exactArg(args, "imagegroup")); const cgGroup = toString(this.exactArg(args, "cggroup")); const groupSelection = resolveGroupedAssetSelection( @@ -1137,6 +1149,9 @@ export class StoryRuntime { } case "largebg": { + // Native port: Torappu.AVG.LargeBackgroundPanel._ExecuteImage (the + // LargeBackgroundPanel method, not AVGImagePanel's namesake). It composes + // two horizontal tiles and keeps fadetime literal. const imageGroup = toString(this.exactArg(args, "imagegroup")); const cgGroup = toString(this.exactArg(args, "cggroup")); const groupSelection = resolveGroupedAssetSelection( @@ -1210,6 +1225,8 @@ export class StoryRuntime { } case "largeimg": { + // Web-only compatibility extension: no native panel registers `largeimg` + // in the documented client; native `image` is its closest equivalent. const imageGroup = toString(this.arg(args, "imagegroup")); const cgGroup = toString(this.arg(args, "cggroup")); const groupSelection = resolveGroupedAssetSelection( @@ -1279,6 +1296,8 @@ export class StoryRuntime { } case "image": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImage. This is the + // foreground-panel registration of the same inherited executor used by const image = toString(this.exactArg(args, "image")); const fadeMs = this.calculateFadeMs(this.exactArg(args, "fadetime")); const block = @@ -1311,7 +1330,8 @@ export class StoryRuntime { } case "showitem": { - // _slotStyles only registers `photo` and `cutin`; `cg` and anything else + // Native port: Torappu.AVG.AVGShowItemPanel._ExecuteShowItem. `_slotStyles` + // only registers `photo` and `cutin`; `cg` and anything else // miss _FindSlotStyle, which logs an error and finishes the command // without rendering. Every shipped sample is the default `photo`. const style = toString(this.exactArg(args, "style"), "photo"); @@ -1341,6 +1361,7 @@ export class StoryRuntime { return "continue"; } + // Native port scope: AVGShowItemPanel._ExecuteShowItem / _ExecuteHideItem. // Neither executor reads `block`: showitem always returns true, and // hideitem returns true only while a slot is actually in use. The photo // slot's serialized defaults are _defaultFadeTime 0.5 and @@ -1374,13 +1395,15 @@ export class StoryRuntime { } case "cgitem": { + // Native port: Torappu.AVG.AVGCgItemPanel._ExecuteShowCgItem. This + // adapter preserves the id-derived slot key and independent transform const image = toString(this.exactArg(args, "image")); const id = toString(this.exactArg(args, "id")); const key = id ? `${image}_${id}` : image; const style = toString(this.exactArg(args, "style"), "cg"); if (style === "blocker") { - // Native uses a scene-resident sprite and deliberately does not put - // this instance in m_slotsInUseDict. No shipped story uses it. + // Native port scope: AVGCgItemPanel._ShowItem's `blocker` branch uses a + // scene-resident sprite and deliberately skips m_slotsInUseDict. No this.warn( "unsupported_command", "cgitem style=blocker uses a native-only scene sprite", @@ -1436,6 +1459,8 @@ export class StoryRuntime { } case "hidecgitem": { + // Native port: Torappu.AVG.AVGCgItemPanel._ExecuteHideCgItem. An omitted + // key clears all slots; a supplied image/id selects one. const image = toString(this.exactArg(args, "image")); const id = toString(this.exactArg(args, "id")); const key = image || id ? (id ? `${image}_${id}` : image) : undefined; @@ -1452,6 +1477,7 @@ export class StoryRuntime { } case "imagetween": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImageTween. Its duration const durationMs = Math.max( 0, toNumber(this.exactArg(args, "duration"), 0) * 1000, @@ -1473,6 +1499,7 @@ export class StoryRuntime { } case "imagerotate": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImageRotate. Unlike the await this.renderer.setImageRotate({ angleDeg: toNumber(this.exactArg(args, "angle"), 0), block: toBoolean(this.exactArg(args, "block"), false), @@ -1484,6 +1511,8 @@ export class StoryRuntime { } case "largebgtween": { + // Native port: Torappu.AVG.LargeBackgroundPanel._ExecuteImageTween. + // It transforms the existing large-background container without loading const durationMs = Math.max( 0, toNumber(this.exactArg(args, "duration"), 0) * 1000, @@ -1506,6 +1535,7 @@ export class StoryRuntime { } case "largeimgtween": { + // Web-only compatibility extension paired with `largeimg`; it has no const xScale = toOptionalNumber(this.arg(args, "xscale")); const yScale = toOptionalNumber(this.arg(args, "yscale")); const x = toOptionalNumber(this.arg(args, "x")); @@ -1530,6 +1560,8 @@ export class StoryRuntime { } case "blocker": { + // Native port: Torappu.AVG.AVGBlockerPanel._ExecuteBlocker. The malformed + // default `defualt` is intentional, and fadetime is scaled. const styleArg = toString(this.exactArg(args, "style"), "defualt"); const style = styleArg === "slider" || styleArg === "verticalslider" @@ -1570,7 +1602,8 @@ export class StoryRuntime { 0, Math.round(toNumber(this.exactArg(args, "fadetime"), 0.4) * 1000), ); - // _ExecuteCurtain zeroes its closure's block field on both the + // Native port: Torappu.AVG.AVGCurtainPanel._ExecuteCurtain. It zeroes its + // closure's block field on both the // zero-fadetime and the direction < 0 paths, so only a real curtain tween // can block -- the same short circuit blocker has. const block = @@ -1608,8 +1641,8 @@ export class StoryRuntime { const rawSlot = toString(args.slot).trim(); const slot = this.parseCharacterSlot(rawSlot); const nameRef = toString(args.name); - // _ExecuteCharslot (0x1839A8460): duration defaults to 0.0 and goes through - // CalculateFadetime; NeedSkipAnimation is MathUtil.IsZero(duration). + // Native port: Torappu.AVG.AVGCharacterslotPanel._ExecuteCharslot. Duration + // defaults to 0.0 and goes through CalculateFadetime; zero skips animation. const durationMs = Math.round(this.calculateFadeMs(args.duration, 0)); const block = toBoolean(args.isblock, false); @@ -1712,8 +1745,9 @@ export class StoryRuntime { const name2Ref = toString(args.name2); const focus = toNumber(args.focus, 0); const durationMs = this.calculateFadeMs(args.fadetime, 0.15); - // _ExecuteCharacter (0x1839C60D0) reads `isblock`, not `block`, and returns - // it raw without registering any tween callback -- returning true would + // Native port: Torappu.AVG.CharacterPanel._ExecuteCharacter. It reads + // `isblock`, not `block`, and returns it raw without registering any tween + // callback -- returning true would // deadlock the queue since nothing ever calls FinishCommand. Scripts only // ever write `block=`, which is not a key this command reads, so the // command is effectively never blocking. Reproduce that, not the deadlock. @@ -1781,6 +1815,8 @@ export class StoryRuntime { } case "characteraction": { + // Native port: Torappu.AVG.CharacterPanel._ExecuteCharacterAction. It uses + // literal fadetime and accepts both legacy block spellings. const type = this.parseCharacterActionType(args.type); if (!type) { this.warn( @@ -1839,10 +1875,10 @@ export class StoryRuntime { const nameRef = toString(args.name).trim(); const resolved = nameRef ? this.resolveCharacterName(nameRef) : null; + // Native port: Torappu.AVG.AVGCharacterCutinPanel._ExecuteCharacterCutin. // Defaults come from the cutin slot's serialized fields // (_defaultFadetime = 0.4, _defaultSlotWidth = 200), not from PRTS's // 0.14 / 150. `block` really is the key here, unlike the rest of the - // character family, which reads `isblock`. const fadeMs = Math.max( 0, Math.round(toNumber(args.fadetime, 0.4) * 1000), @@ -1886,8 +1922,8 @@ export class StoryRuntime { } case "interlude": { - // Native validates with default 0, then constructs CutinParam with - // default -1. This makes an omitted channel different from channel=-1. + // Native port: Torappu.AVG.AVGCharacterCutinPanel._ExecuteInterlude and + // _GenCutinParamWithCommand. Validation uses default 0, while CutinParam const checkedChannel = Math.trunc( toNumber(this.exactArg(args, "channel"), 0), ); @@ -1962,6 +1998,7 @@ export class StoryRuntime { } case "animtext": { + // Native port: Torappu.AVG.AVGDisplayableExecutor._ExecuteAnimatedText. const rawPosition = this.exactArg(args, "pos"); const parsedPosition = parseVector2(rawPosition); if (rawPosition !== undefined && !parsedPosition) @@ -1982,6 +2019,7 @@ export class StoryRuntime { } case "avgdisplay": { + // Native port: Torappu.AVG.AVGDisplayableExecutor._ExecuteAVGDisplayable const optional = (key: string) => toOptionalNumber(this.exactArg(args, key)); const rawStyle = toString(this.exactArg(args, "style")); @@ -2047,6 +2085,8 @@ export class StoryRuntime { } case "playmusic": { + // Native port: Torappu.AVG.CommonExecutors._ExecutePlayMusicCommand. + // Audio calls are asynchronous and do not block the command loop. const key = toString(this.exactArg(args, "key")); if (!key) { this.warn("parse", "playmusic key is empty"); @@ -2078,6 +2118,7 @@ export class StoryRuntime { } case "stopmusic": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteStopMusicCommand. void this.audio.stopMusic( Math.max(0, toNumber(this.exactArg(args, "fadetime"), 0) * 1000), ); @@ -2085,6 +2126,7 @@ export class StoryRuntime { } case "musicvolume": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteMusicVolumeCommand. void this.audio.setMusicVolume( toNumber(this.exactArg(args, "volume"), 1), Math.max(0, toNumber(this.exactArg(args, "fadetime"), 0) * 1000), @@ -2093,6 +2135,8 @@ export class StoryRuntime { } case "playsound": { + // Native port: Torappu.AVG.CommonExecutors._ExecutePlaySoundCommand. + // Channel defaults to key; the call is non-blocking. const key = toString(this.exactArg(args, "key")); if (!key) { this.warn("parse", "playsound key is empty"); @@ -2117,6 +2161,7 @@ export class StoryRuntime { } case "stopsound": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteStopSoundCommand. const key = toString(this.exactArg(args, "key")); void this.audio.stopSound( toString(this.exactArg(args, "channel"), key), @@ -2126,6 +2171,7 @@ export class StoryRuntime { } case "soundvolume": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteSoundVolumeCommand. void this.audio.setSoundVolume( toString(this.exactArg(args, "channel")), toNumber(this.exactArg(args, "volume"), 1), @@ -2135,6 +2181,8 @@ export class StoryRuntime { } case "cameraeffect": { + // Native port: Torappu.AVG.AVGCameraEffect._ExecuteCameraEffect. This + // covers its Grayscale/Colorinverse/Chaos dispatch and scaled fadetime. const effect = toString(this.exactArg(args, "effect")); const block = toBoolean(this.exactArg(args, "block"), false); const keep = toBoolean(this.exactArg(args, "keep"), false); @@ -2172,6 +2220,7 @@ export class StoryRuntime { } case "camerashake": { + // Native port: Torappu.AVG.AVGCameraEffect._ExecuteCameraShake. Duration, await this.renderer.shakeCamera({ block: toBoolean(this.exactArg(args, "block"), false), durationMs: @@ -2193,6 +2242,7 @@ export class StoryRuntime { } case "focusout": { + // Native port: Torappu.AVG.AVGCameraEffect._ExecuteFocusout. Its duration const durationMs = Math.max( 0, toNumber(this.exactArg(args, "duration"), 0) * 1000, @@ -2211,6 +2261,7 @@ export class StoryRuntime { } case "focusparam": { + // Native port: Torappu.AVG.AVGCameraEffect._ExecuteFocusParam. This is a const effect = toString(this.exactArg(args, "effect")); this.renderer.setFocusParam({ blur: toBoolean(this.exactArg(args, "blur"), true), @@ -2223,6 +2274,8 @@ export class StoryRuntime { } case "multiline": { + // Native port: Torappu.AVG.DialogPanel._ExecuteMultiline. Consecutive + // fragments append until `end`; they share the typewriter timing model. const text = line.trailingText; if (!text) { this.renderer.setDialogue("", ""); @@ -2246,6 +2299,8 @@ export class StoryRuntime { } case "subtitle": { + // Native port: Torappu.AVG.SubtitlePanel._ExecuteSubtitle. The script's + // `delay` parameter is ignored; typing speed is global. const text = this.translateText(toString(this.exactArg(args, "text"))); if (!text) { @@ -2276,6 +2331,8 @@ export class StoryRuntime { } case "sticker": { + // Native port: Torappu.AVG.StickerPanel._ExecuteSticker. The state is a + // multi-id slot dictionary, with show/append/hide behavior selected by id const id = toString(this.exactArg(args, "id")); if (!id) { this.warn("parse", "sticker id is empty"); @@ -2327,6 +2384,7 @@ export class StoryRuntime { } case "timersticker": { + // Native port: Torappu.AVG.StickerPanel._ExcuteTimerSticker. This is a await this.renderer.setTimerSticker({ durationMs: Math.max(0, toNumber(this.exactArg(args, "duration"), 1) * 1000) || @@ -2346,6 +2404,8 @@ export class StoryRuntime { } case "timerclear": { + // Native port: Torappu.AVG.StickerPanel._ExcuteTimerClier / AVGTimerView. + // StopTimer hides the reusable slot rather than destroying it. await this.renderer.clearTimerSticker({ durationMs: Math.max( 0, @@ -2356,6 +2416,8 @@ export class StoryRuntime { } case "stickerclear": { + // Native port: Torappu.AVG.StickerPanel._ExcuteClear / _RecycleStickers. + // It clears all sticker slots and also stops the timer-sticker path. this.stickerIds.clear(); void this.renderer.clearStickers(150); void this.renderer.clearTimerSticker({ durationMs: 0 }); @@ -2365,6 +2427,8 @@ export class StoryRuntime { } case "spellsticker": { + // Native port: Torappu.AVG.AVGSpellStickerPanel._ExecuteSpellSticker. + // `block` waits for a click, whose follow-up hides the sticker. const id = toString(this.exactArg(args, "id")); if (!id) return "continue"; const action = toString(this.exactArg(args, "action"), "show"); @@ -2393,6 +2457,7 @@ export class StoryRuntime { } case "spellstickerclear": { + // Native port: Torappu.AVG.AVGSpellStickerPanel._ExecuteSpellStickerClear. await this.renderer.clearSpellStickers(); this.pendingInputEffect = null; return toBoolean(this.exactArg(args, "block"), false) @@ -2401,6 +2466,8 @@ export class StoryRuntime { } case "theater": { + // Native port: Torappu.AVG.AVGTheaterLabel._ExecuteTheaterNode. Web adapts + // the native theater UI mode to autoplay state only. const mode = toBoolean(this.exactArg(args, "mode"), true); if (mode && !this.theaterMode) { this.theaterAutoPlayCache = this.getAutoPlayState(); @@ -2423,6 +2490,8 @@ export class StoryRuntime { } case "decision": { + // Native port: Torappu.AVG.DecisionPanel._ExecuteDecision. Selection writes + // a value for the command-loop predicate gate; it is not a label jump. const autoPlayCache = this.autoPlayMode; this.setAutoPlayMode("default"); this.decisionSelectValue = 0; @@ -2455,6 +2524,8 @@ export class StoryRuntime { } case "predicate": { + // Native port: Torappu.AVG.DecisionPanel._ExecutePredicate. This ghost + // command only replaces the current reference-value gate. const references = this.exactArg(args, "references"); this.decisionReferences = references === undefined diff --git a/tests/runtime.spec.ts b/tests/runtime.spec.ts index 16485d3c..b637a780 100644 --- a/tests/runtime.spec.ts +++ b/tests/runtime.spec.ts @@ -446,7 +446,10 @@ describe("StoryRuntime", () => { it("clears spellstickers when skipping the story", async () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( - createContext(['[spellsticker(id="s",block=true)]x']), + createContext([ + '[spellsticker(id="s",block=true)]x', + '[skipnode(mode="skip")]', + ]), renderer, new FakeAudio(), ); @@ -879,13 +882,13 @@ describe("StoryRuntime", () => { expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "after" }); }); - it("nofirstskip disables story skip for first-read playback", async () => { + it("nofirstskip handles first-read skip by jumping to the next protected anchor", async () => { const sleep = vi.fn(() => new Promise(() => {})); const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[skipnode(mode="nofirstskip")]', "[delay(time=30)]", + '[skipnode(mode="nofirstskip")]', '[showitem(image="avg_npc_1",x=10,y=20)]', '[skipnode(mode="skip")]', '[name="A"]ok', @@ -900,12 +903,46 @@ describe("StoryRuntime", () => { await Promise.resolve(); expect(runtime.getState()).toBe("waiting_timer"); - expect(runtime.canSkipNode()).toBe(false); + expect(runtime.canSkipNode()).toBe(true); expect(sleep).toHaveBeenCalledWith(30_000); await runtime.skipNode(); + await startPromise; + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "ok" }); + }); + + it("does not lose the active process loop while skip cleanup is asynchronous", async () => { + let finishCleanup!: () => void; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const sleep = vi.fn(() => new Promise(() => {})); + const renderer = new FakeRenderer(); + vi.spyOn(renderer, "clearInterludes").mockReturnValue(cleanup); + const runtime = new StoryRuntime( + createContext([ + "[delay(time=30)]", + '[skipnode(mode="nofirstskip")]', + '[name="A"]after', + ]), + renderer, + new FakeAudio(), + { sleep }, + ); + + const startPromise = runtime.start(); + await Promise.resolve(); + const skipPromise = runtime.skipNode(); + await Promise.resolve(); + expect(runtime.getState()).toBe("waiting_timer"); - void startPromise; + finishCleanup(); + await skipPromise; + await startPromise; + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "after" }); }); it("nofirstskip permits skip after the story has been read", async () => { @@ -2647,7 +2684,7 @@ describe("StoryRuntime", () => { ]); }); - it("skipnode does not interrupt dialogue typing", async () => { + it("skipnode can finish the story from a dialogue input wait", async () => { const sleep = vi.fn(() => new Promise(() => {})); const renderer = new FakeRenderer(); const runtime = new StoryRuntime( @@ -2668,23 +2705,72 @@ describe("StoryRuntime", () => { await runtime.start(); expect(runtime.getState()).toBe("waiting_input"); - expect(runtime.canSkipNode()).toBe(false); + expect(runtime.canSkipNode()).toBe(true); expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "" }); await runtime.skipNode(); - expect(runtime.getState()).toBe("waiting_input"); + expect(runtime.getState()).toBe("finished"); expect(runtime.canSkipNode()).toBe(false); - expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "" }); + }); - await runtime.advance(); - expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "hello" }); + it("prioritizes SkipToThis and resumes from the command after its anchor", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[name="A"]before', "[SkipToThis]", '[name="B"]after']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + await runtime.skipNode(); - await runtime.advance(); expect(runtime.getState()).toBe("waiting_input"); - expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "" }); + expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "after" }); + }); - await runtime.advance(); - expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "next" }); + it("disables segment skip when the story has no skip anchors", async () => { + const runtime = new StoryRuntime( + createContext(['[name="A"]before', '[name="B"]after']), + new FakeRenderer(), + new FakeAudio(), + ); + + await runtime.start(); + expect(runtime.canSkipNode()).toBe(false); + await runtime.skipNode(); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("does not expose segment skip during an active command without anchors", async () => { + const sleep = vi.fn(() => new Promise(() => {})); + const runtime = new StoryRuntime( + createContext(["[delay(time=30)]", '[name="A"]unreachable']), + new FakeRenderer(), + new FakeAudio(), + { sleep }, + ); + + const startPromise = runtime.start(); + await Promise.resolve(); + expect(runtime.canSkipNode()).toBe(false); + await runtime.skipNode(); + expect(runtime.getState()).toBe("waiting_timer"); + void startPromise; + }); + + it("does not jump backward after passing SkipToThis", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[SkipToThis]", '[name="A"]after', '[name="B"]later']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + await runtime.skipNode(); + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "after" }); }); }); diff --git a/tests/textVariables.spec.ts b/tests/textVariables.spec.ts new file mode 100644 index 00000000..04d30a8c --- /dev/null +++ b/tests/textVariables.spec.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + expandStoryText, + getStoryNickname, +} from "../src/widgets/StoryPlayer/engine/textVariables"; + +afterEach(() => { + window.mw = undefined; +}); + +describe("story text variables", () => { + it("uses the MediaWiki username and removes Dr. case-insensitively", () => { + window.mw = { config: { get: () => "Dr.Kal'tsit" } }; + + expect(getStoryNickname()).toBe("Kal'tsit"); + expect(expandStoryText("欢迎,{@nickname}。{@nickname}!")).toBe( + "欢迎,Kal'tsit。Kal'tsit!", + ); + expect(expandStoryText("欢迎,{@Nickname}!")).toBe("欢迎,Kal'tsit!"); + }); + + it("falls back to 博士 for anonymous users", () => { + window.mw = { config: { get: () => null } }; + + expect(getStoryNickname()).toBe("博士"); + expect(expandStoryText("Dr.{@nickname}。")).toBe("Dr.博士。"); + }); + + it("resolves story variables case-insensitively and removes unknown ones", () => { + expect(expandStoryText("Ave{@NBS}Mujica")).toBe("Ave\u00A0Mujica"); + expect(expandStoryText("{@answer}/{@missing}", { answer: 42 })).toBe("42/"); + }); +}); From b599fbaee98ed334dc5ed6103ef17e93e70d2e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Mon, 3 Aug 2026 00:07:05 +0800 Subject: [PATCH 6/9] sentry --- src/entries/sentry.ts | 11 +++++++++ src/mw.d.ts | 8 ++++++- src/widgets/StoryPlayer/index.vue | 38 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/entries/sentry.ts b/src/entries/sentry.ts index 58c4d57e..3e49f149 100644 --- a/src/entries/sentry.ts +++ b/src/entries/sentry.ts @@ -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" @@ -12,6 +17,7 @@ Sentry.init({ Sentry.browserTracingIntegration(), Sentry.httpClientIntegration(), Sentry.contextLinesIntegration(), + feedback, ], sampleRate: 0.01, @@ -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([ diff --git a/src/mw.d.ts b/src/mw.d.ts index c22976bc..df80135e 100644 --- a/src/mw.d.ts +++ b/src/mw.d.ts @@ -1,5 +1,11 @@ declare interface Window { RLQ?: any[]; - Sentry: Record; + Sentry?: { + captureException?: (error: unknown) => string; + showFeedback?: ( + tags?: Record, + ) => Promise; + showReportDialog?: (...args: any[]) => void; + }; mw: any; } diff --git a/src/widgets/StoryPlayer/index.vue b/src/widgets/StoryPlayer/index.vue index 47e597a7..65c2b735 100644 --- a/src/widgets/StoryPlayer/index.vue +++ b/src/widgets/StoryPlayer/index.vue @@ -4,6 +4,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"; import { FullscreenExitOutlined as FullscreenExitIcon, FullscreenOutlined as FullscreenIcon, + FeedbackOutlined as FeedbackIcon, SubjectFilled as LogAllIcon, PauseFilled as PauseIcon, PlayArrowFilled as PlayArrowIcon, @@ -165,6 +166,36 @@ function setAutoPlaySpeedLevel(level: number): void { syncState(); } +async function openFeedback(): Promise { + const currentAutoPlay = player?.getAutoPlayState(); + + try { + await window.Sentry?.showFeedback?.({ + widget: "story_player", + story_path: props.path, + player_state: player?.getState() ?? state.value, + auto_play_mode: currentAutoPlay?.mode ?? autoPlayMode.value, + button_speed_level: + currentAutoPlay?.buttonSpeedLevel ?? buttonSpeedLevel.value, + quick_speed_level: + currentAutoPlay?.quickSpeedLevel ?? quickSpeedLevel.value, + current_speed_level: currentSpeedLevel.value, + view_mode: viewMode.value, + displayed_line_index: player?.getDisplayedLineIndex() ?? -1, + decision_select_value: + player?.getDecisionSelectValue() ?? logAllDecisionValue.value, + can_skip_node: player?.canSkipNode() ?? canSkipNode.value, + preload_ready: preloadReady.value, + is_preloading: isPreloading.value, + preload_percent: preloadPercent.value, + fullscreen: isFullscreen.value, + has_script: hasScript.value, + }); + } catch (error) { + console.error("[story-player] opening feedback failed:", error); + } +} + async function initAndPreload(): Promise { if (!hostRef.value) { preloadError.value = "播放器容器未初始化"; @@ -457,6 +488,13 @@ onBeforeUnmount(() => { LOG + + + Feedback + + Date: Mon, 3 Aug 2026 01:21:10 +0800 Subject: [PATCH 7/9] refactor(story-player): use shared TypeScript config --- src/widgets/StoryPlayer/engine/logAll.ts | 2 +- .../engine/rendering/PixiStoryRenderer.ts | 63 ++----------------- src/widgets/StoryPlayer/engine/runtime.ts | 17 ++--- tsconfig.app.json | 7 +-- tsconfig.engine.json | 33 ---------- tsconfig.json | 1 - 6 files changed, 13 insertions(+), 110 deletions(-) delete mode 100644 tsconfig.engine.json diff --git a/src/widgets/StoryPlayer/engine/logAll.ts b/src/widgets/StoryPlayer/engine/logAll.ts index 3040b067..a0704d8d 100644 --- a/src/widgets/StoryPlayer/engine/logAll.ts +++ b/src/widgets/StoryPlayer/engine/logAll.ts @@ -257,7 +257,7 @@ export function buildLogAll( ); const labels = references .map((ref) => labelByValue.get(ref)) - .filter(Boolean); + .filter((label): label is string => label !== undefined); // 在当前 decision 下新建分支段,替换栈顶 target const branch: LogAllBranch = { entries: [], labels, references }; diff --git a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts index 1d1ba5ed..e9a09257 100644 --- a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts +++ b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts @@ -29,7 +29,6 @@ import { LayerGraph } from "./core/LayerGraph"; import { applyCenteredTransform as applyCenteredTransformToRoot, buildGridBackgroundRoot as buildGridRoot, - layoutCover as layoutSpriteCover, readCenteredTransform as readRootTransform, rotateTweenDelta, } from "./core/SceneGeometry"; @@ -161,7 +160,8 @@ export class PixiStoryRenderer implements StoryRenderer { private readonly layers = new LayerGraph(); private readonly backgroundLayer = this.layers.background; private backgroundRoot: Container | null = null; - private backgroundSprite: Sprite | null = null; + /** Current background visual; exposed for renderer diagnostics and tests. */ + backgroundSprite: Sprite | null = null; private backgroundTweenSessionId = 0; private readonly context: Context; private readonly charLayer = this.layers.characters; @@ -239,7 +239,6 @@ export class PixiStoryRenderer implements StoryRenderer { widthPx: number; } | null = null; private subtitleTypingSessionId = 0; - private subtitleRichChars: RichChar[] = []; private readonly stickerRichChars = new Map(); private timerStickerInterval: ReturnType | null = null; private timerStickerText: Text | null = null; @@ -368,7 +367,6 @@ export class PixiStoryRenderer implements StoryRenderer { this.subtitleFadeSessionId += 1; this.subtitleTypingTarget = null; this.subtitleTypingSessionId += 1; - this.subtitleRichChars = []; this.stickerRichChars.clear(); this.videoPanel.destroy(); } @@ -1763,7 +1761,6 @@ export class PixiStoryRenderer implements StoryRenderer { this.subtitleTypingSessionId += 1; this.subtitleFadeSessionId += 1; this.subtitleTypingTarget = null; - this.subtitleRichChars = []; const subtitle = this.subtitleText; if (!subtitle) return; @@ -2181,7 +2178,6 @@ export class PixiStoryRenderer implements StoryRenderer { const prevChars: RichChar[] = []; const newChars = parseRichChars(input.text); const allChars = [...prevChars, ...newChars]; - this.subtitleRichChars = allChars; const colors = collectColors(allChars); const style = this.createOverlayTextStyle(input.sizePx, input.widthPx); @@ -2468,7 +2464,9 @@ export class PixiStoryRenderer implements StoryRenderer { threshold: number, ): Array<{ x: number; y: number }> { const output: Array<{ x: number; y: number }> = []; - let previous = polygon.at(-1); + const last = polygon.at(-1); + if (!last) return output; + let previous = last; let previousInside = this.isCurtainPointInside(previous, vector, threshold); for (const current of polygon) { @@ -3279,49 +3277,6 @@ export class PixiStoryRenderer implements StoryRenderer { tick(); } - private startRotateAction( - state: CharacterRenderState, - input: CharacterActionInput, - ): void { - this.stopRotateAction(state); - if (input.stop) return; - - const intervalMs = Math.max(1, input.durationMs); - const sessionId = state.rotateSessionId; - let nextLeft = true; - let count = 0; - state.rotationDeg = input.rotationFromDeg; - this.updateCharacterState(state); - - const tick = (): void => { - if ( - !this.isActiveCharacterState(state) || - state.rotateSessionId !== sessionId - ) - return; - - state.rotationDeg = nextLeft - ? input.rotationLeftDeg - : input.rotationRightDeg; - nextLeft = !nextLeft; - this.updateCharacterState(state); - - if (input.times >= 0) { - count += 1; - if (count > input.times) { - state.rotateTimeout = null; - state.rotationDeg = 0; - this.updateCharacterState(state); - return; - } - } - - state.rotateTimeout = setTimeout(tick, intervalMs); - }; - - state.rotateTimeout = setTimeout(tick, intervalMs); - } - private isActiveCharacterState( state: CharacterRenderState, transformSessionId?: number, @@ -3562,14 +3517,6 @@ export class PixiStoryRenderer implements StoryRenderer { this.stickerTypingTargets.delete(id); } - private layoutCover( - sprite: Sprite, - x = STORY_WIDTH / 2, - y = STORY_HEIGHT / 2, - ): void { - layoutSpriteCover(sprite, x, y); - } - private layoutImageForScreenAdapt( sprite: Sprite, mode?: BackgroundInput["screenAdapt"], diff --git a/src/widgets/StoryPlayer/engine/runtime.ts b/src/widgets/StoryPlayer/engine/runtime.ts index 2b7a19b1..34c639b6 100644 --- a/src/widgets/StoryPlayer/engine/runtime.ts +++ b/src/widgets/StoryPlayer/engine/runtime.ts @@ -1043,7 +1043,7 @@ export class StoryRuntime { const imageKeys = groupSelection.groupValue .split("/") .map((item) => this.resolveImageKey(item)) - .filter(Boolean); + .filter((key): key is string => key !== null); const solidWidths = parseNumberSequence( this.exactArg(args, "solidwidth"), ).slice(0, 2); @@ -1108,7 +1108,7 @@ export class StoryRuntime { const imageKeys = groupSelection.groupValue .split("/") .map((item) => this.resolveImageKey(item)) - .filter(Boolean); + .filter((key): key is string => key !== null); const solidWidth = toNumber(this.exactArg(args, "solidwidth"), 0); const solidWidths = solidWidth > 0 ? [solidWidth] : []; const solidHeights = parseNumberSequence( @@ -1183,7 +1183,7 @@ export class StoryRuntime { const imageKeys = groupSelection.groupValue .split("/") .map((item) => this.resolveImageKey(item)) - .filter(Boolean); + .filter((key): key is string => key !== null); const solidWidths = parseNumberSequence( this.exactArg(args, "solidwidth"), ).slice(0, 2); @@ -1257,7 +1257,7 @@ export class StoryRuntime { const imageKeys = groupSelection.groupValue .split("/") .map((item) => this.resolveImageKey(item)) - .filter(Boolean); + .filter((key): key is string => key !== null); const solidWidths = parseNumberSequence(this.arg(args, "solidwidth")); const solidHeights = parseNumberSequence(this.arg(args, "solidheight")); @@ -1704,11 +1704,7 @@ export class StoryRuntime { : { circles: Math.trunc(toNumber(args.circles, 0)) }), durationMs, expression: resolved?.expression, - focusMode: this.resolveCharacterSlotFocusMode( - args, - slot, - Boolean(nameRef), - ), + focusMode: this.resolveCharacterSlotFocusMode(args, Boolean(nameRef)), focusSlots: this.resolveCharacterSlotFocusSlots(args), ...(args.inverse === undefined ? {} @@ -2753,7 +2749,6 @@ export class StoryRuntime { private resolveCharacterSlotFocusMode( args: StoryCommandArgs, - slot: string, hasName: boolean, ): CharacterSlotInput["focusMode"] | undefined { const focus = toString(args.focus).trim().toLowerCase(); @@ -2774,7 +2769,7 @@ export class StoryRuntime { const slots = focus .split(",") .map((value) => this.parseCharacterSlot(value)) - .filter(Boolean); + .filter((slot): slot is string => slot !== undefined); return slots.length > 0 ? [...new Set(slots)] : undefined; } diff --git a/tsconfig.app.json b/tsconfig.app.json index 60064647..ca4df618 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -31,10 +31,5 @@ "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts"], - "exclude": [ - "src/widgets/StoryPlayer/engine", - "src/widgets/StoryPlayer/context.ts" - ], - "references": [{ "path": "./tsconfig.engine.json" }] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts"] } diff --git a/tsconfig.engine.json b/tsconfig.engine.json deleted file mode 100644 index 0bf64108..00000000 --- a/tsconfig.engine.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "extends": "@vue/tsconfig/tsconfig.dom.json", - "compilerOptions": { - "composite": true, - "noEmit": false, - "emitDeclarationOnly": true, - "outDir": "./node_modules/.tmp/tsconfig.engine-out", - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.engine.tsbuildinfo", - "types": ["vite/client"], - "lib": ["ESNext", "DOM", "DOM.Iterable"], - - // This directory vendors the framework-agnostic AVG story engine from - // arknights-story-player, authored and verified against TypeScript ~5.9 - // (see its tsconfig + passing `vue-tsc -b`). Under TS 6.0's stricter - // inference the same verified code trips several flags: - // - unused locals/params that are deliberate API surfaces, - // - element-nullability on array `.map()` results (strictNullChecks), - // - a couple of pixi Container runtime methods no longer in the .d.ts. - // The engine is covered by its own upstream test suite, so rather than - // rewrite verified behavior for the stricter compiler we compile it under - // the configuration it was written for. - "strict": false, - "noImplicitThis": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitOverride": false - }, - "include": [ - "src/widgets/StoryPlayer/engine/**/*.ts", - "src/widgets/StoryPlayer/context.ts", - "src/widgets/StoryPlayer/assets.ts" - ] -} diff --git a/tsconfig.json b/tsconfig.json index 924cf475..1ffef600 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,6 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.engine.json" }, { "path": "./tsconfig.node.json" } ] } From 3996f6b080c21389aa3b0511dae7ac95539137d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Mon, 3 Aug 2026 16:04:22 +0800 Subject: [PATCH 8/9] refactor --- eslint.config.js | 31 +------- src/widgets/StoryPlayer/engine/parser.ts | 2 +- src/widgets/StoryPlayer/engine/preload.ts | 5 +- .../engine/rendering/PixiStoryRenderer.ts | 78 ++++++++++--------- .../engine/rendering/core/SceneGeometry.ts | 36 ++++----- .../engine/rendering/core/TweenRunner.ts | 4 +- .../engine/rendering/panels/AnimTextPanel.ts | 14 ++-- .../rendering/panels/AvgDisplayPanel.ts | 3 +- .../engine/rendering/panels/CgItemPanel.ts | 20 ++--- .../engine/rendering/panels/DecisionPanel.ts | 37 ++++++--- .../engine/rendering/panels/DialogPanel.ts | 3 +- .../rendering/panels/FocusEffectPanel.ts | 3 +- .../engine/rendering/panels/InterludePanel.ts | 15 ++-- .../rendering/panels/SpellStickerPanel.ts | 12 +-- src/widgets/StoryPlayer/engine/runtime.ts | 42 +++++----- 15 files changed, 152 insertions(+), 153 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 2ddf4d53..85412982 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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", @@ -367,32 +367,8 @@ const unicorn = [ }, ]; -// Vendored engine: a framework-agnostic TypeScript port of the in-game AVG -// engine (see arknights-story-player). It carries its own verified style and -// is type-checked strictly; only relax cosmetic lint rules that would force a -// behavior-neutral rewrite of the imported code. -const vendoredEngine = { - files: ["src/widgets/StoryPlayer/engine/**/*.ts"], - rules: { - "no-void": "off", - "require-await": "off", - "no-duplicate-imports": "off", - "no-useless-escape": "off", - "no-useless-assignment": "off", - "unicorn/no-for-loop": "off", - "unicorn/no-useless-switch-case": "off", - "unicorn/consistent-function-scoping": "off", - "unicorn/no-array-reduce": "off", - "@typescript-eslint/consistent-type-assertions": "off", - }, -}; - -// Tests for the vendored engine, ported from arknights-story-player. Same -// rationale as vendoredEngine: mock implementations satisfy an async interface -// signature (require-await), fixtures embed game-script data with JSON-style -// double quotes that prettier escapes inside double-quoted strings -// (no-useless-escape), and panels are mocked with side-effect-free stubs. -// Relax only the rules these verified fixtures trip. +// StoryPlayer test mocks implement async interfaces without needing to await, +// and some fixtures embed game-script data containing deliberate escapes. const vendoredEngineTests = { files: ["tests/**/*.spec.ts"], rules: { @@ -420,6 +396,5 @@ export default [ ...prettier, ...unicorn, unocss, - vendoredEngine, vendoredEngineTests, ]; diff --git a/src/widgets/StoryPlayer/engine/parser.ts b/src/widgets/StoryPlayer/engine/parser.ts index e95aa974..5a330cfd 100644 --- a/src/widgets/StoryPlayer/engine/parser.ts +++ b/src/widgets/StoryPlayer/engine/parser.ts @@ -14,7 +14,7 @@ import type { * deliberately retain source case, matching the native parameter dictionary. * */ -const commandRegex = /^\[\s*(?:(.*?)\((.*)\)|(?:([.\|\w]*)|(.*)))\s*\]\s*(.*)/; +const commandRegex = /^\[\s*(?:(.*?)\((.*)\)|(?:([.|\w]*)|(.*)))\s*\]\s*(.*)/; // Param keys are case-sensitive in the native dictionary, so `[Name="X"]` is a // dialog line whose `name` lookup misses -- it renders without a speaker rather // than treating X as one. diff --git a/src/widgets/StoryPlayer/engine/preload.ts b/src/widgets/StoryPlayer/engine/preload.ts index 9b93d9b9..deba32a9 100644 --- a/src/widgets/StoryPlayer/engine/preload.ts +++ b/src/widgets/StoryPlayer/engine/preload.ts @@ -82,9 +82,8 @@ function addScriptImageUrls(urls: Set, context: Context): void { } if (line.command === "avgdisplay") { const rawStyle = argAsString(line.args.style); - const style = - ({ 5: "bg", 7: "character" } as Record)[rawStyle] ?? - rawStyle; + const styleMap: Record = { 5: "bg", 7: "character" }; + const style = styleMap[rawStyle] ?? rawStyle; const name = argAsString(line.args.name); if (style === "bg") addImageUrl(urls, name, true); else if (style === "character") addCharacterUrl(urls, name); diff --git a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts index e9a09257..985f2477 100644 --- a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts +++ b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts @@ -21,9 +21,38 @@ import { collectColors, parseRichChars, richCharsToTaggedText, + type RichChar, } from "../richtext"; import { computeLegacyShowItemLayout } from "../showitem"; -import { STORY_HEIGHT, STORY_WIDTH } from "../types"; +import { + STORY_HEIGHT, + STORY_WIDTH, + type AnimTextInput, + type AvgDisplayInput, + type BackgroundInput, + type BackgroundTweenInput, + type BlockerInput, + type CameraShakeInput, + type CgItemInput, + type CharacterActionInput, + type CharacterCutinInput, + type CharacterSlotInput, + type CurtainInput, + type FocusOutInput, + type FocusParamInput, + type GridBackgroundInput, + type ImageRotateInput, + type ImageTweenInput, + type InterludeInput, + type LargeBackgroundTweenInput, + type ShowItemInput, + type SpellStickerInput, + type StickerInput, + type StoryRenderer, + type SubtitleInput, + type TimerClearInput, + type TimerStickerInput, +} from "../types"; import { LayerGraph } from "./core/LayerGraph"; import { @@ -45,34 +74,6 @@ import { SpellStickerPanel } from "./panels/SpellStickerPanel"; import { VideoPanel } from "./panels/VideoPanel"; import type { Context } from "../../context"; -import type { RichChar } from "../richtext"; -import type { - AnimTextInput, - AvgDisplayInput, - BackgroundInput, - BackgroundTweenInput, - BlockerInput, - CameraShakeInput, - CgItemInput, - CharacterActionInput, - CharacterCutinInput, - CharacterSlotInput, - CurtainInput, - FocusOutInput, - FocusParamInput, - GridBackgroundInput, - ImageRotateInput, - ImageTweenInput, - InterludeInput, - LargeBackgroundTweenInput, - ShowItemInput, - SpellStickerInput, - StickerInput, - StoryRenderer, - SubtitleInput, - TimerClearInput, - TimerStickerInput, -} from "../types"; function isFiniteNumber(value: number | undefined): value is number { return typeof value === "number" && Number.isFinite(value); @@ -439,7 +440,7 @@ export class PixiStoryRenderer implements StoryRenderer { } async showDecision(options: string[], values: number[]): Promise { - return this.decisionPanel.show(options, values); + return await this.decisionPanel.show(options, values); } /** @@ -804,6 +805,7 @@ export class PixiStoryRenderer implements StoryRenderer { state.sessionId += 1; state.sprite.destroy(); this.cutinStates.delete(id); + await Promise.resolve(); } async clearInterludes(): Promise { @@ -943,7 +945,7 @@ export class PixiStoryRenderer implements StoryRenderer { const endLeft = centerX + input.offsetX - halfW; const endTop = -input.offsetY; - let fadeStyle = 0; + let fadeStyle: number; let startLeft = endLeft; let startTop = endTop; @@ -1883,6 +1885,7 @@ export class PixiStoryRenderer implements StoryRenderer { timer.visible = false; }, ); + await Promise.resolve(); } /** @@ -2209,6 +2212,7 @@ export class PixiStoryRenderer implements StoryRenderer { newChars, widthPx: input.widthPx, }); + await Promise.resolve(); } /** @@ -2274,6 +2278,7 @@ export class PixiStoryRenderer implements StoryRenderer { newChars, widthPx: input.widthPx, }); + await Promise.resolve(); } setSpellSticker(input: SpellStickerInput): void { @@ -2317,6 +2322,7 @@ export class PixiStoryRenderer implements StoryRenderer { this.timerStickerText.text = this.formatTimer(remainingSeconds); if (remainingSeconds <= 0) this.clearTimerInterval(); }, 1000); + await Promise.resolve(); } private async createUi(): Promise { @@ -2561,8 +2567,8 @@ export class PixiStoryRenderer implements StoryRenderer { const content = new Container(); visual.addChild(content); - let sourceWidth = 0; - let sourceHeight = 0; + let sourceWidth: number; + let sourceHeight: number; if (item.group === -1 && "image" in item && item.image) { const texture = await this.textureForCharacterKey(item.image); @@ -3613,7 +3619,7 @@ export class PixiStoryRenderer implements StoryRenderer { return null; } - return this.textureForUrl(rawUrl, kind, key); + return await this.textureForUrl(rawUrl, kind, key); } private async textureForCharacterKey(key: string): Promise { @@ -3623,14 +3629,14 @@ export class PixiStoryRenderer implements StoryRenderer { return null; } - return this.textureForUrl(rawUrl, "character", key); + return await this.textureForUrl(rawUrl, "character", key); } private async textureForInterlude( input: InterludeInput, ): Promise { if (input.type !== 3) - return this.textureForImageKey( + return await this.textureForImageKey( input.name, input.type === 2 ? "background" : "image", ); diff --git a/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts b/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts index 7acab7e2..850ab325 100644 --- a/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts +++ b/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts @@ -1,9 +1,10 @@ -import { Container, Sprite } from "pixi.js"; +import { Container, Sprite, type Texture } from "pixi.js"; -import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; - -import type { GridBackgroundInput } from "../../types"; -import type { Texture } from "pixi.js"; +import { + STORY_HEIGHT, + STORY_WIDTH, + type GridBackgroundInput, +} from "../../types"; export interface CenteredTransform { scaleX: number; @@ -182,19 +183,18 @@ export function buildGridBackgroundRoot( })), ); } - const totalWidth = rows.reduce( - (max, row) => - Math.max( - max, - row.reduce((sum, item) => sum + item.width, 0), - ), - 0, - ); - const totalHeight = rows.reduce( - (sum, row) => - sum + row.reduce((max, item) => Math.max(max, item.height), 0), - 0, - ); + let totalWidth = 0; + let totalHeight = 0; + for (const row of rows) { + let rowWidth = 0; + let rowHeight = 0; + for (const item of row) { + rowWidth += item.width; + rowHeight = Math.max(rowHeight, item.height); + } + totalWidth = Math.max(totalWidth, rowWidth); + totalHeight += rowHeight; + } let offsetY = 0; for (const row of rows) { let offsetX = 0; diff --git a/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts b/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts index 1f35a784..5fcefda1 100644 --- a/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts +++ b/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts @@ -1,6 +1,4 @@ -import { browserAnimationClock } from "../../execution"; - -import type { AnimationClock } from "../../execution"; +import { browserAnimationClock, type AnimationClock } from "../../execution"; /** * Web-only animation adapter. AVG executors create DOTween sequences; callers diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts index f5d76158..58e6af19 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts @@ -1,10 +1,14 @@ -import { Assets, Container, Sprite, Text, TextStyle } from "pixi.js"; +import { + Assets, + Container, + Sprite, + Text, + TextStyle, + type Texture, +} from "pixi.js"; import { STAMP_ASSETS } from "../../../assets"; -import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; - -import type { AnimTextInput } from "../../types"; -import type { Texture } from "pixi.js"; +import { STORY_HEIGHT, STORY_WIDTH, type AnimTextInput } from "../../types"; const ANIMATION_MS = 5000; diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts index 1691fd3b..d018d35a 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts @@ -1,7 +1,6 @@ -import { Container, Sprite } from "pixi.js"; +import { Container, Sprite, type Texture } from "pixi.js"; import type { AvgDisplayInput, AvgDisplaySlot } from "../../types"; -import type { Texture } from "pixi.js"; interface AvgDisplayState { name: string; diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts index 1ce6848c..d7a2014e 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts @@ -1,9 +1,6 @@ -import { Container, Sprite } from "pixi.js"; +import { Container, Sprite, type Texture } from "pixi.js"; -import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; - -import type { CgItemInput } from "../../types"; -import type { Texture } from "pixi.js"; +import { STORY_HEIGHT, STORY_WIDTH, type CgItemInput } from "../../types"; type TextureLoader = (key: string) => Promise; type Tween = ( @@ -22,6 +19,10 @@ function lerp(from: number, to: number, progress: number): number { return from + (to - from) * progress; } +function colorChannel(value: number): number { + return Math.max(0, Math.min(255, Math.round(value * 255))); +} + function easeProgress(raw: number, ease: string): number { switch (ease.toLowerCase()) { case "linear": { @@ -33,7 +34,6 @@ function easeProgress(raw: number, ease: string): number { case "inoutquad": { return raw < 0.5 ? 2 * raw * raw : 1 - (-2 * raw + 2) ** 2 / 2; } - case "outquad": default: { return 1 - (1 - raw) * (1 - raw); } @@ -41,9 +41,11 @@ function easeProgress(raw: number, ease: string): number { } function rgb(color: { r: number; g: number; b: number }): number { - const channel = (value: number) => - Math.max(0, Math.min(255, Math.round(value * 255))); - return (channel(color.r) << 16) | (channel(color.g) << 8) | channel(color.b); + return ( + (colorChannel(color.r) << 16) | + (colorChannel(color.g) << 8) | + colorChannel(color.b) + ); } /** diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts index 4e1ed019..615c4583 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts @@ -1,9 +1,26 @@ -import { Container, Graphics, Text, TextStyle } from "pixi.js"; +import { + Container, + Graphics, + Text, + TextStyle, + type Container as ContainerType, +} from "pixi.js"; import { DIALOG_FONT_FAMILY } from "../../font"; import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; -import type { Container as ContainerType } from "pixi.js"; +function paintButton( + background: Graphics, + color: number, + width: number, + height: number, +): void { + background + .clear() + .roundRect(0, 0, width, height, 4) + .fill({ color }) + .stroke({ color: 0xff_ff_ff, width: 2 }); +} /** * Web/PIXI presentation for `Torappu.AVG.DecisionPanel._ExecuteDecision`. @@ -37,13 +54,7 @@ export class DecisionPanel { button.eventMode = "static"; button.cursor = "pointer"; const background = new Graphics(); - const paint = (color: number) => - background - .clear() - .roundRect(0, 0, buttonWidth, buttonHeight, 4) - .fill({ color }) - .stroke({ color: 0xff_ff_ff, width: 2 }); - paint(0x30_30_30); + paintButton(background, 0x30_30_30, buttonWidth, buttonHeight); const label = new Text({ style: new TextStyle({ align: "center", @@ -56,8 +67,12 @@ export class DecisionPanel { label.anchor.set(0.5); label.position.set(buttonWidth / 2, buttonHeight / 2); button.addChild(background, label); - button.on("pointerover", () => paint(0x50_50_50)); - button.on("pointerout", () => paint(0x30_30_30)); + button.on("pointerover", () => + paintButton(background, 0x50_50_50, buttonWidth, buttonHeight), + ); + button.on("pointerout", () => + paintButton(background, 0x30_30_30, buttonWidth, buttonHeight), + ); button.on("pointertap", () => { const resolve = this.resolve; this.clear(); diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts index 1e71a2b8..2fb8a15a 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts @@ -5,14 +5,13 @@ import { Text, TextStyle, Texture, + type Container, } from "pixi.js"; import { DIALOG_FRAME_URL } from "../../../assets"; import { DIALOG_FONT_FAMILY } from "../../font"; import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; -import type { Container } from "pixi.js"; - /** * Web/PIXI reconstruction of the visual surface used by * `Torappu.AVG.DialogPanel._ExecuteDialog`. Command sequencing, typewriter, diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts index 3446155d..54093afe 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts @@ -1,7 +1,6 @@ -import { BlurFilter, ColorMatrixFilter } from "pixi.js"; +import { BlurFilter, ColorMatrixFilter, type Container } from "pixi.js"; import type { FocusOutInput, FocusParamInput } from "../../types"; -import type { Container } from "pixi.js"; type Tween = ( durationMs: number, diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts index 31078b08..f5b56a6b 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts @@ -1,9 +1,13 @@ -import { Container, Graphics, Sprite, Text, TextStyle } from "pixi.js"; +import { + Container, + Graphics, + Sprite, + Text, + TextStyle, + type Texture, +} from "pixi.js"; -import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; - -import type { InterludeInput } from "../../types"; -import type { Texture } from "pixi.js"; +import { STORY_HEIGHT, STORY_WIDTH, type InterludeInput } from "../../types"; type TextureLoader = (input: InterludeInput) => Promise; type Tween = ( @@ -114,6 +118,7 @@ export class InterludePanel { for (const state of this.channels.values()) state.root.destroy({ children: true }); this.channels.clear(); + await Promise.resolve(); } destroy(): void { diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts index 0629b235..00634cad 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts @@ -1,10 +1,12 @@ -import { Container, Text, TextStyle } from "pixi.js"; +import { + Container, + Text, + TextStyle, + type Container as ContainerType, +} from "pixi.js"; import { DIALOG_FONT_FAMILY } from "../../font"; -import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; - -import type { SpellStickerInput } from "../../types"; -import type { Container as ContainerType } from "pixi.js"; +import { STORY_HEIGHT, STORY_WIDTH, type SpellStickerInput } from "../../types"; interface SpellStickerView { root: Container; diff --git a/src/widgets/StoryPlayer/engine/runtime.ts b/src/widgets/StoryPlayer/engine/runtime.ts index 34c639b6..f928afa1 100644 --- a/src/widgets/StoryPlayer/engine/runtime.ts +++ b/src/widgets/StoryPlayer/engine/runtime.ts @@ -6,11 +6,11 @@ import { collectColors, parseRichChars, richCharsToTaggedText, + type RichChar, } from "./richtext"; import { expandStoryText } from "./textVariables"; import type { Context } from "../context"; -import type { RichChar } from "./richtext"; import type { AutoPlayMode, AutoPlayState, @@ -272,8 +272,7 @@ function preprocessSkipNodes( ): SkipNodeLabel[] { const labels: SkipNodeLabel[] = []; - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; + for (const [index, line] of lines.entries()) { if (line.kind !== "command" || line.command !== "skipnode") continue; // Native port: Torappu.AVG.AVGStoryCache.CalSkipMode. Only the exact @@ -757,7 +756,7 @@ export class StoryRuntime { } private async waitInterruptible(ms: number): Promise { - return this.waitInterruptiblePromise(this.sleep(Math.max(0, ms))); + return await this.waitInterruptiblePromise(this.sleep(Math.max(0, ms))); } private resolveCharacterName( @@ -2020,25 +2019,22 @@ export class StoryRuntime { toOptionalNumber(this.exactArg(args, key)); const rawStyle = toString(this.exactArg(args, "style")); const rawSlot = toString(this.exactArg(args, "slot")); - const style = - ( - { - 1: "animetext", - 2: "spine", - 3: "effect", - 4: "bgeffect", - 5: "bg", - 6: "animekv", - 7: "character", - } as Record - )[rawStyle] ?? rawStyle; - const slot = - ( - { 1: "bgover", 2: "charover", 3: "cgover" } as Record< - string, - string - > - )[rawSlot] ?? rawSlot; + const styleMap: Record = { + 1: "animetext", + 2: "spine", + 3: "effect", + 4: "bgeffect", + 5: "bg", + 6: "animekv", + 7: "character", + }; + const slotMap: Record = { + 1: "bgover", + 2: "charover", + 3: "cgover", + }; + const style = styleMap[rawStyle] ?? rawStyle; + const slot = slotMap[rawSlot] ?? rawSlot; // All three avgdisplay features read the same `duration` key and each // scales it through CalculateFadetime. const durationMs = this.calculateFadeMs( From 5454b00b2194d5060e3e7cb7f921c8dec4beda26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=95=E8=88=9E=E5=85=AB=E5=BC=A6?= <1677759063@qq.com> Date: Mon, 3 Aug 2026 16:21:15 +0800 Subject: [PATCH 9/9] refactor(story-player): use shared ESLint rules --- eslint.config.js | 16 +---- .../engine/rendering/PixiStoryRenderer.ts | 13 ++-- .../engine/rendering/panels/InterludePanel.ts | 1 - src/widgets/StoryPlayer/engine/runtime.ts | 2 +- tests/cgItemPanel.spec.ts | 30 +++++--- tests/interlude.spec.ts | 23 +++--- tests/runtime.spec.ts | 71 +++++++++---------- tests/spellStickerPanel.spec.ts | 4 +- 8 files changed, 74 insertions(+), 86 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 85412982..5078c577 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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": [ @@ -367,19 +367,6 @@ const unicorn = [ }, ]; -// StoryPlayer test mocks implement async interfaces without needing to await, -// and some fixtures embed game-script data containing deliberate escapes. -const vendoredEngineTests = { - files: ["tests/**/*.spec.ts"], - rules: { - "no-void": "off", - "require-await": "off", - "no-duplicate-imports": "off", - "no-useless-escape": "off", - "unicorn/consistent-function-scoping": "off", - }, -}; - const ignores = [ gitignore(), { @@ -396,5 +383,4 @@ export default [ ...prettier, ...unicorn, unocss, - vendoredEngineTests, ]; diff --git a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts index 985f2477..acfa9bfb 100644 --- a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts +++ b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts @@ -440,7 +440,7 @@ export class PixiStoryRenderer implements StoryRenderer { } async showDecision(options: string[], values: number[]): Promise { - return await this.decisionPanel.show(options, values); + return this.decisionPanel.show(options, values); } /** @@ -805,7 +805,6 @@ export class PixiStoryRenderer implements StoryRenderer { state.sessionId += 1; state.sprite.destroy(); this.cutinStates.delete(id); - await Promise.resolve(); } async clearInterludes(): Promise { @@ -1885,7 +1884,6 @@ export class PixiStoryRenderer implements StoryRenderer { timer.visible = false; }, ); - await Promise.resolve(); } /** @@ -2212,7 +2210,6 @@ export class PixiStoryRenderer implements StoryRenderer { newChars, widthPx: input.widthPx, }); - await Promise.resolve(); } /** @@ -2278,7 +2275,6 @@ export class PixiStoryRenderer implements StoryRenderer { newChars, widthPx: input.widthPx, }); - await Promise.resolve(); } setSpellSticker(input: SpellStickerInput): void { @@ -2322,7 +2318,6 @@ export class PixiStoryRenderer implements StoryRenderer { this.timerStickerText.text = this.formatTimer(remainingSeconds); if (remainingSeconds <= 0) this.clearTimerInterval(); }, 1000); - await Promise.resolve(); } private async createUi(): Promise { @@ -3619,7 +3614,7 @@ export class PixiStoryRenderer implements StoryRenderer { return null; } - return await this.textureForUrl(rawUrl, kind, key); + return this.textureForUrl(rawUrl, kind, key); } private async textureForCharacterKey(key: string): Promise { @@ -3629,14 +3624,14 @@ export class PixiStoryRenderer implements StoryRenderer { return null; } - return await this.textureForUrl(rawUrl, "character", key); + return this.textureForUrl(rawUrl, "character", key); } private async textureForInterlude( input: InterludeInput, ): Promise { if (input.type !== 3) - return await this.textureForImageKey( + return this.textureForImageKey( input.name, input.type === 2 ? "background" : "image", ); diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts index f5b56a6b..70087344 100644 --- a/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts +++ b/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts @@ -118,7 +118,6 @@ export class InterludePanel { for (const state of this.channels.values()) state.root.destroy({ children: true }); this.channels.clear(); - await Promise.resolve(); } destroy(): void { diff --git a/src/widgets/StoryPlayer/engine/runtime.ts b/src/widgets/StoryPlayer/engine/runtime.ts index f928afa1..096615c5 100644 --- a/src/widgets/StoryPlayer/engine/runtime.ts +++ b/src/widgets/StoryPlayer/engine/runtime.ts @@ -756,7 +756,7 @@ export class StoryRuntime { } private async waitInterruptible(ms: number): Promise { - return await this.waitInterruptiblePromise(this.sleep(Math.max(0, ms))); + return this.waitInterruptiblePromise(this.sleep(Math.max(0, ms))); } private resolveCharacterName( diff --git a/tests/cgItemPanel.spec.ts b/tests/cgItemPanel.spec.ts index 2c28d535..3db23086 100644 --- a/tests/cgItemPanel.spec.ts +++ b/tests/cgItemPanel.spec.ts @@ -30,19 +30,23 @@ function input(key: string, overrides: Partial = {}): CgItemInput { }; } -describe("CgItemPanel", () => { - const tween = async ( - _duration: number, - update: (progress: number) => void, - complete?: () => void, - ) => { - update(1); - complete?.(); - }; +async function tweenImmediately( + _duration: number, + update: (progress: number) => void, + complete?: () => void, +): Promise { + update(1); + complete?.(); +} +describe("CgItemPanel", () => { it("keeps different keys and replaces an equal key in place", async () => { const layer = new Container(); - const panel = new CgItemPanel(layer, async () => Texture.WHITE, tween); + const panel = new CgItemPanel( + layer, + async () => Texture.WHITE, + tweenImmediately, + ); await panel.show(input("a")); const oldA = panel.targets("a")[0]; @@ -62,7 +66,11 @@ describe("CgItemPanel", () => { it("hides one key or clears all keys independently of block", async () => { const layer = new Container(); - const panel = new CgItemPanel(layer, async () => Texture.WHITE, tween); + const panel = new CgItemPanel( + layer, + async () => Texture.WHITE, + tweenImmediately, + ); await panel.show(input("a")); await panel.show(input("b")); diff --git a/tests/interlude.spec.ts b/tests/interlude.spec.ts index a2f7088c..e82b4d05 100644 --- a/tests/interlude.spec.ts +++ b/tests/interlude.spec.ts @@ -32,6 +32,15 @@ function input(overrides: Partial = {}): InterludeInput { }; } +async function tweenImmediately( + _duration: number, + update: (progress: number) => void, + complete?: () => void, +): Promise { + update(1); + complete?.(); +} + describe("InterludePanel", () => { it("reuses a channel, adds an element, toggles it, and clears the channel", async () => { const layer = new Container(); @@ -65,15 +74,11 @@ describe("InterludePanel", () => { it("clears every channel when channel is negative", async () => { const layer = new Container(); - const tween = async ( - _duration: number, - update: (progress: number) => void, - complete?: () => void, - ): Promise => { - update(1); - complete?.(); - }; - const panel = new InterludePanel(layer, async () => Texture.EMPTY, tween); + const panel = new InterludePanel( + layer, + async () => Texture.EMPTY, + tweenImmediately, + ); await panel.run(input({ channel: 3 })); await panel.run(input({ channel: 4 })); diff --git a/tests/runtime.spec.ts b/tests/runtime.spec.ts index b637a780..2dd9f060 100644 --- a/tests/runtime.spec.ts +++ b/tests/runtime.spec.ts @@ -1082,8 +1082,8 @@ describe("StoryRuntime", () => { const sleep = vi.fn(async () => {}); const runtime = new StoryRuntime( createContext([ - '[character(name=\"avg_npc_1\",block=true,fadetime=0.2,enter=\"left\",blackstart=0.2,blackend=0.8)]', - '[name=\"A\"]ok', + '[character(name="avg_npc_1",block=true,fadetime=0.2,enter="left",blackstart=0.2,blackend=0.8)]', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1120,8 +1120,8 @@ describe("StoryRuntime", () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[character(name=\"avg_npc_1\",name2=\"avg_npc_1#1\",focus=1)]', - '[name=\"A\"]ok', + '[character(name="avg_npc_1",name2="avg_npc_1#1",focus=1)]', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1288,8 +1288,8 @@ describe("StoryRuntime", () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[charslot(slot=\"m\",name=\"avg_1012_skadiSP_1#2\")]', - '[name=\"A\"]ok', + '[charslot(slot="m",name="avg_1012_skadiSP_1#2")]', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1333,8 +1333,8 @@ describe("StoryRuntime", () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[charslot(slot=\"left\",name=\"avg_npc_1\",duration=0.4,isblock=true)]', - '[name=\"A\"]ok', + '[charslot(slot="left",name="avg_npc_1",duration=0.4,isblock=true)]', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1355,9 +1355,9 @@ describe("StoryRuntime", () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[charslot(slot=\"m\",name=\"avg_npc_1\")]', - '[charslot(slot=\"middle\",focus=\"none\",posto=\"10,20\",duration=0.2)]', - '[name=\"A\"]ok', + '[charslot(slot="m",name="avg_npc_1")]', + '[charslot(slot="middle",focus="none",posto="10,20",duration=0.2)]', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1378,10 +1378,7 @@ describe("StoryRuntime", () => { it("clears all characters with charslot duration when slot is omitted", async () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( - createContext([ - "[charslot(duration=0.5,isblock=true)]", - '[name=\"A\"]ok', - ]), + createContext(["[charslot(duration=0.5,isblock=true)]", '[name="A"]ok']), renderer, new FakeAudio(), ); @@ -1400,7 +1397,7 @@ describe("StoryRuntime", () => { resolveClear = resolve; }); const runtime = new StoryRuntime( - createContext(["[charslot(duration=0.5)]", '[name=\"A\"]ok']), + createContext(["[charslot(duration=0.5)]", '[name="A"]ok']), renderer, new FakeAudio(), ); @@ -1421,7 +1418,7 @@ describe("StoryRuntime", () => { createContext([ "[camerashake(xstrength=12,ystrength=8)]", "[camerashake(duration=0.5,randomness=40,vibrato=18,block=false,stop=true)]", - '[name=\"A\"]ok', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1583,7 +1580,7 @@ describe("StoryRuntime", () => { createContext([ "[curtain(direction=0,fillfrom=0.01,fillto=0.2,fadetime=1.5,isblock=true)]", "[curtain(direction=4,fillto=0.08,a=0.5,fadetime=0.25,block=true)]", - '[name=\"A\"]ok', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1623,7 +1620,7 @@ describe("StoryRuntime", () => { const runtime = new StoryRuntime( createContext([ "[curtain(direction=4,fillfrom=0.18,fillto=0.18,afrom=0,ato=1,fadetime=0.1,block=true)]", - '[name=\"A\"]ok', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1649,7 +1646,7 @@ describe("StoryRuntime", () => { it("clears all curtains when direction is omitted", async () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( - createContext(["[curtain(fadetime=0.3,block=true)]", '[name=\"A\"]ok']), + createContext(["[curtain(fadetime=0.3,block=true)]", '[name="A"]ok']), renderer, new FakeAudio(), ); @@ -1961,8 +1958,8 @@ describe("StoryRuntime", () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[characteraction(name=\"left\",type=\"move\",xpos=-200,ypos=60,fadetime=0.1,isblock=true)]', - '[name=\"A\"]ok', + '[characteraction(name="left",type="move",xpos=-200,ypos=60,fadetime=0.1,isblock=true)]', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -1997,10 +1994,10 @@ describe("StoryRuntime", () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[characteraction(name=\"middle\",type=\"rotate\",duration=0.5,start=3,leftend=20,rightend=10,times=-1,stop=false)]', - '[characteraction(name=\"char_right\",type=\"zoom\",scale=1.2,yscale=0.8,block=true)]', - '[characteraction(name=\"r\",type=\"exit\",direction=\"left\",block=false)]', - '[name=\"A\"]ok', + '[characteraction(name="middle",type="rotate",duration=0.5,start=3,leftend=20,rightend=10,times=-1,stop=false)]', + '[characteraction(name="char_right",type="zoom",scale=1.2,yscale=0.8,block=true)]', + '[characteraction(name="r",type="exit",direction="left",block=false)]', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -2054,9 +2051,9 @@ describe("StoryRuntime", () => { const runtime = new StoryRuntime( createContext([ "[imagerotate(angle=-5,fadetime=0.1,block=true)]", - '[imagerotate(angle=0,fadetime=10,isblock=false,image=\"70_i11\")]', + '[imagerotate(angle=0,fadetime=10,isblock=false,image="70_i11")]', "[imagerotate]", - '[name=\"A\"]ok', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -2311,8 +2308,8 @@ describe("StoryRuntime", () => { const runtime = new StoryRuntime( createContext([ "[musicvolume(volume=0.25,fadetime=1.5)]", - '[soundvolume(channel=\"b\",volume=0.75,fadetime=0.5)]', - '[name=\"A\"]ok', + '[soundvolume(channel="b",volume=0.75,fadetime=0.5)]', + '[name="A"]ok', ]), new FakeRenderer(), audio, @@ -2381,9 +2378,9 @@ describe("StoryRuntime", () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[subtitle(text=\"HELLO\",alignment=\"center\",size=24,width=400,x=100,y=200,delay=0.1,fadetime=0.2,multi=true)]', + '[subtitle(text="HELLO",alignment="center",size=24,width=400,x=100,y=200,delay=0.1,fadetime=0.2,multi=true)]', "[subtitle(fadetime=0.3)]", - '[name=\"A\"]ok', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -2417,12 +2414,12 @@ describe("StoryRuntime", () => { const sleep = vi.fn(async () => {}); const runtime = new StoryRuntime( createContext([ - '[sticker(id=\"tip\",text=\"LEFT\",alignment=\"left\",size=20,width=200,x=40,y=60,delay=0.05,multi=true,fadetime=0.1)]', - '[sticker(id=\"tip\",fadetime=0.2)]', + '[sticker(id="tip",text="LEFT",alignment="left",size=20,width=200,x=40,y=60,delay=0.05,multi=true,fadetime=0.1)]', + '[sticker(id="tip",fadetime=0.2)]', "[timersticker(x=30,y=90,size=24,time=10)]", "[timerclear(afrom=0.8,ato=0.2,duration=0.5)]", "[stickerclear]", - '[name=\"A\"]ok', + '[name="A"]ok', ]), renderer, new FakeAudio(), @@ -2480,8 +2477,8 @@ describe("StoryRuntime", () => { const renderer = new FakeRenderer(); const runtime = new StoryRuntime( createContext([ - '[subtitle(text=\"HELLO\",alignment=\"center\")]', - '[name=\"A\"]ok', + '[subtitle(text="HELLO",alignment="center")]', + '[name="A"]ok', ]), renderer, new FakeAudio(), diff --git a/tests/spellStickerPanel.spec.ts b/tests/spellStickerPanel.spec.ts index 8e3087b2..b3bfa4fe 100644 --- a/tests/spellStickerPanel.spec.ts +++ b/tests/spellStickerPanel.spec.ts @@ -1,10 +1,8 @@ -import { Container } from "pixi.js"; +import { Container, type Text } from "pixi.js"; import { describe, expect, it, vi } from "vitest"; import { SpellStickerPanel } from "../src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel"; -import type { Text } from "pixi.js"; - describe("SpellStickerPanel", () => { it("renders the two native text slots in centered coordinates and hides without removing", () => { const layer = new Container();