|
一、设计理念:把文本文件当轻量数据库用
理解这套接口,先要理解它的设计定位。这不是通用文件 IO,而是一套行式文本数据库操作接口。
传奇脚本的传统里,很多数据存在文本文件里,每行一条记录。比如 GM 名单文件,每行一个 GM 名字;黑名单文件,每行一个被封禁的玩家;随机台词文件,每行一句台词。这种存储方式简单、直观、易编辑 —— 用记事本就能打开修改,不需要数据库工具。
但简单的存储带来一个问题:操作起来麻烦。要查某个名字在不在名单里,得打开文件逐行读;要随机取一句台词,得先知道总行数再随机;要删除某一行,得读全部内容、跳过目标行、再写回去。这些操作如果每次都手写 Lua 代码,既繁琐又容易出错。
VV 引擎的这套文本文件接口,就是把这些常用操作封装成了专用函数。你不需要自己写文件读写循环,直接调接口就能完成查、取、删、写。接口的设计完全围绕 "行式文本" 的使用场景展开,每一个接口对应一个常见操作。
这种设计思路和通用文件 IO 形成了鲜明对比。通用 IO 是 "给你最基础的读写能力,复杂操作自己组合";而这套接口是 "常见操作我都封装好了,你直接调"。前者灵活但繁琐,后者简单但有局限。对于传奇脚本这种 "行式文本就是主要数据存储" 的场景,后者的效率更高,开发者用起来更省心。
二、基础操作:创建、写入、读取、删除,围绕行展开
文本文件的基础操作有四个:创建、写入、读取、删除。每个操作都针对 "行" 来设计。
创建用 createfile,就一个路径参数。文档里的示例是在 Envir\QuestDiary 目录下创建文件,路径用相对路径 ..\\QuestDiary\\abc.txt。这个相对路径的基准是脚本运行目录,所以用 .. 回到上一级再进 QuestDiary。实际使用的时候要注意路径的写法,Windows 下用双反斜杠 \\,不要用单反斜杠(Lua 里单反斜杠是转义符)。
写入用 addtextlist,传路径、文本、行号。这个接口的设计很有意思 —— 它不是 "追加到文件末尾",而是 "写入指定行"。行号从 0 开始,你可以指定写到第几行。如果那一行已经有内容,会被覆盖;如果那一行不存在,会自动扩展。
更妙的是,文本参数支持用 | 分隔一次写多行。比如传 aaa|bbb|ccc|ddd|eee 写到第 5 行,就会把 aaa 写到第 5 行、bbb 写到第 6 行、ccc 写到第 7 行,依此类推。这个设计让批量写入变得很方便,不需要循环调用多次接口,一次调用就能写多行。
读取有三个接口,覆盖不同场景。 getrandomtext 可以随机取一行,也可以指定行号取 —— 传 - 1 是随机,传具体数字是取指定行。做随机台词、随机事件、随机奖励的时候用这个接口非常方便,不需要自己算总行数再随机。 getliststring 取指定行的内容,这个后面单独讲,因为它有特殊的分割功能。
删除用 deltextlist,有三种模式:删除指定行、清空指定行(内容清空但行还在)、删除随机行。三种模式覆盖了不同的删除需求。 clearnamelist 是清除整个文件的全部内容,文件还在但内容空了。
基础操作的设计思路很清晰:每个操作都针对行,支持批量,覆盖常见场景。写入支持指定行和批量多行,读取支持随机和指定,删除支持行删除、清空、随机删除。这些都是行式文本操作中最高频的需求,引擎全部封装好了。
三、查询体系:精确匹配、包含匹配、位置查找,三种查询方式
查询是文本文件操作中最高频的需求 —— 查某个名字在不在名单里、查某条记录在第几行。这套接口给了三种查询方式,覆盖不同的匹配需求。
精确匹配用 checktextlist,检查字符串是否在文件中,不区分大小写。这个接口做的是整行完全匹配 —— 文件里有一行和你传的字符串完全一样(忽略大小写),就返回 true。做 GM 名单、黑名单、白名单的查询时用这个,比如判断玩家是不是 GM,就查玩家名字在不在 GM 名单文件里。
"不区分大小写" 这个细节很贴心。玩家名字可能有大小写变化,如果区分大小写,"ABC" 和 "abc" 会被当成两个人,可能导致查询失败。不区分大小写就避免了这个问题。
包含匹配用 checkcontainstextlist,这个更灵活,支持两种模式。模式 0 是 "列表中的行是否包含被检测的字符串"—— 比如文件里有一行是 "abcdef",你查 "bcd",返回 true,因为 "abcdef" 包含 "bcd"。模式 1 反过来,是 "被检测的字符串是否包含列表中的某一行"—— 比如文件里有一行是 "abc",你查 "abcdef",返回 true,因为 "abcdef" 包含 "abc"。
两种包含模式覆盖了不同的应用场景。模式 0 适合做关键词过滤 —— 文件里是敏感词列表,检测玩家发言是否包含任何敏感词。模式 1 适合做前缀 / 后缀匹配 —— 文件里是允许的 IP 前缀,检测某个 IP 是否匹配。
位置查找用 getstringpos,返回字符串在文件中的行号,未找到返回 9999999。这个接口不只是查 "在不在",还告诉你 "在第几行"。做需要定位行号的操作时用这个 —— 比如你要修改或删除某条记录,先查行号,再用行号去操作。
9999999 这个未找到返回值是个有趣的设计。它不是 - 1 也不是 nil,而是一个很大的数字。这可能是因为行号从 0 开始,-1 可能和某些逻辑冲突,用一个超大数字可以明确表示 "不存在"。实际使用的时候判断返回值是否等于 9999999,或者是否小于某个合理的行数上限。
三种查询方式的设计思路是:从精确到模糊,从 "在不在" 到 "在哪",逐步覆盖更复杂的查询需求。简单的名单查询用精确匹配,关键词过滤用包含匹配,需要定位用位置查找。开发者根据场景选合适的接口,不需要自己写遍历和匹配逻辑。
四、分割读取:冒号分割和自定义符号分割,把一行拆成多列
文本文件操作中最有特色的设计是分割读取。一行文本不只是一个字符串,还可以是 "键:值" 的格式,读取时自动拆分。
getliststring 接口有两个返回值。正常情况下第一个返回值是整行内容,第二个是空。但如果这一行里有冒号 :,接口会自动把冒号前面的内容作为第一个返回值,冒号后面的作为第二个返回值。比如一行是 aaa:99999,读取后第一个返回 "aaa",第二个返回 "99999"。
这个设计把文本文件变成了简单的键值存储。每行一个 "键:值" 对,读取时自动拆分,不需要自己写字符串分割。做简单的配置文件、数据记录时非常方便 —— 比如玩家积分文件,每行是 "玩家名:积分",读取时直接拿到名字和积分两个值。
getliststringex 更进一步,可以自定义分割符号。传一个符号参数(比如 |),接口会按这个符号把整行拆分成一个 table 返回。比如一行是 aaa|bbb|ccc|ddd|eee,按 | 分割后返回一个包含五个元素的 table。
这个接口让一行文本可以存储多列数据,相当于把文本文件变成了简单的表格。每行一条记录,每列用指定符号分隔,读取时自动拆成数组。做复杂一点的数据存储时用这个 —— 比如物品配置,一行是 "物品名 | 数量 | 绑定状态 | 描述",读取后直接拿到各列的值。
分割读取的设计思路是:在纯文本的基础上,提供结构化读取的能力。纯文本简单但不够结构化,数据库结构化但太重。分割读取在两者之间找到了平衡点 —— 存储还是纯文本,编辑方便,但读取时可以自动拆分成键值对或数组,满足结构化数据的需求。
这里有个心得:做文本数据存储时,提前规划好格式。简单的键值对用冒号分隔(配合 getliststring),多列数据用自定义符号分隔(配合 getliststringex),纯列表就一行一个值。格式定好了,读取和写入都方便,不要一会儿用冒号一会儿用竖线,混乱了不好维护。
五、应用场景:这套接口在实际开发中能做什么
文本文件操作接口虽然简单,但应用场景非常广泛。结合传奇脚本的开发实践,总结几个最常见的用法。
名单管理是最高频的场景。GM 名单、黑名单、白名单、VIP 名单、封禁名单,都是一行一个名字的文本文件。玩家登录或执行操作时,用 checktextlist 查名字在不在名单里,决定是否有权限。需要添加或删除名单时,用 addtextlist 写入或 deltextlist 删除。这种方式比数据库简单,比硬编码在脚本里灵活(改名单不需要改脚本重启)。
随机抽取是另一个高频场景。随机台词、随机事件、随机奖励、随机 NPC 名字,都可以把选项存在文本文件里,每行一个,用 getrandomtext 随机取一行。做随机抽奖时,把奖品存在文件里,随机取一行就是中奖结果。这种方式的好处是奖品配置可以随时改文件,不需要改代码。
配置读取是更通用的场景。游戏里的各种配置 —— 活动参数、掉落概率、刷怪数量、经验倍率 —— 都可以存在文本文件里,用 getliststring 或 getliststringex 读取。用冒号分隔存键值对,用竖线分隔存多列数据。脚本启动时读取配置,运行时使用。改配置只需要改文件触发热重载,不需要改脚本。
数据持久化是更进阶的用法。玩家的某些数据(比如个人积分、累计登录天数、特殊标记)可以存在文本文件里,每行一个玩家的记录。用 getstringpos 查玩家在第几行,用 getliststring 读数据,用 addtextlist 写数据,用 deltextlist 删记录。虽然不如数据库高效,但对于数据量不大、结构简单的场景,文本文件完全够用,而且不需要搭数据库环境。
日志记录也可以用文本文件。重要操作(比如 GM 命令、玩家交易、异常事件)可以用 addtextlist 追加写入日志文件,每行一条记录,带时间戳。虽然专业日志系统更好,但简单的日志需求用文本文件完全能满足。
这些应用场景的共同特点是:数据量不大、结构简单、需要易编辑、不需要复杂查询。在这些场景下,文本文件比数据库更轻量、更方便,是传奇脚本开发中不可或缺的数据存储方式。
六、和 Lua 原生文件操作的对比与选择
Lua 本身也有文件操作能力(io 库),可以打开文件、读写、关闭。那为什么还要用引擎提供的这组文本文件接口?两者的区别在哪里?
引擎接口更简单,针对行式文本优化。 用 Lua 原生 io 库读文件,需要 open、read、close,还要自己处理行分割、编码、错误处理。用引擎接口,一个函数调用搞定,不需要关心文件句柄和底层细节。对于行式文本的常见操作(查、取、删、写),引擎接口的效率远高于自己写 Lua 代码。
引擎接口更安全,有错误处理。 原生 io 操作如果文件不存在、权限不足、路径错误,可能会报错甚至崩溃。引擎接口封装了错误处理,文件不存在会返回合理的默认值(比如查询返回 false、读取返回空),不会因为文件操作异常导致脚本中断。
引擎接口和引擎的热重载机制集成。 传奇引擎支持脚本热重载,文本文件的改动可能也会被引擎监控。用引擎接口操作的文件,可能和引擎的文件缓存、热重载机制有集成,行为更一致。用原生 io 操作的文件,可能绕过引擎的缓存管理,导致读写不一致。
原生 io 更灵活,能做复杂操作。 引擎接口只能做行式文本的简单操作,如果需要二进制文件、复杂格式解析、大文件流式处理,引擎接口就不够用了,这时候需要用 Lua 原生 io 库或者其他方式。
实际开发中的选择原则是:行式文本的简单操作用引擎接口,复杂的文件操作用原生 io。 大部分传奇脚本的文件需求都是行式文本(名单、配置、记录),所以引擎接口能覆盖绝大多数场景。只有在特殊需求下才需要原生 io。
七、几个实际使用中的心得和坑
把文本文件操作过完,结合传奇脚本的开发经验,总结几个心得和容易踩的坑。
第一,路径写法要注意。 文档里用的是相对路径 ..\\QuestDiary\\abc.txt,双反斜杠是因为 Lua 里单反斜杠是转义符。不要写成 ..\QuestDiary\abc.txt,单反斜杠会被当成转义符导致路径错误。也不要写成绝对路径,不同服务器的安装目录不一样,绝对路径移植性差。
第二,行号从 0 开始,不是从 1 开始。 文档里的示例第一行写的是行号 0,第二行是 1。很多人习惯从 1 开始数行,会写错行号。记住第一行是 0,写入和读取时都要注意。
第三,getstringpos 未找到返回 9999999,不是 - 1 也不是 nil。 判断的时候要写 if pos == 9999999 then 或者 if pos < 9999999 then,不要写 if not pos then 或者 if pos == -1 then,那样判断永远不成立。
第四,checktextlist 不区分大小写,checkcontainstextlist 注意两种模式的区别。 精确匹配不区分大小写是好事,但有时候你可能需要区分大小写,这时候引擎接口做不到,得自己写。包含匹配的两种模式容易搞混 —— 模式 0 是 "列表包含检测串",模式 1 是 "检测串包含列表项",用之前想清楚是哪种方向。
第五,addtextlist 写入指定行会覆盖原有内容,不是插入。 如果你想在中间插入一行,不能直接写 —— 直接写会覆盖那一行原来的内容,后面的行不会后移。需要插入行的话,得自己读全部内容、在中间插入、再全部写回,或者换一种存储方式。引擎接口没有 "插入行" 的功能,只有 "覆盖写入"。
第六,文件编码要注意。 文本文件的编码(GBK、UTF-8 等)要和引擎的预期一致,否则中文会乱码。传奇引擎一般用 GBK 编码,创建和编辑文件时注意编码设置,不要用 UTF-8 保存,否则读取出来的中文是乱码。
第七,并发写入可能有问题。 多个玩家同时操作同一个文件(比如都在写同一个积分文件),可能会有并发冲突 —— 后写的覆盖先写的,或者写入不完整。对于高频并发写入的场景,文本文件不是好选择,应该用数据库或者加锁机制。文本文件适合低频写入、高频读取的场景。
第八,大文件性能差。 文本文件的查询和读取是逐行扫描的,文件越大越慢。如果文件有几千几万行,每次查询都要扫一遍,性能会很差。大数据量的场景应该用数据库,文本文件适合小数据量(几百行以内)的场景。
第九,删除行后行号会变。 deltextlist 删除一行后,后面的行会前移,行号会变化。如果你先查了某条记录的行号,然后删除了前面的某一行,原来的行号就失效了。做批量删除或修改时,注意行号变化的问题,最好从后往前操作,或者每次操作后重新查行号。
第十,clearnamelist 是清空内容不是删除文件。 文件还在,只是内容空了。如果你想彻底删除文件,这个接口做不到,需要用其他方式(比如 Lua 原生 io 的 os.remove)。
|