JS游戏引擎单片机逻辑实现详解
本文要介绍的内容比较有意思,笔者尝试在团队自研的 JS 游戏引擎里复刻大学时期做过的电子设计大赛中的一道题目:基于 LDC1000 的循迹小车,最终实现的效果如下:
题目描述
地面上存在着一个由金属围成的轨道圈,有一辆小车,车头有个 LDC1000。LDC1000 就是一种金属传感器,能够检测到靠近的金属,并产生涡电流。金属轨道有一定宽度,LDC1000 是一个小矩形线圈,轨道与线圈重合的面积越大,产生的涡电流也就越大。现在需要让这辆小车能够自动沿着轨道圈跑,当然方式是单片机编程。
算法描述
算法的基本思路是实时读取 LDC1000 的涡电流,通过涡电流的变化,判断小车是否偏离轨道,继而调整小车的运动行为,伪代码如下:
- 读取涡电流,若涡电流高于某个阈值,则前进一小段距离,否则进入 2;
- 小角度右转弯,读取涡电流,若涡电流比 1 中的大,则进入 1,否则进入 3;
- 回正方向,小角度左转弯,读取涡电流,若涡电流比 1 中的大,则进入 1,否则进入 4;
- 降低速度,前进一小段距离,进入 1。
可以看出,这是一段循环逻辑。
问题描述
循迹算法并不难,笔者面临的真正问题是:如何使当前的游戏引擎支持实现这种带循环的单片机逻辑?
游戏引擎的基本逻辑是以 requestAnimationFrame 驱动 tick 函数,tick 函数里会去执行修改状态数据的逻辑,然后再将状态数据同步到物理引擎、渲染引擎。在笔者团队自研的游戏引擎里,修改状态数据的逻辑可以通过编写游戏脚本注入。
简单来说,就是将循迹算法封装为一个 javascript 函数,然后交由游戏引擎去执行,这个函数对应的就是单片机的逻辑。
function main({ scene, car }) {
// 控制小车的逻辑
}
那么现在的问题就是:游戏引擎该如何去运行这个 main 函数呢?
同步实现
游戏引擎的 tick 机制本身就具有循环性,所以比较直接的思路是每一帧运行一次 main 函数,并且 main 是个同步函数。
这里有个问题:当前的游戏引擎是基于 ECSM 架构,由于这个架构,在 main 函数里设置小车的状态并不是立即生效的,至少在 main 函数执行完前不会生效。而且实际的情况是,比如在当前帧设置小车转过某个角度,要在下一帧才能看到小车真的转过这个角度。
有种办法是增加标记变量,在当前帧标记已经设置小车转过某个角度,在下一帧根据标记变量来判断小车已经转过这个角度。
// 示例代码
function main({ scene, car }) {
const dataThreshold = 0.22;
const data = car.getEddyCurrent();
if (data > dataThreshold) { // move
car.setSpeed(10);
car.store.isScanning = false;
} else {
const scanRadBase = 2;
const scanRadStep = 1;
if (!car.store.isScanning) {
car.stop();
car.store.isScanning = true;
car.store.dataMiddle = data;
car.store.scanCount = 0;
car.store.scanRad = scanRadBase;
} else {
if (car.store.scanCount === 0) { // scan right
car.turn(-car.store.scanRad);
car.store.scanCount++;
} else if (car.store.scanCount === 1) { // check right
if (data >= car.store.dataMiddle) { // right pass
car.store.dataMiddle = data;
car.store.scanCount = 0;
car.store.scanRad = scanRadBase;
} else { // scan left
car.turn(2 * car.store.scanRad);
car.store.scanCount++;
}
} else if (car.store.scanCount === 2) { // check left
if (data >= car.store.dataMiddle) { // left pass
car.store.dataMiddle = data;
car.store.scanCount = 0;
car.store.scanRad = scanRadBase;
} else {
car.turn(-car.store.scanRad);
car.setSpeed(5);
car.store.dataMiddle = data;
car.store.scanCount = 0;
car.store.scanRad += scanRadStep;
}
}
}
}
}
上面这种写法存在两个问题:
- 因为受限于 ECSM 架构,对小车的状态设置是延迟到下一帧生效的,所以需要额外地增加标记变量(如 scanCount),这使得编写、理解代码变得很复杂,要知道算法伪代码里就 4 个步骤;
- 在这每一帧都会被执行的 main 函数里,临时变量只能被记录在 car 的 store 上面,导致缺少一定的编码自由度(灵活性),这个可以通过对比现实单片机里的运行程序来感受:
function main({ scene, car }) {
let x = 0; // 临时变量
while(true) {
x++; // 修改临时变量
}
}
笔者希望引擎能提供和现实单片机编程类似的编程体验。
异步实现
针对第一个问题,解决方法是将 main 函数变为异步,相应地,car 的各个方法也需要变为异步。
针对第二个问题,解决方法是参考实际单片机的运行程序改写 main 函数,main 函数只会被启动运行一次,而循环逻辑放到 main 函数的 while 中。
// 示例代码
async main({ scene, car }) {
const dataThreshold = 0.22;
const scanRadBase = 2;
const scanRadStep = 1;
let scanRad = scanRadBase;
while(true) {
if (car.getEddyCurrent() > dataThreshold) { // move
await car.aSetSpeed(10);
scanRad = scanRadBase;
} else {
await car.aStop();
const dataMiddle = car.getEddyCurrent();
// 这里的 await 能保证小车真正转过 -scanRad 角度后再执行下面的逻辑
await car.aTurn(-scanRad); // scan right
if (car.getEddyCurrent() < dataMiddle) { // right not pass
await car.aTurn(2 * scanRad); // scan left
if (car.getEddyCurrent() < dataMiddle) { // left not pass
await car.aSetSpeed(5);
scanRad += scanRadStep;
}
}
}
}
}
可以看出代码简洁了许多,更重要的是和实际单片机的编程体验很接近了。而 car 的各个方法的异步实现也简单,本质就是一个 async 函数,内部需要等待一帧:
car.setMethod('aTurn', async function (angle) {
// 设置 car 的角度
// ...
// 等待一帧,让设置生效
await director.delay(1);
});
Coroutine
我们知道,上面 async main 函数的运行特点是:当遇到 await 一个异步任务时,会暂停运行,等该异步任务完成后,再恢复运行。这可以从协程切换的角度来理解,运行 main 的为主协程,而 main 内部的为子协程。其实除了使用 async 函数来实现协程,我们还可以使用 Generator 函数:
// 示例代码
function* main({ scene, car }) {
const dataThreshold = 0.22;
const scanRadBase = 2;
const scanRadStep = 1;
let scanRad = scanRadBase;
while(true) {
if (car.getEddyCurrent() > dataThreshold) { // move
yield car.aSetSpeed(10);
scanRad = scanRadBase;
} else {
yield car.aStop();
const dataMiddle = car.getEddyCurrent();
// 这里的 yield 能保证小车真正转过 -scanRad 角度后再执行下面的逻辑
yield car.aTurn(-scanRad); // scan right
if (car.getEddyCurrent() < dataMiddle) { // right not pass
yield car.aTurn(2 * scanRad); // scan left
if (car.getEddyCurrent() < dataMiddle) { // left not pass
yield car.aSetSpeed(5);
scanRad += scanRadStep;
}
}
}
}
}
因为 yield 后面可以直接跟一个 Promise,所以这里可以直接使用 car 的各个异步版本方法(如 car.aTurn)。
当然, Generator 函数是外驱动的,因此需要提供一个 run 方法:
async function runCoroutine(coroutine: AsyncCoroutine): Promise<void> {
return new Promise((rs, rj) => {
const step = () => {
try {
const res = coroutine.next();
const { value, done } = res;
if (done) {
rs();
} else if (value instanceof Promise) {
value.then(() => {
step();
}).catch(rj);
} else {
step();
}
} catch (e) {
rj(e);
}
}
step();
});
}
// 使用
runCoroutine(main({ scene, car }));
死循环检测熔断
前面提到,在笔者团队自研的游戏引擎里,main 函数是通过编写游戏脚本注入到引擎里的,这部分代码内容通常是不可控的。为了保证引擎能够稳定运行,对于 main 函数,除了基本的错误捕获外,还要处理可能出现的死循环问题。
基本思路是利用 babel(@babel/standalone) 在 main 函数的各个循环地方注入熔断函数:
// 处理前
async main({ scene, car }) {
// ...
while(true) {
// ...
while(true) {
// ...
}
}
}
// 处理后
async main({ scene, car }) {
// ...
breaker('id1', 'start');
while(true) {
breaker('id1', 'looping');
// ...
breaker('id2', 'start');
while(true) {
breaker('id2', 'looping');
// ...
}
breaker('id2', 'end');
}
breaker('id1', 'end');
}
需要注意的是:如何判定一个循环为死循环呢?我们允许异步形式的“死”循环:
async main({ scene, car }) {
while(true) {
await new Promise(rs => {
setTimeout(rs);
});
}
}
上面这个循环不会停止,但主线程也不会卡主,而且经过测试,上面循环体的单次执行时间间隔不小于 4 ms。据此,若循环体的单次执行时间间隔小于 4 ms,且循环执行时间过长,则可认为代码陷入一个死循环。
// 示例代码
const breaker = (() => {
const gapTimeMin = 4; // 4 ms
const passedTimeMax = 3 * 60 * 1000; // 3 分钟
const loops = new Map<string, { startTime: number, count: number }>();
return (id: string, phase: 'start' | 'looping' | 'end' = 'looping') => {
if (phase === 'start') {
loops.set(id, {
startTime: new Date().getTime(),
count: 0,
});
return;
}
const loop = loops.get(id);
if (loop) {
const { startTime, count } = loop;
const passedTime = new Date().getTime() - startTime;
// 平均单次执行时间间隔小于 4 ms,且循环执行时间超过 3 分钟
if (passedTime / count < gapTimeMin && passedTime > passedTimeMax) {
throw Error('dead loop'); // 终止 main 函数运行
}
loop.count++;
} else {
throw Error('unexpected');
}
if (phase === 'end') {
loops.delete(id);
}
}
})();
总结
本文以基于 LDC1000 的循迹小车为例,介绍如何让 JS 游戏引擎支持编写单片机逻辑代码,首先是逐帧执行的同步 main 函数,然后优化为单次启动的异步 main 函数,接着拓展成 Generator 函数实现,最后为 main 函数增加了死循环检测熔断机制。
附:利用 babel(@babel/standalone) 在 main 函数的各个循环地方注入熔断函数
// 示例代码
import * as Babel from '@babel/standalone';
async main() {}
const {
parser, traverse, generator, types: t
} = Babel.packages;
const mainCodeOld = `function mainWrapper() { return ${main.toString()} }`;
const ast = parser.parse(mainCodeOld);
const genId = () => Math.random().toString(36).substr(2, 10);
const breaker = (() => {
const gapTimeMin = 4;
const passedTimeMax = 3 * 60 * 1000;
const loops = new Map<string, { startTime: number, count: number }>();
return (id: string, phase: 'start' | 'looping' | 'end' = 'looping') => {
if (phase === 'start') {
loops.set(id, {
startTime: new Date().getTime(),
count: 0,
});
return;
}
const loop = loops.get(id);
if (loop) {
const { startTime, count } = loop;
const passedTime = new Date().getTime() - startTime;
if (passedTime / count < gapTimeMin && passedTime > passedTimeMax) {
throw Error('dead loop');
}
loop.count++;
} else {
throw Error('unexpected');
}
if (phase === 'end') {
loops.delete(id);
}
}
})();
const injectBreaker = (path: any) => {
const { body } = path.node;
let bodyNew: any[] = [];
if (t.isBlockStatement(body)) {
bodyNew = [...body.body];
} else if (t.isExpressionStatement(body)) {
bodyNew = [body];
}
const breakerId = genId();
bodyNew.unshift(t.expressionStatement(
t.callExpression(
t.identifier('breaker'),
[t.stringLiteral(breakerId), t.stringLiteral('looping')]
)
));
path.get('body').replaceWithMultiple(bodyNew);
path.insertBefore(t.expressionStatement(
t.callExpression(
t.identifier('breaker'),
[t.stringLiteral(breakerId), t.stringLiteral('start')]
)
));
path.insertAfter(t.expressionStatement(
t.callExpression(
t.identifier('breaker'),
[t.stringLiteral(breakerId), t.stringLiteral('end')]
)
));
}
traverse.default(ast, {
ForStatement(path: any) {
injectBreaker(path);
},
WhileStatement(path: any) {
injectBreaker(path);
},
DoWhileStatement(path: any) {
injectBreaker(path);
},
});
const mainCodeNew = generator.default(ast).code;
const mainNew = eval(`(${mainCodeNew})()`);
作者:lumoux