Skip to content
Merged

4.6.5 #1038

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
759bfaa
chore: 移除构建产物中的 gz 压缩文件
A-kirami Aug 14, 2026
a006b53
fix(parser): 资源与子场景改为就地累积,消除 Θ(资源数²)
A-kirami Sep 12, 2026
e780672
Merge pull request #1031 from A-kirami/fix/parser-asset-accumulation-…
MakinoharaShoko Sep 13, 2026
7469e0f
feat(parser): assetsPrefetcher 可选,省略时跳过资源收集
A-kirami Sep 12, 2026
3a87cbd
Merge pull request #1032 from A-kirami/feat/parser-optional-asset-pre…
MakinoharaShoko Sep 13, 2026
5f2f943
Merge pull request #1029 from A-kirami/chore/remove-gz-artifacts
MakinoharaShoko Sep 15, 2026
a491d38
补充优化注释
MakinoharaShoko Sep 15, 2026
14ffc6a
feat: use transformFrom arg
MakinoharaShoko Sep 16, 2026
ed9e94e
fix: 删除一些在改造内核后不需要的 setTimeout
MakinoharaShoko Sep 18, 2026
667c886
refactor: 资源管理、加载机制
MakinoharaShoko Sep 18, 2026
75cbaf0
添加 changeFigureDiff 功能
MakinoharaShoko Sep 19, 2026
89a2c17
update parser
MakinoharaShoko Sep 19, 2026
0c0b60f
fix(webgal): 预览基线的有效期绑定编辑指针
A-kirami Sep 21, 2026
4fcbaed
fix: changeFigureDiff 机制优化
MakinoharaShoko Sep 23, 2026
868653e
Merge pull request #1033 from OpenWebGAL/add-transform-from-arg
MakinoharaShoko Sep 23, 2026
8e088a6
Merge pull request #1034 from OpenWebGAL/fix-settimeout
MakinoharaShoko Sep 23, 2026
d586a7c
Merge branch 'dev' into pr/1037
MakinoharaShoko Sep 23, 2026
f2b31dc
Merge pull request #1037 from A-kirami/fix/editor-preview-baseline-li…
MakinoharaShoko Sep 23, 2026
b4e42f1
fix: 渐显跳变问题、concat 和 wait 一起使用是错误提前展示文本问题
MakinoharaShoko Sep 24, 2026
1836062
fix: 使用 -concat 时的渐显问题
MakinoharaShoko Sep 24, 2026
634dcdf
fix: 修复自动播放误推进与 concat 文本分段和恢复
MakinoharaShoko Sep 24, 2026
c31dcaf
fix: 缩短 notend 尾部渐显并取消模拟嘴型等待
MakinoharaShoko Sep 24, 2026
c51a8c3
fix: 修复文字速度预览刷新与渐显被剧情打断
MakinoharaShoko Sep 24, 2026
c27f419
fix: 恢复 notend 模拟嘴型并分离对话状态与演出重建
MakinoharaShoko Sep 24, 2026
1391d45
v4.6.5
MakinoharaShoko Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions dev-docs/立绘差分切换.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 立绘差分切换

```text
changeFigure:normal.png -id=hero;
changeFigureDiff:smile.png -id=hero;
```

`changeFigure` 是换立绘,`changeFigureDiff` 是同一立绘的差分替换(如换表情)。省略 `-id` 时默认操作中间立绘,也可使用 `-left` 等位置参数。

差分只改状态表中的图片地址,立绘的其余状态全部保留:变换、层级(zIndex)与混合模式、入退场设置,以及舞台上的遮挡顺序。旧图不算退场,不播放自定义退场;这些设置留给之后真正换走立绘的 `changeFigure`。

口型、眨眼图是与表情配套的整张图,差分后旧图不再适用。因此 `changeFigureDiff` 接受 `-mouthOpen`、`-mouthClose`、`-mouthHalfOpen`、`-eyesOpen`、`-eyesClose`、`-animationFlag`,并按本句参数整体替换,未写的即为不启用。仍在进行的语音口型、眨眼若使用旧表情的图,舞台会丢弃这些换图。

