二进制计数法

来自星际争霸重制版地图研究所
跳到导航 跳到搜索

本文为星际1地图编辑的进阶教程,需要读者拥有扎实的基础知识,熟练掌握触发的运行机制。请确保你已熟悉触发执行顺序

本文所有内容均可在scmd编辑器中用普通触发来实现,不需要eud

原理

这篇文章主要是向各位介绍一个很实用的制图技巧,我称之为“二进制数数法(Binary counting method)”或“二进制计数法”。掌握这个技巧之后,你就可以利用普通触发来在游戏中实现加减运算以及变量赋值。

让我们先从一个具体实例入手。假设我现在正在制作一个刷兵对战的地图(类似于DSA),这个地图中,玩家P1可以自由建造人族兵营(Terran Barracks),并且需要实现如下功能:每隔30游戏秒,在地图的location1区域给P1刷枪兵,P1拥有多少个兵营,就刷多少个枪兵。我们假设,由于地盘的限制,玩家1造的人族兵营数量不超过100个。

我相信,这个问题一定困扰过不少地图作者。它乍一看简单,但是在实际操作中我们又很难实时获取玩家1拥有的兵营数量。当然,如果这是一张eud地图,我们可以直接读取储存着玩家1拥有的兵营数量的内存,瞬间搞定。但eud的弊端是众所周知的,在诸多情况下,地图作者并不希望用eud,而是希望用普通触发搞定这些问题。

我们多希望scmd的触发里面有这么一种action:

Create "玩家1拥有的人族兵营数量"个 Terran Marine at 'location1' for Player 1

或者我们也希望有这么两种action:

Set variable "x" to "玩家1拥有的人族兵营数量"

Create "x" Terran Marine at 'location1' for Player 1

可惜,它们不存在。好消息是,我们可以利用已有的普通触发,来实现上面的效果。我先公布最佳答案,然后再解释其背后的原理。

注:“RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);”这个Action是用来保证command条件的正确判定的,你可以先无视它,之后我会详细介绍。

[Figure01][Figure02]

Trigger {

    players = {P1},

    conditions = {

        Always();

    },

    actions = {

        SetCountdownTimer(SetTo, 30);

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

        Bring(P1, AtLeast, 64, "Terran Barracks", "Anywhere");

    },

    actions = {

        RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);

        GiveUnits(64, "Terran Barracks", P1, "Anywhere", P9);

        CreateUnit(64, "Terran Marine", "location1", P1);

        PreserveTrigger();

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

        Bring(P1, AtLeast, 32, "Terran Barracks", "Anywhere");

    },

    actions = {

        RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);

        GiveUnits(32, "Terran Barracks", P1, "Anywhere", P9);

        CreateUnit(32, "Terran Marine", "location1", P1);

        PreserveTrigger();

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

        Bring(P1, AtLeast, 16, "Terran Barracks", "Anywhere");

    },

    actions = {

        RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);

        GiveUnits(16, "Terran Barracks", P1, "Anywhere", P9);

        CreateUnit(16, "Terran Marine", "location1", P1);

        PreserveTrigger();

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

        Bring(P1, AtLeast, 8, "Terran Barracks", "Anywhere");

    },

    actions = {

        RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);

        GiveUnits(8, "Terran Barracks", P1, "Anywhere", P9);

        CreateUnit(8, "Terran Marine", "location1", P1);

        PreserveTrigger();

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

        Bring(P1, AtLeast, 4, "Terran Barracks", "Anywhere");

    },

    actions = {

        RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);

        GiveUnits(4, "Terran Barracks", P1, "Anywhere", P9);

        CreateUnit(4, "Terran Marine", "location1", P1);

        PreserveTrigger();

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

        Bring(P1, AtLeast, 2, "Terran Barracks", "Anywhere");

    },

    actions = {

        RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);

        GiveUnits(2, "Terran Barracks", P1, "Anywhere", P9);

        CreateUnit(2, "Terran Marine", "location1", P1);

        PreserveTrigger();

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

        Bring(P1, AtLeast, 1, "Terran Barracks", "Anywhere");

    },

    actions = {

        RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);

        GiveUnits(1, "Terran Barracks", P1, "Anywhere", P9);

        CreateUnit(1, "Terran Marine", "location1", P1);

        PreserveTrigger();

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

    },

    actions = {

        GiveUnits(All, "Terran Barracks", P9, "Anywhere", P1);

        PreserveTrigger();

    },

}

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

    },

    actions = {

        SetCountdownTimer(SetTo, 30);

        PreserveTrigger();

    },

}

