From 92bd90b75e54321786161718e6ec50b9b3676bfd Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 26 Oct 2025 22:35:46 +0800 Subject: [PATCH 1/2] feat: template file load and font inject --- packages/webgal/src/Core/initializeScript.ts | 2 + .../coreInitialFunction/templateLoader.ts | 61 +++++++++++++++++++ packages/webgal/src/Core/webgalCore.ts | 2 + packages/webgal/src/types/template.ts | 25 ++++++++ 4 files changed, 90 insertions(+) create mode 100644 packages/webgal/src/Core/util/coreInitialFunction/templateLoader.ts create mode 100644 packages/webgal/src/types/template.ts diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index ebab753e2..4e3e77f2c 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -14,6 +14,7 @@ import PixiStage from '@/Core/controller/stage/pixi/PixiController'; import axios from 'axios'; import { __INFO } from '@/config/info'; import { WebGAL } from '@/Core/WebGAL'; +import { loadTemplate } from '@/Core/util/coreInitialFunction/templateLoader'; const u = navigator.userAgent; export const isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); // 判断是否是 iOS终端 @@ -26,6 +27,7 @@ export const initializeScript = (): void => { logger.info(`WebGAL v${__INFO.version}`); logger.info('Github: https://github.com/OpenWebGAL/WebGAL '); logger.info('Made with ❤ by OpenWebGAL'); + loadTemplate(); // 激活强制缩放 // 在调整窗口大小时重新计算宽高,设计稿按照 1600*900。 if (isIOS) { diff --git a/packages/webgal/src/Core/util/coreInitialFunction/templateLoader.ts b/packages/webgal/src/Core/util/coreInitialFunction/templateLoader.ts new file mode 100644 index 000000000..230808e35 --- /dev/null +++ b/packages/webgal/src/Core/util/coreInitialFunction/templateLoader.ts @@ -0,0 +1,61 @@ +import axios from 'axios'; +import { logger } from '@/Core/util/logger'; +import { WebGAL } from '@/Core/WebGAL'; +import { TemplateFontDescriptor, WebgalTemplate } from '@/types/template'; + +const TEMPLATE_PATH = './game/template/template.json'; +const TEMPLATE_FONT_STYLE_SELECTOR = 'style[data-webgal-template-fonts]'; + +export async function loadTemplate(): Promise { + try { + const { data } = await axios.get(TEMPLATE_PATH); + WebGAL.template = data; + injectTemplateFonts(data.fonts ?? []); + return data; + } catch (error) { + logger.warn('加载模板文件失败', error); + return null; + } +} + +function injectTemplateFonts(fonts: TemplateFontDescriptor[]): void { + if (!fonts.length) return; + const rules = fonts.map((font) => generateFontFaceRule(font)).filter((rule): rule is string => Boolean(rule)); + + if (!rules.length) return; + + const styleElement = document.createElement('style'); + styleElement.setAttribute('data-webgal-template-fonts', 'true'); + styleElement.appendChild(document.createTextNode(rules.join('\n'))); + + const head = document.head; + if (!head) return; + + const existing = head.querySelector(TEMPLATE_FONT_STYLE_SELECTOR); + existing?.remove(); + + head.appendChild(styleElement); +} + +function generateFontFaceRule(font: TemplateFontDescriptor): string | null { + const fontFamily = font['font-family']; + if (!fontFamily || !font.url || !font.type) { + logger.warn('忽略无效的模板字体配置', font); + return null; + } + + const src = resolveTemplateAssetPath(font.url); + const weight = font.weight !== undefined ? `font-weight: ${font.weight};` : ''; + const style = font.style ? `font-style: ${font.style};` : ''; + const display = `font-display: ${font.display ?? 'swap'};`; + + return `@font-face { font-family: '${fontFamily}'; src: url('${src}') format('${font.type}'); ${weight} ${style} ${display} }`; +} + +function resolveTemplateAssetPath(path: string): string { + if (/^(https?:)?\/\//i.test(path) || path.startsWith('data:')) { + return path; + } + const normalized = path.replace(/^[./]+/, ''); + return `./game/template/${normalized}`; +} diff --git a/packages/webgal/src/Core/webgalCore.ts b/packages/webgal/src/Core/webgalCore.ts index f1becd487..1f124a4bd 100644 --- a/packages/webgal/src/Core/webgalCore.ts +++ b/packages/webgal/src/Core/webgalCore.ts @@ -4,6 +4,7 @@ import { SceneManager } from '@/Core/Modules/scene'; import { AnimationManager } from '@/Core/Modules/animations'; import { Gameplay } from './Modules/gamePlay'; import { Events } from '@/Core/Modules/events'; +import { WebgalTemplate } from '@/types/template'; export class WebgalCore { public sceneManager = new SceneManager(); @@ -13,4 +14,5 @@ export class WebgalCore { public gameName = ''; public gameKey = ''; public events = new Events(); + public template: WebgalTemplate | null = null; } diff --git a/packages/webgal/src/types/template.ts b/packages/webgal/src/types/template.ts new file mode 100644 index 000000000..42654efb0 --- /dev/null +++ b/packages/webgal/src/types/template.ts @@ -0,0 +1,25 @@ +export type TemplateFontFormat = + | 'truetype' + | 'opentype' + | 'woff' + | 'woff2' + | 'embedded-opentype' + | 'svg' + | 'collection'; + +export interface TemplateFontDescriptor { + 'font-family': string; + url: string; + type: TemplateFontFormat; + weight?: string | number; + style?: string; + display?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional'; +} + +export interface WebgalTemplate { + name: string; + id?: string; + 'webgal-version': string; + fonts?: TemplateFontDescriptor[]; + [key: string]: unknown; +} From af0f2e6ea5d22fa6d9f1ae7aed8271147387941f Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 26 Oct 2025 23:16:02 +0800 Subject: [PATCH 2/2] feat: add custom font choose and apply --- .../src/Core/gameScripts/choose/index.tsx | 5 +- .../Core/gameScripts/getUserInput/index.tsx | 5 +- .../coreInitialFunction/templateLoader.ts | 19 +++++++- .../webgal/src/Core/util/fonts/fontOptions.ts | 46 +++++++++++++++++++ .../src/UI/Menu/Options/Display/Display.tsx | 33 +++++++------ packages/webgal/src/hooks/useFontFamily.ts | 31 +++++++------ packages/webgal/src/store/GUIReducer.ts | 20 +++++--- packages/webgal/src/store/guiInterface.ts | 14 ++++-- .../webgal/src/store/userDataInterface.ts | 8 +--- packages/webgal/src/store/userDataReducer.ts | 3 +- 10 files changed, 129 insertions(+), 55 deletions(-) create mode 100644 packages/webgal/src/Core/util/fonts/fontOptions.ts diff --git a/packages/webgal/src/Core/gameScripts/choose/index.tsx b/packages/webgal/src/Core/gameScripts/choose/index.tsx index ac22cba97..d730da788 100644 --- a/packages/webgal/src/Core/gameScripts/choose/index.tsx +++ b/packages/webgal/src/Core/gameScripts/choose/index.tsx @@ -6,7 +6,6 @@ import ReactDOM from 'react-dom'; import React from 'react'; import styles from './choose.module.scss'; import { webgalStore } from '@/store/store'; -import { textFont } from '@/store/userDataInterface'; import { PerformController } from '@/Core/Modules/perform/performController'; import { useSEByWebgalStore } from '@/hooks/useSoundEffect'; import { WebGAL } from '@/Core/WebGAL'; @@ -14,6 +13,7 @@ import { whenChecker } from '@/Core/controller/gamePlay/scriptExecutor'; import useEscape from '@/hooks/useEscape'; import useApplyStyle from '@/hooks/useApplyStyle'; import { Provider } from 'react-redux'; +import { useFontFamily } from '@/hooks/useFontFamily'; class ChooseOption { /** @@ -81,8 +81,7 @@ export const choose = (sentence: ISentence): IPerform => { }; function Choose(props: { chooseOptions: ChooseOption[] }) { - const fontFamily = webgalStore.getState().userData.optionData.textboxFont; - const font = fontFamily === textFont.song ? '"思源宋体", serif' : '"WebgalUI", serif'; + const font = useFontFamily(); const { playSeEnter, playSeClick } = useSEByWebgalStore(); const applyStyle = useApplyStyle('Stage/Choose/choose.scss'); // 运行时计算JSX.Element[] diff --git a/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx b/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx index 5c5fbc53e..9793d3300 100644 --- a/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx +++ b/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx @@ -6,13 +6,13 @@ import ReactDOM from 'react-dom'; import React from 'react'; import styles from './getUserInput.module.scss'; import { webgalStore } from '@/store/store'; -import { textFont } from '@/store/userDataInterface'; import { PerformController } from '@/Core/Modules/perform/performController'; import { useSEByWebgalStore } from '@/hooks/useSoundEffect'; import { WebGAL } from '@/Core/WebGAL'; import { getStringArgByKey } from '@/Core/util/getSentenceArg'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; import { setStageVar } from '@/store/stageReducer'; +import { getCurrentFontFamily } from '@/hooks/useFontFamily'; /** * 显示选择枝 @@ -27,8 +27,7 @@ export const getUserInput = (sentence: ISentence): IPerform => { buttonText = buttonText === '' ? 'OK' : buttonText; const defaultValue = getStringArgByKey(sentence, 'defaultValue'); - const fontFamily = webgalStore.getState().userData.optionData.textboxFont; - const font = fontFamily === textFont.song ? '"思源宋体", serif' : '"WebgalUI", serif'; + const font = getCurrentFontFamily(); const { playSeEnter, playSeClick } = useSEByWebgalStore(); const chooseElements = ( diff --git a/packages/webgal/src/Core/util/coreInitialFunction/templateLoader.ts b/packages/webgal/src/Core/util/coreInitialFunction/templateLoader.ts index 230808e35..074840fbd 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/templateLoader.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/templateLoader.ts @@ -2,6 +2,10 @@ import axios from 'axios'; import { logger } from '@/Core/util/logger'; import { WebGAL } from '@/Core/WebGAL'; import { TemplateFontDescriptor, WebgalTemplate } from '@/types/template'; +import { buildFontOptionsFromTemplate } from '@/Core/util/fonts/fontOptions'; +import { webgalStore } from '@/store/store'; +import { setFontOptions } from '@/store/GUIReducer'; +import { setOptionData } from '@/store/userDataReducer'; const TEMPLATE_PATH = './game/template/template.json'; const TEMPLATE_FONT_STYLE_SELECTOR = 'style[data-webgal-template-fonts]'; @@ -10,14 +14,27 @@ export async function loadTemplate(): Promise { try { const { data } = await axios.get(TEMPLATE_PATH); WebGAL.template = data; - injectTemplateFonts(data.fonts ?? []); + const fonts = data.fonts ?? []; + injectTemplateFonts(fonts); + updateFontOptions(fonts); return data; } catch (error) { logger.warn('加载模板文件失败', error); + updateFontOptions([]); return null; } } +function updateFontOptions(fonts: TemplateFontDescriptor[]): void { + const options = buildFontOptionsFromTemplate(fonts); + webgalStore.dispatch(setFontOptions(options)); + const currentIndex = webgalStore.getState().userData.optionData.textboxFont ?? 0; + if (options.length === 0) return; + if (currentIndex >= options.length) { + webgalStore.dispatch(setOptionData({ key: 'textboxFont', value: 0 })); + } +} + function injectTemplateFonts(fonts: TemplateFontDescriptor[]): void { if (!fonts.length) return; const rules = fonts.map((font) => generateFontFaceRule(font)).filter((rule): rule is string => Boolean(rule)); diff --git a/packages/webgal/src/Core/util/fonts/fontOptions.ts b/packages/webgal/src/Core/util/fonts/fontOptions.ts new file mode 100644 index 000000000..85ed111f0 --- /dev/null +++ b/packages/webgal/src/Core/util/fonts/fontOptions.ts @@ -0,0 +1,46 @@ +import { FontOption } from '@/store/guiInterface'; +import { TemplateFontDescriptor } from '@/types/template'; + +export const DEFAULT_FONT_OPTIONS: FontOption[] = [ + { + family: `'思源宋体', serif`, + source: 'default', + labelKey: 'textFont.options.siYuanSimSun', + }, + { + family: `'WebgalUI', serif`, + source: 'default', + labelKey: 'textFont.options.SimHei', + }, + { + family: `'LXGW', serif`, + source: 'default', + labelKey: 'textFont.options.lxgw', + }, +]; + +export const FALLBACK_FONT_FAMILY = DEFAULT_FONT_OPTIONS[1].family; + +export function buildFontOptionsFromTemplate(fonts: TemplateFontDescriptor[]): FontOption[] { + const templateOptions: FontOption[] = fonts.map((font) => ({ + family: formatFontFamily(font['font-family']), + source: 'template', + label: font['font-family'], + })); + + const combined = [...templateOptions, ...DEFAULT_FONT_OPTIONS]; + + const seen = new Set(); + return combined.filter((option) => { + if (seen.has(option.family)) return false; + seen.add(option.family); + return true; + }); +} + +export function formatFontFamily(fontFamily: string): string { + const trimmed = fontFamily.trim(); + const needsQuote = /\s/.test(trimmed); + const normalized = needsQuote ? `'${trimmed}'` : trimmed; + return `${normalized}, serif`; +} diff --git a/packages/webgal/src/UI/Menu/Options/Display/Display.tsx b/packages/webgal/src/UI/Menu/Options/Display/Display.tsx index 49a5a728d..c11314c29 100644 --- a/packages/webgal/src/UI/Menu/Options/Display/Display.tsx +++ b/packages/webgal/src/UI/Menu/Options/Display/Display.tsx @@ -6,7 +6,7 @@ import styles from '@/UI/Menu/Options/options.module.scss'; import useFullScreen from '@/hooks/useFullScreen'; import useTrans from '@/hooks/useTrans'; import { RootState } from '@/store/store'; -import { textFont, textSize } from '@/store/userDataInterface'; +import { textSize } from '@/store/userDataInterface'; import { setOptionData } from '@/store/userDataReducer'; import { useDispatch, useSelector } from 'react-redux'; import { OptionSlider } from '../OptionSlider'; @@ -16,6 +16,15 @@ export function Display() { const dispatch = useDispatch(); const t = useTrans('menu.options.pages.display.options.'); const { isSupported: isFullscreenSupported, enter: enterFullscreen, exit: exitFullscreen } = useFullScreen(); + const fontOptions = useSelector((state: RootState) => state.GUI.fontOptions); + const fontOptionTexts = fontOptions.map((option) => { + if (option.labelKey) return t(option.labelKey); + if (option.label) return option.label; + return option.family; + }); + const currentFontIndex = fontOptions.length + ? Math.min(userDataState.optionData.textboxFont, fontOptions.length - 1) + : 0; return (
@@ -50,22 +59,12 @@ export function Display() { { - dispatch(setOptionData({ key: 'textboxFont', value: textFont.song })); - setStorage(); - }, - () => { - dispatch(setOptionData({ key: 'textboxFont', value: textFont.hei })); - setStorage(); - }, - () => { - dispatch(setOptionData({ key: 'textboxFont', value: textFont.lxgw })); - setStorage(); - }, - ]} - currentChecked={userDataState.optionData.textboxFont} + textList={fontOptionTexts} + functionList={fontOptions.map((_, index) => () => { + dispatch(setOptionData({ key: 'textboxFont', value: index })); + setStorage(); + })} + currentChecked={currentFontIndex} /> diff --git a/packages/webgal/src/hooks/useFontFamily.ts b/packages/webgal/src/hooks/useFontFamily.ts index 54c315ae9..b36aefe29 100644 --- a/packages/webgal/src/hooks/useFontFamily.ts +++ b/packages/webgal/src/hooks/useFontFamily.ts @@ -1,18 +1,23 @@ import { useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; -import { textFont } from '@/store/userDataInterface'; -import { match } from '@/Core/util/match'; +import { RootState, webgalStore } from '@/store/store'; +import { FALLBACK_FONT_FAMILY } from '@/Core/util/fonts/fontOptions'; -export function useFontFamily() { - const fontFamily = useSelector((state: RootState) => state.userData.optionData.textboxFont); +export function useFontFamily(): string { + return useSelector(selectFontFamily); +} - function getFont() { - return match(fontFamily) - .with(textFont.song, () => '"思源宋体", serif') - .with(textFont.lxgw, () => '"LXGW", serif') - .with(textFont.hei, () => '"WebgalUI", serif') - .default(() => '"WebgalUI", serif'); - } +export function getCurrentFontFamily(): string { + return selectFontFamily(webgalStore.getState()); +} - return getFont(); +export function selectFontFamily(state: RootState): string { + const index = state.userData.optionData.textboxFont ?? 0; + const fonts = state.GUI.fontOptions; + if (fonts[index]) { + return fonts[index].family; + } + if (fonts.length > 0) { + return fonts[0].family; + } + return FALLBACK_FONT_FAMILY; } diff --git a/packages/webgal/src/store/GUIReducer.ts b/packages/webgal/src/store/GUIReducer.ts index bfc5a29be..7d87e2439 100644 --- a/packages/webgal/src/store/GUIReducer.ts +++ b/packages/webgal/src/store/GUIReducer.ts @@ -1,16 +1,20 @@ -/** - * @file 记录当前GUI的状态信息,引擎初始化时会重置。 - * @author Mahiru - */ import { getStorage } from '@/Core/controller/storage/storageController'; -import { GuiAsset, IGuiState, MenuPanelTag, setAssetPayload, setVisibilityPayload } from '@/store/guiInterface'; +import { + FontOption, + GuiAsset, + IGuiState, + MenuPanelTag, + setAssetPayload, + setVisibilityPayload, +} from '@/store/guiInterface'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { key } from 'localforage'; +import { DEFAULT_FONT_OPTIONS } from '@/Core/util/fonts/fontOptions'; /** * 初始GUI状态表 */ const initState: IGuiState = { + fontOptions: [...DEFAULT_FONT_OPTIONS], showBacklog: false, showStarter: true, showTitle: true, @@ -80,6 +84,9 @@ const GUISlice = createSlice({ setFontOptimization: (state, action: PayloadAction) => { state.fontOptimization = action.payload; }, + setFontOptions: (state, action: PayloadAction) => { + state.fontOptions = [...action.payload]; + }, }, }); @@ -90,6 +97,7 @@ export const { setLogoImage, setEnableAppreciationMode, setFontOptimization, + setFontOptions, } = GUISlice.actions; export default GUISlice.reducer; diff --git a/packages/webgal/src/store/guiInterface.ts b/packages/webgal/src/store/guiInterface.ts index e1dd97c5e..1fc4fdaeb 100644 --- a/packages/webgal/src/store/guiInterface.ts +++ b/packages/webgal/src/store/guiInterface.ts @@ -1,5 +1,3 @@ -import { IWebGalTextBoxTheme } from '@/Stage/themeInterface'; - /** * 当前Menu页面显示的Tag */ @@ -13,6 +11,7 @@ export enum MenuPanelTag { * @interface IGuiState GUI状态接口 */ export interface IGuiState { + fontOptions: FontOption[]; showStarter: boolean; // 是否显示初始界面(用于使得bgm可以播放) showTitle: boolean; // 是否显示标题界面 showMenuPanel: boolean; // 是否显示Menu界面 @@ -35,7 +34,7 @@ export interface IGuiState { export type componentsVisibility = Pick< IGuiState, - Exclude + Exclude >; // 标题资源 export type GuiAsset = Pick; @@ -58,3 +57,12 @@ export interface setAssetPayload { } export type GuiStore = IGuiStore; + +export type FontOptionSource = 'default' | 'template'; + +export interface FontOption { + family: string; + source: FontOptionSource; + labelKey?: string; + label?: string; +} diff --git a/packages/webgal/src/store/userDataInterface.ts b/packages/webgal/src/store/userDataInterface.ts index e627b1f37..aacff85a0 100644 --- a/packages/webgal/src/store/userDataInterface.ts +++ b/packages/webgal/src/store/userDataInterface.ts @@ -18,12 +18,6 @@ export enum textSize { large, } -export enum textFont { - song, - hei, - lxgw, -} - export enum voiceOption { yes, no, @@ -47,7 +41,7 @@ export interface IOptionData { seVolume: number; // 音效音量 uiSeVolume: number; // 用户界面音效音量 slPage: number; // 存读档界面所在页面 - textboxFont: textFont; + textboxFont: number; textboxOpacity: number; language: language; voiceInterruption: voiceOption; // 是否中断语音 diff --git a/packages/webgal/src/store/userDataReducer.ts b/packages/webgal/src/store/userDataReducer.ts index bd80b9146..0b611f761 100644 --- a/packages/webgal/src/store/userDataReducer.ts +++ b/packages/webgal/src/store/userDataReducer.ts @@ -13,7 +13,6 @@ import { IUserData, fullScreenOption, playSpeed, - textFont, textSize, voiceOption, } from '@/store/userDataInterface'; @@ -31,7 +30,7 @@ const initialOptionSet: IOptionData = { bgmVolume: 25, // 背景音乐音量 seVolume: 100, // 音效音量 uiSeVolume: 50, // UI音效音量 - textboxFont: textFont.song, + textboxFont: 0, textboxOpacity: 75, language: language.zhCn, voiceInterruption: voiceOption.yes,