用原生 JS 开发编程游戏”机器人流水线“

admin 2023年3月8日09:40:41评论23 views字数 14117阅读47分3秒阅读模式
动手点关注
用原生 JS 开发编程游戏”机器人流水线“
干货不迷路

记得之前玩过一个 flash 编程小游戏,印象深刻,叫“机器人流水线(manufactoria)”,不知道有没有同学也玩过。可惜的是,现在 falsh 已经停止运行了,这个原版的小游戏无法体验到。

用原生 JS 开发编程游戏”机器人流水线“

不过最近几天,我凭着之前的印象,复刻出了这个小游戏。(戳左下方“阅读原文”立即体验)

用原生 JS 开发编程游戏”机器人流水线“

这个小游戏的规则是,将左侧的元件放置到右侧的面板上,然后点击运行,机器人会沿着元件指定的路径运行,并影响地步序列的状态,最终按照任务的要求完成,即可过关。

例如上面的截图是第五关,任务是“队列里不能出现不同颜色的球”,也就是说如果队列中只有红球或只有蓝球,要把机器人移动到 🚩 处,否则将机器人移到任意其他空格。

我们能将元件放置到在任意白色空格处,机器人走到元件上会根据元件的类型来产生相应的动作。

manufactoria 的元件非常简单,只有两种类型:传送器比较器,但根据不同的作用一共分为 7 种:

用原生 JS 开发编程游戏”机器人流水线“

其中传送器有五种,四种带颜色的,机器人通过的时候会将对应颜色的球添加到序列的末尾,还有最后一种黑色的,机器人通过,序列不变。

比较器有两种,分别是红蓝比较器和黄绿比较器。比较器的作用是,当机器人通过它时,判断序列头部的球颜色,若颜色是比较器允许的颜色,则机器人朝对应的加号方向前进,并将该序列头部的这个球取出,否则,机器人沿着弧形箭头方向前进,且序列保持不变。

神奇的是有了这些简单的元件,我们就可以让机器人完成复杂的任务了。而且这和编程思想是一致的,我们可以通过元件构建出顺序,选择和循环结构体!

如下图,在第 22 关,可以用绿色小球构建出循环体解决问题:

用原生 JS 开发编程游戏”机器人流水线“


好了,前面说了规则,有兴趣的同学可以自行挑战,目前有 20 多个关卡,我会不定期更新新的关卡,等待大家的挑战。

接下来,我们看一下游戏是怎么实现的。

首先是面板的 HTML 结构,这个结构非常简单:

<div>🤖🔴🟡🔵🟢🚩 <span><select id="levelPicker"><option>1</option></select></span></div>
<div id="main">
  <div id="panel">
    <div class="buttons">
      <div class="pass green" title="绿色通道:🤖通过时将🟢添加到序列尾部" data-turn=0 data-flip=0></div>
      <div class="pass yellow" title="黄色通道:🤖通过时将🟡添加到序列尾部" data-turn=0 data-flip=0></div>
      <div class="comparator green yellow" title="黄绿比较器:🤖通过时读取序列头部元素根据颜色判断路径" data-turn=0 data-flip=0></div>
      <div class="pass red" title="红色通道:🤖通过时将🔴添加到序列尾部" data-turn=0 data-flip=0></div>
      <div class="pass blue" title="蓝色通道:🤖通过时将🔵添加到序列尾部" data-turn=0 data-flip=0></div>
      <div class="comparator red blue" title="红蓝比较器:🤖通过时读取序列头部元素根据颜色判断路径" data-turn=0 data-flip=0></div>
      <div class="pass" title="通道" data-turn=0 data-flip=0></div>
      <div class="trash" title="清除"></div>
    </div>
    <div class="info">
      说明:鼠标选择上方元件添加到右侧面板中,键盘上下左右旋转,空格翻转。
    </div>
    <div class="task" id="taskInfo"></div>
    <div class="run">
      <button class="btn" id="runBtn"></button>
      <button class="btn" id="stopBtn"></button>
    </div>
  </div>
  <div>
    <div id="app"></div>
    <div id="io">序列 ← <i>❤️</i><i>💙</i></div>
    <div id="result">结果 → </div>
  </div>
  </div>
  <div id="mousePick">
</div>

在这里我就不多说了,元件是通过 CSS 样式绘制的,比如比较器:

.comparator {
  margin: 10px 20px;
  border-bottom-right-radius: 50%;
  border-bottom-left-radius: 50%;
}
.comparator::before {
  content: '+';
  margin-left: -10px;
}
.comparator::after {
  content: '+';
  margin-left: 10px;
}