用这些触发就可以完美实现我们的需求。其中,第2条至第8条便是二进制数数法的一个应用实例。它的原理很简单,就是“此消彼长”地数数。假如你面前有一堆石子,你想要知道它们的数量,你可以把他们全部放到左手边,然后一颗一颗地拿到右边(或者五颗五颗地拿到右边)来数,直到左手边的石子全部被拿到右边,就完成了计数。星际的触发系统允许虽然无法直接获取石子的数量,但是每次可以拿任意数量的石子去右边,在这种情况下,用2的n次方这样的方式拿石子可以保证用最少的触发数完成任意石子数的计数工作。

下面我简述一下它背后的数学原理:

任意一个正整数x,都可以被写为二进制的形式,二进制形式的每一位都由1或者0组成。

十进制的1 = 二进制的1

十进制的2 = 二进制的10

十进制的3 = 二进制的11

十进制的4 = 二进制的100

...

十进制的8 = 二进制的1000

...

十进制的16 = 二进制的10000

...

十进制的32 = 二进制的100000

...

十进制的64 = 二进制的1000000

...

十进制的99 = 二进制的1100011

十进制的100 = 二进制的1100100

任意一个二进制数都可以写成若干个2的n次方数的和,比如:

十进制的99 = 二进制的1100011 = 二进制的(1000000 + 100000 + 10 + 1) = 十进制的(2^6 + 2^5 + 2^1 + 2^0)

一个数字的最右边的位我们称为最低位,最左边的位我们称为最高位,从最低位到最高位依次可称为第1位、第2位、....,可以发现,二进制数的第k位所对应的是2^(k-1)。

而二进制数数法,就是把一个数转化为二进制数,然后依次获取其最高位到最低位的数字。

回到我们的实际问题,兵营的总数量已知不超过100,即小于2的7次方,因此转化成的二进制数一定不超过6位,因此我们从64开始。

我们首先来看标注着“64”的那条触发

Bring(P1, AtLeast, 64, "Terran Barracks", "Anywhere");

这个条件如果为True,则说明这个二进制数的第6位为1;如果为False,则说明这个二进制数的第6位为0

如果第6位为1,我们就照抄1(即刷2^6个兵),然后把原二进制数x去掉最高位(即Give 2^6个兵营给P9)。这时,原二进制数的第5位就变成了最高位(可能为1也可能为0)。

如果第6位为0,则什么都不做。

这条触发执行完毕后,我们便可以保证P1所剩余的兵营数量小于2的6次方。之后下一条触发的本质也是在判定第5位是1还是0,然后照抄(刷枪兵)。

之后的触发也是在做同样的事,最终归到第1位,至此,P1的兵营数为0,全部转化为了P9(此消彼长)。而在这个过程中,我们就刷了跟P1兵营数一样多的枪兵。

这种方法便称为二进制数数法,这是最迅速高效的数数方法,用的触发数最少、用时最短,在一轮扫触发内便可完成。

要注意!触发的顺序不能改变,必须是从最高位到最低位!

下面解释一下RemoveUnitAt(1, "Map Revealer", "Anywhere", P1)这个Action的作用:

由于星际游戏机制的问题,Bring和command这两个condition这个条件有个bug,即不能实时更新,要等到下一轮扫触发才更新。以上面的触发举例,假设P1有70个兵营,那么扫触发时,“Bring(P1, AtLeast, 64, "Terran Barracks", "Anywhere")”结果为True,所以这条触发会执行,即Give64个兵营给P9,那么此时按理来说P1应该只剩下6个兵营了,因此下一条触发的condition“Bring(P1, AtLeast, 32, "Terran Barracks", "Anywhere")”的结果应该为False。但是在实际操作中,这个结果将是True,因为系统并没有实时更新P1的兵营数,系统会认为P1仍然有70的兵营,要到下一轮扫触发才会更新P1的兵营数。这无可置疑是星际程序员写出的一个bug,这个bug会导致这套触发系统失效,以及其他各种匪夷所思的错误。而“RemoveUnitAt(1, "Map Revealer", "Anywhere", P1)”这一条action可以强迫系统实时更新P1的兵营数,消除这个bug。这是各个地图作者的实践结果,知道就是知道,不知道就是不知道,几乎没有任何道理可讲。当然,随便一个RemoveUnitAt、RemoveUnits、MoveLocation动作都可以消除这个bug。详情可以参考如下网页:

http://www.staredit.net/wiki/index.php?title=Bring_Condition_Bug

注:以上第2条至第8条触发,可以用如下TEP代码轻松搞定:

maxbit = 6

for i = maxbit,0,-1 do

x = 2^i

Trigger {

    players = {P1},

    conditions = {

        CountdownTimer(Exactly, 0);

        Bring(P1, AtLeast, x, "Terran Barracks", "Anywhere");

    },

    actions = {

        RemoveUnitAt(1, "Map Revealer", "Anywhere", P1);

        GiveUnits(x, "Terran Barracks", P1, "Anywhere", P9);

        CreateUnit(x, "Terran Marine", "location 1", P1);

        PreserveTrigger();

    },

}

