Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2b2c15c
行尾序列改一下
ganyudedog Jul 7, 2025
dd325f1
开始加DLC了
ganyudedog Jul 9, 2025
6b79195
血条功能搞完了
ganyudedog Jul 9, 2025
7bbbf0c
setCharacter搞完了
ganyudedog Jul 10, 2025
eb29a9e
可以插入html了
ganyudedog Jul 13, 2025
100d5e8
可以插入图片
ganyudedog Jul 13, 2025
7e094de
差不多改完了
ganyudedog Jul 14, 2025
1f4665d
webgal的customHtml绑定立绘
ganyudedog Jul 20, 2025
fb5f25a
准备提pr
ganyudedog Aug 17, 2025
4fa4c7d
去掉一些没必要的东西
ganyudedog Aug 17, 2025
de56134
把一些多余的改动去掉
ganyudedog Aug 17, 2025
3813934
Update index.html
ganyudedog Aug 17, 2025
86a8ff9
移除say遗留的注释
ganyudedog Aug 17, 2025
f5fad55
移除掉不需要的dlc
ganyudedog Aug 17, 2025
38debef
移除之前测试的map
ganyudedog Aug 17, 2025
b0b0032
Merge branch 'main' into main
ganyudedog Aug 17, 2025
bbfff22
Update packages/webgal/src/Core/gameScripts/setCustomHtml.ts
ganyudedog Aug 17, 2025
96a3146
Update setCustomHtml.ts
ganyudedog Aug 17, 2025
2f25898
Update CustomHtml.tsx
ganyudedog Aug 17, 2025
189417f
Update stageReducer.ts
ganyudedog Aug 17, 2025
6d7257e
Update stageInterface.ts
ganyudedog Aug 17, 2025
19c85a4
安全以及其他的考虑
ganyudedog Aug 17, 2025
669bed8
Merge branch 'main' of https://github.com/ganyudedog/WebGAL
ganyudedog Aug 17, 2025
d12f0f1
合并时出现问题
ganyudedog Aug 17, 2025
f08b771
Update setCustomHtml.ts
ganyudedog Aug 17, 2025
38a0a0d
解决安全问题
ganyudedog Aug 17, 2025
9dd45d4
删除不用的函数
ganyudedog Aug 17, 2025
563bc06
Merge branch 'main' into main
ganyudedog Sep 11, 2025
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
1 change: 1 addition & 0 deletions packages/parser/src/interface/sceneInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export enum commandType {
getUserInput,
applyStyle,
wait,
setCustomHtml, // 设置自定义HTML
}

/**
Expand Down
13 changes: 9 additions & 4 deletions packages/webgal/public/game/scene/demo_zh_cn.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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: <div style="color: {color},font-size: 20vh, feature: left">危</div>;
changeFigure:stand2.png -right -next;

{egine} 是使用 Web 技术开发的引擎,因此在网页端有良好的表现。 -v2.wav;
由于这个特性,如果你将 {egine} 部署到服务器或网页托管平台上,玩家只需要一串链接就可以开始游玩! -v3.wav;
setAnimation:move-front-and-back -target=fig-left -continue;
Expand All @@ -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:新版本添加了特性:获取用户输入,你要尝试一下吗?
Expand Down
11 changes: 8 additions & 3 deletions packages/webgal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@ 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(() => {
initializeScript();
}, []);
return (
<div className="App">
<CustomHtml />
<Translation />
<Stage />
<BottomControlPanel />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { WebGAL } from '@/Core/WebGAL';
*/
export const startGame = () => {
resetStage(true);

// 重新获取初始场景
const sceneUrl: string = assetSetter('start.txt', fileType.scene);
// 场景写入到运行时
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export enum commandType {
getUserInput,
applyStyle,
wait,
setCustomHtml, // 设置自定义html
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/webgal/src/Core/gameScripts/say.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'); // 获取说话者
Expand Down
220 changes: 220 additions & 0 deletions packages/webgal/src/Core/gameScripts/setCustomHtml.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>; feature?: string } {
const styleObj: Record<string, string> = {};
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 };
}
Comment on lines +8 to +29

Choose a reason for hiding this comment

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

high

parseCssString 函数中,您使用逗号 , 作为 CSS 属性的分隔符。这不是标准的 CSS 语法,会导致在处理包含逗号的合法 CSS 值(例如 font-family: "Times New Roman", serifbox-shadow 的多重阴影)时解析错误。这使得该功能不够健壮,并限制了其可用性。

建议遵循标准的 CSS 语法,使用分号 ; 作为属性分隔符。这样开发者可以直接使用标准的 CSS,也使功能更加可靠。对应的, demo_zh_cn.txt 中的 setCustomHtml 命令也需要更新。

function parseCssString(css: string): { styleObj: Record<string, string>; feature?: string } {
  const styleObj: Record<string, string> = {};
  let feature: string | undefined;
  css
    .replace(/[{}]/g, '')
    .split(';') // 使用分号分割,更符合CSS标准
    .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<string, string>;
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) {

Choose a reason for hiding this comment

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

critical

存在严重的安全漏洞。当前实现没有对 HTML 进行充分的净化,允许执行任意 JavaScript 代码(XSS)。由于最终内容会通过 dangerouslySetInnerHTML 渲染,这会带来严重的安全风险。

具体来说:

  1. 危险标签未被过滤:像 <script>, <iframe>, <object>, <embed> 等标签可以被直接注入并执行。
  2. 危险属性值未被过滤:例如,<a> 标签的 href 属性可以设置为 javascript:alert('XSS')

强烈建议使用成熟的 HTML 净化库(如 DOMPurify)来处理用户输入的 HTML,以确保安全。如果不想引入新库,则必须实现一个严格的白名单机制,只允许安全的标签和属性,并对属性值进行校验。

例如,在 parseNode 中可以增加一个标签白名单:

const ALLOWED_TAGS = ['div', 'span', 'p', 'b', 'i', 'u', 'strong', 'em'];
// ...
if (child.nodeType === Node.ELEMENT_NODE) {
  const element = child as HTMLElement;
  const tagName = element.tagName.toLowerCase();
  if (!ALLOWED_TAGS.includes(tagName)) {
    // 跳过或移除不允许的标签
    return;
  }
  // ...
}

const element = child as HTMLElement;
const tagName = element.tagName.toLowerCase();
const attributes: Record<string, string> = {};

// 提取所有属性
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中的样式(不再支持<div>content</div> {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 }));
});
Comment on lines +192 to +209

Choose a reason for hiding this comment

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

medium

setCustomHtml 函数中,样式信息被解析了两次:一次在 convertElementToStyledHtml 函数内部用于生成 HTML 字符串,另一次在 elements.forEach 循环中用于创建 React.CSSProperties 对象。这是不必要的重复工作,影响了代码效率。

建议进行重构,让样式只被解析一次。例如,可以让 convertElementToStyledHtmlparseCssString 返回解析后的样式对象,然后在后续逻辑中复用这个对象,避免重复解析。


return {
performName: 'none',
duration: 0,
isHoldOn: false,
stopFunction: () => {},
blockingNext: () => false,
blockingAuto: () => true,
stopTimeout: undefined,
};
};
3 changes: 3 additions & 0 deletions packages/webgal/src/Core/parser/sceneParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
Loading