.comparator.red::before {
color: red;
}
.comparator.green::before {
color: green;
}
.comparator.blue::after {
color: blue;
}
.comparator.yellow::after {
color: orange;
}

因为所有的元件结构都不复杂,所以用一个 HTML 标签,加上 before 和 after 伪元素,就完全可以绘制出来的。

右侧的网格是一个 grid 布局的棋盘:

#app {
  width: 520px;
  height: 520px;
  border-bottom: solid 1px #0002;
  border-right: solid 1px #0002;
  background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.15) 2.5%, transparent 2.5%), linear-gradient( rgba(0, 0, 0, 0.15) 2.5%, transparent 2.5%);
  background-size: 40px 40px;
  background-repeat: repeat;
  display: grid;
  grid-template-columns: repeat(13, 40px);
  grid-template-rows: repeat(13, 40px);
}

#app > div {
text-align: center;
font-size: 1.8rem;
line-height: 48px;;
}

在网格中添加对应的元件,就只要找到对应的格子往里添加指定类型的元素就可以了。

机器人是绝对定位的元素,它移动的时候的动画效果可以通过 transition 给出:

#robot {
  position: absolute;
  transition: all linear .2s;
}

#robot::after {
font-size: 1.8rem;
content: '🤖';
margin: 5px;
}

这样,基本的 HTML 和 CSS 就实现完成了。实际上,大部分 UI 和交互效果都可以通过 HTML 和 CSS 指定,让 JS 只需要负责控制逻辑,这样就简单很多。

接下来我们看具体的逻辑。

首先我们实现一个点击左侧面板的元件,将元件用鼠标拾取的效果:

unction enablePicker() {
  const buttons = panel.querySelector('.buttons');
  buttons.addEventListener('mousedown', ({target}) => {
    if(main.className !== 'running' && target !== buttons && target.className) {
      const node = target.cloneNode(true);
      mousePick.innerHTML = '';
      mousePick.appendChild(node);
    }
  });
  window.addEventListener('mousemove', ({x, y}) => {
    mousePick.style.left = `${x - 25}px`;
    mousePick.style.top = `${y - 25}px`;
  });
  window.addEventListener('contextmenu', (e) => {
    e.preventDefault();
    return false;
  });
  window.addEventListener('mouseup', ({target}) => {
    if(target.parentNode !== buttons && target.className !== 'normal') {
      mousePick.innerHTML = '';
    }
  });
  window.addEventListener('keydown', ({key}) => {
    const el = mousePick.children[0];
    if(!el || el.className === 'trash') return;
    if(key === 'ArrowRight') {
      el.dataset.turn = 0;
    } else if(key === 'ArrowDown') {
      el.dataset.turn = 1;
    } else if(key === 'ArrowLeft') {
      el.dataset.turn = 2;
    } else if(key === 'ArrowUp') {
      el.dataset.turn = 3;
    } else if(key === ' ') {
      let n = Number(el.dataset.flip) || 0;
      el.dataset.flip = ++n % 2;
    }
    if(key.startsWith('Arrow') && el.classList.contains('comparator')) {
      el.dataset.turn = (Number(el.dataset.turn) + 3) % 4;
    }
  });
}

这里,我们直接用 cloneNode,将面板上的元素复制出来,做出一个透明效果,跟随鼠标移动。另外,我们还做了键盘控制,通过键盘控制元件的具体方向:

用原生 JS 开发编程游戏”机器人流水线“

注意,我们用 JS 控制元素方向的时候,通过设置 turn 和 flip 来表示元素翻转,至于元素具体的展现,则通过 CSS 来定义:

*[data-turn="1"] {
  transform: rotate(.25turn);
}
*[data-turn="2"] {
  transform: rotate(.5turn);
}
*[data-turn="3"] {
  transform: rotate(.75turn);
}

*[data-flip="1"] {
transform: scale(-1, 1);
}
*[data-turn="1"][data-flip="1"] {
transform: rotate(.25turn) scale(-1, 1);
}
*[data-turn="2"][data-flip="1"] {
transform: rotate(.5turn) scale(-1, 1);
}
*[data-turn="3"][data-flip="1"] {
transform: rotate(.75turn) scale(-1, 1);
}

接着是设置和移动机器人的函数:

function setRobot() {
  const start = app.querySelector('.start');
  const row = Number(start.dataset.x);
  const col = Number(start.dataset.y);
  let {x, y} = app.getBoundingClientRect();
  x = x + col * 40;
  y = y + row * 40;
  const el = document.getElementById('robot') || document.createElement('div');
  el.id = 'robot';
  el.style.left = `${x}px`;
  el.style.top = `${y}px`;
  el.dataset.x = x;
  el.dataset.y = y;
  el.dataset.row = row;
  el.dataset.col = col;
  el.dataset.fromDirection = '';
  document.body.appendChild(el);
}