end

星际中的所有游戏数据的储存空间均不超过4字节,因此所保存的数据大小不会超过2^32 - 1,即不超过4294967295。所以,用一次二进制数数法,最多只需要32条触发。当然,绝大多数情况下,任何游戏数据都不太可能达到很大的值,一般都不会超过2^24。

[Page02]

再举另一实例:杀敌分数转化为钱。

假设我们有如下需求:当玩家把他自己的任何一个单位派到location1内时,瞬间将该玩家的杀敌分数(kills and razings)全部转化为水晶矿,兑换比例为:10杀敌分 = 1水晶矿。

注:KillsAndRazings分数(杀敌分数) = Kills分数(杀兵分数) + Razings(摧毁建筑分数)

这个例子又涉及到了另一个常识问题,即:

KillsAndRazings分数跟bring/command的判定一样,是不会实时更新的,需要等到下一轮扫触发才会更新,并且暂时没有找到任何方法强迫它更新。而Kills和Razings这两个分数却是正常的,实时更新的。

二进制数数法的特性之一就是快捷,一切工作都在一轮扫触发内瞬间完成,所以不应该使用这个condition:

Score(xxx, KillsAndRazings, AtLeast, xxx);

而要把kills和razings分开来计算。

因此,我们可以用如下TEP代码来实现我们的目的:

Trigger {

    players = {AllPlayers},

    conditions = {

        Always();

    },

    actions = {

        LeaderBoardScore(KillsAndRazings, "KillRaze");

        LeaderBoardComputerPlayers(Disable);

    },

}

exchangeRate = 10

maxbit = 20

for i = maxbit,0,-1 do

    x = 2^i

    Trigger {

        players = {AllPlayers},

        conditions = {

            Bring(CurrentPlayer, AtLeast, 1, "Any unit", "location1");

            Score(CurrentPlayer, Kills, AtLeast, exchangeRate*x);

        },

        actions = {

            SetScore(CurrentPlayer, Subtract, exchangeRate*x, Kills);

            SetResources(CurrentPlayer, Add, x, Ore);

            PreserveTrigger();

        },

    }

    Trigger {

        players = {AllPlayers},

        conditions = {

            Bring(CurrentPlayer, AtLeast, 1, "Any unit", "location1");

            Score(CurrentPlayer, Razings, AtLeast, exchangeRate*x);

        },

        actions = {

            SetScore(CurrentPlayer, Subtract, exchangeRate*x, Razings);

            SetResources(CurrentPlayer, Add, x, Ore);

            PreserveTrigger();

        },

    }

end

二进制数数法配合TEP代码,可以轻松解决这类问题。

思考:

对于这个问题,普通的初学者可能会使用如下触发:

Trigger {

players = {AllPlayers},

conditions = {

Bring(CurrentPlayer, AtLeast, 1, "Any unit", "location1");

Score(CurrentPlayer, Razings, AtLeast, 5000);

},

actions = {

SetScore(CurrentPlayer, Subtract, 5000, Razings);

SetResources(CurrentPlayer, Add, 500, Ore);

PreserveTrigger();

},

}

这个方法虽然只使用了一个触发,但是它的缺点也是显而易见的:

1. 本来每10分就能兑换1水晶,这个触发却限制了必须5000分以上才能兑换水晶

2. 兑换效率极低,每轮扫触发只能换5000分。试想,如果玩家积攒了1000万分数,全部兑换为水晶矿需要2000轮触发,即使使用加速触发,也是需要250游戏秒才能全部兑换完成,速度太慢。

假设把5000改成50000,那么兑换钱的门槛太高,玩家得不到50000分,就永远不能换钱。如果把5000改成500,那么兑换效率又太低。二者不可兼得。

显然,这是一个很差的处理方式。

而二进制数数法可以完美化解以上矛盾:有击杀分数就可以换钱,并且一轮触发瞬间搞定。

[Page03]

经过以上两个例子,我相信读者已经对二进制数数法有一定的了解了。下面我将介绍地图编辑中的一个重要概念:变量。

变量就是在游戏过程中储存着的各种游戏数据的载体,每个变量都储存着一个数值。在游戏中,变量的值可以被不断更新,它的值可以被游戏机制本身改变,也可以被触发所改变。举一个具体例子,“玩家1的水晶矿数量”就是一个变量,它可以被游戏机制本身所改变(如果玩家1将一片水晶矿送入基地,则这个变量的值+8),也可以被触发所改变(SetResource触发)。再比如,“玩家8的Terran Marine死亡数”也是一个变量,它也是可以被游戏机制改变(玩家8死一个枪兵)或者被触发改变(SetDeath触发)。变量的根本意义在于储存一些值,可以被我们所利用。死亡计数器本质上就是一个变量,被我们所利用、操控,用来作为一个时间上的标尺。

