Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

项目架构分析及i18n注入点分析 #1

Closed
Lyoko-Jeremie opened this issue Sep 11, 2023 · 4 comments
Closed

项目架构分析及i18n注入点分析 #1

Lyoko-Jeremie opened this issue Sep 11, 2023 · 4 comments

Comments

@Lyoko-Jeremie
Copy link
Owner

Lyoko-Jeremie commented Sep 11, 2023

ref

在以上issue讨论中描述了如下两种i18n改造方式,以下复述文本:


所以应该可以在这里做 获取标签的原始文本内容 或 把汉化之后的文本替换/注入到输出结果 的工作。

大概想了一下,有两个在这个地方实现汉化注入的方法,

1:(方法A)

学习Angular的 @angular/localize ,在原始项目的 <<>> 标签上面添加i18n注解,这个是为了给标签生成固定id,然后导出angular那样格式的.xlf翻译文件,翻译之后生成angular式的节点到翻译的json字典,然后在运行时加载回去替换。
优点是翻译效果好,结果固定。
缺点就是需要给原始游戏添加i18n注解,但这个注解加完以后可以PR给原始游戏,相信作者会很开心。对付游戏JS脚本生成的动态内容以及变量,可以组合用翻译工厂 (类似Angular这种或者这种 ) 来解决

2:(方法B)

代码跑起来,在这里挂一个记录器,记录所有跑过这里的文本语句,然后生成原文字典cvs,翻译之后生成语句和翻译的查找表类似于KinkiestDungeon这种,然后在运行时加载回这个地方做替换。
这个思路就有点像GalGame啃生肉用的VNR,只不过VNR的思路是给游戏输出字符串的win api函数挂钩来提取原始文本输出到翻译机再以外挂字幕的方式显示,这里的方法要再进一步,把翻译的结果字典又输回游戏的渲染引擎(Wikifier)。
优点就是完全不需要触碰游戏原始剧本文件,跟踪上游更方便。
缺点就是匹配准确的要求高,上游即使改动一个空格这里也会出现匹配不上的情况,并且要跑过的地方才能抓到内容,要全覆盖比较麻烦。


根据以上思路对项目架构进行分析,得到的初步情况如下:

1:

此处使用正则表达式

