Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/webgal/src/Core/gameScripts/choose/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ 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';
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 {
/**
Expand Down Expand Up @@ -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[]
Expand Down
5 changes: 2 additions & 3 deletions packages/webgal/src/Core/gameScripts/getUserInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
* 显示选择枝
Expand All @@ -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 = (
Expand Down
2 changes: 2 additions & 0 deletions packages/webgal/src/Core/initializeScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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终端
Expand All @@ -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();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

loadTemplate 是一个异步函数,但在这里以“即发即忘”的方式调用,没有等待其完成。这会导致 initializeScript 中的后续代码在模板和字体加载完成前就执行,可能引发竞态条件(race condition)。例如,UI 可能在自定义字体注入前就渲染,导致字体闪烁或初期显示不正确。为了确保初始化按预期顺序完成,initializeScript 函数应该被声明为 async,并且在这里 await loadTemplate()

// 激活强制缩放
// 在调整窗口大小时重新计算宽高,设计稿按照 1600*900。
if (isIOS) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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]';

export async function loadTemplate(): Promise<WebgalTemplate | null> {
try {
const { data } = await axios.get<WebgalTemplate>(TEMPLATE_PATH);
WebGAL.template = data;
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));

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<HTMLStyleElement>(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} }`;
}
Comment on lines +57 to +70

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

generateFontFaceRule 函数在生成 @font-face 规则时,直接用单引号包裹了从 template.json 中获取的 font-family。如果用户在 JSON 文件中提供的字体名称本身已经包含了引号(例如 "'My Font'"),这会导致生成无效的 CSS(例如 font-family: ''My Font'')。建议在处理前先移除字体名称两端可能存在的引号,以增强函数的健壮性。

Suggested change
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 generateFontFaceRule(font: TemplateFontDescriptor): string | null {
const fontFamily = font['font-family'];
if (!fontFamily || !font.url || !font.type) {
logger.warn('忽略无效的模板字体配置', font);
return null;
}
const sanitizedFontFamily = fontFamily.trim().replace(/^['"]|['"]$/g, '');
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: '${sanitizedFontFamily}'; 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}`;
}
Comment on lines +72 to +78

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

resolveTemplateAssetPath 函数对相对路径的处理存在问题。当前的实现 path.replace(/^[./]+/,'') 会错误地处理包含 .. 的路径(例如,../fonts/myfont.woff 会被解析到 game/template/fonts/myfont.woff 而不是 game/fonts/myfont.woff),并且也会不正确地处理以 / 开头的根相对路径。这会导致字体等资源无法按预期加载。建议重写此函数,以更准确地处理不同类型的路径。

Suggested change
function resolveTemplateAssetPath(path: string): string {
if (/^(https?:)?\/\//i.test(path) || path.startsWith('data:')) {
return path;
}
const normalized = path.replace(/^[./]+/, '');
return `./game/template/${normalized}`;
}
function resolveTemplateAssetPath(path: string): string {
if (/^(https?:)?\/\//i.test(path) || path.startsWith('data:')) {
return path;
}
if (path.startsWith('/')) {
return path;
}
return `./game/template/${path}`;
}

46 changes: 46 additions & 0 deletions packages/webgal/src/Core/util/fonts/fontOptions.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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`;
}
Comment on lines +41 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

formatFontFamily 函数没有处理 fontFamily 字符串本身已包含引号的情况。如果 template.json 中的字体名是 "'My Font'",此函数会返回 ''My Font'', serif,这是一个无效的 font-family 值。建议在处理前先移除字符串两端可能存在的引号,以提高代码的健壮性。

Suggested change
export function formatFontFamily(fontFamily: string): string {
const trimmed = fontFamily.trim();
const needsQuote = /\s/.test(trimmed);
const normalized = needsQuote ? `'${trimmed}'` : trimmed;
return `${normalized}, serif`;
}
export function formatFontFamily(fontFamily: string): string {
const trimmed = fontFamily.trim().replace(/^['"]|['"]$/g, '');
const needsQuote = /\s/.test(trimmed);
const normalized = needsQuote ? `'${trimmed}'` : trimmed;
return `${normalized}, serif`;
}

2 changes: 2 additions & 0 deletions packages/webgal/src/Core/webgalCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -13,4 +14,5 @@ export class WebgalCore {
public gameName = '';
public gameKey = '';
public events = new Events();
public template: WebgalTemplate | null = null;
}
33 changes: 16 additions & 17 deletions packages/webgal/src/UI/Menu/Options/Display/Display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
<div className={styles.Options_main_content_half}>
Expand Down Expand Up @@ -50,22 +59,12 @@ export function Display() {
</NormalOption>
<NormalOption key="textFont" title={t('textFont.title')}>
<NormalButton
textList={t('textFont.options.siYuanSimSun', 'textFont.options.SimHei', 'textFont.options.lxgw')}
functionList={[
() => {
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}
/>
</NormalOption>
<NormalOption key="textSpeed" title={t('textSpeed.title')}>
Expand Down
31 changes: 18 additions & 13 deletions packages/webgal/src/hooks/useFontFamily.ts
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 14 additions & 6 deletions packages/webgal/src/store/GUIReducer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -80,6 +84,9 @@ const GUISlice = createSlice({
setFontOptimization: (state, action: PayloadAction<boolean>) => {
state.fontOptimization = action.payload;
},
setFontOptions: (state, action: PayloadAction<FontOption[]>) => {
state.fontOptions = [...action.payload];
},
},
});

Expand All @@ -90,6 +97,7 @@ export const {
setLogoImage,
setEnableAppreciationMode,
setFontOptimization,
setFontOptions,
} = GUISlice.actions;
export default GUISlice.reducer;

Expand Down
14 changes: 11 additions & 3 deletions packages/webgal/src/store/guiInterface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { IWebGalTextBoxTheme } from '@/Stage/themeInterface';

/**
* 当前Menu页面显示的Tag
*/
Expand All @@ -13,6 +11,7 @@ export enum MenuPanelTag {
* @interface IGuiState GUI状态接口
*/
export interface IGuiState {
fontOptions: FontOption[];
showStarter: boolean; // 是否显示初始界面(用于使得bgm可以播放)
showTitle: boolean; // 是否显示标题界面
showMenuPanel: boolean; // 是否显示Menu界面
Expand All @@ -35,7 +34,7 @@ export interface IGuiState {

export type componentsVisibility = Pick<
IGuiState,
Exclude<keyof IGuiState, 'currentMenuTag' | 'titleBg' | 'titleBgm' | 'logoImage' | 'theme'>
Exclude<keyof IGuiState, 'currentMenuTag' | 'titleBg' | 'titleBgm' | 'logoImage' | 'theme' | 'fontOptions'>
>;
// 标题资源
export type GuiAsset = Pick<IGuiState, 'titleBgm' | 'titleBg'>;
Expand All @@ -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;
}
Loading