|
记得之前玩过一个 flash 编程小游戏,印象深刻,叫“机器人流水线(manufactoria)”,不知道有没有同学也玩过。可惜的是,现在 falsh 已经停止运行了,这个原版的小游戏无法体验到。不过最近几天,我凭着之前的印象,复刻出了这个小游戏。(戳左下方“阅读原文”立即体验)这个小游戏的规则是,将左侧的元件放置到右侧的面板上,然后点击运行,机器人会沿着元件指定的路径运行,并影响地步序列的状态,最终按照任务的要求完成,即可过关。例如上面的截图是第五关,任务是“队列里不能出现不同颜色的球”,也就是说如果队列中只有红球或只有蓝球,要把机器人移动到 处,否则将机器人移到任意其他空格。我们能将元件放置到在任意白色空格处,机器人走到元件上会根据元件的类型来产生相应的动作。manufactoria 的元件非常简单,只有两种类型:传送器和比较器,但根据不同的作用一共分为 7 种:其中传送器有五种,四种带颜色的,机器人通过的时候会将对应颜色的球添加到序列的末尾,还有最后一种黑色的,机器人通过,序列不变。比较器有两种,分别是红蓝比较器和黄绿比较器。比较器的作用是,当机器人通过它时,判断序列头部的球颜色,若颜色是比较器允许的颜色,则机器人朝对应的加号方向前进,并将该序列头部的这个球取出,否则,机器人沿着弧形箭头方向前进,且序列保持不变。神奇的是有了这些简单的元件,我们就可以让机器人完成复杂的任务了。而且这和编程思想是一致的,我们可以通过元件构建出顺序,选择和循环结构体!如下图,在第 22 关,可以用绿色小球构建出循环体解决问题:好了,前面说了规则,有兴趣的同学可以自行挑战,目前有 20 多个关卡,我会不定期更新新的关卡,等待大家的挑战。接下来,我们看一下游戏是怎么实现的。首先是面板的 HTML 结构,这个结构非常简单:第1关说明:鼠标选择上方元件添加到右侧面板中,键盘上下左右旋转,空格翻转。序列←结果→在这里我就不多说了,元件是通过 CSS 样式绘制的,比如比较器:.comparator{margin:10px20px;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{colorrange;}因为所有的元件结构都不复杂,所以用一个 HTML 标签,加上 before 和 after 伪元素,就完全可以绘制出来的。右侧的网格是一个 grid 布局的棋盘:#app{width:520px;height:520px;border-bottom:solid1px#0002;border-right:solid1px#0002;background-image:linear-gradient(90deg,rgba(0,0,0,0.15)2.5%,transparent2.5%),linear-gradient(rgba(0,0,0,0.15)2.5%,transparent2.5%);background-size:40px40px;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:alllinear.2s;}#robot::after{font-size:1.8rem;content:'';margin:5px;}这样,基本的 HTML 和 CSS 就实现完成了。实际上,大部分 UI 和交互效果都可以通过 HTML 和 CSS 指定,让 JS 只需要负责控制逻辑,这样就简单很多。接下来我们看具体的逻辑。首先我们实现一个点击左侧面板的元件,将元件用鼠标拾取的效果:unctionenablePicker(){constbuttons=panel.querySelector('.buttons');buttons.addEventListener('mousedown',({target})=>{if(main.className!=='running'&target!==buttons&target.className){constnode=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();returnfalse;});window.addEventListener('mouseup',({target})=>{if(target.parentNode!==buttons&target.className!=='normal'){mousePick.innerHTML='';}});window.addEventListener('keydown',({key})=>{constel=mousePick.children[0];if(!el||el.className==='trash')return;if(key==='ArrowRight'){el.dataset.turn=0;}elseif(key==='ArrowDown'){el.dataset.turn=1;}elseif(key==='ArrowLeft'){el.dataset.turn=2;}elseif(key==='ArrowUp'){el.dataset.turn=3;}elseif(key===''){letn=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);}接着是设置和移动机器人的函数:functionsetRobot(){conststart=app.querySelector('.start');constrow=Number(start.dataset.x);constcol=Number(start.dataset.y);let{x,y}=app.getBoundingClientRect();x=x+col*40;y=y+row*40;constel=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);}functionmoveRobot(direction){letx=Number(robot.dataset.x);lety=Number(robot.dataset.y);letrow=Number(robot.dataset.row);letcol=Number(robot.dataset.col);letfromDirection='';if(direction==='left'){x-=40;col--;fromDirection='right';}elseif(direction==='right'){x+=40;col++;fromDirection='left';}elseif(direction==='up'){y-=40;row--;fromDirection='down';}elseif(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);returnnewPromise(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 来防止这种情况。接下来的一系列方法和底部序列有关,序列代表着输入输出,机器人就是通过移动来影响序列,从而达成指定任务。序列实际上是一个队列,操作比较简单。functionsetDataList(list=[]){io.innerHTML="序列←";for(leti=0;i{item.addEventListener('transitionend',()=>{item.remove();resolve(item);},{once:true});//防止浏览器transitionend事件有时候不被触发setTimeout(()=>{item.remove();resolve(item);},220);});}functionappendData(data=''){constel=document.createElement('i');el.innerHTML=data;io.appendChild(el);}functiongetIOData(){constlist=io.querySelectorAll('i');letret='';for(leti=0;idiv[data-x="${x}"][data-y="${y}"]`);returncell;}接下来就是代码最核心的部分了。functioncheckCell(cell,fromDirection){constret={direction:null,effect:null,type:null,data:false,};constchildren=cell.children;if(children.length){for(leti=0;i1){//交叉通道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='';}elseif(el.classList.contains('comparator')){//比较器ret.type='comparator';constdata=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;}elseif(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;}elseif(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;}elseif(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';elseif(ret.direction==='right')ret.direction='left';}else{if(ret.direction==='up')ret.direction='down';elseif(ret.direction==='down')ret.direction='up';}}}}//console.log(ret);returnret;}functioncheckState(){constcell=getRobotCell();constfromDirection=robot.dataset.fromDirection;letstate={direction:null,effect:null,accepted:false,fromDirection,};if(cell.className==='flag'){state.accepted=true;}elseif(cell.className!=='start'){state={...state,...checkCell(cell,fromDirection),};}returnstate;}当机器人移动到一个格子的时候,我们通过 checkState 判断他的状态,状态包括四个信息,direction:机器人当前可以移动的方向,effect:机器人操作序列的动作,accepted:机器人是否移动到 ,fromDirection:机器人上一步从哪里移动过来的。checkCell 则是具体的判断逻辑,我们通过格子中的元件来具体判断机器人的这些状态,这部分逻辑虽然较繁琐,但其实也不太复杂,唯一需要注意的是,一个网格中可以放两个相互垂直的传送器,当机器人经过的时候,如果有两个方向,会默认选择直行的方向,这也是为什么我们需要 fromDirection 来判断机器人从哪个方向过来。接下来是展示结果,运行、停止按钮状态,sleep 等细节,就不一一赘述了。functioninitResult(){result.innerHTML='结果→';}functionappendResult(success=false){constr=success'A':'E';constel=document.createElement('span');el.innerHTML=r;if(success)el.className='accept';result.appendChild(el);}functionsleep(ms=10){returnnewPromise(resolve=>{setTimeout(resolve,ms);});}runBtn.addEventListener('mousedown',async()=>{mousePick.innerHTML='';runBtn.className='btntap';runBtn.disabled=true;main.className='running';awaitrun();});stopBtn.addEventListener('mousedown',()=>{mousePick.innerHTML='';stopBtn.className='btntap';main.className='';//setRobot();});window.addEventListener('mouseup',()=>{if(stopBtn.className==='btntap'){stopBtn.className='btn';//runBtn.disabled=false;//runBtn.className='btn';}});然后,我们根据关卡数据,读取和初始化对应的关卡:letcurrentLevel;functionloadLevel(level){constdata=levels[level];currentLevel={...data,level,};taskInfo.innerHTML=`
任务:${data.task}
提示:${data.hint}`;constitems=document.querySelectorAll('.buttons>div');for(leti=0;i{loadLevel(levelPicker.value);});loadLevel(levelPicker.value);}initLevelPicker();这样,我们的游戏就开发完成了。实际上这个游戏本身开发的难度并不高,但是玩法却很丰富,关卡也很有挑战性。这就是编程游戏的乐趣。欢迎戳左下方“阅读原文”体验游戏,有同学玩通关的话可以代码评论区交流玩法心得哦~点击上方关注 · 追更不迷路
|
|