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 += ``; + } + + 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 = []; + }, }, });