游戏中的变量有很多,我们作为作者,显然应该选择“基本不受到游戏本身影响、只能被我们的触发所改变的”变量。所以,各种不常用单位的死亡数便成为了我们首选的变量。

用死亡数作为变量的优势:

1. 可以完全被我们用触发操作,不被游戏所干扰。

2. 拥有实时更新的特性,不会出现诸如command/bring的bug

3. 永远可用。即游戏中即使没有P8,我们也可以通过触发来改变P8的某个单位的死亡数

星际一共有228种单位,可用的玩家数一共有8个(P9至P12的单位死亡数无法被非eud触发改变)。228个单位中有很多单位是地图不常用单位,比如6种地图生物。所以,地图生物的死亡数就给了我们48个可用变量。

下面我用一个实例来讲解“变量的赋值”:

假设我们想在游戏中使用一个变量来储存P1的实时水晶矿数量,应该如何写触发?

为了方便叙述,我们用x、y、z来代指这些变量:

x可以是P3的"Bengalaas (Jungle)"死亡数

y可以是P3的"Kakaru (Twilight)"死亡数

z可以是P3的"Ragnasaur (Ash World)"死亡数

我们要做的事情在本质上就是一行代码:

x = P1的水晶矿

我将这个称为“变量的赋值”,具体其实是“将一个变量的值赋值给另一个变量”。在这个例子中,是把“P1的水晶数”这个变量赋值给变量x。

注意,“将一个变量的值赋值给另一个变量”跟“将一个常数赋值给一个变量”是有本质区别的。后者很容易做到,用SetDeath(..., SetTo, 一个常数, xxx)即可。

之前说过,二进制数数法的特性之一就是此消彼长,我们想数谁,就不可避免地要让谁归零,然后再还回去。获取P1的水晶矿数量的过程必然会使得P1的水晶矿变成0,所以如果我们简单地用二进制数数法直接把P1的水晶矿加在x上面,就会“忘记”P1原本的水晶矿的值,就没法再把水晶矿还给P1了。所以我们还需要另外的辅助变量用来“记住”P1的水晶矿数值。所以正确的方法是用二进制数数法将P1的水晶矿同时转化为x和y两个变量的值,其中y为辅助变量(auxiliary variable),或者缓冲变量(buffer variable)。然后再用二进制数数法把y的值还给P1的水晶矿。

TEP代码如下:

HyperTriggerSwitch = 0

Trigger {

players = {AllPlayers},

conditions = {

Switch(HyperTriggerSwitch, Set);

},

actions = {

Comment("[Hyper Trigger]");

PreserveTrigger();

Wait(0);

},

}

Trigger {

players = {AllPlayers},

conditions = {

Always();

},

actions = {

Comment("[Hyper Trigger]");

SetSwitch(HyperTriggerSwitch, Set);

},

}

Critters = {"Bengalaas (Jungle)", "Kakaru (Twilight)", "Ragnasaur (Ash World)", "Rhynadon (Badlands)", "Scantid (Desert)", "Ursadon (Ice World)"}

x = {p = P3, u = Critters[1]}

y = {p = P3, u = Critters[2]}

maxbit = 20

for i = maxbit,0,-1 do

temp = 2^i

Trigger {

players = {P1},

conditions = {

Accumulate(P1, AtLeast, temp, Ore);

},

actions = {

SetResources(P1, Subtract, temp, Ore);

SetDeaths(x.p, Add, temp, x.u);

SetDeaths(y.p, Add, temp, y.u);

PreserveTrigger();

},

}

end

for i = maxbit,0,-1 do

temp = 2^i

Trigger {

players = {P1},

conditions = {

Deaths(y.p, AtLeast, temp, y.u);

},

actions = {

SetDeaths(y.p, Subtract, temp, y.u);

SetResources(P1, Add, temp, Ore);

PreserveTrigger();

},

}

end

代码解析:

前两条触发用来加速触发。

maxbit设为20,即默认P1的水晶数不超过2097151,是一个很保守的设置。

x = {p = P3, u = Critters[1]}这两行代码是为了增加代码可读性,让之后的SetDeath触发一目了然。

第一个for loop用来把P1的水晶数归零,并使得x和y的值都为P1的水晶数。第二个for loop是把y的值归零并将水晶还给P1。整体的结果就是:x永远储存着“P1的水晶数”,并且不断实时更新。

这些触发将在扫触发时一瞬间完成执行(P1的水晶数瞬间归零,又瞬间还原),因此在游戏中不会看到P1的水晶数有任何异常变动,P1花钱也不会受影响。

因此,我们用这些触发成功完成了之前提到过的难题,一行“代码”:

x = P1的水晶矿