JS 游戏引擎 - 实现单片机逻辑

本文要介绍的内容比较有意思,笔者尝试在团队自研的 JS 游戏引擎里复刻大学时期做过的电子设计大赛中的一道题目:基于 LDC1000 的循迹小车,最终实现的效果如下:

在这里插入图片描述

题目描述

地面上存在着一个由金属围成的轨道圈,有一辆小车,车头有个 LDC1000。LDC1000 就是一种金属传感器,能够检测到靠近的金属,并产生涡电流。金属轨道有一定宽度,LDC1000 是一个小矩形线圈,轨道与线圈重合的面积越大,产生的涡电流也就越大。现在需要让这辆小车能够自动沿着轨道圈跑,当然方式是单片机编程。

算法描述

算法的基本思路是实时读取 LDC1000 的涡电流,通过涡电流的变化,判断小车是否偏离轨道,继而调整小车的运动行为,伪代码如下:

  1. 读取涡电流,若涡电流高于某个阈值,则前进一小段距离,否则进入 2;
  2. 小角度右转弯,读取涡电流,若涡电流比 1 中的大,则进入 1,否则进入 3;
  3. 回正方向,小角度左转弯,读取涡电流,若涡电流比 1 中的大,则进入 1,否则进入 4;
  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;
          }
        }
      }
    }
}

上面这种写法存在两个问题:

  1. 因为受限于 ECSM 架构,对小车的状态设置是延迟到下一帧生效的,所以需要额外地增加标记变量(如 scanCount),这使得编写、理解代码变得很复杂,要知道算法伪代码里就 4 个步骤;
  2. 在这每一帧都会被执行的 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})()`);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值