function moveRobot(direction) {
let x = Number(robot.dataset.x);
let y = Number(robot.dataset.y);
let row = Number(robot.dataset.row);
let col = Number(robot.dataset.col);
let fromDirection = '';
if(direction === 'left') {
x -= 40;
col--;
fromDirection = 'right';
} else if(direction === 'right') {
x += 40;
col++;
fromDirection = 'left';
} else if(direction === 'up') {
y -= 40;
row--;
fromDirection = 'down';
} else if(direction === 'down') {
y += 40;
row++;
fromDirection = 'up';
}
robot.style.left = `${x}px`;
robot.style.top = `${y}px`;
robot.dataset.x = x;
robot.dataset.y = y;
robot.dataset.row = row;
robot.dataset.col = col;
robot.dataset.fromDirection = fromDirection;
// console.log(row, col, robot);

return new Promise(resolve => {
robot.addEventListener('transitionend', () => {
// console.log(row, col, robot.dataset.row, robot.dataset.col);
resolve(robot);
}, {once: true});
// 防止浏览器transitionend事件有时候不被触发
setTimeout(() => resolve(robot), 220);
});
}

这里,setRobot将机器人设置到起始位置,起始位置在网格中是一个 className 包含 start 的 div 元素,这个元素的位置在后续调用 loadLevel 读取当前关卡的时候初始化。

moveRobot实际上是一个异步方法,它返回一个 Promise,在机器人执行完动作之后 resolve。不过这里有个细节要注意,我一开始使用transitionend来判断动画结束,但是浏览器不能保证transitionend每次都被触发,所以有时候机器人会不明原因停下来,后来我就加了一个 setTimeout 来防止这种情况。

接下来的一系列方法和底部序列有关,序列代表着输入输出,机器人就是通过移动来影响序列,从而达成指定任务。序列实际上是一个队列,操作比较简单。

function setDataList(list = []) {
  io.innerHTML = "序列 ← ";
  for(let i = 0; i < list.length; i++) {
    const el = document.createElement('i');
    el.innerHTML = list[i];
    io.appendChild(el);
  }
}

function getTopData() {
const item = io.querySelector('i');
if(item) return item.innerHTML;
else return null;
}
function popData() {
const item = io.querySelector('i');
item.style.width = 0;
return new Promise(resolve => {
item.addEventListener('transitionend', () => {
item.remove();
resolve(item);
}, {once: true});
// 防止浏览器transitionend事件有时候不被触发
setTimeout(() => {
item.remove();
resolve(item);
}, 220);
});
}
function appendData(data = '🔴') {
const el = document.createElement('i');
el.innerHTML = data;
io.appendChild(el);
}
function getIOData() {
const list = io.querySelectorAll('i');
let ret = '';
for(let i = 0; i < list.length; i++) {
ret += list[i].innerHTML;
}
return ret;
}

然后是一个辅助方法,用来获得机器人所在位置的棋盘元素。我们在初始化棋盘的时候,会给每个元素设置 x 和 y 坐标,在机器人走动的时候,也会更新对应的 row 和 col 坐标,所以我们通过选择器就可以快速找到机器人所在位置的棋盘格子,从而判断其中的元件。

function getRobotCell() {
  let x = Number(robot.dataset.row);
  let y = Number(robot.dataset.col);
  const cell = document.querySelector(`#app > div[color: #e06c75;line-height: 26px;">${x}"][color: #e06c75;line-height: 26px;">${y}"]`);
  return cell;
}

接下来就是代码最核心的部分了。

