diff --git a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss index b2bd03c01..b75eb2480 100644 --- a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss +++ b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss @@ -176,3 +176,7 @@ .text { overflow: hidden; } + +.read { + color: rgb(237, 162, 162); +} diff --git a/packages/webgal/src/Core/Modules/readHistory.ts b/packages/webgal/src/Core/Modules/readHistory.ts new file mode 100644 index 000000000..aa5336abb --- /dev/null +++ b/packages/webgal/src/Core/Modules/readHistory.ts @@ -0,0 +1,118 @@ +/** + * 已读历史记录 + */ + +import { webgalStore } from "@/store/store"; +import { SceneManager } from "./scene"; +import { setReadHistory } from "@/store/userDataReducer"; +import { setStage } from "@/store/stageReducer"; +import { setStorage } from "../controller/storage/storageController"; + +export class ReadHistoryManager { + private history: Map = new Map(); + + private load: boolean = false; + + private readonly sceneManager: SceneManager; + + public constructor(sceneManager: SceneManager) { + this.sceneManager = sceneManager; + } + + private loadReadHistory() { + const readHistory = webgalStore.getState().userData.readHistory; + + Object.entries(readHistory).forEach(([key, value]) => { + try { + const uint8 = Uint8Array.from(Buffer.from(value, 'base64')); + this.history.set(key, uint8); + } catch { + // 浏览器环境下没有 Buffer 时的兜底逻辑 + const binary = atob(value); + const uint8 = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + uint8[i] = binary.charCodeAt(i); + } + this.history.set(key, uint8); + } + }); + + this.load = true; + } + + private checkLoad() { + if (!this.load) { + this.loadReadHistory(); + } + } + + private saveReadHistory(key: string) { + const bitset = this.history.get(key)!; + + try { + const base64 = Buffer.from(bitset).toString('base64'); + webgalStore.dispatch(setReadHistory({ + key, + value: base64, + })); + } catch { + // 浏览器环境下没有 Buffer 时的兜底逻辑 + let binary = ''; + const len = bitset.length; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bitset[i]); + } + const base64 = btoa(binary); + webgalStore.dispatch(setReadHistory({ + key, + value: base64, + })); + } + setStorage(); + } + + private addReadHistory() { + const scenarioName = this.sceneManager.sceneData.currentScene.sceneName; + const index = this.sceneManager.sceneData.currentSentenceId; + + if (!this.history.has(scenarioName)) { + const length = this.sceneManager.sceneData.currentScene.sentenceList.length; + this.history.set(scenarioName, new Uint8Array(Math.ceil(length / 8))); + } + let bitset = this.history.get(scenarioName)!; + + // 处理因剧本更新可能导致的 index 溢出问题 + const requiredIndex = index >> 3; + if (requiredIndex >= bitset.length) { + const length = this.sceneManager.sceneData.currentScene.sentenceList.length; + const newBitset = new Uint8Array(Math.ceil(length / 8)); + newBitset.set(bitset); + bitset = newBitset; + this.history.set(scenarioName, bitset); + } + + bitset[requiredIndex] |= (1 << (index & 7)); + + this.saveReadHistory(scenarioName); + } + + public checkIsRead() { + this.checkLoad(); + + const scenarioName = this.sceneManager.sceneData.currentScene.sceneName; + const index = this.sceneManager.sceneData.currentSentenceId; + + let isRead = false; + if (this.history.has(scenarioName)) { + const bitset = this.history.get(scenarioName)!; + isRead = (bitset[index >> 3] & (1 << (index & 7))) !== 0; + } + webgalStore.dispatch(setStage({ + key: 'isRead', + value: isRead, + })); + if (!isRead) { + this.addReadHistory(); + } + } +} diff --git a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts index 8798f3243..9e3fd2d1e 100644 --- a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts +++ b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts @@ -4,6 +4,7 @@ import styles from '@/UI/BottomControlPanel/bottomControlPanel.module.scss'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; import { WebGAL } from '@/Core/WebGAL'; +import { webgalStore } from "@/store/store"; import { SYSTEM_CONFIG } from '@/config'; /** @@ -36,12 +37,16 @@ export const stopFast = () => { /** * 开启快进 */ -export const startFast = () => { +export const startFast = (force = false) => { if (isFast()) { return; } WebGAL.gameplay.isFast = true; + const skipAll = force || webgalStore.getState().userData.optionData.skipAll; WebGAL.gameplay.fastInterval = setInterval(() => { + if (!skipAll && !webgalStore.getState().stage.isRead) { + stopFast(); + } nextSentence(); }, SYSTEM_CONFIG.fast_timeout); }; diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 04f5c997b..83b814684 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -100,6 +100,7 @@ export const scriptExecutor = () => { nextSentence(); return; } + WebGAL.readHistoryManager.checkIsRead(); runScript(currentScript); // 是否要进行下一句 let isNext = getBooleanArgByKey(currentScript, 'next') ?? false; diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index b72573834..ffe1f7823 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -2,6 +2,7 @@ import { initState, resetStageState, setStage } from '@/store/stageReducer'; import { webgalStore } from '@/store/store'; import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; +import { saveActions } from '@/store/savesReducer'; export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { /** diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 2ebf4ce6b..562966e58 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -67,6 +67,9 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => { // 恢复舞台状态 const newStageState: IStageState = cloneDeep(backlogFile.currentStageState); + // 确保原先未读的文本在使用 backlog 时能正确显示为已读文本 + newStageState.isRead = true; + dispatch(resetStageState(newStageState)); // 恢复演出 diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index ae471ab7e..f0e8aa04b 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -61,6 +61,8 @@ export function loadGameFromStageData(stageData: ISaveData) { // 恢复舞台状态 const newStageState = cloneDeep(loadFile.nowStageState); + // 确保原先未读的文本在 load 时能正确显示为已读文本 + newStageState.isRead = true; const dispatch = webgalStore.dispatch; dispatch(resetStageState(newStageState)); diff --git a/packages/webgal/src/Core/webgalCore.ts b/packages/webgal/src/Core/webgalCore.ts index 98d1c53ec..71e85d770 100644 --- a/packages/webgal/src/Core/webgalCore.ts +++ b/packages/webgal/src/Core/webgalCore.ts @@ -1,4 +1,5 @@ import { BacklogManager } from '@/Core/Modules/backlog'; +import { ReadHistoryManager } from './Modules/readHistory'; import mitt from 'mitt'; import { SceneManager } from '@/Core/Modules/scene'; import { AnimationManager } from '@/Core/Modules/animations'; @@ -11,6 +12,7 @@ import { IWebGALStyleObj } from 'webgal-parser/build/types/styleParser'; export class WebgalCore { public sceneManager = new SceneManager(); public backlogManager = new BacklogManager(this.sceneManager); + public readHistoryManager = new ReadHistoryManager(this.sceneManager); public animationManager = new AnimationManager(); public gameplay = new Gameplay(); public gameName = ''; diff --git a/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx b/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx index f603f2792..e3eec5350 100644 --- a/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx +++ b/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx @@ -14,6 +14,7 @@ export default function IMSSTextbox(props: ITextboxProps) { textDelay, currentConcatDialogPrev, currentDialogKey, + isRead, isText, isSafari, isFirefox: boolean, @@ -167,7 +168,7 @@ export default function IMSSTextbox(props: ITextboxProps) { > {e} - {e} + {e} {isUseStroke && {e}} diff --git a/packages/webgal/src/Stage/TextBox/TextBox.tsx b/packages/webgal/src/Stage/TextBox/TextBox.tsx index dea485b2d..f6c1d028c 100644 --- a/packages/webgal/src/Stage/TextBox/TextBox.tsx +++ b/packages/webgal/src/Stage/TextBox/TextBox.tsx @@ -27,6 +27,7 @@ export const TextBox = () => { const textDuration = useTextAnimationDuration(userDataState.optionData.textSpeed); let size = getTextSize(userDataState.optionData.textSize) + '%'; const font = useFontFamily(); + const isRead = stageState.isRead; const isText = stageState.showText !== '' || stageState.showName !== ''; let textSizeState = userDataState.optionData.textSize; if (isText && stageState.showTextSize !== -1) { @@ -88,6 +89,7 @@ export const TextBox = () => { return ( + + { + dispatch(setOptionData({ key: 'skipAll', value: false })); + setStorage(); + }, () => { + dispatch(setOptionData({ key: 'skipAll', value: true })); + setStorage(); + }]} + currentChecked={userDataState.optionData.skipAll ? 1 : 0} + /> + { lineLimit: 3, isUseStroke: true, textboxOpacity: textboxOpacity, + isRead: false, }; return ( diff --git a/packages/webgal/src/hooks/useHotkey.tsx b/packages/webgal/src/hooks/useHotkey.tsx index b87199278..00db7b485 100644 --- a/packages/webgal/src/hooks/useHotkey.tsx +++ b/packages/webgal/src/hooks/useHotkey.tsx @@ -201,7 +201,8 @@ export function useSkip() { const isCtrlKey = useCallback((e) => e.keyCode === 17, []); const handleCtrlKeydown = useCallback((e) => { if (isCtrlKey(e) && isGameActive()) { - startFast(); + // 按下 ctrl 键快进时,强制全文快进 + startFast(true); } }, []); const handleCtrlKeyup = useCallback((e) => { diff --git a/packages/webgal/src/store/stageInterface.ts b/packages/webgal/src/store/stageInterface.ts index b4621af87..d5ae57247 100644 --- a/packages/webgal/src/store/stageInterface.ts +++ b/packages/webgal/src/store/stageInterface.ts @@ -215,6 +215,7 @@ export interface IStageState { // 自由立绘 freeFigure: Array; figureAssociatedAnimation: Array; + isRead: boolean; // 是否已读 showText: string; // 文字 showTextSize: number; // 文字 showName: string; // 人物名 diff --git a/packages/webgal/src/store/stageReducer.ts b/packages/webgal/src/store/stageReducer.ts index 4800053f6..f34017cad 100644 --- a/packages/webgal/src/store/stageReducer.ts +++ b/packages/webgal/src/store/stageReducer.ts @@ -36,6 +36,7 @@ export const initState: IStageState = { figNameRight: '', // 立绘_右 文件地址(相对或绝对) freeFigure: [], figureAssociatedAnimation: [], + isRead: false, showText: '', // 文字 showTextSize: -1, showName: '', // 人物名 diff --git a/packages/webgal/src/store/userDataInterface.ts b/packages/webgal/src/store/userDataInterface.ts index 111b0e1c9..e0245c2a1 100644 --- a/packages/webgal/src/store/userDataInterface.ts +++ b/packages/webgal/src/store/userDataInterface.ts @@ -46,6 +46,7 @@ export interface IOptionData { language: language; voiceInterruption: voiceOption; // 是否中断语音 fullScreen: fullScreenOption; + skipAll: boolean; // 快进已读/快进全文 } /** @@ -91,6 +92,7 @@ export interface IUserData { optionData: IOptionData; // 用户设置选项数据 appreciationData: IAppreciation; gameConfigInit: IGameVar; + readHistory: Record; } export interface ISetUserDataPayload { diff --git a/packages/webgal/src/store/userDataReducer.ts b/packages/webgal/src/store/userDataReducer.ts index d7cd5fa9a..856b74ef7 100644 --- a/packages/webgal/src/store/userDataReducer.ts +++ b/packages/webgal/src/store/userDataReducer.ts @@ -35,6 +35,7 @@ const initialOptionSet: IOptionData = { language: language.zhCn, voiceInterruption: voiceOption.no, fullScreen: fullScreenOption.off, + skipAll: false, }; // 初始化用户数据 @@ -47,6 +48,7 @@ export const initState: IUserData = { cg: [], }, gameConfigInit: {}, + readHistory: {}, }; const userDataSlice = createSlice({ @@ -142,6 +144,9 @@ const userDataSlice = createSlice({ const { gameConfigInit } = state; Object.assign(state, { ...cloneDeep(initState), globalGameVar: cloneDeep(gameConfigInit), gameConfigInit }); }, + setReadHistory: (state, action: PayloadAction>) => { + state.readHistory[action.payload.key] = action.payload.value; + }, }, }); @@ -156,6 +161,7 @@ export const { unlockBgmInUserData, resetOptionSet, resetAllData, + setReadHistory, } = userDataSlice.actions; export default userDataSlice.reducer; diff --git a/packages/webgal/src/translations/de.ts b/packages/webgal/src/translations/de.ts index 905ce7828..a40875a38 100644 --- a/packages/webgal/src/translations/de.ts +++ b/packages/webgal/src/translations/de.ts @@ -58,6 +58,13 @@ const de = { contributors: 'Contributors', website: 'Website', }, + skipAll: { + title: 'Schnellvorlauf-Modus', + options: { + read: 'Gelesen', + all: 'Alle', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/en.ts b/packages/webgal/src/translations/en.ts index 7b982ddcf..801e41296 100644 --- a/packages/webgal/src/translations/en.ts +++ b/packages/webgal/src/translations/en.ts @@ -58,6 +58,13 @@ const en = { contributors: 'Contributors', website: 'Website', }, + skipAll: { + title: 'Skip Mode', + options: { + read: 'Read', + all: 'All', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/fr.ts b/packages/webgal/src/translations/fr.ts index 4c6dd0af1..faf3296dd 100644 --- a/packages/webgal/src/translations/fr.ts +++ b/packages/webgal/src/translations/fr.ts @@ -58,6 +58,13 @@ const fr = { contributors: 'Contributeurs', website: 'Site web', }, + skipAll: { + title: 'Mode Avance Rapide', + options: { + read: 'Lu', + all: 'Tout', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/jp.ts b/packages/webgal/src/translations/jp.ts index bfefcea93..d54154471 100644 --- a/packages/webgal/src/translations/jp.ts +++ b/packages/webgal/src/translations/jp.ts @@ -58,6 +58,13 @@ const jp = { contributors: '貢献者', website: 'ウェブサイト', }, + skipAll: { + title: 'スキップモード', + options: { + read: '既読', + all: 'すべて', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/zh-cn.ts b/packages/webgal/src/translations/zh-cn.ts index ec9cb0d71..8a178b558 100644 --- a/packages/webgal/src/translations/zh-cn.ts +++ b/packages/webgal/src/translations/zh-cn.ts @@ -58,6 +58,13 @@ const zhCn = { contributors: '贡献者', website: '网站', }, + skipAll: { + title: '快进模式', + options: { + read: '已读', + all: '全部', + } + } }, }, display: { diff --git a/packages/webgal/src/translations/zh-tw.ts b/packages/webgal/src/translations/zh-tw.ts index 184cf477b..0da6c9f43 100644 --- a/packages/webgal/src/translations/zh-tw.ts +++ b/packages/webgal/src/translations/zh-tw.ts @@ -58,6 +58,13 @@ const zhTw = { contributors: '貢獻者', website: '網站', }, + skipAll: { + title: '快進模式', + options: { + read: '已讀', + all: '全部', + } + } }, }, display: {