天天看點

用原生 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[data-x="${x}"][data-y="${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();
           

這樣,我們的遊戲就開發完成了。實際上這個遊戲本身開發的難度并不高,但是玩法卻很豐富,關卡也很有挑戰性。這就是程式設計遊戲的樂趣。

有同學玩通關的話,歡迎戳下方連結,在代碼評論區交流玩法心得~

manufactoria - 碼上掘金 manufactoria - 碼上掘金

關注「位元組前端 ByteFE」公衆号,追更不迷路!

繼續閱讀