由于百度空间下线,暂且将东西转移到csdn。。。
(一)素材的提取和处理
酝酿了半年终于开始摸索galgame的移植了,不知能坚持多久。想着把自己摸索的东西记下来,供以后查阅,如果被别人翻到又对其工作有所帮助,就是幸运之至了。
这里拿《九十九之月》(ツクモノツキ)开刀,完全是个人研究,也没有向汉化组申请授权,直接搬日文的脚本
一、提取
首先要得到PC平台的游戏,一般我喜欢搜游戏名的贴吧,在置顶楼里找资源,另外百度搜索 site:pan.baidu.com[空格][游戏名]也很可能搜到游戏的资源。
接下来就是拆包了。galgame成品的资源和脚本几乎都是打包的,不同公司的作品,不同引擎,资源包的格式也不一样。这里并没有自己写代码拆包的能力,只能靠前人的工作。
比较著名的通用工具有古老的ExtractData,国人写的Crass,以及最近以推倒椎名里绪著名的arc_conv。关于资源提取工具的一个比较详细的索引贴:http://tieba.baidu.com/p/2138930285。有些著名的引擎,这些工具可以直接提取,例如Nscripter和手机上的Onscripter引擎,游戏的资源文件打包的文件名为arc.nsa;吉里吉里的data.xp3。很多工具通过插件运作以识别和提取不同引擎对应的资源文件,一些比较新的游戏要用这些工具提取,可能需要搜索插件。
另外还介绍一个网站http://asmodean.reverse.net,可以找到各种公司、引擎对应的游戏解包的源代码和工具,目前仍在更新,有兴趣的读者也可以下载下来研究,成为新一代大神~还是那句话,善用搜索工具,前人做过的事放出了资源尽量找,重新发明轮子是很糟糕的事。╮(╯▽╰)╭
九十九之月的游戏目录下有grp.aos(graph,图片), cv.aos(Character Voice,配音), bgm.aos(background music,背景音乐), se.aos(sound effect,音效), scr.aos(script,脚本)这几个文件。用不同工具尝试后,arc_conv效果最好。用Crass也能提取,但是得到的文件名被重新编号,不能保持本来的名字。估计arc_conv的原理是首先扫描要拆包文件的目录,上传到服务器端判断这是什么游戏,然后得到方法拆包(从断网后发现不灵了提出的假设,也可能不是这样)。
于是得到了各种资源,加起来有几G,如果直接用这些资源我的充话费送的低端安卓机是跑不动的,如果别人这么做,我的小水管也是拖不下来的_(:з」∠)_所以需要压缩。。。
二、语音的压缩
于是需要压缩。。首先拿人物语音开刀。语音都是ogg格式,采样率44100kHz,码率92kbps。按照大计基课的说法,人声采样率8kHz,电话未压缩时传输码率64kbps,又参照之前下载的几个Ons移植游戏,决定输出设置为采样率44100kHz,码率32kbps。首先用格式工厂弄,可是格式工厂导出单声道ogg老是失败;后来又下了一个名为xrecode的批量音频转码的工具,可这货会事先载入每个选中文件的详细信息,一万多个文件啊,就卡在那里了。遂用ffmpeg命令行下直接转换。ffmpeg是用的小丸工具箱里自带的。ffmpeg是一个非常强大的音视频转码与混流软件。
批处理如下:
@echo off
echo ======================
echo 批量语音压缩
echo 将此文件放在ogg文件所在的同一个文件夹下
echo 确认ffmpeg.exe和本脚本处于同一级目录下,或者ffmpeg已在PATH中!
echo ======================
for %%a in (cv~\*.ogg) do ffmpeg -loglevel error -y -i "%%a" -vn -acodec libvorbis -b:a 32k -f ogg "cv/%%~na.ogg"
echo 编码转换已经完成,请检查转换后的文件是否正确
echo 转换后的文件在cv中。
pause
效果是多出一个cv_low文件夹,里面是经过压缩,码率变为32kbps的语音文件
三、图片的转换与压缩
Crass转码得到的清一色的bmp文件,但是文件名都丢失了,这不是我们想要的。arc_conv拆包出来有bmp和tga文件,还有后缀名为msk的遮罩文件,实际上也是bmp。这些都是无损的图片,不过分损失质量前提下压缩空间很大。这些文件中,bmp用于背景,可以直接转成jpg;tga用于带透明度的立绘和按钮等,就是bmp的RGB三个通道加上记录透明的alpha通道。
图片格式的转换这里介绍一个软件XnView,“是一款的图像查看程序,自身支持100多种图片格式,安装插件后支持图片格式近500种。”(引自百度百科),支持批量带附加效果转换,支持不受扩展名干扰的文件格式判断。打开软件,菜单栏工具->批量转换,点击“格式”一栏的“选项”按钮设置输出格式为转换成质量为90%的jpg,
返回,点击“添加文件”按钮,找到所在目录,在文件名文本框中输入“*.bmp”回车,然后全选上面的文件,确定,这样就选中了所有的bmp,再次点击“添加文件”按钮,这次是用“*.msk”,以把所有的遮罩图片转成jpg
接下来是带透明度的tga了。可以用XnView转成png,保留透明。但是作为无损压缩的png一张图有100多kB大小,估测了下不带OP最后打包的文件会有将近800MB。也许对于土豪们的手机并不算什么,但我还是得为自己的屌丝机着想,把带alpha通道的tga转成Nscripter发明的左边RGB通道右边反过来的alpha通道的jpg,如图:
这里需要首先用到批量文件转换的“变换”选项卡,提取通道,选择alpha,以及负片。选择导出的目录,我这里是原图下新建的mask目录。
然后是漫长的导出。。
接下来要做的就是拼接原图和mask图片,我研究了半天还是不知道怎样用XnView拼接./*.tga和./mask/*.bmp,这样文件名对应的,遂找了个据说日本那边同人游戏常用的一个图片工具(imageUtility,下载地址http://pan.baidu.com/s/1F0pvC,原地址一时找不到了)
这个工具有GUI,可是日文环境直接打开有乱码,apploc打开出错。不过我们用到的是其命令行工具
首先用XnView把所有tga转成bmp,放在./ori/文件夹下
在遮罩所在目录(grp.aos~\mask\)新建一个批处理文件toNsjpg.bat,贴出我的代码
for %%a in (*.png) do F:\ACG\汉化工具\imageUtility059d\imageUtility.exe -FMT,JPG,24,90,1 -OUT,..\result\%%a -CNT,1 ..\ori\%%a %%a
cd ..\result
rename *.png *.jpg
cd ..\mask
F:\ACG\汉化工具\imageUtility059d\imageUtility.exe是我的imageUtility所在位置,需要修改。
我的bat技术也不好,for获取的文件名*.bmp储存到%%1,怎么得到基名,怎么去掉扩展名也不知道。。
抽出所有这种形式的tga,先用XnView转成透明的png,保存至一目录,还是用ImageUtility把每张图片按行切成3等分,再横向组合,新建批处理如下:
for %%a in (*.png) do (
F:\ACG\汉化工具\imageUtility059d\imageUtility.exe -FMT,PNG -DIV,1,3 -OUT,tmp\%%a %%a
F:\ACG\汉化工具\imageUtility059d\imageUtility.exe -FMT,PNG -CNT,1 -OUT,out\%%a tmp\*.png
del tmp\*.png
)
于是此目录下out文件夹里就是转换好的按钮图片了。
这样图像部分也大都处理好了。此外以后翻译脚本过程中碰到Ns难以实现的图片特效,例如黑白等,还需要修图。
四、脚本
脚本需要批量从日文编码转成中文的。网上肯定有“xx转换专家”这样的软件,不过我是用的Perl脚本。以后用到正则表达式的地方多得是,所以python和Perl还是选至少一种学了吧。
在scr.aos~文件夹创建scr.pl,输入以下内容:
use Encode;
use Encode::CN;
use Win32::File;
my @files = <*.txt>;
mkdir 'scr_GBK';
foreach my $file (@files) {
my $attr;
Win32::File::GetAttributes($file, $attr);
my $newfile = 'scr_GBK\\'.$file;
open OLD, $file or die "open $file failed: $!";
open NEW, ">$newfile" or die "open $newfile failed: $!";
while (my $line = <OLD>) {
$line = decode("Shift-JIS", $line);
print NEW encode("GBK", $line);
}
close OLD;
close NEW;
print "$file change to GBK\n";
}
这样就将所有文本转成了GBK编码。新文件保存在scr_GBK目录下。
五、OP
原OP是mpg编码的,文件将近200M。由于Onscripter本身支持的视频编码十分有限,折腾老半天,尝试的结果记下:
视频编码AVC的mp4是不支持的;avi用Ons测试出现崩溃;经过压缩的mpg出不了图画,就连原文件播放也有神奇的声音降调的效果(Nscripter2.96无此毛病),故最终舍掉了OP。
(二)摸索原脚本的结构
前期的资源处理之后,应该就是漫长的翻译脚本的工作了。首先得浏览转换出来的脚本,《九十九之月》的引擎名为“SFA”,如果有现成的SFA脚本翻译为Nscripte的脚本的程序就好了,可是搜了半天一点信息都没找到,所以得自己把脚本从一种语言翻译到另一种语言了。游戏脚本还是大同小异的,很多地方是一一对应的,可以用正则表达式批量处理,这里脚本的处理使用Perl,配合Notepad++。而有些地方例如构图就比较麻烦,需要各种猜函数和函数参数的意义。
一、从跳转标签开始
原脚本文件从00_01.txt到WND_NOV.txt,一共三百多个文件。注意到有start.txt,main.txt,start.txt是入口,对应Ns标签就是*start了;main.txt包含所有剧情的跳转关系。每个文件的结构大致如下:
猜得出来,SFA以#号为注释,执行区以::START开始,::END结束,^ret是函数返回。
############################################
# 打头注释
############################################
::START
执行区
^ret
::END
执行区有形如^gsb(init_spr)的语句,这里的^gsb()函数想必是调用其它文件,文件名就是init_spr.txt了,对应的返回就是^ret。还有:LABEL_00_00和^jp(LABEL_01_00)这样的语句,:LABEL_00_00就是跳转标签,相当于Ns的*LABEL_00_00;^jp(LABEL_01_00)就是相当于goto *LABEL_01_00了。
由此,脚本的跳转结构分析如下:一个文件名相当于一个函数,从start开始,通过^gsb()进入另一个文件,再通过^ret返回,等效的Ns语句是gosub *[文件名]和return,但是有些文件名第一个字是数字,不符合Ns标签的命名规则,所以统一在文件名转成的标签名前加下划线_,即00_01.txt在移植脚本中的标签是*_00_01。
PS:Nscipter标签跳转有两种方式:goto和gosub。前者是无条件跳转,后者跳转之后,碰到return会返回上层标签,有函数编程的特性。Ns对这两种跳转的标签不加区分,容易才造成结构混乱,所以另一方面,在gosub跳到的标前加下划线也是一个比较好的办法。
例如,有start.txt内容如下:
::START
gsb(main)
::END
有main.txt内容如下:
::START
^jp(LABEL_01_00)
:LABEL_01_00
^ret
::END
转换后等效的Ns脚本就是:
*_start
gosub *_main
;sub_end
*_main
goto *LABEL_01_00
*LABEL_01_00
return
;sub_end
以上转换利用Perl实现,程序的框架如下。程序脚本置于移植的工作目录./,而脚本文件夹重命名为scr_SFA,输出的文件在scr_ons文件夹。
$bname = 'title*'; #这里是要转换的脚本的文件名,可以用通配符
my @scr_dir = glob "./scr_SFA/$bname.txt";
open OUT,">./scr_ons/out.txt";
foreach $scr_name(@scr_dir)
{
open IN,'<'.$scr_name;
$bname = basename $scr_name;
open OUT,'>'.'./scr_ons/'.$bname;
$bname =~ s/(.+)\..+/$1/;
while(<IN>)
{
# 在这下面写替换的表达式
s/#/;/g;
s/::START/*_$bname/;
s/\^jp\((\w+)\)/goto *$1/;
s/^:(\w+)/*$1/;
s/\^ret/return/;
s/::END/;sub_end/;
print OUT;
}
}
Perl的文本替换运算形如s/\^ret/return/,可以写成s///;或s###等等,其中第一个字符串\^ret是一个regex,匹配这个然后替换成后面那个就是return。$1,$2等是捕获的变量在Notepad++等支持regex的文本编辑器中就是\1,\2等。不管用什么语言或软件做文本处理工具,替换文本所用的regex仍然是相似的。
二、选择结构
另有选择结构,SFA脚本是这样的:
^if(%iAkane >= %iYukina)# 茜と雪菜を比較
%day = 3
^else
%day = 4
^endi
这个比较麻烦,因为Ns脚本的if命令后面只跟一条命令,而且没有else这种东西。一开始,自己想到的几乎等效的命令如下:
if %iAkane >= %iYukina mov %day,3 : jumpf
mov %day,4
~
但是这样如果出现嵌套的if就麻烦了。。。自己也是移植了一小部分,发现如果正文中的if都要手动改的话简直是噩梦,于是得想办法通过Ns蹩脚的if和跳转标签,利用goto实现多行的,可嵌套的选择支。另有switch结构,Nscripter不支持多重选择故也得设法用其他方法实现。
先贴出最终解决问题的代码片段:
if(/^\s*\^/){
$flag++;
$flagj=1;
$flagj ++ if s/\^jp\(\s*(\w+)\s*\)/goto *$1/; #跳转至标签
if(/\^if\((.+)\)/){ #if
$jk++; #jk为if用到的块编号
push @js,$jk; #js为储存块编号的栈
#%jh为块编号当前次数
$jn=$jh{$jk}=1;
$_ = "if $1 goto *jump_${scr_name}_${jk}_${jn}_s\ngoto *jump_${scr_name}_${jk}_${jn}_e\n*jump_${scr_name}_${jk}_${jn}_s\n";
}
elsif(/\^else/){ #else
$jm=$js[-1];
$jn=$jh{$jm};
$_ = "goto *jump_${scr_name}_${jm}_end\n*jump_${scr_name}_${jm}_${jn}_e\n";
$jh{$jm}++;
$jn=$jh{$jm};
$_ = $_ . "*jump_${scr_name}_${jm}_${jn}_s\n";
}
elsif(/\^elif\((.+)\)/){ #elseif
$jm=$js[-1];
$jn=$jh{$jm};
$_ = "goto *jump_${scr_name}_${jm}_end\n*jump_${scr_name}_${jm}_${jn}_e\n";
$jh{$jm}++;
$jn=$jh{$jm};
$_ = $_ . "if $1 goto *jump_${scr_name}_${jm}_${jn}_s\ngoto *jump_${scr_name}_${jm}_${jn}_e\n*jump_${scr_name}_${jm}_${jn}_s\n";
}
elsif(/\^endi/){ #endif
$jm=pop @js;
$jn=$jh{$jm};
$_ = "*jump_${scr_name}_${jm}_${jn}_e\n*jump_${scr_name}_${jm}_end\n";
}
elsif(s/\^sw\((%\w+)\)/mov %swtmp,$1/){ #switch
$swt++;
$sws=1;
}
elsif(/\^case\((\d+)\)/){ #case
if($sws!=1){
$_ = "goto *sw_${scr_name}_${swt}_end\n*sw_${scr_name}_${swt}_${sws}_e\n";
}else{
$_ = "";
}
$sws++;
$_ = $_ . "if %swtmp=$1 goto *sw_${scr_name}_${swt}_${sws}_s\ngoto *sw_${scr_name}_${swt}_${sws}_e\n*sw_${scr_name}_${swt}_${sws}_s\n"
}
elsif(/\^df/){ #default
$_ = "goto *sw_${scr_name}_${swt}_end\n*sw_${scr_name}_${swt}_${sws}_e\n";
$sws++;
$_ = $_ . "*sw_${scr_name}_${swt}_${sws}_s\n";
}
elsif(/\^ends/){ #end switch
$_ = "*sw_${scr_name}_${swt}_${sws}_e\n*sw_${scr_name}_${swt}_end\n";
}
elsif($flagj==1){
$flagj=0;
}
$flag=0 if $flagj==0;
}
Perl的代码是write only的,要我过一个礼拜回过头来看也看不懂的,所以有必要写笔记。下面就趁着还没忘掉,赶快把想法和代码的意义记下来。
SFA中除了单选择支的^if-^endi,SFA中还有二选择支的^if_-^else-^endi以及多选择支的^if-^elif-^elif-^elif-……-^endi结构。我们需要写出通用的选择结构转换代码,为了区分不同文件,该文件下第几个if,该if块下第几个条件,我们需要以下变量:
记录文件名的$scr_name,这在Perl脚本一开头就用到了;
记录该文件中出现if次数的$jk(不要问我是怎么命名变量的,我也不记得了)
记录if嵌套的列表@js,作为栈,使用push和pop记录各嵌套层if的$jk编号;以及储存栈顶元素的临时变量$jm,省得每次都要敲$js[-1]。
哈希%jh(^if下紧跟着的为第一个,^else为第二个,若是elseif结构,每多一个^elif增加一个)。哈希(hash)就是盛有一堆“键值对”的桶子,一个哈希中键是不同的,相异的键各自对应一个值,例如学生的学号作为键,姓名作为值。学号是区分学生的标准,而姓名从学号检索得到,相不相同无所谓。在这里,键是第几个if,而值是当前if到了第几个选择支。之所以这么做,是因为执行到嵌套的if中时,有必要知道跳入的各层if的名字,用来命名跳转标签,否则跳出来之后就不知道原来是在第几个if的第几个选择支里了;还有储存当前if当前选择支的临时变量$jn,省得每次都得写$jh{$jm}。
if-end块的结束以“junp_文件名_当前是该文件下第几个if_end”标签以“junp_文件名_当前是该文件下第几个if_当前if的第几个条件_状态”命名,例如*jump_01_01_1_1_s代表文件01_01.txt第1个if第1个选择支的开始,*jump_01_08_2_4_e代表文件01_08.txt第2个if第4个选择支的结束,而整个选择结构的结束(^endi)则用*jump_01_08_2_4_s标记。
例如,01_01.txt下的SFA脚本:
^if(%DEBUG_ON == 1)# デバッグ用パラメータ(特殊なものが無い限り使用しない)※今回は選択肢のデバッグモードで使用
btnset(1,"slctwnd", 220, 220, "「箸の使い方を教える」 ツクモ好感度○", 52, 10)
btnset(2,"slctwnd", 220, 260, "「摩耶に教えさせる」 摩耶好感度○", 52, 10)
^else
btnset(1,"slctwnd", 220, 220, "「箸の使い方を教える」", 52, 10)
btnset(2,"slctwnd", 220, 260, "「摩耶に教えさせる」", 52, 10)
^endi
替换后变成:
if %DEBUG_ON == 1 goto *jump_01_01_1_1_s
goto *jump_01_01_1_1_e
*jump_01_01_1_1_s
btnset2 1,"slctwnd",220,220,"「箸の使い方を教える」 ツクモ好感度○",52,10
btnset2 2,"slctwnd",220,260,"「摩耶に教えさせる」 摩耶好感度○",52,10
goto *jump_01_01_1_end
*jump_01_01_1_1_e
*jump_01_01_1_2_s
btnset2 1,"slctwnd",220,220,"「箸の使い方を教える」",52,10
btnset2 2,"slctwnd",220,260,"「摩耶に教えさせる」",52,10
*jump_01_01_1_2_e
*jump_01_01_1_end
一开始用的实现switch的方法是用的jumpf和~。select结构,SFA脚本如下:
^sw(%sentaku)
^case(0)
^gsb(02_04a)
^case(1)
^gsb(02_04b)
^case(2)
^gsb(02_04c)
^case(3)
^gsb(02_04d)
^df
エラー01
^ends
而Ns没有select结构,故也需要用if实现,以下命令可以替换只有一个select结构的情况:
s/\^sw\(%(\w+)\)//mg;
$varname = $1;
s/\^case\((\d+)\)\s+\^gsb\((\w+)\)/if $varname=$1 gosub *_$2:jumpf/mg;
s/\^df/;$&/;
s/\^ends/~/;
对应的Ns脚本就是这样:
if %sentaku=0 gosub *_02_04a:jumpf
if %sentaku=1 gosub *_02_04b:jumpf
if %sentaku=2 gosub *_02_04c:jumpf
if %sentaku=3 gosub *_02_04d:jumpf
;^df
エラー01
~
但是~号隔得太远总让人提心吊胆的,怕中间出现别的~。中途也确实出现了这样的bug,测试时到了最后的staff部分时出现了奇怪的跳转,原来是本该跳到~的地方跳到中间的注释去了,于是出来一串文字“りんご”,应该是一个staff的名字。在上面的Perl脚本中包含了解决此问题的复杂版本。但是由于一开始的标题(title)和故事走向(main)是半手动做的,没有用上。
(三)图像、声音和文本
一、区分已知和未知语句
脚本移植的基本思路是使用Perl脚本批量转换,从第一个文件开始,找出SFA脚本中未知意义的语句,通过Perl脚本中正则表达式匹配替换,以及在Ons脚本中添加自定义函数得到等价的Ons脚本。例如,显示背景的bgon("title")替换为Ns的自定义过程bgon "title",实现这个的Perl语句为:
s#bgon\("(\w+)"\)#bgon "$1"#;并且在Ons脚本里加上
*bgon
getparam $1
lsplt 999,"grp/"+$1+".jpg"
return
为了检索翻译得到的Ons脚本中还有哪些未知的命令,所以我们在每一条正则表达式前加上一个标记,如果匹配成功,$flag++,然后直到脚本末尾时若$flag的值仍然为0,则把这一行暂时注释掉,并添加<unknown>标签:
if($flag==0){
$dkt++ if s/^(\s*[!-:<-~].*$)/;<unknown>$1/;
}
而原来的替换bgon的语句就改成:
$flag ++ if s#bgon\("(\w+)"\)#bgon "$1"#;
这样就可以用Notepad++查找得到的Ons脚本中那些行是未知的。
重复"猜命令意义->写对应的Ons自定义过程->在Perl脚本中添加正则表达式->执行新的Perl脚本->查找剩下的<unknown>语句"这一过程。
如果出现实在没法解决<unknown>语句,并且哌啶此语句存在与否并不会影响整个游戏,那么可以将其忽略掉,例如,下列语句忽略了下雨特效:
$flag ++ if s/^(\s*fall\(.*\))/;<ignore>$1/;
这样搜索文本中的<unknown>时就不会出现这些语句了。
二、背景、BGM、语音和音效
接下来就是寻找SFA脚本中一些常用的功能命令长什么样,并想办法将其替换成Ns的命令。从标题开始:
首先有个背景图片,SFA中的语句是bgon("bk_title")这样,后面可能跟着 ud(1, 500),这应该是指显示背景的效果,第一个是效果,1应该是淡入淡出,500应该是延时500秒;而ud(2, "k_rht00", 2000)则是使用掩模淡入淡出。
然后便着手重写print。SFA脚本参数可变,就像C++和Java的重载。而Ns自定义过程是不支持重载的,也不支持只写一个过程从参数判定——如果传入参数少于getparam的就会报错,反之多余的参数会作为文本输出而成为乱码。故需要用Perl识别出来并转成不同的函数。例如,立即显示的ud(0)转换成自定义函数ud_0,内容为print 1;而ud(1000)替换成ud_1(1000),然后在ud_1中根据持续时间(1000ms)找到对应的effect编号并print;而ud(2)则是遮片效果。后来在自己爪机上测试发现掩模效果确实够卡。。。附上ud的部分代码:
;-----重写print-----
*ud_0 #立即显示
print 1
return
*ud_1 #淡出淡出
getparam %udtmp
print 10,%udtmp
return
*ud_2 #掩模淡入淡出
getparam $1,%2
mov $1,"grp/"+$1+".jpg"
print 18,%2,$1
return<div><p><span data-blogger-escaped-style="color: #0070c0; font-family: 宋体; font-size: 10.5pt;">*ud_3 #类似电视跳台时画面撕裂效果,Ns做不出来</span></p></div><div><p><span data-blogger-escaped-style="color: #0070c0; font-family: 宋体; font-size: 10.5pt;">getparam %udtmp</span></p></div><div><p><span data-blogger-escaped-style="color: #0070c0; font-family: 宋体; font-size: 10.5pt;">ud_1 %udtmp ;未完成</span></p></div><div><p><span data-blogger-escaped-style="color: #0070c0; font-family: 宋体; font-size: 10.5pt;">return</span></p></div>
回到背景的制作。bgon可以用Ns自带的函数bg实现,但是移植过程中发现用bg就不能使背景和立绘一块渐入,所以用lsp 999代替,使用自定义函数bgon。lsp的编号是序号小的优先,例如1号lsp在画面中会覆盖到2号lsp的上面。
有bgon就有bgoff。这两者都不直接print,等待ud命令刷新画面。
SFA中有一个特殊的效果wout(white out),内容为画面曝光度增加至全白,这个用Ons难以实现,改成用块纯白的图片遮住。但是wout并不会擦除任何图片和立绘,所以这块白图就定为lsp 998.
类似地,还有控制语音的cvon和cvoff,控制音效的seon和seoff。其中SFA的seon可以指定播放次数,而Ons做不到,一概播放一次算了= =;还有环境音效envon和envoff,这是循环播放的。这些声音使用自定义函数,由不同的wave通道控制。
三、按钮的实现
SFA中有下列几个按钮的函数:
benset:定义按钮,有两种重载方式,即图片按钮与文本选项时带有文字的图片按钮。其中带文字的按钮用两个lsp叠加实现。SFA中的btnset分别替换成Ons的自定义过程tnset和btnset2。由于Ns的按钮编号记录在btnwait后会被擦除,而btnwait2虽然不会擦除定义,但用Ons模拟器测试发现如果在选项出存档,读档丢失btn定义丢失,导致游戏进度卡在这里。这里想了个办法:开数组自行储存按钮定义。
btnon:启用按钮,对于Ons,相当于过一遍spbtn,用自定义的btnrec过程实现。
btnenable:显示按钮,可用ud_0实现。
另外。SFA中按钮判定不需要循环,所以btnwait的循环还是要手动加的。
最后,在跳出时记得擦除按钮定义,这里用自定义过程btnclr。
这一部分的代码如下:
;-----设置按钮-----
*btnset
getparam %17,$3,%18,%19
lsp %17,":a/3,0,3;grp/"+$3+".png",%18,%19
mov ?2[%17][0],%17
return
*btnset2
getparam %17,$3,%18,%19,$4,%20,%21
lsp %17+btn2const,":a/3,0,3;grp/"+$3+".png",%18,%19
lsp %17,":s/28,28,0,2;"+$stxtcolor+$4,%18+%20,%19+%21-4
mov ?2[%17][0],%17+btn2const
return
*btnrec
for %0=0 to 20
if ?2[%0][0]!=0 spbtn ?2[%0][0],%0
next
return
*btnclr
btndef clear
for %0=0 to 20
if ?2[%0][0]!=0 csp ?2[%0][0]:csp %0
mov ?2[%0][0],0
next
return
四、文本与对话框
这一块在原脚本中没有找到完整的由WND_NOVADV.txt控制,主要用到的函数名为wnd,其他地方都没有出现,没有必要用正则表达式,直接重写这一块就是。
对话框上的按钮原本是在定义区使用windowchip绑定使其与对话框同现同消,但发现Ons对这一块的支持存在混乱,所以也得重写texton和textoff,并命名为showwnd和hidewnd,其中hidewnd对应SFA中的wndoff,而SFA中窗口的出现是自动的,这就只能寄希望后面会讲到的textgosub *sys_first,让每段文本显示完毕都会调用*sys_first,并在此过程中加上showwnd,否则读档后对话框上的按钮会一直失效。
;-----绘制窗口-----
*setwnd
setwindow 208,484,22,4,28,28,0,4,20,0,1,":a;grp/msgwnd1.png",148,432 ;原脚本是22号字,但是这样在爪机上显示太小,故改为28号字
textbtn
return
*textbtn
lsph 8,":a/3,0,3;grp/mlsskip.png",571,452
lsph 9,":a/3,0,3;grp/mlsauto.png",640,452
lsph 10,":a/3,0,3;grp/mlslog.png",700,452
lsph 13,":a/18,70,0;grp/fprompt.png",786,560
lsph 14,":a/3,0,3;grp/mlsqsave.png",832,463
lsph 15,":a/3,0,3;grp/mlsqload.png",832,488
lsph 16,":a/3,0,3;grp/mlssave.png",832,513
lsph 17,":a/3,0,3;grp/mlsload.png",832,536
lsph 18,":a/3,0,3;grp/mlscnfg.png",832,563
;lsph 10,":a/3,0,3;grp/namewnd1.png",148,432 ;似乎并没用到这个
print 1
return
;-----显示窗口-----
*showwnd
texton
vsp 8,1
vsp 9,1
vsp 10,1
vsp 12,1
vsp 13,1
vsp 14,1
vsp 15,1
vsp 16,1
vsp 17,1
vsp 18,1
spbtn 8,8
spbtn 9,9
spbtn 10,10
spbtn 14,14
spbtn 15,15
spbtn 16,16
spbtn 17,17
spbtn 18,18
for %21=1 to 7
vsp %21,1
vsp %21+btn2const,1
next
print 1
return
;-----隐藏窗口-----
*hidewnd
textoff
vsp 8,0
vsp 9,0
vsp 10,0
vsp 12,0
vsp 13,0
vsp 14,0
vsp 15,0
vsp 16,0
vsp 17,0
vsp 18,0
for %21=1 to 7
vsp %21,0
vsp %21+btn2const,0
next
print 1
return
文本的显示需要自定义的地方包括名字标签,使用pretextgosub;还有句子结束处的@和\,使用textgosub。这一块就不贴上来了。脚本我已放出,在自己网盘的分享中:链接:pan.baidu.com/s/1ntkOYKT ,PW:hvbp。
五、重写与立绘相关的过程
Ns中用ld显示立绘只有左中右三个,而如果有4个人物,或者人物有移动特效等等,ld就无能为力了。所以所有立绘都要基于lsp重写。
包括下列函数:
chon - 立绘的载入,例如chon(1, "ta01aac1", 512, 0的意思是在(512, 0)位置载入文件名为"ta01aac1"的立绘,编号为1。
choff - 立绘的擦除 - choff(1)是擦除1号立绘,而choff(-1)是擦除所有立绘
efex - 立绘的移动 - 有两种,一种是现成立绘移动,另一种是载入立绘并移动,efex的第一个参数若为正,则指代待移动立绘的编号,。移动不立即执行,需要遇到efex(-1, 200)这样的语句才会执行。
ef - 立绘的震动效果
主要就是这几个。SFA立绘与Ns的lsp主要区别在于,①编号大的会重叠在在编号小的之上,这与Ns的次序是相反的;②立绘的横坐标是图片中心的横坐标,这样转换坐标时有点麻烦,需要先用lsp载入图片,再获取其尺寸,再将横坐标减去1/2*图片宽度得到左边缘的坐标。
替换用Perl脚本片段如下:
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*CENTER\s*\)/chon_c $1,$2/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*NON\s*\)/chon $1,$2/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*(\d+),\s*(\d+)\s*\)/chon_d $1,$2,$3,$4/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*(\d+)\s*\)/chon_d $1,$2,$3,0/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*CENTER,\s*(\d+)\s*\)/chon_d $1,$2,512,$3/;
$flag ++ if s/choff\(\s*(-?\d+)\)/choff $1/;
$flag ++ if s#efex\((\d+),\s*\d+,\s*(\d+),\s*(\d+)\)#efex $1,$2,$3\r\nefexon $1,200#;
$flag ++ if s#efex\((\d+),\s*12,\s*(\d+),\s*(\d+),\s*("\w+"),\s*(\d+),\s*(\d+)\s*\)#efex2 $1,$2,$3,$4,$5,$6\r\nefexon $1,200#;
$flag ++ if s#efex\(-1,\s*(\d+),\s*(\d+)\s*\)#;efexon $1,$2#; #由于实现上的困难,改成依次移动
$flag ++ if s#ef\(\s*(-?\d+),\s*\d+,\s*(\d+),\s*(\d+),\s*(\d+)\)#ef $1,$2,$3,$4#;
Ons脚本中相关自定义过程如下:
;-----从原立绘编号返回Ons立绘编号(chconst-原编号),由于SFA中是编号大的在上,和Ns是反的-----
*getchnum
getparam %chtmp
mov %chtmp,chconst-%chtmp
return
;-----checkalpha-判定此立绘的透明模式是copy还是alpha-----
*checkalpha
getparam $alphatmp
if $alphatmp="black" mov $alphatmp,":c;":goto *checkalpha_end
if $alphatmp="white" mov $alphatmp,":c;":goto *checkalpha_end
split $alphatmp,"_",$15,$16
if $15="ic" mov $alphatmp,":c;":goto *checkalpha_end
mov $alphatmp,":a;"
*checkalpha_end
return
;-----显示立绘-----
*chon ;对应于chon(3, "ta01aaa2", NON)
getparam %chontmp,$chonstmp
getchnum %chontmp
checkalpha $chonstmp
lsp %chtmp,$alphatmp+"grp/"+$chonstmp+".jpg",?0[%chontmp][0],?0[%chontmp][1]
return
*chon_d ;对应于chon(1, "ta01aac1", 512, 0),横坐标是立绘中心的坐标
getparam %chontmp,$chonstmp,%11,%12
getchnum %chontmp
checkalpha $chonstmp
lsp %chtmp,$alphatmp+"grp/"+$chonstmp+".jpg",0,0
getspsize %chtmp,%13,%14
mov ?0[%chontmp][0],%11-%13/2
mov ?0[%chontmp][1],%12
mov ?1[%chontmp][0],?0[%chontmp][0]
mov ?1[%chontmp][1],?0[%chontmp][1]
amsp %chtmp,?0[%chontmp][0],?0[%chontmp][1] ;原脚本中chon的参数是立绘中心的横坐标
return
*chon_c ;对应于chon(5, "white", CENTER),立绘在中间
getparam %chontmp,$chonstmp
getchnum %chontmp
checkalpha $chonstmp
lsp %chtmp,$alphatmp+"grp/"+$chonstmp+".jpg",0,0
getspsize %chtmp,%13,%14 ;开了两个数组,分别储存立绘现在的横纵坐标,和将要移动到位置的横纵坐标,在efex中要用到
mov ?0[%chontmp][0],(%width-%13)/2
mov ?0[%chontmp][1],(%height-%14)/2
mov ?1[%chontmp][0],?0[%chontmp][0]
mov ?1[%chontmp][1],?0[%chontmp][1]
amsp %chtmp,?0[%chontmp][0],?0[%chontmp][1]
*chon_c_end
return
;-----擦除立绘和CG-----
*choff
getparam %chofftmp
if %chofftmp=-1 goto *choff_m1;立ちCG消去
if %chofftmp=18 goto *choff_m1;没弄懂原脚本中参数18指什么,姑且认作全消
getchnum %chofftmp
csp %chtmp
mov ?0[%chofftmp][0],0
mov ?0[%chofftmp][1],0
goto *choff_end
*choff_m1
getchnum 1
for %1=1 to 20
csp %chtmp+1-%1
mov ?0[%1][0],0
mov ?0[%1][1],0
mov ?1[%1][0],0
mov ?1[%1][1],0
next
*choff_end
return
;-----元素震动时的特效-----
*ef
getparam %efexn,%efexa,%efexb,%efext
isskip %0
if %0=1 goto *ef_end
if %efexn!=-1 jumpf
if %efexa=0 quakey %efexb/2,%efext:goto *ef_end
if %efexb=0 quakex %efexa/2,%efext:goto *ef_end
quake (%efexa+%efexb)/4,%efext
goto *ef_end
~
getchnum %efexn
mov %efexi,%efext/20
for %efext=1 to %efexi
mov %11,%efext mod 4
if %11=1 mov %efexc,0-%efexa:mov %efexd,%efexb
if %11=2 mov %efexc,0-%efexa:mov %efexd,0-%efexb
if %11=3 mov %efexc,%efexa:mov %efexd,0-%efexb
if %11=4 mov %efexc,%efexa:mov %efexd,%efexb
;wait 10
amsp %chtmp,?0[%efexn][0]+%efexc,?0[%efexn][1]+%efexd
print 1
next
amsp %chtmp,?0[%efexn][0],?0[%efexn][1]
print 1
*ef_end
return
;-----立绘移动的特效-----
*efex
getparam %efexn,%efexa,%efexb
getchnum %efexn
getspsize %chtmp,%11,%12
mov ?1[%efexn][0],%efexa-%11/2
mov ?1[%efexn][1],%efexb
return
*efex2
getparam %efexn,%efexa,%efexb,$chonstmp,%efexc,%efexd
chon_d %efexn,$chonstmp,%efexc,%efexd
efex %efexn,%efexa,%efexb
return
*efexon
;执行立绘移动,50ms刷新一次——但是在渣机上测试发现卡的要死,故把wait注释掉了
getparam %efexn,%efext
isskip %30
if %30=1 goto *efexon_skip
mov %efexi,%efext/50
mov %efexa,(?1[%efexn][0]-?0[%efexn][0])/%efexi
mov %efexb,(?1[%efexn][1]-?0[%efexn][1])/%efexi
for %efexc=1 to %efexi-1
getchnum %efexn
amsp %chtmp,?0[%efexn][0]+%efexa*%efexc,?0[%efexn][1]+%efexb*%efexc
print 1
;wait 50
next
*efexon_skip
mov ?0[%efexn][0],?1[%efexn][0]
mov ?0[%efexn][1],?1[%efexn][1]
getchnum %efexn
amsp %chtmp,?1[%efexn][0],?1[%efexn][1]
print 1
return
立绘的移动和震动在PC上试验无任何问题,而自己的爪机上卡顿严重,故索性把其中的wait注释掉,不控制时间了。
六、其它特效
剩下的基本上就是写零散的特效了,能实现就实现,不能就ignore掉。在检查了最初的几个脚本后,发现接下来的文件中unknown的语句越来越少,然后只需更改Perl脚本中输入文件控制变量$bname的内容,可一次输出全部文本。
附:完整的将SFA翻译成Ons的Perl脚本
#!/usr/bin/perl
use File::Basename;
$bname = '*_*';
my @scr_dir = glob "./scr_GBK/$bname.txt";
open OUT,">./scr_ons/out.txt";
foreach $scr_name(@scr_dir)
{
$scr_name =~ s#^.*/(\w+)\.txt#$1#;
open IN,"<./scr_GBK/$scr_name.txt";
$dkt=0; #Unknown command count
$jk=0; #if用到的块编号
@js=(); #储存块编号的栈
undef %jh;
$swt=0; #多重选择用到的块编号
$sws=0; #当前多重选择第几项
while (<IN>){
$flag=0;
$fwc=0; #first word id ;
s/#/;/g;
unless(/^\s*;/ or /^\s*$/){ #是注释行则跳过
if(s/^(s*(?:\[|[\x80-\xfe]+).*)(;*.*)$/$1\\$2/){ #是文本行
s/\?/?/g if $flag==0; #修复半角问号
}
else{ #是指令行
$flag ++ if s/(%\w+)\s*=\s*(%\w+|\d+)/mov $1,$2/;
$flag ++ if s/(%\w+)\s*\+=\s*(%\w+|\d+)/mov $1,$1+$2/;
$flag ++ if s/::START/*_$scr_name/;
$flag ++ if s/\^ret/return/;
$flag ++ if s/::END/;sub_end/;
$flag ++ if s/\^gsb\((\w+)\)/gosub *_$1/;
$flag ++ if s#btnset\((\d+),\s*("\w+"),\s*(\d+),\s*(\d+)\)#btnset $1,$2,$3,$4#;
$flag ++ if s#btnset\((\d+),("\w+"),\s*(\d+),\s*(\d+),\s*(".*"),\s*(\d+),\s*(\d+)\)#btnset2 $1,$2,$3,$4,$5,$6,$7#;
$flag ++ if s#btnon\(ALL\)#;btnrec#;
$flag ++ if s#bgon\("(\w+)"\)#bgon "$1"#;
$flag ++ if s#bgoff#bgoff#;
$flag ++ if s#cgon\("(\w+)"\)#cgon "$1"#;
$flag ++ if s#filter\(\d+,\s*("\w+"),\s*(\d+),\s*(\d+)\s*\)#filteron $1,$2,$3#;
$flag ++ if s#filter\(\d+\)#filteroff#;
$flag ++ if m/^\s*wout/;
if(/wout\(\s*(\d+)\s*\)/)
{
if($1<=2000){$_="wout\r\n";}
else {$_="woutlong\r\n";}
$flag ++;
}
$flag ++ if s#ud\(0\)#ud_0#;
$flag ++ if s#ud\(1,\s*(\d+)\)#ud_1 $1#; #淡入淡出效果 例:ud(1, 200)
$flag ++ if s#ud\(2,\s*"(\w+)",\s*(\d+)\)#ud_2 "$1",$2#; #遮罩效果 例:ud(2, "k_rht00", 2000)
$flag ++ if s#ud\(31,\s*(\d+)\)#ud_4 $1#; #“变身”
$flag ++ if s#ud\(38,\s*(\d+)\)#ud_3 $1#; #Noise out效果
$flag ++ if s#wndoff#hidewnd#;
$flag ++ if s/bgmon\(("\w+")\)/bgmon $1/;
$flag ++ if s/bgmon\(("\w+"),\s*\d\)/bgm1 $1/;
$flag ++ if s/bgmoff.*/bgmstop/;
$flag ++ if s/mono\(\s*0\s*\)/monocro #ffe8c8/; #暂不知mono(0)是什么
$flag ++ if s/mono\(\s*1\s*\)/monocro #ffe8c8/;
$flag ++ if s/mono\(\s*4\s*\)/nega 1/;
$flag ++ if s/mono\(\s*-1\s*\)/monocrooff/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*CENTER\s*\)/chon_c $1,$2/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*NON\s*\)/chon $1,$2/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*(\d+),\s*(\d+)\s*\)/chon_d $1,$2,$3,$4/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*(\d+)\s*\)/chon_d $1,$2,$3,0/;
$flag ++ if s/chon\(\s*(\d+),\s*("\w+"),\s*CENTER,\s*(\d+)\s*\)/chon_d $1,$2,512,$3/;
$flag ++ if s/choff\(\s*(-?\d+)\)/choff $1/;
$flag ++ if s#efex\((\d+),\s*11,\s*(\d+),\s*(-?\d+)\)#efex $1,$2,$3#; #动了之后不消失
$flag ++ if s#efex\((\d+),\s*12,\s*(\d+),\s*(-?\d+)\)#efex $1,$2,$3#; #动了之后不消失
$flag ++ if s#efex\((\d+),\s*13,\s*(\d+),\s*(-?\d+)\)#efex3 $1,$2,$3#;#动了之后消失
$flag ++ if s#efex\((\d+),\s*12,\s*(\d+),\s*(\d+),\s*("\w+"),\s*(\d+),\s*(-?\d+)\s*\)#efex2 $1,$2,$3,$4,$5,$6#;
$flag ++ if s#efex\(-1,\s*(\d+),\s*(\d+)\s*\)#;efexon $1,$2#; #由于实现上的困难,前面都改成依次移动
$flag ++ if s#ef\(\s*(-?\d+),\s*\d+,\s*(\d+),\s*(\d+),\s*(\d+)\)#ef $1,$2,$3,$4#;
$flag ++ if s/cvon\(\s*("\w+")\s*\)/cvon $1/;
$flag ++ if s/cv\(""\)/cvoff/;
$flag ++ if s/seon\(\s*("\w+")\s*\)/seon $1/;
$flag ++ if s/seon\(\s*("\w+")\s*,\s*\d+\s*,\s*\d+\s*\)/seon $1/; #无论原来播放几遍,都只能做到播一遍。。。
$flag ++ if s/seoff\(.*\)/seoff/;
$flag ++ if s/envon\(("\w+")\s*\)/envon $1/;
$flag ++ if s/envoff\(.*\)/envoff/;
$flag ++ if s/saveenable/saveon/;
$flag ++ if s/loadenable\r\n//;
$flag ++ if s#wait\((\d+)\)#wait $1#;
$flag ++ if s/clrbacklog/lookbackflush/;
$flag ++ if s/pause/click/;
$flag ++ if s/global\(\s*%(\w+),\s*1\s*\)/mov \%$1,1/;
$flag ++ if s/global\(\s*%(\w+),\s*-1\s*\)/mov \%reg,\%$1/;
$flag ++ if s/backtitle/return/;
$flag ++ if s/^:(\w+)/*$1/; #跳转标签
if(/float\(\s*(\d+)\s*\)/){
$flag ++;
$_ = "gosub *save\n" if $1==1;
$_ = "gosub *load\n" if $1==2;
$_ = "gosub *cgmode\n" if $1==3;
$_ = "gosub *hswitch\n" if $1==4;
$_ = "gosub *bgmmode\n" if $1==5;
$_ = "gosub *qsave\n" if $1==7;
$_ = "gosub *qload\n" if $1==8;
$_ = "gosub *config\n" if $1==9;
}
if(/^\s*\^/){
$flag++;
$flagj=1;
$flagj ++ if s/\^jp\(\s*(\w+)\s*\)/goto *$1/; #跳转至标签
if(/\^if\((.+)\)/){ #if
$jk++; #jk为if用到的块编号
push @js,$jk; #js为储存块编号的栈
#%jh为块编号当前次数
$jn=$jh{$jk}=1;
$_ = "if $1 goto *jump_${scr_name}_${jk}_${jn}_s\ngoto *jump_${scr_name}_${jk}_${jn}_e\n*jump_${scr_name}_${jk}_${jn}_s\n";
}
elsif(/\^else/){ #else
$jm=$js[-1];
$jn=$jh{$jm};
$_ = "goto *jump_${scr_name}_${jm}_end\n*jump_${scr_name}_${jm}_${jn}_e\n";
$jh{$jm}++;
$jn=$jh{$jm};
$_ = $_ . "*jump_${scr_name}_${jm}_${jn}_s\n";
}
elsif(/\^elif\((.+)\)/){ #elseif
$jm=$js[-1];
$jn=$jh{$jm};
$_ = "goto *jump_${scr_name}_${jm}_end\n*jump_${scr_name}_${jm}_${jn}_e\n";
$jh{$jm}++;
$jn=$jh{$jm};
$_ = $_ . "if $1 goto *jump_${scr_name}_${jm}_${jn}_s\ngoto *jump_${scr_name}_${jm}_${jn}_e\n*jump_${scr_name}_${jm}_${jn}_s\n";
}
elsif(/\^endi/){ #endif
$jm=pop @js;
$jn=$jh{$jm};
$_ = "*jump_${scr_name}_${jm}_${jn}_e\n*jump_${scr_name}_${jm}_end\n";
}
elsif(s/\^sw\((%\w+)\)/mov %swtmp,$1/){ #switch
$swt++;
$sws=1;
}
elsif(/\^case\((\d+)\)/){ #case
if($sws!=1){
$_ = "goto *sw_${scr_name}_${swt}_end\n*sw_${scr_name}_${swt}_${sws}_e\n";
}else{
$_ = "";
}
$sws++;
$_ = $_ . "if %swtmp=$1 goto *sw_${scr_name}_${swt}_${sws}_s\ngoto *sw_${scr_name}_${swt}_${sws}_e\n*sw_${scr_name}_${swt}_${sws}_s\n"
}
elsif(/\^df/){ #default
$_ = "goto *sw_${scr_name}_${swt}_end\n*sw_${scr_name}_${swt}_${sws}_e\n";
$sws++;
$_ = $_ . "*sw_${scr_name}_${swt}_${sws}_s\n";
}
elsif(/\^ends/){ #end switch
$_ = "*sw_${scr_name}_${swt}_${sws}_e\n*sw_${scr_name}_${swt}_end\n";
}
elsif($flagj==1){
$flagj=0;
}
$flag=0 if $flagj==0;
}
$flag ++ if s/^(\s*memo\("\s*"\))/;<ignore>$1/; #忽略的指令
$flag ++ if s/^(\s*anmnum\(.*\))/;<ignore>$1/;
$flag ++ if s/^(\s*fall\(.*\))/;<ignore>$1/;
$flag ++ if s/^(\s*raster\(.*\))/;<ignore>$1/;
$flag ++ if s/^(\s*ch\()/;<ignore>$1/;
if($flag==0){
$dkt++ if s/^(\s*[!-:<-~].*$)/;<unknown>$1/;
}
}
}
# s///;
print OUT;
}
close IN;
print "$scr_name: $dkt lines unknown\r\n";
}
close OUT;
本文详细记录了将基于SFA引擎的Galgame资源和脚本移植到Onscripter的过程,涉及素材提取、语音压缩、图片处理、脚本结构分析及转换等多个步骤。作者通过Perl脚本实现了从SFA到Ons的转换,并分享了遇到的问题及解决方案,旨在为同类移植工作提供参考。

&spm=1001.2101.3001.5002&articleId=45153227&d=1&t=3&u=34658f2b31a948baaec7270fbaca605e)
2482

被折叠的 条评论
为什么被折叠?