```text
changeFigureDiff:smile.png -id=hero -mouthOpen=smile_open.png -mouthClose=smile.png;
```

差分优先于口型眨眼:混合从 Sprite 实际显示的图开始(可能是闭嘴图,闭嘴图常与原图相同),混合期间暂停口型眨眼换图,结束后显示新差分的原图。

```text
changeFigure:normal.png -id=hero -exit=exitSlide;
changeFigureDiff:smile.png -id=hero; // 差分,不播放 exitSlide
changeFigure:other.png -id=hero; // smile.png 以 exitSlide 退场
```

除上述口型眨眼参数外,只接受定位与流程参数(`-id`、位置、`-next`、`-continue`、`-when`),其余参数会被忽略并给出警告;需要同时改变换、动画或模型参数时使用 `changeFigure`。没有可替换的立绘、或内容为 `none` 时不存在差分,按 `changeFigure` 处理。

Live2D 与 Spine 使用各自内置的表情、动作系统,不适用差分:源立绘或目标是模型时,本句不执行。

差分演出命名为 `animation-<key>#diff`:登记时不会去重掉同目标已有的动画演出(先 `setAnimation` 再差分时,混合与动作同时进行);之后同目标的非并行 `setTransform`、`setAnimation`、`changeFigure` 会接管并移除它,同时差分标记被清除,上屏走回退,淡入也随之取消(旧图渐出,新图直接执行动作)。混合期间对象被锁定;对象正被其他动画锁定时同样走回退。

舞台提交时,新指令留下的临时标记(不进入存档;同一次提交内连续差分以最初的源图为起点)与实际上屏的源图和目标 URL 匹配、基准位置未变,两张静态图片的宽高及像素密度完全一致,且两者 GPU 资源已就绪时,复用容器,以 200 ms 的互补 RGBA 混合过渡。

资源未就绪、尺寸不同、跳过动画、标记过期等情况,退回淡出淡入:旧图以默认淡出退场,新对象接替旧对象的遮挡顺序,保持已有变换,仅透明度淡入。不会等待资源来争取混合,也不会为尺寸不同的图片重新计算混合几何。

标记由 `WebGAL.figureDiffManager` 实例管理,只记录源 URL 和目标 URL:脚本执行时 `mark`,舞台替换时 `consume`,每次提交结束时 `clear`。后续修改目标的变换或动画设置、丢弃未提交演出、重建舞台状态时也会清除相应标记,不保存状态快照或演出引用。混合或淡入动画注册到现有 Pixi 动画管理,由差分演出负责结束。

状态表只保存差分后的图片地址,没有额外差分字段;读档、回溯和快速预览不依赖标记来恢复图片。
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@webgal/base",
"version": "4.6.4",
"version": "4.6.5",
"description": "A brand new web Visual Novel engine.",
"repository": "https://github.com/OpenWebGAL/WebGAL.git",
"author": "Mahiru <Mahiru_@outlook.com>",
Expand Down
9 changes: 9 additions & 0 deletions packages/parser/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# WebGAL Parser Changelog

## [4.6.5] - 2026-09-24

### Added
- 新增 `changeFigureDiff` 命令(切换立绘差分),语句内容按立绘路径解析,并参与资源扫描
- `SceneParser` 的 `assetsPrefetcher` 改为可选:省略时跳过资源收集,`sentenceAssets`、`subScene`、`assetsList`、`subSceneList` 均为空数组,适用于只需要语句结构的调用方(如编辑器语法解析)

### Fixed
- 资源与子场景列表改为就地累积,消除资源密集的长场景解析时 Θ(资源数²) 的开销

## [4.6.3] - 2026-08-01

