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/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/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/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/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/public/game/template/Stage/TextBox/textbox.scss b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss index b2bd03c01..a4d8a20c0 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: #C0C0C0; +} 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/packages/webgal/public/webgal-serviceworker.js b/packages/webgal/public/webgal-serviceworker.js index e9bb76f68..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,48 +20,30 @@ 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; @@ -69,17 +51,10 @@ async function staleWhileRevalidate(request) { 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); }), ); diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index f72cf0df5..740cf9112 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, @@ -16,15 +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(); + 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) { - newEffect = cloneDeep({ ...targetSetEffect.transform, 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/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/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index f303a64b8..43e24f44b 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -18,8 +18,17 @@ 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) { // 检查演出列表内是否有相同的演出,如果有,一定是出了什么问题 + // 并行演出的 performName 带有唯一后缀,因此不会命中去重 const dupPerformIndex = this.performList.findIndex((p) => p.performName === perform.performName); if (dupPerformIndex > -1) { // 结束并删除全部重复演出 @@ -50,7 +59,7 @@ export class PerformController { // perform.isOver = true; if (!perform.isHoldOn) { // 如果不是保持演出,清除 - this.unmountPerform(perform.performName); + this.softUnmountPerformObject(perform); } }, perform.duration); @@ -64,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); /** @@ -85,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--; /** * 从状态表里清除演出 */ @@ -103,6 +115,25 @@ 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)); } diff --git a/packages/webgal/src/Core/Modules/readHistory.ts b/packages/webgal/src/Core/Modules/readHistory.ts new file mode 100644 index 000000000..aa5336abb --- /dev/null +++ b/packages/webgal/src/Core/Modules/readHistory.ts @@ -0,0 +1,118 @@ +/** + * 已读历史记录 + */ + +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); + } + 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 时的兜底逻辑 + 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, + })); + } + 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))); + } + 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); + } + + public checkIsRead() { + this.checkLoad(); + + const scenarioName = this.sceneManager.sceneData.currentScene.sceneName; + const index = this.sceneManager.sceneData.currentSentenceId; + + let isRead = false; + if (this.history.has(scenarioName)) { + const bitset = this.history.get(scenarioName)!; + isRead = (bitset[index >> 3] & (1 << (index & 7))) !== 0; + } + webgalStore.dispatch(setStage({ + key: 'isRead', + value: isRead, + })); + if (!isRead) { + this.addReadHistory(); + } + } +} 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/gamePlay/fastSkip.ts b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts index 8798f3243..848e0a064 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,17 @@ 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(); + return; + } nextSentence(); }, SYSTEM_CONFIG.fast_timeout); }; diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 04f5c997b..6670ca15d 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 > @@ -100,6 +102,7 @@ export const scriptExecutor = () => { nextSentence(); return; } + WebGAL.readHistoryManager.checkIsRead(); runScript(currentScript); // 是否要进行下一句 let isNext = getBooleanArgByKey(currentScript, 'next') ?? false; diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index ead6e3bd1..53aadd554 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -2,8 +2,7 @@ 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 { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -28,11 +27,8 @@ 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); + 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 67df0879a..4bac9b37d 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -2,8 +2,7 @@ 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 { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -22,11 +21,8 @@ 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); + clearPrefetchLinks(); + WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 logger.debug('现在切换场景,切换后的结果:', WebGAL.sceneManager.sceneData); WebGAL.sceneManager.lockSceneWrite = false; nextSentence(); diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 7b1e9fc81..c0ae8c2f0 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; if (convertAlpha) { @@ -92,15 +94,14 @@ 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: 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 立绘 @@ -111,11 +112,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; /** * 暂时没用上,以后可能用 @@ -128,6 +133,7 @@ export default class PixiStage { const app = new PIXI.Application({ backgroundAlpha: 0, preserveDrawingBuffer: true, + autoStart: false, }); // @ts-ignore @@ -189,19 +195,26 @@ 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(); + } + + public requestRender() { + if (this.isRenderPending) return; + this.isRenderPending = true; + + requestAnimationFrame(() => { + this.isRenderPending = false; + if (!this.currentApp?.ticker.started) { + this.currentApp?.render(); + } + }); } public getFigureObjects() { @@ -361,6 +374,7 @@ export default class PixiStage { return; } sprite.texture = texture; + this.requestRender(); }); } @@ -389,6 +403,7 @@ export default class PixiStage { return; } sprite.texture = texture; + this.requestRender(); }); } @@ -416,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, }); // 完成图片加载后执行的函数 @@ -451,6 +467,7 @@ export default class PixiStage { // 挂载 thisBgContainer.addChild(bgSprite); + this.requestRender(); } }, 0); }; @@ -583,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, }); // 完成图片加载后执行的函数 @@ -628,6 +646,7 @@ export default class PixiStage { } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.requestRender(); } }, 0); }; @@ -1067,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 { @@ -1152,13 +1171,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); } @@ -1177,6 +1189,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); + 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 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) { + 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[]) { @@ -1189,40 +1255,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/generateTransformAnimationObj.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts index 1cf2a38af..dbbe8c0ba 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 = true, ): AnimationObj { let animationObj; // 获取那个 target 的当前变换 @@ -25,8 +27,21 @@ 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 || {}, (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 { // 应用默认effect,也就是最终的 effect 的 alpha = 0 版本 const effectWithDuration = { ...applyFrame, alpha: 0, duration: 0, ease }; 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/timeline.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts index f0b7198b3..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)); @@ -71,11 +73,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)) { @@ -98,11 +100,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/controller/stage/pixi/animations/universalSoftIn.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts index a4cbfc868..385b8c15b 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 96b1c75fc..305c9962b 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/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/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 2ebf4ce6b..ae7bd2b61 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.settledScenes.add(WebGAL.sceneManager.sceneData.currentScene.sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 }); WebGAL.sceneManager.sceneData.currentSentenceId = backlogFile.saveScene.currentSentenceId; WebGAL.sceneManager.sceneData.sceneStack = cloneDeep(backlogFile.saveScene.sceneStack); @@ -67,6 +61,9 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => { // 恢复舞台状态 const newStageState: IStageState = cloneDeep(backlogFile.currentStageState); + // 确保原先未读的文本在使用 backlog 时能正确显示为已读文本 + newStageState.isRead = true; + dispatch(resetStageState(newStageState)); // 恢复演出 @@ -77,4 +74,7 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => { // 重新显示 TextBox dispatch(setVisibility({ component: 'showTextBox', visibility: true })); + + // 重新渲染 + setTimeout(() => WebGAL.gameplay.pixiStage?.requestRender(), 100); }; diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index ae471ab7e..4daeac8e8 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.settledScenes.add(WebGAL.sceneManager.sceneData.currentScene.sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 }); WebGAL.sceneManager.sceneData.currentSentenceId = loadFile.sceneData.currentSentenceId; WebGAL.sceneManager.sceneData.sceneStack = cloneDeep(loadFile.sceneData.sceneStack); @@ -61,6 +55,8 @@ export function loadGameFromStageData(stageData: ISaveData) { // 恢复舞台状态 const newStageState = cloneDeep(loadFile.nowStageState); + // 确保原先未读的文本在 load 时能正确显示为已读文本 + newStageState.isRead = true; const dispatch = webgalStore.dispatch; dispatch(resetStageState(newStageState)); 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; } diff --git a/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts b/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts index d4f93cc90..70922f6f9 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/setEbg.ts @@ -1,19 +1,32 @@ 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}")`; + ebg.style.backgroundImage = getValidBgImage(url); } const ebgOverlay = document.getElementById('ebgOverlay') as HTMLElement; if (ebgOverlay) { - ebgOverlay.style.backgroundImage = `url("${previousImageUrl}")`; - ebgOverlay.animate([{ opacity: 1 }, { opacity: 0 }], { + ebgOverlay.style.backgroundImage = getValidBgImage(previousImageUrl); + if (animation) { + animation.cancel(); + } + animation = ebgOverlay.animate([{ opacity: 1 }, { opacity: 0 }], { duration: duration, - easing: 'ease-in-out', + easing: ease, }); } previousImageUrl = url; } + +function getValidBgImage(url: string): string { + if (url === '') { + return 'none'; + } else { + return `url("${url}")`; + } +} + 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/gameScripts/setAnimation.ts b/packages/webgal/src/Core/gameScripts/setAnimation.ts index aa68a5bb4..d904823bf 100644 --- a/packages/webgal/src/Core/gameScripts/setAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setAnimation.ts @@ -19,12 +19,14 @@ 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}`; + const performName = parallel ? `${performInitName}#${animationName}` : performInitName; let keepAnimationStopped = false; - WebGAL.gameplay.performController.unmountPerform(performInitName, true); + if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); let stopFunction; setTimeout(() => { @@ -37,6 +39,7 @@ export const setAnimation = (sentence: ISentence): IPerform => { target, animationDuration, writeDefault, + !parallel, ); if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); @@ -55,7 +58,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 bd6f6964d..8bbe766de 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -10,13 +10,14 @@ import { baseTransform } from '@/store/stageInterface'; import { IUserAnimation } from '../Modules/animations'; import { getAnimateDuration, getAnimationObject } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; +import { v4 as uuid } from 'uuid'; /** * 设置临时动画 * @param sentence */ export const setTempAnimation = (sentence: ISentence): IPerform => { - const animationName = (Math.random() * 10).toString(16); + const animationName = uuid(); const animationString = sentence.content; let animationObj; try { @@ -30,12 +31,14 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { 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}`; + const performName = parallel ? `${performInitName}#${animationName}` : performInitName; let keepAnimationStopped = false; - WebGAL.gameplay.performController.unmountPerform(performInitName, true); + if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); let stopFunction = () => {}; setTimeout(() => { @@ -48,6 +51,7 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { target, animationDuration, writeDefault, + !parallel, ); if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); @@ -66,7 +70,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 5a53042ae..3bd29819a 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -11,13 +11,13 @@ 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 { v4 as uuid } from 'uuid'; /** * 设置变换 * @param sentence */ export const setTransform = (sentence: ISentence): IPerform => { - const animationName = (Math.random() * 10).toString(16); + const animationName = uuid(); const animationString = sentence.content; let animationObj: AnimationFrame[]; @@ -26,14 +26,17 @@ 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}`; + const performName = parallel ? `${performInitName}#${animationName}` : performInitName; - 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 的旧语义;是否写完整字段由 parallel 单独控制 + animationObj = generateTransformAnimationObj(target, frame, duration, ease, !parallel); console.log('animationObj:', animationObj); } catch (e) { // 解析都错误了,歇逼吧 @@ -43,7 +46,6 @@ export const setTransform = (sentence: ISentence): IPerform => { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); const animationDuration = getAnimateDuration(animationName); - const key = `${target}-${animationName}-${animationDuration}`; let keepAnimationStopped = false; setTimeout(() => { @@ -56,6 +58,7 @@ export const setTransform = (sentence: ISentence): IPerform => { target, animationDuration, writeDefault, + !parallel, ); if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); @@ -74,7 +77,7 @@ export const setTransform = (sentence: ISentence): IPerform => { }; return { - performName: performInitName, + performName: performName, duration: animationDuration, isHoldOn: keep, stopFunction, 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]; diff --git a/packages/webgal/src/Core/gameScripts/wait.ts b/packages/webgal/src/Core/gameScripts/wait.ts index 17380d094..73d7ce752 100644 --- a/packages/webgal/src/Core/gameScripts/wait.ts +++ b/packages/webgal/src/Core/gameScripts/wait.ts @@ -1,5 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; /** * 等待 n 毫秒 @@ -8,14 +9,18 @@ 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; + return { performName, duration: duration, goNextWhenOver: true, isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, + stopFunction: () => { + // 无需状态清理 + }, + blockingNext: () => nobreak, + blockingAuto: () => nobreak, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index a1b17960c..2cba7ae38 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'; @@ -51,11 +49,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); + 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 f3176f0cd..6886f5f10 100644 --- a/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts +++ b/packages/webgal/src/Core/util/prefetcher/assetsPrefetcher.ts @@ -2,36 +2,123 @@ 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 runAssetsPrefetchQueue = () => { + if (isAssetPrefetchQueueRunning || assetPrefetchQueue.length === 0) { + return; + } + isAssetPrefetchQueueRunning = true; + const nextAsset = assetPrefetchQueue.shift() as IAsset; + setTimeout(() => { + try { + prefetchByLinkElement(nextAsset); + } catch (e) { + logger.warn(`预加载资源失败,将允许重试:${nextAsset.url}`, e); + WebGAL.sceneManager.settledAssets.delete(nextAsset.url); + } finally { + 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 场景资源列表 */ -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.has(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); - } - } - WebGAL.sceneManager.settledAssets.push(asset.url); + logger.info(`现在预加载资源${asset.url},触发行号:${asset.lineNumber}`); + WebGAL.sceneManager.settledAssets.add(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..30eee280b 100644 --- a/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts +++ b/packages/webgal/src/Core/util/prefetcher/scenePrefetcher.ts @@ -8,15 +8,51 @@ 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 () => { + if (WebGAL.sceneManager.settledScenes.has(sceneUrl)) { + queuedSceneUrlSet.delete(sceneUrl); + isScenePrefetchQueueRunning = false; + runScenePrefetchQueue(); + return; + } + WebGAL.sceneManager.settledScenes.add(sceneUrl); + queuedSceneUrlSet.delete(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); + WebGAL.sceneManager.settledScenes.delete(sceneUrl); + } 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.has(sceneUrl) || queuedSceneUrlSet.has(sceneUrl)) { + continue; } + queuedSceneUrlSet.add(sceneUrl); + scenePrefetchQueue.push(sceneUrl); } + runScenePrefetchQueue(); }; 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; } } 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/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[]) { diff --git a/packages/webgal/src/Stage/MainStage/useSetEffects.ts b/packages/webgal/src/Stage/MainStage/useSetEffects.ts index c5a1790ad..049d04b02 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; @@ -35,6 +36,7 @@ export function setStageEffects(effects: IEffect[]) { } } } + WebGAL.gameplay.pixiStage?.requestRender(); } function convertTransform(transform: ITransform | undefined) { @@ -42,5 +44,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/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 ( + + { + dispatch(setOptionData({ key: 'skipAll', value: false })); + setStorage(); + }, () => { + dispatch(setOptionData({ key: 'skipAll', value: true })); + setStorage(); + }]} + currentChecked={userDataState.optionData.skipAll ? 1 : 0} + /> + { lineLimit: 3, isUseStroke: true, textboxOpacity: textboxOpacity, + isRead: false, }; return ( diff --git a/packages/webgal/src/hooks/useHotkey.tsx b/packages/webgal/src/hooks/useHotkey.tsx index b87199278..00db7b485 100644 --- a/packages/webgal/src/hooks/useHotkey.tsx +++ b/packages/webgal/src/hooks/useHotkey.tsx @@ -201,7 +201,8 @@ export function useSkip() { const isCtrlKey = useCallback((e) => 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/stageInterface.ts b/packages/webgal/src/store/stageInterface.ts index 12142f1c1..d5ae57247 100644 --- a/packages/webgal/src/store/stageInterface.ts +++ b/packages/webgal/src/store/stageInterface.ts @@ -25,47 +25,47 @@ 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; - oldFilm: number; - dotFilm: number; - reflectionFilm: number; - glitchFilm: number; - rgbFilm: number; - godrayFilm: 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; + oldFilm?: number; + dotFilm?: number; + reflectionFilm?: number; + glitchFilm?: number; + rgbFilm?: number; + godrayFilm?: number; + shockwaveFilter?: number; + radiusAlphaFilter?: number; } /** @@ -215,6 +215,7 @@ export interface IStageState { // 自由立绘 freeFigure: Array; 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..f34017cad 100644 --- a/packages/webgal/src/store/stageReducer.ts +++ b/packages/webgal/src/store/stageReducer.ts @@ -24,6 +24,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { commandType } from '@/Core/controller/scene/sceneInterface'; import { STAGE_KEYS } from '@/Core/constants'; import { baseBlinkParam, baseFocusParam } from '@/Core/live2DCore'; +import { isUndefined, omitBy } from 'lodash'; // 初始化舞台数据 @@ -35,6 +36,7 @@ export const initState: IStageState = { figNameRight: '', // 立绘_右 文件地址(相对或绝对) freeFigure: [], figureAssociatedAnimation: [], + isRead: false, showText: '', // 文字 showTextSize: -1, showName: '', // 人物名 @@ -124,12 +126,22 @@ const stageSlice = createSlice({ const effectIndex = state.effects.findIndex((e) => e.target === target); if (effectIndex >= 0) { // Update the existing effect - state.effects[effectIndex].transform = transform; + 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 + // Add a new effect, use baseTransform as default to ensure completeness state.effects.push({ target, - transform, + transform: transform ? { ...baseTransform, ...transform } : { ...baseTransform }, }); } }, @@ -202,9 +214,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--; } diff --git a/packages/webgal/src/store/userDataInterface.ts b/packages/webgal/src/store/userDataInterface.ts index 111b0e1c9..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; // 快进已读/快进全文 } /** @@ -91,6 +92,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..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, }; // 初始化用户数据 @@ -47,6 +48,7 @@ export const initState: IUserData = { cg: [], }, gameConfigInit: {}, + readHistory: {}, }; const userDataSlice = createSlice({ @@ -142,6 +144,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 +161,7 @@ export const { unlockBgmInUserData, resetOptionSet, resetAllData, + setReadHistory, } = userDataSlice.actions; export default userDataSlice.reducer; 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: { diff --git a/releasenote.md b/releasenote.md index 26e371ee9..d200fab6c 100644 --- a/releasenote.md +++ b/releasenote.md @@ -1,6 +1,6 @@ ## 发布日志 -**本仓库只发布源代码** +**本仓库发布源代码,并在 Release 中附带 WebGAL 引擎网页版压缩包** **如果你想要体验使用便捷的图形化编辑器创建、制作并实时预览 WebGAL 游戏,请 [下载 WebGAL 图形化编辑器](https://github.com/OpenWebGAL/WebGAL_Terre/releases)** @@ -8,36 +8,36 @@ #### 新功能 -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 -**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)** @@ -45,36 +45,36 @@ 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 ## リリースノート -**このリポジトリはソースコードのみを公開しています** +**このリポジトリではソースコードを公開し、Release には WebGAL エンジンの Web 版パッケージも同梱しています** **もしあなたが使いやすいグラフィカルエディタでWebGALゲームを作成、制作、リアルタイムプレビューしたい場合は、[WebGALグラフィカルエディタをダウンロードしてください](https://github.com/OpenWebGAL/WebGAL_Terre/releases)** @@ -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 のキャッシュが干渉する問題を修正し、ハッシュ付きビルドアセットのみをキャッシュするようにしました -タイトル画面のスタイルとレイアウトの問題を修正しました +旧バージョンのユーザーデータでフィールドが不足している場合に全体がリセットされる問題を修正し、既存セーブに既定フィールドを補完するようにしました