Skip to content

Commit 5eaa8ee

Browse files
Merge pull request #790 from OpenWebGAL/custom-template-font
Custom template font
2 parents 4c04b0b + cb532ce commit 5eaa8ee

File tree

13 files changed

+218
-54
lines changed

13 files changed

+218
-54
lines changed

packages/webgal/src/Core/gameScripts/choose/index.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import ReactDOM from 'react-dom';
66
import React from 'react';
77
import styles from './choose.module.scss';
88
import { webgalStore } from '@/store/store';
9-
import { textFont } from '@/store/userDataInterface';
109
import { PerformController } from '@/Core/Modules/perform/performController';
1110
import { useSEByWebgalStore } from '@/hooks/useSoundEffect';
1211
import { WebGAL } from '@/Core/WebGAL';
1312
import { whenChecker } from '@/Core/controller/gamePlay/scriptExecutor';
1413
import useEscape from '@/hooks/useEscape';
1514
import useApplyStyle from '@/hooks/useApplyStyle';
1615
import { Provider } from 'react-redux';
16+
import { useFontFamily } from '@/hooks/useFontFamily';
1717

1818
class ChooseOption {
1919
/**
@@ -81,8 +81,7 @@ export const choose = (sentence: ISentence): IPerform => {
8181
};
8282

8383
function Choose(props: { chooseOptions: ChooseOption[] }) {
84-
const fontFamily = webgalStore.getState().userData.optionData.textboxFont;
85-
const font = fontFamily === textFont.song ? '"思源宋体", serif' : '"WebgalUI", serif';
84+
const font = useFontFamily();
8685
const { playSeEnter, playSeClick } = useSEByWebgalStore();
8786
const applyStyle = useApplyStyle('Stage/Choose/choose.scss');
8887
// 运行时计算JSX.Element[]

packages/webgal/src/Core/gameScripts/getUserInput/index.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import ReactDOM from 'react-dom';
66
import React from 'react';
77
import styles from './getUserInput.module.scss';
88
import { webgalStore } from '@/store/store';
9-
import { textFont } from '@/store/userDataInterface';
109
import { PerformController } from '@/Core/Modules/perform/performController';
1110
import { useSEByWebgalStore } from '@/hooks/useSoundEffect';
1211
import { WebGAL } from '@/Core/WebGAL';
1312
import { getStringArgByKey } from '@/Core/util/getSentenceArg';
1413
import { nextSentence } from '@/Core/controller/gamePlay/nextSentence';
1514
import { setStageVar } from '@/store/stageReducer';
15+
import { getCurrentFontFamily } from '@/hooks/useFontFamily';
1616

1717
/**
1818
* 显示选择枝
@@ -27,8 +27,7 @@ export const getUserInput = (sentence: ISentence): IPerform => {
2727
buttonText = buttonText === '' ? 'OK' : buttonText;
2828
const defaultValue = getStringArgByKey(sentence, 'defaultValue');
2929

30-
const fontFamily = webgalStore.getState().userData.optionData.textboxFont;
31-
const font = fontFamily === textFont.song ? '"思源宋体", serif' : '"WebgalUI", serif';
30+
const font = getCurrentFontFamily();
3231

3332
const { playSeEnter, playSeClick } = useSEByWebgalStore();
3433
const chooseElements = (

packages/webgal/src/Core/initializeScript.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import PixiStage from '@/Core/controller/stage/pixi/PixiController';
1414
import axios from 'axios';
1515
import { __INFO } from '@/config/info';
1616
import { WebGAL } from '@/Core/WebGAL';
17+
import { loadTemplate } from '@/Core/util/coreInitialFunction/templateLoader';
1718

1819
const u = navigator.userAgent;
1920
export const isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); // 判断是否是 iOS终端
@@ -26,6 +27,7 @@ export const initializeScript = (): void => {
2627
logger.info(`WebGAL v${__INFO.version}`);
2728
logger.info('Github: https://github.com/OpenWebGAL/WebGAL ');
2829
logger.info('Made with ❤ by OpenWebGAL');
30+
loadTemplate();
2931
// 激活强制缩放
3032
// 在调整窗口大小时重新计算宽高,设计稿按照 1600*900。
3133
if (isIOS) {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import axios from 'axios';
2+
import { logger } from '@/Core/util/logger';
3+
import { WebGAL } from '@/Core/WebGAL';
4+
import { TemplateFontDescriptor, WebgalTemplate } from '@/types/template';
5+
import { buildFontOptionsFromTemplate } from '@/Core/util/fonts/fontOptions';
6+
import { webgalStore } from '@/store/store';
7+
import { setFontOptions } from '@/store/GUIReducer';
8+
import { setOptionData } from '@/store/userDataReducer';
9+
10+
const TEMPLATE_PATH = './game/template/template.json';
11+
const TEMPLATE_FONT_STYLE_SELECTOR = 'style[data-webgal-template-fonts]';
12+
13+
export async function loadTemplate(): Promise<WebgalTemplate | null> {
14+
try {
15+
const { data } = await axios.get<WebgalTemplate>(TEMPLATE_PATH);
16+
WebGAL.template = data;
17+
const fonts = data.fonts ?? [];
18+
injectTemplateFonts(fonts);
19+
updateFontOptions(fonts);
20+
return data;
21+
} catch (error) {
22+
logger.warn('加载模板文件失败', error);
23+
updateFontOptions([]);
24+
return null;
25+
}
26+
}
27+
28+
function updateFontOptions(fonts: TemplateFontDescriptor[]): void {
29+
const options = buildFontOptionsFromTemplate(fonts);
30+
webgalStore.dispatch(setFontOptions(options));
31+
const currentIndex = webgalStore.getState().userData.optionData.textboxFont ?? 0;
32+
if (options.length === 0) return;
33+
if (currentIndex >= options.length) {
34+
webgalStore.dispatch(setOptionData({ key: 'textboxFont', value: 0 }));
35+
}
36+
}
37+
38+
function injectTemplateFonts(fonts: TemplateFontDescriptor[]): void {
39+
if (!fonts.length) return;
40+
const rules = fonts.map((font) => generateFontFaceRule(font)).filter((rule): rule is string => Boolean(rule));
41+
42+
if (!rules.length) return;
43+
44+
const styleElement = document.createElement('style');
45+
styleElement.setAttribute('data-webgal-template-fonts', 'true');
46+
styleElement.appendChild(document.createTextNode(rules.join('\n')));
47+
48+
const head = document.head;
49+
if (!head) return;
50+
51+
const existing = head.querySelector<HTMLStyleElement>(TEMPLATE_FONT_STYLE_SELECTOR);
52+
existing?.remove();
53+
54+
head.appendChild(styleElement);
55+
}
56+
57+
function generateFontFaceRule(font: TemplateFontDescriptor): string | null {
58+
const fontFamily = font['font-family'];
59+
if (!fontFamily || !font.url || !font.type) {
60+
logger.warn('忽略无效的模板字体配置', font);
61+
return null;
62+
}
63+
64+
const src = resolveTemplateAssetPath(font.url);
65+
const weight = font.weight !== undefined ? `font-weight: ${font.weight};` : '';
66+
const style = font.style ? `font-style: ${font.style};` : '';
67+
const display = `font-display: ${font.display ?? 'swap'};`;
68+
69+
return `@font-face { font-family: '${fontFamily}'; src: url('${src}') format('${font.type}'); ${weight} ${style} ${display} }`;
70+
}
71+
72+
function resolveTemplateAssetPath(path: string): string {
73+
if (/^(https?:)?\/\//i.test(path) || path.startsWith('data:')) {
74+
return path;
75+
}
76+
const normalized = path.replace(/^[./]+/, '');
77+
return `./game/template/${normalized}`;
78+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { FontOption } from '@/store/guiInterface';
2+
import { TemplateFontDescriptor } from '@/types/template';
3+
4+
export const DEFAULT_FONT_OPTIONS: FontOption[] = [
5+
{
6+
family: `'思源宋体', serif`,
7+
source: 'default',
8+
labelKey: 'textFont.options.siYuanSimSun',
9+
},
10+
{
11+
family: `'WebgalUI', serif`,
12+
source: 'default',
13+
labelKey: 'textFont.options.SimHei',
14+
},
15+
{
16+
family: `'LXGW', serif`,
17+
source: 'default',
18+
labelKey: 'textFont.options.lxgw',
19+
},
20+
];
21+
22+
export const FALLBACK_FONT_FAMILY = DEFAULT_FONT_OPTIONS[1].family;
23+
24+
export function buildFontOptionsFromTemplate(fonts: TemplateFontDescriptor[]): FontOption[] {
25+
const templateOptions: FontOption[] = fonts.map((font) => ({
26+
family: formatFontFamily(font['font-family']),
27+
source: 'template',
28+
label: font['font-family'],
29+
}));
30+
31+
const combined = [...templateOptions, ...DEFAULT_FONT_OPTIONS];
32+
33+
const seen = new Set<string>();
34+
return combined.filter((option) => {
35+
if (seen.has(option.family)) return false;
36+
seen.add(option.family);
37+
return true;
38+
});
39+
}
40+
41+
export function formatFontFamily(fontFamily: string): string {
42+
const trimmed = fontFamily.trim();
43+
const needsQuote = /\s/.test(trimmed);
44+
const normalized = needsQuote ? `'${trimmed}'` : trimmed;
45+
return `${normalized}, serif`;
46+
}

packages/webgal/src/Core/webgalCore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AnimationManager } from '@/Core/Modules/animations';
55
import { Gameplay } from './Modules/gamePlay';
66
import { Events } from '@/Core/Modules/events';
77
import { SteamIntegration } from '@/Core/integration/steamIntegration';
8+
import { WebgalTemplate } from '@/types/template';
89

910
export class WebgalCore {
1011
public sceneManager = new SceneManager();
@@ -15,4 +16,5 @@ export class WebgalCore {
1516
public gameKey = '';
1617
public events = new Events();
1718
public steam = new SteamIntegration();
19+
public template: WebgalTemplate | null = null;
1820
}

packages/webgal/src/UI/Menu/Options/Display/Display.tsx

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import styles from '@/UI/Menu/Options/options.module.scss';
66
import useFullScreen from '@/hooks/useFullScreen';
77
import useTrans from '@/hooks/useTrans';
88
import { RootState } from '@/store/store';
9-
import { textFont, textSize } from '@/store/userDataInterface';
9+
import { textSize } from '@/store/userDataInterface';
1010
import { setOptionData } from '@/store/userDataReducer';
1111
import { useDispatch, useSelector } from 'react-redux';
1212
import { OptionSlider } from '../OptionSlider';
@@ -16,6 +16,15 @@ export function Display() {
1616
const dispatch = useDispatch();
1717
const t = useTrans('menu.options.pages.display.options.');
1818
const { isSupported: isFullscreenSupported, enter: enterFullscreen, exit: exitFullscreen } = useFullScreen();
19+
const fontOptions = useSelector((state: RootState) => state.GUI.fontOptions);
20+
const fontOptionTexts = fontOptions.map((option) => {
21+
if (option.labelKey) return t(option.labelKey);
22+
if (option.label) return option.label;
23+
return option.family;
24+
});
25+
const currentFontIndex = fontOptions.length
26+
? Math.min(userDataState.optionData.textboxFont, fontOptions.length - 1)
27+
: 0;
1928

2029
return (
2130
<div className={styles.Options_main_content_half}>
@@ -50,22 +59,12 @@ export function Display() {
5059
</NormalOption>
5160
<NormalOption key="textFont" title={t('textFont.title')}>
5261
<NormalButton
53-
textList={t('textFont.options.siYuanSimSun', 'textFont.options.SimHei', 'textFont.options.lxgw')}
54-
functionList={[
55-
() => {
56-
dispatch(setOptionData({ key: 'textboxFont', value: textFont.song }));
57-
setStorage();
58-
},
59-
() => {
60-
dispatch(setOptionData({ key: 'textboxFont', value: textFont.hei }));
61-
setStorage();
62-
},
63-
() => {
64-
dispatch(setOptionData({ key: 'textboxFont', value: textFont.lxgw }));
65-
setStorage();
66-
},
67-
]}
68-
currentChecked={userDataState.optionData.textboxFont}
62+
textList={fontOptionTexts}
63+
functionList={fontOptions.map((_, index) => () => {
64+
dispatch(setOptionData({ key: 'textboxFont', value: index }));
65+
setStorage();
66+
})}
67+
currentChecked={currentFontIndex}
6968
/>
7069
</NormalOption>
7170
<NormalOption key="textSpeed" title={t('textSpeed.title')}>
Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import { useSelector } from 'react-redux';
2-
import { RootState } from '@/store/store';
3-
import { textFont } from '@/store/userDataInterface';
4-
import { match } from '@/Core/util/match';
2+
import { RootState, webgalStore } from '@/store/store';
3+
import { FALLBACK_FONT_FAMILY } from '@/Core/util/fonts/fontOptions';
54

6-
export function useFontFamily() {
7-
const fontFamily = useSelector((state: RootState) => state.userData.optionData.textboxFont);
5+
export function useFontFamily(): string {
6+
return useSelector(selectFontFamily);
7+
}
88

9-
function getFont() {
10-
return match(fontFamily)
11-
.with(textFont.song, () => '"思源宋体", serif')
12-
.with(textFont.lxgw, () => '"LXGW", serif')
13-
.with(textFont.hei, () => '"WebgalUI", serif')
14-
.default(() => '"WebgalUI", serif');
15-
}
9+
export function getCurrentFontFamily(): string {
10+
return selectFontFamily(webgalStore.getState());
11+
}
1612

17-
return getFont();
13+
export function selectFontFamily(state: RootState): string {
14+
const index = state.userData.optionData.textboxFont ?? 0;
15+
const fonts = state.GUI.fontOptions;
16+
if (fonts[index]) {
17+
return fonts[index].family;
18+
}
19+
if (fonts.length > 0) {
20+
return fonts[0].family;
21+
}
22+
return FALLBACK_FONT_FAMILY;
1823
}

packages/webgal/src/store/GUIReducer.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
/**
2-
* @file 记录当前GUI的状态信息,引擎初始化时会重置。
3-
* @author Mahiru
4-
*/
51
import { getStorage } from '@/Core/controller/storage/storageController';
6-
import { GuiAsset, IGuiState, MenuPanelTag, setAssetPayload, setVisibilityPayload } from '@/store/guiInterface';
2+
import {
3+
FontOption,
4+
GuiAsset,
5+
IGuiState,
6+
MenuPanelTag,
7+
setAssetPayload,
8+
setVisibilityPayload,
9+
} from '@/store/guiInterface';
710
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
8-
import { key } from 'localforage';
11+
import { DEFAULT_FONT_OPTIONS } from '@/Core/util/fonts/fontOptions';
912

1013
/**
1114
* 初始GUI状态表
1215
*/
1316
const initState: IGuiState = {
17+
fontOptions: [...DEFAULT_FONT_OPTIONS],
1418
showBacklog: false,
1519
showStarter: true,
1620
showTitle: true,
@@ -80,6 +84,9 @@ const GUISlice = createSlice({
8084
setFontOptimization: (state, action: PayloadAction<boolean>) => {
8185
state.fontOptimization = action.payload;
8286
},
87+
setFontOptions: (state, action: PayloadAction<FontOption[]>) => {
88+
state.fontOptions = [...action.payload];
89+
},
8390
},
8491
});
8592

@@ -90,6 +97,7 @@ export const {
9097
setLogoImage,
9198
setEnableAppreciationMode,
9299
setFontOptimization,
100+
setFontOptions,
93101
} = GUISlice.actions;
94102
export default GUISlice.reducer;
95103

packages/webgal/src/store/guiInterface.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { IWebGalTextBoxTheme } from '@/Stage/themeInterface';
2-
31
/**
42
* 当前Menu页面显示的Tag
53
*/
@@ -13,6 +11,7 @@ export enum MenuPanelTag {
1311
* @interface IGuiState GUI状态接口
1412
*/
1513
export interface IGuiState {
14+
fontOptions: FontOption[];
1615
showStarter: boolean; // 是否显示初始界面(用于使得bgm可以播放)
1716
showTitle: boolean; // 是否显示标题界面
1817
showMenuPanel: boolean; // 是否显示Menu界面
@@ -35,7 +34,7 @@ export interface IGuiState {
3534

3635
export type componentsVisibility = Pick<
3736
IGuiState,
38-
Exclude<keyof IGuiState, 'currentMenuTag' | 'titleBg' | 'titleBgm' | 'logoImage' | 'theme'>
37+
Exclude<keyof IGuiState, 'currentMenuTag' | 'titleBg' | 'titleBgm' | 'logoImage' | 'theme' | 'fontOptions'>
3938
>;
4039
// 标题资源
4140
export type GuiAsset = Pick<IGuiState, 'titleBgm' | 'titleBg'>;
@@ -58,3 +57,12 @@ export interface setAssetPayload {
5857
}
5958

6059
export type GuiStore = IGuiStore;
60+
61+
export type FontOptionSource = 'default' | 'template';
62+
63+
export interface FontOption {
64+
family: string;
65+
source: FontOptionSource;
66+
labelKey?: string;
67+
label?: string;
68+
}

0 commit comments

Comments
 (0)