正式发布 4.6.3-beta.1 / beta.2 的多行语句支持,另含以下改动。
Expand Down
2 changes: 1 addition & 1 deletion packages/parser/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "webgal-parser",
"version": "4.6.3",
"version": "4.6.5",
"description": "WebGAL script parser",
"scripts": {
"test": "vitest",
Expand Down
1 change: 1 addition & 0 deletions packages/parser/src/config/scriptConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const SCRIPT_CONFIG = [
{ scriptString: 'say', scriptType: commandType.say },
{ scriptString: 'changeBg', scriptType: commandType.changeBg },
{ scriptString: 'changeFigure', scriptType: commandType.changeFigure },
{ scriptString: 'changeFigureDiff', scriptType: commandType.changeFigureDiff },
{ scriptString: 'bgm', scriptType: commandType.bgm },
{ scriptString: 'playVideo', scriptType: commandType.video },
{ scriptString: 'pixiPerform', scriptType: commandType.pixi },
Expand Down
6 changes: 5 additions & 1 deletion packages/parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ import {

export default class SceneParser {
private readonly SCRIPT_CONFIG_MAP: ConfigMap;
/**
* @param assetsPrefetcher 资源预取回调。省略时不收集资源:`sentenceAssets`、`subScene`、
* `assetsList`、`subSceneList` 均为空数组,适用于只需要语句结构的调用方(编辑器语法解析等)。
*/
constructor(
private readonly assetsPrefetcher: (assetList: IAsset[]) => void,
private readonly assetsPrefetcher: ((assetList: IAsset[]) => void) | undefined,
private readonly assetSetter: (
fileName: string,
assetType: fileType,
Expand Down
9 changes: 5 additions & 4 deletions packages/parser/src/interface/sceneInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export enum commandType {
wait,
callSteam, // 调用Steam功能
return, // 从被调用的场景返回
changeFigureDiff, // 切换等尺寸静态立绘差分;追加以保持旧指令编号
}

/**
Expand Down Expand Up @@ -72,8 +73,8 @@ export interface ISentence {
commandRaw: string; // 命令的原始内容,方便调试
content: string; // 语句内容
args: Array<arg>; // 参数列表
sentenceAssets: Array<IAsset>; // 语句携带的资源列表
subScene: Array<string>; // 语句包含子场景列表
sentenceAssets: Array<IAsset>; // 语句携带的资源列表;SceneParser 未提供 assetsPrefetcher 时为空
subScene: Array<string>; // 语句包含子场景列表;未提供 assetsPrefetcher 时为空
inlineComment: string; // 行内注释
/**
* 本语句在原始场景文件中占据的行范围(0-based,含首尾)。
Expand All @@ -94,8 +95,8 @@ export interface IScene {
sceneName: string; // 场景名称
sceneUrl: string; // 场景url
sentenceList: Array<ISentence>; // 语句列表
assetsList: Array<IAsset>; // 资源列表
subSceneList: Array<string>; // 子场景的url列表
assetsList: Array<IAsset>; // 资源列表;未提供 assetsPrefetcher 时为空
subSceneList: Array<string>; // 子场景的url列表;未提供 assetsPrefetcher 时为空
}

/**
Expand Down
30 changes: 23 additions & 7 deletions packages/parser/src/sceneParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ export const sceneParser = (
rawScene: string,
sceneName: string,
sceneUrl: string,
assetsPrefetcher: (assetList: Array<IAsset>) => void,
assetsPrefetcher: ((assetList: Array<IAsset>) => void) | undefined,
assetSetter: (fileName: string, assetType: fileType) => string,
ADD_NEXT_ARG_LIST: commandType[],
SCRIPT_CONFIG_MAP: ConfigMap,
): IScene => {
// 没有预取回调时不需要场景级资源列表,连同每条语句的资源扫描一起跳过
const collectAssets = assetsPrefetcher !== undefined;

// 预处理把多行语句折叠进它的首行,并用占位行补齐被折叠掉的行,
// 因此这里的行数与原始场景严格一致,「语句 index == 文件行号」始终成立。
const rawSentenceList = sceneTextPreProcess(rawScene).split('\n'); // 原始句子列表
Expand All @@ -50,19 +53,32 @@ export const sceneParser = (
ADD_NEXT_ARG_LIST,
SCRIPT_CONFIG_MAP,
index,
collectAssets,
);
// 在这里解析出语句可能携带的资源和场景,合并到 assetsList 和 subSceneList
assetsList = [...assetsList, ...returnSentence.sentenceAssets];
subSceneList = [...subSceneList, ...returnSentence.subScene];
if (!collectAssets) {
return returnSentence;
}
// 在这里解析出语句可能携带的资源和场景,合并到 assetsList 和 subSceneList。
// 必须就地追加:如果写成 [...assetsList, ...sentenceAssets],每条语句都要复制
// 一次已累积的列表,整体退化成 Θ(资源数²)——资源密集的场景脚本(几乎每条语句
// 都带 bg / figure / vocal)在数千条语句规模下会明显变慢。
for (const asset of returnSentence.sentenceAssets) {
assetsList.push(asset);
}
for (const subScene of returnSentence.subScene) {
subSceneList.push(subScene);
}
return returnSentence;
},
);

markMultilineRanges(sentenceList, rawSentenceList);

// 开始资源的预加载
assetsList = deduplicateAssets(assetsList);
assetsPrefetcher(assetsList);
if (collectAssets) {
// 开始资源的预加载
assetsList = deduplicateAssets(assetsList);
assetsPrefetcher(assetsList);
}

return {
sceneName: sceneName, // 场景名称
Expand Down
2 changes: 1 addition & 1 deletion packages/parser/src/scriptParser/assetsScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const assetsScanner = (
type: fileType.background,
});
}
if (command === commandType.changeFigure) {
if (command === commandType.changeFigure || command === commandType.changeFigureDiff) {
returnAssetsList.push({
name: content,
url: content,
Expand Down
1 change: 1 addition & 0 deletions packages/parser/src/scriptParser/contentParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const contentParser = (
case commandType.changeBg:
return assetSetter(contentRaw, fileType.background);
case commandType.changeFigure:
case commandType.changeFigureDiff:
return assetSetter(contentRaw, fileType.figure);
case commandType.bgm:
return assetSetter(contentRaw, fileType.bgm);
Expand Down
7 changes: 5 additions & 2 deletions packages/parser/src/scriptParser/scriptParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ import { ConfigMap } from '../config/scriptConfig';
* @param assetSetter
* @param ADD_NEXT_ARG_LIST
* @param SCRIPT_CONFIG_MAP
* @param lineNumber 语句所在行号
* @param collectAssets 是否扫描语句携带的资源与子场景;为 false 时两者都返回空数组
*/
export const scriptParser = (
sentenceRaw: string,
assetSetter: any,
ADD_NEXT_ARG_LIST: commandType[],
SCRIPT_CONFIG_MAP: ConfigMap,
lineNumber = 0,
collectAssets = true,
): ISentence => {
let command: commandType; // 默认为对话
let content: string; // 语句内容
Expand Down Expand Up @@ -109,8 +112,8 @@ export const scriptParser = (
}

content = contentParser(newSentenceRaw.trim(), command, assetSetter); // 将语句内容里的文件名转为相对或绝对路径
sentenceAssets = assetsScanner(command, content, args, lineNumber); // 扫描语句携带资源
subScene = subSceneScanner(command, content); // 扫描语句携带子场景
sentenceAssets = collectAssets ? assetsScanner(command, content, args, lineNumber) : []; // 扫描语句携带资源
subScene = collectAssets ? subSceneScanner(command, content) : []; // 扫描语句携带子场景
return {
command: command, // 语句类型
commandRaw: commandRaw.trim(), // 命令原始内容,方便调试
Expand Down
24 changes: 24 additions & 0 deletions packages/parser/test/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,30 @@ test("scene assets skip entries with empty urls", async () => {
expect(result.assetsList).toEqual([]);
});

test("scene assets and subscenes are accumulated from every statement", async () => {
let prefetchedAssets: IAsset[] = [];
const parser = new SceneParser((assetList) => {
prefetchedAssets = assetList;
}, (fileName, assetType) => {
return fileName;
}, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG);

const result = parser.parse(`changeBg:a.webp;
changeFigure:b.png -id=b;
say:voice line -vocal=c.mp3;
changeBg:a.webp;
callScene:chapter1.txt;
callScene:chapter2.txt;`, 'test', 'test');

expect(result.assetsList).toEqual([
{ name: 'a.webp', url: 'a.webp', type: fileType.background, lineNumber: 0 },
{ name: 'b.png', url: 'b.png', type: fileType.figure, lineNumber: 1 },
{ name: 'c.mp3', url: 'c.mp3', type: fileType.vocal, lineNumber: 2 },
]);
expect(prefetchedAssets).toEqual(result.assetsList);
expect(result.subSceneList).toEqual(['chapter1.txt', 'chapter2.txt']);
});

test("wait command", async () => {
const parser = new SceneParser((assetList) => {
}, (fileName, assetType) => {
Expand Down
70 changes: 70 additions & 0 deletions packages/parser/test/parserAssetsScaling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, test } from 'vitest';

import { ADD_NEXT_ARG_LIST, SCRIPT_CONFIG } from '../src/config/scriptConfig';
import SceneParser from '../src/index';

/**
* 资源密集脚本:几乎每条语句都带一个资源引用(bg / figure / vocal)。
* sceneParser 会把每条语句的资源累积到场景级 assetsList。
*/
function buildAssetDenseScene(statementCount: number): string {
const lines: string[] = [];
for (let index = 0; index < statementCount; index++) {
switch (index % 3) {
case 0: {
lines.push(`changeBg:background-${index % 40}.webp;`);
break;
}
case 1: {
lines.push(
`character-${index % 6}:line ${index} -vocal=voice-${index}.mp3;`,
);
break;
}
default: {
lines.push(
`changeFigure:figure-${index % 12}.png -id=figure-${index % 12};`,
);
}
}
}
return lines.join('\n');
}

const SMALL_STATEMENT_COUNT = 4000;
const LARGE_STATEMENT_COUNT = SMALL_STATEMENT_COUNT * 4;
/** 语句数 ×4 时允许的最大耗时倍率,阈值刻意放宽,只用于拦截复杂度回退 */
const MAX_TIME_RATIO = 8;

function measureParseTime(source: string): number {
const parser = new SceneParser(
() => { },
(fileName) => fileName,
ADD_NEXT_ARG_LIST,
SCRIPT_CONFIG,
);

let best = Number.POSITIVE_INFINITY;
for (let round = 0; round < 3; round++) {
const start = performance.now();
parser.parse(source, 'scaling', '/scaling.txt');
best = Math.min(best, performance.now() - start);
}
return best;
}

test('asset accumulation stays linear in the number of asset references', () => {
const smallSource = buildAssetDenseScene(SMALL_STATEMENT_COUNT);
const largeSource = buildAssetDenseScene(LARGE_STATEMENT_COUNT);

// 预热:把首次调用的编译开销排除在测量之外
measureParseTime(smallSource);

const smallMs = measureParseTime(smallSource);
const largeMs = measureParseTime(largeSource);

// 就地追加累积时,语句数 ×4 的耗时约为 4 倍;
// 旧的全量复制实现([...assetsList, ...sentenceAssets])会退化成 Θ(资源数²),
// 同样的 4 倍规模下耗时约为 12 倍以上。
expect(largeMs / smallMs).toBeLessThan(MAX_TIME_RATIO);
});
Loading
Loading