function checkCell(cell, fromDirection) {
  const ret = {
    direction: null,
    effect: null,
    type: null,
    data: false,
  };

const children = cell.children;
if(children.length) {
for(let i = 0; i < children.length; i++) {
const el = children[i];
const flip = el.dataset.flip;
const turn = el.dataset.turn;
if(el.classList.contains('pass')) {
ret.type = 'pass';
// 通道
if(children.length > 1) {
// 交叉通道
if(fromDirection === 'up' || fromDirection === 'down') {
if(turn === '0' || turn === '2') continue;
}
if(fromDirection === 'left' || fromDirection === 'right') {
if(turn === '1' || turn === '3') continue;
}
}
if(turn === '0') ret.direction = 'right';
if(turn === '1') ret.direction = 'down';
if(turn === '2') ret.direction = 'left';
if(turn === '3') ret.direction = 'up';
if(el.classList.contains('red')) ret.effect = '🔴';
if(el.classList.contains('green')) ret.effect = '🟢';
if(el.classList.contains('yellow')) ret.effect = '🟡';
if(el.classList.contains('blue')) ret.effect = '🔵';
} else if(el.classList.contains('comparator')) {
// 比较器
ret.type = 'comparator';
const data = getTopData();
if(data === '🔴' && el.classList.contains('red')) {
if(turn === '0') ret.direction = 'left';
if(turn === '1') ret.direction = 'up';
if(turn === '2') ret.direction = 'right';
if(turn === '3') ret.direction = 'down';
ret.data = true;
} else if(data === '🟢' && el.classList.contains('green')) {
if(turn === '0') ret.direction = 'left';
if(turn === '1') ret.direction = 'up';
if(turn === '2') ret.direction = 'right';
if(turn === '3') ret.direction = 'down';
ret.data = true;
} else if(data === '🔵' && el.classList.contains('blue')) {
if(turn === '0') ret.direction = 'right';
if(turn === '1') ret.direction = 'down';
if(turn === '2') ret.direction = 'left';
if(turn === '3') ret.direction = 'up';
ret.data = true;
} else if(data === '🟡' && el.classList.contains('yellow')) {
if(turn === '0') ret.direction = 'right';
if(turn === '1') ret.direction = 'down';
if(turn === '2') ret.direction = 'left';
if(turn === '3') ret.direction = 'up';
ret.data = true;
} else {
if(turn === '0') ret.direction = 'down';
if(turn === '1') ret.direction = 'left';
if(turn === '2') ret.direction = 'up';
if(turn === '3') ret.direction = 'right';
}
}
if(flip === '1') {
// 翻转交换
if(turn === '0' || turn === '2') {
if(ret.direction === 'left') ret.direction = 'right';
else if(ret.direction === 'right') ret.direction = 'left';
} else {
if(ret.direction === 'up') ret.direction = 'down';
else if(ret.direction === 'down') ret.direction = 'up';
}
}
}
}
// console.log(ret);
return ret;
}
function checkState() {
const cell = getRobotCell();
const fromDirection = robot.dataset.fromDirection;
let state = {
direction: null,
effect: null,
accepted: false,
fromDirection,
};
if(cell.className === 'flag') {
state.accepted = true;
} else if(cell.className !== 'start') {
state = {
...state,
...checkCell(cell, fromDirection),
};
}
return state;
}

当机器人移动到一个格子的时候,我们通过 checkState 判断他的状态,状态包括四个信息,direction:机器人当前可以移动的方向,effect:机器人操作序列的动作,accepted:机器人是否移动到 🚩,fromDirection:机器人上一步从哪里移动过来的。

checkCell 则是具体的判断逻辑,我们通过格子中的元件来具体判断机器人的这些状态,这部分逻辑虽然较繁琐,但其实也不太复杂,唯一需要注意的是,一个网格中可以放两个相互垂直的传送器,当机器人经过的时候,如果有两个方向,会默认选择直行的方向,这也是为什么我们需要 fromDirection 来判断机器人从哪个方向过来。

接下来是展示结果,运行、停止按钮状态,sleep 等细节,就不一一赘述了。

function initResult() {
  result.innerHTML = '结果 →';
}

function appendResult(success = false) {
const r = success ? 'A' : 'E';
const el = document.createElement('span');
el.innerHTML = r;
if(success) el.className = 'accept';
result.appendChild(el);
}
function sleep(ms = 10) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}

runBtn.addEventListener('mousedown', async () => {
mousePick.innerHTML = '';
runBtn.className = 'btn tap';
runBtn.disabled = true;
main.className = 'running';
await run();
});
stopBtn.addEventListener('mousedown', () => {
mousePick.innerHTML = '';
stopBtn.className = 'btn tap';
main.className = '';
// setRobot();
});
window.addEventListener('mouseup', () => {
if(stopBtn.className === 'btn tap') {
stopBtn.className = 'btn';
// runBtn.disabled = false;
// runBtn.className = 'btn';
}
});

然后,我们根据关卡数据,读取和初始化对应的关卡:

