三国群英2吧 关注:45,931贴子:1,015,567

【脚本基础教程】第十章:特殊武将技和深入话题

只看楼主收藏回复

一楼


IP属地:美国1楼2020-04-19 12:32回复
    我们已经探讨了magic脚本的大部分内容。在本章的前半段,我们将补全一些有特殊作用的,但是目前尚未探讨的武将技效果,包括八门金锁、御飞刀、命疗术和十面埋伏的实现;在本章的后半段,我们将介绍一些深入的话题,包括三国2脚本运行时的底层细节,以及对脚本编译器的配置。


    IP属地:美国2楼2020-04-19 12:34
    回复
      2026-01-19 01:15:37
      广告
      不感兴趣
      开通SVIP免广告
      一、特殊武将技
      1. 八门金锁
      事实上,八门金锁的实现方法我们在很久以前就已经接触过了:我们在几乎每一个武将技的开头(除了毒箭),都使用了AddAttackCounter函数将主将定身。这个函数实际上可以被应用到所有主将和士兵物件上。
      笔者直接给出原版八门金锁的部分代码。这里,第二个参数指定了定身的时间;不过,注意的是这个函数接受的时间单位不是Tick,而是“定身基本单位”,一个基本单位等于40 Tick。这个也已在之前有所说明。原版八门金锁定身的时间为25个单位,等于1000 Tick,等于约8.3个回合。

      2. 御飞刀
      在原版中,御飞刀是可以打掉对方集气的唯一的武将技。投掷御飞刀的方法和“毒箭”武将技中非常类似;唯一的区别是,在击中对方武将时,额外使用DecreaseGatherTick函数来削减对方的集气槽。该函数的定义如下:
      void DecreaseGatherTick (int majorObject, int percentage);
      该函数指定第一个参数为主将物件,第二个参数为打掉集气的百分比(100为打掉全部集气槽)。可以为负数,此时为增加集气。
      (这个奇怪的名字是注册起的。原因么,我猜是将“集”直译成了Gather,于是集气时间干脆就译成了GatherTick……讲真,要从GatherTick这个名字想到集气,确实需要一点想象力。
      毫不意外地,原版中对该系统函数的使用只有一次,出现在ShootObjectCallback回调函数中,用来为御飞刀打掉对方一半的集气槽:
      DecreaseGatherTick(target, 50);
      3. 命疗术与回天术
      回血技能的效果由以下两个系统函数提供:
      int GetMajorHP (int isLeft);
      void SetMajorHP (int isLeft, int hp);
      其中第一个参数指定了所属方是左方还是右方。因此,回血实现的机制也就很明确了:先使用GetMajorHP得到当前血量,然后每过一段时间设置一个新的血量。在此不作赘述。
      4. 十面埋伏
      十面埋伏的机制也是明确的:
      void DisableEscape (int isLeft);
      该函数使得指定的一方无法退兵。


      IP属地:美国本楼含有高级字体3楼2020-04-19 12:37
      收起回复
        能不动EXE,实现这么多功能,
        老师真神人也。666


        IP属地:江苏来自Android客户端4楼2020-04-19 12:37
        收起回复
          二、多个so文件
          (为防抽,请自行将中文的“点”视作英文的 “." )
          到目前为止,我们所生成的so文件全部都是magic点so文件。如果读者对之前注册时代有一些印象的话,会注意到,原版实际上支持多个so文件,并不仅限于magic点so和system点so。如果需要增加so文件,需在Sango点ini中进行注册(这一部分原版下在Terrain节之后,Item节之前):
          [SCRIPT]
          File = script\system点so
          File = script\magic点so
          在使用多个so文件时,应当注意以下问题:
          1. 多个so文件的调用代码是同一列表,互相之间会产生冲突。例如,当两个so文件中存在两个不同的、但调用代码均为25001的函数时,指定调用代码为25001会调用两个函数之一,另一个函数的调用代码则无效。这意味着:
          a) 当使用DoScript()或SetCallbackProcedure()系统函数指定调用代码时,目标函数不必和当前函数同属一个so文件;
          b) 应尽量避免不同so文件中调用代码冲突的情况。
          2. 多个so文件的函数列表也是同一列表(!);使用异步调用、Wait和IsRunning语句时,不同so的重名函数会产生冲突。这是一个很不直觉的现象:假设a点so和b点so中都有函数A(),则在任意so文件中asynccall A()时,很有可能调用另一个so文件当中的A()函数。和调用代码类似,由于编译器无从得知其它so文件中的函数名,编译器并不会(也不可能)对这类冲突发出警告,因此请务必注意不同so文件之间的函数名冲突问题。读者可以查看magic点cpp和system点cpp中的函数定义,以检查自己定义的函数是否和另一个so文件中的已有函数重名。
          不过,使用正常方式调用函数时,总是调用当前so文件中的函数。
          基于以上理由,除非使用第三方现有的so文件,或者意图将单独的、包括一部分新武将技的so文件进行分享,否则笔者通常建议避免将magic或system脚本拆分为多个so文件。
          一种可能的使用多个so文件的理由是,源代码文件过于冗长,希望建立多个源代码文件。这种情况下,可以使用include的方法解决问题,例如,可以为每个武将技系列单独建立一个cpp文件,在主cpp文件中将这些子cpp文件include进来;编译时只需编译主cpp文件即可。
          当然,如果读者确信能避免冲突,或者遇到了必须拆分为多个so文件的情况,使用多个so文件总是实践上可行的。


          IP属地:美国本楼含有高级字体5楼2020-04-19 12:42
          回复
            三、脚本运行时与EXE
            我们讨论一些深入的底层话题。
            三国2脚本是在群2自带的一个特殊的“虚拟机”上运行的。因此,一般来说,我们将三国2脚本运行的环境和脚本以外的环境区分开;它们之间是一道“透明”的“墙”,互相之间是半独立的。
            下面的图显示了脚本运行时和EXE之间的关系。当然,需要澄清的是,脚本运行时当然不是独立于EXE运行时存在的,它当然是EXE运行时的一部分;但是,由于通过EXE调用脚本和通过脚本调用EXE都需要经过特定的途径,脚本部分和非脚本部分的运行也是相互半独立的,因此,将它们区分开来有助于理解,并且笔者也习惯于使用这样的区分法。

            在EXE中控制脚本的运行,一般是采用调用脚本和设置预设变量的方式。由于本教程是脚本教程,笔者对EXE的所知也有限,因此笔者不打算过多地讨论这条通路;一般也很少在EXE中直接干预脚本的运行——因为脚本的编译是较容易的,而EXE的修改是较困难的,改脚本总是较省事。
            反过来,在脚本中,不可避免地需要对EXE进行操作,因为本质上,图形物件、主将、士兵等诸多内容都是运行在EXE中的。奥汀官方提供的渠道是通过系统函数。没有系统函数的脚本是没有意义的。
            为了使得在脚本中操作EXE方便,自注册以降,诞生了许多的“内存操作”。这是一条额外添加的“隐秘通路”;通过这种方法,我们可以直接从脚本中获取和修改EXE运行时的内容,而不局限于奥汀官方提供的极为有限的系统函数。(不过,在1.05+中,几乎所有的内存操作本质上都是由一系列新增的系统函数实现的。)
            脚本运行时自身又可分为三个部分:一是全局变量的部分,二是预设变量的部分,三是各个正在运行的函数运行时。熟悉操作系统的读者可能会联想到堆和栈;事实上,全局变量/预设变量和局部变量,确实与堆和栈有许多共通之处。底层上,全局变量和预设变量是单独的一块有限空间,而局部变量则是以栈的形式储存的。但是,和堆/栈不同的是,在三国2脚本中,几乎所有的数据都储存在局部变量(栈)中,并且也不支持动态分配内存的方式。
            因此,在三国2脚本中,当我们说到“操作内存”时,指的总是对EXE的内存进行操作,而不是局部变量、全局变量或预设变量。当然,也并不存在指向这些变量的指针。


            IP属地:美国本楼含有高级字体6楼2020-04-19 12:46
            回复
              四、底层与栈
              (本节面向有一定基础的读者。不关心这些内容的读者只需要知道,每个独立并行运行的、用asynccall调用的和系统直接调用的函数,都有自己的栈,并且所有参数、局部变量都储存在栈中即可。)
              每个脚本运行时都有自己独立的栈。在三国2脚本中,不存在寄存器;因此,除了全局变量和预设变量以外的所有数据都是储存在栈中的。栈的结构如下图所示:

              其中,黑色的位置指示了当前函数的0号位置。0号和-1号位置由系统自动压栈,储存了调用者的PC和SP,也即调用者调用时的代码位置指针和调用者的堆栈位置指针。往下的-2, -3, -4等位置是当前函数的参数;当前函数的调用者在调用时,将需要的参数一个一个压栈,然后调用当前函数,因此参数处在0号位置的下面。
              往上的1号和2号位置是脚本伪代码编译器预留的位置,用于在编译一些特殊的语法结构时,储存中间数据(该部分在奥汀的代码里是不存在的,奥汀的局部变量直接从1号位置开始)。
              从3号位置开始往上的蓝色的区域储存着当前函数的所有局部变量。所有在函数中声明的局部变量都储存在这一区域,按照编译时读取到的声明的顺序,从3号开始往上逐个排列。
              再往上就是所谓的“表达式求值过程”的部分了。由于没有寄存器,所有的表达式运算都是在栈中完成的。我们举个简单的例子。在计算a = (2 * b) + 3时,假设变量a占用3号位置,b占用4号位置,并且没有别的局部变量了;那么在运行这段代码之前,栈顶在4号位置。这段代码编译出来的汇编代码是这样的:
              PUSH 2 ; 将常数2压栈
              PUSHARG 4 ; 将4号位置的值(局部变量b)压栈
              MUL ; 弹出栈顶的两个数,将它们的乘积压栈
              PUSH 3 ; 将常数3压栈
              ADD ; 弹出栈顶的两个数,将它们的和压栈
              POPN 3 ; 弹出栈顶的数,将其储存在栈中的3号位置(局部变量a)
              可以看到,这些操作都在栈的最顶端完成,并且当所有表达式都求值完毕时,所有多余的值都已被弹栈(上面的代码正好压栈5次,弹栈5次),栈顶指针重新指向最后一个局部变量。
              一个比较特殊的情况是调用函数。当使用常规方法调用函数时,底层上,会做以下这些事情:
              1. 将所有参数从左到右依次压入栈中;
              2. 调用函数;系统自动压入当前函数的堆栈相对位置和当前执行到的代码位置的指针,也即调用者SP和调用者PC;
              3. 跳转到被调用的函数的开头;
              4. 栈顶指针向上移动,为局部变量和预留的1、2号位置腾出空间(一般而言,在编译完成的汇编代码中,该操作是函数体的第一条指令);
              5. 执行函数体。
              因此,从左到右的多个参数,它们排列的顺序也是“从低到高”的(这里的低和高是数字大小的含义),即从最小的负值一直排到-2. 比如,有三个参数,则第一个参数占据 -4号位置,第二个占据-3,第三个占据-2.
              当函数返回时,会根据0号和-1号栈的内容,调整栈顶位置和接下来执行的代码。此时新增的栈都会全部退回原位。
              需要说明的是,图中描述的一个函数叠一个函数的情况,只适用于常规方法调用的函数的情况。如果调用的函数是用asynccall或回调函数方式调用的,那么在调用时,系统会创建一个新的运行时。这个运行时有独立的栈,并且参数会被依次压在栈底;因此当前函数参数的部分再往下,就不再是调用者函数的部分了。


              IP属地:美国本楼含有高级字体7楼2020-04-19 12:51
              回复
                五、系统函数的调用
                在之前,我们在介绍各个系统函数时,总是加入参数的类型。比如:
                void DecreaseGatherTick (int majorObject, int percentage);
                可能并不是那么意料之中的是,实际上,编译器并不会对系统函数进行类型检查。这是因为三国2脚本的系统调用,在底层上只有三个要素:系统函数的编号、参数个数和返回值类型。
                例如,下面这段对系统函数的调用
                CreateObjectRaw(centerX, centerY, 0, 0, 43004);
                会被编译为
                PUSHARG -6 ; centerX
                PUSHARG -5 ; centerY
                PUSH 0
                PUSH 0
                PUSH 43004
                SYSCALL 0x10, (5 | (1 << 16)) ; CreateObjectRaw
                其中,之前5行是这个系统函数需要的5个参数,最后一行调用系统函数。可以看到在进行系统函数调用(最后一行)时,需要指定的元素确实只有三个:其中,0x10是系统函数的编号,5是参数个数,1是返回值的类型(0=void,1=int, 2=float, 3=string)。
                既然系统函数的“本体”只是一个编号,那也就意味着它的名字是可变的。实际上,读者可以打开编译器config目录下的SYSCALL_TABLE点txt文件,修改各个系统函数的名字对应的编号、参数个数和返回值类型。兹摘录一段如下:
                0x10 5 1 CreateObjectRaw
                0x11 4 1 CreateObjectBelongTo
                0x11 4 1 CreateObjectByReference
                0x12 1 1 IsObjectExist
                0x13 1 0 FreeObjectByHandle
                0x13 1 0 FreeObject
                0x14 4 0 SetObjectCoordinate
                可以看到,第一个数指定了编号,第二个数是参数个数,第三个数是返回值类型,最后接着定义的系统函数的名字。同时,不难发现,一个系统函数可以有多个名字;例如0x11号系统函数就同时有CreateObjectBelongTo和CreateObjectByReference两个名字,通常而言这是为了和曾经有过的命名相兼容。只要编译器能从这张表中找到哪个名字对应哪个系统函数,知道需要几个参数,返回值类型是什么,编译器就可以完成编译,至于类型检查则是不存在也做不到的。
                读者也可以自行对手上的编译器调整系统函数的命名。


                IP属地:美国本楼含有高级字体8楼2020-04-19 12:56
                回复
                  2026-01-19 01:09:37
                  广告
                  不感兴趣
                  开通SVIP免广告
                  六、预设变量的定义
                  和系统函数类似,预设变量的名字事实上也是可以在配置文件中改变的。打开编译器config文件夹下的INTV_TABLE点txt文件,可以看到以下内容:
                  intvPeroid 0x00
                  intvAttackerMajor 0x02
                  intvDefenderMajor 0x03
                  intvMagicAttackValue 0x04
                  intvIsLeft 0x05
                  intvRightGeneral 0x07
                  intvLeftGeneral 0x08
                  intvAttackerKingGeneral 0x09
                  intvCurrentYear 0x5AC6
                  intvCurrentTick 0x721A
                  注意,其中一些预设变量如果不按照注册的方式进行修改的话是无效的,比如最后的两个intvCurrentYear和intvCurrentTick;只需看一眼即可知道,这两个“预设变量”并不是真正奥汀设计的预设变量,只是一种访问内存的方法。为了保持兼容,配置文件中仍然保留了这两个预设变量。此外,0号预设变量intvPeriod通常也是无效的(除非挂载注册的启动器)。
                  此外,和全局变量一样,预设变量是可以直接按照编号访问的。可以使用下面的语句:
                  int GetIntv (int number);
                  void SetIntv (int number, int value);
                  它们本质上是指令而不是系统函数,因此使用了“语句”的称呼,尽管使用的时候和函数没有区别。


                  IP属地:美国本楼含有高级字体9楼2020-04-19 12:59
                  回复
                    七、内嵌汇编
                    (注:为防抽,这里使用了部分俄文字母替换英文字母,因此长得有点不一样。实际均以英文小写为准。)
                    这里的“汇编”指的是编译出来的符合三国2脚本指令集的语言,而不是x86汇编。在一些时候,您可能想直接编写汇编语句,或许是为了完成一些奇思妙想的hаck,又或许只是为了照抄原版武将技,而不想对它进行反编译。
                    三国2脚本有两种内嵌汇编的方法。一种方法是直接使用__аsм语句块。例如下面的代码:

                    由于本教程不介绍具体的汇编指令,在此不作过多的解释。只说明两点:其一,在编写汇编代码时,PUSHARG和POPN后面可以直接跟变量名,编译器会自动把变量名替换为对应的栈位置编号;PUSHSTR后面也可以直接跟字符串,如PUSHSTR "HelloWorld"。其二,在任何内嵌汇编块内,使用 ; 添加注释和使用 // /* */ 添加注释都是允许的;在使用 ; 和 // 的情况下,编译成的аsм文件里会保留这部分注释。
                    另一种方法是直接把整个函数完全由汇编编写。这种情况一般见于移植奥汀编写好的既有函数;事实上,magic cpp中全部都是这样的函数。在这种情况下,需要在函数名前加上naked标记;并且,原本的用一对大括号括起来的函数体前面也需要加上__аsм。试举一例:


                    IP属地:美国本楼含有高级字体10楼2020-04-19 13:03
                    回复
                      至此,本教程的第一部分就全部结束了。感谢各位的支持;并谨以此教程,向@还要注册真不爽@竹外桃花叁两枝 二位脚本界的先驱致敬。笔者希望本教程能给各位MOD制作者一些启迪。
                      本教程的第二部分仍然在准备中。由于第二部分涉及的内容多属有待开发的领域,笔者自己也不确定会写成什么样,不过还是敬请期待。


                      IP属地:美国本楼含有高级字体11楼2020-04-19 13:07
                      回复
                        收工……


                        IP属地:美国12楼2020-04-19 13:09
                        回复
                          叹为观止


                          IP属地:广东13楼2020-04-19 13:23
                          回复
                            这类深入游戏参数的技术很难得,我菜只能换换头像之类的皮毛功夫
                            台阶太高了,只能望其项背


                            14楼2020-04-19 14:40
                            回复
                              2026-01-19 01:03:37
                              广告
                              不感兴趣
                              开通SVIP免广告
                              难得的是分享精神,所谓陈沐,陈俊彦,等人应该也掌握了相关技术。缺并未公开。


                              IP属地:江苏来自Android客户端15楼2020-04-19 18:07
                              回复