diff --git a/packages/parser/src/interface/sceneInterface.ts b/packages/parser/src/interface/sceneInterface.ts
index 8f30f34df..ba683dcf0 100644
--- a/packages/parser/src/interface/sceneInterface.ts
+++ b/packages/parser/src/interface/sceneInterface.ts
@@ -39,6 +39,7 @@ export enum commandType {
getUserInput,
applyStyle,
wait,
+ setCustomHtml, // 设置自定义HTML
}
/**
diff --git a/packages/webgal/public/game/scene/demo_zh_cn.txt b/packages/webgal/public/game/scene/demo_zh_cn.txt
index 0265db5d5..f016ec5fc 100644
--- a/packages/webgal/public/game/scene/demo_zh_cn.txt
+++ b/packages/webgal/public/game/scene/demo_zh_cn.txt
@@ -3,14 +3,18 @@ unlockBgm:s_Title.mp3 -name=雲を追いかけて;
intro:你好|欢迎来到 {egine} 的世界;
changeBg:WebGalEnter.webp -next;
setTransition: -target=bg-main -exit=shockwaveOut;
-:你好|欢迎来到 {egine} 的世界;
-changeBg:bg.webp -next;
+setVar: color=blue;
+雪衣:你好|欢迎来到 {egine} 的世界;
+changeBg:bg.png -next;
+
setTransition: -target=bg-main -enter=shockwaveIn -next;
unlockCg:bg.webp -name=良い夜; // 解锁CG并赋予名称
changeFigure:stand.webp -left -enter=enter-from-left -next;
miniAvatar:miniavatar.webp;
{heroine}:欢迎使用 {egine}!这是一款全新的网页端视觉小说引擎。 -v1.wav;
-changeFigure:stand2.webp -right -next;
+setCustomHtml:
危
;
+changeFigure:stand2.png -right -next;
+
{egine} 是使用 Web 技术开发的引擎,因此在网页端有良好的表现。 -v2.wav;
由于这个特性,如果你将 {egine} 部署到服务器或网页托管平台上,玩家只需要一串链接就可以开始游玩! -v3.wav;
setAnimation:move-front-and-back -target=fig-left -continue;
@@ -20,10 +24,11 @@ setTransform:{"position": {"x": 500,"y": 0}} -target=fig-left -next;
{egine} 引擎也具有动画系统和特效系统,使用 {egine} 开发的游戏可以拥有很好的表现效果。 -v5.wav;
pixiInit;
pixiPerform:snow;
+setCustomHtml: remove(1);
比如,这个下起小雪的特效。 -v6.wav;
除此以外,分支选择的功能也必不可少。 -v7.wav;
pixiInit;
-WebGAL:接下来介绍一些新版本功能!
+WebGAL:接下来介绍一些新版本功能!;
WebGAL:比如这个[注](zhù)[音](yīn)功能,可以为游戏带来更好的体验!
WebGAL:我们也支持了[文本拓展语法](style=color:#B5495B\;),可以为[文](wen)[本](ben)带来[富文本支持](style-alltext=font-style:italic\; style=color:#66327C\;)、交互等特性。
WebGAL:新版本添加了特性:获取用户输入,你要尝试一下吗?
diff --git a/packages/webgal/src/App.tsx b/packages/webgal/src/App.tsx
index 5b1427abc..c4cba2997 100644
--- a/packages/webgal/src/App.tsx
+++ b/packages/webgal/src/App.tsx
@@ -9,9 +9,13 @@ import Title from '@/UI/Title/Title';
import Logo from '@/UI/Logo/Logo';
import { Extra } from '@/UI/Extra/Extra';
import Menu from '@/UI/Menu/Menu';
-import GlobalDialog from '@/UI/GlobalDialog/GlobalDialog';
-import PanicOverlay from '@/UI/PanicOverlay/PanicOverlay';
-import DevPanel from '@/UI/DevPanel/DevPanel';
+import { PanicOverlay } from '@/UI/PanicOverlay/PanicOverlay';
+import Title from '@/UI/Title/Title';
+import Translation from '@/UI/Translation/Translation';
+import { useEffect } from 'react';
+import { initializeScript } from './Core/initializeScript';
+import { CustomHtml } from './extends/CustomHtml/CustomHtml';
+
export default function App() {
useEffect(() => {
@@ -19,6 +23,7 @@ export default function App() {
}, []);
return (
+
diff --git a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts
index e3998acd5..f74459376 100644
--- a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts
+++ b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts
@@ -16,7 +16,6 @@ import { WebGAL } from '@/Core/WebGAL';
*/
export const startGame = () => {
resetStage(true);
-
// 重新获取初始场景
const sceneUrl: string = assetSetter('start.txt', fileType.scene);
// 场景写入到运行时
diff --git a/packages/webgal/src/Core/controller/scene/sceneInterface.ts b/packages/webgal/src/Core/controller/scene/sceneInterface.ts
index 801149d86..5b9d4562c 100644
--- a/packages/webgal/src/Core/controller/scene/sceneInterface.ts
+++ b/packages/webgal/src/Core/controller/scene/sceneInterface.ts
@@ -39,6 +39,7 @@ export enum commandType {
getUserInput,
applyStyle,
wait,
+ setCustomHtml, // 设置自定义html
}
/**
diff --git a/packages/webgal/src/Core/gameScripts/say.ts b/packages/webgal/src/Core/gameScripts/say.ts
index 020f422c5..225d5a867 100644
--- a/packages/webgal/src/Core/gameScripts/say.ts
+++ b/packages/webgal/src/Core/gameScripts/say.ts
@@ -26,6 +26,7 @@ export const say = (sentence: ISentence): IPerform => {
if (dialogToShow) {
dialogToShow = String(dialogToShow).replace(/ {2,}/g, (match) => '\u00a0'.repeat(match.length)); // 替换连续两个或更多空格
}
+
const isConcat = getBooleanArgByKey(sentence, 'concat') ?? false; // 是否是继承语句
const isNotend = getBooleanArgByKey(sentence, 'notend') ?? false; // 是否有 notend 参数
const speaker = getStringArgByKey(sentence, 'speaker'); // 获取说话者
diff --git a/packages/webgal/src/Core/gameScripts/setCustomHtml.ts b/packages/webgal/src/Core/gameScripts/setCustomHtml.ts
new file mode 100644
index 000000000..590a55f94
--- /dev/null
+++ b/packages/webgal/src/Core/gameScripts/setCustomHtml.ts
@@ -0,0 +1,220 @@
+import { ISentence } from '@/Core/controller/scene/sceneInterface';
+import { IPerform } from '@/Core/Modules/perform/performInterface';
+import { webgalStore } from '@/store/store';
+import { stageActions } from '@/store/stageReducer';
+import React from 'react';
+
+// 工具函数:将css字符串转为对象,并返回 feature 字段
+function parseCssString(css: string): { styleObj: Record
; feature?: string } {
+ const styleObj: Record = {};
+ let feature: string | undefined;
+ css
+ .replace(/[{}]/g, '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .forEach((item) => {
+ const [key, value] = item.split(':').map((s) => s.trim());
+ if (key && value) {
+ if (key === 'feature') {
+ feature = value;
+ } else {
+ // 驼峰化
+ const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
+ styleObj[camelKey] = value;
+ }
+ }
+ });
+ return { styleObj, feature };
+}
+
+// 解析HTML元素,提取标签名、属性和内容
+interface ParsedElement {
+ tagName: string;
+ attributes: Record;
+ innerHTML: string;
+ children: ParsedElement[];
+ selfClosing: boolean; // 标记是否为自闭合标签
+}
+
+// 自动添加绝对定位
+function autoAddPositionAbsolute(style: string): string {
+ // 只要有 left/top/width/height 且没有 position,就加上
+ if (!/position\s*:/.test(style) && /(left\s*:|top\s*:|right\s*:|bottom\s*:|width\s*:|height\s*:)/.test(style)) {
+ // 逗号分隔
+ return `position: absolute,pointer-events: none, ${style}`;
+ }
+ return style;
+}
+
+// 将单个解析后的元素转换为带样式的HTML字符串
+// 支持返回 feature 字段
+function convertElementToStyledHtml(element: ParsedElement): { html: string; feature?: string } {
+ let feature: string | undefined;
+ let html = `<${element.tagName}`;
+
+ // 处理style属性
+ if (element.attributes.style) {
+ element.attributes.style = autoAddPositionAbsolute(element.attributes.style);
+ const { styleObj, feature: f } = parseCssString(element.attributes.style);
+ if (f && !feature) feature = f;
+ const cssString = Object.entries(styleObj)
+ .map(([key, value]) => {
+ const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
+ return `${cssKey}: ${value}`;
+ })
+ .join('; ');
+ html += ` style="${cssString}"`;
+ }
+
+ // 处理其他属性,但过滤掉所有on*事件处理器以防止XSS
+ Object.entries(element.attributes).forEach(([key, value]) => {
+ // 禁止所有on*事件处理器属性,防止XSS攻击
+ if (key !== 'style' && !key.startsWith('on')) {
+ html += ` ${key}="${value}"`;
+ }
+ });
+
+ // 处理自闭合标签
+ if (element.selfClosing) {
+ html += '/>';
+ } else {
+ html += '>';
+ // 添加子元素
+ if (element.children.length > 0) {
+ // 递归处理所有子元素
+ const childResults = element.children.map((child) => convertElementToStyledHtml(child));
+ html += childResults
+ .map((result) => {
+ if (result.feature && !feature) feature = result.feature;
+ return result.html;
+ })
+ .join('');
+ } else if (element.innerHTML) {
+ html += element.innerHTML;
+ }
+ html += `${element.tagName}>`;
+ }
+
+ return { html, feature };
+}
+
+// 解析HTML字符串,提取所有元素及其样式
+function parseHtmlWithStyles(htmlInput: string): ParsedElement[] {
+ // 使用DOMParser解析HTML字符串,更加安全和可靠
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(htmlInput, 'text/html');
+
+ // 递归遍历节点并转换为ParsedElement结构
+ function parseNode(node: Node): ParsedElement[] {
+ const elements: ParsedElement[] = [];
+
+ // 遍历所有子节点
+ node.childNodes.forEach((child) => {
+ if (child.nodeType === Node.ELEMENT_NODE) {
+ const element = child as HTMLElement;
+ const tagName = element.tagName.toLowerCase();
+ const attributes: Record = {};
+
+ // 提取所有属性
+ for (const attr of element.attributes) {
+ attributes[attr.name] = attr.value;
+ }
+
+ // 判断是否为自闭合标签
+ const selfClosingTags = [
+ 'img',
+ 'br',
+ 'hr',
+ 'input',
+ 'link',
+ 'meta',
+ 'area',
+ 'base',
+ 'col',
+ 'embed',
+ 'source',
+ 'track',
+ 'wbr',
+ ];
+ const isSelfClosing =
+ selfClosingTags.includes(tagName) || (element.childNodes.length === 0 && !element.textContent);
+
+ // 递归处理子元素(如果不是自闭合标签)
+ const children = isSelfClosing ? [] : parseNode(element);
+
+ // 获取innerHTML内容
+ let innerHTML = '';
+ if (children.length === 0 && !isSelfClosing) {
+ innerHTML = element.innerHTML;
+ }
+
+ elements.push({
+ tagName,
+ attributes,
+ innerHTML,
+ children,
+ selfClosing: isSelfClosing,
+ });
+ }
+ });
+
+ return elements;
+ }
+
+ // 从body开始解析(因为DOMParser会自动包装HTML)
+ return parseNode(doc.body);
+}
+
+export const setCustomHtml = (sentence: ISentence): IPerform => {
+ const removeMatch = sentence.content.match(/^remove\((\d+)\)$/);
+ if (removeMatch) {
+ const idx = parseInt(removeMatch[1], 10) - 1 || 0;
+ webgalStore.dispatch(stageActions.removeCustomHtml(idx));
+ return {
+ performName: 'none',
+ duration: 0,
+ isHoldOn: false,
+ stopFunction: () => {},
+ blockingNext: () => false,
+ blockingAuto: () => true,
+ stopTimeout: undefined,
+ };
+ }
+
+ // 直接解析HTML中的样式(不再支持content
{css}格式)
+ const html = sentence.content.trim();
+
+ // 解析HTML并处理内联样式
+ const elements = parseHtmlWithStyles(html);
+
+ // 为每个元素单独处理和分发action
+ elements.forEach((element) => {
+ const { html: styledHtml, feature } = convertElementToStyledHtml(element);
+
+ // 直接从元素的attributes中提取样式,避免再次解析HTML字符串
+ let style: React.CSSProperties = { position: 'absolute' };
+ if (element.attributes.style) {
+ const processedStyle = autoAddPositionAbsolute(element.attributes.style);
+ const { styleObj } = parseCssString(processedStyle);
+ // 将styleObj转换为React.CSSProperties
+ Object.keys(styleObj).forEach((key) => {
+ // 使用类型断言来避免TypeScript错误
+ (style as any)[key] = styleObj[key];
+ });
+ }
+
+ // 添加到状态管理,带 feature 字段和style对象
+ webgalStore.dispatch(stageActions.addCustomHtml({ html: styledHtml, _feature: feature, style }));
+ });
+
+ return {
+ performName: 'none',
+ duration: 0,
+ isHoldOn: false,
+ stopFunction: () => {},
+ blockingNext: () => false,
+ blockingAuto: () => true,
+ stopTimeout: undefined,
+ };
+};
diff --git a/packages/webgal/src/Core/parser/sceneParser.ts b/packages/webgal/src/Core/parser/sceneParser.ts
index dc3a92212..7a3b89dff 100644
--- a/packages/webgal/src/Core/parser/sceneParser.ts
+++ b/packages/webgal/src/Core/parser/sceneParser.ts
@@ -36,6 +36,7 @@ import { showVars } from '../gameScripts/showVars';
import { defineScripts, IConfigInterface, ScriptConfig, ScriptFunction, scriptRegistry } from './utils';
import { applyStyle } from '@/Core/gameScripts/applyStyle';
import { wait } from '@/Core/gameScripts/wait';
+import { setCustomHtml } from '../gameScripts/setCustomHtml';
export const SCRIPT_TAG_MAP = defineScripts({
say: ScriptConfig(commandType.say, say),
@@ -72,6 +73,8 @@ export const SCRIPT_TAG_MAP = defineScripts({
getUserInput: ScriptConfig(commandType.getUserInput, getUserInput),
applyStyle: ScriptConfig(commandType.applyStyle, applyStyle, { next: true }),
wait: ScriptConfig(commandType.wait, wait),
+ setCustomHtml: ScriptConfig(commandType.setCustomHtml, setCustomHtml, { next: true }),
+ // if: ScriptConfig(commandType.if, undefined, { next: true }),
});
export const SCRIPT_CONFIG: IConfigInterface[] = Object.values(SCRIPT_TAG_MAP);
diff --git a/packages/webgal/src/extends/CustomHtml/CustomHtml.tsx b/packages/webgal/src/extends/CustomHtml/CustomHtml.tsx
new file mode 100644
index 000000000..1f6183593
--- /dev/null
+++ b/packages/webgal/src/extends/CustomHtml/CustomHtml.tsx
@@ -0,0 +1,94 @@
+import React, { FC } from 'react';
+import { useSelector } from 'react-redux';
+import { RootState } from '@/store/store';
+import styles from './customHtml.module.scss';
+import { createPortal } from 'react-dom';
+
+export const CustomHtml: FC = () => {
+ const stageState = useSelector((state: RootState) => state.stage);
+ const { customHtml } = stageState;
+ if (!customHtml || customHtml.length === 0) {
+ return null;
+ }
+ // 优先渲染到 #pixiContianer,否则 fallback 到 body
+ const pixiContainer = typeof window !== 'undefined' ? document.getElementById('pixiContianer') : null;
+
+ // 获取 fig-xxx 的舞台像素坐标
+ function getFigurePosition(feature: string | undefined): { left?: number; top?: number } | undefined {
+ if (!feature) return undefined;
+ const key =
+ feature === 'left'
+ ? 'fig-left'
+ : feature === 'center'
+ ? 'fig-center'
+ : feature === 'right'
+ ? 'fig-right'
+ : undefined;
+ if (!key) return undefined;
+ // 通过 window.PIXIapp 获取 PixiStage 实例
+ const pixiApp: any = (window as any).PIXIapp;
+ if (!pixiApp?.figureObjects) return undefined;
+ const fig = pixiApp.figureObjects.find((f: any) => f.key === key);
+ if (fig?.pixiContainer && typeof fig.pixiContainer.toGlobal === 'function') {
+ // 获取精灵(Sprite)高度
+ let spriteHeight = 0;
+ const sprite = fig.pixiContainer.children?.[0];
+ if (sprite?.height) {
+ spriteHeight = sprite.height;
+ }
+ // 获取 pixiContainer 的全局坐标
+ const pos = fig.pixiContainer.toGlobal({ x: 0, y: 0 });
+ // y 坐标减去一半高度(顶部对齐)
+ return { left: pos.x, top: pos.y + spriteHeight / 6 };
+ }
+ return undefined;
+ }
+
+ return createPortal(
+
+ {customHtml.map((item, index) => {
+ const html = item.html;
+ const feature = item._feature;
+
+ // 使用从store中传入的style对象,如果不存在则创建默认样式
+ let style: React.CSSProperties = {
+ position: 'absolute',
+ zIndex: 10000 + index,
+ ...(item.style || {}),
+ };
+
+ if (feature) {
+ const pos = getFigurePosition(feature);
+ if (pos) {
+ // 使用CSS的calc函数来处理偏移,而不是手动计算像素值
+ if (item.style?.left !== undefined) {
+ style.left = `calc(${pos.left}px + ${item.style.left})`;
+ } else {
+ style.left = `${pos.left}px`;
+ }
+
+ if (item.style?.top !== undefined) {
+ style.top = `calc(${pos.top}px + ${item.style.top})`;
+ } else {
+ style.top = `${pos.top}px`;
+ }
+
+ style.transform = 'translate(-50%, -50%)';
+ style.pointerEvents = 'none';
+ }
+ }
+
+ return (
+
+ );
+ })}
+
,
+ pixiContainer || document.body,
+ );
+};
diff --git a/packages/webgal/src/extends/CustomHtml/customHtml.module.scss b/packages/webgal/src/extends/CustomHtml/customHtml.module.scss
new file mode 100644
index 000000000..215b8f35b
--- /dev/null
+++ b/packages/webgal/src/extends/CustomHtml/customHtml.module.scss
@@ -0,0 +1,15 @@
+.customHtmlContainer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ pointer-events: none;
+ z-index: 9999;
+}
+
+.customHtmlItem {
+ position: absolute;
+ pointer-events: auto;
+ z-index: 10001;
+}
\ No newline at end of file
diff --git a/packages/webgal/src/store/stageInterface.ts b/packages/webgal/src/store/stageInterface.ts
index a9606d89c..34ee0dc53 100644
--- a/packages/webgal/src/store/stageInterface.ts
+++ b/packages/webgal/src/store/stageInterface.ts
@@ -1,4 +1,5 @@
import { ISentence } from '@/Core/controller/scene/sceneInterface';
+import React from 'react';
import { BlinkParam, FocusParam } from '@/Core/live2DCore';
/**
@@ -219,6 +220,8 @@ export interface IStageState {
isDisableTextbox: boolean;
replacedUIlable: Record;
figureMetaData: figureMetaData;
+ // 插入的html
+ customHtml: Array<{ html: string; _feature?: string; style?: React.CSSProperties }>;
}
/**
diff --git a/packages/webgal/src/store/stageReducer.ts b/packages/webgal/src/store/stageReducer.ts
index a764af4cf..25c3c384c 100644
--- a/packages/webgal/src/store/stageReducer.ts
+++ b/packages/webgal/src/store/stageReducer.ts
@@ -21,6 +21,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import cloneDeep from 'lodash/cloneDeep';
import { commandType } from '@/Core/controller/scene/sceneInterface';
import { STAGE_KEYS } from '@/Core/constants';
+import React from 'react';
// 初始化舞台数据
@@ -70,6 +71,8 @@ export const initState: IStageState = {
isDisableTextbox: false,
replacedUIlable: {},
figureMetaData: {},
+ // 插入的html
+ customHtml: [],
};
/**
@@ -264,6 +267,32 @@ const stageSlice = createSlice({
state.figureMetaData[action.payload[0]][action.payload[1]] = action.payload[2];
}
},
+ /**
+ * 添加自定义HTML
+ * @param state 当前状态
+ * @param action 要添加的HTML内容
+ */
+ addCustomHtml: (state, action: PayloadAction<{ html: string; _feature?: string; style?: React.CSSProperties }>) => {
+ state.customHtml.push(action.payload);
+ },
+ /**
+ * 移除自定义HTML
+ * @param state 当前状态
+ * @param action 要移除的HTML索引
+ */
+ removeCustomHtml: (state, action: PayloadAction) => {
+ const index = action.payload;
+ if (index >= 0 && index < state.customHtml.length) {
+ state.customHtml.splice(index, 1);
+ }
+ },
+ /**
+ * 清空所有自定义HTML
+ * @param state 当前状态
+ */
+ clearCustomHtml: (state) => {
+ state.customHtml = [];
+ },
},
});