let currentLevel;
function loadLevel(level) {
  const data = levels[level];
  currentLevel = {
    ...data,
    level,
  };
  taskInfo.innerHTML = `<p>任务:${data.task}</p><p>提示:${data.hint}</p>`;
  const items = document.querySelectorAll('.buttons > div');
  for(let i = 0; i < items.length; i++) {
    if(!data.units.includes(i)) {
      items[i].classList.add('hide');
    } else {
      items[i].classList.remove('hide');
    }
  }
  setDataList([...data.tests[0].data]);

const board = new Array(169);
board.fill(-1);

const size = data.size || 13;
const v = (13 - size) / 2;
const range = [
v, v,
v + size, v + size,
];

const [a, b, c, d] = range;
for(let i = a; i < c; i++) {
for(let j = b; j < d; j++) {
const idx = i * 13 + j;
board[idx] = 0;
}
}

const s = v + Math.floor(size / 2);
const start = v * 13 + s;
const end = (v + size - 1) * 13 + s;
board[start] = 1;
board[end] = 2;
init(board);

const savedData = localStorage.getItem(`manufactoria-level-${level}`);
if(savedData) {
const data = JSON.parse(savedData);
for(let i = 0; i < data.cells.length; i++) {
const cell = data.cells[i];
const el = document.createElement('div');
el.className = cell.state;
el.dataset.turn = cell.turn;
el.dataset.flip = cell.flip;
app.children[cell.idx].appendChild(el);
}
}
setRobot();

return currentLevel;
}

初始化之后,当放置好元件,点击运行时,让机器人运行起来:

async function run() {
  levelPicker.disabled = true;
  const tests = currentLevel.tests;
  initResult();

for(let i = 0; i < tests.length; i++) {
const {data, accept} = tests[i];
setDataList([...data]);

setRobot();
await sleep();
await moveRobot('down');

while(true) {
if(main.className !== 'running') break;
const state = checkState();
if(state.direction) {
if(state.type === 'comparator' && state.data) {
await Promise.all([
moveRobot(state.direction),
popData(),
]);
} else {
await moveRobot(state.direction);
if(state.effect) {
appendData(state.effect);
}
}
} else {
break;
}
}
if(main.className !== 'running') break;

const cell = getRobotCell();

if(accept === true) {
appendResult(cell.className === 'flag');
} else if(typeof accept === 'string') {
if(cell.className !== 'flag') {
appendResult(false);
} else {
appendResult(accept === getIOData());
}
} else {
appendResult(cell.className !== 'flag');
}
await sleep(500);
}
runBtn.className = 'btn';
runBtn.disabled = false;
if(main.className === 'running') {
const success = !result.textContent.includes('E');
const el = document.createElement('span');
el.innerHTML = success? ':成功':':失败';
if(success) el.className = 'accept';
result.appendChild(el);
setDataList([]);
}
main.className = '';
levelPicker.disabled = false;
setRobot();
}

因为有的关卡比较复杂,玩家也不希望好不容易通关的结果,下一次进游戏又没有了,所以我们做一个 localStorage 的本地保存机制:

// 把数据保存到本地
function saveLevel() {
  const {level} = currentLevel;
  const data = {level, cells: []};
  const cells = app.children;
  for(let i = 0; i < 169; i++) {
    const cell = cells[i];
    if(cell.children.length) {
      for(let j = 0; j < cell.children.length; j++) {
        const item = cell.children[j];
        const d = {
          state: item.className,
          turn: item.dataset.turn,
          flip: item.dataset.flip,
          idx: Number(cell.dataset.x) * 13 + Number(cell.dataset.y),
        };
        data.cells.push(d);
      }
    }
  }
  localStorage.setItem(`manufactoria-level-${level}`, JSON.stringify(data));
}

最后的最后,我们做一个下拉列表来选择对应的关卡:

function initLevelPicker() {
  const len = levels.length;
  levelPicker.innerHTML = '';
  for(let i = 0; i < len; i++) {
    const option = new Option(i + 1, i);
    levelPicker.appendChild(option);
  }
  levelPicker.addEventListener('change', () => {
    loadLevel(levelPicker.value);
  });
  loadLevel(levelPicker.value);
}

initLevelPicker();

这样,我们的游戏就开发完成了。实际上这个游戏本身开发的难度并不高,但是玩法却很丰富,关卡也很有挑战性。这就是编程游戏的乐趣。

欢迎戳左下方“阅读原文”体验游戏,有同学玩通关的话可以代码评论区交流玩法心得哦~

用原生 JS 开发编程游戏”机器人流水线“

🎁 文 末 彩 蛋 🎁
用原生 JS 开发编程游戏”机器人流水线“

「码上掘金月赛 第 1 期」结果新鲜出炉
观看获奖作品 👉🏻 https://juejin.cn/post/7207250137465372727

观看作品可以参加【有奖调研】活动哦~

原文始发于微信公众号(字节跳动技术团队):用原生 JS 开发编程游戏”机器人流水线“

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年3月8日09:40:41
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   用原生 JS 开发编程游戏”机器人流水线“http://cn-sec.com/archives/1591715.html

发表评论

匿名网友 填写信息