From f99e5ede1b21088532772f4de77573bfafb7b7a8 Mon Sep 17 00:00:00 2001 From: TremblingMoeNew <39522864+TremblingMoeNew@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:10:10 +0800 Subject: [PATCH 01/30] Fix: setTransform cannot execute continuously when targeting the same object --- .../Core/Modules/perform/performController.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index f303a64b8..7de5585c5 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -50,7 +50,7 @@ export class PerformController { // perform.isOver = true; if (!perform.isHoldOn) { // 如果不是保持演出,清除 - this.unmountPerform(perform.performName); + this.softUnmountPerformObject(perform); } }, perform.duration); @@ -103,6 +103,26 @@ export class PerformController { } } + public softUnmountPerformObject(perform: IPerform) { + const idx = this.performList.indexOf(perform) + if (idx < 0) return; + perform.stopFunction(); + clearTimeout(perform.stopTimeout as unknown as number); + /** + * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前 + * 因为 goNextWhenOver 会调用 nextSentence,而 nextSentence 会清除目前未结束的演出 + * 那么 nextSentence 函数就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 + * 所以 goNextWhenOver 后的代码会多删东西,解决方法就是在调用 goNextWhenOver 前先删掉这个演出对象 + * 此问题对所有 goNextWhenOver 属性为真的演出都有影响,但只有 2 个演出有此问题 + */ + this.performList.splice(idx, 1); + if (perform.goNextWhenOver) { + // nextSentence(); + this.goNextWhenOver(); + } + + } + public erasePerformFromState(name: string) { webgalStore.dispatch(stageActions.removePerformByName(name)); } From 623570492d6de4a733d469841428e5abb693e498 Mon Sep 17 00:00:00 2001 From: TremblingMoeNew <39522864+TremblingMoeNew@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:18:45 +0800 Subject: [PATCH 02/30] Feat: Decouple properties in timeline animations & support parallel animation for setTransform command --- .../src/Core/Modules/animationFunctions.ts | 25 +++++++- .../Core/Modules/perform/performController.ts | 24 ++++---- .../Core/Modules/perform/performInterface.ts | 2 + .../controller/stage/pixi/PixiController.ts | 8 ++- .../generateTransformAnimationObj.ts | 16 ++++- .../stage/pixi/animations/timeline.ts | 14 ++--- .../src/Core/gameScripts/setTransform.ts | 16 ++--- .../src/Stage/MainStage/useSetEffects.ts | 3 +- packages/webgal/src/store/stageInterface.ts | 58 +++++++++---------- packages/webgal/src/store/stageReducer.ts | 9 ++- 10 files changed, 113 insertions(+), 62 deletions(-) diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index 1c5fa4471..b8e85a23c 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -7,6 +7,8 @@ import { baseTransform } from '@/store/stageInterface'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; import { WebGAL } from '@/Core/WebGAL'; import PixiStage, { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; +import { IUserAnimation } from './animations'; +import { pickBy } from 'lodash'; import { DEFAULT_BG_IN_DURATION, DEFAULT_BG_OUT_DURATION, @@ -18,12 +20,25 @@ import { export function getAnimationObject(animationName: string, target: string, duration: number, writeDefault: boolean) { const effect = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); if (effect) { + const unionKeys = new Set(); + const unionScaleKeys = new Set(); + const unionPositionKeys = new Set(); + effect.effects.forEach((effect) => { + Object.keys(effect).forEach((k) => unionKeys.add(k)); + if (effect.scale) Object.keys(effect.scale).forEach((k) => unionScaleKeys.add(k)); + if (effect.position) Object.keys(effect.position).forEach((k) => unionPositionKeys.add(k)); + }); const mappedEffects = effect.effects.map((effect) => { const targetSetEffect = webgalStore.getState().stage.effects.find((e) => e.target === target); let newEffect; if (!writeDefault && targetSetEffect && targetSetEffect.transform) { - newEffect = cloneDeep({ ...targetSetEffect.transform, duration: 0, ease: '' }); + const targetScale = pickBy(targetSetEffect.transform.scale, (source, key)=> unionScaleKeys.has(key)) + const targetPosition = pickBy(targetSetEffect.transform.position, (source, key)=> unionPositionKeys.has(key)) + const originalTransform = { ...pickBy(targetSetEffect.transform, (source, key)=> unionKeys.has(key))}; + originalTransform.scale = targetScale + originalTransform.position = targetPosition + newEffect = cloneDeep({ ...originalTransform, duration: 0, ease: '' }); } else { newEffect = cloneDeep({ ...baseTransform, duration: 0, ease: '' }); } @@ -51,6 +66,14 @@ export function getAnimateDuration(animationName: string) { return 0; } +export function getAnimateDurationFromObj(animation: IUserAnimation) { + let duration = 0; + animation.effects.forEach((e) => { + duration += e.duration; + }); + return duration; +} + // eslint-disable-next-line max-params export function getEnterExitAnimation( target: string, diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index 7de5585c5..7d40a687b 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -19,17 +19,19 @@ export class PerformController { public performList: Array = []; public arrangeNewPerform(perform: IPerform, script: ISentence, syncPerformState = true) { - // 检查演出列表内是否有相同的演出,如果有,一定是出了什么问题 - const dupPerformIndex = this.performList.findIndex((p) => p.performName === perform.performName); - if (dupPerformIndex > -1) { - // 结束并删除全部重复演出 - for (let i = 0; i < this.performList.length; i++) { - const e = this.performList[i]; - if (e.performName === perform.performName) { - e.stopFunction(); - clearTimeout(e.stopTimeout as unknown as number); - this.performList.splice(i, 1); - i--; + if (!perform.isParallel){ + // 检查演出列表内是否有相同的演出,如果有,一定是出了什么问题 + const dupPerformIndex = this.performList.findIndex((p) => p.performName === perform.performName); + if (dupPerformIndex > -1) { + // 结束并删除全部重复演出 + for (let i = 0; i < this.performList.length; i++) { + const e = this.performList[i]; + if (e.performName === perform.performName) { + e.stopFunction(); + clearTimeout(e.stopTimeout as unknown as number); + this.performList.splice(i, 1); + i--; + } } } } diff --git a/packages/webgal/src/Core/Modules/perform/performInterface.ts b/packages/webgal/src/Core/Modules/perform/performInterface.ts index 8e00b2f90..3c2b5f63a 100644 --- a/packages/webgal/src/Core/Modules/perform/performInterface.ts +++ b/packages/webgal/src/Core/Modules/perform/performInterface.ts @@ -23,6 +23,8 @@ export interface IPerform { arrangePerformPromise?: Promise; // 跳过由 nextSentence 函数引发的演出回收 skipNextCollect?: boolean; + // + isParallel?: boolean; } // next之后,可以被打断的演出会被打断,不能被打断的演出会继续,阻塞next的演出会阻止next被响应。 diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 76ec7f3d6..799f64323 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -10,6 +10,8 @@ import { SCREEN_CONSTANTS } from '@/Core/util/constants'; import { logger } from '@/Core/util/logger'; import { v4 as uuid } from 'uuid'; import { cloneDeep, isEqual } from 'lodash'; +import omitBy from 'lodash/omitBy'; +import isUndefined from 'lodash/isUndefined'; import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; @@ -71,9 +73,9 @@ export default class PixiStage { if (!source) return; const targetScale = target.scale; const targetPosition = target.position; - if (target.scale) Object.assign(targetScale, source.scale); - if (target.position) Object.assign(targetPosition, source.position); - Object.assign(target, source); + if (target.scale) Object.assign(targetScale!, omitBy(source.scale,isUndefined)); + if (target.position) Object.assign(targetPosition!, omitBy(source.position,isUndefined)); + Object.assign(target, omitBy(source,isUndefined)); target.scale = targetScale; target.position = targetPosition; } diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts index 1cf2a38af..693cf4989 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts @@ -1,5 +1,6 @@ import { AnimationFrame } from '@/Core/Modules/animations'; import { webgalStore } from '@/store/store'; +import { has, pickBy } from 'lodash'; import isNull from 'lodash/isNull'; type AnimationObj = Array; @@ -10,6 +11,7 @@ export function generateTransformAnimationObj( applyFrame: AnimationFrame, duration: number | string | boolean | null, ease: string, + writeFullEffect: boolean = true, ): AnimationObj { let animationObj; // 获取那个 target 的当前变换 @@ -25,8 +27,18 @@ export function generateTransformAnimationObj( // 找到 effect if (targetEffect) { - const effectWithDuration = { ...targetEffect!.transform!, duration: 0, ease }; - animationObj.unshift(effectWithDuration); + if (writeFullEffect) { + const effectWithDuration = { ...targetEffect!.transform!, duration: 0, ease }; + animationObj.unshift(effectWithDuration); + } + else { + const targetScale = pickBy(targetEffect!.transform!.scale, (source, key)=> has(applyFrame.scale, key)) + const targetPosition = pickBy(targetEffect!.transform!.position, (source, key)=> has(applyFrame.position, key)) + const effectWithDuration = { ...pickBy(targetEffect!.transform!, (source, key)=> has(applyFrame, key) ), duration: 0, ease }; + effectWithDuration.scale = targetScale + effectWithDuration.position = targetPosition + animationObj.unshift(effectWithDuration); + } } else { // 应用默认effect,也就是最终的 effect 的 alpha = 0 版本 const effectWithDuration = { ...applyFrame, alpha: 0, duration: 0, ease }; diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts index a49d55787..145c5c13b 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts @@ -33,7 +33,7 @@ export function generateTimelineObj( currentDelay += segmentDuration; const { position, scale, ...segmentValues } = segment; // 不能用 scale,因为 popmotion 不能用嵌套 - values.push({ x: position.x, y: position.y, scaleX: scale.x, scaleY: scale.y, ...segmentValues }); + values.push({ x: position?.x, y: position?.y, scaleX: scale?.x, scaleY: scale?.y, ...segmentValues }); // Easing 需要比 values 的长度少一个 if (i > 0) { easeArray.push(stringToEasing(segment.ease)); @@ -74,11 +74,11 @@ export function generateTimelineObj( if (target?.pixiContainer) { // 不能赋值到 position,因为 x 和 y 被 WebGALPixiContainer 代理,而 position 属性没有代理 const { position, scale, ...state } = getStartStateEffect(); - const assignValue = omitBy({ x: position.x, y: position.y, ...state }, isUndefined); + const assignValue = omitBy({ x: position?.x, y: position?.y, ...state }, isUndefined); // @ts-ignore PixiStage.assignTransform(target?.pixiContainer, assignValue); - if (target?.pixiContainer) { - if (!isUndefined(scale.x)) { + if (scale && target?.pixiContainer) { + if (!isUndefined(scale?.x)) { target.pixiContainer.scale.x = scale.x; } if (!isUndefined(scale?.y)) { @@ -101,11 +101,11 @@ export function generateTimelineObj( // 不能赋值到 position,因为 x 和 y 被 WebGALPixiContainer 代理,而 position 属性没有代理 // 不能赋值到 position,因为 x 和 y 被 WebGALPixiContainer 代理,而 position 属性没有代理 const { position, scale, ...state } = getEndStateEffect(); - const assignValue = omitBy({ x: position.x, y: position.y, ...state }, isUndefined); + const assignValue = omitBy({ x: position?.x, y: position?.y, ...state }, isUndefined); // @ts-ignore PixiStage.assignTransform(target?.pixiContainer, assignValue); - if (target?.pixiContainer) { - if (!isUndefined(scale.x)) { + if (scale && target?.pixiContainer) { + if (!isUndefined(scale?.x)) { target.pixiContainer.scale.x = scale.x; } if (!isUndefined(scale?.y)) { diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index 149287293..6e25dd291 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -10,15 +10,15 @@ import { baseTransform, ITransform } from '@/store/stageInterface'; import { AnimationFrame, IUserAnimation } from '../Modules/animations'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { WebGAL } from '@/Core/WebGAL'; -import { getAnimateDuration, getAnimationObject } from '../Modules/animationFunctions'; - +import { getAnimateDurationFromObj, getAnimationObject } from '../Modules/animationFunctions'; +import { v4 as uuid } from 'uuid'; /** * 设置变换 * @param sentence */ export const setTransform = (sentence: ISentence): IPerform => { const startDialogKey = webgalStore.getState().stage.currentDialogKey; - const animationName = (Math.random() * 10).toString(16); + const animationName = uuid(); const animationString = sentence.content; let animationObj: AnimationFrame[]; @@ -27,14 +27,16 @@ export const setTransform = (sentence: ISentence): IPerform => { const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const target = getStringArgByKey(sentence, 'target') ?? '0'; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; + const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; const performInitName = `animation-${target}`; - WebGAL.gameplay.performController.unmountPerform(performInitName, true); + if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); try { const frame = JSON.parse(animationString) as AnimationFrame; - animationObj = generateTransformAnimationObj(target, frame, duration, ease); + // writeDefault时需要完整的当前effect,其他时候不需要 + animationObj = generateTransformAnimationObj(target, frame, duration, ease, writeDefault); console.log('animationObj:', animationObj); } catch (e) { // 解析都错误了,歇逼吧 @@ -43,8 +45,7 @@ export const setTransform = (sentence: ISentence): IPerform => { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); - const animationDuration = getAnimateDuration(animationName); - + const animationDuration = getAnimateDurationFromObj(newAnimation); const key = `${target}-${animationName}-${animationDuration}`; let keepAnimationStopped = false; setTimeout(() => { @@ -82,5 +83,6 @@ export const setTransform = (sentence: ISentence): IPerform => { blockingNext: () => false, blockingAuto: () => !keep, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 + isParallel: parallel, }; }; diff --git a/packages/webgal/src/Stage/MainStage/useSetEffects.ts b/packages/webgal/src/Stage/MainStage/useSetEffects.ts index c5a1790ad..3c1a2b517 100644 --- a/packages/webgal/src/Stage/MainStage/useSetEffects.ts +++ b/packages/webgal/src/Stage/MainStage/useSetEffects.ts @@ -2,6 +2,7 @@ import { baseTransform, IEffect, IStageState, ITransform } from '@/store/stageIn import { WebGAL } from '@/Core/WebGAL'; import PixiStage from '@/Core/controller/stage/pixi/PixiController'; +import { isUndefined, omitBy } from 'lodash'; export function setStageObjectEffects(stageState: IStageState) { const effects = stageState.effects; @@ -42,5 +43,5 @@ function convertTransform(transform: ITransform | undefined) { return {}; } const { position, ...rest } = transform; - return { ...rest, x: position.x, y: position.y }; + return omitBy({ ...rest, x: position?.x, y: position?.y },isUndefined); } diff --git a/packages/webgal/src/store/stageInterface.ts b/packages/webgal/src/store/stageInterface.ts index 5494635d2..9269e7edd 100644 --- a/packages/webgal/src/store/stageInterface.ts +++ b/packages/webgal/src/store/stageInterface.ts @@ -25,41 +25,41 @@ export interface IChooseItem { } export interface ITransform { - alpha: number; - scale: { - x: number; - y: number; + alpha?: number; + scale?: { + x?: number; + y?: number; }; // pivot: { // x: number; // y: number; // }; - position: { - x: number; - y: number; + position?: { + x?: number; + y?: number; }; - rotation: number; - blur: number; - brightness: number; - contrast: number; - saturation: number; - gamma: number; - colorRed: number; - colorGreen: number; - colorBlue: number; - bevel: number; - bevelThickness: number; - bevelRotation: number; - bevelSoftness: number; - bevelRed: number; - bevelGreen: number; - bevelBlue: number; - bloom: number; - bloomBrightness: number; - bloomBlur: number; - bloomThreshold: number; - shockwaveFilter: number; - radiusAlphaFilter: number; + rotation?: number; + blur?: number; + brightness?: number; + contrast?: number; + saturation?: number; + gamma?: number; + colorRed?: number; + colorGreen?: number; + colorBlue?: number; + bevel?: number; + bevelThickness?: number; + bevelRotation?: number; + bevelSoftness?: number; + bevelRed?: number; + bevelGreen?: number; + bevelBlue?: number; + bloom?: number; + bloomBrightness?: number; + bloomBlur?: number; + bloomThreshold?: number; + shockwaveFilter?: number; + radiusAlphaFilter?: number; } /** diff --git a/packages/webgal/src/store/stageReducer.ts b/packages/webgal/src/store/stageReducer.ts index 450efbe38..a67acd4e2 100644 --- a/packages/webgal/src/store/stageReducer.ts +++ b/packages/webgal/src/store/stageReducer.ts @@ -23,6 +23,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import cloneDeep from 'lodash/cloneDeep'; import { commandType } from '@/Core/controller/scene/sceneInterface'; import { STAGE_KEYS } from '@/Core/constants'; +import { isUndefined, omitBy } from 'lodash'; // 初始化舞台数据 @@ -123,7 +124,13 @@ const stageSlice = createSlice({ const effectIndex = state.effects.findIndex((e) => e.target === target); if (effectIndex >= 0) { // Update the existing effect - state.effects[effectIndex].transform = transform; + const targetScale = state.effects[effectIndex]!.transform!.scale; + const targetPosition = state.effects[effectIndex]!.transform!.position; + if (transform!.scale) Object.assign(targetScale!, omitBy(transform!.scale,isUndefined)); + if (transform!.position) Object.assign(targetPosition!, omitBy(transform!.position,isUndefined)); + Object.assign(state.effects[effectIndex]!.transform!, omitBy(transform,isUndefined)) + state.effects[effectIndex].transform!.scale = targetScale; + state.effects[effectIndex].transform!.position = targetPosition; } else { // Add a new effect state.effects.push({ From 07a61d1f91d3ce43d81f128ca2b3ee7f36a12998 Mon Sep 17 00:00:00 2001 From: TremblingMoeNew <39522864+TremblingMoeNew@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:19:48 +0800 Subject: [PATCH 03/30] Feat: Support parallel animation for setTempAnimation command --- .../webgal/src/Core/gameScripts/setTempAnimation.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index 8da9635f7..778ef0df7 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -8,8 +8,9 @@ import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/tim import cloneDeep from 'lodash/cloneDeep'; import { baseTransform } from '@/store/stageInterface'; import { IUserAnimation } from '../Modules/animations'; -import { getAnimateDuration, getAnimationObject } from '@/Core/Modules/animationFunctions'; +import { getAnimateDurationFromObj, getAnimationObject } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; +import { v4 as uuid } from 'uuid'; /** * 设置临时动画 @@ -17,7 +18,7 @@ import { WebGAL } from '@/Core/WebGAL'; */ export const setTempAnimation = (sentence: ISentence): IPerform => { const startDialogKey = webgalStore.getState().stage.currentDialogKey; - const animationName = (Math.random() * 10).toString(16); + const animationName = uuid(); const animationString = sentence.content; let animationObj; try { @@ -27,15 +28,16 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { } const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); - const animationDuration = getAnimateDuration(animationName); + const animationDuration = getAnimateDurationFromObj(newAnimation); const target = getStringArgByKey(sentence, 'target') ?? '0'; const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; + const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; const key = `${target}-${animationName}-${animationDuration}`; const performInitName = `animation-${target}`; - WebGAL.gameplay.performController.unmountPerform(performInitName, true); + if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); let stopFunction = () => {}; setTimeout(() => { @@ -67,5 +69,6 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { blockingNext: () => false, blockingAuto: () => !keep, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 + isParallel: parallel, }; }; From 38c149be8650c1eeb50f319900c19d706bcec1be Mon Sep 17 00:00:00 2001 From: 22 <60903333+nini22P@users.noreply.github.com> Date: Thu, 18 Dec 2025 20:59:27 +0800 Subject: [PATCH 04/30] feat: on-demand rendering --- .../controller/stage/pixi/PixiController.ts | 86 ++++++++++++++++++- .../controller/storage/jumpFromBacklog.ts | 2 + 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 76ec7f3d6..7ae875463 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -88,12 +88,12 @@ export default class PixiStage { public frameDuration = 16.67; public notUpdateBacklogEffects = false; public readonly figureContainer: PIXI.Container; - public figureObjects: Array = []; + public figureObjects = this.createReactiveList([]); public stageWidth = SCREEN_CONSTANTS.width; public stageHeight = SCREEN_CONSTANTS.height; public assetLoader = new PIXI.Loader(); public readonly backgroundContainer: PIXI.Container; - public backgroundObjects: Array = []; + public backgroundObjects = this.createReactiveList([]); public mainStageObject: IStageObject; /** * 添加 Spine 立绘 @@ -104,11 +104,15 @@ export default class PixiStage { public addSpineFigure = addSpineFigureImpl.bind(this); public addSpineBg = addSpineBgImpl.bind(this); // 注册到 Ticker 上的函数 - private stageAnimations: Array = []; + private stageAnimations = this.createReactiveList([]); private loadQueue: { url: string; callback: () => void; name?: string }[] = []; private live2dFigureRecorder: Array = []; // 锁定变换对象(对象可能正在执行动画,不能应用变换) private lockTransformTarget: Array = []; + // 手动请求渲染防抖标记 + private isRenderPending = false; + // 更新 ticker 状态的防抖标记 + private isTickerUpdatePending = false; /** * 暂时没用上,以后可能用 @@ -121,6 +125,7 @@ export default class PixiStage { const app = new PIXI.Application({ backgroundAlpha: 0, preserveDrawingBuffer: true, + autoStart: false, }); // @ts-ignore @@ -194,7 +199,22 @@ export default class PixiStage { this.callLoader(); }; reload(); - this.initialize().then(() => {}); + this.initialize().then(() => { }); + this.requestRender(); + } + + public requestRender() { + if (this.isRenderPending) return; + this.isRenderPending = true; + + Promise.resolve().then(() => { + requestAnimationFrame(() => { + this.isRenderPending = false; + if (!this.currentApp?.ticker.started) { + this.currentApp?.render(); + } + }); + }); } public getFigureObjects() { @@ -346,6 +366,7 @@ export default class PixiStage { return; } sprite.texture = texture; + this.requestRender(); }); } @@ -374,6 +395,7 @@ export default class PixiStage { return; } sprite.texture = texture; + this.requestRender(); }); } @@ -436,6 +458,7 @@ export default class PixiStage { // 挂载 thisBgContainer.addChild(bgSprite); + this.requestRender(); } }, 0); }; @@ -610,6 +633,7 @@ export default class PixiStage { } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.requestRender(); } }, 0); }; @@ -1100,6 +1124,60 @@ export default class PixiStage { console.error('Failed to load figureCash:', error); } } + + private createReactiveList(array: T[]): T[] { + return new Proxy(array, { + // eslint-disable-next-line max-params + set: (target, property, value, receiver) => { + const result = Reflect.set(target, property, value, receiver); + if (property !== 'length') { + this.updateTickerStatus(); + } else { + this.updateTickerStatus(); + } + return result; + }, + deleteProperty: (target, property) => { + const result = Reflect.deleteProperty(target, property); + this.updateTickerStatus(); + return result; + }, + }); + } + + private updateTickerStatus() { + if (this.isTickerUpdatePending) return; + this.isTickerUpdatePending = true; + + Promise.resolve().then(() => { + this.isTickerUpdatePending = false; + const app = this.currentApp; + if (!app) return; + + const hasActiveAnimations = this.stageAnimations.length > 0; + const hasLive2D = this.figureObjects.some((fig) => fig.sourceType === 'live2d'); + const hasSpine = this.figureObjects.some((fig) => fig.sourceType === 'spine'); + const hasDynamicBg = this.backgroundObjects.some((bg) => bg.sourceType === 'video' || bg.sourceType === 'gif'); + const hasGifFigure = this.figureObjects.some((fig) => fig.sourceType === 'gif'); + + const shouldRun = hasActiveAnimations || hasLive2D || hasSpine || hasDynamicBg || hasGifFigure; + + if (shouldRun) { + if (!app.ticker.started) { + app.ticker.start(); + logger.debug('Ticker: STARTED'); + } + } else { + if (app.ticker.started) { + app.ticker.stop(); + this.currentApp?.render(); + logger.debug('Ticker: STOPPED'); + } else { + this.requestRender(); + } + } + }); + } } function updateCurrentBacklogEffects(newEffects: IEffect[]) { diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 2ebf4ce6b..48098db7f 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -24,6 +24,8 @@ export const restorePerform = () => { performToRestore.forEach((e) => { runScript(e.script); }); + // 重新渲染 + WebGAL.gameplay.pixiStage?.requestRender(); }; /** From 546ddbb3d60fa4345bb71678ef76f1bfb476f6c4 Mon Sep 17 00:00:00 2001 From: 22 <60903333+nini22P@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:06:15 +0800 Subject: [PATCH 05/30] simplify code for on-demand rendering --- .../controller/stage/pixi/PixiController.ts | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 7ae875463..92e455a15 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -207,13 +207,11 @@ export default class PixiStage { if (this.isRenderPending) return; this.isRenderPending = true; - Promise.resolve().then(() => { - requestAnimationFrame(() => { - this.isRenderPending = false; - if (!this.currentApp?.ticker.started) { - this.currentApp?.render(); - } - }); + requestAnimationFrame(() => { + this.isRenderPending = false; + if (!this.currentApp?.ticker.started) { + this.currentApp?.render(); + } }); } @@ -1130,11 +1128,7 @@ export default class PixiStage { // eslint-disable-next-line max-params set: (target, property, value, receiver) => { const result = Reflect.set(target, property, value, receiver); - if (property !== 'length') { - this.updateTickerStatus(); - } else { - this.updateTickerStatus(); - } + this.updateTickerStatus(); return result; }, deleteProperty: (target, property) => { @@ -1155,12 +1149,16 @@ export default class PixiStage { if (!app) return; const hasActiveAnimations = this.stageAnimations.length > 0; - const hasLive2D = this.figureObjects.some((fig) => fig.sourceType === 'live2d'); - const hasSpine = this.figureObjects.some((fig) => fig.sourceType === 'spine'); - const hasDynamicBg = this.backgroundObjects.some((bg) => bg.sourceType === 'video' || bg.sourceType === 'gif'); - const hasGifFigure = this.figureObjects.some((fig) => fig.sourceType === 'gif'); - - const shouldRun = hasActiveAnimations || hasLive2D || hasSpine || hasDynamicBg || hasGifFigure; + const allObjects = [...this.figureObjects, ...this.backgroundObjects]; + const hasDynamicObjects = allObjects.some( + (obj) => + obj.sourceType === 'live2d' || + obj.sourceType === 'spine' || + obj.sourceType === 'video' || + obj.sourceType === 'gif', + ); + + const shouldRun = hasActiveAnimations || hasDynamicObjects; if (shouldRun) { if (!app.ticker.started) { From 4f6fe37febec45e8a2164f24f9cdd98e4fce39d7 Mon Sep 17 00:00:00 2001 From: 22 <60903333+nini22P@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:47:59 +0800 Subject: [PATCH 06/30] feat: get deltaMS from the ticker --- .../controller/stage/pixi/PixiController.ts | 53 +------------------ .../stage/pixi/animations/template.ts | 6 +-- .../stage/pixi/animations/testblur.ts | 4 +- .../stage/pixi/animations/universalSoftIn.ts | 4 +- .../stage/pixi/animations/universalSoftOff.ts | 4 +- .../controller/storage/jumpFromBacklog.ts | 5 +- 6 files changed, 13 insertions(+), 63 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 92e455a15..15924905c 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -85,7 +85,6 @@ export default class PixiStage { public readonly mainStageContainer: WebGALPixiContainer; public readonly foregroundEffectsContainer: PIXI.Container; public readonly backgroundEffectsContainer: PIXI.Container; - public frameDuration = 16.67; public notUpdateBacklogEffects = false; public readonly figureContainer: PIXI.Container; public figureObjects = this.createReactiveList([]); @@ -187,19 +186,13 @@ export default class PixiStage { this.backgroundContainer, ); this.currentApp = app; - // 每 5s 获取帧率,并且防 loader 死 - const update = () => { - this.updateFps(); - setTimeout(update, 10000); - }; - update(); // loader 防死 const reload = () => { setTimeout(reload, 500); this.callLoader(); }; reload(); - this.initialize().then(() => { }); + this.initialize(); this.requestRender(); } @@ -1097,13 +1090,6 @@ export default class PixiStage { } } - private updateFps() { - getScreenFps?.(120).then((fps) => { - this.frameDuration = 1000 / (fps as number); - // logger.info('当前帧率', fps); - }); - } - private lockStageObject(targetName: string) { this.lockTransformTarget.push(targetName); } @@ -1188,40 +1174,3 @@ function updateCurrentBacklogEffects(newEffects: IEffect[]) { webgalStore.dispatch(setStage({ key: 'effects', value: newEffects })); } - -/** - * @param {number} targetCount 不小于1的整数,表示经过targetCount帧之后返回结果 - * @return {Promise} - */ -const getScreenFps = (() => { - // 先做一下兼容性处理 - const nextFrame = [ - window.requestAnimationFrame, - // @ts-ignore - window.webkitRequestAnimationFrame, - // @ts-ignore - window.mozRequestAnimationFrame, - ].find((fn) => fn); - if (!nextFrame) { - console.error('requestAnimationFrame is not supported!'); - return; - } - return (targetCount = 60) => { - // 判断参数是否合规 - if (targetCount < 1) throw new Error('targetCount cannot be less than 1.'); - const beginDate = Date.now(); - let count = 0; - return new Promise((resolve) => { - (function log() { - nextFrame(() => { - if (++count >= targetCount) { - const diffDate = Date.now() - beginDate; - const fps = (count / diffDate) * 1000; - return resolve(fps); - } - log(); - }); - })(); - }); - }; -})(); diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/template.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/template.ts index 74b017a10..9ae4c4c23 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/template.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/template.ts @@ -14,13 +14,13 @@ export function generateTemplateAnimationObj(targetKey: string, duration: number /** * 在此书写为动画设置初态的操作 */ - function setStartState() {} + function setStartState() { } // TODO:通用终态设置 /** * 在此书写为动画设置终态的操作 */ - function setEndState() {} + function setEndState() { } /** * 在此书写动画每一帧执行的函数 @@ -31,7 +31,7 @@ export function generateTemplateAnimationObj(targetKey: string, duration: number // 要操控的精灵 const sprite = target.pixiContainer; // 每一帧的时间 - const baseDuration = WebGAL.gameplay.pixiStage!.frameDuration; + const currentDeltaMS = WebGAL.gameplay.pixiStage!.currentApp!.ticker.deltaMS; /** * 在下面书写具体的动画 diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/testblur.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/testblur.ts index 496e35fed..5d88127c5 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/testblur.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/testblur.ts @@ -36,8 +36,8 @@ export function generateTestblurAnimationObj(targetKey: string, duration: number function tickerFunc(delta: number) { if (target) { const container = target.pixiContainer; - const baseDuration = WebGAL.gameplay.pixiStage!.frameDuration; - const currentAddOplityDelta = (duration / baseDuration) * delta; + const currentDeltaMS = WebGAL.gameplay.pixiStage!.currentApp!.ticker.deltaMS; + const currentAddOplityDelta = (duration / currentDeltaMS) * delta; const increasement = 1 / currentAddOplityDelta; const decreasement = 5 / currentAddOplityDelta; if (container) diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts index 61f8a3d69..02108bbaf 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts @@ -35,9 +35,9 @@ export function generateUniversalSoftInAnimationObj(targetKey: string, duration: function tickerFunc(delta: number) { if (target) { const sprite = target.pixiContainer; - const baseDuration = WebGAL.gameplay.pixiStage!.frameDuration; + const currentDeltaMS = WebGAL.gameplay.pixiStage!.currentApp!.ticker.deltaMS; - elapsedTime += baseDuration; + elapsedTime += currentDeltaMS; const realElapsedTime = Math.min(elapsedTime, duration); const progress = realElapsedTime / duration; diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftOff.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftOff.ts index 3f5477457..773536310 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftOff.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftOff.ts @@ -35,9 +35,9 @@ export function generateUniversalSoftOffAnimationObj(targetKey: string, duration function tickerFunc(delta: number) { if (target) { const targetContainer = target.pixiContainer; - const baseDuration = WebGAL.gameplay.pixiStage!.frameDuration; + const currentDeltaMS = WebGAL.gameplay.pixiStage!.currentApp!.ticker.deltaMS; - elapsedTime += baseDuration; + elapsedTime += currentDeltaMS; const realElapsedTime = Math.min(elapsedTime, duration); const progress = realElapsedTime / duration; diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 48098db7f..b8e50caa1 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -24,8 +24,6 @@ export const restorePerform = () => { performToRestore.forEach((e) => { runScript(e.script); }); - // 重新渲染 - WebGAL.gameplay.pixiStage?.requestRender(); }; /** @@ -79,4 +77,7 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => { // 重新显示 TextBox dispatch(setVisibility({ component: 'showTextBox', visibility: true })); + + // 重新渲染 + setTimeout(() => WebGAL.gameplay.pixiStage?.requestRender(), 100); }; From d661a76a1377b4bf0d75e26fc5bb8538236830a1 Mon Sep 17 00:00:00 2001 From: TremblingMoeNew <39522864+TremblingMoeNew@users.noreply.github.com> Date: Sun, 7 Dec 2025 13:48:48 +0800 Subject: [PATCH 07/30] Feat: Support parallel animation for setAnimation command; Fix: potential runtime error caused by undefined parameters reported by review bot --- .../game/scene/demo_parallel_animation.txt | 31 +++++++++++++++++++ .../src/Core/Modules/animationFunctions.ts | 18 +++-------- .../Core/Modules/perform/performController.ts | 5 ++- .../Core/Modules/perform/performInterface.ts | 2 +- .../controller/stage/pixi/PixiController.ts | 6 ++-- .../generateTransformAnimationObj.ts | 19 +++++++----- .../src/Core/gameScripts/setAnimation.ts | 4 ++- .../src/Core/gameScripts/setTempAnimation.ts | 4 +-- .../src/Core/gameScripts/setTransform.ts | 4 +-- .../src/Stage/MainStage/useSetEffects.ts | 2 +- packages/webgal/src/store/stageReducer.ts | 18 ++++++----- 11 files changed, 72 insertions(+), 41 deletions(-) create mode 100644 packages/webgal/public/game/scene/demo_parallel_animation.txt diff --git a/packages/webgal/public/game/scene/demo_parallel_animation.txt b/packages/webgal/public/game/scene/demo_parallel_animation.txt new file mode 100644 index 000000000..796ac524b --- /dev/null +++ b/packages/webgal/public/game/scene/demo_parallel_animation.txt @@ -0,0 +1,31 @@ +changeBg:WebGalEnter.webp -next; +changeFigure:stand.webp -id=figure01 -transform={"position":{"x":1000,"y":720}}; +;演示setAnimation平行执行 +setAnimation:shockwaveIn -target=figure01 -next +setAnimation:move-front-and-back -target=figure01 -parallel +;演示通过-continue接续执行两个常规setTransform正常运作、不被打断 +setTransform:{"position":{"x":-1000}} -duration=5000 -target=figure01 -continue +setTransform:{"position":{"x":1000}} -duration=5000 -target=figure01 +;演示setTransform平行执行 +setTransform:{"position":{"x":-1000},"scale":{"x":0.5},"contrast":0.5} -duration=5000 -target=figure01 -ease=easeOut -next -keep +wait:2000 +setTransform:{"position":{"y":0},"scale":{"y":0.5},"saturation":0} -duration=5000 -target=figure01 -ease=linear -parallel -continue +setTransform:{"position":{"y":-720},"scale":{"y":1},"saturation":1} -duration=5000 -target=figure01 -ease=linear -next +setTransform:{"position":{"x":1000},"scale":{"x":1},"contrast":1} -duration=5000 -target=figure01 -ease=easeIn -parallel; +;演示参数解耦改动后setTempAnimation普通运作是否正常 +setTempAnimation:[{"duration":0}, {"duration":500,"position":{"x":-1000}}, {"duration":500,"position":{"y":720},"scale":{"y":0.5},"saturation":0}, {"duration":500,"position":{"x":-1000, "y":720}}, {"duration":500}, {"duration":500,"position":{"x":-1000},"scale":{"x":0.5},"contrast":0.5}] -target=figure01 +setTempAnimation:[{"duration":0}, {"duration":500,"position":{"x":1000}}, {"duration":500,"position":{"y":720}}, {"duration":500,"position":{"x":1000, "y":720}}, {"duration":500}, {"duration":500,"position":{"x":1000}}] -target=figure01 +;演示参数解耦改动后setTransform的-writeDefault参数是否运作正常 +setTransform:{} -writeDefault -target=figure01 -duration=500 +setTransform:{"position":{"x":1000,"y":720}} -target=figure01 -next; +setAnimation:shockwaveOut -target=figure01 -parallel +;演示setTempAnimation平行执行 +setTempAnimation:[{"duration":0},{"position":{"x":-1000},"scale":{"x":0.5},"contrast":0.5,"duration":5000,"ease":"easeOut"},{"position":{"x":-1000},"scale":{"x":0.5},"contrast":0.5,"duration":2000},{"position":{"x":600},"scale":{"x":1},"contrast":1,"duration":5000,"ease":"easeIn"},{"duration":2000}] -target=figure01 -next; +setTempAnimation:[{"duration":2000},{"position":{"y":0},"scale":{"y":0.5},"saturation":0,"duration":5000,"ease":"linear"},{"position":{"y":-720},"scale":{"y":1},"saturation":1,"duration":5000,"ease":"linear"},{"duration":2000}] -target=figure01 -parallel -continue; +;演示并行执行多条终止时间点不一致的setTransform +setTransform:{"position":{"x":-1000}} -duration=5000 -next -target=figure01; +setTransform:{"position":{"y":0}} -duration=3000 -parallel -target=figure01; +setTransform:{"position":{"x":1000}} -duration=3000 -next -target=figure01; +setTransform:{"position":{"y":-720}} -duration=5000 -parallel -target=figure01; +changeBg: -next; +changeFigure: -id=figure01 diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index b8e85a23c..026b96014 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -33,11 +33,11 @@ export function getAnimationObject(animationName: string, target: string, durati let newEffect; if (!writeDefault && targetSetEffect && targetSetEffect.transform) { - const targetScale = pickBy(targetSetEffect.transform.scale, (source, key)=> unionScaleKeys.has(key)) - const targetPosition = pickBy(targetSetEffect.transform.position, (source, key)=> unionPositionKeys.has(key)) - const originalTransform = { ...pickBy(targetSetEffect.transform, (source, key)=> unionKeys.has(key))}; - originalTransform.scale = targetScale - originalTransform.position = targetPosition + const targetScale = pickBy(targetSetEffect.transform.scale || {}, (source, key) => unionScaleKeys.has(key)); + const targetPosition = pickBy(targetSetEffect.transform.position || {}, (s, key) => unionPositionKeys.has(key)); + const originalTransform = { ...pickBy(targetSetEffect.transform, (source, key) => unionKeys.has(key)) }; + originalTransform.scale = targetScale; + originalTransform.position = targetPosition; newEffect = cloneDeep({ ...originalTransform, duration: 0, ease: '' }); } else { newEffect = cloneDeep({ ...baseTransform, duration: 0, ease: '' }); @@ -66,14 +66,6 @@ export function getAnimateDuration(animationName: string) { return 0; } -export function getAnimateDurationFromObj(animation: IUserAnimation) { - let duration = 0; - animation.effects.forEach((e) => { - duration += e.duration; - }); - return duration; -} - // eslint-disable-next-line max-params export function getEnterExitAnimation( target: string, diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index 7d40a687b..e3569fdd2 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -19,7 +19,7 @@ export class PerformController { public performList: Array = []; public arrangeNewPerform(perform: IPerform, script: ISentence, syncPerformState = true) { - if (!perform.isParallel){ + if (!perform.isParallel) { // 检查演出列表内是否有相同的演出,如果有,一定是出了什么问题 const dupPerformIndex = this.performList.findIndex((p) => p.performName === perform.performName); if (dupPerformIndex > -1) { @@ -106,7 +106,7 @@ export class PerformController { } public softUnmountPerformObject(perform: IPerform) { - const idx = this.performList.indexOf(perform) + const idx = this.performList.indexOf(perform); if (idx < 0) return; perform.stopFunction(); clearTimeout(perform.stopTimeout as unknown as number); @@ -122,7 +122,6 @@ export class PerformController { // nextSentence(); this.goNextWhenOver(); } - } public erasePerformFromState(name: string) { diff --git a/packages/webgal/src/Core/Modules/perform/performInterface.ts b/packages/webgal/src/Core/Modules/perform/performInterface.ts index 3c2b5f63a..5431e7dd4 100644 --- a/packages/webgal/src/Core/Modules/perform/performInterface.ts +++ b/packages/webgal/src/Core/Modules/perform/performInterface.ts @@ -23,7 +23,7 @@ export interface IPerform { arrangePerformPromise?: Promise; // 跳过由 nextSentence 函数引发的演出回收 skipNextCollect?: boolean; - // + // isParallel?: boolean; } diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 799f64323..19d2d7d04 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -73,9 +73,9 @@ export default class PixiStage { if (!source) return; const targetScale = target.scale; const targetPosition = target.position; - if (target.scale) Object.assign(targetScale!, omitBy(source.scale,isUndefined)); - if (target.position) Object.assign(targetPosition!, omitBy(source.position,isUndefined)); - Object.assign(target, omitBy(source,isUndefined)); + if (target.scale) Object.assign(targetScale!, omitBy(source.scale || {}, isUndefined)); + if (target.position) Object.assign(targetPosition!, omitBy(source.position || {}, isUndefined)); + Object.assign(target, omitBy(source, isUndefined)); target.scale = targetScale; target.position = targetPosition; } diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts index 693cf4989..dbbe8c0ba 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts @@ -11,7 +11,7 @@ export function generateTransformAnimationObj( applyFrame: AnimationFrame, duration: number | string | boolean | null, ease: string, - writeFullEffect: boolean = true, + writeFullEffect = true, ): AnimationObj { let animationObj; // 获取那个 target 的当前变换 @@ -30,13 +30,16 @@ export function generateTransformAnimationObj( if (writeFullEffect) { const effectWithDuration = { ...targetEffect!.transform!, duration: 0, ease }; animationObj.unshift(effectWithDuration); - } - else { - const targetScale = pickBy(targetEffect!.transform!.scale, (source, key)=> has(applyFrame.scale, key)) - const targetPosition = pickBy(targetEffect!.transform!.position, (source, key)=> has(applyFrame.position, key)) - const effectWithDuration = { ...pickBy(targetEffect!.transform!, (source, key)=> has(applyFrame, key) ), duration: 0, ease }; - effectWithDuration.scale = targetScale - effectWithDuration.position = targetPosition + } else { + const targetScale = pickBy(targetEffect.transform?.scale || {}, (source, key) => has(applyFrame.scale, key)); + const targetPosition = pickBy(targetEffect.transform?.position || {}, (sr, key) => has(applyFrame.position, key)); + const effectWithDuration = { + ...pickBy(targetEffect.transform || {}, (source, key) => has(applyFrame, key)), + duration: 0, + ease, + }; + effectWithDuration.scale = targetScale; + effectWithDuration.position = targetPosition; animationObj.unshift(effectWithDuration); } } else { diff --git a/packages/webgal/src/Core/gameScripts/setAnimation.ts b/packages/webgal/src/Core/gameScripts/setAnimation.ts index 748b4498a..a9a384c0d 100644 --- a/packages/webgal/src/Core/gameScripts/setAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setAnimation.ts @@ -20,11 +20,12 @@ export const setAnimation = (sentence: ISentence): IPerform => { target = target !== '' ? target : 'default_id'; const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; + const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; const key = `${target}-${animationName}-${animationDuration}`; const performInitName = `animation-${target}`; - WebGAL.gameplay.performController.unmountPerform(performInitName, true); + if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); let stopFunction; setTimeout(() => { @@ -56,5 +57,6 @@ export const setAnimation = (sentence: ISentence): IPerform => { blockingNext: () => false, blockingAuto: () => !keep, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 + isParallel: parallel, }; }; diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index 778ef0df7..811c05d73 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -8,7 +8,7 @@ import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/tim import cloneDeep from 'lodash/cloneDeep'; import { baseTransform } from '@/store/stageInterface'; import { IUserAnimation } from '../Modules/animations'; -import { getAnimateDurationFromObj, getAnimationObject } from '@/Core/Modules/animationFunctions'; +import { getAnimateDuration, getAnimationObject } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; import { v4 as uuid } from 'uuid'; @@ -28,7 +28,7 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { } const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); - const animationDuration = getAnimateDurationFromObj(newAnimation); + const animationDuration = getAnimateDuration(animationName); const target = getStringArgByKey(sentence, 'target') ?? '0'; const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index 6e25dd291..087ae6a97 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -10,7 +10,7 @@ import { baseTransform, ITransform } from '@/store/stageInterface'; import { AnimationFrame, IUserAnimation } from '../Modules/animations'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { WebGAL } from '@/Core/WebGAL'; -import { getAnimateDurationFromObj, getAnimationObject } from '../Modules/animationFunctions'; +import { getAnimateDuration, getAnimationObject } from '../Modules/animationFunctions'; import { v4 as uuid } from 'uuid'; /** * 设置变换 @@ -45,7 +45,7 @@ export const setTransform = (sentence: ISentence): IPerform => { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); - const animationDuration = getAnimateDurationFromObj(newAnimation); + const animationDuration = getAnimateDuration(animationName); const key = `${target}-${animationName}-${animationDuration}`; let keepAnimationStopped = false; setTimeout(() => { diff --git a/packages/webgal/src/Stage/MainStage/useSetEffects.ts b/packages/webgal/src/Stage/MainStage/useSetEffects.ts index 3c1a2b517..8a096d11c 100644 --- a/packages/webgal/src/Stage/MainStage/useSetEffects.ts +++ b/packages/webgal/src/Stage/MainStage/useSetEffects.ts @@ -43,5 +43,5 @@ function convertTransform(transform: ITransform | undefined) { return {}; } const { position, ...rest } = transform; - return omitBy({ ...rest, x: position?.x, y: position?.y },isUndefined); + return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); } diff --git a/packages/webgal/src/store/stageReducer.ts b/packages/webgal/src/store/stageReducer.ts index a67acd4e2..e95ff7a78 100644 --- a/packages/webgal/src/store/stageReducer.ts +++ b/packages/webgal/src/store/stageReducer.ts @@ -124,13 +124,17 @@ const stageSlice = createSlice({ const effectIndex = state.effects.findIndex((e) => e.target === target); if (effectIndex >= 0) { // Update the existing effect - const targetScale = state.effects[effectIndex]!.transform!.scale; - const targetPosition = state.effects[effectIndex]!.transform!.position; - if (transform!.scale) Object.assign(targetScale!, omitBy(transform!.scale,isUndefined)); - if (transform!.position) Object.assign(targetPosition!, omitBy(transform!.position,isUndefined)); - Object.assign(state.effects[effectIndex]!.transform!, omitBy(transform,isUndefined)) - state.effects[effectIndex].transform!.scale = targetScale; - state.effects[effectIndex].transform!.position = targetPosition; + if (!state.effects[effectIndex].transform) { + state.effects[effectIndex].transform = transform; + } else if (transform) { + const targetScale = state.effects[effectIndex].transform!.scale || {}; + const targetPosition = state.effects[effectIndex].transform!.position || {}; + if (transform.scale) Object.assign(targetScale, omitBy(transform.scale, isUndefined)); + if (transform.position) Object.assign(targetPosition, omitBy(transform.position, isUndefined)); + Object.assign(state.effects[effectIndex].transform!, omitBy(transform, isUndefined)); + state.effects[effectIndex].transform!.scale = targetScale; + state.effects[effectIndex].transform!.position = targetPosition; + } } else { // Add a new effect state.effects.push({ From cbd0232d153580b2e6084ed2f740785c837c905c Mon Sep 17 00:00:00 2001 From: xiaoxustudio Date: Thu, 5 Feb 2026 13:35:39 +0800 Subject: [PATCH 08/30] feat: add nobreak argument for wait --- .../src/Core/controller/gamePlay/nextSentence.ts | 5 +++++ packages/webgal/src/Core/gameScripts/wait.ts | 12 ++++++++++++ packages/webgal/src/store/GUIReducer.ts | 6 ++++++ packages/webgal/src/store/guiInterface.ts | 1 + 4 files changed, 24 insertions(+) diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index 7cb4a9f78..46022eaf3 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -24,6 +24,11 @@ export const nextSentence = () => { return; } + // 如果处于 wait 指令的不可中断状态,那么不进行下一句 + if (GUIState.waitNoBreak) { + return; + } + // 第一步,检查是否存在 blockNext 的演出 let isBlockingNext = false; WebGAL.gameplay.performController.performList.forEach((e) => { diff --git a/packages/webgal/src/Core/gameScripts/wait.ts b/packages/webgal/src/Core/gameScripts/wait.ts index 17380d094..51b843ade 100644 --- a/packages/webgal/src/Core/gameScripts/wait.ts +++ b/packages/webgal/src/Core/gameScripts/wait.ts @@ -1,5 +1,8 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { setWaitNoBreak } from '@/store/GUIReducer'; +import { webgalStore } from '@/store/store'; +import { getBooleanArgByKey } from '../util/getSentenceArg'; /** * 等待 n 毫秒 @@ -8,6 +11,15 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; export const wait = (sentence: ISentence): IPerform => { const duration = Number(sentence.content); const performName = `wait${Math.random().toString()}`; + const nobreak = getBooleanArgByKey(sentence, 'nobreak') ?? false; + + if (nobreak) { + webgalStore.dispatch(setWaitNoBreak(true)); + setTimeout(() => { + webgalStore.dispatch(setWaitNoBreak(false)); + }, duration); + } + return { performName, duration: duration, diff --git a/packages/webgal/src/store/GUIReducer.ts b/packages/webgal/src/store/GUIReducer.ts index 7d87e2439..392e15f41 100644 --- a/packages/webgal/src/store/GUIReducer.ts +++ b/packages/webgal/src/store/GUIReducer.ts @@ -33,6 +33,7 @@ const initState: IGuiState = { isShowLogo: true, enableAppreciationMode: false, // Paf87 fontOptimization: false, + waitNoBreak: false, }; /** @@ -87,6 +88,10 @@ const GUISlice = createSlice({ setFontOptions: (state, action: PayloadAction) => { state.fontOptions = [...action.payload]; }, + + setWaitNoBreak: (state, action: PayloadAction) => { + state.waitNoBreak = action.payload; + }, }, }); @@ -98,6 +103,7 @@ export const { setEnableAppreciationMode, setFontOptimization, setFontOptions, + setWaitNoBreak, } = GUISlice.actions; export default GUISlice.reducer; diff --git a/packages/webgal/src/store/guiInterface.ts b/packages/webgal/src/store/guiInterface.ts index 1fc4fdaeb..84af0a752 100644 --- a/packages/webgal/src/store/guiInterface.ts +++ b/packages/webgal/src/store/guiInterface.ts @@ -30,6 +30,7 @@ export interface IGuiState { isShowLogo: boolean; enableAppreciationMode: boolean; // Pc102 fontOptimization: boolean; // 字体优化 + waitNoBreak: boolean; // 处于不可中断的等待 } export type componentsVisibility = Pick< From 9920561034a7caaf68b33fbd4423a75886226996 Mon Sep 17 00:00:00 2001 From: xiaoxustudio Date: Thu, 5 Feb 2026 13:44:31 +0800 Subject: [PATCH 09/30] feat: change stop logic --- packages/webgal/src/Core/gameScripts/wait.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/wait.ts b/packages/webgal/src/Core/gameScripts/wait.ts index 51b843ade..dc4b57e4c 100644 --- a/packages/webgal/src/Core/gameScripts/wait.ts +++ b/packages/webgal/src/Core/gameScripts/wait.ts @@ -13,19 +13,16 @@ export const wait = (sentence: ISentence): IPerform => { const performName = `wait${Math.random().toString()}`; const nobreak = getBooleanArgByKey(sentence, 'nobreak') ?? false; - if (nobreak) { - webgalStore.dispatch(setWaitNoBreak(true)); - setTimeout(() => { - webgalStore.dispatch(setWaitNoBreak(false)); - }, duration); - } + if (nobreak) webgalStore.dispatch(setWaitNoBreak(true)); return { performName, duration: duration, goNextWhenOver: true, isHoldOn: false, - stopFunction: () => {}, + stopFunction: () => { + webgalStore.dispatch(setWaitNoBreak(false)); + }, blockingNext: () => false, blockingAuto: () => true, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 From 59491696752cfea90b50164f6d1f87d30488da0a Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 1 Mar 2026 13:47:46 +0800 Subject: [PATCH 10/30] Optimize prefetch --- packages/parser/src/sceneParser.ts | 3 +- .../parser/src/scriptParser/assetsScanner.ts | 13 +- .../parser/src/scriptParser/scriptParser.ts | 3 +- packages/parser/test/parser.test.ts | 2 +- .../webgal/public/webgal-serviceworker.js | 30 +++++ .../controller/gamePlay/scriptExecutor.ts | 2 + .../src/Core/controller/scene/callScene.ts | 6 - .../src/Core/controller/scene/changeScene.ts | 6 - .../controller/storage/jumpFromBacklog.ts | 6 - .../src/Core/controller/storage/loadGame.ts | 6 - packages/webgal/src/Core/initializeScript.ts | 6 - .../Core/util/prefetcher/assetsPrefetcher.ts | 124 +++++++++++++++--- .../util/prefetcher/progressPrefetcher.ts | 57 ++++++++ .../Core/util/prefetcher/scenePrefetcher.ts | 54 ++++++-- 14 files changed, 256 insertions(+), 62 deletions(-) create mode 100644 packages/webgal/src/Core/util/prefetcher/progressPrefetcher.ts diff --git a/packages/parser/src/sceneParser.ts b/packages/parser/src/sceneParser.ts index 68d130c0b..e6528be25 100644 --- a/packages/parser/src/sceneParser.ts +++ b/packages/parser/src/sceneParser.ts @@ -38,12 +38,13 @@ export const sceneParser = ( let assetsList: Array = []; // 场景资源列表 let subSceneList: Array = []; // 子场景列表 const sentenceList: Array = rawSentenceListWithoutEmpty.map( - (sentence) => { + (sentence, index) => { const returnSentence: ISentence = scriptParser( sentence, assetSetter, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG_MAP, + index, ); // 在这里解析出语句可能携带的资源和场景,合并到 assetsList 和 subSceneList assetsList = [...assetsList, ...returnSentence.sentenceAssets]; diff --git a/packages/parser/src/scriptParser/assetsScanner.ts b/packages/parser/src/scriptParser/assetsScanner.ts index cdff61be5..8cf74e03e 100644 --- a/packages/parser/src/scriptParser/assetsScanner.ts +++ b/packages/parser/src/scriptParser/assetsScanner.ts @@ -12,6 +12,7 @@ export const assetsScanner = ( command: commandType, content: string, args: Array, + lineNumber: number, ): Array => { let hasVocalArg = false; const returnAssetsList: Array = []; @@ -22,7 +23,7 @@ export const assetsScanner = ( returnAssetsList.push({ name: e.value as string, url: e.value as string, - lineNumber: 0, + lineNumber, type: fileType.vocal, }); } @@ -36,7 +37,7 @@ export const assetsScanner = ( returnAssetsList.push({ name: content, url: content, - lineNumber: 0, + lineNumber, type: fileType.background, }); } @@ -44,7 +45,7 @@ export const assetsScanner = ( returnAssetsList.push({ name: content, url: content, - lineNumber: 0, + lineNumber, type: fileType.figure, }); } @@ -52,7 +53,7 @@ export const assetsScanner = ( returnAssetsList.push({ name: content, url: content, - lineNumber: 0, + lineNumber, type: fileType.figure, }); } @@ -60,7 +61,7 @@ export const assetsScanner = ( returnAssetsList.push({ name: content, url: content, - lineNumber: 0, + lineNumber, type: fileType.video, }); } @@ -68,7 +69,7 @@ export const assetsScanner = ( returnAssetsList.push({ name: content, url: content, - lineNumber: 0, + lineNumber, type: fileType.bgm, }); } diff --git a/packages/parser/src/scriptParser/scriptParser.ts b/packages/parser/src/scriptParser/scriptParser.ts index 62e6020dc..4ecb68e73 100644 --- a/packages/parser/src/scriptParser/scriptParser.ts +++ b/packages/parser/src/scriptParser/scriptParser.ts @@ -24,6 +24,7 @@ export const scriptParser = ( assetSetter: any, ADD_NEXT_ARG_LIST: commandType[], SCRIPT_CONFIG_MAP: ConfigMap, + lineNumber = 0, ): ISentence => { let command: commandType; // 默认为对话 let content: string; // 语句内容 @@ -105,7 +106,7 @@ export const scriptParser = ( } content = contentParser(newSentenceRaw.trim(), command, assetSetter); // 将语句内容里的文件名转为相对或绝对路径 - sentenceAssets = assetsScanner(command, content, args); // 扫描语句携带资源 + sentenceAssets = assetsScanner(command, content, args, lineNumber); // 扫描语句携带资源 subScene = subSceneScanner(command, content); // 扫描语句携带子场景 return { command: command, // 语句类型 diff --git a/packages/parser/test/parser.test.ts b/packages/parser/test/parser.test.ts index 42b1e1d4d..87d1cd94b 100644 --- a/packages/parser/test/parser.test.ts +++ b/packages/parser/test/parser.test.ts @@ -49,7 +49,7 @@ test("args", async () => { { key: "left", value: true }, { key: "next", value: true } ], - sentenceAssets: [{ name: "m2.png", url: 'm2.png', type: fileType.figure, lineNumber: 0 }], + sentenceAssets: [{ name: "m2.png", url: 'm2.png', type: fileType.figure, lineNumber: 24 }], subScene: [], inlineComment: "" }; diff --git a/packages/webgal/public/webgal-serviceworker.js b/packages/webgal/public/webgal-serviceworker.js index 892ecb1d6..6b3bab90f 100644 --- a/packages/webgal/public/webgal-serviceworker.js +++ b/packages/webgal/public/webgal-serviceworker.js @@ -50,6 +50,24 @@ async function cacheFirst(request) { return response; } +async function prefetchFromMessage(urlString) { + const requestUrl = new URL(urlString, self.location.origin).toString(); + const request = new Request(requestUrl, { method: 'GET' }); + if (!isCriticalGameRequest(request)) { + return; + } + const cache = await caches.open(CACHE_NAME); + const hasCached = await cache.match(requestUrl); + if (hasCached) { + return; + } + const response = await fetch(request); + if (response.ok && response.status === 200) { + await cache.put(requestUrl, response.clone()); + logOnce(`message-cache:${requestUrl}`, 'message cached:', new URL(requestUrl).pathname); + } +} + self.addEventListener('fetch', (event) => { const { request } = event; if (!isCriticalGameRequest(request)) return; @@ -67,3 +85,15 @@ self.addEventListener('fetch', (event) => { }), ); }); + +self.addEventListener('message', (event) => { + const data = event.data || {}; + if (data.type !== 'WEBGAL_PREFETCH_ASSET' || typeof data.url !== 'string') { + return; + } + event.waitUntil( + prefetchFromMessage(data.url).catch((error) => { + console.warn(LOG_PREFIX, 'message prefetch failed:', error); + }), + ); +}); diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 04f5c997b..74e8f6242 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -13,6 +13,7 @@ import { IBacklogItem } from '@/Core/Modules/backlog'; import { SYSTEM_CONFIG } from '@/config'; import { WebGAL } from '@/Core/WebGAL'; import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; +import { prefetchCurrentSceneByProgress } from '@/Core/util/prefetcher/progressPrefetcher'; export const whenChecker = (whenValue: string | undefined): boolean => { if (whenValue === undefined) { @@ -39,6 +40,7 @@ export const whenChecker = (whenValue: string | undefined): boolean => { * 执行语句,同步场景状态,并根据情况立即执行下一句或者加入backlog */ export const scriptExecutor = () => { + prefetchCurrentSceneByProgress(); // 超过总语句数量,则从场景栈拿出一个需要继续的场景,然后继续流程。若场景栈清空,则停止流程 if ( WebGAL.sceneManager.sceneData.currentSentenceId > diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index ead6e3bd1..a131efa50 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -2,8 +2,6 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; -import uniqWith from 'lodash/uniqWith'; -import { scenePrefetcher } from '@/Core/util/prefetcher/scenePrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -28,11 +26,7 @@ export const callScene = (sceneUrl: string, sceneName: string) => { .then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); WebGAL.sceneManager.sceneData.currentSentenceId = 0; - // 开始场景的预加载 - const subSceneList = WebGAL.sceneManager.sceneData.currentScene.subSceneList; WebGAL.sceneManager.settledScenes.push(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - const subSceneListUniq = uniqWith(subSceneList); // 去重 - scenePrefetcher(subSceneListUniq); logger.debug('现在调用场景,调用结果:', WebGAL.sceneManager.sceneData); WebGAL.sceneManager.lockSceneWrite = false; nextSentence(); diff --git a/packages/webgal/src/Core/controller/scene/changeScene.ts b/packages/webgal/src/Core/controller/scene/changeScene.ts index 67df0879a..4128d54a3 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -2,8 +2,6 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; -import uniqWith from 'lodash/uniqWith'; -import { scenePrefetcher } from '@/Core/util/prefetcher/scenePrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -22,11 +20,7 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { .then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); WebGAL.sceneManager.sceneData.currentSentenceId = 0; - // 开始场景的预加载 - const subSceneList = WebGAL.sceneManager.sceneData.currentScene.subSceneList; WebGAL.sceneManager.settledScenes.push(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - const subSceneListUniq = uniqWith(subSceneList); // 去重 - scenePrefetcher(subSceneListUniq); logger.debug('现在切换场景,切换后的结果:', WebGAL.sceneManager.sceneData); WebGAL.sceneManager.lockSceneWrite = false; nextSentence(); diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 2ebf4ce6b..c503d2123 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -8,8 +8,6 @@ import { setVisibility } from '@/store/GUIReducer'; import { runScript } from '@/Core/controller/gamePlay/runScript'; import { stopAllPerform } from '@/Core/controller/gamePlay/stopAllPerform'; import cloneDeep from 'lodash/cloneDeep'; -import uniqWith from 'lodash/uniqWith'; -import { scenePrefetcher } from '@/Core/util/prefetcher/scenePrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -44,11 +42,7 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => { backlogFile.saveScene.sceneName, backlogFile.saveScene.sceneUrl, ); - // 开始场景的预加载 - const subSceneList = WebGAL.sceneManager.sceneData.currentScene.subSceneList; WebGAL.sceneManager.settledScenes.push(WebGAL.sceneManager.sceneData.currentScene.sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - const subSceneListUniq = uniqWith(subSceneList); // 去重 - scenePrefetcher(subSceneListUniq); }); WebGAL.sceneManager.sceneData.currentSentenceId = backlogFile.saveScene.currentSentenceId; WebGAL.sceneManager.sceneData.sceneStack = cloneDeep(backlogFile.saveScene.sceneStack); diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index ae471ab7e..30bedadd7 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -8,8 +8,6 @@ import { setVisibility } from '@/store/GUIReducer'; import { restorePerform } from './jumpFromBacklog'; import { stopAllPerform } from '@/Core/controller/gamePlay/stopAllPerform'; import cloneDeep from 'lodash/cloneDeep'; -import uniqWith from 'lodash/uniqWith'; -import { scenePrefetcher } from '@/Core/util/prefetcher/scenePrefetcher'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { WebGAL } from '@/Core/WebGAL'; @@ -40,11 +38,7 @@ export function loadGameFromStageData(stageData: ISaveData) { loadFile.sceneData.sceneName, loadFile.sceneData.sceneUrl, ); - // 开始场景的预加载 - const subSceneList = WebGAL.sceneManager.sceneData.currentScene.subSceneList; WebGAL.sceneManager.settledScenes.push(WebGAL.sceneManager.sceneData.currentScene.sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - const subSceneListUniq = uniqWith(subSceneList); // 去重 - scenePrefetcher(subSceneListUniq); }); WebGAL.sceneManager.sceneData.currentSentenceId = loadFile.sceneData.currentSentenceId; WebGAL.sceneManager.sceneData.sceneStack = cloneDeep(loadFile.sceneData.sceneStack); diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index 4e3e77f2c..d8ae8339f 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -8,8 +8,6 @@ import { sceneFetcher } from './controller/scene/sceneFetcher'; import { sceneParser } from './parser/sceneParser'; import { bindExtraFunc } from '@/Core/util/coreInitialFunction/bindExtraFunc'; import { webSocketFunc } from '@/Core/util/syncWithEditor/webSocketFunc'; -import uniqWith from 'lodash/uniqWith'; -import { scenePrefetcher } from './util/prefetcher/scenePrefetcher'; import PixiStage from '@/Core/controller/stage/pixi/PixiController'; import axios from 'axios'; import { __INFO } from '@/config/info'; @@ -52,11 +50,7 @@ export const initializeScript = (): void => { // 场景写入到运行时 sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); - // 开始场景的预加载 - const subSceneList = WebGAL.sceneManager.sceneData.currentScene.subSceneList; WebGAL.sceneManager.settledScenes.push(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - const subSceneListUniq = uniqWith(subSceneList); // 去重 - scenePrefetcher(subSceneListUniq); }); /** * 启动Pixi diff --git a/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts b/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts index f3176f0cd..707e8d8c9 100644 --- a/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts +++ b/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts @@ -2,36 +2,130 @@ import { IAsset } from '@/Core/controller/scene/sceneInterface'; import { logger } from '../logger'; import { WebGAL } from '@/Core/WebGAL'; +import { fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; + +interface IAssetsPrefetcherOptions { + /** + * 默认会限制为“场景开头窗口”资源,避免 parser 一次性触发整场景预加载。 + */ + ignoreLineGate?: boolean; +} + +const INITIAL_PARSE_LINE_LOOKAHEAD = 24; +const ASSET_PREFETCH_INTERVAL_MS = 220; +const assetPrefetchQueue: Array = []; +const queuedAssetUrlSet = new Set(); +let isAssetPrefetchQueueRunning = false; + +const uniqueAssetsByUrl = (assetList: Array) => { + const seenUrlSet = new Set(); + return assetList.filter((asset) => { + if (!asset.url || seenUrlSet.has(asset.url)) { + return false; + } + seenUrlSet.add(asset.url); + return true; + }); +}; + +const inferPrefetchAs = (assetType: fileType): string => { + switch (assetType) { + case fileType.background: + case fileType.figure: + return 'image'; + case fileType.bgm: + case fileType.vocal: + return 'audio'; + case fileType.video: + return 'video'; + default: + return ''; + } +}; + +const prefetchByLinkElement = (asset: IAsset) => { + const newLink = document.createElement('link'); + newLink.setAttribute('rel', 'prefetch'); + newLink.setAttribute('href', asset.url); + const prefetchAs = inferPrefetchAs(asset.type); + if (prefetchAs) { + newLink.setAttribute('as', prefetchAs); + } + const head = document.getElementsByTagName('head'); + if (!head.length) { + return; + } + try { + head[0].appendChild(newLink); + } catch (e) { + logger.warn('预加载资源挂载 link 失败:', e); + } +}; + +const prefetchByServiceWorkerMessage = (assetUrl: string): boolean => { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return false; + } + const controller = navigator.serviceWorker.controller; + if (!controller) { + return false; + } + try { + controller.postMessage({ + type: 'WEBGAL_PREFETCH_ASSET', + url: assetUrl, + }); + return true; + } catch (e) { + logger.warn('通过 Service Worker 发送预加载消息失败,将回退 link prefetch:', e); + return false; + } +}; + +const runAssetsPrefetchQueue = () => { + if (isAssetPrefetchQueueRunning || assetPrefetchQueue.length === 0) { + return; + } + isAssetPrefetchQueueRunning = true; + const nextAsset = assetPrefetchQueue.shift() as IAsset; + setTimeout(() => { + const useServiceWorker = prefetchByServiceWorkerMessage(nextAsset.url); + if (!useServiceWorker) { + prefetchByLinkElement(nextAsset); + } + queuedAssetUrlSet.delete(nextAsset.url); + isAssetPrefetchQueueRunning = false; + runAssetsPrefetchQueue(); + }, ASSET_PREFETCH_INTERVAL_MS); +}; /** * 预加载函数 * @param assetList 场景资源列表 */ -export const assetsPrefetcher = (assetList: Array) => { +export const assetsPrefetcher = (assetList: Array, options: IAssetsPrefetcherOptions = {}) => { // @ts-ignore // 未必要移除,加载到内存里也有用 // if (window?.isElectron) { // return; // } - - for (const asset of assetList) { + const filteredAssetList = uniqueAssetsByUrl(assetList).filter((asset) => { + if (options.ignoreLineGate) { + return true; + } + return asset.lineNumber <= INITIAL_PARSE_LINE_LOOKAHEAD; + }); + for (const asset of filteredAssetList) { // 判断是否已经存在 - const hasPrefetch = WebGAL.sceneManager.settledAssets.includes(asset.url); + const hasPrefetch = WebGAL.sceneManager.settledAssets.includes(asset.url) || queuedAssetUrlSet.has(asset.url); if (hasPrefetch) { logger.debug(`该资源${asset.url}已在预加载列表中,无需重复加载`); } else { - const newLink = document.createElement('link'); - newLink.setAttribute('rel', 'prefetch'); - newLink.setAttribute('href', asset.url); - const head = document.getElementsByTagName('head'); - if (head.length) { - try { - head[0].appendChild(newLink); - } catch (e) { - console.log('预加载出错', e); - } - } + logger.info(`现在预加载资源${asset.url},触发行号:${asset.lineNumber}`); WebGAL.sceneManager.settledAssets.push(asset.url); + queuedAssetUrlSet.add(asset.url); + assetPrefetchQueue.push(asset); + runAssetsPrefetchQueue(); } } }; diff --git a/packages/webgal/src/Core/util/prefetcher/progressPrefetcher.ts b/packages/webgal/src/Core/util/prefetcher/progressPrefetcher.ts new file mode 100644 index 000000000..78157a3f7 --- /dev/null +++ b/packages/webgal/src/Core/util/prefetcher/progressPrefetcher.ts @@ -0,0 +1,57 @@ +import { IScene } from '@/Core/controller/scene/sceneInterface'; +import { assetsPrefetcher } from '@/Core/util/prefetcher/assetsPrefetcher'; +import { scenePrefetcher } from '@/Core/util/prefetcher/scenePrefetcher'; +import { WebGAL } from '@/Core/WebGAL'; + +const PROGRESS_ASSET_LOOKAHEAD = 20; +const PROGRESS_SUB_SCENE_LOOKAHEAD = 36; +let lastProgressPrefetchMark = ''; + +const uniqueAssetsByUrl = (scene: IScene, startLine: number, lookahead: number) => { + const assetMap = new Map(); + for (const sentence of scene.sentenceList.slice(startLine, startLine + lookahead + 1)) { + for (const asset of sentence.sentenceAssets) { + if (asset.url && !assetMap.has(asset.url)) { + assetMap.set(asset.url, asset); + } + } + } + return [...assetMap.values()]; +}; + +const uniqueSubScenes = (scene: IScene, startLine: number, lookahead: number) => { + const sceneSet = new Set(); + for (const sentence of scene.sentenceList.slice(startLine, startLine + lookahead + 1)) { + for (const subScene of sentence.subScene) { + if (subScene) { + sceneSet.add(subScene); + } + } + } + return [...sceneSet]; +}; + +export const prefetchSceneByProgress = (scene: IScene, currentSentenceId: number, force = false) => { + if (!scene.sceneUrl) { + return; + } + const mark = `${scene.sceneUrl}#${currentSentenceId}`; + if (!force && mark === lastProgressPrefetchMark) { + return; + } + lastProgressPrefetchMark = mark; + const startLine = Math.max(0, currentSentenceId); + const nextAssets = uniqueAssetsByUrl(scene, startLine, PROGRESS_ASSET_LOOKAHEAD); + const nextSubScenes = uniqueSubScenes(scene, startLine, PROGRESS_SUB_SCENE_LOOKAHEAD); + if (nextAssets.length > 0) { + assetsPrefetcher(nextAssets, { ignoreLineGate: true }); + } + if (nextSubScenes.length > 0) { + scenePrefetcher(nextSubScenes); + } +}; + +export const prefetchCurrentSceneByProgress = (force = false) => { + const { currentScene, currentSentenceId } = WebGAL.sceneManager.sceneData; + prefetchSceneByProgress(currentScene, currentSentenceId, force); +}; diff --git a/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts b/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts index a09822b7d..4e6152d4f 100644 --- a/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts +++ b/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts @@ -8,15 +8,53 @@ import { logger } from '@/Core/util/logger'; import { WebGAL } from '@/Core/WebGAL'; +const SCENE_PREFETCH_INTERVAL_MS = 320; +const scenePrefetchQueue: Array = []; +const queuedSceneUrlSet = new Set(); +let isScenePrefetchQueueRunning = false; + +const uniqueSceneUrls = (sceneList: Array) => [...new Set(sceneList.filter((sceneUrl) => !!sceneUrl))]; + +const runScenePrefetchQueue = () => { + if (isScenePrefetchQueueRunning || scenePrefetchQueue.length === 0) { + return; + } + isScenePrefetchQueueRunning = true; + const sceneUrl = scenePrefetchQueue.shift() as string; + setTimeout(async () => { + queuedSceneUrlSet.delete(sceneUrl); + if (WebGAL.sceneManager.settledScenes.includes(sceneUrl)) { + isScenePrefetchQueueRunning = false; + runScenePrefetchQueue(); + return; + } + WebGAL.sceneManager.settledScenes.push(sceneUrl); + try { + logger.info(`现在预加载场景${sceneUrl}`); + const rawScene = await sceneFetcher(sceneUrl); + // 注意:这里只做深度 1。sceneParser 内部会触发 assetsPrefetcher, + // 并只预加载该子场景前 N 行资源(由 assetsPrefetcher 的 line gate 控制)。 + sceneParser(rawScene, sceneUrl, sceneUrl); + } catch (e) { + logger.error(`场景预加载失败:${sceneUrl}`, e); + const settledIndex = WebGAL.sceneManager.settledScenes.indexOf(sceneUrl); + if (settledIndex !== -1) { + WebGAL.sceneManager.settledScenes.splice(settledIndex, 1); + } + } finally { + isScenePrefetchQueueRunning = false; + runScenePrefetchQueue(); + } + }, SCENE_PREFETCH_INTERVAL_MS); +}; + export const scenePrefetcher = (sceneList: Array): void => { - for (const e of sceneList) { - if (!WebGAL.sceneManager.settledScenes.includes(e)) { - logger.info(`现在预加载场景${e}`); - sceneFetcher(e).then((r) => { - sceneParser(r, e, e); - }); - } else { - logger.warn(`场景${e}已经加载过,无需再次加载`); + for (const sceneUrl of uniqueSceneUrls(sceneList)) { + if (WebGAL.sceneManager.settledScenes.includes(sceneUrl) || queuedSceneUrlSet.has(sceneUrl)) { + continue; } + queuedSceneUrlSet.add(sceneUrl); + scenePrefetchQueue.push(sceneUrl); } + runScenePrefetchQueue(); }; From 34fba0df7d6b488168ef9a0e85c8b68406239e43 Mon Sep 17 00:00:00 2001 From: Hardy--Lee Date: Tue, 24 Mar 2026 17:53:07 +0800 Subject: [PATCH 11/30] fix: resume audio context --- .../src/Core/gameScripts/vocal/index.ts | 118 ++++++++++-------- .../Core/gameScripts/vocal/vocalAnimation.ts | 40 +++++- 2 files changed, 99 insertions(+), 59 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/vocal/index.ts b/packages/webgal/src/Core/gameScripts/vocal/index.ts index 5d2cd0017..a080b957b 100644 --- a/packages/webgal/src/Core/gameScripts/vocal/index.ts +++ b/packages/webgal/src/Core/gameScripts/vocal/index.ts @@ -6,9 +6,11 @@ import { getBooleanArgByKey, getNumberArgByKey, getStringArgByKey } from '@/Core import { IStageState } from '@/store/stageInterface'; import { audioContextWrapper, + ensureAudioContextReady, getAudioLevel, performBlinkAnimation, performMouthAnimation, + resetMaxAudioLevel, updateThresholds, } from '@/Core/gameScripts/vocal/vocalAnimation'; import { match } from '../../util/match'; @@ -64,7 +66,7 @@ export const playVocal = (sentence: ISentence) => { return { arrangePerformPromise: new Promise((resolve) => { // 播放语音 - setTimeout(() => { + setTimeout(async () => { let VocalControl: any = document.getElementById('currentVocal'); // 设置语音音量 webgalStore.dispatch(setStage({ key: 'vocalVolume', value: volume })); @@ -102,10 +104,20 @@ export const playVocal = (sentence: ISentence) => { stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; WebGAL.gameplay.performController.arrangeNewPerform(perform, sentence, false); + const finishPerform = () => { + for (const e of WebGAL.gameplay.performController.performList) { + if (e.performName === performInitName) { + isOver = true; + e.stopFunction(); + WebGAL.gameplay.performController.unmountPerform(e.performName); + } + } + }; + key = key ? key : `fig-${pos}`; const animationItem = figureAssociatedAnimation.find((tid) => tid.targetId === key); if (animationItem) { - let maxAudioLevel = 0; + resetMaxAudioLevel(); const foundFigure = freeFigure.find((figure) => figure.key === key); @@ -113,53 +125,50 @@ export const playVocal = (sentence: ISentence) => { pos = foundFigure.basePosition; } - if (!audioContextWrapper.audioContext) { - let audioContext: AudioContext | null; - audioContext = new AudioContext(); - audioContextWrapper.analyser = audioContext.createAnalyser(); - audioContextWrapper.analyser.fftSize = 256; - audioContextWrapper.dataArray = new Uint8Array(audioContextWrapper.analyser.frequencyBinCount); - } - - if (!audioContextWrapper.analyser) { - audioContextWrapper.analyser = audioContextWrapper.audioContext.createAnalyser(); - audioContextWrapper.analyser.fftSize = 256; - } - - bufferLength = audioContextWrapper.analyser.frequencyBinCount; - audioContextWrapper.dataArray = new Uint8Array(bufferLength); - let vocalControl = document.getElementById('currentVocal') as HTMLMediaElement; - - if (!audioContextWrapper.source || audioContextWrapper.source.mediaElement !== vocalControl) { - if (audioContextWrapper.source) { - audioContextWrapper.source.disconnect(); + const isAudioContextReady = await ensureAudioContextReady(); + if (isAudioContextReady && audioContextWrapper.audioContext) { + if (!audioContextWrapper.analyser) { + audioContextWrapper.analyser = audioContextWrapper.audioContext.createAnalyser(); + audioContextWrapper.analyser.fftSize = 256; } - audioContextWrapper.source = audioContextWrapper.audioContext.createMediaElementSource(vocalControl); - audioContextWrapper.source.connect(audioContextWrapper.analyser!); - } - audioContextWrapper.analyser.connect(audioContextWrapper.audioContext.destination); + bufferLength = audioContextWrapper.analyser.frequencyBinCount; + audioContextWrapper.dataArray = new Uint8Array(bufferLength); + let vocalControl = document.getElementById('currentVocal') as HTMLMediaElement; - // Lip-snc Animation - audioContextWrapper.audioLevelInterval = setInterval(() => { - const audioLevel = getAudioLevel( - audioContextWrapper.analyser!, - audioContextWrapper.dataArray!, - bufferLength, - ); - const { OPEN_THRESHOLD, HALF_OPEN_THRESHOLD } = updateThresholds(audioLevel); + if (!audioContextWrapper.source || audioContextWrapper.source.mediaElement !== vocalControl) { + if (audioContextWrapper.source) { + audioContextWrapper.source.disconnect(); + } + audioContextWrapper.source = audioContextWrapper.audioContext.createMediaElementSource(vocalControl); + audioContextWrapper.source.connect(audioContextWrapper.analyser); + } - performMouthAnimation({ - audioLevel, - OPEN_THRESHOLD, - HALF_OPEN_THRESHOLD, - currentMouthValue, - lerpSpeed, - key, - animationItem, - pos, - }); - }, 50); + audioContextWrapper.analyser.connect(audioContextWrapper.audioContext.destination); + + // Lip-sync Animation + audioContextWrapper.audioLevelInterval = setInterval(() => { + const audioLevel = getAudioLevel( + audioContextWrapper.analyser!, + audioContextWrapper.dataArray!, + bufferLength, + ); + const { OPEN_THRESHOLD, HALF_OPEN_THRESHOLD } = updateThresholds(audioLevel); + + performMouthAnimation({ + audioLevel, + OPEN_THRESHOLD, + HALF_OPEN_THRESHOLD, + currentMouthValue, + lerpSpeed, + key, + animationItem, + pos, + }); + }, 50); + } else { + logger.warn('AudioContext is not ready, skip lip-sync analyzer for this vocal.'); + } // blinkAnimation let animationEndTime: number; @@ -174,17 +183,16 @@ export const playVocal = (sentence: ISentence) => { }, 10000); } - VocalControl?.play(); + const playPromise = VocalControl?.play(); - VocalControl.onended = () => { - for (const e of WebGAL.gameplay.performController.performList) { - if (e.performName === performInitName) { - isOver = true; - e.stopFunction(); - WebGAL.gameplay.performController.unmountPerform(e.performName); - } - } - }; + if (playPromise?.catch) { + playPromise.catch((error: unknown) => { + logger.warn('Vocal play was blocked by browser autoplay policy or audio activation state.', error); + finishPerform(); + }); + } + + VocalControl.onended = finishPerform; } }, 1); }), diff --git a/packages/webgal/src/Core/gameScripts/vocal/vocalAnimation.ts b/packages/webgal/src/Core/gameScripts/vocal/vocalAnimation.ts index e46637006..73e2bb744 100644 --- a/packages/webgal/src/Core/gameScripts/vocal/vocalAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/vocal/vocalAnimation.ts @@ -1,7 +1,7 @@ import { WebGAL } from '@/Core/WebGAL'; interface IAudioContextWrapper { - audioContext: AudioContext; + audioContext: AudioContext | null; source: MediaElementAudioSourceNode | null; analyser: AnalyserNode | undefined; dataArray: Uint8Array | undefined; @@ -12,7 +12,7 @@ interface IAudioContextWrapper { // Initialize the object based on the interface export const audioContextWrapper: IAudioContextWrapper = { - audioContext: new AudioContext(), + audioContext: null, source: null, analyser: undefined, dataArray: undefined, @@ -21,6 +21,34 @@ export const audioContextWrapper: IAudioContextWrapper = { maxAudioLevel: 0, }; +export const ensureAudioContextReady = async (): Promise => { + if (!audioContextWrapper.audioContext) { + const AudioContextCtor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + + if (!AudioContextCtor) { + return false; + } + + audioContextWrapper.audioContext = new AudioContextCtor(); + } + + if (audioContextWrapper.audioContext.state === 'suspended') { + try { + await audioContextWrapper.audioContext.resume(); + } catch { + return false; + } + } + + return audioContextWrapper.audioContext.state === 'running'; +}; + +export const resetMaxAudioLevel = () => { + audioContextWrapper.maxAudioLevel = 0; +}; + export const updateThresholds = (audioLevel: number) => { audioContextWrapper.maxAudioLevel = Math.max(audioLevel, audioContextWrapper.maxAudioLevel); return { @@ -52,8 +80,12 @@ export const performBlinkAnimation = (params: { }; // Updated getAudioLevel function -export const getAudioLevel = (analyser: AnalyserNode, dataArray: Uint8Array, bufferLength: number): number => { - analyser.getByteFrequencyData(dataArray); +export const getAudioLevel = ( + analyser: AnalyserNode, + dataArray: Uint8Array, + bufferLength: number, +): number => { + analyser.getByteFrequencyData(dataArray as any); let sum = 0; for (let i = 0; i < bufferLength; i++) { sum += dataArray[i]; From da58391ebde5c18bb254d3571df39df4f84ec09c Mon Sep 17 00:00:00 2001 From: Mahiru Date: Wed, 25 Mar 2026 22:28:56 +0800 Subject: [PATCH 12/30] refactor: prefetch --- packages/webgal/src/Core/Modules/scene.ts | 6 ++-- .../src/Core/controller/scene/callScene.ts | 4 ++- .../src/Core/controller/scene/changeScene.ts | 4 ++- .../controller/storage/jumpFromBacklog.ts | 2 +- .../src/Core/controller/storage/loadGame.ts | 2 +- packages/webgal/src/Core/initializeScript.ts | 2 +- .../Core/util/prefetcher/assetsPrefetcher.ts | 32 ++++++++++++++----- .../Core/util/prefetcher/scenePrefetcher.ts | 14 ++++---- 8 files changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/webgal/src/Core/Modules/scene.ts b/packages/webgal/src/Core/Modules/scene.ts index 1f72069f1..89816968b 100644 --- a/packages/webgal/src/Core/Modules/scene.ts +++ b/packages/webgal/src/Core/Modules/scene.ts @@ -24,8 +24,8 @@ export const initSceneData = { }; export class SceneManager { - public settledScenes: Array = []; - public settledAssets: Array = []; + public settledScenes: Set = new Set(); + public settledAssets: Set = new Set(); public sceneData: ISceneData = cloneDeep(initSceneData); public lockSceneWrite = false; @@ -33,5 +33,7 @@ export class SceneManager { this.sceneData.currentSentenceId = 0; this.sceneData.sceneStack = []; this.sceneData.currentScene = cloneDeep(initSceneData.currentScene); + this.settledScenes.clear(); + this.settledAssets.clear(); } } diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index a131efa50..53aadd554 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -2,6 +2,7 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -26,7 +27,8 @@ export const callScene = (sceneUrl: string, sceneName: string) => { .then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); WebGAL.sceneManager.sceneData.currentSentenceId = 0; - WebGAL.sceneManager.settledScenes.push(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + clearPrefetchLinks(); + WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 logger.debug('现在调用场景,调用结果:', WebGAL.sceneManager.sceneData); WebGAL.sceneManager.lockSceneWrite = false; nextSentence(); diff --git a/packages/webgal/src/Core/controller/scene/changeScene.ts b/packages/webgal/src/Core/controller/scene/changeScene.ts index 4128d54a3..4bac9b37d 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -2,6 +2,7 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -20,7 +21,8 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { .then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); WebGAL.sceneManager.sceneData.currentSentenceId = 0; - WebGAL.sceneManager.settledScenes.push(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + clearPrefetchLinks(); + WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 logger.debug('现在切换场景,切换后的结果:', WebGAL.sceneManager.sceneData); WebGAL.sceneManager.lockSceneWrite = false; nextSentence(); diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index c503d2123..81a459a8a 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -42,7 +42,7 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => { backlogFile.saveScene.sceneName, backlogFile.saveScene.sceneUrl, ); - WebGAL.sceneManager.settledScenes.push(WebGAL.sceneManager.sceneData.currentScene.sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.sceneManager.settledScenes.add(WebGAL.sceneManager.sceneData.currentScene.sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 }); WebGAL.sceneManager.sceneData.currentSentenceId = backlogFile.saveScene.currentSentenceId; WebGAL.sceneManager.sceneData.sceneStack = cloneDeep(backlogFile.saveScene.sceneStack); diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index 30bedadd7..865000bee 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -38,7 +38,7 @@ export function loadGameFromStageData(stageData: ISaveData) { loadFile.sceneData.sceneName, loadFile.sceneData.sceneUrl, ); - WebGAL.sceneManager.settledScenes.push(WebGAL.sceneManager.sceneData.currentScene.sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.sceneManager.settledScenes.add(WebGAL.sceneManager.sceneData.currentScene.sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 }); WebGAL.sceneManager.sceneData.currentSentenceId = loadFile.sceneData.currentSentenceId; WebGAL.sceneManager.sceneData.sceneStack = cloneDeep(loadFile.sceneData.sceneStack); diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index a9eb608c2..2cba7ae38 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -49,7 +49,7 @@ export const initializeScript = (): void => { // 场景写入到运行时 sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); - WebGAL.sceneManager.settledScenes.push(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 }); /** * 启动Pixi diff --git a/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts b/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts index 707e8d8c9..712e36402 100644 --- a/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts +++ b/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts @@ -89,16 +89,32 @@ const runAssetsPrefetchQueue = () => { isAssetPrefetchQueueRunning = true; const nextAsset = assetPrefetchQueue.shift() as IAsset; setTimeout(() => { - const useServiceWorker = prefetchByServiceWorkerMessage(nextAsset.url); - if (!useServiceWorker) { - prefetchByLinkElement(nextAsset); + try { + const useServiceWorker = prefetchByServiceWorkerMessage(nextAsset.url); + if (!useServiceWorker) { + prefetchByLinkElement(nextAsset); + } + } catch (e) { + logger.warn(`预加载资源失败,将允许重试:${nextAsset.url}`, e); + WebGAL.sceneManager.settledAssets.delete(nextAsset.url); + } finally { + queuedAssetUrlSet.delete(nextAsset.url); + isAssetPrefetchQueueRunning = false; + runAssetsPrefetchQueue(); } - queuedAssetUrlSet.delete(nextAsset.url); - isAssetPrefetchQueueRunning = false; - runAssetsPrefetchQueue(); }, ASSET_PREFETCH_INTERVAL_MS); }; +/** + * 清理 中的 prefetch link 元素,在切换场景时调用以避免累积。 + */ +export const clearPrefetchLinks = () => { + const head = document.getElementsByTagName('head')[0]; + if (!head) return; + const links = head.querySelectorAll('link[rel="prefetch"]'); + links.forEach((link) => link.remove()); +}; + /** * 预加载函数 * @param assetList 场景资源列表 @@ -117,12 +133,12 @@ export const assetsPrefetcher = (assetList: Array, options: IAssetsPrefe }); for (const asset of filteredAssetList) { // 判断是否已经存在 - const hasPrefetch = WebGAL.sceneManager.settledAssets.includes(asset.url) || queuedAssetUrlSet.has(asset.url); + const hasPrefetch = WebGAL.sceneManager.settledAssets.has(asset.url) || queuedAssetUrlSet.has(asset.url); if (hasPrefetch) { logger.debug(`该资源${asset.url}已在预加载列表中,无需重复加载`); } else { logger.info(`现在预加载资源${asset.url},触发行号:${asset.lineNumber}`); - WebGAL.sceneManager.settledAssets.push(asset.url); + WebGAL.sceneManager.settledAssets.add(asset.url); queuedAssetUrlSet.add(asset.url); assetPrefetchQueue.push(asset); runAssetsPrefetchQueue(); diff --git a/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts b/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts index 4e6152d4f..30eee280b 100644 --- a/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts +++ b/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts @@ -22,13 +22,14 @@ const runScenePrefetchQueue = () => { isScenePrefetchQueueRunning = true; const sceneUrl = scenePrefetchQueue.shift() as string; setTimeout(async () => { - queuedSceneUrlSet.delete(sceneUrl); - if (WebGAL.sceneManager.settledScenes.includes(sceneUrl)) { + if (WebGAL.sceneManager.settledScenes.has(sceneUrl)) { + queuedSceneUrlSet.delete(sceneUrl); isScenePrefetchQueueRunning = false; runScenePrefetchQueue(); return; } - WebGAL.sceneManager.settledScenes.push(sceneUrl); + WebGAL.sceneManager.settledScenes.add(sceneUrl); + queuedSceneUrlSet.delete(sceneUrl); try { logger.info(`现在预加载场景${sceneUrl}`); const rawScene = await sceneFetcher(sceneUrl); @@ -37,10 +38,7 @@ const runScenePrefetchQueue = () => { sceneParser(rawScene, sceneUrl, sceneUrl); } catch (e) { logger.error(`场景预加载失败:${sceneUrl}`, e); - const settledIndex = WebGAL.sceneManager.settledScenes.indexOf(sceneUrl); - if (settledIndex !== -1) { - WebGAL.sceneManager.settledScenes.splice(settledIndex, 1); - } + WebGAL.sceneManager.settledScenes.delete(sceneUrl); } finally { isScenePrefetchQueueRunning = false; runScenePrefetchQueue(); @@ -50,7 +48,7 @@ const runScenePrefetchQueue = () => { export const scenePrefetcher = (sceneList: Array): void => { for (const sceneUrl of uniqueSceneUrls(sceneList)) { - if (WebGAL.sceneManager.settledScenes.includes(sceneUrl) || queuedSceneUrlSet.has(sceneUrl)) { + if (WebGAL.sceneManager.settledScenes.has(sceneUrl) || queuedSceneUrlSet.has(sceneUrl)) { continue; } queuedSceneUrlSet.add(sceneUrl); From 46b78b7de4a5891f06502020b293198b5bef423f Mon Sep 17 00:00:00 2001 From: Hardy--Lee Date: Wed, 1 Apr 2026 08:34:39 +0800 Subject: [PATCH 13/30] fix: fade ebg when bg is empty --- .../webgal/src/Core/gameScripts/changeBg/setEbg.ts | 10 +++++++--- packages/webgal/src/Stage/MainStage/useSetBg.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts b/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts index d4f93cc90..b9c8b8493 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts @@ -1,8 +1,9 @@ import { DEFAULT_BG_IN_DURATION } from '@/Core/constants'; let previousImageUrl = ''; +let animation: Animation | null = null; -export function setEbg(url: string, duration = DEFAULT_BG_IN_DURATION) { +export function setEbg(url: string, duration = DEFAULT_BG_IN_DURATION, ease = 'ease-in-out') { const ebg = document.getElementById('ebg') as HTMLElement; if (ebg) { ebg.style.backgroundImage = `url("${url}")`; @@ -10,9 +11,12 @@ export function setEbg(url: string, duration = DEFAULT_BG_IN_DURATION) { const ebgOverlay = document.getElementById('ebgOverlay') as HTMLElement; if (ebgOverlay) { ebgOverlay.style.backgroundImage = `url("${previousImageUrl}")`; - ebgOverlay.animate([{ opacity: 1 }, { opacity: 0 }], { + if (animation) { + animation.cancel(); + } + animation = ebgOverlay.animate([{ opacity: 1 }, { opacity: 0 }], { duration: duration, - easing: 'ease-in-out', + easing: ease, }); } previousImageUrl = url; diff --git a/packages/webgal/src/Stage/MainStage/useSetBg.ts b/packages/webgal/src/Stage/MainStage/useSetBg.ts index d196fe802..2c2a684be 100644 --- a/packages/webgal/src/Stage/MainStage/useSetBg.ts +++ b/packages/webgal/src/Stage/MainStage/useSetBg.ts @@ -6,6 +6,7 @@ import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { getEnterExitAnimation } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; +import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; export function useSetBg(stageState: IStageState) { const bgName = stageState.bgName; @@ -29,15 +30,17 @@ export function useSetBg(stageState: IStageState) { WebGAL.gameplay.pixiStage!.registerPresetAnimation(animation, 'bg-main-softin', thisBgKey, stageState.effects); setTimeout(() => WebGAL.gameplay.pixiStage!.removeAnimationWithSetEffects('bg-main-softin'), duration); } else { + let exitDuration = DEFAULT_BG_OUT_DURATION; const currentBg = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisBgKey); if (currentBg) { - removeBg(currentBg); + exitDuration = removeBg(currentBg); } + setEbg(bgName, exitDuration, 'cubic-bezier(0.5, 0, 0.75, 0)'); } }, [bgName]); } -function removeBg(bgObject: IStageObject) { +function removeBg(bgObject: IStageObject): number { WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects('bg-main-softin'); const oldBgKey = bgObject.key; bgObject.key = 'bg-main-off' + String(new Date().getTime()); @@ -50,6 +53,7 @@ function removeBg(bgObject: IStageObject) { WebGAL.gameplay.pixiStage?.removeAnimation(bgAniKey); WebGAL.gameplay.pixiStage?.removeStageObjectByKey(bgKey); }, duration); + return duration; } function addBg(type?: 'image' | 'spine', ...args: any[]) { From a2221896b4b5c653855a4f9bce1e265c6d59cf00 Mon Sep 17 00:00:00 2001 From: Hardy--Lee Date: Wed, 1 Apr 2026 09:05:51 +0800 Subject: [PATCH 14/30] fix: avoid unnecessary resource requests --- .../webgal/src/Core/gameScripts/changeBg/setEbg.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts b/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts index b9c8b8493..70922f6f9 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts @@ -6,11 +6,11 @@ let animation: Animation | null = null; export function setEbg(url: string, duration = DEFAULT_BG_IN_DURATION, ease = 'ease-in-out') { const ebg = document.getElementById('ebg') as HTMLElement; if (ebg) { - ebg.style.backgroundImage = `url("${url}")`; + ebg.style.backgroundImage = getValidBgImage(url); } const ebgOverlay = document.getElementById('ebgOverlay') as HTMLElement; if (ebgOverlay) { - ebgOverlay.style.backgroundImage = `url("${previousImageUrl}")`; + ebgOverlay.style.backgroundImage = getValidBgImage(previousImageUrl); if (animation) { animation.cancel(); } @@ -21,3 +21,12 @@ export function setEbg(url: string, duration = DEFAULT_BG_IN_DURATION, ease = 'e } previousImageUrl = url; } + +function getValidBgImage(url: string): string { + if (url === '') { + return 'none'; + } else { + return `url("${url}")`; + } +} + From 93aa855f66f675183df131052173abb6f9a01d89 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Thu, 9 Apr 2026 00:31:38 +0800 Subject: [PATCH 15/30] fix: parallel perform dedup & prefix matching for correct save/load - Use unique performName (baseName#animationName) for parallel performs - Add prefix matching in unmountPerform and removePerformByName - Fix force path splice ordering (before goNextWhenOver) - Use baseTransform as default in updateEffect else path - Remove isParallel dedup guard (unique IDs handle it naturally) --- .../Core/Modules/perform/performController.ts | 44 ++++++++++++------- .../Core/Modules/perform/performInterface.ts | 2 +- .../src/Core/gameScripts/setAnimation.ts | 3 +- .../src/Core/gameScripts/setTempAnimation.ts | 3 +- .../src/Core/gameScripts/setTransform.ts | 3 +- packages/webgal/src/store/stageReducer.ts | 7 +-- 6 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index e3569fdd2..43e24f44b 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -18,20 +18,27 @@ export const getRandomPerformName = (): string => { export class PerformController { public performList: Array = []; + /** + * 判断 perform 名称是否匹配(支持前缀匹配,用于清理并行演出) + * 并行演出的 performName 格式为 "baseName#uuid",匹配时需要同时命中精确匹配和前缀匹配 + */ + private matchPerformName(performName: string, name: string): boolean { + return performName === name || performName.startsWith(name + '#'); + } + public arrangeNewPerform(perform: IPerform, script: ISentence, syncPerformState = true) { - if (!perform.isParallel) { - // 检查演出列表内是否有相同的演出,如果有,一定是出了什么问题 - const dupPerformIndex = this.performList.findIndex((p) => p.performName === perform.performName); - if (dupPerformIndex > -1) { - // 结束并删除全部重复演出 - for (let i = 0; i < this.performList.length; i++) { - const e = this.performList[i]; - if (e.performName === perform.performName) { - e.stopFunction(); - clearTimeout(e.stopTimeout as unknown as number); - this.performList.splice(i, 1); - i--; - } + // 检查演出列表内是否有相同的演出,如果有,一定是出了什么问题 + // 并行演出的 performName 带有唯一后缀,因此不会命中去重 + const dupPerformIndex = this.performList.findIndex((p) => p.performName === perform.performName); + if (dupPerformIndex > -1) { + // 结束并删除全部重复演出 + for (let i = 0; i < this.performList.length; i++) { + const e = this.performList[i]; + if (e.performName === perform.performName) { + e.stopFunction(); + clearTimeout(e.stopTimeout as unknown as number); + this.performList.splice(i, 1); + i--; } } } @@ -66,7 +73,7 @@ export class PerformController { if (!force) { for (let i = 0; i < this.performList.length; i++) { const e = this.performList[i]; - if (!e.isHoldOn && e.performName === name) { + if (!e.isHoldOn && this.matchPerformName(e.performName, name)) { e.stopFunction(); clearTimeout(e.stopTimeout as unknown as number); /** @@ -87,15 +94,18 @@ export class PerformController { } else { for (let i = 0; i < this.performList.length; i++) { const e = this.performList[i]; - if (e.performName === name) { + if (this.matchPerformName(e.performName, name)) { e.stopFunction(); clearTimeout(e.stopTimeout as unknown as number); + /** + * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前(同上) + */ + this.performList.splice(i, 1); + i--; if (e.goNextWhenOver) { // nextSentence(); this.goNextWhenOver(); } - this.performList.splice(i, 1); - i--; /** * 从状态表里清除演出 */ diff --git a/packages/webgal/src/Core/Modules/perform/performInterface.ts b/packages/webgal/src/Core/Modules/perform/performInterface.ts index 5431e7dd4..2b6dda62d 100644 --- a/packages/webgal/src/Core/Modules/perform/performInterface.ts +++ b/packages/webgal/src/Core/Modules/perform/performInterface.ts @@ -23,7 +23,7 @@ export interface IPerform { arrangePerformPromise?: Promise; // 跳过由 nextSentence 函数引发的演出回收 skipNextCollect?: boolean; - // + // 标记此演出是否为并行演出,允许同一 target 上同时运行多个互不干扰的演出 isParallel?: boolean; } diff --git a/packages/webgal/src/Core/gameScripts/setAnimation.ts b/packages/webgal/src/Core/gameScripts/setAnimation.ts index 71acff411..ad8967f01 100644 --- a/packages/webgal/src/Core/gameScripts/setAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setAnimation.ts @@ -23,6 +23,7 @@ export const setAnimation = (sentence: ISentence): IPerform => { const key = `${target}-${animationName}-${animationDuration}`; const performInitName = `animation-${target}`; + const performName = parallel ? `${performInitName}#${animationName}` : performInitName; let keepAnimationStopped = false; if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); @@ -56,7 +57,7 @@ export const setAnimation = (sentence: ISentence): IPerform => { }; return { - performName: performInitName, + performName: performName, duration: animationDuration, isHoldOn: keep, stopFunction, diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index c7da662a4..aea5bda54 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -35,6 +35,7 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { const key = `${target}-${animationName}-${animationDuration}`; const performInitName = `animation-${target}`; + const performName = parallel ? `${performInitName}#${animationName}` : performInitName; let keepAnimationStopped = false; if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); @@ -68,7 +69,7 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { }; return { - performName: performInitName, + performName: performName, duration: animationDuration, isHoldOn: keep, stopFunction, diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index ca4bd9090..3268c8adf 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -29,6 +29,7 @@ export const setTransform = (sentence: ISentence): IPerform => { const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; const performInitName = `animation-${target}`; + const performName = parallel ? `${performInitName}#${animationName}` : performInitName; if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); @@ -75,7 +76,7 @@ export const setTransform = (sentence: ISentence): IPerform => { }; return { - performName: performInitName, + performName: performName, duration: animationDuration, isHoldOn: keep, stopFunction, diff --git a/packages/webgal/src/store/stageReducer.ts b/packages/webgal/src/store/stageReducer.ts index 277e7e78c..cb3600945 100644 --- a/packages/webgal/src/store/stageReducer.ts +++ b/packages/webgal/src/store/stageReducer.ts @@ -137,10 +137,10 @@ const stageSlice = createSlice({ state.effects[effectIndex].transform!.position = targetPosition; } } else { - // Add a new effect + // Add a new effect, use baseTransform as default to ensure completeness state.effects.push({ target, - transform, + transform: transform ? { ...baseTransform, ...transform } : { ...baseTransform }, }); } }, @@ -213,9 +213,10 @@ const stageSlice = createSlice({ state.PerformList.push(action.payload); }, removePerformByName: (state, action: PayloadAction) => { + const name = action.payload; for (let i = 0; i < state.PerformList.length; i++) { const performItem: IRunPerform = state.PerformList[i]; - if (performItem.id === action.payload) { + if (performItem.id === name || performItem.id.startsWith(name + '#')) { state.PerformList.splice(i, 1); i--; } From ebcfd2620e4944b69109bcb89431cd8e52b1eafe Mon Sep 17 00:00:00 2001 From: ChangeSuger Date: Thu, 16 Apr 2026 00:42:26 +0800 Subject: [PATCH 16/30] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8C=BA=E5=88=86=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/template/Stage/TextBox/textbox.scss | 4 + packages/webgal/src/Core/Modules/backlog.ts | 2 + .../webgal/src/Core/Modules/readHistory.ts | 103 ++++++++++++++++++ .../controller/gamePlay/scriptExecutor.ts | 1 + .../src/Core/controller/stage/resetStage.ts | 1 + packages/webgal/src/Core/webgalCore.ts | 2 + .../webgal/src/Stage/TextBox/IMSSTextbox.tsx | 3 +- packages/webgal/src/Stage/TextBox/TextBox.tsx | 2 + .../src/Stage/TextBox/textbox.module.scss | 4 + packages/webgal/src/Stage/TextBox/types.ts | 1 + packages/webgal/src/store/stageInterface.ts | 1 + packages/webgal/src/store/stageReducer.ts | 1 + .../webgal/src/store/userDataInterface.ts | 1 + packages/webgal/src/store/userDataReducer.ts | 5 + 14 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/webgal/src/Core/Modules/readHistory.ts diff --git a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss index b2bd03c01..b75eb2480 100644 --- a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss +++ b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss @@ -176,3 +176,7 @@ .text { overflow: hidden; } + +.read { + color: rgb(237, 162, 162); +} diff --git a/packages/webgal/src/Core/Modules/backlog.ts b/packages/webgal/src/Core/Modules/backlog.ts index 8d9c9e8c8..e17571de9 100644 --- a/packages/webgal/src/Core/Modules/backlog.ts +++ b/packages/webgal/src/Core/Modules/backlog.ts @@ -42,6 +42,8 @@ export class BacklogManager { // 存一下 Backlog const currentStageState = webgalStore.getState().stage; const stageStateToBacklog = cloneDeep(currentStageState); + // 确保原先未读的文本在使用 backlog 时能正确显示为已读文本 + stageStateToBacklog.isRead = true; stageStateToBacklog.PerformList.forEach((ele) => { ele.script.args.forEach((argelement) => { if (argelement.key === 'concat') { diff --git a/packages/webgal/src/Core/Modules/readHistory.ts b/packages/webgal/src/Core/Modules/readHistory.ts new file mode 100644 index 000000000..03dc486d6 --- /dev/null +++ b/packages/webgal/src/Core/Modules/readHistory.ts @@ -0,0 +1,103 @@ +/** + * 已读历史记录 + */ + +import { webgalStore } from "@/store/store"; +import { SceneManager } from "./scene"; +import { setReadHistory } from "@/store/userDataReducer"; +import { setStage } from "@/store/stageReducer"; +import { setStorage } from "../controller/storage/storageController"; + +export class ReadHistoryManager { + private history: Map = new Map(); + + private load: boolean = false; + + private readonly sceneManager: SceneManager; + + public constructor(sceneManager: SceneManager) { + this.sceneManager = sceneManager; + } + + private loadReadHistory() { + const readHistory = webgalStore.getState().userData.readHistory; + + Object.entries(readHistory).forEach(([key, value]) => { + try { + const uint8 = Uint8Array.from(Buffer.from(value, 'base64')); + this.history.set(key, uint8); + } catch { + // 浏览器环境下没有 Buffer 时的兜底逻辑 + const binary = atob(value); + const uint8 = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + uint8[i] = binary.charCodeAt(i); + } + console.log('Camille Load', uint8); + this.history.set(key, uint8); + } + }); + + this.load = true; + } + + private checkLoad() { + if (!this.load) { + this.loadReadHistory(); + } + } + + private saveReadHistory(key: string) { + const bitset = this.history.get(key)!; + + try { + const base64 = Buffer.from(bitset).toString('base64'); + webgalStore.dispatch(setReadHistory({ + key, + value: base64, + })); + } catch { + // 浏览器环境下没有 Buffer 时的兜底逻辑 + const base64 = btoa(String.fromCharCode(...bitset)); + webgalStore.dispatch(setReadHistory({ + key, + value: base64, + })); + } + setStorage(); + } + + private addReadHistory() { + const scenarioName = this.sceneManager.sceneData.currentScene.sceneName; + const index = this.sceneManager.sceneData.currentSentenceId; + + if (!this.history.has(scenarioName)) { + const length = this.sceneManager.sceneData.currentScene.sentenceList.length; + this.history.set(scenarioName, new Uint8Array(Math.ceil(length / 8))); + } + const bitset = this.history.get(scenarioName)!; + bitset[index >> 3] |= (1 << (index & 7)); + + this.saveReadHistory(scenarioName); + } + + public checkIsReaded() { + this.checkLoad(); + + const scenarioName = this.sceneManager.sceneData.currentScene.sceneName; + const index = this.sceneManager.sceneData.currentSentenceId; + + let isReaded = false; + if (this.history.has(scenarioName)) { + const bitset = this.history.get(scenarioName)!; + isReaded = (bitset[index >> 3] & (1 << (index & 7))) !== 0; + } + webgalStore.dispatch(setStage({ + key: 'isRead', + value: isReaded, + })); + if (!isReaded) { + this.addReadHistory(); + } + } +} diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 04f5c997b..ceeb5d2f9 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -100,6 +100,7 @@ export const scriptExecutor = () => { nextSentence(); return; } + WebGAL.readHistoryManager.checkIsReaded(); runScript(currentScript); // 是否要进行下一句 let isNext = getBooleanArgByKey(currentScript, 'next') ?? false; diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index b72573834..ffe1f7823 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -2,6 +2,7 @@ import { initState, resetStageState, setStage } from '@/store/stageReducer'; import { webgalStore } from '@/store/store'; import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; +import { saveActions } from '@/store/savesReducer'; export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { /** diff --git a/packages/webgal/src/Core/webgalCore.ts b/packages/webgal/src/Core/webgalCore.ts index 98d1c53ec..71e85d770 100644 --- a/packages/webgal/src/Core/webgalCore.ts +++ b/packages/webgal/src/Core/webgalCore.ts @@ -1,4 +1,5 @@ import { BacklogManager } from '@/Core/Modules/backlog'; +import { ReadHistoryManager } from './Modules/readHistory'; import mitt from 'mitt'; import { SceneManager } from '@/Core/Modules/scene'; import { AnimationManager } from '@/Core/Modules/animations'; @@ -11,6 +12,7 @@ import { IWebGALStyleObj } from 'webgal-parser/build/types/styleParser'; export class WebgalCore { public sceneManager = new SceneManager(); public backlogManager = new BacklogManager(this.sceneManager); + public readHistoryManager = new ReadHistoryManager(this.sceneManager); public animationManager = new AnimationManager(); public gameplay = new Gameplay(); public gameName = ''; diff --git a/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx b/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx index f603f2792..e3eec5350 100644 --- a/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx +++ b/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx @@ -14,6 +14,7 @@ export default function IMSSTextbox(props: ITextboxProps) { textDelay, currentConcatDialogPrev, currentDialogKey, + isRead, isText, isSafari, isFirefox: boolean, @@ -167,7 +168,7 @@ export default function IMSSTextbox(props: ITextboxProps) { > {e} - {e} + {e} {isUseStroke && {e}} diff --git a/packages/webgal/src/Stage/TextBox/TextBox.tsx b/packages/webgal/src/Stage/TextBox/TextBox.tsx index dea485b2d..f6c1d028c 100644 --- a/packages/webgal/src/Stage/TextBox/TextBox.tsx +++ b/packages/webgal/src/Stage/TextBox/TextBox.tsx @@ -27,6 +27,7 @@ export const TextBox = () => { const textDuration = useTextAnimationDuration(userDataState.optionData.textSpeed); let size = getTextSize(userDataState.optionData.textSize) + '%'; const font = useFontFamily(); + const isRead = stageState.isRead; const isText = stageState.showText !== '' || stageState.showName !== ''; let textSizeState = userDataState.optionData.textSize; if (isText && stageState.showTextSize !== -1) { @@ -88,6 +89,7 @@ export const TextBox = () => { return ( ; figureAssociatedAnimation: Array; + isRead: boolean; // 是否已读 showText: string; // 文字 showTextSize: number; // 文字 showName: string; // 人物名 diff --git a/packages/webgal/src/store/stageReducer.ts b/packages/webgal/src/store/stageReducer.ts index ac382f44a..c2f463221 100644 --- a/packages/webgal/src/store/stageReducer.ts +++ b/packages/webgal/src/store/stageReducer.ts @@ -35,6 +35,7 @@ export const initState: IStageState = { figNameRight: '', // 立绘_右 文件地址(相对或绝对) freeFigure: [], figureAssociatedAnimation: [], + isRead: false, showText: '', // 文字 showTextSize: -1, showName: '', // 人物名 diff --git a/packages/webgal/src/store/userDataInterface.ts b/packages/webgal/src/store/userDataInterface.ts index 111b0e1c9..355651e27 100644 --- a/packages/webgal/src/store/userDataInterface.ts +++ b/packages/webgal/src/store/userDataInterface.ts @@ -91,6 +91,7 @@ export interface IUserData { optionData: IOptionData; // 用户设置选项数据 appreciationData: IAppreciation; gameConfigInit: IGameVar; + readHistory: Record; } export interface ISetUserDataPayload { diff --git a/packages/webgal/src/store/userDataReducer.ts b/packages/webgal/src/store/userDataReducer.ts index d7cd5fa9a..88e45d118 100644 --- a/packages/webgal/src/store/userDataReducer.ts +++ b/packages/webgal/src/store/userDataReducer.ts @@ -47,6 +47,7 @@ export const initState: IUserData = { cg: [], }, gameConfigInit: {}, + readHistory: {}, }; const userDataSlice = createSlice({ @@ -142,6 +143,9 @@ const userDataSlice = createSlice({ const { gameConfigInit } = state; Object.assign(state, { ...cloneDeep(initState), globalGameVar: cloneDeep(gameConfigInit), gameConfigInit }); }, + setReadHistory: (state, action: PayloadAction>) => { + state.readHistory[action.payload.key] = action.payload.value; + }, }, }); @@ -156,6 +160,7 @@ export const { unlockBgmInUserData, resetOptionSet, resetAllData, + setReadHistory, } = userDataSlice.actions; export default userDataSlice.reducer; From 916b57d97dce2c29df92224eb6233e8ece1fd000 Mon Sep 17 00:00:00 2001 From: ChangeSuger Date: Thu, 16 Apr 2026 01:37:44 +0800 Subject: [PATCH 17/30] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E5=BF=AB=E8=BF=9B=E4=B8=8E=E5=85=A8=E6=96=87=E5=BF=AB?= =?UTF-8?q?=E8=BF=9B=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webgal/src/Core/controller/gamePlay/fastSkip.ts | 7 ++++++- .../webgal/src/UI/Menu/Options/System/System.tsx | 13 +++++++++++++ packages/webgal/src/hooks/useHotkey.tsx | 3 ++- packages/webgal/src/store/userDataInterface.ts | 1 + packages/webgal/src/store/userDataReducer.ts | 1 + packages/webgal/src/translations/de.ts | 7 +++++++ packages/webgal/src/translations/en.ts | 7 +++++++ packages/webgal/src/translations/fr.ts | 7 +++++++ packages/webgal/src/translations/jp.ts | 7 +++++++ packages/webgal/src/translations/zh-cn.ts | 7 +++++++ packages/webgal/src/translations/zh-tw.ts | 7 +++++++ 11 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts index 8798f3243..9e3fd2d1e 100644 --- a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts +++ b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts @@ -4,6 +4,7 @@ import styles from '@/UI/BottomControlPanel/bottomControlPanel.module.scss'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; import { WebGAL } from '@/Core/WebGAL'; +import { webgalStore } from "@/store/store"; import { SYSTEM_CONFIG } from '@/config'; /** @@ -36,12 +37,16 @@ export const stopFast = () => { /** * 开启快进 */ -export const startFast = () => { +export const startFast = (force = false) => { if (isFast()) { return; } WebGAL.gameplay.isFast = true; + const skipAll = force || webgalStore.getState().userData.optionData.skipAll; WebGAL.gameplay.fastInterval = setInterval(() => { + if (!skipAll && !webgalStore.getState().stage.isRead) { + stopFast(); + } nextSentence(); }, SYSTEM_CONFIG.fast_timeout); }; diff --git a/packages/webgal/src/UI/Menu/Options/System/System.tsx b/packages/webgal/src/UI/Menu/Options/System/System.tsx index 00333ee94..4b7a69580 100644 --- a/packages/webgal/src/UI/Menu/Options/System/System.tsx +++ b/packages/webgal/src/UI/Menu/Options/System/System.tsx @@ -116,6 +116,19 @@ export function System() { }} /> + + { + dispatch(setOptionData({ key: 'skipAll', value: false })); + setStorage(); + }, () => { + dispatch(setOptionData({ key: 'skipAll', value: true })); + setStorage(); + }]} + currentChecked={userDataState.optionData.skipAll ? 1 : 0} + /> + e.keyCode === 17, []); const handleCtrlKeydown = useCallback((e) => { if (isCtrlKey(e) && isGameActive()) { - startFast(); + // 按下 ctrl 键快进时,强制全文快进 + startFast(true); } }, []); const handleCtrlKeyup = useCallback((e) => { diff --git a/packages/webgal/src/store/userDataInterface.ts b/packages/webgal/src/store/userDataInterface.ts index 355651e27..e0245c2a1 100644 --- a/packages/webgal/src/store/userDataInterface.ts +++ b/packages/webgal/src/store/userDataInterface.ts @@ -46,6 +46,7 @@ export interface IOptionData { language: language; voiceInterruption: voiceOption; // 是否中断语音 fullScreen: fullScreenOption; + skipAll: boolean; // 快进已读/快进全文 } /** diff --git a/packages/webgal/src/store/userDataReducer.ts b/packages/webgal/src/store/userDataReducer.ts index 88e45d118..856b74ef7 100644 --- a/packages/webgal/src/store/userDataReducer.ts +++ b/packages/webgal/src/store/userDataReducer.ts @@ -35,6 +35,7 @@ const initialOptionSet: IOptionData = { language: language.zhCn, voiceInterruption: voiceOption.no, fullScreen: fullScreenOption.off, + skipAll: false, }; // 初始化用户数据 diff --git a/packages/webgal/src/translations/de.ts b/packages/webgal/src/translations/de.ts index 905ce7828..a40875a38 100644 --- a/packages/webgal/src/translations/de.ts +++ b/packages/webgal/src/translations/de.ts @@ -58,6 +58,13 @@ const de = { contributors: 'Contributors', website: 'Website', }, + skipAll: { + title: 'Schnellvorlauf-Modus', + options: { + read: 'Gelesen', + all: 'Alle', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/en.ts b/packages/webgal/src/translations/en.ts index 7b982ddcf..801e41296 100644 --- a/packages/webgal/src/translations/en.ts +++ b/packages/webgal/src/translations/en.ts @@ -58,6 +58,13 @@ const en = { contributors: 'Contributors', website: 'Website', }, + skipAll: { + title: 'Skip Mode', + options: { + read: 'Read', + all: 'All', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/fr.ts b/packages/webgal/src/translations/fr.ts index 4c6dd0af1..faf3296dd 100644 --- a/packages/webgal/src/translations/fr.ts +++ b/packages/webgal/src/translations/fr.ts @@ -58,6 +58,13 @@ const fr = { contributors: 'Contributeurs', website: 'Site web', }, + skipAll: { + title: 'Mode Avance Rapide', + options: { + read: 'Lu', + all: 'Tout', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/jp.ts b/packages/webgal/src/translations/jp.ts index bfefcea93..d54154471 100644 --- a/packages/webgal/src/translations/jp.ts +++ b/packages/webgal/src/translations/jp.ts @@ -58,6 +58,13 @@ const jp = { contributors: '貢献者', website: 'ウェブサイト', }, + skipAll: { + title: 'スキップモード', + options: { + read: '既読', + all: 'すべて', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/zh-cn.ts b/packages/webgal/src/translations/zh-cn.ts index ec9cb0d71..8a178b558 100644 --- a/packages/webgal/src/translations/zh-cn.ts +++ b/packages/webgal/src/translations/zh-cn.ts @@ -58,6 +58,13 @@ const zhCn = { contributors: '贡献者', website: '网站', }, + skipAll: { + title: '快进模式', + options: { + read: '已读', + all: '全部', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/zh-tw.ts b/packages/webgal/src/translations/zh-tw.ts index 184cf477b..0da6c9f43 100644 --- a/packages/webgal/src/translations/zh-tw.ts +++ b/packages/webgal/src/translations/zh-tw.ts @@ -58,6 +58,13 @@ const zhTw = { contributors: '貢獻者', website: '網站', }, + skipAll: { + title: '快進模式', + options: { + read: '已讀', + all: '全部', + } + } }, }, display: { From 527a810d4e26b8d7b67aaefccac08b451dca755b Mon Sep 17 00:00:00 2001 From: ChangeSuger Date: Thu, 16 Apr 2026 01:50:53 +0800 Subject: [PATCH 18/30] fix: typo --- packages/webgal/src/Core/Modules/readHistory.ts | 10 +++++----- .../src/Core/controller/gamePlay/scriptExecutor.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/webgal/src/Core/Modules/readHistory.ts b/packages/webgal/src/Core/Modules/readHistory.ts index 03dc486d6..f609cf79f 100644 --- a/packages/webgal/src/Core/Modules/readHistory.ts +++ b/packages/webgal/src/Core/Modules/readHistory.ts @@ -81,22 +81,22 @@ export class ReadHistoryManager { this.saveReadHistory(scenarioName); } - public checkIsReaded() { + public checkIsRead() { this.checkLoad(); const scenarioName = this.sceneManager.sceneData.currentScene.sceneName; const index = this.sceneManager.sceneData.currentSentenceId; - let isReaded = false; + let isRead = false; if (this.history.has(scenarioName)) { const bitset = this.history.get(scenarioName)!; - isReaded = (bitset[index >> 3] & (1 << (index & 7))) !== 0; + isRead = (bitset[index >> 3] & (1 << (index & 7))) !== 0; } webgalStore.dispatch(setStage({ key: 'isRead', - value: isReaded, + value: isRead, })); - if (!isReaded) { + if (!isRead) { this.addReadHistory(); } } diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index ceeb5d2f9..83b814684 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -100,7 +100,7 @@ export const scriptExecutor = () => { nextSentence(); return; } - WebGAL.readHistoryManager.checkIsReaded(); + WebGAL.readHistoryManager.checkIsRead(); runScript(currentScript); // 是否要进行下一句 let isNext = getBooleanArgByKey(currentScript, 'next') ?? false; From cf7dcb97e2f6872d7c8b17743bd8cce3b027d86d Mon Sep 17 00:00:00 2001 From: ChangeSuger Date: Fri, 17 Apr 2026 01:09:33 +0800 Subject: [PATCH 19/30] =?UTF-8?q?feat:=20update=20-=20=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=A4=B1=E8=B4=A5=E9=97=AE=E9=A2=98=20-=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E8=84=9A=E6=9C=AC=E5=8F=98=E6=9B=B4=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E5=AF=BC=E8=87=B4=E7=9A=84=E7=B4=A2=E5=BC=95=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webgal/src/Core/Modules/readHistory.ts | 23 +++++++++++++++---- .../Menu/Options/TextPreview/TextPreview.tsx | 1 + 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/webgal/src/Core/Modules/readHistory.ts b/packages/webgal/src/Core/Modules/readHistory.ts index f609cf79f..aa5336abb 100644 --- a/packages/webgal/src/Core/Modules/readHistory.ts +++ b/packages/webgal/src/Core/Modules/readHistory.ts @@ -33,7 +33,6 @@ export class ReadHistoryManager { for (let i = 0; i < binary.length; i++) { uint8[i] = binary.charCodeAt(i); } - console.log('Camille Load', uint8); this.history.set(key, uint8); } }); @@ -58,7 +57,12 @@ export class ReadHistoryManager { })); } catch { // 浏览器环境下没有 Buffer 时的兜底逻辑 - const base64 = btoa(String.fromCharCode(...bitset)); + let binary = ''; + const len = bitset.length; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bitset[i]); + } + const base64 = btoa(binary); webgalStore.dispatch(setReadHistory({ key, value: base64, @@ -75,8 +79,19 @@ export class ReadHistoryManager { const length = this.sceneManager.sceneData.currentScene.sentenceList.length; this.history.set(scenarioName, new Uint8Array(Math.ceil(length / 8))); } - const bitset = this.history.get(scenarioName)!; - bitset[index >> 3] |= (1 << (index & 7)); + let bitset = this.history.get(scenarioName)!; + + // 处理因剧本更新可能导致的 index 溢出问题 + const requiredIndex = index >> 3; + if (requiredIndex >= bitset.length) { + const length = this.sceneManager.sceneData.currentScene.sentenceList.length; + const newBitset = new Uint8Array(Math.ceil(length / 8)); + newBitset.set(bitset); + bitset = newBitset; + this.history.set(scenarioName, bitset); + } + + bitset[requiredIndex] |= (1 << (index & 7)); this.saveReadHistory(scenarioName); } diff --git a/packages/webgal/src/UI/Menu/Options/TextPreview/TextPreview.tsx b/packages/webgal/src/UI/Menu/Options/TextPreview/TextPreview.tsx index 0a86b672e..1a40ccc2d 100644 --- a/packages/webgal/src/UI/Menu/Options/TextPreview/TextPreview.tsx +++ b/packages/webgal/src/UI/Menu/Options/TextPreview/TextPreview.tsx @@ -54,6 +54,7 @@ export const TextPreview = (props: any) => { lineLimit: 3, isUseStroke: true, textboxOpacity: textboxOpacity, + isRead: false, }; return ( From 3a6cce81db02b45b4d01317d1401ce790f60174b Mon Sep 17 00:00:00 2001 From: ChangeSuger Date: Fri, 17 Apr 2026 01:25:47 +0800 Subject: [PATCH 20/30] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=20backlog=20?= =?UTF-8?q?=E4=B8=8E=20load=20=E6=97=B6=E8=83=BD=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=96=87=E6=9C=AC=E4=B8=BA=E5=B7=B2=E8=AF=BB?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgal/src/Core/Modules/backlog.ts | 2 -- packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts | 3 +++ packages/webgal/src/Core/controller/storage/loadGame.ts | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/webgal/src/Core/Modules/backlog.ts b/packages/webgal/src/Core/Modules/backlog.ts index e17571de9..8d9c9e8c8 100644 --- a/packages/webgal/src/Core/Modules/backlog.ts +++ b/packages/webgal/src/Core/Modules/backlog.ts @@ -42,8 +42,6 @@ export class BacklogManager { // 存一下 Backlog const currentStageState = webgalStore.getState().stage; const stageStateToBacklog = cloneDeep(currentStageState); - // 确保原先未读的文本在使用 backlog 时能正确显示为已读文本 - stageStateToBacklog.isRead = true; stageStateToBacklog.PerformList.forEach((ele) => { ele.script.args.forEach((argelement) => { if (argelement.key === 'concat') { diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 2ebf4ce6b..562966e58 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -67,6 +67,9 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => { // 恢复舞台状态 const newStageState: IStageState = cloneDeep(backlogFile.currentStageState); + // 确保原先未读的文本在使用 backlog 时能正确显示为已读文本 + newStageState.isRead = true; + dispatch(resetStageState(newStageState)); // 恢复演出 diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index ae471ab7e..f0e8aa04b 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -61,6 +61,8 @@ export function loadGameFromStageData(stageData: ISaveData) { // 恢复舞台状态 const newStageState = cloneDeep(loadFile.nowStageState); + // 确保原先未读的文本在 load 时能正确显示为已读文本 + newStageState.isRead = true; const dispatch = webgalStore.dispatch; dispatch(resetStageState(newStageState)); From 4cdaf3a980c1e8f362617c7505ad9ca5d6ac3bf2 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Mon, 20 Apr 2026 20:01:19 +0800 Subject: [PATCH 21/30] feat: add writeFullEffect parameter to getAnimationObject and update related functions --- .../src/Core/Modules/animationFunctions.ts | 36 ++++++++++++------- .../Core/Modules/perform/performInterface.ts | 2 -- .../src/Core/gameScripts/setAnimation.ts | 2 +- .../src/Core/gameScripts/setTempAnimation.ts | 2 +- .../src/Core/gameScripts/setTransform.ts | 6 ++-- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index 9c303a227..740cf9112 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -18,28 +18,40 @@ import { import { stageActions } from '@/store/stageReducer'; // eslint-disable-next-line max-params -export function getAnimationObject(animationName: string, target: string, duration: number, writeDefault: boolean) { +export function getAnimationObject( + animationName: string, + target: string, + duration: number, + writeDefault: boolean, + writeFullEffect = true, +) { const effect = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); if (effect) { const unionKeys = new Set(); const unionScaleKeys = new Set(); const unionPositionKeys = new Set(); - effect.effects.forEach((effect) => { - Object.keys(effect).forEach((k) => unionKeys.add(k)); - if (effect.scale) Object.keys(effect.scale).forEach((k) => unionScaleKeys.add(k)); - if (effect.position) Object.keys(effect.position).forEach((k) => unionPositionKeys.add(k)); - }); + if (!writeFullEffect) { + effect.effects.forEach((effect) => { + Object.keys(effect).forEach((k) => unionKeys.add(k)); + if (effect.scale) Object.keys(effect.scale).forEach((k) => unionScaleKeys.add(k)); + if (effect.position) Object.keys(effect.position).forEach((k) => unionPositionKeys.add(k)); + }); + } const mappedEffects = effect.effects.map((effect) => { const targetSetEffect = webgalStore.getState().stage.effects.find((e) => e.target === target); let newEffect; if (!writeDefault && targetSetEffect && targetSetEffect.transform) { - const targetScale = pickBy(targetSetEffect.transform.scale || {}, (source, key) => unionScaleKeys.has(key)); - const targetPosition = pickBy(targetSetEffect.transform.position || {}, (s, key) => unionPositionKeys.has(key)); - const originalTransform = { ...pickBy(targetSetEffect.transform, (source, key) => unionKeys.has(key)) }; - originalTransform.scale = targetScale; - originalTransform.position = targetPosition; - newEffect = cloneDeep({ ...originalTransform, duration: 0, ease: '' }); + if (writeFullEffect) { + newEffect = cloneDeep({ ...targetSetEffect.transform, duration: 0, ease: '' }); + } else { + const targetScale = pickBy(targetSetEffect.transform.scale || {}, (source, key) => unionScaleKeys.has(key)); + const targetPosition = pickBy(targetSetEffect.transform.position || {}, (s, key) => unionPositionKeys.has(key)); + const originalTransform = { ...pickBy(targetSetEffect.transform, (source, key) => unionKeys.has(key)) }; + originalTransform.scale = targetScale; + originalTransform.position = targetPosition; + newEffect = cloneDeep({ ...originalTransform, duration: 0, ease: '' }); + } } else { newEffect = cloneDeep({ ...baseTransform, duration: 0, ease: '' }); } diff --git a/packages/webgal/src/Core/Modules/perform/performInterface.ts b/packages/webgal/src/Core/Modules/perform/performInterface.ts index 2b6dda62d..8e00b2f90 100644 --- a/packages/webgal/src/Core/Modules/perform/performInterface.ts +++ b/packages/webgal/src/Core/Modules/perform/performInterface.ts @@ -23,8 +23,6 @@ export interface IPerform { arrangePerformPromise?: Promise; // 跳过由 nextSentence 函数引发的演出回收 skipNextCollect?: boolean; - // 标记此演出是否为并行演出,允许同一 target 上同时运行多个互不干扰的演出 - isParallel?: boolean; } // next之后,可以被打断的演出会被打断,不能被打断的演出会继续,阻塞next的演出会阻止next被响应。 diff --git a/packages/webgal/src/Core/gameScripts/setAnimation.ts b/packages/webgal/src/Core/gameScripts/setAnimation.ts index ad8967f01..d904823bf 100644 --- a/packages/webgal/src/Core/gameScripts/setAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setAnimation.ts @@ -39,6 +39,7 @@ export const setAnimation = (sentence: ISentence): IPerform => { target, animationDuration, writeDefault, + !parallel, ); if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); @@ -64,6 +65,5 @@ export const setAnimation = (sentence: ISentence): IPerform => { blockingNext: () => false, blockingAuto: () => !keep, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - isParallel: parallel, }; }; diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index aea5bda54..8bbe766de 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -51,6 +51,7 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { target, animationDuration, writeDefault, + !parallel, ); if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); @@ -76,6 +77,5 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { blockingNext: () => false, blockingAuto: () => !keep, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - isParallel: parallel, }; }; diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index 3268c8adf..3bd29819a 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -35,8 +35,8 @@ export const setTransform = (sentence: ISentence): IPerform => { try { const frame = JSON.parse(animationString) as AnimationFrame; - // writeDefault时需要完整的当前effect,其他时候不需要 - animationObj = generateTransformAnimationObj(target, frame, duration, ease, writeDefault); + // 保持 writeDefault 的旧语义;是否写完整字段由 parallel 单独控制 + animationObj = generateTransformAnimationObj(target, frame, duration, ease, !parallel); console.log('animationObj:', animationObj); } catch (e) { // 解析都错误了,歇逼吧 @@ -58,6 +58,7 @@ export const setTransform = (sentence: ISentence): IPerform => { target, animationDuration, writeDefault, + !parallel, ); if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); @@ -83,6 +84,5 @@ export const setTransform = (sentence: ISentence): IPerform => { blockingNext: () => false, blockingAuto: () => !keep, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - isParallel: parallel, }; }; From 273c4875b2b8fec5552478df5a5f16704365b15a Mon Sep 17 00:00:00 2001 From: Mahiru Date: Tue, 21 Apr 2026 19:29:57 +0800 Subject: [PATCH 22/30] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=95=B0=E6=8D=AE=E5=AD=98=E5=82=A8=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=95=B0=E6=8D=AE=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=92=8C=E8=A7=84=E8=8C=83=E5=8C=96=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Core/controller/gamePlay/fastSkip.ts | 1 + .../controller/storage/storageController.ts | 75 ++++++++++++++++--- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts index 9e3fd2d1e..848e0a064 100644 --- a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts +++ b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts @@ -46,6 +46,7 @@ export const startFast = (force = false) => { WebGAL.gameplay.fastInterval = setInterval(() => { if (!skipAll && !webgalStore.getState().stage.isRead) { stopFast(); + return; } nextSentence(); }, SYSTEM_CONFIG.fast_timeout); diff --git a/packages/webgal/src/Core/controller/storage/storageController.ts b/packages/webgal/src/Core/controller/storage/storageController.ts index 1d0a1fb86..962e73d45 100644 --- a/packages/webgal/src/Core/controller/storage/storageController.ts +++ b/packages/webgal/src/Core/controller/storage/storageController.ts @@ -3,6 +3,7 @@ import { IUserData } from '@/store/userDataInterface'; import { logger } from '../../util/logger'; import { webgalStore } from '@/store/store'; import { initState, resetUserData } from '@/store/userDataReducer'; +import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; @@ -21,13 +22,19 @@ export const setStorage = debounce(() => { */ export const getStorage = debounce(() => { localforage.getItem(WebGAL.gameKey).then((newUserData) => { - // 如果没有数据或者属性不完全,重新初始化 - if (!newUserData || !checkUserDataProperty(newUserData)) { + // 如果没有数据,重新初始化 + if (!newUserData) { logger.warn('现在重置数据'); setStorage(); return; } - webgalStore.dispatch(resetUserData(newUserData as IUserData)); + const shouldMigrate = !checkUserDataProperty(newUserData); + const normalizedUserData = normalizeUserData(newUserData as Partial); + webgalStore.dispatch(resetUserData(normalizedUserData)); + if (shouldMigrate) { + logger.warn('检测到旧版本用户数据,已补齐默认字段'); + setStorage(); + } }); }, 100); @@ -60,7 +67,7 @@ export const dumpToStorageFast = () => { setStorage(); return; } - webgalStore.dispatch(resetUserData(newUserData as IUserData)); + webgalStore.dispatch(resetUserData(normalizeUserData(newUserData as Partial))); }); logger.info('同步本地存储'); }); @@ -71,13 +78,52 @@ export const dumpToStorageFast = () => { * @param userData 需要检查的数据 */ function checkUserDataProperty(userData: any) { - let result = true; - for (const key in initState) { - if (!userData.hasOwnProperty(key)) { - result = false; + return ( + checkStateProperty(userData, initState) && + checkStateProperty(userData.optionData, initState.optionData) && + checkStateProperty(userData.appreciationData, initState.appreciationData) + ); +} + +function checkStateProperty(currentData: any, templateData: object) { + if (!isObject(currentData)) { + return false; + } + for (const key in templateData) { + if (!Object.prototype.hasOwnProperty.call(currentData, key)) { + return false; } } - return result; + return true; +} + +function normalizeUserData(userData: Partial): IUserData { + const defaultUserData = cloneDeep(initState); + const optionData: Record = isObject(userData.optionData) ? userData.optionData : {}; + const appreciationData: Record = isObject(userData.appreciationData) ? userData.appreciationData : {}; + + return { + ...defaultUserData, + ...userData, + scriptManagedGlobalVar: Array.isArray(userData.scriptManagedGlobalVar) ? userData.scriptManagedGlobalVar : [], + globalGameVar: isObject(userData.globalGameVar) ? userData.globalGameVar : {}, + optionData: { + ...defaultUserData.optionData, + ...optionData, + }, + appreciationData: { + ...defaultUserData.appreciationData, + ...appreciationData, + bgm: Array.isArray(appreciationData.bgm) ? appreciationData.bgm : defaultUserData.appreciationData.bgm, + cg: Array.isArray(appreciationData.cg) ? appreciationData.cg : defaultUserData.appreciationData.cg, + }, + gameConfigInit: isObject(userData.gameConfigInit) ? userData.gameConfigInit : {}, + readHistory: isObject(userData.readHistory) ? userData.readHistory : {}, + }; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } export async function setStorageAsync() { @@ -87,10 +133,17 @@ export async function setStorageAsync() { export async function getStorageAsync() { const newUserData = await localforage.getItem(WebGAL.gameKey); - if (!newUserData || !checkUserDataProperty(newUserData)) { + if (!newUserData) { const userDataState = webgalStore.getState().userData; logger.warn('现在重置数据'); return await localforage.setItem(WebGAL.gameKey, userDataState); - } else webgalStore.dispatch(resetUserData(newUserData as IUserData)); + } + const shouldMigrate = !checkUserDataProperty(newUserData); + const normalizedUserData = normalizeUserData(newUserData as Partial); + webgalStore.dispatch(resetUserData(normalizedUserData)); + if (shouldMigrate) { + logger.warn('检测到旧版本用户数据,已补齐默认字段'); + return await localforage.setItem(WebGAL.gameKey, normalizedUserData); + } return; } From 112eb241c191beba7694c513c1401b8e579ee87b Mon Sep 17 00:00:00 2001 From: ChangeSuger Date: Sun, 26 Apr 2026 15:42:28 +0800 Subject: [PATCH 23/30] feat: change default color for read text --- packages/webgal/public/game/template/Stage/TextBox/textbox.scss | 2 +- packages/webgal/src/Stage/TextBox/textbox.module.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss index b75eb2480..a4d8a20c0 100644 --- a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss +++ b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss @@ -178,5 +178,5 @@ } .read { - color: rgb(237, 162, 162); + color: #C0C0C0; } diff --git a/packages/webgal/src/Stage/TextBox/textbox.module.scss b/packages/webgal/src/Stage/TextBox/textbox.module.scss index 916a8288d..2d775f045 100644 --- a/packages/webgal/src/Stage/TextBox/textbox.module.scss +++ b/packages/webgal/src/Stage/TextBox/textbox.module.scss @@ -236,5 +236,5 @@ $height: 330px; } .read { - color: rgb(237, 162, 162); + color: #C0C0C0; } From fed28bc6fb026a29ebf6ea6a4c651fc89e1bb00e Mon Sep 17 00:00:00 2001 From: xiaoxustudio Date: Tue, 28 Apr 2026 20:12:18 +0800 Subject: [PATCH 24/30] refactor: simplify nobreak logic --- .../src/Core/controller/gamePlay/nextSentence.ts | 5 ----- packages/webgal/src/Core/gameScripts/wait.ts | 12 ++++-------- packages/webgal/src/store/GUIReducer.ts | 6 ------ packages/webgal/src/store/guiInterface.ts | 1 - 4 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index 46022eaf3..7cb4a9f78 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -24,11 +24,6 @@ export const nextSentence = () => { return; } - // 如果处于 wait 指令的不可中断状态,那么不进行下一句 - if (GUIState.waitNoBreak) { - return; - } - // 第一步,检查是否存在 blockNext 的演出 let isBlockingNext = false; WebGAL.gameplay.performController.performList.forEach((e) => { diff --git a/packages/webgal/src/Core/gameScripts/wait.ts b/packages/webgal/src/Core/gameScripts/wait.ts index dc4b57e4c..73d7ce752 100644 --- a/packages/webgal/src/Core/gameScripts/wait.ts +++ b/packages/webgal/src/Core/gameScripts/wait.ts @@ -1,8 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { setWaitNoBreak } from '@/store/GUIReducer'; -import { webgalStore } from '@/store/store'; -import { getBooleanArgByKey } from '../util/getSentenceArg'; +import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; /** * 等待 n 毫秒 @@ -13,18 +11,16 @@ export const wait = (sentence: ISentence): IPerform => { const performName = `wait${Math.random().toString()}`; const nobreak = getBooleanArgByKey(sentence, 'nobreak') ?? false; - if (nobreak) webgalStore.dispatch(setWaitNoBreak(true)); - return { performName, duration: duration, goNextWhenOver: true, isHoldOn: false, stopFunction: () => { - webgalStore.dispatch(setWaitNoBreak(false)); + // 无需状态清理 }, - blockingNext: () => false, - blockingAuto: () => true, + blockingNext: () => nobreak, + blockingAuto: () => nobreak, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/store/GUIReducer.ts b/packages/webgal/src/store/GUIReducer.ts index 392e15f41..7d87e2439 100644 --- a/packages/webgal/src/store/GUIReducer.ts +++ b/packages/webgal/src/store/GUIReducer.ts @@ -33,7 +33,6 @@ const initState: IGuiState = { isShowLogo: true, enableAppreciationMode: false, // Paf87 fontOptimization: false, - waitNoBreak: false, }; /** @@ -88,10 +87,6 @@ const GUISlice = createSlice({ setFontOptions: (state, action: PayloadAction) => { state.fontOptions = [...action.payload]; }, - - setWaitNoBreak: (state, action: PayloadAction) => { - state.waitNoBreak = action.payload; - }, }, }); @@ -103,7 +98,6 @@ export const { setEnableAppreciationMode, setFontOptimization, setFontOptions, - setWaitNoBreak, } = GUISlice.actions; export default GUISlice.reducer; diff --git a/packages/webgal/src/store/guiInterface.ts b/packages/webgal/src/store/guiInterface.ts index 84af0a752..1fc4fdaeb 100644 --- a/packages/webgal/src/store/guiInterface.ts +++ b/packages/webgal/src/store/guiInterface.ts @@ -30,7 +30,6 @@ export interface IGuiState { isShowLogo: boolean; enableAppreciationMode: boolean; // Pc102 fontOptimization: boolean; // 字体优化 - waitNoBreak: boolean; // 处于不可中断的等待 } export type componentsVisibility = Pick< From 116d1243b01851ed685bb6d8346c0b673fe70499 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Wed, 29 Apr 2026 19:43:04 +0800 Subject: [PATCH 25/30] feat: add default choice for fast preview --- packages/webgal/src/Core/Modules/gamePlay.ts | 2 + .../src/Core/gameScripts/choose/index.tsx | 35 ++++++++++++-- .../util/syncWithEditor/syncWithOrigine.ts | 48 +++++++++++-------- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/packages/webgal/src/Core/Modules/gamePlay.ts b/packages/webgal/src/Core/Modules/gamePlay.ts index e8e8c9f5e..83d0b3cef 100644 --- a/packages/webgal/src/Core/Modules/gamePlay.ts +++ b/packages/webgal/src/Core/Modules/gamePlay.ts @@ -12,6 +12,7 @@ export class Gameplay { public autoTimeout: ReturnType | null = null; public pixiStage: PixiStage | null = null; public performController = new PerformController(); + public isFastPreview = false; /* 有图标状态需求 */ private _isAuto = false; @@ -34,6 +35,7 @@ export class Gameplay { public resetGamePlay() { this.isAuto = false; this.isFast = false; + this.isFastPreview = false; const autoInterval = this.autoInterval; if (autoInterval !== null) clearInterval(autoInterval); this.autoInterval = null; diff --git a/packages/webgal/src/Core/gameScripts/choose/index.tsx b/packages/webgal/src/Core/gameScripts/choose/index.tsx index b7813c4cc..6b48a1758 100644 --- a/packages/webgal/src/Core/gameScripts/choose/index.tsx +++ b/packages/webgal/src/Core/gameScripts/choose/index.tsx @@ -14,6 +14,7 @@ import useEscape from '@/hooks/useEscape'; import useApplyStyle from '@/hooks/useApplyStyle'; import { Provider } from 'react-redux'; import { useFontFamily } from '@/hooks/useFontFamily'; +import { getNumberArgByKey } from '@/Core/util/getSentenceArg'; class ChooseOption { /** @@ -58,6 +59,8 @@ class ChooseOption { export const choose = (sentence: ISentence): IPerform => { const chooseOptionScripts = sentence.content.split(/(? ChooseOption.parse(e.trim())); + const defaultChoose = getNumberArgByKey(sentence, 'defaultChoose'); + const previewChoice = getDefaultPreviewChoice(chooseOptions, defaultChoose); // eslint-disable-next-line react/no-deprecated ReactDOM.render( @@ -66,6 +69,12 @@ export const choose = (sentence: ISentence): IPerform => { , document.getElementById('chooseContainer'), ); + if (previewChoice) { + setTimeout(() => { + selectChooseOption(previewChoice); + WebGAL.gameplay.performController.unmountPerform('choose'); + }, 0); + } return { performName: 'choose', duration: 1000 * 60 * 60 * 24, @@ -80,6 +89,26 @@ export const choose = (sentence: ISentence): IPerform => { }; }; +function getDefaultPreviewChoice(chooseOptions: ChooseOption[], defaultChoose: number | null): ChooseOption | null { + if (!WebGAL.gameplay.isFastPreview || defaultChoose === null) { + return null; + } + const chooseIndex = Math.floor(defaultChoose) - 1; + if (chooseIndex < 0) { + return null; + } + const defaultOption = chooseOptions[chooseIndex]; + return defaultOption ?? null; +} + +function selectChooseOption(option: ChooseOption) { + if (option.jumpToScene) { + changeScene(option.jump, option.text); + } else { + jmp(option.jump); + } +} + function Choose(props: { chooseOptions: ChooseOption[] }) { const font = useFontFamily(); const { playSeEnter, playSeClick } = useSEByWebgalStore(); @@ -96,11 +125,7 @@ function Choose(props: { chooseOptions: ChooseOption[] }) { const onClick = enable ? () => { playSeClick(); - if (e.jumpToScene) { - changeScene(e.jump, e.text); - } else { - jmp(e.jump); - } + selectChooseOption(e); WebGAL.gameplay.performController.unmountPerform('choose'); } : () => {}; diff --git a/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts b/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts index 3f1d39750..c666bfbc6 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts @@ -15,6 +15,7 @@ let syncFastTimeout: ReturnType | undefined; export const syncWithOrigine = (sceneName: string, sentenceId: number, expermental = false) => { logger.warn('正在跳转到' + sceneName + ':' + sentenceId); + WebGAL.gameplay.isFastPreview = false; const dispatch = webgalStore.dispatch; dispatch(setVisibility({ component: 'showTitle', visibility: false })); dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); @@ -27,26 +28,32 @@ export const syncWithOrigine = (sceneName: string, sentenceId: number, experment // 重新获取场景 const sceneUrl: string = assetSetter(sceneName, fileType.scene); // 场景写入到运行时 - sceneFetcher(sceneUrl).then((rawScene) => { - // 等等,先检查一下能不能恢复场景 - const lastSameSentence = findLastSameSentence(pastScene, WebGAL.sceneManager.sceneData.currentScene, sentenceId); - const lastRecoverySentenceId = Math.min(sentenceId, lastSameSentence); - const recId = findLastAvailableBacklog(lastRecoverySentenceId, sceneName); - const isCanRec = recId >= 0 && expermental; - resetStage(!isCanRec); - WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); - // 开始快进到指定语句 - const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - WebGAL.gameplay.isFast = true; - if (isCanRec) { - jumpFromBacklog(recId, false); - } - if (syncFastTimeout) { - // 之前发生的跳转要清理掉 - clearTimeout(syncFastTimeout); - } - syncFast(sentenceId, currentSceneName); - }); + sceneFetcher(sceneUrl) + .then((rawScene) => { + // 等等,先检查一下能不能恢复场景 + const lastSameSentence = findLastSameSentence(pastScene, WebGAL.sceneManager.sceneData.currentScene, sentenceId); + const lastRecoverySentenceId = Math.min(sentenceId, lastSameSentence); + const recId = findLastAvailableBacklog(lastRecoverySentenceId, sceneName); + const isCanRec = recId >= 0 && expermental; + resetStage(!isCanRec); + WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); + // 开始快进到指定语句 + const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; + WebGAL.gameplay.isFast = true; + WebGAL.gameplay.isFastPreview = true; + if (isCanRec) { + jumpFromBacklog(recId, false); + } + if (syncFastTimeout) { + // 之前发生的跳转要清理掉 + clearTimeout(syncFastTimeout); + } + syncFast(sentenceId, currentSceneName); + }) + .catch((e) => { + WebGAL.gameplay.isFastPreview = false; + logger.error('快速预览跳转错误', e); + }); }; export function syncFast(sentenceId: number, currentSceneName: string) { @@ -58,6 +65,7 @@ export function syncFast(sentenceId: number, currentSceneName: string) { syncFastTimeout = setTimeout(() => syncFast(sentenceId, currentSceneName), 2); } else { WebGAL.gameplay.isFast = false; + WebGAL.gameplay.isFastPreview = false; } } From 0b7d137c6b69778b249df0fa3f6212706ee4065c Mon Sep 17 00:00:00 2001 From: Hardy--Lee Date: Fri, 3 Apr 2026 11:05:02 +0800 Subject: [PATCH 26/30] fix: filter timeline segment --- .../src/Core/controller/stage/pixi/animations/timeline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts index 64477f3b2..b16bc7833 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts @@ -29,8 +29,10 @@ export function generateTimelineObj( const segmentDuration = segment.duration; currentDelay += segmentDuration; const { position, scale, ...segmentValues } = segment; + // 移除所有值类型不是 number 的属性 + const filteredSegmentValues = omitBy(segmentValues, (value) => typeof value !== 'number'); // 不能用 scale,因为 popmotion 不能用嵌套 - values.push({ x: position?.x, y: position?.y, scaleX: scale?.x, scaleY: scale?.y, ...segmentValues }); + values.push({ x: position?.x, y: position?.y, scaleX: scale?.x, scaleY: scale?.y, ...filteredSegmentValues }); // Easing 需要比 values 的长度少一个 if (i > 0) { easeArray.push(stringToEasing(segment.ease)); From e4c4592c6b3257090c34fba9c36c2f96d1e4f5b4 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Fri, 1 May 2026 17:16:51 +0800 Subject: [PATCH 27/30] feat: update source type and extension handling for background and figure objects --- .../src/Core/controller/stage/pixi/PixiController.ts | 12 +++++++----- packages/webgal/src/Stage/MainStage/useSetEffects.ts | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index b43665bdf..c0ae8c2f0 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -431,13 +431,14 @@ export default class PixiStage { // 挂载 this.backgroundContainer.addChild(thisBgContainer); const bgUuid = uuid(); + const sourceExt = this.getExtName(url); this.backgroundObjects.push({ uuid: bgUuid, key: key, pixiContainer: thisBgContainer, sourceUrl: url, - sourceType: 'img', - sourceExt: this.getExtName(url), + sourceType: sourceExt === 'gif' ? 'gif' : 'img', + sourceExt, }); // 完成图片加载后执行的函数 @@ -599,13 +600,14 @@ export default class PixiStage { // 挂载 this.figureContainer.addChild(thisFigureContainer); const figureUuid = uuid(); + const sourceExt = this.getExtName(url); this.figureObjects.push({ uuid: figureUuid, key: key, pixiContainer: thisFigureContainer, sourceUrl: url, - sourceType: 'img', - sourceExt: this.getExtName(url), + sourceType: sourceExt === 'gif' ? 'gif' : 'img', + sourceExt, }); // 完成图片加载后执行的函数 @@ -1084,7 +1086,7 @@ export default class PixiStage { } public getExtName(url: string) { - return url.split('.').pop() ?? 'png'; + return (url.split(/[?#]/)[0].split('.').pop() ?? 'png').toLowerCase(); } public getFigureMetadataByKey(key: string): IFigureMetadata | undefined { diff --git a/packages/webgal/src/Stage/MainStage/useSetEffects.ts b/packages/webgal/src/Stage/MainStage/useSetEffects.ts index 8a096d11c..049d04b02 100644 --- a/packages/webgal/src/Stage/MainStage/useSetEffects.ts +++ b/packages/webgal/src/Stage/MainStage/useSetEffects.ts @@ -36,6 +36,7 @@ export function setStageEffects(effects: IEffect[]) { } } } + WebGAL.gameplay.pixiStage?.requestRender(); } function convertTransform(transform: ITransform | undefined) { From 42b7c51cc191484c4e88d397ab8e8e98eb048b52 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 3 May 2026 10:53:34 +0800 Subject: [PATCH 28/30] fix: preload --- packages/webgal/index.html | 70 +++++++++++++--- .../webgal/public/webgal-serviceworker.js | 81 +++---------------- .../Core/util/prefetcher/assetsPrefetcher.ts | 25 +----- 3 files changed, 75 insertions(+), 101 deletions(-) diff --git a/packages/webgal/index.html b/packages/webgal/index.html index 5c37dbafb..1aa340e6f 100644 --- a/packages/webgal/index.html +++ b/packages/webgal/index.html @@ -320,16 +320,68 @@ diff --git a/packages/webgal/public/webgal-serviceworker.js b/packages/webgal/public/webgal-serviceworker.js index 7eeda51ca..4e6150153 100644 --- a/packages/webgal/public/webgal-serviceworker.js +++ b/packages/webgal/public/webgal-serviceworker.js @@ -1,7 +1,7 @@ -const CACHE_NAME = 'webgal-critical-assets-v3'; -const GAME_PREFIX = '/game/'; -const CRITICAL_PATHS = ['/game/background/', '/game/figure/', '/game/bgm/', '/game/vocal/', '/game/video/']; +const CACHE_PREFIX = 'webgal-'; +const CACHE_NAME = 'webgal-build-assets-v1'; const LOG_PREFIX = '[WebGAL SW]'; +const HASHED_BUILD_ASSET_RE = /(^|\/)assets\/[^/?#]+-[A-Za-z0-9_-]{8,}\.(?:js|css|ttf|woff|woff2)$/; const loggedKeys = new Set(); function logOnce(key, ...args) { @@ -20,97 +20,42 @@ self.addEventListener('activate', (event) => { event.waitUntil( (async () => { const keys = await caches.keys(); - await Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))); + await Promise.all(keys.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME).map((key) => caches.delete(key))); await self.clients.claim(); })(), ); }); -function isCriticalGameRequest(request) { +function isHashedBuildAssetRequest(request) { if (request.method !== 'GET') return false; const url = new URL(request.url); if (url.origin !== self.location.origin) return false; - if (!url.pathname.startsWith(GAME_PREFIX)) return false; - return CRITICAL_PATHS.some((prefix) => url.pathname.startsWith(prefix)); + return HASHED_BUILD_ASSET_RE.test(url.pathname); } -// Stale-while-revalidate: return cached response immediately, then update cache in background. -async function staleWhileRevalidate(request) { +async function cacheFirst(request) { const cache = await caches.open(CACHE_NAME); - const cached = await cache.match(request.url); - - const fetchAndUpdate = async () => { - try { - const response = await fetch(request); - if (response.ok) { - await cache.put(request.url, response.clone()); - } - return response; - } catch (e) { - return null; - } - }; - + const cached = await cache.match(request); if (cached) { - logOnce(`hit:${request.url}`, 'cache hit (revalidating):', new URL(request.url).pathname); - // Revalidate in background — don't await - fetchAndUpdate(); + logOnce(`hit:${request.url}`, 'cache hit:', new URL(request.url).pathname); return cached; } - // No cache — must wait for network const response = await fetch(request); - if (response.ok) { - await cache.put(request.url, response.clone()); + if (response.ok && response.status === 200) { + await cache.put(request, response.clone()); logOnce(`cache:${request.url}`, 'cached:', new URL(request.url).pathname); } return response; } -async function prefetchFromMessage(urlString) { - const requestUrl = new URL(urlString, self.location.origin).toString(); - const request = new Request(requestUrl, { method: 'GET' }); - if (!isCriticalGameRequest(request)) { - return; - } - const cache = await caches.open(CACHE_NAME); - const hasCached = await cache.match(requestUrl); - if (hasCached) { - return; - } - const response = await fetch(request); - if (response.ok && response.status === 200) { - await cache.put(requestUrl, response.clone()); - logOnce(`message-cache:${requestUrl}`, 'message cached:', new URL(requestUrl).pathname); - } -} - self.addEventListener('fetch', (event) => { const { request } = event; - if (!isCriticalGameRequest(request)) return; - - // Audio/video range requests are passed through to avoid partial-content edge cases. - if (request.headers.has('range')) { - logOnce(`range:${request.url}`, 'range passthrough:', new URL(request.url).pathname); - event.respondWith(fetch(request)); - return; - } + if (!isHashedBuildAssetRequest(request)) return; event.respondWith( - staleWhileRevalidate(request).catch(() => { + cacheFirst(request).catch(() => { return fetch(request); }), ); }); - -self.addEventListener('message', (event) => { - const data = event.data || {}; - if (data.type !== 'WEBGAL_PREFETCH_ASSET' || typeof data.url !== 'string') { - return; - } - event.waitUntil( - prefetchFromMessage(data.url).catch((error) => { - console.warn(LOG_PREFIX, 'message prefetch failed:', error); - }), - ); -}); diff --git a/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts b/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts index 712e36402..6886f5f10 100644 --- a/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts +++ b/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts @@ -62,26 +62,6 @@ const prefetchByLinkElement = (asset: IAsset) => { } }; -const prefetchByServiceWorkerMessage = (assetUrl: string): boolean => { - if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { - return false; - } - const controller = navigator.serviceWorker.controller; - if (!controller) { - return false; - } - try { - controller.postMessage({ - type: 'WEBGAL_PREFETCH_ASSET', - url: assetUrl, - }); - return true; - } catch (e) { - logger.warn('通过 Service Worker 发送预加载消息失败,将回退 link prefetch:', e); - return false; - } -}; - const runAssetsPrefetchQueue = () => { if (isAssetPrefetchQueueRunning || assetPrefetchQueue.length === 0) { return; @@ -90,10 +70,7 @@ const runAssetsPrefetchQueue = () => { const nextAsset = assetPrefetchQueue.shift() as IAsset; setTimeout(() => { try { - const useServiceWorker = prefetchByServiceWorkerMessage(nextAsset.url); - if (!useServiceWorker) { - prefetchByLinkElement(nextAsset); - } + prefetchByLinkElement(nextAsset); } catch (e) { logger.warn(`预加载资源失败,将允许重试:${nextAsset.url}`, e); WebGAL.sceneManager.settledAssets.delete(nextAsset.url); From bad32048b6894f29d855e3f47f1c6764aa74c22f Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 3 May 2026 12:13:29 +0800 Subject: [PATCH 29/30] update version --- packages/webgal/package.json | 2 +- .../webgal/public/game/template/template.json | 2 +- packages/webgal/public/webgal-engine.json | 4 +- releasenote.md | 78 +++++++++---------- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 101c9fc72..001d1d7c5 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -1,6 +1,6 @@ { "name": "webgal-engine", - "version": "4.5.19", + "version": "4.5.20", "scripts": { "dev": "vite --host --port 3000", "build": "node scripts/update-engine-version.js && cross-env NODE_ENV=production tsc && vite build --base=./", diff --git a/packages/webgal/public/game/template/template.json b/packages/webgal/public/game/template/template.json index f0ef8309c..bf412bbfd 100644 --- a/packages/webgal/public/game/template/template.json +++ b/packages/webgal/public/game/template/template.json @@ -1,4 +1,4 @@ { "name":"WebGAL Refine 2026", - "webgal-version":"4.5.19" + "webgal-version":"4.5.20" } diff --git a/packages/webgal/public/webgal-engine.json b/packages/webgal/public/webgal-engine.json index 1566acb78..8fe0e7f10 100644 --- a/packages/webgal/public/webgal-engine.json +++ b/packages/webgal/public/webgal-engine.json @@ -2,9 +2,9 @@ "schemaVersion": "1.0.0", "id": "open-webgal.webgal", "name": "WebGAL", - "version": "4.5.19", + "version": "4.5.20", "type": "official", - "webgalVersion": "4.5.19", + "webgalVersion": "4.5.20", "description": "界面美观、功能强大、易于开发的全新网页端视觉小说引擎", "descriptions": { "en": "A brand new web Visual Novel engine with a beautiful interface, powerful features, and easy development", diff --git a/releasenote.md b/releasenote.md index 26e371ee9..c18adb35f 100644 --- a/releasenote.md +++ b/releasenote.md @@ -8,31 +8,31 @@ #### 新功能 -getUserInput 支持正则校验参数 rule / ruleFlag / ruleText / ruleButtonText,可在输入不匹配时弹窗提示,ruleText 中可用 $0 引用用户输入值 +setAnimation / setTempAnimation / setTransform 支持 parallel 参数,可在同一目标上并行动画 -changeFigure 支持 skin 参数,可切换 Spine 模型皮肤 +wait 支持 nobreak 参数,可阻止等待被点击或自动播放跳过 -setTransform 新增 oldFilm / dotFilm / reflectionFilm / glitchFilm / rgbFilm / godrayFilm 滤镜属性 +新增已读历史记录与快进模式设置,支持按已读快进或全文快进,已读文本默认以浅灰色显示 -添加引擎描述文件 webgal-engine.json 及版本自动同步机制 +快速预览中的 choose 支持 defaultChoose 参数,可自动选择指定选项 -标题按钮文字支持多层渲染(outer / inner),方便模板自定义描边与阴影效果 +资源预加载改为随剧情进度按窗口预取,支持资源与场景去重、队列限速,减少一次性资源请求 -内置默认字体更换为「资源圆体」(Resource Han Rounded) +Pixi 舞台支持按需渲染,仅在动画或动态资源存在时运行 ticker,降低空闲资源消耗 -#### 修复 +背景与立绘资源识别支持带 query / hash 的扩展名,并可正确识别 gif 资源 -修复 removeAnimationByTargetKey 无法移除同一目标上多个动画的问题 +#### 修复 -修复 setEffect 前未先移除旧动画导致效果叠加异常的问题 +修复 setTransform 连续作用同一目标时动画被错误中断或覆盖的问题 -修复自动播放与快进按钮状态在部分操作后与实际状态不同步的问题 +修复语音播放时 AudioContext 被浏览器挂起导致口型分析或语音演出异常的问题 -修复 Safari / iOS 下视口大小与缩放异常的问题 +修复背景清空时仍拼接空 url、可能产生无效资源请求,以及 EBG 淡出异常的问题 -重构 Service Worker,采用 cache-first 策略缓存游戏关键资源,修复旧缓存逻辑缺陷 +修复 Service Worker 在本地预览、Electron、iOS 环境中可能产生缓存干扰的问题,并改为只缓存带 hash 的构建资源 -修复标题界面样式与布局问题 +修复旧版用户数据字段缺失时被整体重置的问题,改为补齐默认字段并兼容旧存档 ## Release Notes @@ -45,31 +45,31 @@ setTransform 新增 oldFilm / dotFilm / reflectionFilm / glitchFilm / rgbFilm / #### New Features -getUserInput now supports regex validation via rule / ruleFlag / ruleText / ruleButtonText arguments, showing a dialog when input does not match; ruleText supports $0 to reference the user's input value +setAnimation / setTempAnimation / setTransform now support the parallel argument, allowing animations to run in parallel on the same target -changeFigure now supports a skin argument for switching Spine model skins +wait now supports the nobreak argument to prevent waits from being skipped by clicks or auto-play -setTransform adds new filter properties: oldFilm / dotFilm / reflectionFilm / glitchFilm / rgbFilm / godrayFilm +Added read history and skip mode settings, supporting read-text skip or full skip; read text is now shown in light gray by default -Added engine description file webgal-engine.json and automatic version synchronization mechanism +choose in fast preview now supports the defaultChoose argument for automatically selecting a specified option -Title button text now supports layered rendering (outer / inner) for easier template customization of strokes and shadows +Resource prefetching now follows story progress with a lookahead window, deduplicated asset and scene queues, and throttled requests -Default built-in font changed to Resource Han Rounded (资源圆体) +The Pixi stage now supports on-demand rendering, running the ticker only while animations or dynamic resources exist to reduce idle resource usage -#### Fixes +Background and figure source detection now supports extensions with query / hash suffixes and correctly identifies gif resources -Fixed removeAnimationByTargetKey not removing all animations sharing the same target key +#### Fixes -Fixed old animations not being removed before setEffect, causing effects to stack incorrectly +Fixed setTransform animations being incorrectly interrupted or overwritten when applied continuously to the same target -Fixed auto-play and fast-forward button states becoming out of sync with actual state after certain operations +Fixed lip-sync analysis or vocal performs failing when the browser suspends AudioContext before playback -Fixed viewport sizing and scaling issues on Safari / iOS +Fixed empty background changes still producing empty url references, unnecessary resource requests, and abnormal EBG fade-out behavior -Refactored Service Worker with a cache-first strategy for critical game assets, fixing legacy caching logic issues +Fixed Service Worker cache interference in local preview, Electron, and iOS environments; only hashed build assets are now cached -Fixed title screen style and layout issues +Fixed old user data being fully reset when fields are missing; missing default fields are now migrated into existing saves ## リリースノート @@ -82,28 +82,28 @@ Fixed title screen style and layout issues #### 新機能 -getUserInput で正規表現バリデーション引数 rule / ruleFlag / ruleText / ruleButtonText をサポートし、入力が一致しない場合にダイアログを表示できるようになりました。ruleText 内で $0 を使用してユーザー入力値を参照できます +setAnimation / setTempAnimation / setTransform が parallel 引数に対応し、同じターゲット上で複数のアニメーションを並列実行できるようになりました -changeFigure で skin 引数をサポートし、Spine モデルのスキンを切り替えられるようになりました +wait が nobreak 引数に対応し、待機がクリックや自動再生でスキップされるのを防げるようになりました -setTransform に oldFilm / dotFilm / reflectionFilm / glitchFilm / rgbFilm / godrayFilm フィルター属性を追加しました +既読履歴とスキップモード設定を追加し、既読のみスキップ / 全文スキップを選択できるようになりました。既読テキストは既定で薄いグレー表示になります -エンジン記述ファイル webgal-engine.json およびバージョン自動同期メカニズムを追加しました +高速プレビュー中の choose が defaultChoose 引数に対応し、指定した選択肢を自動選択できるようになりました -タイトルボタンのテキストが多層レンダリング(outer / inner)に対応し、テンプレートでのストロークやシャドウのカスタマイズが容易になりました +リソースのプリフェッチを進行状況に応じた先読み方式に変更し、アセットとシーンの重複排除、キュー制御、リクエスト間隔の制御に対応しました -デフォルト内蔵フォントを「資源圓體」(Resource Han Rounded) に変更しました +Pixi ステージがオンデマンドレンダリングに対応し、アニメーションや動的リソースが存在する場合のみ ticker を実行してアイドル時の負荷を削減します -#### 修正 +背景と立ち絵のリソース判定が query / hash 付き拡張子に対応し、gif リソースも正しく識別できるようになりました -removeAnimationByTargetKey が同一ターゲット上の複数アニメーションを削除できない問題を修正しました +#### 修正 -setEffect の前に旧アニメーションが削除されず、エフェクトが不正に重複する問題を修正しました +setTransform を同じターゲットに連続適用した際、アニメーションが誤って中断または上書きされる問題を修正しました -一部操作後に自動再生・早送りボタンの状態が実際の状態と同期しなくなる問題を修正しました +ブラウザによって AudioContext が停止され、口パク解析やボイス演出が異常になる問題を修正しました -Safari / iOS でのビューポートサイズとスケーリングの異常を修正しました +背景を空にした際に空の url が生成される、不要なリソースリクエストが発生する、または EBG のフェードアウトが不自然になる問題を修正しました -Service Worker をリファクタリングし、ゲームの重要なアセットに cache-first 戦略を採用、レガシーキャッシュロジックの不具合を修正しました +ローカルプレビュー、Electron、iOS 環境で Service Worker のキャッシュが干渉する問題を修正し、ハッシュ付きビルドアセットのみをキャッシュするようにしました -タイトル画面のスタイルとレイアウトの問題を修正しました +旧バージョンのユーザーデータでフィールドが不足している場合に全体がリセットされる問題を修正し、既存セーブに既定フィールドを補完するようにしました From a58b13f99e35809c19fed30475c6c24d0c95ae70 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 3 May 2026 12:21:21 +0800 Subject: [PATCH 30/30] release zip pkg of webgal --- .github/workflows/release.yml | 52 ++++++++++++++++++++++++++++++++ packages/parser/rollup.config.js | 2 ++ releasenote.md | 6 ++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2fef307b..393c39c52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,14 +5,56 @@ on: tags: - '*.*' +permissions: + contents: write + jobs: + build-webgal-static-webpage: + name: Build WebGAL Static Webpage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + persist-credentials: false + + - name: Setup Node.js environment + uses: actions/setup-node@v3 + with: + node-version-file: package.json + cache: 'yarn' + + - name: Install + run: npm install yarn -g && yarn install + + - name: Build + run: yarn build + + - name: Package WebGAL Engine Web + run: | + cd packages/webgal/dist + zip -r "$GITHUB_WORKSPACE/webgal-engine-web.zip" . + + - name: Upload WebGAL Engine Artifact + uses: actions/upload-artifact@v4 + with: + name: webgal-engine-web + path: webgal-engine-web.zip + if-no-files-found: error + release: name: Release runs-on: ubuntu-latest + needs: build-webgal-static-webpage steps: - name: Checkout uses: actions/checkout@v2 + - name: Download WebGAL Engine Artifact + uses: actions/download-artifact@v4 + with: + name: webgal-engine-web + - name: Create Release id: create_release uses: actions/create-release@v1 @@ -24,3 +66,13 @@ jobs: body_path: releasenote.md draft: true prerelease: false + + - name: Upload WebGAL Engine Web + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: webgal-engine-web.zip + asset_name: WebGAL-${{ github.ref_name }}-web.zip + asset_content_type: application/zip diff --git a/packages/parser/rollup.config.js b/packages/parser/rollup.config.js index 787b18bc7..57bce0cc5 100644 --- a/packages/parser/rollup.config.js +++ b/packages/parser/rollup.config.js @@ -40,6 +40,7 @@ export default [ tsconfigOverride: { compilerOptions: { sourceMap: !isProd, + rootDir: "src", declarationDir: "build/cjs" }, include: ["src"] } @@ -61,6 +62,7 @@ export default [ tsconfigOverride: { compilerOptions: { sourceMap: !isProd, + rootDir: "src", declarationDir: "build/types" }, include: ["src"] } diff --git a/releasenote.md b/releasenote.md index c18adb35f..d200fab6c 100644 --- a/releasenote.md +++ b/releasenote.md @@ -1,6 +1,6 @@ ## 发布日志 -**本仓库只发布源代码** +**本仓库发布源代码,并在 Release 中附带 WebGAL 引擎网页版压缩包** **如果你想要体验使用便捷的图形化编辑器创建、制作并实时预览 WebGAL 游戏,请 [下载 WebGAL 图形化编辑器](https://github.com/OpenWebGAL/WebGAL_Terre/releases)** @@ -37,7 +37,7 @@ Pixi 舞台支持按需渲染,仅在动画或动态资源存在时运行 ticke ## Release Notes -**Only source code is released in this repository** +**This repository releases source code and includes a WebGAL engine web package in each Release** **If you want to experience creating, making, and real-time previewing WebGAL games using a user-friendly graphical editor, please [download the WebGAL graphical editor](https://github.com/OpenWebGAL/WebGAL_Terre/releases)** @@ -74,7 +74,7 @@ Fixed old user data being fully reset when fields are missing; missing default f ## リリースノート -**このリポジトリはソースコードのみを公開しています** +**このリポジトリではソースコードを公開し、Release には WebGAL エンジンの Web 版パッケージも同梱しています** **もしあなたが使いやすいグラフィカルエディタでWebGALゲームを作成、制作、リアルタイムプレビューしたい場合は、[WebGALグラフィカルエディタをダウンロードしてください](https://github.com/OpenWebGAL/WebGAL_Terre/releases)**