<<(\/?[A-Za-z][\w-]*|[=-])(?:\s*)((?:(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/)|(?:\/\/.*\n)|(?:`(?:\\.|[^`\\])*`)|(?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:\[(?:[<>]?[Ii][Mm][Gg])?\[[^\r\n]*?\]\]+)|[^>]|(?:>(?!>)))*)>>
<<(\/?${Patterns.macroName})(?:\s*)((?:(?:\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/)|(?:\/\/.*\n)|(?:`(?:\\.|[^`\\])*`)|(?:"(?:\\.|[^"\\])*")|(?:'(?:\\.|[^'\\])*')|(?:\[(?:[<>]?[Ii][Mm][Gg])?\[[^\r\n]*?\]\]+)|[^>]|(?:>(?!>)))*)>>

来匹配诸如 <<if nnn>> xxx <</if> <<if nnn>> xxx <<endif> <<if nnn>> aaa <<if nnn>> xxx <</if> bbb <<endif> 这样的结构,并提取其中的 ifnnn

图片

Wikifier.Parser.add({
name : 'macro',
profiles : ['core'],
match : '<<',
lookahead : new RegExp(`<<(/?${Patterns.macroName})(?:\\s*)((?:(?:/\\*[^*]*\\*+(?:[^/*][^*]*\\*+)*/)|(?://.*\\n)|(?:\`(?:\\\\.|[^\`\\\\])*\`)|(?:"(?:\\\\.|[^"\\\\])*")|(?:'(?:\\\\.|[^'\\\\])*')|(?:\\[(?:[<>]?[Ii][Mm][Gg])?\\[[^\\r\\n]*?\\]\\]+)|[^>]|(?:>(?!>)))*)>>`, 'gm'),
working : { source : '', name : '', arguments : '', index : 0 }, // the working parse object
context : null, // last execution context object (top-level macros, hierarchically, have a null context)
handler(w) {
const matchStart = this.lookahead.lastIndex = w.matchStart;

2:

此处的 Wikifier 是整个项目的核心解析构造器,这个wikifier.js文件完成了内容输入、调用parserlib.js注册的解析器解析标签、根据标签内容调用macrolib.js注册的标签类型实际执行标签对应操作、并最终输出应输出到html的文本(html)内容。

class Wikifier {
constructor(destination, source, options) {
if (Wikifier.Parser.Profile.isEmpty()) {
Wikifier.Parser.Profile.compile();
}
Object.defineProperties(this, {
// General Wikifier properties.
source : {
value : String(source)
},
options : {
writable : true,
value : Object.assign({
profile : 'all'
}, options)
},
nextMatch : {
writable : true,
value : 0
},
output : {
writable : true,
value : null
},
// Macro parser ('macro') related properties.
_rawArgs : {
writable : true,
value : ''
}
});

在以下位置的 outputText 函数中创建TextNode并插入到html中,在此处可查看到纯文本字符串输出

outputText(destination, startPos, endPos) {
jQuery(destination).append(document.createTextNode(this.source.substring(startPos, endPos)));
}

图片

如上图所示,故可在此处实现注入方法(方法B)的 记录所有跑过这里的文本语句,然后生成原文字典cvs,翻译之后生成语句和翻译的查找表,然后在运行时加载回这个地方做替换 的实现

但此处的缺陷是,此处输出文本非常零散,对于一句话中间被标签分割的输出,会出现拆成两句的情况,如下图

图片

同样如此图所示,我们可以在 class Wikifier 的 constructor 的输入参数上获得每一个原始脚本文件输入

class Wikifier {
constructor(destination, source, options) {
if (Wikifier.Parser.Profile.isEmpty()) {
Wikifier.Parser.Profile.compile();

在此处可实现对原始脚本的整句替换,替换后再输入到 Wikifier 解析器解析并执行。


此处有一个可以注意的地方,

var Wikifier = (() => { // eslint-disable-line no-unused-vars, no-var
'use strict';
// Wikifier call depth.
let _callDepth = 0;
/*******************************************************************************************************************
Wikifier Class.
*******************************************************************************************************************/
class Wikifier {

这里有一个记录 Wikifier 嵌套调用深度的全局变量 _callDepth

try {
++_callDepth;
this.subWikify(this.output);
// Limit line break conversion to non-recursive calls.
if (_callDepth === 1 && Config.cleanupWikifierOutput) {
convertBreaks(this.output);
}
}
finally {
--_callDepth;
}
}
subWikify(output, terminator, options) {
// Cache and temporarily replace the current output buffer.
const oldOutput = this.output;

const parsersProfile = Wikifier.Parser.Profile.get(this.options.profile);
const terminatorRegExp = terminator
? new RegExp(`(?:${terminator})`, this.options.ignoreTerminatorCase ? 'gim' : 'gm')
: null;
let terminatorMatch;
let parserMatch;
do {
// Prepare the RegExp match positions.
parsersProfile.parserRegExp.lastIndex = this.nextMatch;
if (terminatorRegExp) {
terminatorRegExp.lastIndex = this.nextMatch;
}
// Get the first matches.
parserMatch = parsersProfile.parserRegExp.exec(this.source);
terminatorMatch = terminatorRegExp ? terminatorRegExp.exec(this.source) : null;

其会随着对函数 subWikify 进行递归调用时记录subWikify的递归调用深度(次数),此处的关系是


Wikifier constructor()  -> subWikify() -> 

         `const parsersProfile   = Wikifier.Parser.Profile.get(this.options.profile);  parsersProfile.parserRegExp.exec(this.source);` 
   // 此处 Wikifier.Parser 中的是 parserlib.js 注册的所有 Parser ,或是 macrolib.js 注册的 Macro

-> 调用Parser并执行 
-> 在某些Parser和Macro中会存在 `new Wikifier()` 指令, 又递归循环上面的过程

   // 对此会导致 _callDepth 深度增加,同步地可以跟踪 outputText 函数在dom树上地注入位置,以及对output的构造过程

此处就意味着,Wikifier constructor() 的输入会是脚本文件的原始输入,且其会递归地深入每一对 <<M>><</M>> 标签,因此我们可以在此处整段整段地替换原始脚本,但在构造替换模式时需要注意其地递归特性。

@Lyoko-Jeremie
Copy link
Owner Author

根据以上的初步分析我们可以知道,(方法B) 的实现难度最低,并且根据以上分析结果就可以实现动态翻译。
且同样的,我们可以预见到的是,(方法B) 可以与 (方法A) 结合使用,补充一些 (方法A) 无法方便翻译的部分。

@Lyoko-Jeremie
Copy link
Owner Author

Lyoko-Jeremie commented Sep 11, 2023

典型地,对于一个 if 控制块的实现,以及上面macro的Parser的正则表达式实现,可以发现的一点是,(1)其要求一个macro紧挨着<<书写,并且会将if之后直到>>的部分都当作命令的一部分

/*******************************************************************************************************************
Control Macros.
*******************************************************************************************************************/
/*
<<if>>, <<elseif>>, & <<else>>
*/
Macro.add('if', {
skipArgs : true,
tags : ['elseif', 'else'],
elseifWsRe : /^\s*if\b/i,
ifAssignRe : /[^!=&^|<>*/%+-]=[^=>]/,
handler() {
let i;
try {
const len = this.payload.length;
// Sanity checks.
const elseifWsRe = this.self.elseifWsRe;
const ifAssignRe = this.self.ifAssignRe;
for (/* declared previously */ i = 0; i < len; ++i) {
/* eslint-disable prefer-template */
switch (this.payload[i].name) {
case 'else':
if (this.payload[i].args.raw.length > 0) {
if (elseifWsRe.test(this.payload[i].args.raw)) {
return this.error(`whitespace is not allowed between the "else" and "if" in <<elseif>> clause${i > 0 ? ' (#' + i + ')' : ''}`);
}
return this.error(`<<else>> does not accept a conditional expression (perhaps you meant to use <<elseif>>), invalid: ${this.payload[i].args.raw}`);
}
if (i + 1 !== len) {
return this.error('<<else>> must be the final clause');
}
break;
default:
if (this.payload[i].args.full.length === 0) {
return this.error(`no conditional expression specified for <<${this.payload[i].name}>> clause${i > 0 ? ' (#' + i + ')' : ''}`);
}
else if (
Config.macros.ifAssignmentError
&& ifAssignRe.test(this.payload[i].args.full)
) {
return this.error(`assignment operator found within <<${this.payload[i].name}>> clause${i > 0 ? ' (#' + i + ')' : ''} (perhaps you meant to use an equality operator: ==, ===, eq, is), invalid: ${this.payload[i].args.raw}`);
}
break;
}
/* eslint-enable prefer-template */
}
const evalJavaScript = Scripting.evalJavaScript;
let success = false;

对于 (方法A) 的实现需要给标签添加i18n标志的方法思路,根据上面的分析已经可以知道,我们需要给所有macro及Parser的指令分析进行一些改造,要么(1)缩短命令的一部分,使得同一个控制块内可以分析出两个macro,要么(2)创造一个新语法,来在现有的<<>>标签外独立添加标识

因此,对于 (方法A) 的实现存在肉眼可见的复杂度。

@Lyoko-Jeremie
Copy link
Owner Author

Lyoko-Jeremie commented Sep 12, 2023

i18n翻译框架

Lyoko-Jeremie/sugarcube-2-i18n@d0ee044

现在已经实现TypeB(暂未实现翻译数据加载),并进行了简单测试,测试结果如下

2023-09-12 08_40_04-i18n – I18NManager ts 2023-09-12 08_40_15-i18n – I18NManager ts 2023-09-12 08_39_30-Degrees of Lewdity — Firefox Developer Edition

TypeBOutputText 挂钩在最终文本输出处,直接替换输出到html上的txt节点内的文本,适合对独立且上下文无关的句子以及其他方法不便于替换的独立单词进行替换,例如简单按钮,独立句子,特别适用于由js生成的人名地名。

TypeBInputStoryScript 挂钩在 Wikifier 构造函数处,直接接触输入的原始剧本文件,剧本数据是以标签层级的方式输入,所以会被按照以下的输入顺序调用并执行替换测试。

  1. <<if>> aa <<if>> xx <</if>> bb <</if>>
  2. aa <<if>> xx <</if>> bb
  3. <<if>> xx <</if>>

故此处适合大段大段替换原始剧本,并适用于在句子中存在标签且需要调整标签前后上下文句子的语序的情况,典型例子如下图

2023-09-12 08_40_15-i18n – I18NManager ts

@Lyoko-Jeremie
Copy link
Owner Author

passage分析:

编译之后没有文件信息了,放在HTML里面的是以passage为单位的数据,一个passage一个内容,读取脚本数据的时候也是以passage为单位的

所有数据放在<tw-storydata></tw-storydata>内,每个<tw-passagedata></tw-passagedata>为一个passage

图片

passage有几个关键数据:

  1. passage的数据类型,是js/style/脚本;
  2. 是不是widget片段;
  3. 每个passage的id(pid 唯一,按照数字顺序编号);
  4. 每个passage的名字(name 唯一,字符串)。

然后每一幕是一个passage,在passage之间跳转的时候是以passage 的name为单位跳转的。

/*
Process stylesheet passages.
*/
$storydata
.children('style') // alternatively: '[type="text/twine-css"]' or '#twine-user-stylesheet'
.each(function (i) {
_styles.push(new Passage(`tw-user-style-${i}`, this));
});
/*
Process script passages.
*/
$storydata
.children('script') // alternatively: '[type="text/twine-javascript"]' or '#twine-user-script'
.each(function (i) {
_scripts.push(new Passage(`tw-user-script-${i}`, this));
});
/*
Process normal passages, excluding any tagged 'Twine.private' or 'annotation'.
*/
$storydata
.children('tw-passagedata:not([tags~="Twine.private"],[tags~="annotation"])')
.each(function () {
const $this = jQuery(this);
const pid = $this.attr('pid') || '';
const passage = new Passage($this.attr('name'), this);
// Special cases.
if (pid === startNode && startNode !== '') {
Config.passages.start = passage.title;
validateStartingPassage(passage);
_passages[passage.title] = passage;
}
else if (passage.tags.includes('init')) {
validateSpecialPassages(passage, 'init');
_inits.push(passage);
}
else if (passage.tags.includes('widget')) {
validateSpecialPassages(passage, 'widget');
_widgets.push(passage);
}
// All other passages.
else {
_passages[passage.title] = passage;
}
});

对于链接和按钮的点击跳转事件,在macrolib的button/link宏中

/*
<<button>> & <<link>>
*/
Macro.add(['button', 'link'], {
isAsync : true,
tags : null,
handler() {
if (this.args.length === 0) {
return this.error(`no ${this.name === 'button' ? 'button' : 'link'} text specified`);
}
const $link = jQuery(document.createElement(this.name === 'button' ? 'button' : 'a'));
let passage;
if (typeof this.args[0] === 'object') {
if (this.args[0].isImage) {
// Argument was in wiki image syntax.
const $image = jQuery(document.createElement('img'))
.attr('src', this.args[0].source)
.appendTo($link);
$link.addClass('link-image');
if (this.args[0].hasOwnProperty('passage')) {
$image.attr('data-passage', this.args[0].passage);
}
if (this.args[0].hasOwnProperty('title')) {
$image.attr('title', this.args[0].title);
}
if (this.args[0].hasOwnProperty('align')) {
$image.attr('align', this.args[0].align);
}
passage = this.args[0].link;
}
else {
// Argument was in wiki link syntax.
$link.append(document.createTextNode(this.args[0].text));
passage = this.args[0].link;
}
}
else {
// Argument was simply the link text.
$link.wikiWithOptions({ profile : 'core' }, this.args[0]);
passage = this.args.length > 1 ? this.args[1] : undefined;
}
if (passage != null) { // lazy equality for null
$link.attr('data-passage', passage);
if (Story.has(passage)) {
$link.addClass('link-internal');
if (Config.addVisitedLinkClass && State.hasPlayed(passage)) {
$link.addClass('link-visited');
}
}
else {
$link.addClass('link-broken');
}
}
else {
$link.addClass('link-internal');
}
$link
.addClass(`macro-${this.name}`)
.ariaClick({
namespace : '.macros',
role : passage != null ? 'link' : 'button', // lazy equality for null
one : passage != null // lazy equality for null
}, this.createShadowWrapper(
this.payload[0].contents !== ''
? () => Wikifier.wikifyEval(this.payload[0].contents.trim())
: null,
passage != null // lazy equality for null
? () => Engine.play(passage)
: null
))
.appendTo(this.output);
}
});

点击处理函数

}, this.createShadowWrapper(
this.payload[0].contents !== ''
? () => Wikifier.wikifyEval(this.payload[0].contents.trim())
: null,
passage != null // lazy equality for null
? () => Engine.play(passage)
: null
))

点击后会对button/link标签内的内容(<<link [passage]>>内容<</link>>)求值,然后以传入的参数passage调用Engine.play(passage)跳转到对应的passage。


把文件转换成passage这个工作是那个golang写的tweego干活的,不太想动那里的代码

如果想要替换的话,也可以整个整个地把passage替换掉,
如果要实现mod的话,最简单的方法是在加载passage之前把mod按照同样的格式处理好,按照一样的格式插进html里面去,然后按照标准流程把mod和本体一起加载,
这个方法唯一需要注意的就是pid和name不能重复,否则passage会出现冲突,对于同时加载多个passage也是如此

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant