diff --git a/package.json b/package.json index 4772602ff..646a980ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@webgal/base", - "version": "4.6.0", + "version": "4.6.1", "description": "A brand new web Visual Novel engine.", "repository": "https://github.com/OpenWebGAL/WebGAL.git", "author": "Mahiru ", diff --git a/packages/parser/package.json b/packages/parser/package.json index af15e314f..6eb112d2e 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "webgal-parser", - "version": "4.5.18", + "version": "4.6.1", "description": "WebGAL script parser", "scripts": { "test": "vitest", diff --git a/packages/parser/src/scriptParser/argsParser.ts b/packages/parser/src/scriptParser/argsParser.ts index 019ecbabb..c9b998d0e 100644 --- a/packages/parser/src/scriptParser/argsParser.ts +++ b/packages/parser/src/scriptParser/argsParser.ts @@ -35,36 +35,39 @@ export function argsParser( key: 'vocal', value: assetSetter(e, fileType.vocal), }); - } else { - // 判断是不是省略参数 - if (argValue === undefined) { - returnArrayList.push({ - key: argName, - value: true, - }); - } else { - // 是字符串描述的布尔值 - if (argValue === 'true' || argValue === 'false') { - returnArrayList.push({ - key: argName, - value: argValue === 'true', - }); - } else { - // 是数字 - if (!isNaN(Number(argValue))) { - returnArrayList.push({ - key: argName, - value: Number(argValue), - }); - } else { - // 是普通参数 - returnArrayList.push({ - key: argName, - value: argValue, - }); - } - } - } + } else if (argName === 'vocal' && argValue !== undefined) { + returnArrayList.push({ + key: argName, + value: assetSetter(argValue, fileType.vocal), + }); + } + // 判断是不是省略参数 + else if (argValue === undefined) { + returnArrayList.push({ + key: argName, + value: true, + }); + } + // 是字符串描述的布尔值 + else if (argValue === 'true' || argValue === 'false') { + returnArrayList.push({ + key: argName, + value: argValue === 'true', + }); + } + // 是数字 + else if (!isNaN(Number(argValue))) { + returnArrayList.push({ + key: argName, + value: Number(argValue), + }); + } + // 是普通参数 + else { + returnArrayList.push({ + key: argName, + value: argValue, + }); } }); return returnArrayList; diff --git a/packages/parser/test/parser.test.ts b/packages/parser/test/parser.test.ts index 87d1cd94b..f2cafaa39 100644 --- a/packages/parser/test/parser.test.ts +++ b/packages/parser/test/parser.test.ts @@ -202,6 +202,27 @@ test("say statement", async () => { expect(result.sentenceList).toContainEqual(expectSentenceItem); }); +test("say statement applies asset setter to vocal named argument", async () => { + const parser = new SceneParser((assetList) => { + }, (fileName, assetType) => { + if (assetType === fileType.vocal) { + return `./game/vocal/${fileName}`; + } + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`say:123 -speaker=xx -vocal=a.mp3;`, 'test', 'test'); + const sentence = result.sentenceList[0]; + + expect(sentence.args).toContainEqual({ key: 'vocal', value: './game/vocal/a.mp3' }); + expect(sentence.sentenceAssets).toContainEqual({ + name: './game/vocal/a.mp3', + url: './game/vocal/a.mp3', + type: fileType.vocal, + lineNumber: 0, + }); +}); + test("wait command", async () => { const parser = new SceneParser((assetList) => { }, (fileName, assetType) => { diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 19107aa8f..7a216c9f4 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -1,6 +1,6 @@ { "name": "webgal-engine", - "version": "4.6.0", + "version": "4.6.1", "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/config.txt b/packages/webgal/public/game/config.txt index 972b4e06c..c2c6f05ac 100644 --- a/packages/webgal/public/game/config.txt +++ b/packages/webgal/public/game/config.txt @@ -4,3 +4,4 @@ Title_img:WebGAL_New_Enter_Image.webp; Title_bgm:s_Title.mp3; Game_Logo:WebGalEnter.webp; Enable_Appreciation:true; +Enable_Continue:true; diff --git a/packages/webgal/public/game/scene/demo_zh_cn.txt b/packages/webgal/public/game/scene/demo_zh_cn.txt index 9776aa6f1..73f93057c 100644 --- a/packages/webgal/public/game/scene/demo_zh_cn.txt +++ b/packages/webgal/public/game/scene/demo_zh_cn.txt @@ -6,7 +6,8 @@ setTransition: -target=bg-main -exit=shockwaveOut; :你好|欢迎来到 {engine} 的世界; changeBg:bg.webp -next; setTransition: -target=bg-main -enter=shockwaveIn -next; -unlockCg:bg.webp -name=良い夜; // 解锁CG并赋予名称 +unlockCg:bg.webp -name=良い夜 -series=op; // 解锁CG并赋予名称 +unlockCg:WebGAL_New_Enter_Image.webp -name=Enter -series=op; // 解锁CG并赋予名称 changeFigure:stand.webp -left -enter=enter-from-left -animationFlag=on -eyesOpen=stand.webp -eyesClose=stand.webp -mouthOpen=open_mouth.webp -mouthHalfOpen=open_mouth.webp -mouthClose=stand.webp -next; miniAvatar:miniavatar.webp; {heroine}:欢迎使用 {engine}!这是一款全新的网页端视觉小说引擎。 -v1.wav -left; diff --git a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss index cd0e7421b..54a0a6849 100644 --- a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss +++ b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss @@ -182,5 +182,4 @@ background-clip: border-box; -webkit-background-clip: border-box; color: #C0C0C0; - -webkit-text-fill-color: #C0C0C0; } diff --git a/packages/webgal/public/game/template/UI/Title/title.scss b/packages/webgal/public/game/template/UI/Title/title.scss index e6ac0f800..a4b0693ed 100644 --- a/packages/webgal/public/game/template/UI/Title/title.scss +++ b/packages/webgal/public/game/template/UI/Title/title.scss @@ -69,7 +69,6 @@ background-clip: border-box; -webkit-background-clip: border-box; color: #ffffff; - -webkit-text-fill-color: #ffffff; z-index: 2; text-shadow: 0 2px 6px rgba(7, 12, 20, 0.15), diff --git a/packages/webgal/public/game/template/template.json b/packages/webgal/public/game/template/template.json index e99ce4989..8d2dd8058 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.6.0" + "webgal-version":"4.6.1" } diff --git a/packages/webgal/public/webgal-engine.json b/packages/webgal/public/webgal-engine.json index 9fb944cdb..f3c2cd034 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.6.0", + "version": "4.6.1", "type": "official", - "webgalVersion": "4.6.0", + "webgalVersion": "4.6.1", "description": "界面美观、功能强大、易于开发的全新网页端视觉小说引擎", "descriptions": { "en": "A brand new web Visual Novel engine with a beautiful interface, powerful features, and easy development", diff --git a/packages/webgal/scripts/update-engine-version.js b/packages/webgal/scripts/update-engine-version.js index bb2822c5b..2f7ddb07e 100644 --- a/packages/webgal/scripts/update-engine-version.js +++ b/packages/webgal/scripts/update-engine-version.js @@ -26,8 +26,10 @@ try { const engineJson = JSON.parse(fs.readFileSync(engineJsonPath, 'utf-8')); // 更新版本号 - const oldVersion = engineJson.version; - engineJson.version = version; + const oldVersion = engineJson.webgalVersion; + if (engineJson.type === 'official') { + engineJson.version = version; + } engineJson.webgalVersion = version; // 写回文件(保持格式化) diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index 7cbc5ea37..1ba79e854 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -66,21 +66,19 @@ export function getAnimationTimeline( } const mappedEffects = effect.effects.map((effect) => { const targetSetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target); + const sourceTransform = + !writeDefault && targetSetEffect && targetSetEffect.transform ? targetSetEffect.transform : baseTransform; let newEffect; - if (!writeDefault && targetSetEffect && targetSetEffect.transform) { - 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: '' }); - } + if (writeFullEffect) { + newEffect = cloneDeep({ ...sourceTransform, duration: 0, ease: '' }); } else { - newEffect = cloneDeep({ ...baseTransform, duration: 0, ease: '' }); + const targetScale = pickBy(sourceTransform.scale || {}, (source, key) => unionScaleKeys.has(key)); + const targetPosition = pickBy(sourceTransform.position || {}, (s, key) => unionPositionKeys.has(key)); + const originalTransform = { ...pickBy(sourceTransform, (source, key) => unionKeys.has(key)) }; + if (unionScaleKeys.size > 0) originalTransform.scale = targetScale; + if (unionPositionKeys.size > 0) originalTransform.position = targetPosition; + newEffect = cloneDeep({ ...originalTransform, duration: 0, ease: '' }); } PixiStage.assignTransform(newEffect, effect, false); @@ -121,22 +119,26 @@ export function getEnterExitAnimation( if (isBg) { duration = DEFAULT_BG_IN_DURATION; } - duration = - stageStateManager.getCalculationStageState().animationSettings.find((setting) => setting.target === target) - ?.enterDuration ?? - duration; + const animationSettings = stageStateManager + .getCalculationStageState() + .animationSettings.find((setting) => setting.target === target); + duration = animationSettings?.enterDuration ?? duration; // 走默认动画 let animation: IAnimationObject | null = generateUniversalSoftInAnimationObj(realTarget ?? target, duration); const transformState = stageStateManager.getCalculationStageState().effects; const targetEffect = transformState.find((effect) => effect.target === target); - const animationName = stageStateManager - .getCalculationStageState() - .animationSettings.find((setting) => setting.target === target)?.enterAnimationName; + const animationName = animationSettings?.enterAnimationName; if (animationName && !targetEffect) { logger.debug('取代默认进入动画', target); - animation = getAnimationObject(animationName, realTarget ?? target, getAnimateDuration(animationName), false); + animation = getAnimationObject( + animationName, + realTarget ?? target, + getAnimateDuration(animationName), + false, + !(animationSettings?.enterAnimationIgnoreDefault ?? false), + ); duration = getAnimateDuration(animationName); } return { duration, animation }; @@ -155,7 +157,13 @@ export function getEnterExitAnimation( const animationName = animationSettings?.exitAnimationName; if (animationName) { logger.debug('取代默认退出动画', target); - animation = getAnimationObject(animationName, realTarget ?? target, getAnimateDuration(animationName), false); + animation = getAnimationObject( + animationName, + realTarget ?? target, + getAnimateDuration(animationName), + false, + !(animationSettings?.exitAnimationIgnoreDefault ?? false), + ); duration = getAnimateDuration(animationName); } if (animationSettings) { diff --git a/packages/webgal/src/Core/Modules/gamePlay.ts b/packages/webgal/src/Core/Modules/gamePlay.ts index 83d0b3cef..9462abf0a 100644 --- a/packages/webgal/src/Core/Modules/gamePlay.ts +++ b/packages/webgal/src/Core/Modules/gamePlay.ts @@ -31,6 +31,9 @@ export class Gameplay { this._isFast = value; setFastButton(value); } + public get skipAnimation() { + return this.isFast || this.isFastPreview; + } public resetGamePlay() { this.isAuto = false; diff --git a/packages/webgal/src/Core/Modules/stage/stageInterface.ts b/packages/webgal/src/Core/Modules/stage/stageInterface.ts index d5ae57247..4e8cd25f5 100644 --- a/packages/webgal/src/Core/Modules/stage/stageInterface.ts +++ b/packages/webgal/src/Core/Modules/stage/stageInterface.ts @@ -83,6 +83,8 @@ export interface IStageAnimationSetting { exitAnimationName?: string; enterDuration?: number; exitDuration?: number; + enterAnimationIgnoreDefault?: boolean; + exitAnimationIgnoreDefault?: boolean; } export type StageAnimationSettingUpdatableKey = Exclude; diff --git a/packages/webgal/src/Core/controller/gamePlay/backToTitle.ts b/packages/webgal/src/Core/controller/gamePlay/backToTitle.ts index 7711349f9..80927591f 100644 --- a/packages/webgal/src/Core/controller/gamePlay/backToTitle.ts +++ b/packages/webgal/src/Core/controller/gamePlay/backToTitle.ts @@ -5,9 +5,11 @@ import { stopAuto } from '@/Core/controller/gamePlay/autoPlay'; import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { fastSaveGame } from '../storage/fastSaveLoad'; export const backToTitle = () => { if (webgalStore.getState().GUI.showTitle) return; + fastSaveGame(); const dispatch = webgalStore.dispatch; stopAllPerform(); stopAuto(); diff --git a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts index d4a9c39a4..dd02b65a8 100644 --- a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts +++ b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts @@ -8,6 +8,13 @@ import { webgalStore } from "@/store/store"; import { SYSTEM_CONFIG } from '@/config'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +let temporaryFastTimeout: ReturnType | null = null; + +const clearTemporaryFast = () => { + if (temporaryFastTimeout !== null) clearTimeout(temporaryFastTimeout); + temporaryFastTimeout = null; +}; + /** * 设置 fast 按钮的激活与否 * @param on @@ -25,9 +32,7 @@ export const setFastButton = (on: boolean) => { * 停止快进模式 */ export const stopFast = () => { - if (!isFast()) { - return; - } + clearTemporaryFast(); WebGAL.gameplay.isFast = false; if (WebGAL.gameplay.fastInterval !== null) { clearInterval(WebGAL.gameplay.fastInterval); @@ -39,7 +44,8 @@ export const stopFast = () => { * 开启快进 */ export const startFast = (force = false) => { - if (isFast()) { + clearTemporaryFast(); + if (WebGAL.gameplay.fastInterval !== null) { return; } WebGAL.gameplay.isFast = true; @@ -53,6 +59,18 @@ export const startFast = (force = false) => { }, SYSTEM_CONFIG.fast_timeout); }; +export const startTemporaryFast = (duration = 150) => { + clearTemporaryFast(); + if (WebGAL.gameplay.fastInterval !== null) stopFast(); + WebGAL.gameplay.isFast = true; + temporaryFastTimeout = setTimeout(() => { + if (WebGAL.gameplay.fastInterval === null) { + WebGAL.gameplay.isFast = false; + } + temporaryFastTimeout = null; + }, duration); +}; + // 判断是否是快进模式 export const isFast = function () { return WebGAL.gameplay.isFast; @@ -71,7 +89,7 @@ export const stopAll = () => { */ export const switchFast = () => { // 现在正在快进 - if (WebGAL.gameplay.isFast) { + if (WebGAL.gameplay.fastInterval !== null) { stopFast(); } else { // 当前不在快进 diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 94dfc53ba..8bfbb5cd8 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -13,7 +13,7 @@ import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { jumpToLabel } from '@/Core/gameScripts/label/jumpToLabel'; import { prefetchCurrentSceneByProgress } from '@/Core/util/prefetcher/progressPrefetcher'; -const MAX_FORWARD_SCRIPT_EXECUTION = 10000; +const MAX_FORWARD_SCRIPT_EXECUTION = 1000; export const whenChecker = (whenValue: string | undefined): boolean => { if (whenValue === undefined) { diff --git a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts index b61cdd9ec..67ec27c09 100644 --- a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts +++ b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts @@ -34,20 +34,9 @@ export async function continueGame() { * 重设模糊背景 */ setEbg(stageStateManager.getViewStageState().bgName); - // 当且仅当游戏未开始时使用快速存档 - // 当游戏开始后 使用原来的逻辑 - if ((await hasFastSaveRecord()) && WebGAL.sceneManager.sceneData.currentSentenceId === 0) { + if ((await hasFastSaveRecord())) { + webgalStore.dispatch(setVisibility({ component: 'showTitle', visibility: false })); // 恢复记录 await loadFastSaveGame(); - return; - } - if ( - WebGAL.sceneManager.sceneData.currentSentenceId === 0 && - WebGAL.sceneManager.sceneData.currentScene.sceneName === 'start.txt' - ) { - // 如果游戏没有开始,开始游戏 - nextSentence(); - } else { - restorePerform(); } } diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index 38eaa542c..12786e9ba 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -55,7 +55,7 @@ function syncBg(stageState: IStageState) { addBg(thisBgKey, bgName); logger.debug('重设背景'); const { duration, animation } = getEnterExitAnimation(thisBgKey, 'enter', true); - if (WebGAL.gameplay.isFast) { + if (WebGAL.gameplay.skipAnimation) { setEbg(bgName, 0); } else { setEbg(bgName, duration); @@ -111,7 +111,7 @@ function syncFigureSlot(key: string, sourceUrl: string, position: 'left' | 'cent addFigure(key, sourceUrl, position); logger.debug(`${key} 立绘已重设`); const { duration, animation } = getEnterExitAnimation(key, 'enter'); - if (!WebGAL.gameplay.isFast) { + if (!WebGAL.gameplay.skipAnimation) { pixiStage.registerPresetAnimation(animation, softInAniKey, key, stageState.effects); setTimeout(() => pixiStage.removeAnimationWithSetEffects(softInAniKey), duration); } @@ -163,7 +163,7 @@ function removeBg(bgObject: IStageObject): number { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return DEFAULT_BG_OUT_DURATION; pixiStage.removeAnimationWithSetEffects('bg-main-softin'); - if (WebGAL.gameplay.isFast) { + if (WebGAL.gameplay.skipAnimation) { pixiStage.removeStageObjectByKey(bgObject.key); return 0; } @@ -185,7 +185,7 @@ function removeFig(figObj: IStageObject, enterTikerKey: string, effects: IEffect const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; pixiStage.removeAnimationWithSetEffects(enterTikerKey); - if (WebGAL.gameplay.isFast) { + if (WebGAL.gameplay.skipAnimation) { logger.debug('快速模式,立刻关闭立绘'); pixiStage.removeStageObjectByKey(figObj.key); return; diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index 7dd84ec72..7b9af34c9 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -1,6 +1,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; import { initState, stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { /** @@ -16,6 +17,7 @@ export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { // 清空所有演出和timeOut WebGAL.gameplay.pixiStage?.removeAllAnimations(); + stopFast(); WebGAL.gameplay.performController.removeAllPerform(); WebGAL.gameplay.resetGamePlay(); diff --git a/packages/webgal/src/Core/controller/storage/fastSaveLoad.ts b/packages/webgal/src/Core/controller/storage/fastSaveLoad.ts index 61c5975cb..b666b6ed6 100644 --- a/packages/webgal/src/Core/controller/storage/fastSaveLoad.ts +++ b/packages/webgal/src/Core/controller/storage/fastSaveLoad.ts @@ -1,9 +1,10 @@ import { webgalStore } from '@/store/store'; -import { getStorageAsync, setStorageAsync } from '@/Core/controller/storage/storageController'; +import { getStorageAsync } from '@/Core/controller/storage/storageController'; import { ISaveData } from '@/store/userDataInterface'; import { loadGameFromStageData } from '@/Core/controller/storage/loadGame'; import { generateCurrentStageData } from '@/Core/controller/storage/saveGame'; import cloneDeep from 'lodash/cloneDeep'; +import throttle from 'lodash/throttle'; import { WebGAL } from '@/Core/WebGAL'; import { saveActions } from '@/store/savesReducer'; import { dumpFastSaveToStorage, getFastSaveFromStorage } from '@/Core/controller/storage/savesController'; @@ -11,6 +12,7 @@ import { dumpFastSaveToStorage, getFastSaveFromStorage } from '@/Core/controller export let fastSaveGameKey = ''; export let isFastSaveKey = ''; let lock = true; +let dumpFastSaveTask = Promise.resolve(); export function initKey() { lock = false; @@ -18,16 +20,30 @@ export function initKey() { isFastSaveKey = `FastSaveActive-${WebGAL.gameName}-${WebGAL.gameKey}`; } +function dumpFastSaveToStorageSerial() { + dumpFastSaveTask = dumpFastSaveTask.catch(() => {}).then(dumpFastSaveToStorage); + return dumpFastSaveTask; +} + /** * 用于紧急回避时的数据存储 & 快速保存 */ export async function fastSaveGame() { + const showTitle = webgalStore.getState().GUI.showTitle; + if (showTitle || WebGAL.sceneManager.sceneData.currentSentenceId === 0) { + // 如果在标题界面或游戏未开始,不进行快速保存 + return; + } const saveData: ISaveData = generateCurrentStageData(-1, false); const newSaveData = cloneDeep(saveData); webgalStore.dispatch(saveActions.setFastSave(newSaveData)); - await dumpFastSaveToStorage(); + await dumpFastSaveToStorageSerial(); } +export const autoFastSaveGame = throttle(() => { + void fastSaveGame(); +}, 1000); + /** * 判断是否有无存储紧急回避时的数据 */ @@ -55,8 +71,7 @@ export async function loadFastSaveGame() { * 移除紧急回避的数据 */ export async function removeFastSaveGameRecord() { + autoFastSaveGame.cancel(); webgalStore.dispatch(saveActions.resetFastSave()); - await setStorageAsync(); - // await localforage.setItem(isFastSaveKey, false); - // await localforage.setItem(fastSaveGameKey, null); + await dumpFastSaveToStorageSerial(); } diff --git a/packages/webgal/src/Core/gameScripts/changeBg/index.ts b/packages/webgal/src/Core/gameScripts/changeBg/index.ts index cfcc01b7b..f65d6d350 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/index.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/index.ts @@ -3,7 +3,7 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; // import {getRandomPerformName} from '../../../util/getRandomPerformName'; import styles from '@/Stage/stage.module.scss'; import { webgalStore } from '@/store/store'; -import { getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; +import { getBooleanArgByKey, getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; import { unlockCgInUserData } from '@/store/userDataReducer'; import { logger } from '@/Core/util/logger'; import { ITransform } from '@/Core/Modules/stage/stageInterface'; @@ -25,16 +25,18 @@ export const changeBg = (sentence: ISentence): IPerform => { const url = sentence.content; const unlockName = getStringArgByKey(sentence, 'unlockname') ?? ''; const series = getStringArgByKey(sentence, 'series') ?? 'default'; + const order = getNumberArgByKey(sentence, 'order') ?? 0; const transformString = getStringArgByKey(sentence, 'transform'); let duration = getNumberArgByKey(sentence, 'duration') ?? DEFAULT_BG_OUT_DURATION; const enterDuration = getNumberArgByKey(sentence, 'enterDuration') ?? duration; duration = enterDuration; const exitDuration = getNumberArgByKey(sentence, 'exitDuration') ?? DEFAULT_BG_OUT_DURATION; const ease = getStringArgByKey(sentence, 'ease') ?? ''; + const ignoreDefault = getBooleanArgByKey(sentence, 'ignoreDefault') ?? false; const dispatch = webgalStore.dispatch; if (unlockName !== '') { - dispatch(unlockCgInUserData({ name: unlockName, url, series })); + dispatch(unlockCgInUserData({ name: unlockName, url, series, order })); const userDataState = webgalStore.getState().userData; localforage.setItem(WebGAL.gameKey, userDataState).then(() => {}); } @@ -57,7 +59,7 @@ export const changeBg = (sentence: ISentence): IPerform => { if (transformString) { try { const frame = JSON.parse(transformString.toString()) as AnimationFrame; - animationObj = generateTransformAnimationObj('bg-main', frame, enterDuration, ease); + animationObj = generateTransformAnimationObj('bg-main', frame, enterDuration, ease, !ignoreDefault); // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); @@ -76,7 +78,7 @@ export const changeBg = (sentence: ISentence): IPerform => { function applyDefaultTransform() { // 应用默认的 const frame = {}; - animationObj = generateTransformAnimationObj('bg-main', frame as AnimationFrame, duration, ease); + animationObj = generateTransformAnimationObj('bg-main', frame as AnimationFrame, duration, ease, !ignoreDefault); // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); @@ -85,6 +87,11 @@ export const changeBg = (sentence: ISentence): IPerform => { duration = getAnimateDuration(animationName); stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }); } + stageStateManager.updateAnimationSettings({ + target: 'bg-main', + key: 'enterAnimationIgnoreDefault', + value: ignoreDefault, + }); // 应用动画的优先级更高一点 const enterAnimation = getStringArgByKey(sentence, 'enter'); @@ -95,6 +102,11 @@ export const changeBg = (sentence: ISentence): IPerform => { } if (exitAnimation) { stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'exitAnimationName', value: exitAnimation }); + stageStateManager.updateAnimationSettings({ + target: 'bg-main', + key: 'exitAnimationIgnoreDefault', + value: ignoreDefault, + }); duration = getAnimateDuration(exitAnimation); } if (enterDuration >= 0) { @@ -125,11 +137,16 @@ export const changeBg = (sentence: ISentence): IPerform => { if (sentence.content === '' || !isUrlChanged) { return; } - const animationName = stageStateManager + const animationSetting = stageStateManager .getCalculationStageState() - .animationSettings.find((setting) => setting.target === 'bg-main')?.enterAnimationName; - if (animationName) { - applyAnimationEndState(animationName, 'bg-main', false); + .animationSettings.find((setting) => setting.target === 'bg-main'); + if (animationSetting?.enterAnimationName) { + applyAnimationEndState( + animationSetting.enterAnimationName, + 'bg-main', + false, + !(animationSetting.enterAnimationIgnoreDefault ?? false), + ); } }, stopFunction: () => { diff --git a/packages/webgal/src/Core/gameScripts/changeFigure.ts b/packages/webgal/src/Core/gameScripts/changeFigure.ts index 91fb96f5f..b6b64e6e4 100644 --- a/packages/webgal/src/Core/gameScripts/changeFigure.ts +++ b/packages/webgal/src/Core/gameScripts/changeFigure.ts @@ -95,6 +95,7 @@ export function changeFigure(sentence: ISentence): IPerform { const enterDuration = getNumberArgByKey(sentence, 'enterDuration') ?? duration; duration = enterDuration; const exitDuration = getNumberArgByKey(sentence, 'exitDuration') ?? DEFAULT_FIG_OUT_DURATION; + const ignoreDefault = getBooleanArgByKey(sentence, 'ignoreDefault') ?? false; const currentFigureAssociatedAnimation = stageStateManager.getCalculationStageState().figureAssociatedAnimation; const filteredFigureAssociatedAnimation = currentFigureAssociatedAnimation.filter((item) => item.targetId !== id); @@ -164,7 +165,7 @@ export function changeFigure(sentence: ISentence): IPerform { console.log(transformString); try { const frame = JSON.parse(transformString) as AnimationFrame; - animationObj = generateTransformAnimationObj(key, frame, duration, ease); + animationObj = generateTransformAnimationObj(key, frame, duration, ease, !ignoreDefault); // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); @@ -183,7 +184,7 @@ export function changeFigure(sentence: ISentence): IPerform { function applyDefaultTransform() { // 应用默认的 const frame = {}; - animationObj = generateTransformAnimationObj(key, frame as AnimationFrame, duration, ease); + animationObj = generateTransformAnimationObj(key, frame as AnimationFrame, duration, ease, !ignoreDefault); // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); @@ -192,6 +193,11 @@ export function changeFigure(sentence: ISentence): IPerform { duration = getAnimateDuration(animationName); stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }); } + stageStateManager.updateAnimationSettings({ + target: key, + key: 'enterAnimationIgnoreDefault', + value: ignoreDefault, + }); if (enterAnimation) { stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: enterAnimation }); @@ -199,6 +205,11 @@ export function changeFigure(sentence: ISentence): IPerform { } if (exitAnimation) { stageStateManager.updateAnimationSettings({ target: key, key: 'exitAnimationName', value: exitAnimation }); + stageStateManager.updateAnimationSettings({ + target: key, + key: 'exitAnimationIgnoreDefault', + value: ignoreDefault, + }); duration = getAnimateDuration(exitAnimation); } if (enterDuration >= 0) { @@ -287,11 +298,16 @@ export function changeFigure(sentence: ISentence): IPerform { if (content === '' || !isUrlChanged) { return; } - const animationName = stageStateManager + const animationSetting = stageStateManager .getCalculationStageState() - .animationSettings.find((setting) => setting.target === key)?.enterAnimationName; - if (animationName) { - applyAnimationEndState(animationName, key, false); + .animationSettings.find((setting) => setting.target === key); + if (animationSetting?.enterAnimationName) { + applyAnimationEndState( + animationSetting.enterAnimationName, + key, + false, + !(animationSetting.enterAnimationIgnoreDefault ?? false), + ); } }, stopFunction: () => { diff --git a/packages/webgal/src/Core/gameScripts/end.ts b/packages/webgal/src/Core/gameScripts/end.ts index b2c041f60..b9d1ebf0d 100644 --- a/packages/webgal/src/Core/gameScripts/end.ts +++ b/packages/webgal/src/Core/gameScripts/end.ts @@ -9,7 +9,7 @@ import { setVisibility } from '@/store/GUIReducer'; import { playBgm } from '@/Core/controller/stage/playBgm'; import { WebGAL } from '@/Core/WebGAL'; import { dumpToStorageFast } from '@/Core/controller/storage/storageController'; -import { saveActions } from '@/store/savesReducer'; +import { removeFastSaveGameRecord } from '../controller/storage/fastSaveLoad'; /** * 结束游戏 @@ -24,7 +24,7 @@ export const end = (sentence: ISentence): IPerform => { setTimeout(() => { WebGAL.sceneManager.resetScene(); }, 5); - dispatch(saveActions.resetFastSave()); + removeFastSaveGameRecord(); dumpToStorageFast(); sceneFetcher(sceneUrl).then((rawScene) => { // 场景写入到运行时 diff --git a/packages/webgal/src/Core/gameScripts/pixi/performs/cherryBlossoms.ts b/packages/webgal/src/Core/gameScripts/pixi/performs/cherryBlossoms.ts index bd60683c6..31fa9168c 100644 --- a/packages/webgal/src/Core/gameScripts/pixi/performs/cherryBlossoms.ts +++ b/packages/webgal/src/Core/gameScripts/pixi/performs/cherryBlossoms.ts @@ -3,6 +3,7 @@ import * as PIXI from 'pixi.js'; import { registerPerform } from '@/Core/util/pixiPerformManager/pixiPerformManager'; import { WebGAL } from '@/Core/WebGAL'; import { SCREEN_CONSTANTS } from '@/Core/util/constants'; +import cherryBlossomsTextureUrl from '@/assets/tex/cherryBlossoms.webp'; type ContainerType = 'foreground' | 'background'; @@ -59,7 +60,7 @@ const pixiCherryBlossoms = ( container.addChild(particleContainer); const sakuras: SakuraSprite[] = []; - const texture = PIXI.Texture.from('./game/tex/cherryBlossoms.webp'); + const texture = PIXI.Texture.from(cherryBlossomsTextureUrl); const randRange = (min: number, max: number): number => min + Math.random() * (max - min); diff --git a/packages/webgal/src/Core/gameScripts/pixi/performs/rain.ts b/packages/webgal/src/Core/gameScripts/pixi/performs/rain.ts index a1739f4d5..5aecbf19c 100644 --- a/packages/webgal/src/Core/gameScripts/pixi/performs/rain.ts +++ b/packages/webgal/src/Core/gameScripts/pixi/performs/rain.ts @@ -3,6 +3,7 @@ import * as PIXI from 'pixi.js'; import { registerPerform } from '@/Core/util/pixiPerformManager/pixiPerformManager'; import { WebGAL } from '@/Core/WebGAL'; import { SCREEN_CONSTANTS } from '@/Core/util/constants'; +import rainTextureUrl from '@/assets/tex/rain.png'; type ContainerType = 'foreground' | 'background'; @@ -59,7 +60,6 @@ const pixiRain = ( const raindropTextures: PIXI.Texture[] = []; const raindrops: RaindropSprite[] = []; - const baseTexturePath = './game/tex/rain.png'; const SPRITE_WIDTH = 128; const SPRITE_HEIGHT = 640; const NUM_SPRITES = 5; @@ -133,7 +133,7 @@ const pixiRain = ( while (raindrops.length > 0) raindrops.pop(); particleContainer.removeChildren(); - const baseTexture = PIXI.BaseTexture.from(baseTexturePath); + const baseTexture = PIXI.BaseTexture.from(rainTextureUrl); const finalizeSetup = () => { if (baseTexture.valid) { @@ -174,7 +174,7 @@ const pixiRain = ( finalizeSetup(); }); baseTexture.once('error', (errorEvent) => { - console.error(`Error loading base texture ${baseTexturePath}:`, errorEvent); + console.error(`Error loading base texture ${rainTextureUrl}:`, errorEvent); finalizeSetup(); }); } diff --git a/packages/webgal/src/Core/gameScripts/pixi/performs/snow.ts b/packages/webgal/src/Core/gameScripts/pixi/performs/snow.ts index 8fd93c3ec..294dbfc20 100644 --- a/packages/webgal/src/Core/gameScripts/pixi/performs/snow.ts +++ b/packages/webgal/src/Core/gameScripts/pixi/performs/snow.ts @@ -3,6 +3,7 @@ import * as PIXI from 'pixi.js'; import { registerPerform } from '@/Core/util/pixiPerformManager/pixiPerformManager'; import { WebGAL } from '@/Core/WebGAL'; import { SCREEN_CONSTANTS } from '@/Core/util/constants'; +import snowTextureUrl from '@/assets/tex/snow.png'; type ContainerType = 'foreground' | 'background'; @@ -60,7 +61,6 @@ const pixiSnow = ( const snowflakeTextures: PIXI.Texture[] = []; const snowflakes: SnowflakeSprite[] = []; - const baseTexturePath = './game/tex/snow.png'; const SPRITE_WIDTH = 128; const SPRITE_HEIGHT = 128; const NUM_SPRITES = 10; @@ -138,7 +138,7 @@ const pixiSnow = ( while (snowflakes.length > 0) snowflakes.pop(); particleContainer.removeChildren(); - const baseTexture = PIXI.BaseTexture.from(baseTexturePath); + const baseTexture = PIXI.BaseTexture.from(snowTextureUrl); const finalizeSetup = () => { if (baseTexture.valid) { @@ -179,7 +179,7 @@ const pixiSnow = ( finalizeSetup(); }); baseTexture.once('error', (errorEvent) => { - console.error(`Error loading base texture ${baseTexturePath}:`, errorEvent); + console.error(`Error loading base texture ${snowTextureUrl}:`, errorEvent); finalizeSetup(); }); } diff --git a/packages/webgal/src/Core/gameScripts/setAnimation.ts b/packages/webgal/src/Core/gameScripts/setAnimation.ts index b9c563661..24af0fefa 100644 --- a/packages/webgal/src/Core/gameScripts/setAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setAnimation.ts @@ -20,6 +20,7 @@ export const setAnimation = (sentence: ISentence): IPerform => { const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; + const writeFullEffect = !parallel && !(getBooleanArgByKey(sentence, 'ignoreDefault') ?? false); const key = `${target}-${animationName}-${animationDuration}`; const performInitName = `animation-${target}`; @@ -27,7 +28,7 @@ export const setAnimation = (sentence: ISentence): IPerform => { let keepAnimationStopped = false; if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); - const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, !parallel); + const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, writeFullEffect); const startFunction = () => { if (keep && keepAnimationStopped) { diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index d7c31c273..dc06c9f96 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -29,6 +29,7 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; + const writeFullEffect = !parallel && !(getBooleanArgByKey(sentence, 'ignoreDefault') ?? false); const key = `${target}-${animationName}-${animationDuration}`; const performInitName = `animation-${target}`; @@ -36,7 +37,7 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { let keepAnimationStopped = false; if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); - const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, !parallel); + const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, writeFullEffect); const startFunction = () => { if (keep && keepAnimationStopped) { diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index ef6bc8f30..f306c34ea 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -24,6 +24,7 @@ export const setTransform = (sentence: ISentence): IPerform => { const target = getStringArgByKey(sentence, 'target') ?? '0'; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; + const writeFullEffect = !parallel && !(getBooleanArgByKey(sentence, 'ignoreDefault') ?? false); const performInitName = `animation-${target}`; const performName = parallel ? `${performInitName}#${animationName}` : performInitName; @@ -32,8 +33,7 @@ export const setTransform = (sentence: ISentence): IPerform => { try { const frame = JSON.parse(animationString) as AnimationFrame; - // 保持 writeDefault 的旧语义;是否写完整字段由 parallel 单独控制 - animationObj = generateTransformAnimationObj(target, frame, duration, ease, !parallel); + animationObj = generateTransformAnimationObj(target, frame, duration, ease, writeFullEffect); console.log('animationObj:', animationObj); } catch (e) { // 解析都错误了,歇逼吧 @@ -43,7 +43,7 @@ export const setTransform = (sentence: ISentence): IPerform => { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); const animationDuration = getAnimateDuration(animationName); - const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, !parallel); + const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, writeFullEffect); const key = `${target}-${animationName}-${animationDuration}`; let keepAnimationStopped = false; const startFunction = () => { diff --git a/packages/webgal/src/Core/gameScripts/setTransition.ts b/packages/webgal/src/Core/gameScripts/setTransition.ts index 1f9f82685..3b1232564 100644 --- a/packages/webgal/src/Core/gameScripts/setTransition.ts +++ b/packages/webgal/src/Core/gameScripts/setTransition.ts @@ -1,6 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; -import { getStringArgByKey } from '@/Core/util/getSentenceArg'; +import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** @@ -12,11 +12,18 @@ export const setTransition = (sentence: ISentence): IPerform => { let key = getStringArgByKey(sentence, 'target') ?? '0'; const enterAnimation = getStringArgByKey(sentence, 'enter'); const exitAnimation = getStringArgByKey(sentence, 'exit'); + const ignoreDefault = getBooleanArgByKey(sentence, 'ignoreDefault') ?? false; if (enterAnimation) { stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: enterAnimation }); + stageStateManager.updateAnimationSettings({ + target: key, + key: 'enterAnimationIgnoreDefault', + value: ignoreDefault, + }); } if (exitAnimation) { stageStateManager.updateAnimationSettings({ target: key, key: 'exitAnimationName', value: exitAnimation }); + stageStateManager.updateAnimationSettings({ target: key, key: 'exitAnimationIgnoreDefault', value: ignoreDefault }); } return createNonePerform({ blockingAuto: false }); }; diff --git a/packages/webgal/src/Core/gameScripts/setVar.ts b/packages/webgal/src/Core/gameScripts/setVar.ts index 584de1f93..f86a3d356 100644 --- a/packages/webgal/src/Core/gameScripts/setVar.ts +++ b/packages/webgal/src/Core/gameScripts/setVar.ts @@ -12,77 +12,102 @@ import random from 'lodash/random'; import { getBooleanArgByKey } from '../util/getSentenceArg'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +interface ISetGameVarFromExpressionPayload { + key: string; + value: string; + isGlobal?: boolean; + persistGlobal?: boolean; +} + /** - * 设置变量 - * @param sentence + * 设置变量表达式。 */ -export const setVar = (sentence: ISentence): IPerform => { - let setGlobal = getBooleanArgByKey(sentence, 'global') ?? false; +export const setGameVarFromExpression = ({ + key, + value, + isGlobal = false, + persistGlobal = true, +}: ISetGameVarFromExpressionPayload) => { const setGameVar = (payload: ISetGameVar) => { - if (setGlobal) { + if (isGlobal) { webgalStore.dispatch(setScriptManagedGlobalVar(payload)); } else { stageStateManager.setStageVar(payload); } }; - // 先把表达式拆分为变量名和赋值语句 + + const normalizedKey = key.trim(); + if (!normalizedKey) { + return; + } + setGameVar({ key: normalizedKey, value: resolveSetVarValue(value) }); + if (isGlobal) { + logger.debug('设置全局变量:', { key: normalizedKey, value: webgalStore.getState().userData.globalGameVar[normalizedKey] }); + if (persistGlobal) { + dumpToStorageFast(); + } + } else { + logger.debug('设置变量:', { key: normalizedKey, value: stageStateManager.getCalculationStageState().GameVar[normalizedKey] }); + } +}; + +/** + * 设置变量 + * @param sentence + */ +export const setVar = (sentence: ISentence): IPerform => { + const setGlobal = getBooleanArgByKey(sentence, 'global') ?? false; if (sentence.content.match(/\s*=\s*/)) { const key = sentence.content.split(/\s*=\s*/)[0]; const valExp = sentence.content.split(/\s*=\s*/)[1]; - if (/^\s*[a-zA-Z_$][\w$]*\s*\(.*\)\s*$/.test(valExp)) { - setGameVar({ key, value: EvaluateExpression(valExp) }); - } else if (valExp.match(/[+\-*\/()]/)) { - // 如果包含加减乘除号,则运算 - // 先取出运算表达式中的变量 - const valExpArr = valExp.split(/([+\-*\/()])/g); - // 将变量替换为变量的值,然后合成表达式字符串 - const valExp2 = valExpArr - .map((e) => { - if (!e.trim().match(/^[a-zA-Z_$][a-zA-Z0-9_.]*$/)) { - // 检查是否是变量名,不是就返回本身 - return e; - } - const _r = getValueFromStateElseKey(e.trim(), true); - return typeof _r === 'string' ? `'${_r}'` : _r; - }) - .reduce((pre, curr) => pre + curr, ''); - let result = ''; - try { - const exp = compile(valExp2); - result = exp(); - } catch (e) { - logger.error('expression compile error', e); - } - setGameVar({ key, value: result }); - } else if (valExp.match(/true|false/)) { - if (valExp.match(/true/)) { - setGameVar({ key, value: true }); - } - if (valExp.match(/false/)) { - setGameVar({ key, value: false }); - } - } else if (valExp.length === 0) { - setGameVar({ key, value: '' }); - } else { - if (!isNaN(Number(valExp))) { - setGameVar({ key, value: Number(valExp) }); - } else { - // 字符串 - setGameVar({ key, value: getValueFromStateElseKey(valExp, true) }); - } - } - if (setGlobal) { - logger.debug('设置全局变量:', { key, value: webgalStore.getState().userData.globalGameVar[key] }); - dumpToStorageFast(); - } else { - logger.debug('设置变量:', { key, value: stageStateManager.getCalculationStageState().GameVar[key] }); - } + setGameVarFromExpression({ key, value: valExp, isGlobal: setGlobal }); } return createNonePerform(); }; type BaseVal = string | number | boolean | undefined; +export function resolveSetVarValue(valExp: string): string | boolean | number { + if (/^\s*[a-zA-Z_$][\w$]*\s*\(.*\)\s*$/.test(valExp)) { + return EvaluateExpression(valExp); + } else if (valExp.match(/[+\-*\/()]/)) { + const valExpArr = valExp.split(/([+\-*\/()])/g); + const valExp2 = valExpArr + .map((e) => { + if (!e.trim().match(/^[a-zA-Z_$][a-zA-Z0-9_.]*$/)) { + return e; + } + const _r = getValueFromStateElseKey(e.trim(), true); + return typeof _r === 'string' ? `'${_r}'` : _r; + }) + .reduce((pre, curr) => pre + curr, ''); + let result = ''; + try { + const exp = compile(valExp2); + result = exp(); + } catch (e) { + logger.error('expression compile error', e); + } + return result; + } else if (valExp.match(/true|false/)) { + if (valExp.match(/true/)) { + return true; + } + if (valExp.match(/false/)) { + return false; + } + } else if (valExp.length === 0) { + return ''; + } else { + if (!isNaN(Number(valExp))) { + return Number(valExp); + } else { + return getValueFromStateElseKey(valExp, true) ?? ''; + } + } + return ''; +} + /** * 执行函数 */ diff --git a/packages/webgal/src/Core/gameScripts/unlockCg.ts b/packages/webgal/src/Core/gameScripts/unlockCg.ts index 3d8cca97d..9dcf694be 100644 --- a/packages/webgal/src/Core/gameScripts/unlockCg.ts +++ b/packages/webgal/src/Core/gameScripts/unlockCg.ts @@ -6,7 +6,7 @@ import { logger } from '@/Core/util/logger'; import localforage from 'localforage'; import { WebGAL } from '@/Core/WebGAL'; -import { getStringArgByKey } from '../util/getSentenceArg'; +import { getStringArgByKey, getNumberArgByKey } from '../util/getSentenceArg'; /** * 解锁cg @@ -16,8 +16,9 @@ export const unlockCg = (sentence: ISentence): IPerform => { const url = sentence.content; const name = getStringArgByKey(sentence, 'name') ?? sentence.content; const series = getStringArgByKey(sentence, 'series') ?? 'default'; - logger.info(`解锁CG:${name},路径:${url},所属系列:${series}`); - webgalStore.dispatch(unlockCgInUserData({ name, url, series })); + const order = getNumberArgByKey(sentence, 'order') ?? 0; + logger.info(`解锁CG:${name},路径:${url},所属系列:${series},排序:${order}`); + webgalStore.dispatch(unlockCgInUserData({ name, url, series, order })); const userDataState = webgalStore.getState().userData; localforage.setItem(WebGAL.gameKey, userDataState).then(() => {}); return createNonePerform(); diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index f2428f344..f6373f6c9 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -7,7 +7,7 @@ import { assetSetter, fileType } from './util/gameAssetsAccess/assetSetter'; 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 { startPreviewSyncRuntime } from '@/Core/util/syncWithEditor/previewSyncRuntime'; import PixiStage from '@/Core/controller/stage/pixi/PixiController'; import { syncPixiStageState } from '@/Core/controller/stage/pixi/syncPixiStageState'; import axios from 'axios'; @@ -15,6 +15,7 @@ import { __INFO } from '@/config/info'; import { WebGAL } from '@/Core/WebGAL'; import { loadTemplate } from '@/Core/util/coreInitialFunction/templateLoader'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { autoFastSaveGame } from './controller/storage/fastSaveLoad'; export const isIOS = window.__WEBGAL_DEVICE_INFO__?.isIOS ?? false; // 判断是否是 iOS 终端 @@ -44,20 +45,23 @@ export const initializeScript = (): void => { loadStyle('./game/userStyleSheet.css'); // 获得 user Animation getUserAnimation(); - // 获取游戏信息 - infoFetcher('./game/config.txt'); // 获取start场景 const sceneUrl: string = assetSetter('start.txt', fileType.scene); // 场景写入到运行时 - sceneFetcher(sceneUrl).then((rawScene) => { + const initialSceneReady = sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 }); + // 获取游戏信息 + infoFetcher('./game/config.txt'); /** * 启动Pixi */ WebGAL.gameplay.pixiStage = new PixiStage(); - stageStateManager.setCommitHandler(syncPixiStageState); + stageStateManager.setCommitHandler((stageState, options) => { + syncPixiStageState(stageState, options); + if (options.notifyReact) autoFastSaveGame(); + }); /** * iOS 设备 卸载所有 Service Worker @@ -76,7 +80,7 @@ export const initializeScript = (): void => { * 绑定工具函数 */ bindExtraFunc(); - webSocketFunc(); + startPreviewSyncRuntime(); }; function loadStyle(url: string) { diff --git a/packages/webgal/src/Core/util/coreInitialFunction/bindExtraFunc.ts b/packages/webgal/src/Core/util/coreInitialFunction/bindExtraFunc.ts index 6aa3c40d8..84cf7a8c2 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/bindExtraFunc.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/bindExtraFunc.ts @@ -1,5 +1,5 @@ -import { syncFast } from '@/Core/util/syncWithEditor/syncWithOrigine'; +import { runFastPreview } from '@/Core/util/syncWithEditor/runtime/previewSyncSceneCommand'; export const bindExtraFunc = () => { - (window as any).JMP = syncFast; + (window as any).JMP = runFastPreview; }; diff --git a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts index 2268bcafc..7a239ff32 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts @@ -14,9 +14,9 @@ import { IGameVar } from '@/Core/Modules/stage/stageInterface'; * 获取游戏信息 * @param url 游戏信息路径 */ -export const infoFetcher = (url: string) => { +export const infoFetcher = (url: string): Promise => { const dispatch = webgalStore.dispatch; - axios.get(url).then(async (r) => { + return axios.get(url).then(async (r) => { let gameConfigRaw: string = r.data; let gameConfig = WebgalParser.parseConfig(gameConfigRaw); logger.info('获取到游戏信息', gameConfig); @@ -75,5 +75,7 @@ export const infoFetcher = (url: string) => { // @ts-expect-error renderPromiseResolve is a global variable window.renderPromiseResolve(); setStorage(); + + return gameConfigInit; }); }; diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSetEffectTransform.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSetEffectTransform.ts new file mode 100644 index 000000000..551fc7daf --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSetEffectTransform.ts @@ -0,0 +1,22 @@ +import type { ITransform } from '@/Core/Modules/stage/stageInterface'; +import type { SetEffectPayload } from '@/types/editorPreviewProtocol'; + +type SetEffectTransformInput = SetEffectPayload['transform']; + +export function mergeSetEffectPreviewTransform( + baseline: ITransform, + transform?: SetEffectTransformInput, +): ITransform { + return { + ...baseline, + ...(transform ?? {}), + position: { + ...baseline.position, + ...(transform?.position ?? {}), + }, + scale: { + ...baseline.scale, + ...(transform?.scale ?? {}), + }, + }; +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts new file mode 100644 index 000000000..d9fc83b00 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -0,0 +1,398 @@ +import { + createEventEnvelope, + createRequestEnvelope, + createResponseEnvelope, + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isPreviewRequestEnvelope, + isProtocolEnvelope, + PreviewRequestPayloadByType, + PreviewRequestType, + PreviewResponsePayloadByType, + RunSceneContentPayload, + RunSnippetPayload, + SetComponentVisibilityPayload, + SetEffectPayload, + SetFontOptimizationPayload, + SetTextReadModePayload, + StageSnapshotUpdatedPayload, + SyncScenePayload, + FastPreviewTimeoutPayload, +} from '../../../types/editorPreviewProtocol'; +import { webgalStore } from '@/store/store'; +import { setFontOptimization, setVisibility } from '@/store/GUIReducer'; +import { WebGAL } from '@/Core/WebGAL'; +import { sceneParser, WebgalParser } from '@/Core/parser/sceneParser'; +import { ISentence } from '@/Core/controller/scene/sceneInterface'; +import { runScript } from '@/Core/controller/gamePlay/runScript'; +import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { resetStage } from '@/Core/controller/stage/resetStage'; +import { logger } from '@/Core/util/logger'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import type { IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; +import { mergeSetEffectPreviewTransform } from './previewSetEffectTransform'; +import { requestEmbeddedLaunchId } from './runtime/embeddedPreviewBootstrap'; +import { + createPreviewSyncTransport, + PreviewSyncTransport, + PreviewSyncTransportSocket, +} from './runtime/previewSyncTransport'; +import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneCommand'; +import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; +import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; + +let previewSyncRuntimeStarted = false; +type StageStateSnapshot = IStageState; + +interface RegisterPreviewLogContext { + requestId: string; + gameId: string | undefined; + embeddedLaunchId: string | undefined; +} + +export const startPreviewSyncRuntime = () => { + if (previewSyncRuntimeStarted) { + return; + } + + const protocol = window.location.protocol; + if (protocol !== 'http:' && protocol !== 'https:') { + logger.info('当前环境不支持启动编辑器同步 V1 WebSocket'); + return; + } + + previewSyncRuntimeStarted = true; + + const loc = window.location.hostname; + const port = window.location.port; + const defaultPort = port && port !== '80' && port !== '443' ? `:${port}` : ''; + const wsProtocol = protocol === 'https:' ? 'wss' : 'ws'; + const wsUrl = `${wsProtocol}://${loc}${defaultPort}/api/webgalsync`; + + let disposed = false; + let registered = false; + let pendingRegisterRequestId: string | null = null; + let pendingRegisterContext: RegisterPreviewLogContext | null = null; + let lastPublishedSceneName: string | null = null; + let lastPublishedSentenceId: number | null = null; + let lastPublishedStageState: StageStateSnapshot | null = null; + const setEffectBaselines = new Map(); + const embeddedLaunchIdPromise = requestEmbeddedLaunchId(); + let transport!: PreviewSyncTransport; + + const createRequestId = () => `req-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + const resetRegistrationState = () => { + registered = false; + pendingRegisterRequestId = null; + pendingRegisterContext = null; + lastPublishedSceneName = null; + lastPublishedSentenceId = null; + lastPublishedStageState = null; + setEffectBaselines.clear(); + }; + + const buildStageStateSnapshot = (stageState: StageStateSnapshot): StageSnapshotUpdatedPayload['stageState'] => { + return JSON.parse(JSON.stringify(stageState)) as StageSnapshotUpdatedPayload['stageState']; + }; + + const publishReady = () => { + transport.send( + createEventEnvelope('preview.ready.updated', { + ready: true, + }), + ); + }; + + const publishStageSnapshot = (force: boolean, stageState = stageStateManager.getCalculationStageState()) => { + if (!registered) { + return; + } + + const sceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; + const sentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; + const snapshotUnchanged = + stageState === lastPublishedStageState && + sceneName === lastPublishedSceneName && + sentenceId === lastPublishedSentenceId; + + if (!force && snapshotUnchanged) { + return; + } + + const payload = { + sceneName, + sentenceId, + stageState: buildStageStateSnapshot(stageState), + }; + + const sent = transport.send(createEventEnvelope('stage.snapshot.updated', payload)); + if (sent) { + lastPublishedSceneName = sceneName; + lastPublishedSentenceId = sentenceId; + lastPublishedStageState = stageState; + } + }; + + const registerPreview = async (socket: PreviewSyncTransportSocket) => { + const requestId = createRequestId(); + pendingRegisterRequestId = requestId; + const embeddedLaunchId = await embeddedLaunchIdPromise; + if (!transport.isActiveSocket(socket) || !transport.isSocketOpen(socket)) { + return; + } + + const registerContext: RegisterPreviewLogContext = { + requestId, + gameId: WebGAL.gameKey || undefined, + embeddedLaunchId, + }; + pendingRegisterContext = registerContext; + logger.info('发送编辑器同步 V1 注册请求', registerContext); + transport.send( + createRequestEnvelope('session.register-preview', requestId, { + gameId: registerContext.gameId, + embeddedLaunchId, + }), + ); + }; + + const emitFastPreviewTimeout = (payload: FastPreviewTimeoutPayload) => { + if (!registered) { + return; + } + transport.send(createEventEnvelope('preview.event.fast-preview-timeout', payload)); + }; + + const handleSyncScene = (payload: SyncScenePayload) => { + setEffectBaselines.clear(); + executePreviewSyncSceneCommand(payload, emitFastPreviewTimeout); + }; + + const handleRunSnippet = (payload: RunSnippetPayload) => { + setEffectBaselines.clear(); + applyPreviewDebugVariables(payload.debugVariables); + const scene = WebgalParser.parse(payload.snippet, 'temp.txt', 'temp.txt'); + (scene.sentenceList as unknown as ISentence[]).forEach((sentence) => { + runScript(sentence); + }); + }; + + const applyComponentVisibility = (payload: SetComponentVisibilityPayload) => { + (Object.keys(payload) as Array).forEach((component) => { + const visibility = payload[component]; + if (typeof visibility !== 'boolean') { + return; + } + + webgalStore.dispatch( + setVisibility({ + component, + visibility, + }), + ); + }); + }; + + const handleReloadTemplates = () => { + const title = document.querySelector('.html-body__title-enter') as HTMLElement | null; + if (title) { + title.style.display = 'none'; + } + WebGAL.events.styleUpdate.emit(); + }; + + const handleRunSceneContent = (payload: RunSceneContentPayload) => { + setEffectBaselines.clear(); + resetStage(true); + applyPreviewDebugVariables(payload.debugVariables); + WebGAL.sceneManager.sceneData.currentScene = sceneParser(payload.sceneContent, 'temp', './temp.txt'); + applyComponentVisibility({ + showTitle: false, + showMenuPanel: false, + isEnterGame: true, + showPanicOverlay: false, + }); + setTimeout(() => { + nextSentence(); + }, 100); + }; + + const handleSetFontOptimization = (payload: SetFontOptimizationPayload) => { + webgalStore.dispatch(setFontOptimization(payload.enabled)); + }; + + const handleSetComponentVisibility = (payload: SetComponentVisibilityPayload) => { + applyComponentVisibility(payload); + }; + + const handleSetTextReadMode = (payload: SetTextReadModePayload) => { + setDebugTextReadMode(payload.isRead); + }; + + const getSetEffectBaseline = (target: string): ITransform => { + const cachedBaseline = setEffectBaselines.get(target); + if (cachedBaseline) { + return cachedBaseline; + } + + const currentTransform = stageStateManager + .getCalculationStageState() + .effects.find((effect) => effect.target === target)?.transform; + const baseline = mergeSetEffectPreviewTransform(baseTransform, currentTransform); + setEffectBaselines.set(target, baseline); + return baseline; + }; + + const handleSetEffect = (payload: SetEffectPayload) => { + const newTransform = mergeSetEffectPreviewTransform(getSetEffectBaseline(payload.target), payload.transform); + WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); + stageStateManager.updateEffectAndCommit({ + target: payload.target, + transform: newTransform, + }); + }; + + const previewRequestHandlers: { + [K in PreviewRequestType]: (payload: PreviewRequestPayloadByType[K]) => PreviewResponsePayloadByType[K]; + } = { + 'preview.command.sync-scene': (payload: SyncScenePayload) => { + handleSyncScene(payload); + return {}; + }, + 'preview.command.run-scene-content': (payload: RunSceneContentPayload) => { + handleRunSceneContent(payload); + return {}; + }, + 'preview.command.run-snippet': (payload: RunSnippetPayload) => { + handleRunSnippet(payload); + return {}; + }, + 'preview.command.reload-templates': () => { + handleReloadTemplates(); + return {}; + }, + 'preview.command.set-effect': (payload: SetEffectPayload) => { + handleSetEffect(payload); + return {}; + }, + 'preview.command.set-component-visibility': (payload: SetComponentVisibilityPayload) => { + handleSetComponentVisibility(payload); + return {}; + }, + 'preview.command.set-font-optimization': (payload: SetFontOptimizationPayload) => { + handleSetFontOptimization(payload); + return {}; + }, + 'preview.command.set-text-read-mode': (payload: SetTextReadModePayload) => { + handleSetTextReadMode(payload); + return {}; + }, + }; + + const handlePreviewRequest = ( + type: TType, + payload: PreviewRequestPayloadByType[TType], + ): PreviewResponsePayloadByType[TType] => { + const handler = previewRequestHandlers[type] as ( + nextPayload: PreviewRequestPayloadByType[TType], + ) => PreviewResponsePayloadByType[TType]; + + return handler(payload); + }; + + transport = createPreviewSyncTransport({ + url: wsUrl, + subprotocol: EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + onConnecting: resetRegistrationState, + onOpen: registerPreview, + onMessage: (rawData) => { + try { + const envelope = JSON.parse(String(rawData)) as unknown; + if (!isProtocolEnvelope(envelope)) { + logger.warn('收到无法识别的编辑器同步 V1 消息'); + return; + } + + if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { + if (pendingRegisterRequestId === null || envelope.requestId !== pendingRegisterRequestId) { + return; + } + + if (pendingRegisterContext) { + logger.info('编辑器同步 V1 注册完成', pendingRegisterContext); + } + pendingRegisterRequestId = null; + pendingRegisterContext = null; + registered = true; + publishReady(); + publishStageSnapshot(true); + return; + } + + if (!registered) { + if (envelope.kind === 'request') { + logger.warn(`收到注册完成前的编辑器同步 V1 请求:${envelope.type}`); + } + return; + } + + if (!isPreviewRequestEnvelope(envelope)) { + if (envelope.kind === 'request') { + logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + } + return; + } + + let responsePayload: PreviewResponsePayloadByType[typeof envelope.type]; + try { + responsePayload = handlePreviewRequest(envelope.type, envelope.payload); + } catch (error) { + logger.error(`执行编辑器同步 V1 命令失败:${envelope.type}`, error); + return; + } + + transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); + } catch (error) { + logger.error('解析编辑器同步 V1 消息失败', error); + } + }, + onClose: resetRegistrationState, + logInfo: (message) => logger.info(message), + logError: (message, error) => logger.error(message, error), + logWarn: (message, error) => logger.warn(message, error), + }); + + const storeUnsubscribe = stageStateManager.subscribe((stageState) => { + publishStageSnapshot(false, stageState); + }); + + const ensureConnected = () => { + if (disposed) { + return; + } + + transport.ensureConnected(); + }; + + const disposeRuntime = () => { + if (disposed) { + return; + } + + disposed = true; + storeUnsubscribe(); + transport.dispose(); + }; + + window.addEventListener('focus', ensureConnected); + window.addEventListener('online', ensureConnected); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + ensureConnected(); + } + }); + window.addEventListener('pagehide', disposeRuntime, { once: true }); + + transport.connect(); +}; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.ts new file mode 100644 index 000000000..13d0838f8 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.ts @@ -0,0 +1,101 @@ +import { logger } from '../../logger'; + +export const WEBGAL_PREVIEW_BOOTSTRAP_REQUEST = 'webgal.preview.bootstrap.request'; +export const WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE = 'webgal.preview.bootstrap.provide'; + +interface BootstrapProvideMessage { + type: typeof WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE; + embeddedLaunchId: string; +} + +interface EmbeddedPreviewBootstrapWindow { + parent: { postMessage: (message: unknown, targetOrigin: string) => void } | null; + addEventListener: (type: 'message', listener: (event: { data: unknown; source?: unknown }) => void) => void; + removeEventListener: (type: 'message', listener: (event: { data: unknown; source?: unknown }) => void) => void; + setTimeout: (...args: any[]) => any; + clearTimeout: (...args: any[]) => void; +} + +export interface RequestEmbeddedLaunchIdOptions { + selfWindow?: EmbeddedPreviewBootstrapWindow; + timeoutMs?: number; +} + +function isBootstrapProvideMessage(value: unknown): value is BootstrapProvideMessage { + if (typeof value !== 'object' || value === null) { + return false; + } + + const maybeMessage = value as Partial; + return ( + maybeMessage.type === WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE && + typeof maybeMessage.embeddedLaunchId === 'string' && + maybeMessage.embeddedLaunchId.length > 0 + ); +} + +function getDefaultSelfWindow(): EmbeddedPreviewBootstrapWindow { + return { + parent: window.parent === window ? null : window.parent, + addEventListener: window.addEventListener.bind(window), + removeEventListener: window.removeEventListener.bind(window), + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + }; +} + +export async function requestEmbeddedLaunchId({ + selfWindow = getDefaultSelfWindow(), + timeoutMs = 1000, +}: RequestEmbeddedLaunchIdOptions = {}): Promise { + const parentWindow = selfWindow.parent; + if (parentWindow === null) { + return undefined; + } + + return new Promise((resolve) => { + let settled = false; + let timerId: any = null; + + const cleanup = () => { + selfWindow.removeEventListener('message', handleMessage); + if (timerId !== null) { + selfWindow.clearTimeout(timerId); + timerId = null; + } + }; + + const finish = (embeddedLaunchId: string | undefined) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + resolve(embeddedLaunchId); + }; + + const handleMessage = (event: { data: unknown; source?: unknown }) => { + if (event.source !== undefined && event.source !== parentWindow) { + return; + } + + if (!isBootstrapProvideMessage(event.data)) { + return; + } + + logger.info('收到 embeddedLaunchId bootstrap', { + embeddedLaunchId: event.data.embeddedLaunchId, + }); + finish(event.data.embeddedLaunchId); + }; + + selfWindow.addEventListener('message', handleMessage); + timerId = selfWindow.setTimeout(() => { + logger.warn('等待 embeddedLaunchId bootstrap 超时,将继续以未绑定模式注册'); + finish(undefined); + }, timeoutMs); + logger.info('开始请求 embeddedLaunchId bootstrap'); + parentWindow.postMessage({ type: WEBGAL_PREVIEW_BOOTSTRAP_REQUEST }, '*'); + }); +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts new file mode 100644 index 000000000..aa8862705 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts @@ -0,0 +1,49 @@ +import { setGameVarFromExpression } from '@/Core/gameScripts/setVar'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import type { IGameVar } from '@/Core/Modules/stage/stageInterface'; +import { webgalStore } from '@/store/store'; +import { setUserData } from '@/store/userDataReducer'; +import type { DebugVariablePayload } from '../../../../types/editorPreviewProtocol'; + +let debugStageVarKeys = new Set(); +let debugGlobalBackup: { globalGameVar: IGameVar; scriptManagedGlobalVar: string[] } | null = null; + +function clearPreviewDebugVariables() { + const stageVars = stageStateManager.getCalculationStageState().GameVar; + debugStageVarKeys.forEach((key) => { + delete stageVars[key]; + }); + debugStageVarKeys = new Set(); + + if (!debugGlobalBackup) { + return; + } + webgalStore.dispatch(setUserData({ key: 'globalGameVar', value: debugGlobalBackup.globalGameVar })); + webgalStore.dispatch(setUserData({ key: 'scriptManagedGlobalVar', value: debugGlobalBackup.scriptManagedGlobalVar })); + debugGlobalBackup = null; +} + +export function applyPreviewDebugVariables(debugVariables: DebugVariablePayload[] = []) { + clearPreviewDebugVariables(); + if (debugVariables.some((item) => item.isGlobal)) { + const userData = webgalStore.getState().userData; + debugGlobalBackup = { + globalGameVar: { ...userData.globalGameVar }, + scriptManagedGlobalVar: [...userData.scriptManagedGlobalVar], + }; + } + + debugVariables + .filter((item) => item.key.trim()) + .forEach((item) => { + setGameVarFromExpression({ + key: item.key, + value: item.value, + isGlobal: item.isGlobal, + persistGlobal: false, + }); + if (!item.isGlobal) { + debugStageVarKeys.add(item.key.trim()); + } + }); +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts similarity index 77% rename from packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts rename to packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index 945a0991a..36f8ae0e5 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -4,58 +4,61 @@ import { WebGAL } from '@/Core/WebGAL'; import { resetStage } from '@/Core/controller/stage/resetStage'; import { sceneFetcher } from '@/Core/controller/scene/sceneFetcher'; import { commitForward, forward } from '@/Core/controller/gamePlay/nextSentence'; +import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; import { sceneParser } from '@/Core/parser/sceneParser'; import { logger } from '@/Core/util/logger'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; -import type { IFastPreviewTimeoutPayload } from '@/types/debugProtocol'; +import { FastPreviewTimeoutPayload, SyncScenePayload } from '../../../../types/editorPreviewProtocol'; +import { applyPreviewDebugVariables } from './previewDebugVariables'; -const FAST_PREVIEW_MAX_DURATION_MS = 500; +export const FAST_PREVIEW_MAX_DURATION_MS = 500; const FAST_PREVIEW_TIMEOUT_CHECK_INTERVAL = 100; -type FastPreviewTimeoutHandler = (payload: IFastPreviewTimeoutPayload) => void; +export type FastPreviewTimeoutEmitter = (payload: FastPreviewTimeoutPayload) => void; -export const syncWithOrigine = ( - sceneName: string, - sentenceId: number, - onFastPreviewTimeout?: FastPreviewTimeoutHandler, -) => { +export function executePreviewSyncSceneCommand( + { sceneName, sentenceId, debugVariables }: SyncScenePayload, + onFastPreviewTimeout?: FastPreviewTimeoutEmitter, +): void { logger.warn('正在跳转到' + sceneName + ':' + sentenceId); WebGAL.gameplay.isFastPreview = false; + const dispatch = webgalStore.dispatch; dispatch(setVisibility({ component: 'showTitle', visibility: false })); dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); dispatch(setVisibility({ component: 'isEnterGame', visibility: true })); dispatch(setVisibility({ component: 'isShowLogo', visibility: false })); - const title = document.querySelector('.html-body__title-enter') as HTMLElement; + + const title = document.querySelector('.html-body__title-enter') as HTMLElement | null; if (title) { title.style.display = 'none'; } - // 重新获取场景 - const sceneUrl: string = assetSetter(sceneName, fileType.scene); - // 场景写入到运行时 + + const sceneUrl = assetSetter(sceneName, fileType.scene); + sceneFetcher(sceneUrl) .then((rawScene) => { resetStage(true); + applyPreviewDebugVariables(debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); - // 开始快进到指定语句 const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - void syncFast(sentenceId, currentSceneName, onFastPreviewTimeout); + void runFastPreview(sentenceId, currentSceneName, onFastPreviewTimeout); }) - .catch((e) => { - WebGAL.gameplay.isFast = false; + .catch((error) => { + stopFast(); WebGAL.gameplay.isFastPreview = false; - logger.error('实时预览跳转错误', e); + logger.error('实时预览跳转错误', error); }); -}; +} -export async function syncFast( +export async function runFastPreview( sentenceId: number, currentSceneName: string, - onFastPreviewTimeout?: FastPreviewTimeoutHandler, -) { + onFastPreviewTimeout?: FastPreviewTimeoutEmitter, +): Promise { const fastPreviewStartTime = performance.now(); const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; - WebGAL.gameplay.isFast = true; + stopFast(); WebGAL.gameplay.isFastPreview = true; let forwardCount = 0; let isTimedOut = false; @@ -100,24 +103,24 @@ export async function syncFast( logger.warn('实时预览跳转停止:本次 forward 没有推进语句指针'); break; } - } } finally { - WebGAL.gameplay.isFast = false; WebGAL.gameplay.isFastPreview = false; } commitForward(); + const forwardedLineCount = WebGAL.sceneManager.sceneData.currentScene.sceneName === currentSceneName ? Math.min(WebGAL.sceneManager.sceneData.currentSentenceId, sentenceId) : sentenceId; const fastPreviewElapsedMs = Math.round(performance.now() - fastPreviewStartTime - suspendedElapsedMs); + if (isTimedOut) { - const payload: IFastPreviewTimeoutPayload = { - scene: WebGAL.sceneManager.sceneData.currentScene.sceneName, - sentence: WebGAL.sceneManager.sceneData.currentSentenceId, - targetSentence: sentenceId, + const payload: FastPreviewTimeoutPayload = { + sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentenceId: WebGAL.sceneManager.sceneData.currentSentenceId, + targetSentenceId: sentenceId, forwardedLineCount, elapsedMs: Math.max(timeoutElapsedMs, fastPreviewElapsedMs), maxDurationMs: FAST_PREVIEW_MAX_DURATION_MS, @@ -127,10 +130,11 @@ export async function syncFast( ); onFastPreviewTimeout?.(payload); } + logger.info(`实时预览快进完成:快进 ${forwardedLineCount} 行,用时 ${fastPreviewElapsedMs}ms`); } -function shouldContinueFastPreview(sentenceId: number, currentSceneName: string, baseSceneStackDepth: number) { +function shouldContinueFastPreview(sentenceId: number, currentSceneName: string, baseSceneStackDepth: number): boolean { const sceneData = WebGAL.sceneManager.sceneData; if (sceneData.currentScene.sceneName === currentSceneName) { return sceneData.currentSentenceId < sentenceId; @@ -138,7 +142,7 @@ function shouldContinueFastPreview(sentenceId: number, currentSceneName: string, return sceneData.sceneStack.length > baseSceneStackDepth; } -async function waitForPendingSceneWrite() { +async function waitForPendingSceneWrite(): Promise { const sceneWritePromise = WebGAL.sceneManager.sceneWritePromise; if (!sceneWritePromise) { return false; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.ts new file mode 100644 index 000000000..21bf47c1b --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.ts @@ -0,0 +1,173 @@ +const SOCKET_CONNECTING = 0; +const SOCKET_OPEN = 1; + +export interface PreviewSyncTransportSocket { + readyState: number; + onopen: (() => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onclose: (() => void) | null; + onerror: ((error: unknown) => void) | null; + send: (data: string) => void; + close: () => void; +} + +export interface PreviewSyncTransportOptions { + url: string; + subprotocol: string; + createSocket?: (url: string, subprotocol: string) => PreviewSyncTransportSocket; + onConnecting?: () => void; + onOpen: (socket: PreviewSyncTransportSocket) => void | Promise; + onMessage: (data: unknown, socket: PreviewSyncTransportSocket) => void; + onClose?: (socket: PreviewSyncTransportSocket) => void; + logInfo: (message: string) => void; + logError: (message: string, error?: unknown) => void; + logWarn: (message: string, error?: unknown) => void; +} + +export interface PreviewSyncTransport { + connect: () => void; + ensureConnected: () => void; + dispose: () => void; + send: (envelope: unknown) => boolean; + isSocketOpen: (socket: PreviewSyncTransportSocket | null | undefined) => boolean; + isActiveSocket: (socket: PreviewSyncTransportSocket | null | undefined) => boolean; +} + +function createBrowserSocket(url: string, subprotocol: string): PreviewSyncTransportSocket { + return new WebSocket(url, subprotocol) as unknown as PreviewSyncTransportSocket; +} + +export function createPreviewSyncTransport({ + url, + subprotocol, + createSocket = createBrowserSocket, + onConnecting, + onOpen, + onMessage, + onClose, + logInfo, + logError, + logWarn, +}: PreviewSyncTransportOptions): PreviewSyncTransport { + let disposed = false; + let activeSocket: PreviewSyncTransportSocket | null = null; + let connectionId = 0; + + const isSocketOpen = (socket: PreviewSyncTransportSocket | null | undefined) => { + return socket !== null && socket !== undefined && socket.readyState === SOCKET_OPEN; + }; + + const isActiveSocket = (socket: PreviewSyncTransportSocket | null | undefined) => { + return socket !== null && socket !== undefined && activeSocket === socket; + }; + + const send = (envelope: unknown): boolean => { + const socket = activeSocket; + if (socket === null || !isSocketOpen(socket)) { + return false; + } + + try { + socket.send(JSON.stringify(envelope)); + return true; + } catch (error) { + logError('发送编辑器同步 V1 消息失败', error); + return false; + } + }; + + const connect = () => { + if (disposed) { + return; + } + + if (activeSocket && (activeSocket.readyState === SOCKET_CONNECTING || activeSocket.readyState === SOCKET_OPEN)) { + return; + } + + connectionId += 1; + const currentConnectionId = connectionId; + onConnecting?.(); + logInfo(`正在启动编辑器同步 V1 WebSocket:${url}`); + const socket = createSocket(url, subprotocol); + activeSocket = socket; + + socket.onopen = () => { + if (disposed || currentConnectionId !== connectionId || !isActiveSocket(socket)) { + return; + } + + logInfo('编辑器同步 V1 WebSocket 已连接'); + Promise.resolve(onOpen(socket)).catch((error) => { + logError('处理编辑器同步 V1 WebSocket 连接回调失败', error); + if (!disposed && currentConnectionId === connectionId && isActiveSocket(socket)) { + socket.close(); + } + }); + }; + + socket.onmessage = (event) => { + if (disposed || currentConnectionId !== connectionId || !isActiveSocket(socket)) { + return; + } + + onMessage(event.data, socket); + }; + + socket.onclose = () => { + if (currentConnectionId !== connectionId || !isActiveSocket(socket)) { + return; + } + + activeSocket = null; + onClose?.(socket); + if (disposed) { + return; + } + + logInfo('编辑器同步 V1 WebSocket 已关闭'); + }; + + socket.onerror = (error) => { + if (currentConnectionId !== connectionId || !isActiveSocket(socket)) { + return; + } + + logWarn('编辑器同步 V1 WebSocket 发生错误', error); + }; + }; + + const ensureConnected = () => { + if (disposed) { + return; + } + + if (activeSocket && (activeSocket.readyState === SOCKET_OPEN || activeSocket.readyState === SOCKET_CONNECTING)) { + return; + } + + connect(); + }; + + const dispose = () => { + if (disposed) { + return; + } + + disposed = true; + if (activeSocket) { + const socket = activeSocket; + activeSocket = null; + socket.close(); + } + }; + + return { + connect, + ensureConnected, + dispose, + send, + isSocketOpen, + isActiveSocket, + }; +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts b/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts deleted file mode 100644 index ef2d3631e..000000000 --- a/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { - DebugCommand, - IComponentVisibilityCommand, - IDebugMessage, - ITextReadModeCommand, - type IFastPreviewTimeoutPayload, -} from '@/types/debugProtocol'; -import { webgalStore } from '@/store/store'; -import { setFontOptimization, setVisibility } from '@/store/GUIReducer'; -import { WebGAL } from '@/Core/WebGAL'; -import { sceneParser, WebgalParser } from '@/Core/parser/sceneParser'; -import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { runScript } from '@/Core/controller/gamePlay/runScript'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; -import { resetStage } from '@/Core/controller/stage/resetStage'; -import { logger } from '@/Core/util/logger'; -import { syncWithOrigine } from './syncWithOrigine'; -import { baseTransform, IEffect } from '@/Core/Modules/stage/stageInterface'; -import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; -import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; - -let editorSocket: WebSocket | null = null; - -export function sendDebugMessageToEditor(data: IDebugMessage['data']) { - if (!editorSocket || editorSocket.readyState !== WebSocket.OPEN) { - return false; - } - - const message: IDebugMessage = { - event: 'message', - data, - }; - - editorSocket.send(JSON.stringify(message)); - return true; -} - -function sendFastPreviewTimeoutMessage(payload: IFastPreviewTimeoutPayload) { - sendDebugMessageToEditor({ - command: DebugCommand.FAST_PREVIEW_TIMEOUT, - sceneMsg: { - scene: payload.scene, - sentence: payload.sentence, - }, - stageSyncMsg: stageStateManager.getCalculationStageState(), - message: JSON.stringify(payload), - }); -} - -export const webSocketFunc = () => { - const loc: string = window.location.hostname; - const protocol: string = window.location.protocol; - const port: string = window.location.port; // 获取端口号 - - // 默认情况下,不需要在URL中明确指定标准HTTP(80)和HTTPS(443)端口 - let defaultPort = ''; - if (port && port !== '80' && port !== '443') { - // 如果存在非标准端口号,将其包含在URL中 - defaultPort = `:${port}`; - } - - if (protocol !== 'http:' && protocol !== 'https:') { - return; - } - // 根据当前协议构建WebSocket URL,并包括端口号(如果有) - let wsUrl = `ws://${loc}${defaultPort}/api/webgalsync`; - if (protocol === 'https:') { - wsUrl = `wss://${loc}${defaultPort}/api/webgalsync`; - } - logger.info('正在启动socket连接位于:' + wsUrl); - const socket = new WebSocket(wsUrl); - socket.onopen = () => { - logger.info('socket已连接'); - editorSocket = socket; - function sendStageSyncMessage() { - sendDebugMessageToEditor({ - command: DebugCommand.SYNCFC, - sceneMsg: { - scene: WebGAL.sceneManager.sceneData.currentScene.sceneName, - sentence: WebGAL.sceneManager.sceneData.currentSentenceId, - }, - stageSyncMsg: stageStateManager.getCalculationStageState(), - message: 'sync', - }); - // logger.debug('传送信息', message); - setTimeout(sendStageSyncMessage, 1000); - } - sendStageSyncMessage(); - }; - socket.onmessage = (e) => { - // logger.info('收到信息', e.data); - const str: string = e.data; - const data: IDebugMessage = JSON.parse(str); - const message = data.data; - if (message.command === DebugCommand.JUMP) { - syncWithOrigine(message.sceneMsg.scene, message.sceneMsg.sentence, sendFastPreviewTimeoutMessage); - } - if (message.command === DebugCommand.EXE_COMMAND) { - const command = message.message; - const scene = WebgalParser.parse(command, 'temp.txt', 'temp.txt'); - scene.sentenceList.forEach((sentence: ISentence) => { - runScript(sentence); - }); - } - if (message.command === DebugCommand.REFETCH_TEMPLATE_FILES) { - const title = document.querySelector('.html-body__title-enter') as HTMLElement; - if (title) { - title.style.display = 'none'; - } - WebGAL.events.styleUpdate.emit(); - } - if (message.command === DebugCommand.SET_COMPONENT_VISIBILITY) { - // handle SET_COMPONENT_VISIBILITY message - const command = message.message; - - const commandData = JSON.parse(command) as IComponentVisibilityCommand[]; - commandData.forEach((item) => { - if (item) { - webgalStore.dispatch(setVisibility({ component: item.component, visibility: item.visibility })); - } - }); - } - if (message.command === DebugCommand.TEMP_SCENE) { - const command = message.message; - resetStage(true); - WebGAL.sceneManager.sceneData.currentScene = sceneParser(command, 'temp', './temp.txt'); - webgalStore.dispatch(setVisibility({ component: 'showTitle', visibility: false })); - webgalStore.dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); - webgalStore.dispatch(setVisibility({ component: 'isEnterGame', visibility: true })); - webgalStore.dispatch(setVisibility({ component: 'showPanicOverlay', visibility: false })); - setTimeout(() => { - nextSentence(); - }, 100); - } - if (message.command === DebugCommand.FONT_OPTIMIZATION) { - const command = message.message; - webgalStore.dispatch(setFontOptimization(command === 'true')); - } - if (message.command === DebugCommand.SET_EFFECT) { - try { - const effect = JSON.parse(message.message) as IEffect; - const targetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === effect.target); - const targetTransform = targetEffect?.transform ? targetEffect.transform : baseTransform; - const newTransform = { - ...targetTransform, - ...(effect.transform ?? {}), - position: { - ...targetTransform.position, - ...(effect.transform?.position ?? {}), - }, - scale: { - ...targetTransform.scale, - ...(effect.transform?.scale ?? {}), - }, - }; - WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(effect.target); - stageStateManager.updateEffectAndCommit({ target: effect.target, transform: newTransform }); - } catch (e) { - logger.error(`无法设置效果 ${message.message}, ${e}`); - return; - } - } - if (message.command === DebugCommand.SET_TEXT_READ_MODE) { - try { - const command = JSON.parse(message.message) as ITextReadModeCommand; - setDebugTextReadMode(command.isRead); - } catch (e) { - logger.error(`无法设置文本已读显示模式 ${message.message}, ${e}`); - } - } - }; - socket.onerror = () => { - editorSocket = null; - logger.info('当前没有连接到 Terre 编辑器'); - }; - socket.onclose = () => { - editorSocket = null; - }; -}; diff --git a/packages/webgal/src/Stage/TextBox/textbox.module.scss b/packages/webgal/src/Stage/TextBox/textbox.module.scss index 6eefd1fc2..6521bd13b 100644 --- a/packages/webgal/src/Stage/TextBox/textbox.module.scss +++ b/packages/webgal/src/Stage/TextBox/textbox.module.scss @@ -244,7 +244,6 @@ $height: 330px; background-clip: border-box; -webkit-background-clip: border-box; color: #C0C0C0; - -webkit-text-fill-color: #C0C0C0; } .readTextInner { diff --git a/packages/webgal/src/UI/Extra/ExtraCg.tsx b/packages/webgal/src/UI/Extra/ExtraCg.tsx index 8f610ebc7..698f920fa 100644 --- a/packages/webgal/src/UI/Extra/ExtraCg.tsx +++ b/packages/webgal/src/UI/Extra/ExtraCg.tsx @@ -1,23 +1,30 @@ import styles from '@/UI/Extra/extra.module.scss'; -import React from 'react'; +import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '@/store/store'; import { useValue } from '@/hooks/useValue'; import './extraCG_animation_List.scss'; -import { ExtraCgElement } from '@/UI/Extra/ExtraCgElement'; +import { ExtraCgElement, isVideoFile } from '@/UI/Extra/ExtraCgElement'; import useSoundEffect from '@/hooks/useSoundEffect'; +import { IAppreciationAsset } from '@/store/userDataInterface'; + +interface IExtraCgDisplayItem { + key: string; + name: string; + resources: IAppreciationAsset[]; +} export function ExtraCg() { const cgPerPage = 8; const extraState = useSelector((state: RootState) => state.userData.appreciationData); - const pageNumber = Math.ceil(extraState.cg.length / cgPerPage); - // const pageNumber = 10; + const groupedCgList = useMemo(() => buildGroupedCgList(extraState.cg), [extraState.cg]); + const pageNumber = Math.ceil(groupedCgList.length / cgPerPage); const currentPage = useValue(1); const { playSeEnter, playSeClick } = useSoundEffect(); // 开始生成立绘鉴赏的图片 const showCgList = []; - const len = extraState.cg.length; + const len = groupedCgList.length; for ( let i = (currentPage.value - 1) * cgPerPage; i < Math.min(len, (currentPage.value - 1) * cgPerPage + cgPerPage); @@ -27,11 +34,11 @@ export function ExtraCg() { const deg = Random(-5, 5); const temp = ( ); showCgList.push(temp); @@ -73,3 +80,42 @@ export function ExtraCg() { function Random(min: number, max: number) { return Math.round(Math.random() * (max - min)) + min; } + +function buildGroupedCgList(cgList: IAppreciationAsset[]): IExtraCgDisplayItem[] { + const groupedCgList: IExtraCgDisplayItem[] = []; + const seriesIndexMap = new Map(); + + cgList.forEach((cg, index) => { + // 视频不进行分组 + if (cg.series !== 'default' && !isVideoFile(cg.url)) { + const groupedIndex = seriesIndexMap.get(cg.series); + if (groupedIndex !== undefined) { + groupedCgList[groupedIndex].resources.push(cg); + return; + } + + seriesIndexMap.set(cg.series, groupedCgList.length); + groupedCgList.push({ + key: `series:${cg.series}`, + name: cg.name, + resources: [cg], + }); + return; + } + + groupedCgList.push({ + key: `default:${index}:${cg.url}`, + name: cg.name, + resources: [cg], + }); + }); + + return groupedCgList.map((groupedCg) => ({ + ...groupedCg, + resources: groupedCg.resources.length > 1 ? [...groupedCg.resources].sort(sortCgByOrder) : groupedCg.resources, + })); +} + +function sortCgByOrder(prev: IAppreciationAsset, next: IAppreciationAsset) { + return (prev.order ?? 0) - (next.order ?? 0); +} diff --git a/packages/webgal/src/UI/Extra/ExtraCgElement.tsx b/packages/webgal/src/UI/Extra/ExtraCgElement.tsx index 63a36287c..84d2c2dd1 100644 --- a/packages/webgal/src/UI/Extra/ExtraCgElement.tsx +++ b/packages/webgal/src/UI/Extra/ExtraCgElement.tsx @@ -1,37 +1,58 @@ import { useValue } from '@/hooks/useValue'; import styles from '@/UI/Extra/extra.module.scss'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import useSoundEffect from '@/hooks/useSoundEffect'; +import { IAppreciationAsset } from '@/store/userDataInterface'; + +export const isVideoFile = (url: string) => { + const extension = url.split('.').pop()?.toLowerCase() || ''; + return ['mp4', 'webm', 'mkv'].includes(extension); +} interface IProps { name: string; - resourceUrl: string; + resources: IAppreciationAsset[]; transformDeg: number; index: number; } export function ExtraCgElement(props: IProps) { const showFull = useValue(false); + const [currentResourceIndex, setCurrentResourceIndex] = useState(0); const { playSeEnter, playSeClick } = useSoundEffect(); + const previewResource = props.resources[0]; + const currentResource = props.resources[currentResourceIndex] ?? previewResource; // Determine if the resource is a video based on file extension const isVideo = useMemo(() => { - const extension = props.resourceUrl.split('.').pop()?.toLowerCase() || ''; - return ['mp4', 'webm', 'mkv'].includes(extension); - }, [props.resourceUrl]); + return isVideoFile(previewResource.url); + }, [previewResource.url]); // Determine if the resource is an image based on file extension const isImage = useMemo(() => { - const extension = props.resourceUrl.split('.').pop()?.toLowerCase() || ''; + const extension = previewResource.url.split('.').pop()?.toLowerCase() || ''; return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(extension); - }, [props.resourceUrl]); + }, [previewResource.url]); + const isStackPreview = props.resources.length > 1 && isImage; + const animationDelay = 100 + props.index * 100; + const stackResources = isStackPreview ? [...props.resources].reverse() : []; + + const getStackItemStyle = (index: number, length: number) => { + const offset = index - (length - 1) / 2; + return { + zIndex: index, + animationDelay: `${animationDelay + index * 140}ms`, + '--cg-stack-start-transform': `translate(${offset * 1.5}%, ${offset * -0.8}%) rotate(${offset * 2}deg)`, + '--cg-stack-end-transform': `translate(${offset * 6}%, ${offset * -3}%) rotate(${offset * 6}deg)`, + } as React.CSSProperties; + }; // Render media content based on resource type - const renderMedia = (fullScreen: boolean) => { + const renderMedia = (resourceUrl: string) => { if (isVideo) { return (