最近清理旧项目时翻到一个叫 fakefish 的小东西一支用简化版脚本语言 FakeLua 写的“大鱼吃小鱼”游戏。项目本身不算复杂但它让我重新琢磨了一件事玩法逻辑究竟该放在宿主层还是脚本层以及一门刻意“砍过一刀”的脚本语言到底适不适合拿来承载完整的游戏规则。这篇文章就把 fakefish 的整个搭建过程拆开聊一聊包括 FakeLua 运行时的接入、吞噬碰撞的判定、鱼群 AI 的行为组织以及我在存档、热更新和性能优化时踩过的几个隐蔽的坑。如果你正在考虑做类似的脚本化原型或者对嵌入式脚本的运行机制感兴趣这篇应该能帮你省掉不少弯路。fakefish 会起这么个名字纯粹是因为项目目录里先有了“FakeLua”后来想做一个验证玩法就跑通了经典的大鱼吃小鱼取名的时候顺手就拼成了 fakefish。它算是一个“规则在脚本层、引擎在宿主层”的典型例子移动端桌面端都能跑核心是证明一套干净的事件回调加实体句柄机制足以撑起一个完整的小游戏。1. 为什么我会用 FakeLua 来写游戏逻辑1.1 从硬编码到脚本化的转变最开始我写小游戏有个坏习惯所有鱼的移动、吞噬条件、AI 转向全写在宿主语言里比如 C 或者 JavaScript。结果就是玩法数值、碰撞阈值和渲染代码搅在一起改一条鱼的移动速度都得重新编译整个工程。更难受的是AI 逻辑稍微调一点很容易顺手把底层渲染或者输入系统也碰出问题。后来一个朋友点拨我说规则这种东西应该抽出去让程序只提供“能力”具体怎么玩交给脚本去描述。我试过几种方案最后落到 FakeLua 上。这个语言很像 Lua 5.1 的简化版保留变量、函数、表、循环、条件分支和协程但砍掉了元表、io、os 这些容易被误用的特性。它天然适合做“游戏规则描述语言”而 fakefish 就是这样一个实验场。把鱼的逻辑抽到 FakeLua 之后最大的变化是改数值不用重新编译改 AI 策略不用重新打包。玩着玩着觉得大鱼追小鱼追得太快直接改速度系数保存脚本再热加载就能看到效果。对于个人项目来说这个迭代速度的提升非常可观。1.2 FakeLua 的设计边界哪些功能值得保留有人会问既然要脚本化为什么不直接用完整版 Lua我当时也纠结过。完整版确实功能更多但 fakefish 这个体量的游戏根本用不到元表和完整标准库。FakeLua 砍掉的每一块背后都是有理由的。保留下来的核心特性是这几样基本类型number、string、boolean、table函数与闭包流程控制if、for、while基础字符串拼接和格式化简化版协程支持挂起和恢复table 是绝对要留的。游戏里的鱼、配置、存档、关卡信息几乎都能用 table 描述。一个 Fish 对象在脚本里就是一个包含 id、坐标、半径、速度的普通表宿主语言拿到这个表之后映射到内部实体交互非常自然。协程我也是强烈建议保留的。写 AI 行为时经常要表达“先游向目标等两秒再换个方向”这种顺序逻辑。用回调写会拆得稀碎用协程写就和写普通顺序代码一样舒服很多。关于协程怎么用于 AI第 4 章我会展开讲。至于为什么砍掉 io 和 os原因很简单fakefish 里的脚本不应该有直接读文件或执行系统命令的能力这些操作必须经过宿主的受控接口。否则脚本一旦写错整个游戏进程都可能被拖垮。安全边界也是脚本化设计中不能忽略的一环。1.3 宿主与脚本的分工fakefish 的分工思路可以概括成一句话宿主负责“世界是怎么运行的”脚本负责“世界该怎么运转”。具体来说宿主层负责窗口创建、资源加载、渲染、帧循环、碰撞粗筛、音频播放。脚本层负责 AI 决策、事件响应、成长数值、行为切换。两边用一套明确的 API 隔开脚本永远不直接操作渲染资源。我画过一张很粗糙的职责表实际开发时就是按这张表来约束自己的职责宿主层脚本层帧循环与渲染负责不参与碰撞粗筛负责不参与实体数据存储负责通过句柄访问AI 行为决策不参与负责吞噬后的成长计算不参与负责玩家输入预处理负责不参与输入映射为行为意图不参与负责一次完整的鱼移动流程大概是这样的脚本在 on_update 里决定这条鱼当前该往哪个方向游调用 fish:set_dir(angle) 这类 API宿主收到后根据鱼的当前速度计算新位置更新到实体列表里下一帧渲染就能看到效果。脚本不关心具体位移公式宿主不关心为什么要往这个方向游。2. 先把鱼拉起来fakefish 的框架搭建与运行时接入2.1 初始化脚本运行时fakefish 的入口是个很短的宿主启动流程创建 FakeLua 虚拟机注册游戏 API加载 main.lua然后进入主循环。用伪代码描述大概是vm fake_lua_create(); register_api(vm, spawn_fish, api_spawn_fish); register_api(vm, set_speed, api_set_speed); register_api(vm, get_fish_pos, api_get_fish_pos); register_api(vm, random, api_random); int result fake_lua_load_file(vm, main.lua);main.lua 里则约定好两个入口函数-- main.lua function on_ready() for i 1, 12 do spawn_fish(i, random_pos()) end end function on_update(dt) update_all_fish(dt) endon_ready 的意思很直白宿主启动完成后只调用一次用来初始化世界。on_update 则是每帧都会调的更新入口参数 dt 是距上一帧经过的秒数。这里我想多说一句关于 load 时机的问题。第一次做的时候我把 on_ready 里的代码直接写在了文件顶层没包在函数里结果宿主加载完脚本马上去找 on_ready 函数根本找不到。后来老老实实把初始化逻辑全放进 on_ready统一由宿主在合适的时机调用。这个约定虽然简单但能避免很多“脚本到底执行到哪一步了”的困惑。2.2 让脚本驱动画面事件回调的绑定fakefish 的帧循环维持在 60Hz 左右宿主每帧会做三件事处理输入、调用脚本 on_update、渲染。事件回调的绑定方式决定了脚本拿到的信息粒度。我的做法是宿主侧先做物理碰撞的粗筛发现两个实体有碰撞重叠之后再回调脚本里的 on_collide 函数把两个鱼的 id 传过去。脚本拿到 id 之后自行判断应该吞噬、逃跑还是无视。function on_collide(a_id, b_id) local a find_fish(a_id) local b find_fish(b_id) if not a or not b then return end if can_eat(a, b) then eat_fish(a, b) elseif can_eat(b, a) then eat_fish(b, a) end end这个设计的好处是脚本不需要自己遍历所有鱼对碰撞候选集已经在宿主层用网格或四叉树过滤过了。脚本拿到的是一个很小的集合处理起来非常快。需要注意回调里不要做耗时操作。一个错误的示范是在 on_update 里写一个几百层的 for 循环去全图扫描鱼正确的做法是把“找附近鱼”这个操作封装成宿主提供的查询接口内部用空间索引完成脚本只需要表达“我要附近五条鱼”。2.3 调试手段日志、断点与报错过滤脚本化开发的调试体验直接影响项目推进速度。fakefish 里我做了三件事每一件都值得抄走。第一是带前缀的日志输出。所有脚本里的 print 调用都会在控制台显示成[SCRIPT]开头和宿主日志区分开。脚本打印位置、速度、状态切换都很方便。第二是错误调用栈。FakeLua 在运行时报错时除了错误信息还会把脚本层面的调用栈打出来。没有这个我怀疑自己根本坚持不到做完整玩法因为脚本一旦出错你连错在哪个函数里都找不到。第三是一个简单的监视面板可以实时显示指定鱼的位置、半径、当前 AI 状态和速度。调试 AI 时看着状态字段一个个跳很多逻辑问题一眼就能看穿。我建议第一次跑通 fakefish 时不要一上来就写完整玩法先写一个最简脚本屏幕上出现一条静止的鱼然后让它动起来然后再接碰撞。每走完一步都确认一下回调链路是通的再往上面叠新东西。直接一把梭写满几十行逻辑出问题的概率会大很多。3. 核心玩法拆解吞噬、成长与碰撞判定3.1 实体建模一条鱼有哪些状态fakefish 里一条鱼在脚本层的表示就是一个普通 table字段大概是这样的FishBasic { id 1, x 100, y 100, r 15, speed 120, move_dir 0, ai_mode wander, }这里用了 id 作为宿主和脚本之间通信的句柄。为什么不用 table 直接传因为宿主内部的实体可能还带着渲染资源、音频组件、碰撞代理等一大堆东西把整个对象透传给脚本既重又危险。用 id 引用脚本只碰自己需要的字段宿主可以完全控制数据同步。另外我把“逻辑半径”和“视觉大小”分开了。逻辑半径负责碰撞和吞噬判定视觉大小负责渲染和缩放动画。两者通过一个换算函数关联比如视觉半径等于逻辑半径乘以一个动画系数。这样鱼在吞噬前后可以做一段“先膨胀再回缩”的动画不会影响真实碰撞判定。3.2 碰撞与吞噬判定半径不是万能的碰撞判定用最常见的两圆检测两鱼距离小于半径之和就认为发生碰撞。function is_colliding(a, b) local dx a.x - b.x local dy a.y - b.y local rr (a.r b.r) * (a.r b.r) return dx * dx dy * dy rr end这个公式先用距离平方比较避免不必要的开方运算。如果你更看重代码可读性也可以先开方再比较fakefish 里我封装了 is_colliding 之后内部用平方版本外部读代码不需要关心性能细节。但碰撞和吞噬是两件事。碰撞之后要判断谁吃谁不能只看谁半径大。我加了一个吞噬阈值大鱼半径要大于小鱼半径的 1.15 倍才允许触发吞噬行为。这样做的原因是画面里有两群大小差不多的鱼互碰时不会有隔空速吃的违和感。阈值调太大会显得鱼很迟钝调太小会显得误吞频繁1.15 是我试了几个值之后感觉比较自然的系数。这里还有个容易踩的边界场景一条小鱼身边同时出现两条能吃掉自己的大鱼时往哪个方向逃如果只按“离自己最近的那条”来逃很可能刚好逃向另一条大鱼的嘴边。fakefish 的解法是给每个威胁算一个评分评分等于大鱼半径 / 当前距离然后按照评分从高到低排序先处理最危险的。实际写出来就是function find_danger(fish) local list query_nearby(fish, alert_range fish.r) local danger nil local best_score 0 for _, other in ipairs(list) do if other.r fish.r * 1.15 then local dist distance(fish, other) local score other.r / math.max(dist, 0.01) if score best_score then best_score score danger other end end end return danger end吞噬发生时被吃掉的鱼要从实体列表中移除吞噬方增加分数同时更新半径。这个逻辑放在脚本层做的好处是你可以随意调整成长公式和得分规则不用碰宿主代码。3.3 成长曲线和数值表的设计fakefish 的鱼按大小和危险程度分成几类配置直接写在脚本里用 table 保存fish_types { fish_small { r 15, speed 130, score 10 }, fish_medium { r 28, speed 115, score 30 }, fish_big { r 45, speed 100, score 80 }, fish_poison { r 22, speed 110, score 0 }, }这个表的作用范围很大生成鱼时按类型取参数碰撞判定时查半径得分时查分数。AI 在选择目标时也可以根据类型判断“眼前这个东西吃不吃得下”。半径成长我用的不是简单的加法而是按面积相加来反推半径。一条鱼的逻辑面积等于数学意义上的圆面积吃掉一条鱼后新面积等于旧面积加上被吃鱼的面积然后反求出新半径。function grow_fish(fish, eaten) local old_area math.pi * fish.r * fish.r local eaten_area math.pi * eaten.r * eaten.r local new_area old_area eaten_area fish.r math.sqrt(new_area / math.pi) end这样做的好处是视觉比例自然。按面积相加的话吃一条小鱼不会让大鱼视觉上突然膨胀一大圈吃一条大鱼才会有明显变化。如果按半径直接相加小鱼吃一条中鱼后视觉半径可能直接翻倍很夸张。速度也不是固定的。鱼变大之后速度会轻微下降否则大鱼追小鱼会容易到没有游戏性。fakefish 里速度曲线是function current_speed(fish) local ratio fish_inital_speed * math.pow(base_r / fish.r, 0.3) return math.max(ratio, 40) end最后用 math.max 限制最低速度防止鱼长到很大之后完全跑不动。这个 0.3 次方的衰减指数也是试出来的太大后期懒得动太小大鱼还是太快。4. 鱼群的 AI在 FakeLua 里写“游动策略”4.1 最简单的游动随机转向与边界反弹fakefish 里大多数鱼并没有特别聪明的行为漫游wander是基础状态。每条鱼有一个 move_dir 表示当前移动方向还有一个 move_timer 表示距离下一次随机换方向还有多少秒。function wander(fish, dt) fish.move_timer fish.move_timer - dt if fish.move_timer 0 then fish.move_dir random_dir() fish.move_timer random_range(2, 4) end local nx, ny nearest_boundary_point(fish) if too_close_to_boundary(fish, 30) then fish.move_dir angle_to(nx, ny, fish.x, fish.y) end end逻辑很简单每 2 到 4 秒随机换一次方向如果太靠近边界就把方向改成朝场内修正。这里的随机数来自宿主暴露的 random() 接口而不是脚本自己用当前时间戳算主要是为了以后做存档回放时能保证随机序列可复现。漫游看起来不起眼但它决定了整个游戏的“手感”。方向切换频率太快鱼会像抽风一样抖动太慢又会显得呆板。2 到 4 秒区间是我调出来的一个折中值你可以根据自己的画面尺寸微调。4.2 危险感知与逃跑/追逐fakefish 的每条鱼每帧都会执行一次决策函数很短function decide(fish) local danger find_danger(fish) if danger then return flee, danger end local food find_food(fish) if food then return chase, food end return wander, nil end优先级非常简单危险 食物 漫游。一旦视野范围内出现能吃掉自己的鱼不管眼前有多少食物先保命要紧。找不到危险但能找到食物就追最后才漫游。追逐目标时有个细节容易忽略如果一直朝目标当前坐标游目标鱼移动之后你就需要反复修正方向视觉上会一直左右抖。fakefish 的追逐函数会预测目标未来位置用目标的移动方向和速度粗略估算它 0.2 秒后的坐标再朝预测点游。这样追起来平滑很多。逃跑时我也没有直接朝着“远离大鱼的直线方向”逃而是计算大鱼与小鱼形成的连线再选择一个与连线垂直且离最近边界更远的方向。直接反向逃的话很容易被逼到墙角反而是找死。切线逃跑通常能绕开威胁配合边界反弹能拖出不少生存时间。4.3 用协程组织 AI 行为状态fakefish 里最有意思的部分就是用协程来组织 AI 状态机。多状态 AI 如果用回调写状态切换和延迟等待都会拆散到各个函数里读起来非常痛苦。FakeLua 的协程让代码保持顺序结构ai_task coroutine.create(function(fish) while fish.alive do local state, target decide(fish) if state flee then flee_from(fish, target, 1.0) wait(0.5) elseif state chase then chase_fish(fish, target, 1.0) wait(0.2) else wander(fish, 0.1) wait(0.2) end end end)这里的 wait 会把当前协程挂起host 收到协程挂起的信号后会在内部维护一个等待队列等到时间到了再把它 recovery。这样一条鱼的行为流程就是线性的逃跑 1 秒等 0.5 秒再判断一次新情况如此循环。用协程写 AI 的最直观收益是你能在大脑里顺着一行一行读代码把它读成一个完整的行为故事而不是读函数指针和回调注册。fakefish 里的每条鱼都持有一个独立的协程实例状态互不干扰。需要特别提醒的是FakeLua 的协程不会做超时抢占也不会限制死循环。如果你在协程里写了一个不带 wait 的 while true宿主主线程就会被一直卡住游戏直接白屏。所以脚本层的循环要么带明确的退出条件要么循环体内必须调用 wait 或其他可以暂停执行的操作。这个习惯我从第一次遇到死循环之后就一直保持到现在。5. 实际踩过的坑存档、热更新和执行效率5.1 存档与序列化的坑fakefish 一开始是没有存档功能的后来想加上“退出后重新进鱼的状态不变”。第一次实现踩了很大的坑我直接遍历所有鱼对象的 table想把字段全部写入文件结果发现这些 table 里混进了函数字段、协程对象和宿主句柄序列化出来不是 nil 就是报错。解决方案是做“存档专用视图”。每个实体类提供一个 export_data 方法只返回可持久化的字段id、类型、位置、半径、当前状态、分数。加载存档时再用一个 create_fish_from_data 工厂方法重建实体。这样既能保证脚本层的数据干净也能避开函数和协程序列化的问题。存档格式里我还加了一个 version 字段。后来加了新字段或改了字段名老存档还能按版本号做兼容处理。比如旧存档里没有 ai_mode 字段加载时就默认填 wander避免一读旧档就报错。5.2 热更新脚本时的资源管理开发 fakefish 过程中最提升效率的就是热更新。改完 AI 逻辑直接重载脚本不用重新编译宿主工程。第一次做热更新出现的问题是重启了脚本但场上已经存在的鱼仍然引用着旧函数闭包改了半天新逻辑根本没生效。后来我把热更新分成两种情况来处理。如果只是改配置数值比如 fish_types 表里的速度那就只重新加载配置文件让所有鱼在下一帧重新读取自己的速度配置逻辑函数不动。如果改的是 AI 行为函数那就不能只重载脚本文件了必须把场上受控实体全部销毁按当前状态重建。销毁之前先把每条鱼的坐标、半径、分数记录下来重建之后再填回去玩家不会感觉到明显中断。还有一个小建议开发期把配置和逻辑拆成两个文件配置单独放一份。否则你只想改一个数值却因为整个脚本文件哈希变了被迫把整个逻辑也重载一遍容易引发一堆意外状态。5.3 每帧调用开销与缓存优化脚本化开发的性能瓶颈几乎都在跨语言调用上。fakefish 初期我让每条鱼每帧都从脚本层查询属性鱼一多就明显掉帧。后来做了一次粗略估算假设有 300 条鱼每帧每条鱼要调 3 次 API每秒 60 帧一秒钟就有 54000 次跨语言调用。即使单次调用开销不夸张加起来也很可观。fakefish 最后做了三个优化。一个是节流。AI 决策不是每帧都需要做改成每 0.2 秒决策一次就够了中间帧继续沿用上一次的方向。视觉上看不出差别调用次数直接降到原来的十分之一。另一个是批量接口。单个鱼的移动参数不再一次一次调用而是提供一个 update_fishes(list) 接口一次把一批鱼的移动方向、速度传进宿主宿主统一更新位置。把调用次数从 N 次降到 1 次。第三个是把粗筛完全留在宿主侧。碰撞检测、视野查询都用宿主侧的空间网格来做。脚本层永远只在候选集已经非常小之后再做精细判断。优化完之后fakefish 跑到几百条鱼依然很流畅说明脚本化开发的上限比很多人想的要高前提是性能敏感路径不能硬塞进脚本。6. 从 fakefish 里提炼出的可复用经验6.1 脚本 API 的设计要面向“行为”而不是“对象”fakefish 一开始的 API 设计很直接就是暴露实体字段脚本里可以写 fish.x 100 直接改位置。后来发现这样很容易出问题脚本和宿主对同一份坐标可能有各自的缓存改完一边另一边拿不到还会绕过宿主的位置校验导致鱼跑到地图外面或者卡进障碍物。正确的做法是把暴露字段改成暴露行为。脚本不再关心具体坐标怎么改而是调用 fish:move_to(x, y)、fish:turn_away(x, y)、fish:set_dir(angle) 这类意图明确的接口。宿主收到行为调用后可以统一做边界限制、碰撞规避、插值平滑这些事。我在设计接口时给自己定了一个规矩如果脚本正在表达的意图是“我想干什么”那就是一个好的 API如果脚本表达的是“我要改哪个具体符号”那就要警惕数据同步问题。这个原则在后来做其他脚本化项目时一直沿用。6.2 合适颗粒度的错误处理脚本层跑着几十条鱼的 AI任何一条鱼的逻辑出问题都不应该让整个游戏崩溃。fakefish 里我按实体粒度做错误隔离。每条鱼每次被 recovery 时宿主都会单独捕获异常如果某条鱼的脚本执行出错就打印错误堆栈把这条鱼标记为停更状态让它留在原地不再移动游戏整体不受影响。开发模式下的错误处理会严格一些直接弹出详细日志方便定位是哪个函数哪一行写错了。FakeLua 的报错信息自带文件名和行号程序员模式下排查问题非常快。我一直觉得脚本语言的错误信息质量直接决定这个方案能不能真正落地。如果报错只有一句“runtime error”脚本一多就没法维护。fakefish 里花了不少功夫在错误信息的上下文上这个投入是值得的。6.3 还能往哪些方向扩展fakefish 的脚本框架如果保持干净玩法扩展会很快。比如关卡系统可以做成纯数据表由脚本层读取并动态生成不同鱼群配置技能系统可以在脚本里写冲刺、冲击波、短暂无敌等函数宿主只提供对应特效播放和时序控制接口排行榜和成就系统也只需要把玩家得分类字段透传给宿主即可。甚至可以把这套框架直接改造成一个弹幕 Demo因为实体管理、碰撞回调、协程任务调度这些基础设施都是现成的换一套玩法逻辑就是换一组脚本函数而已。每次想到这里我都会感叹边界的价值在复用时体现得最明显。提示如果你也想做一个类似的脚本化游戏我建议第一天就把宿主 API 设计成“行为接口”不要图省事直接暴露对象字段。后面会因为这一条少踩非常多坑。最后说点实在的做完 fakefish 之后我最大的感受是脚本层和宿主层的边界一定要在项目第一天就划清楚。划清了后续所有玩法迭代都不会返工划不清后面每加一个功能都像在两个泥潭之间穿梭。我在 fakefish 后期调整 AI 逻辑时几乎不需要重新编译改完脚本立刻就能上手试手感。这种体验对于个人项目来说太值了。如果你也想做类似的东西建议别一上来就追求完整系统先跑通一个最小闭环——一只会动的鱼、一条能吃掉它的大鱼、一个简单的追逐关系然后再从那里把系统一层一层撑开。宁可边界划小一点也别让脚本包打天下。