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

您所在的位置:网站首页 游戏开发文本 用原生 JS 开发编程游戏“机器人流水线”

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

2023-02-28 22:23| 来源: 网络整理| 查看: 265

作者:吴亮(月影)

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

不过最近几天,我凭着之前的印象,复刻出了这个小游戏。

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

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

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

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

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

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

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

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

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

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

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

🤖🔴🟡🔵🟢🚩 第 1 关 说明:鼠标选择上方元件添加到右侧面板中,键盘上下左右旋转,空格翻转。 序列 ← ❤️💙 结果 → 复制代码

在这里我就不多说了,元件是通过 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 控制元素方向的时候,通过设置 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 = `

任务:${data.task}

提示:${data.hint}

`; 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(); 复制代码

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

有同学玩通关的话,欢迎点击此处在代码评论区交流玩法心得~

尾部关注.gif

扫码关注公众号 👆 追更不迷路



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3