记得之前玩过一个 flash 编程小游戏,印象深刻,叫“机器人流水线(manufactoria)”,不知道有没有同学也玩过。可惜的是,现在 falsh 已经停止运行了,这个原版的小游戏无法体验到。
不过最近几天,我凭着之前的印象,复刻出了这个小游戏。(戳左下方“阅读原文”立即体验)
这个小游戏的规则是,将左侧的元件放置到右侧的面板上,然后点击运行,机器人会沿着元件指定的路径运行,并影响地步序列的状态,最终按照任务的要求完成,即可过关。
例如上面的截图是第五关,任务是“队列里不能出现不同颜色的球”,也就是说如果队列中只有红球或只有蓝球,要把机器人移动到 🚩 处,否则将机器人移到任意其他空格。
我们能将元件放置到在任意白色空格处,机器人走到元件上会根据元件的类型来产生相应的动作。
manufactoria 的元件非常简单,只有两种类型:传送器和比较器,但根据不同的作用一共分为 7 种:
其中传送器有五种,四种带颜色的,机器人通过的时候会将对应颜色的球添加到序列的末尾,还有最后一种黑色的,机器人通过,序列不变。
比较器有两种,分别是红蓝比较器和黄绿比较器。比较器的作用是,当机器人通过它时,判断序列头部的球颜色,若颜色是比较器允许的颜色,则机器人朝对应的加号方向前进,并将该序列头部的这个球取出,否则,机器人沿着弧形箭头方向前进,且序列保持不变。
神奇的是有了这些简单的元件,我们就可以让机器人完成复杂的任务了。而且这和编程思想是一致的,我们可以通过元件构建出顺序,选择和循环结构体!
如下图,在第 22 关,可以用绿色小球构建出循环体解决问题:
好了,前面说了规则,有兴趣的同学可以自行挑战,目前有 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 控制元素方向的时候,通过设置 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 开发编程游戏”机器人流水线“
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论