发信人: zhangsf()
整理人: zhangsf(1999-12-31 17:34:20), 站内信件
|
中阶 LPC
Descartes of Borg
Novermber 1993
第一章: 简介
1.1 基础 LPC
阅读此课本的人应该读过基础 LPC 课本或是够熟悉 mud 世界的程式写作 .
不只是能建造房间和撰写区域内有关的物件而已, 也该清楚自己写出来的程式在
执行的时候到底在做什么. 如果你觉得你的程度还不到, 就回去看完基础 LPC
再来看中阶 LPC 课本. 如果你达到要求, 你会发现你在此读到的东西对你比较
有意义.
1.2 中阶 LPC 课本的目标
此份介绍性的课本是为了对 LPC 一无所知的人, 让他们有能力在任何 LPMud
写出好的游戏世界. 对 LPC 和建设 LPMud 来说, 自然要比建造房间、护甲、
怪物、武器来得艰深. 当你进入更复杂的概念, 例如公会; 或想更深入你的游戏
世界, 你会发现基础 LPC 详细说明的概念里面没有这些东西. 中阶 LPC 的设
计是把你从简单的世界建造过程, 带到完全了解 LPC 作为 LPMud 世界建造者
的知识. 撰写 mudlib 本身的工作留到后面再讲. 读完这份课本, 并实际撰写一
些实验性的程式码之后, 你们读者应该能写出合乎任何设计或想法的游戏物件,
只要这样我的目的就达到了.
1.3 概观
到底多了什么东西 ? 呃, 你们大部份都知道 LPC 支援映射 (mapping) 和阵
列 (array), 也曾问过我基础 LPC 为什么没有详细说明它们. 我觉得那些概念
超出我在基础 LPC 课本里面尝试讲述的范围, 而比较适合放在这份课本里. 不
过, 新工具都棒极了, 而重要的是, 你可以用新工具做啥. 基础 LPC 课本的目
标是让你能建造够格的 LPMud 区域, 不需要映射 (mapping) 和阵列 (array)
就可以办到. 这份课本的目标是让你能在你的区域中实现任何点子, 这样就需要
映射和阵列的知识.
LPMud 中, 任何你想写的点子都可能实现. 令人惊讶的是, LPC 语言非常适合实
现这些目标. 你无法写出你的点子的原因是: 你对 LPC 的知识、不合的 mudli b
、你的 Mud 主题、mud 管理的政策. 这份课本没办法把你工作的 mudlib 变得
更好, 也无法改变你的 Mud 主题或 mud 的管理政策. 千万别认为 LPC 没办
法做到你想做的事. 如果你的点子不容于 mud 主题或是管理政策, 那就是你的
点子无法在你现在的 mud 实现. 如果 mudlib 不合, 告诉负责 mudlib 的人,
在 mudlib 层面上可以做哪些事来增进它. 你会很惊讶, mudlib 中实际上有很
多你不知道的东西. 更重要的是, 读过此课本后, 你应该能读懂你 mud 的
mudlib 程式码, 了解每一行程式在做啥. 你目前应该还没有能力自己写出这种
程式码, 但是至少你可以了解 mudlib 层次在做什么.
这份课本开始时, 讨论 LPMud driver 到底在做些什么. 这份课本的一个好处是
, 里面所讲的东西跟 driver 和 mudlib 无关 (除了 Dworkin Game Driver).
对于游戏 driver 的章节不会深入实际的 driver 程式架构, 而是所有游戏
driver 跑 mud 基本上所要做的事.
接著, 我讨论每个人都想多了解一些的题目 ---- 阵列 (array) 和映射
(mapping). 要了解映射这种资料型态, 大概是最简单也是最困难的事. 大致上
, 映射算是一种复杂的阵列, 要讨论映射之前, 你应该先了解阵列. 一旦你了解
它们, 映射实际上要比阵列好用得多. 无论快慢, 用你最多的的时间来学习这一
章, 因为本章大概是这份课本里头最困难, 也是最有用的一章.
之后, 有一小章讨论 LPC pre-compiler, 在你的程式码送给 compiler 之前,
你可以用它来编排你的程式码. 虽然我在这里介绍得很可怕, 这章恐怕是这份课
本中最简单的一章. 所以我把它摆在介绍映射和阵列的那一章之后.
接著重新介绍字串 (string) , 更详细地教你如何处理字串, 像是分断字串这种
高级的命令处理技巧. 只要你很了解阵列, 这章应该很简单.
再下一章是这本课本第二重要的部分. 如果你想超越中阶的水准、深入 mudlib
程式码, 这可能是最重要的部分. 这章深入介绍 LPC 继承 (inheritance) 的复
杂观念. 既然这份课本的目标并不是教你设计 mudlib 的程式, 这章不会详细讨
论物件导向程式设计. 了解本章, 会让你体会一些物件导向程式设计的内涵, 也
让你能够僭越 (overriding) 其他函式以写出更复杂的物件, 并定义出你自己的
角色基础职业.
最后, 课本以简略地讨论程式码除错作结. 这不是很重要的一章, 但是这样也表
示此章不只是补充你目前所学的知识而已.
1.4 此课本没有的东西
对某些人来说, 此份课本最大的、也是政策性的遗漏就是「投影」(shadow). 我
从来没有看过使用投影是最好或最有效率的例子. 不过, 这样也不代表投影一无
是处. 我在这份课本里不介绍投影的理由是, 学习 LPC 的人, 最好在碰上投影
以前, 先从此课本学得一些观念, 并花上时间去熟悉这些观念. 这样一来, 我觉
得学习 LPC 的人会有能力决定是否要使用投影. 我会在以后的课本里讨论投影 .
如果你经常使用很多的投影, 请别认为上面这段文字是针对你的批评. 我也曾经
看过投影有很多优秀的用途. 不过, 投影并不是一个完成工作的好方法, 所以投
影并不适合这份中阶课本的目标.
我也删掉了讨论系统安全和物件导向程式设计的部份. 这两者很明显都是讨论
mudlib 方面的题目. 不过很多人大概会反对我不讨论物件导向程式设计的作法 .
我决定把这个课题留到以后再说, 因为大多数区域设计者是为了创作而撰写程式
码, 而不是为了资讯理论. 在中阶和基础的课本里, 我决定只在实际 LPC 程式
设计上可以直接应用的地方讨论物件导向程式设计的理论. 对于想撰写一个庞大
mudlib 的 LPC 老手而言, 理论可能要实用得多. 不过以这份课本的目标来说
, 讨论物件导向程式设计只是个让人打瞌睡的题目. 我计划在下一份课本里多讨
论这个理论.
1.5 总结
LPC 不难学习. 虽然比不上其他大部分电脑语言所常做的工作, 令人惊异的是它
非常强大, 在建造 MUD 这种游戏的工作上, 也没其他语言比得上它. 对初学者
来说, 它让你易于学习, 甚至在你还不知道在做什么的时候, 就能写出有用的物
件. 对中阶的人来说, 它让你的任何点子变成文字化的虚拟实境. 对高阶的人来
说, 它的物件导向特点, 可以让你建造一个 internet 上最受喜爱的游戏. 你唯
一所受到的限制, 是你了解多少东西. 而进一步学习并不需要资讯学位.
中阶 LPC
Descartes of Borg
Novermber 1993
第二章: LPMud driver
2.1 回顾基本的 driver/mudlib 间的互动
在基础 LPC 课本里, 你学到很多 mudlib 工作的方式, 尤其是关于你为了建造
区域所撰写的物件. 而 mudlib 和 driver 间的互动讨论得并不多. 不过, 你应
该知道 driver 做了以下的事:
1) 当一个物件第一次被载入记忆体, 原始模式 mud 的 driver 会呼叫
create(), 而精简模式 mud 会呼叫 reset(). 创作的人使 create() 或
reset() 给予物件初始值.
2) 每到游戏管理者设定的周期, driver 呼叫 reset() 函式. 这样让物件能
重新产生怪物之类的东西. 请注意, 在精简模式的 mud 中, 同一个函式不
但用于重新设定房间, 也用于设定初始值.
3) 任何时候, 一个活物件 (living object) 遇到另一个物件时, driver 呼
叫新遇到物件的 init() 函式. 这样可以让新遇到的物件透过 add_action( )
外部函式 (efun) 给予活物件可以执行的命令, 同样也可以执行其他的动作 ,
而这些动作是一个活物件碰到此一物件时所该发生的事.
4) driver 定义了一套称为外部函式的函式, 在游戏中所有的物件都可以使用
它们. 举例来说, 常用的外部函式有: this_player(), this_object(),
write(), say(), 以此类推.
2.2 driver 周期 (cycle)
driver 是执行游戏的 C 程式. 它的基本功能是接受外界的连线, 让人能登录
(login) 、解译定义 LPC 物件和它们在游戏中作用的 LPC 程式码、接受使用
者的输入并呼叫适当的 LPC 函式以配合事件发生. 它最简单的要素就是, 它是
一个永不终止回圈 (loop).
一旦游戏启动, 并且正确地执行功能 (以后会在高阶 LPC 课本中讨论启动程序 )
, driver 就进入一个回圈. 除非合法呼叫 shutdown() 外部函式, 或碰上臭虫
让 driver 崩坏 (crash), 此回圈不会终止. 一开始, driver 控制任何新进的
连线, 并把连线交给登录物件 (login). 之后, driver 把所有使用者输入的命
令放入一个命令表 (table of commands), 此时已是 driver 的最后一个周期.
在组合命令表之后, 所有从 driver 最后一个周期排定要送给连线的讯息, 就送
给使用者. 此时, driver 依序执行命令表中的命令, 并执行每个物件放在命令
表中的各套命令. driver 在周期结束时, 呼叫每一个有 heart_beat() 函式的
物件, 执行其中的 heart_beat() 函式. 最后, 执行所有等待的延迟呼叫
(call out). 本章不讨论连线控制, 本章焦点放在 driver 如何控制使用者命令
、心跳 (heartbeat)、延迟呼叫.
2.3 使用者命令
如同 1.2 中所提, driver 在每个周期中, 把每一个使用者要执行的命令储存
在命令表里. 命令表里头有执行此命令的活物件名称、给予活物件此一命令的物
件、要执行此命令时所执行的函式. driver 把输入命令的物件当作是给予命令
者. 大多数的时候, 这就是 this_player() 所传回的给予命令者.
driver 由有延迟命令的活物件表的头端开始, 接著执行命令, 呼叫这些活物件
输入的命令相关的函式, 并传入给予命令者给函式的任何参数. 当 driver 由新
的活物件所给的命令开始时, 给予命令者变数就改为新的活物件, 这样在命令开
始依序执行函式时, this_player() 外部函式才能传回给予命令的物件.
来看看一个玩家的命令暂存区范例. 在一个叫做 Bozo 的玩家执行最后一个命令
时, 他输入 "north" 和 "tell descartes 下次重新开机是什么时候 ?".
"north" 命令与 Bozo 所在房间里的 "Do_Move()" 函式相关 ("north" 命令由
此房间的 set_exits() 外部函式自动设定). "tell" 命令并没有特别列在玩家
所可以使用的命令中, 而在玩家物件中有一个叫做 "cmd_hook()" 的函式, 比对
玩家可能输入的命令.
当 driver 处理到 Bozo, 给予命令者的变数就设定为 Bozo 这个物件. 然后,
看到 Bozo 输入 "north", 也看到与 "north" 相关的函式, 则 driver 呼叫
Bozo's_room->Do_Move(0) (Bozo 所在房间的 Do_Move() 函式). 因为 Bozo
只输入 "north" 命令, 没有加上参数, 所以用参数 0 传入此函式. 此房间平
常会呼叫一些它需要的函式, 此时 this_player() 外部函式所传回的物件就是
Bozo. 最后, 此房间物件会呼叫 Bozo 中的 move_player(), 之后呼叫
move_object() 外部函式. 这个外部函式负责改变一个物件的环境.
当一个物件的环境改变时, 会删除前一个环境中其他物件和前一个环境中对它加
上的可用命令. 删除之后, driver 呼叫新环境和新环境中每一个物件的 init( )
外部函式. 每一次呼叫 init() 时, Bozo 物件仍然是给予命令者. 所以此次移
动所有的 add_action() 外部函式会加在 Bozo 身上. 完成所有的呼叫后, 控制
权从 move_object() 交给 Bozo 的 move_player() 区域函式. move_player( )
将控制权交回给旧房间的 Do_Move(), Do_Move() 传回 1 给 driver, 以表示
此命令的动作完成. 如果 Do_move() 因为某些原因传回 0, 则 driver 会对
Bozo 显示 "什么?" (或是你的 driver 所预设的错误命令讯息).
一旦第一个命令传回 1, driver 就继续处理 Bozo 的第二个命令, 过程就跟第
一个一样. 请注意, driver 把 "tell descartes 什么时候重新开机 ?" 的
"descartes 什么时候重新开机 ?" 当作参数传给跟 tell 相关的函式. 这个函
式决定要如何处理这个参数. 这个命令之后传回 1 或 0, driver 再继续处理
下一个有延迟命令的活物件, 然后以同样的步骤处理全部有延迟命令的活物件,
执行它们的命令.
2.4 set_heart_beat() 和 call_out() 外部函式
一旦有延迟命令的物件其全部的命令执行完成后, driver 就继续呼叫所有
driver 列为有心跳之物件中的 heart_beat() 函式. 只要一个物件以非零参数
呼叫 set_heart_beat() 外部函式 (视你的 driver 而定, 非零的数字也许很重
要, 但是在大多的情况下为整数 1 ), set_heart_beat() 外部函式把呼叫
set_heart_beat() 的物件加在有心跳物件的列表上. 如果你以 0 为参数呼叫
它, 它就把此物件从有心跳物件的表上删除.
心跳在 mudlib 里最常见的用途是治疗玩家和怪物、执行战斗. 一旦 driver 处
理完命令列表, 它就开始看心跳列表, 呼叫表上每一个物件的 heart_beat().
所以举例来说, 对玩家而言, driver 会呼叫玩家里面的 heart_beat() 以执行
以下功能:
1) 让玩家变老
2) 依照治疗速率治疗玩家.
3) 检查四周是否有任何被人猎杀、正在猎杀人、或正在攻击人的物件
4) 如果第三点成立, 开始攻击.
5) 其他需要每秒钟自动发生的事.
请注意, 有心跳的物件越多, mud 每个周期需要处理的时间也就越久. 有心跳的
物件已知是 mud 贪求 CPU 时间最主要的因素.
call_out() 外部函式用于执行不需要像心跳一样常常发生、或只发生一次的计
时函式呼叫. 延迟呼叫 (call out) 让你指定呼叫一个物件中的某个函式. 一般
延迟呼叫的公式为:
call_out( func, time, args );
第三个指定参数的参数并非必要. 第一个参数是一个字串, 代表被呼叫的函式名
称. 第二个参数是经过几秒之后才呼叫函式.
实际上来说, 当一个物件呼叫 call_out() 时, 它就被加到一个延迟呼叫的物件
表中, 此表中记有延迟呼叫的总延迟时间, 和欲呼叫的函式名称. driver 的每
一个周期, 就会进行倒数, 直到呼叫函式的时间. 时间一到, driver 把此物件
从延迟呼叫表上删除, 并执行呼叫延迟呼叫函式, 传入原本延迟呼叫函式所指定
的参数.
如果你想在一个延迟呼叫执行前将其删除, 你需要用 remove_call_out() 外部
函式, 传入延迟呼叫的函式名称. driver 会删除下一次延迟呼叫的这个函式.
这表示如果同一个函式有一个以上的延迟呼叫, 就会出现模拟两可的情况.
要让一个延迟呼叫循环执行, 你必须在你延迟呼叫的函式中再使用 call_out()
外部函式, 因为 driver 执行完延迟呼叫后, 会自动把函式从延迟呼叫表中删除 .
举例:
void foo() { call_out("hello", 10); }
void hello() { call_out("hello", 10); }
在 foo() 第一次被呼叫后, 每 10 秒呼叫 hello() 一次. 在此有几件事要注
意. 第一, 你必须要小心, 确定你的延迟呼叫不会造成任何不正确的递回方式.
第二, 比较 set_heart_beat() 和 call_out() 所做的事有何不同.
set_heart_beat():
a) 将 this_object() 加在心跳物件列表中
b) 每一次 driver 周期呼叫 this_object() 中的 heart_beat() 函式
call_out():
a) 将 this_object() 、this_object() 中的函式名称、延迟时间、一组参数,
加在延迟呼叫函式的列表上
b) 指定名称的函式只呼叫一次, 在延迟一段指定的时间后, 执行此次呼叫
你可以看到, 延迟呼叫的 (a) 部分有很庞大的记忆总量 (memory overhead),
而心跳的 (b) 部分则有更庞大的 CPU 总量, 假设延迟呼叫的延迟时间要比一
次 driver 周期来得长.
很明显, 你不会执行延迟一秒的延迟呼叫, 否则你会拖垮两者. 同样, 你也不希
望应该使用比一秒钟长的延迟呼叫周期来达成的功能出现在心跳中. 我个人听过
一种论点, 认为你应该多使用延迟呼叫. 我最常听到的是, 单一呼叫或比十秒长
的周期最好使用延迟呼叫. 十秒以内的周期性呼叫, 你最好使用心跳. 我并不知
道这种说法是否正确, 但是我也不认为遵照这种作法会造成任何损害.
2.5 总结
基于更深入了解 LPC, 和了解 driver 和 mudlib 间的互动. 你现在应该知道
driver 执行函式的顺序, 并了解有关 this_player()、add_action()、
move_object() 外部函式和 init() 区域函式更多的细节. 另外, 根据以往你
从基础 LPC 课本学得的知识, 本章以 driver 如何控制延迟呼叫和心跳来介
绍它们. 你现在应该对延迟呼叫和心跳有基本的认识, 并可以在你的程式码中
实验一下.
中阶 LPC
Descartes of Borg
November 1993
第三章: 复杂资料型态
3.1 简单的资料型态
在基础 LPC 课本里, 你学到常见的基本 LPC 资料型态: 整数 (int)、字串
(string)、物件 (object) 、无传回值 (void). 重要的是, 你学到很多运算式
(operation) 和函式 (function) 会因为运算不同的变数资料型态而有不同的行
为. 如果你用错资料型态, 有的运算子 (operator) 和函式会给你错误讯息. 例
如: "a" + "b" 处理起来就跟 1 + 1 不同. "a" + "b" 把 "b" 加在 "a" 的
后面, 得到 "ab". 另一方面, 1 + 1 你不会得到 11, 你会得到你所期望的 2 .
我把这些资料型态归类为简单资料型态, 因为它们基本到无法拆成更小的资料型
态元件. 物件资料型态是个例外, 但是你实际上也没办法知道它由什么元素组成 .
所以我把它归类为简单资料型态.
本章介绍复杂资料型态的概念, 它是由许多简单资料型态单元所组成的. LPC 有
两种常见的复杂资料型态, 两种都属于阵列. 第一种, 传统的阵列 (array), 以
连续的各个元素储存数值, 并以数字代表所储存的值在第几号元素 (element)
中. 第二种是称为映射 (mapping) 的关联性阵列 (associative array). 映射
把一些数值结合起来, 让资料处理起来更接近一般人的习性.
3.2 数值: NULL (虚无) 和 0
深入了解阵列以前, 第一个要先彻底了解的观念是 NULL 的观念和 0 的观念.
在 LPC 中, 一个虚无值 (null value) 由整数 0 代表之. 虽然整数 0 和
NULL 常常随意转换, 在你进入复杂资料型态的领域时, 这种情况常会导致莫大
的困扰. 你可能在使用字串时, 已经碰过此种困扰.
0 对整数来说, 表示你把任何数值加上 0 还是原来的数值. 对任何资料型态的
加法运算来说, ZERO (零值) 对此资料型态来讲, 就是你把任何值加上去都维持
原值. 所以: A 加 ZERO 等于 A. A 是一个已定资料型态的值, 而且 ZERO 是该
资料型态的零值. 这并不算是任何一种正式的数学定义. 虽然还是有一种定义,
但是我不是数学家, 所以我也不知道它的名词是什么. 总之对整数来说, 0 是零
值, 因为 1 + 0 等于 1.
另一方面来说, NULL 表示没有任何值或没有意义. LPC driver 如果能了解 NUL L
在该处的意义, 就把 NULL 解释成整数 0. 除了整数的加法以外, 加上 NULL
会导致错误. NULL 产生错误的原因是, 把那些资料型态加上其他没有值的资料
型态没有意义.
从另一个观点来看, 我们知道 "a" 加上什么值结果会得到 "a", 所以我们可以
得出字串的零值. 答案不是 0, 而是 "". 对整数来说, NULL 和 0 可以互换
是因为 0 代表整数资料型态没有其值. 这种可互换性对其他的资料型态并不适
用, 因为其他资料型态的零值并不代表没有其值. 换句话说, "" 表示一个没有
长度的字串, 而它与 0 完全不一样.
当你第一次宣告任何型态的变数, 它都没有值. 除了整数以外, 在执行任何运算
之前都需要经过初始化. 通常, 全域变数在 create() 函式中初始化, 而区域变
数在区域函数的开端指定某些值, 通常是该资料型态的零值. 举例来说, 在以下
的程式码中, 我想要作出一个由随机单字组成的字串:
string build_nonsense() {
string str;
int i;
str = ""; /* 在此, str 以字串的零值初始化 */
for(i=0; i<6; i++) {
switch(random(3)+1) {
case 1: str += "bing"; break;
case 2: str += "borg"; break;
case 3: str += "foo"; break;
}
if(i==5) str += ".\n";
else str += " ";
}
return capitalize(str);
}
如果我们没有对 str 初始化, 尝试把一个字串加上零值会导致错误. 不过, 在
此段程式码中将 str 以字串的零值 "" 初始化. 之后, 程式进入一个有六次周
期的回圈, 每次把字串加上三个单字的其中一个. 除了最后一个单字之外, 每个
单字后面均加上一个空白字元. 此函式最后离开回圈, 把这个无意义的字串转换
成大写, 然后结束.
3.3 LPC 的阵列 (array)
字串是 LPC 一种强大的复杂资料型态, 让你在一个单一变数中存取多个值. 举
例来说, Nightmare mud 中, 玩家交易时使用多种货币. 但是, 其中只有五种货
币是硬货币 (hard currency). 在此, 硬货币随时可以兑换成其他种类的硬货币
, 但是软货币 (soft currency) 只能购买之, 不能出售. 在银行里, 有一张硬
货币表让银行老板知道哪种货币属于硬货币. 使用简单资料型态, 每次处理货币
兑换交易时, 我们必须要执行以下难看的运算:
int exchange(string str) {
string from, to;
int amt;
if(!str) return 0;
if(sscanf(str, "%d %s for %s", amt, from, to) != 3)
return 0;
if(from != "platinum" && from != "gold" && from !=
"silver" &&
from != "electrum" && from != "copper") {
notify_fail("我们不接受软货币 !\n");
return 0;
}
...
}
以五种硬货币来说, 我们有一个相当简单的例子. 全部只需要两行的程式码, 用
于 if 叙述中过滤不接受兑换的货币种类. 但是, 如果你必须检查所有游戏中不
能使用的货币种类, 怎么办 ? 游戏中可能有 100 种; 你想写一百条 if 叙述 ?
如果你想在硬货币表上加上一种新的货币呢 ? 这表示, 你必须把游戏中每一项
检查硬货币的 if 子句加入新的部分. 阵列让你简易地存取一组相关的资料, 让
你每次执行运算时, 不用分别处理每一个值.
一个阵列常数看起来大概像这样:
({ "platinum", "gold", "silver", "electrum", "copper" })
这是一个字串阵列. 阵列中个别的资料值称为元素 (element), 或是有时候称为
成员 (member). 在程式码里, 作为常数的字串前后以 "" 表示, 阵列常数前后
以 ({ }) 表示, 阵列中个别的元素以 , (逗号) 分开.
你可以使用任何简单的或复杂的 LPC 资料型态阵列. 由不同种类的值所组成的
阵列称作混合 (mixed) 型态阵列. 在多数的 LPC driver 中, 你使用一种 C
语言的阵列语法来宣告阵列. 这种语法常常困扰撰写 LPC 程式的人, 因为这种
语法在 C 中的意义并不能转用到 LPC 中. 无论如何, 如果我们想用一个字串
型态的阵列, 我们要用以下的方式宣告它:
string *arr;
换句话说, 阵列中包含的元素, 其资料型态之后跟著一个空白字元和一个星号.
不过请你记住, 新宣告的字串阵列, 其宣告时里头是 NULL 值.
3.4 使用阵列
你应该了解如何宣告并认识程式码中的阵列. 要了解它们在程式码中如何运作,
让我们回顾一下前面银行的程式码, 这次我们用阵列:
string *hard_currencies;
int exchange(string str) {
string from, to;
int amt;
if(!str) return 0;
if(sscanf(str, "%d %s for %s", amt, from, to) != 3)
return 0;
if(member_array(from, hard_currencies) == -1) {
notify_fail("我们不接受软货币 !\n");
return 0;
}
...
}
这段程式码假设 hard_currencies 是一个全域变数, 并且在 create() 中初始
化:
hard_currencies = ({ "platinum", "gold", "electrum", "silver",
"copper" });
最佳的做法是把硬货币在标头档 (header file) 中定义为 #define, 让所有的
物件都能使用之, 不过 #define 在以后的章节会提到.
一旦你知道 member_array() 外部函式的功能后, 这种方式就比较容易读懂, 也
比较容易撰写. 实际上, 你大概已经猜到 member_array() 外部函式的功能: 它
告诉你一个指定的值是否在某个阵列中. 此处特别是指, 我们想知道玩家想卖出
的货币是否为 hard_currencies 阵列中的元素. 你可能会感到混淆的是,
member_array() 不只告诉我们特定值是否为阵列中的元素, 实际上还告诉我们
阵列中的哪一个元素是此值.
它要怎么告诉你是哪个元素 ? 如果你把阵列变数当作是拥有一个数字, 就比较
容易了解它. 对上面的参数举例来说, 我们假设 hard_currencies 拥有 17900 0
的值. 这个值告诉 driver 要到哪里寻找 hard_currencies 所代表的阵列. 所
以, hard_currencies 指向一个可以找到阵列值的地方. 当有人谈到阵列的第一
个元素时, 它们希望该元素位于 179000. 当一个物件需要阵列第二个元素的值
时, 它就找 179000 + 一个值, 然后 179000 加上两个值就是第三个, 以此类推 .
我们因此可以藉由阵列元素的索引来存取个别的阵列元素, 索引就是在阵列起点
之后第几个值, 而我们在阵列中找寻数值. 对 hard_currencies 阵列来说:
"platinum" 索引为 0.
"gold" 索引为 1.
"electrum" 索引为 2.
"silver" 索引为 3.
"copper" 索引为 4.
如果在阵列中有此种货币, member_array() 传回其元素的索引, 如果阵列中没
有则传回 0. 要参考一个阵列中的单独元素时, 你要照著以下的方式使用之:
阵列名称[索引号]
范例:
hard_currencies[3]
hard_currencies[3] 会是 "silver".
所以, 你现在应该知道阵列以全体或个别元素出现的方式. 全体而言, 你用它的
名称参考 (reference) 之, 而一个阵列常数前后以 ({ }) 围住, 并且用 ,
(逗号) 分隔其元素. 对个别的元素而言, 你用阵列名称跟著前后加上 [] 的索
引号码来参考阵列变数, 而对阵列常数来说, 你可以如同相同型态的简单资料型
态常数般参考之.
整个阵列:
变数: arr
常数: ({ "platinum", "gold", "electrum", "silver", "copper" })
阵列中个别的元素:
变数: arr[2]
常数: "electrum"
你可以将这些参考的方式, 用于你以前习惯其他资料型态的方法. 你可以指定其
值、将其值用于运算式中、将其值当成参数传入函式中、用其值当作传回值. 请
记得一件很重要的事, 当你单独处理一个元素时, 单独的元素本身不是阵列 (除
非你处理的是阵列的阵列). 在上述的范例中, 单独的元素是字串. 所以:
str = arr[3] + " and " + arr[1];
会造出一个字串等于 "silver and gold". 虽然这看起来很简单, 很多刚开始接
触阵列的人试著在阵列中加入新元素时, 就遇到麻烦. 当你处理整个阵列, 并想
要加入新元素时, 你必须用另一个阵列加上去.
注意以下的例子:
string str1, str2;
string *arr;
str1 = "hi";
str2 = "bye";
/* str1 + str2 等于 "hibye" */
arr = ({ str1 }) + ({ str2 });
/* arr 等于 ({ str1, str2 }) */
更深入以前, 我必须说明这个制作阵列的例子是极为恐怖的方法. 你应该这样来
设定阵列: arr = ({ str1, str2 }). 不过, 这个例子的重点是, 你必须以同样
的资料型态进行加法. 如果你试著把一个元素以其资料型态加入一个阵列, 你会
得到错误. 你必须将它视为一个只有单一元素的阵列处理之.
3.5 映射 (mapping)
LPMud 中, 一个最重要的进步是创立了映射资料型态. 大家亦称它为关联性阵列 .
实际上来说, 一个阵列让你不用像阵列般使用数字索引一个值. 映射让你使用实
际上对你有意义的值当作其值的索引, 比较像一个相关的资料库 (relational
database).
在一个有五个元素的阵列中, 你个别使用它们 0 到 4 的整数索引存取这些值 .
想像一下, 再回到钱币的范例中. 玩家有不同数量、不同种类的钱币. 在玩家物
件中, 你需要一个方法储存这些钱币的种类, 并把该种货币与玩家有多少数量连
结起来. 对阵列来说, 最好的方法就是储存一个表示钱币种类的字串阵列, 和另
一个整数阵列代表有多少钱. 这样会产生一段吃光 CPU 的难看程式码:
int query_money(string type) {
int i;
i = member_array(type, currencies);
if(i>-1 && i < sizeof(amounts)) /* sizeof 外部函式传回元素的总数 */
return amounts[i];
else return 0;
}
这是一个简单的查询函式. 接下来看一个加法函式:
void add_money(string type, int amt) {
string *tmp1;
int * tmp2;
int i, x, j, maxj;
i = member_array(type, currencies);
if(i >= sizeof(amounts)) /* 错误的资料, 我们用了一个烂方法 */
return;
else if(i== -1) {
currencies += ({ type });
amounts += ({ amt });
return;
}
else {
amounts[i] += amt;
if(amounts[i] < 1) {
tmp1 = allocate(sizeof(currencies)-1);
tmp2 = allocate(sizeof(amounts)-1);
for(j=0, x =0, maxj=sizeof(tmp1); j < maxj;
j++) {
if(j==i) x = 1;
tmp1[j] = currencies[j+x];
tmp2[j] = amounts[j+x];
}
currencies = tmp1;
amounts = tmp2;
}
}
}
这实在是一些很烂的程式码, 只为了增加钱这种简单的概念. 首先, 我们要得知
玩家有哪些种类的钱币, 如果有, 它是货币阵列中的哪一个元素. 之后, 我们必
须检查更动过之货币资料是否完整. 如果在货币阵列中, 货币种类的索引大于钱
币数量阵列的元素总数, 则我们就出了问题. 因为这两个阵列之间仅靠索引连结
其关系. 只要我们知道资料正确无误, 如果玩家手上目前没有该种货币, 我们仅
把这种货币当作新的元素加入货币阵列, 并把其数量也当作新元素加入数量阵列 .
最后, 如果玩家手上持有该种货币, 我们就把其数量加在数量阵列中相对的索引
上. 如果钱币数量小于 1, 表示用完该种货币, 我们想把该种货币从记忆体中清
除之.
从一个阵列中减去一个阵列不是一件简单的事. 举个例子, 下面的结果:
string *arr;
arr = ({ "a", "b", "a" });
arr -= ({ arr[2] });
你认为 arr 最后的值是多少 ? 唔, 它是:
({ "b", "a" })
从原来的阵列减去 arr[2] 并不会从该阵列中除去第三个元素. 反之, 它从该阵
列减去其第三个元素的值. 而阵列的减法是把该阵列中第一次出现的该值删除之 .
既然我们不想被迫去计算该元素在阵列中是否唯一, 我们就被迫要翻几个筋斗以
从两个阵列中同时除去正确的元素. 如此才能保持两个阵列索引的关联性.
映射提供了一个比较好的方式. 它们让你直接把钱币种类和其总数连结在一起.
有些人认为映射就相当于, 一种不限制你只能用整数当索引的阵列. 事实上, 映
射是一种彻底不同的概念, 用于储存多个集团资讯. 阵列强迫你选择一种对机器
才有意义的索引, 该索引用于寻找正确资料位置之用. 这种索引告诉机器在首值
之后第几个元素才是你想要找的值. 而映射, 你可以选择对你有意义的索引, 不
用担心机器要怎么去寻找和储存它.
以下是映射的格式:
常数:
整个: ([ 索引:值, 索引:值 ]) 例: ([ "gold":10, "silver":20 ])
元素: 10
变数值:
整个: map (map 是映射变数的名称)
元素: map["gold"]
所以现在我的货币函式看起来像:
int query_money(string type) { return money[type]; }
void add_money(string type, int amt) {
if(!money[type]) money[type] = amt;
else money[type] += amt;
if(money[type] < 1)
map_delete(money, type); /* 用于 MudOS */
...或...
money = m_delete(money, type) /* 用于 LPMud 3.* 衍生版本 */
... 或...
m_delete(money, type); /* 用于 LPMud 3.* 衍生版本 */
}
请先注意, 从一个映射中清除一个映射元素的外部函式, 每种 driver 都不同.
查询你的 driver 文件说明, 以得知适当的外部函式名称及语法.
你可以马上看到, 你不需要检查你资料的完整性, 因为你想得知的两个值密不
可分地结合在一起. 另外, 删除无用的值只需要一个简单的外部函式呼叫, 不
用一个繁杂而耗费 CPU 的回圈. 最后, 查询的函式只需要一行 return 指令.
使用映射以前, 你必须宣告并将其初始化.
宣告看来如下:
mapping map;
而通常初始化看来如下:
map = ([]);
map = allocate_mapping(10) ...OR... map = m_allocate(10);
map = ([ "gold": 20, "silver": 15 ]);
跟其他的资料型态一样, 它们通常的运算也有其规则定义, 像是加法和减法:
([ "gold":20, "silver":30 ]) + ([ "electrum":5 ])
得到:
(["gold":20, "silver":30, "electrum":5])
虽然我的示范显示出映射有个顺序, 但是实际上, 映射在储存元素时, 不保证会
遵照其顺序. 所以, 最好别比较两个映射是否相等.
3.6 总结
映射和阵列可以依照你的需求, 要有多复杂就有多复杂. 你可以造出一个阵列的
映射的阵列. 这种东西可以宣告如下:
mapping *map_of_arrs;
它看起来像:
({ ([ ind1: ({valA1, valA2}), ind2: ({valB1, valB2}) ]),
([ indX: ({valX1,valX2}) ]) })
映射可以使用任何一种资料型态作为索引, 包括物件. 映射索引常常称作关键
(key), 是来自资料库的名词. 你随时要谨记在心, 对于任何非整数的资料型
态而言, 作一般像是加法或减法的运算使用之前, 你必须先将其变数初始化.
虽然利用映射和阵列撰写 LPC 程式变得简单又方便, 没有正确地将其初始化
所产生的错误, 常常把刚接触这种资料型态的新手逼疯. 我敢说大家最常碰到
映射和阵列的错误, 是以下三者之一:
Indexing on illegal type.
Illegal index.
Bad argument 1 to (+ += - -=) /* 看你最喜欢哪一种运算 */
第一个和第三个几乎都是因为出问题的阵列或映射没有正确初始化. 第二种错误
讯息通常是当你试著使用一个已初始化过的阵列中所没有的索引. 另外, 对阵列
来说, 刚接触阵列的人常得到第三种错误讯息, 因为他们常试著将一个单独的元
素加入一个阵列, 把初始的阵列与单一的元素值相加, 而没有把一个含有该单一
元素的阵列与初始的阵列相加. 请记住, 只能把阵列加上阵列.
行文至此, 你应该觉得能自在地使用映射和阵列. 刚开始使用它们时, 应会碰上
以上的错误讯息. 使用映射成功的关键, 在于除去这些错误讯息, 并找出你程式
设计上, 何处使你试著使用没有初始化的映射和阵列. 最后, 回到最基本的房间
程式码, 并看看像是 set_exits() 之类的函式 (或在你的 mudlib 上相当的函
式). 它有可能使用映射. 在某些情况下, 它会使用阵列以保持与 mudlib.h 的
相容性.
中阶 LPC
Descartes of Borg
November 1993
第四章: LPC 前编译器 (pre-compiler)
4.1 回顾
上一章的份量相当重, 所以我现在的步调会放慢一些, 藉由 LPC 前编译器这个
简单的课题, 让你能消化并使用映射和阵列. 不过在此, 你应该相当了解 drive r
如何与 mudlib 互动, 并能撰写呼叫延迟呼叫和心跳的物件. 附带一提, 你应该
撰写一些使用映射和阵列的简单物件, 注意这些资料型态如何在物件中使用. 开
始阅读实际的 mudlib 程式码是个不错的主意, 这样能让你制作你自己的 mud.
看看你自己是否了解你的 mudlib 房间和怪物程式码其中的每一件事. 对你不懂
的事, 就询问你 mud 中负责回答创作人程式码问题的人.
前编译器实际上有点误导人, 因为 LPC 码永远不会真正编译过. 虽然这一点随
著新的 LPC driver 原型而渐渐改变, LPC driver 解译创作人所写的程式码,
而非编译为二进位格式. 虽然如此, LPC 前编译器的功能仍然表现得比较像是编
译语言的前编译器, 其指令甚至在 driver 开始看物件码之前就已解译.
4.2 前编译器指令
如果你不知道什么是前编译器, 你不用担心. 对 LPC 而言, 它基本上是在
driver 开始解译 LPC 码, 以让你执行档案中整段程式码的动作之前的一个程
序. 因为程式码还未解译, 前编译器程序在档案以物件存在之前、检查任何 LPC
函式和指令之前执行. 所以前编译器在档案层次上工作, 表示它并不会处理任何
在继承档案中的程式码.
前编译器在送给它的档案中寻找前编译器指令. 这些档案中的小指令只对前编译
器有意义, 并不算是 LPC 语言的一部份. 一个前编译器指令是在档案中任何以
# 号开头的一行. 前编译器指令通常用于制造一个档案看起来的最终程式码. 最
常见的前编译器指令是:
#define
#undefine
#include
#ifdef
#ifndef
#if
#elseif
#else
#endif
#pragma
mud 里大多数的区域码撰写人并不使用 #define 和 #include 指令. 其他你常
见的指令即使你从未用过, 你也大概知道它们的意义.
第一对指令是:
#define
#undefine
#define 指令设定一组字元, 这组字元在程式码中的任何地方都会在前编译器处
理时段换成它们所定义的东西.
举例:
#define OB_USER "/std/user"
这个指令让前编译器寻找整个档案中是否有 OB_USER. 任何有 OB_USER 的地方 ,
它就换成 "/std/user". 注意, OB_USER 在程式码中并不算是变数. LPC 解译器
永远不会看到 OB_USER 的标签. 前面已经说过, 前编译器是在程式码解译之前
的一段过程. 所以你所写的:
#define OB_USER "/std/user"
void create() {
if(!file_exists(OB_USER+".c")) write("Merde! No user file!");
else write("Good! User file still exists!");
}
到了 LPC 解译器的手上就变成:
void create() {
if(!file_exists("/std/user"+".c")) write("Merde! No user file!");
else write("Good! User file still exists!");
}
只要放个 #define, 它就会将定义的标签换成标签后面的任何东西. 你也可以把
#define 用于一种特殊的情况, 标签后面不跟著任何值. 这种情形称为二进位定
义 (binary definition). 举个例子:
#define __NIGHTMARE
出现在 Nightmare Mudlib 的组态档 (config) 中. 这样让前编译器测试一些东
西, 我们在本章稍后会说明.
其他你常用的前编译器指令是 #include. 正如其名字所暗示的, #include 在
前编译时将其他档案的内容放入该指令出现的地方. 专为其他档案纳入而制作的
档案常称为标头档 (header file). 它们有时候含有一些东西被很多档案共用,
像是 #define 指令和函式宣告. 标头档传统的档案延伸名是 .h .
include 指令的语法有两种:
#include
#include "filename"
如果你用档案的绝对名称, 则你用哪一种语法都无所谓. 档案名称前后使用什么
符号决定前编译器如何寻找标头档. 用 <> 围住的档案, 前编译器首先寻找系统
include 目录. 用 "" 围住的档案, 前编译器开始从前编译器正在处理的档案所
在之目录找起. 不然在放弃之前, 前编译器会寻找系统 include 目录和该档案
所在的目录. 使用的语法决定了寻找的顺序.
最简单前编译器指令是 #pragma 指令. 我怀疑你大概从未使用过. 基本上, 你
在 #pragma 指令之后跟著对 driver 有意义的一些关键字. 我唯一见过的关键
字是 strict_types, 它让 driver 知道你希望这个档案以严格资料型态解译之 .
我怀疑你会需要使用这种指令, 而且你可能从未看过它. 我在此介绍它, 只是因
为当你看到它时, 不会让你认为它实际上不具有任何意义.
最后一组前编译器指令是条件前编译器指令 (conditional pre-compiler
directives) . 它们让你在一个运算式为真值时, 以一种方式前编译一个档案,
运算式为伪值时, 以另一种方式前编译该档案. 这是让程式码在不同 mudlib 之
间具有移植性 (portable) 最方便的方法, 举例来说, 因为在 MudOS mud 的程
式码中放入 m_delete() 外部函式会导致错误, 所以你大概会照著以下撰写:
#ifdef MUDOS
map_delete(map, key);
#else
map = m_delete(map, key);
#endif
经过前编译器处理之后, 解译器会看到:
在 MudOS mud 中:
map_delete(map, key);
其他的 mud:
map = m_delete(map, key);
解译器永远看不到会产生错误的函式呼叫.
请注意, 我前面用于说明二进位定义的例子. 二进位定义让你对解译器传入一些
程式码, 基于其他条件下, 你所使用的 driver 或 mudlib 为何.
4.3 总结
前编译器是在你程式之间维持模组性的有用工具. 当你有易受影响而改变的值,
而此值在你的档案中普遍使用, 你可以在标头档使用 #define 叙述将它们全部
置换之, 这样一来你以后需要改变这些值时, 只需要更改 #define 指令. 在此
最好的例子是 money.h , 它包含这个指令:
#define HARD_CURRENCIES ({ "gold", "platinum", "silver", "electrum",
"copper" })
如果你想加上新的硬货币, 你只需要更改这个指令, 就能更新所有需要硬货币为
何的档案.
LPC 前编译器也让你撰写不用随 mudlib 和 driver 而改写的可携性程式码. 最
后, 你应该小心, 前编译器只接受以 carriage return 结束的一行字. 如果你
要撰写一个多行的前编译器指令, 你必须在未结束的一行末尾加上反斜线 (\).
中阶 LPC
Descartes of Borg
November 1993
第五章: 高级的字串处理
5.1 字串是什么
基础 LPC 课本教你字串是简单资料型态. LPC 一般来说也这样处理字串. 不过 ,
在底下的 driver 程式是以 C 写成的, 它没有字串资料型态. driver 实际上
视字串为复杂资料型态, 由字元的阵列所组成 ---- 一种简单的 C 资料型态.
LPC 在另一方面来说, 并不认识字元资料型态 (可能有一两种 driver 认得字元
资料型态, 但是一般上来说不认得) . 其结果是, 你可以对字串作一些类似阵列
的处理, 而其他的 LPC 资料型态则否.
你第一个该学与字串有关的外部函式是 strlen(). 这个外部函式传回一个 LPC
字串中, 以字元为单位的长度. 就从这个外部函式的行为来说, 你可以看到
driver 视字串由更小的元素所组成, 并以此处理之. 在本章之中, 你将学到如
何以更基础的字元和子字串层次处理字串.
5.2 字串是字元阵列
你可以对阵列作的事, 几乎都可以用于字串, 除了在字元基础上指定其值以外.
最基本的是, 你实际上可以在字元前后加上 '' (单引号) 将它当作字元常数.
所以 'a' 和 "a" 在 LPC 中是完全不一样的东西. 'a' 表示是一个字元, 不
能用于指定叙述或其他的运算式中 (比较两值的式子除外). 另一方面, "a" 是
由单一字元所组成的字串. 你可以加减其他的字串, 并指定它为变数值.
对字串变数来说, 你可以存取单独的字元跟字元常数作比较. 其语法与阵列相同 .
换句话说, 以下叙述:
if(str[2] == 'a')
是一个有效的 LPC 叙述, 将 str 的第二个字元与 'a' 字元作比较. 你必须
非常小心, 你不会把阵列元素与字元相比较, 也不会把字串的字元与字串相比较 .
LPC 也让你使用范围运算子 (range operator) .. 一起存取多个字元:
if(str[0..1] == "ab")
换句话讲, 你可以看 str 字串中第 0 到 1 个字元是什么. 如同阵列, 你必
须小心使用索引或范围运算子, 才不会试著参考比最后一个索引还大的索引数.
这样会导致错误.
现在你可以看到字串和阵列之间的几处相似点:
1) 两者你都可以藉由索引存取个别的元素.
a) 字串个别的元素是字元.
b) 阵列个别的元素符合阵列的资料型态.
2) 你可以运算一个范围之内的值.
a) 例: "abcdef"[1..3] 是 "bcd" 字串
b) 例: ({ 1, 2, 3, 4, 5 })[1..3] 整数阵列 ({ 2, 3, 4 })
当然, 你应该记住基本上的相异点: 字串不是由更基本的 LPC 资料型态所组成 .
换句话说, 你没办法将值指定给字串中单独的字元.
5.3 sscanf() 外部函式
不使用 sscanf(), 你在 LPC 中就无法更有效处理字串. 没有它, 你就只能处
理传给命令函式之命令叙述的整个字串. 换句话讲, 你没办法处理一个像
"give sword to leo" 的命令, 因为你没有方法分析 "sword to leo" 的成分.
像这种使用多个参数的命令, 它们使用 sscanf() 外部函式让命令更接近英文.
大部分的人都觉得 sscanf() 的说明文件相当难懂. 这个函式并不算是非常符合
说明文件中的格式. 如同前述, 这函式用于读取字串, 并分析出有用的成分. 技
术上来说, 它读取一个字串, 并分析成一个或一个以上的各种型态之变数. 举个
例子:
int give(string str) {
string what, whom;
if(!str) return notify_fail("Give what to whom?\n");
if(sscanf(str, "%s to %s", what, whom) != 2)
return notify_fail("Give what to whom?\n");
... 其余的 give 程式码 ...
}
sscanf() 外部函式需要三个以上的参数. 第一个参数是你想分析的字串. 第二
个参数称为控制字串. 控制字串是一个模型, 表示原来所写的字串格式为何, 它
该如何分析. 其余的参数是变数, 你会由控制字串指定值给它们.
控制字串由三种不同的元素组成:
1) 常数
2) 被分析的变数参数
3) 要丢弃的变数
在 sscanf() 之中你变数参数的数目必须与控制字串中第二种元素的数目相等.
在上述的例子中, 控制字串是 "%s to %s", 是三个元素的控制字串, 由一个常
数部分 (" to ") 和两个被分析的变数参数 ("%s") 组成. 在此没有要丢弃的变
数.
控制字串基本上指出函式应该在 str 字串中寻找 " to ". 在此常数之前不管
是什么东西, 会以字串型态放在第一个变数参数中. 同理, 常数后面的任何东西 ,
会放在第二个.
变数元素以 % 符号跟著一个解释码表示. 如果变数元素要丢弃, % 符号之后跟
著 * 号, 再跟著解释变数的码. 常见的变数元素解释码是 s 表示字串, 和 d
表示整数. 另外, 你的 mudlib 可能支援其他的转换码, 像是 f 表示浮点数.
所以在上述的两个例子中, 控制字串中的 %s 指出原来字串中, 不管什么东西出
现在对应的位置上, 就会以字串被分析成新的变数.
来一个简单的练习. 你要怎么把字串 "145" 转成一个整数 ?
答案:
int x;
sscanf("145", "%d", x);
sscanf() 执行之后, x 会等于整数 145.
无论何时, 你使用控制字串分析一个字串, 函式会寻找原来字串中第一次出现第
一个常数的地方. 举个例, 如果你的字串是 "magic attack 100", 并撰写了以
下的程式码:
int improve(string str) {
string skill;
int x;
if(sscanf(str, "%s %d", skill, x) != 2) return 0;
...
}
你会发现你得到 sscanf() 错误的传回值 (稍后再多讨论传回值) . 控制字串
"%s %d", 是由被分析的两个变数和一个常数组成的. 常数是 " ". 所以函式寻
找原字串中第一次出现 " " 的地方, 把 " " 之前的任何东西放入 skill, 并
试著把 " " 之后的任何东西放入 x. 这样一来, 把 "magic attack 100" 分成
"magic" 和 "attack 100" 两个部分. 但是函式没办法把 "attack 100" 变成一
个整数, 所以它传回 1, 表示有一个变数值成功分析出来 ("magic" 转 skill).
也许你已经从上面的例子中猜到, 但是 sscanf() 外部函式传回一个整数, 是从
原字串成功分析出来的变数值个数. 这里有些传回值的例子让你看看:
sscanf("swo rd descartes", "%s to %s", str1, str2) 传回: 0
sscanf("swo rd descartes", "%s %s", str1, str2) 传回: 2
sscanf("200 gold to descartes", "%d %s to %s", x, str1, str2) 传回: 3
sscanf("200 gold to descartes", "%d %*s to %s", x, str1) 传回: 2
x 是一个整数, 而 str1 和 str2 是字串.
5.4 总结
LPC 字串可以视为字元的阵列, 但是你要牢记的是, LPC 并没有字元资料型态
(绝大多数, 但不是所有的 driver 皆是). 既然字元不是一种真正的 LPC 资
料型态, 你就无法像其他资料型态一样, 处理一个 LPC 字串中单独的字元. 注
意, 虽然字串和阵列之间的相似关系可以让你比较容易了解字串的范围运算子和
索引的概念, 两者仍有不同之处.
虽然除了 sscanf() 之外, 高级的字串处理仍牵涉到其他的外部函式, 它们却不
常需要用到. 你应该阅读你 mud 中这些外部函式的 man 或 help 档案:
explode() 、implode() 、replace_string()、sprintf(). 这些都是非常有价
值的工具, 尤其是你想在 mudlib 层次上撰写程式码之时.
中阶 LPC
Descartes of Borg
November 1993
第六章: 中级继承 (inheritance)
6.1 基础继承
在基础 LPC 课本中, 你学到 mudlib 如何藉由继承维持 mud 物件之间的一致
性. 继承让 mud 管理人撰写所有的 mudlib 物件, 或某一种的 mudlib 物件都
必须拥有的基本函式, 让你可以专心创作使物件独树一格的函式. 当你建造一个
房间、武器、怪物时, 你使用一套早已替你写好的的函式, 并将它们让你的物件
继承之. 以此方法, 所有 mud 中的物件可以依靠别的物件表现某种方式的行为 .
举个例, 玩家物件实际上依靠所有房间物件其中称为 query_long() 的一个函式
以得知房间的叙述. 继承让你不用担心 query_long() 长得如何.
当然, 这份课本会试著超越继承的基本知识, 让程式撰写人更了解 LPC 程式设
计中, 继承如何运作. 目前还不需要深入高级区域程式码撰写人/初级 mudlib
程式撰写人要知道的细节. 本章会试著详细解释, 你继承一个物件时所发生的事 .
6.2 复制 (cloning) 与继承
当一个档案第一次以一个物件被参考 (相对于读取档案的内容) , 游戏试著将档
案载入记忆体, 并创造一个物件. 如果该物件成功载入记忆体, 它就成为主本
(master copy) . 物件的主本可被复制, 但是不用作实际上的游戏物件. 主本用
于支援游戏中任何的复制物件.
主本是 mud LPC 程式撰写争辩的源头之一, 也就是要复制它还是继承它. 对房
间来说就没有问题, 因为在游戏中每个房间物件应该只有一份. 所以你一般使用
继承来创造房间. 很多 mud 管理人, 包括我自己在内, 鼓励创作人复制标准的
怪物物件, 并从房间物件中设定之, 而不是让怪物分为单独的档案, 并继承标准
怪物物件.
如同我前述的部分, 每次一个档案被参考, 用于创造一个物件时, 一份主本就会
被载入记忆体. 像是你做以下的事:
void reset() {
object ob;
ob = new("/std/monster");
/* clone_object("/std/monster") some places */
ob->set_name("foo monster");
... 其余的怪物设定程式码, 之后再将怪物搬入房间中 ...
}
driver 会寻找是否有一个称为 "/std/monster" 的主物件. 如果没有, 它就创
造一个. 如果存在, 或已被创造出来, driver 就创造一个称为
"/std/monster#<编号>" 的复制物件. 如果此时是第一次参考 "/std/monster" ,
结果会创造两个物件: 主物件和复制物件.
另一方面, 让我们假设你在一个继承 "/std/monster" 的特殊怪物档案中的
create() 里面, 已经做好所有的设定. 不从你房间复制标准怪物物件, 而你复
制你自己的怪物档案. 如果标准怪物尚未载入, 因为你的怪物继承它, 所以载入
之. 另外, 你档案的一个主本也被载入记忆体. 最后, 创造出一份你怪物的复制 ,
并搬入你的房间. 总共游戏中增加了三个物件. 注意, 你无法轻易地使用主本做
到这些. 举例来说, 如果你想做:
"/wizards/descartes/my_monster"->move(this_object());
而非
new("/wizards/descartes/my_monster")->move(this_object());
你会无法修改 "my_monster.c" 并更新它, 因为更新 (update) 指令摧毁一个物
件现存的主版本. 在某些 mudlib 中, 它也载入新版本到记忆体中. 想像一下,
玩家在战斗中杀得如火如荼的时候, 因为你更新档案让怪物消失无踪 ! 此时他
们的脸色可不好看.
所以当你只是计划要复制时, 复制是一个有用的工具. 如果你对怪物并没有做什
么特殊的事, 又不能藉由几个外界呼叫 (call other) 做到, 那你可以避免载入
许多无用的主物件而节省了你 mud 的资源. 不过, 如果你计画要对一个物件增
加一些功能 (撰写你自己的函式) 或是如果你有一个单独的设定多次重复使用
(你有一队完全一样的半兽人守卫, 所以你撰写一个特别的半兽人档案并复制之 ),
继承就相当有用.
6.3 更深入继承
当 A 物件和 B 物件继承 C 物件, 三个物件全都有自己的一套资料, 而由 C
物件共享一套函式定义. 另外, A 和 B 在它们个别的程式码中会有自己的函式
定义. 因为本章余下的部分都需要范例说明, 我们使用以下的程式码. 在此别因
为一些看起来没有意义的程式码而困扰.
C 物件
private string name, cap_name, short, long;
private int setup;
void set_name(string str);
nomask string query_name();
private int query_setup();
static void unsetup();
void set_short(string str);
string query_short();
void set_long(string str);
string query_long();
void set_name(string str) {
if(!query_setup()) {
name = str;
setup = 1;
}
nomask string query_name() { return name; }
private query_setup() { return setup; }
static void unsetup() { setup = 0; }
string query_cap_name() {
return (name ? capitalize(name) : ""); }
}
void set_short(string str) { short = str; }
string query_short() { return short; }
void set_long(string str) { long = str; }
string query_long() { return str; }
void create() { seteuid(getuid()); }
B 物件
inherit "/std/objectc";
private int wc;
void set_wc(int wc);
int query_wc();
int wieldweapon(string str);
void create() { ::create(); }
void init() {
if(environment(this_object()) == this_player())
add_action("wieldweapon", "wield");
}
void set_wc(int x) { wc = x; }
int query_wc() { return wc; }
int wieldweapon(string str) {
... code for wielding the weapon ...
}
A 物件
inherit "/std/objectc";
int ghost;
void create() { ::create(); }
void change_name(string str) {
if(!((int)this_object()->is_player())) unsetup();
set_name(str);
}
string query_cap_name() {
if(ghost) return "A ghost";
else return ::query_cap_name();
}
你可以看到, C 物件被 A 物件和 B 物件继承. C 物件代表的是一个相当简化
的基本物件, 而 B 也是相当简化的武器, A 是简化的活物件. 虽然我们有三个
物件使用这些函式, 每一个函式在记忆体中只维持一份. 当然, 从 C 物件而来
的变数在记忆体中有三份, 而 A 物件和 B 物件各有一份变数在记忆体中. 每
一个物件有自己的资料.
6.4 函式和变数标签 (label)
注意, 以上的许多函式是以本文和基础课本中还未介绍过的标签处理之, 这些标
签就是 static (静态) 、private (私有)、nomask (不可遮盖) . 这些标签定
义一个物件的资料和函式拥有特殊的特权. 你至今所使用的函式, 其预设的标签
是 public (公共). 只有某些 driver 预设如此, 有的 driver 并不支援标签 .
一个公共变数是物件宣告它之后, 其继承树之下的所有物件皆可使用之. 在 C
物件中的公共物件可以被 A 物件与 B 物件存取之. 同样, 公共函式在物件宣
告它以后, 可以被继承树之下的所有物件呼叫之.
相对于公共的是私有. 一个私有变数或函式只能由宣告它的物件内部参考之. 如
果 A 物件或 B 物件试著参考 C 物件中的任何私有变数, 就会导致错误, 因
为这些变数它们根本看不到, 或说因为它们有私有标签, 无法被继承物件使用.
不过, 函式提供一个变数所没有的独特挑战. LPC 外部物件有能力藉由外界呼叫
(call other) 呼叫其他物件中的函式. 而私有标签无法防止外界呼叫.
要防止外界呼叫, 函式要使用静态标签. 一个静态函式只能由完整的物件内部或
driver 呼叫之. 我所谓的完整物件就是 A 物件可以呼叫它所继承 C 物件中
的函式. 静态标签只防止外部的外界呼叫. 另外, this_object()->foo() 就算
有静态标签, 也视为内部呼叫.
既然变数无法由外部参考, 它们就不需要一个同效的标签. 某几行程式里, 有人
决定要捣蛋, 并对变数使用静态标签以造成完全不同的意义. 更令人发狂的是,
这标签在 C 程式语言里头一点意义也没有. 一个静态变数无法经由
save_object() 外部函式储存, 也无法由 restore_object() 还原. 自己试试.
一般来说, 在一个公共函式中有一个私有变数是个很好的练习, 使用 query_*()
函式读取继承变数的值, 并使用 set_*()、add_*() 和其他此类的函式改变这些
值. 在撰写区域程式码时, 这实际上并不需要担心太多. 实际上的情形是, 撰写
区域程式码并不需要本章所谈的任何东西. 不过, 要成为真正优秀的区域程式码
撰写人, 你要有能力阅读 mudlib 程式码. 而 mudlib 程式码到处都是这些标签 .
所以你应该练习这些标签, 直到你可以阅读程式码, 并了解它为什么要以这种方
式撰写, 还有它对继承这些程式码的物件有何意义.
最后一个标签是不可遮盖, 因为继承的特性允许你重写早已定义的函式, 而不可
遮盖的标签防止此情形发生. 举例来说, 你可以看到上述的 A 物件重写
query_cap_name() 函式. 重写一个函式称为僭越 (override) 该函式. 最常见
的函式僭越就像这样, 当我们的物件 (A 物件) 因为特殊的条件情况, 需要在特
定情形下处理函式呼叫. 在 C 物件中, 因为了 A 物件可能是鬼魂而放入测试
的程式码, 是一件很蠢的事. 所以, 我们在 A 物件中僭越 query_cap_name(),
测试该物件是否为鬼魂. 如果是, 我们改变其他物件询问其名字时所发生的事.
如果不是鬼魂, 我们想回到普通的物件行为. 所以我们使用范围解析运算子
(scope resolution operator, ::) 呼叫继承版本的 query_cap_name() 函式,
并传回它的值.
一个不可遮盖函式无法经由继承或投影 (shadow) 僭越之. 投影是一种反向继承 ,
将在高级 LPC 课本中详细介绍. 在上述的范例中, A 物件和 B 物件 (实际上 ,
其他任何物件也不行) 无法僭越 query_name(). 因为我们想让 query_name()
作为物件唯一的监识函式, 我们不想让别人透过投影或继承欺骗我们. 所以此函
式有不可遮盖标签.
6.5 总结
透过继承, 一个程式撰写人可以使用定义在其他物件中的函式, 以避免产生一堆
相似而重复的物件, 并提高 mudlib 物件与物件行为的一致性. LPC 继承允许物
件拥有极大的特权, 定义它们的资料如何被外部物件和继承它们的物件存取之.
资料的安全性由 nomask、private、static 这些标签维持之.
另外, 一个程式码撰写人能藉由僭越, 改变非防护函式的功能. 甚至在僭越一个
函式的过程中, 一个物件可以透过范围解析运算子存取原来的函式.
中阶 LPC
Descartes of Borg
November 1993
第七章: 除错
7.1 错误的种类
至今, 你大概已经到处碰过各式各样的错误. 一般上, 你可能看到的错误有三种 :
编译时段错误 (compile time error) 、执行时段错误 (run time error) 、故
障的程式码 (malfunctioning code). 在大多数的 mud 中, 你会找到一个私人
的档案, 里头记录著你的编译时段错误. 对大多数人来说, 你可以在你的家
(home) 目录找到名叫 "log" 或 ".log" 的档案, 或在 "/log" 目录找到以你
的名字命名的档案. 另外, mud 执行时, 会维持一份执行时段错误的纪录. 而此
档案也在 "/log" 目录中. 对 MudOS mud 来说, 它叫做 "debug.log". 其他的
mud 中, 称为不同的名字, 像是 "lpmud.log". 如果你还不知道编译时段和执行
时段错误纪录在哪里, 请询问你的系统管理者.
编译时段错误是 driver 试著载入一个物件到记忆体的时候发生的错误. 如果此
时它看不懂你写的东西, 它会无法把物件载入记忆体, 并在你私人的错误纪录档
中记录为什么它无法载入该物件. 最普遍的编译时段错误是打字错误、遗漏或多
加 () , {}, [], ""、没有正确宣告物件所使用的函式和变数.
执行时段错误是一个在记忆体中的物件, 当它执行某段叙述时所发生的错误. 举
例来说, driver 不可能知道任何情况下, "x/y" 是否有效. 实际上, 它是一个
有效的 LPC 运算式. 但是, 如果 y 的值为 0, 则会发生执行时段错误, 因为
你不能除以 0. 当 driver 执行一个函式时碰上错误, 它放弃执行函式并纪录在
游戏执行时段错误纪录档中. 如果有定义、如果玩家是创作者, driver 也会对
this_player() 显示错误讯息, 不然就只对玩家显示 "什么 ?". 大多数导致执
行时段错误的原因, 是不正确的值和试著执行没有定义运算资料型态的运算式.
不过, 最狡猾的错误种类, 就是故障的程式码. 这些错误不会纪录下来, 因为
driver 永远不可能知道有地方出错. 简单地说, 这种错误就是你认为程式码做
的是一件事, 但是实际上它做的是另一件事. 常遇到这种错误的人, 一定会认定
是 mudlib 或 driver 的错误. 每个人都制造过各式各样的错误, 而更常见的不
是程式码不按照它该运作的的方式工作, 而是你错读它.
7.2 修正编译时段错误
编译时段错误是最常见以及最容易修正的错误. 新手程式撰写人常常因为一些怪
异的错误讯息, 而感到挫折. 虽然如此, 只要一个人变得习惯于他们 driver 产
生的错误讯息, 修正编译时段错误就成了例行公事.
在你的错误纪录中, driver 会告诉你错误的种类, 还有它最后在第几行注意到
该错误. 注意, 这不表示此行一定是错误实际发生的地方. 除了打字错误, 最常
见的编译时段错误是遗漏或多加各式括号和引号: (), [], {}, "". 这种是最常
困扰新手程式撰写人的错误, 因为 driver 不会注意到遗漏或多加的部分, 直到
稍后出问题为止. 以下是范例:
1 int test(string str) {
2 int x;
3 for(x =0; x<10; x++)
4 write(x+"\n");
5 }
6 write("Done.\n");
7 }
看你想做的是什么, 此处实际上的错误在第三行 (表示你遗漏了一个 {) 或第五
行 (表示你多加一个 }) . 但是, driver 会回报它在第六行找到一个错误. 实
际的 driver 讯息每种 driver 可能都不一样, 但是不管是哪一种 driver, 你
会看到第六行产生一个错误. 因为第五行的 } 会解释为 test() 函式结束. 在
第六行, driver 看见你有一个 write() 出现在函式定义之外, 所以回报为错
误. 一般来说, driver 也会继续回报它在第七行找到一个多加 } 的错误.
修正这种错误的秘诀在于程式撰写风格. 将结束的 } 与该子句开头的 { 垂直
对齐, 在你除错时, 会让你看到你哪里遗漏它们. 同样, 当你使用多组括号时,
像这样用空白将各组分开:
if( (x=sizeof(who=users()) > ( (y+z)/(a-b) + (-(random(7))) ) )
你可以看到, for() 叙述的括号与其余的叙述以空白隔开. 另外, 个别的子群也
用空白隔开, 让它们在产生错误时易于找出.
一旦你拥有帮助你找出错误的程式撰写风格, 你就会学到哪一种错误讯息倾向于
指出哪一种错误. 修正此种错误时, 你会检查出问题的那一行之前与之后的程式
码. 大多数的情况下, 你会直接找到错误.
另一种普遍的编译时段错误是 driver 回报一个不明的 identifier. 一般来说 ,
打字错误和错误宣告变数导致此种错误. 幸运的是, 错误纪录档中几乎都能告诉
你错误所发生的实际位置. 所以修正此种错误时, 进入编辑程式并找到出问题的
该行. 如果该问题出在变数上而不是打字错误, 请确定你正确地宣告该变数. 另
一方面, 如果是打字错误, 就改正它 !
但是, 小心一件事, 这种错误有时候会与遗漏括号的错误结合在一起. 在这种情
形下, 不明 identifier 的问题常常是误报. driver 误读 {} 或其他东西, 而
导致变数宣告混淆. 因此在烦恼此种错误困扰之前, 请确定已修正所有其他的编
译时段错误.
与前述的错误同一级的是普通的语法错误. 当 driver 无法了解你写的东西时,
它就产生此种错误. 这又常是打字错误引起的, 却也是因为不了解某些特徵正确
的语法所致, 像是把 for() 叙述写成这样:
for(x=0, x<10, x++)
如果你像这样的错误, 却不是语法错误, 试著重新检查错误发生的叙述中, 语法
是否正确.
7.3 修正执行时段错误
执行时段错误比起编译时段错误要复杂得多. 幸运的是, 这些错误都有纪录, 但
是许多创作人并不了解, 或是他们不知道纪录在哪里. 执行时段错误的纪录一般
也纪录得比编译时段错误详细, 也就是你可以从它开始到它出错之处, 追踪执行
程序的过程. 所以你可以利用纪录档, 使用前编译器叙述 (precompiler
statement) 设置除错陷阱 (debugging trap). 但是, 执行时段错误常肇因于
复杂的程式撰写技巧, 而初学者并不使用这些技巧. 这表示你一般会碰上比简单
的编译时段错误还要复杂的错误.
执行时段错误几乎都是肇因于使用错误的 LPC 资料型态. 最常见的是, 试著用
NULL 值的物件变数做外界呼叫, 索引指向 NULL 值的映射、阵列、字串变数,
或函式传入错误的参数. 我们看一个 Nightmare 真实的执行时段错误:
Bad argument 1 to explode()
程式: bin/system/_grep.c, 物件: bin/system/_grep 第 32 行
' cmd_hook' in ' std/living.c' (' std/user#4002') 第 83 行
' cmd_grep' in ' bin/system/_grep.c' (' bin/system/_grep') 第 32 行
Bad argument 2 to message()
程式: adm/obj/simul_efun.c, 物件: adm/obj/simul_efun 第 34 行
' cmd_hook' in ' std/living.c' (' std/user#4957') 第 83 行
' cmd_look' in ' bin/mortal/_look.c' (' bin/mortal/_look') 第 23 行
' examine_object' in ' bin/mortal/_look.c' (' bin/mortal/_look') 第 78 行
' write' in 'adm/obj/simul_efun.c' (' adm/obj/simul_efun') 第 34 行
Bad argument 1 to call_other()
程式: bin/system/_clone.c, 物件: bin/system/_clone 第 25 行
' cmd_hook' in ' std/living.c' (' std/user#3734') 第 83 行
' cmd_clone' in ' bin/system/_clone.c' (' bin/system/_clone') 第 25 行
Illegal index
程式: std/monster.c, 物件: wizards/zaknaifen/spy#7205 第 76 行
' heart_beat' in ' std/monster.c' ('wizards/zaknaifen/spy#72 05') 第 76 行
除了最后一个以外, 所有的错误, 都对一个函式传入一个错误的参数. 第一个错
误, 是对 explode() 传入错误的第一个参数. explode() 外部函式要一个字串
当作第一个参数. 修正这类型的错误时, 我们会到 /bin/system/_grep.c 的第
32 行检查第一个传入参数到底其资料型态为何. 在此情况下, 传入的值应是字
串.
如果因为某些原因, 我实际上传入其他的东西, 我在此只要确定传入字串就能修
正错误. 但是在此情况要复杂得多. 我需要追踪传入 explode() 的变数值为何 ,
我才能知道传入 explode() 外部函式的值到底是什么.
出问题的那行是:
borg[files[i]] = regexp(explode(read_file(files[i]), "\n"), exp);
files 是一个字串阵列, i 是整数, borg 是映射. 所以很明显, 我们需要找出
read_file(file[i]) 的值到底是什么. 好, read_file() 这个外部函式传回一
个字串, 除非该档案根本不存在, 或是该物件没有权限读取该档案, 或是该档案
是个空的档案, 这些情形都会导致此函式传回 NULL. 很明显, 我们的问题是这
些情形的其中一种. 要找出是哪一种, 我们要看 file[i].
检查程式码, 这个档案阵列透过 get_dir() 外部函式取得它的值. 如果该物件
有权限读取此目录, get_dir() 就传回目录中所有的档案. 所以问题不在于权限
不足或档案不存在. 导致这个错误的档案一定是空的. 而且事实上, 这就是导致
错误的原因. 要修正此错误, 我们要透过 filter_array() 外部函式传入档案,
确定只有档案大小大于 0 的档案可以读入阵列.
修正执行时段错误的关键在于, 了解有问题的所有变数值在产生错误之时, 它们
确实的值为何. 你阅读你的执行时段错误纪录时, 小心地经由错误发生的档案分
辨物件. 举个例子, 上面的索引错误是物件 /wizard/zaknaifen/spy 产生, 但
是错误发生在执行它所继承的 /std/monster.c 函式.
7.4 故障的程式码
你所遇到最阴险的问题, 就是你程式码的行为不是预期中的行为. 物件顺利载入 ,
没有产生任何执行时段错误, 但是事情就是不对劲. 既然 driver 不可能认出这
种错误, 就没有任何纪录. 所以你需要一行接一行浏览整个程式码, 并搞清楚到
底发生了什么事.
第一步: 找出你已知能顺利执行的最后一行程式码.
第二步: 找出你已知开始出错的第一行程式码.
第三步: 从已知顺利执行的地方到第一个出错的地方, 检查程式码的流程.
常常, 这些问题出现于你使用 if() 叙述没有料到所有的可能情形. 举个例:
int cmd(string tmp) {
if(stringp(tmp)) return do_a()
else if(intp(tmp)) return do_b()
return 1;
}
在此段程式码中, 我们发现它编译和执行起来没有问题. 问题是它执行起来完全
没作用. 我们确定 cmd() 函式已经执行, 所以我们可以从此著手. 我们也知道
实际上 cmd() 传回 1, 因为我们输入此命令时, 没看到 "什么 ?" . 马上, 我
们可以看到因为某些原因, tmp 变数有字串或整数以外的值. 就此得到的答案是 ,
我们输入的命令没有参数, 所以 tmp 是 NULL , 并让所有的测试条件失败.
上面的例子相当简单, 几近于愚蠢. 但是, 它让你知道在修正故障的程式码时,
如何检查程式码的流程. 其他的工具能协助你除错. 最重要的工具就是使用前编
译器来除错. 以前面的例子来说, 我们有一个子句检查传入 cmd() 的整数. 我
们输入 "cmd 10" 时, 我们希望执行 do_b() . 进入回圈之前, 我们需要看 tmp
的值为何:
#define DEBUG
int cmd(string tmp) {
#ifdef DEBUG
write(tmp);
#endif
if(stringp(tmp)) return do_a();
else if(intp(tmp)) return do_b();
else return 1;
}
在我们输入命令之后, 立刻就知道 tmp 的值是 "10" . 回头看程式码, 我们会
怪自己愚蠢, 忘了我们把 tmp 当整数使用之前, 必须要用 sscanf() 把命令参
数转换成整数.
7.5 总结
修正任何 LPC 问题的关键是, 永远要知道你程式码中任何一步的变数值为何.
LPC 的执行降到变数值改变这种最简单的层级上, 所以程式码载入记忆体时, 不
正确的值导致错误发生. 如果你遇到函式有不正确的参数时, 常常是你对一个函
式传入 NULL 值的参数. 这情形常发生于物件, 因为大家常常会做以下的事:
1) 使用设定在一个物件中的值, 而该物件已经被摧毁.
2) 使用 this_player() 的传回值, 而根本没有 this_player().
3) 使用 this_object() 的传回值, 而 this_object() 刚好已被摧毁.
另外, 大家会常常遇上不合法的索引 (illegal indexing) 或索引指向不合法的
型态 (indexing on illegal types). 最常见的是因为有问题的映射或阵列没有
初始化, 所以无法索引之. 关键在于了解出问题的地方, 其阵列和映射的完整值 .
另外, 注意索引编号是否比阵列的长度还大.
最后, 使用前编译器暂时扔出或扔进显示变数值的程式码. 前编译器让你很容易
地删掉除错程式码. 你只需要在除错完毕之后, 删除 DEBUG 定义.
Copyright (c) George Reese 1993
译者: Spock of the Final Frontier 98.Jul.29.
-- ※ 来源:.月光软件站 http://www.moon-soft.com.[FROM: 202.100.198.52]
|
|