之前闲时开发过一个简单的网页版贪食蛇游戏程序,现在把程序的实现思路写下来,供有兴趣同学参考阅读。

代码的实现比较简单,整个程序由三个类,一组常量和一些游戏逻辑以外的初始化和控制代码组成,总共400多行JavaScript。

游戏中的三个类分别是「组成蛇身体的节点」「蛇」「贪食蛇游戏」的抽象,常量用来表示游戏中的各种状态。

先从常量讲起

var TRANSVERSE = 30;
var VERTICAL = 40; var LEFT = 1;
var RIGHT = 2;
var TOP = 3;
var BOTTOM = 4; var GAME_START = 1;
var GAME_STOP = 2;
var GAME_OVER = 3

首先,可以把游戏的逻辑想象成一个不断变换的数据结构,把游戏的界面想象成由一组像素格子组成的长方形,界面渲染程序定时读取游戏数据结构,将数据结构中不同的值表示成不同的颜色并画在游戏界面上。

因此,常量TRANSVERSEVERTICAL分别代表游戏数据结构的最大边界,也就是游戏界面横向和纵向的像素点个数。

常量LEFT、RIGHT、TOP、BOTTOM分别代表贪食蛇上下左右的走向

常量GAME_START、GAME_STOP、GAME_OVER代表游戏的三个状态,游戏进行中、游戏暂停中、游戏结束

游戏中的三个类是游戏的逻辑实现,相对复杂

贪食蛇蛇身由一系列相互引用的节点组成,是一个链表结构,如下图

每一个节点是SnakeNode类的一个实例

//组成蛇的节点,一个链表结构
var SnakeNode = function(point) {
var prevDirection, currDirection,
next,
pos = point; //获得下一个
this.getNext = function() {
return next;
} //设置下一个
this.setNext = function(el) {
next = el;
} //设置方向
this.setDirection = function(value) {
currDirection = value;
} //获得方向
this.getDirection = function() {
return currDirection;
} //计算结点下一个位置
this.computePosition = function() {
pos = SnakeNode.getNextPoint( pos, currDirection );
if( next ) {
next.computePosition();
}
if( prevDirection != currDirection ) {
prevDirection = currDirection;
if( next ){
next.setDirection(currDirection);
}
}
} //获得位置
this.getPosition = function(){
return pos;
}
} //通过方向计算相对与当前位置的下一个位置
SnakeNode.getNextPoint = function (point, direction)
{
var newPoint = {};
switch(direction)
{
case LEFT:
newPoint.x = point.x - 1;
newPoint.y = point.y ;
break;
case RIGHT:
newPoint.x = point.x + 1;
newPoint.y = point.y;
break;
case TOP:
newPoint.x = point.x;
newPoint.y = point.y - 1;
break;
case BOTTOM:
newPoint.x = point.x;
newPoint.y = point.y + 1;
break;
}
return newPoint;
}

蛇身节点有四个属性

prevDirection 上一次移动时的蛇身走向

currDirection 当前蛇身走向

next 节点的下一个节点

pos 节点的位置

六个方法

getNext 获得节点的下一个节点

setNext 设置节点的下一个节点

setDirection 设置节点的方向

getDirection 获得节点的方向

computePosition 计算节点移动后的目标位置

getPosition 获得节点的位置

SnakeNode.getNextPoint 这个方法是一个静态方法, 不属于节点实例, 它的功能是根据方向计算出某一个坐标的下一个坐标, 比如说10和10是某个节点当前的坐标, 那么它向左移动一个单位后坐标就是9和10;向右移动一个单位后坐标就是11和10,同理向上和向下坐标分别是10,9和10,11。

computePosition需要特点说明一下,它在计算出自身移动后的目标位置以后,还会调用它引用的下一个节点的 computePosition方法,然后下一个节点再次执行相同的操作,一直到蛇身的最后一个节点为止,这就是链表的特性。同时如果方向发了变化,这个方法还会把当前节点的方向同步给它引用的下一个节点,就是靠这一点, 蛇身每一个节点的走向才能一致。

通过这一系列属性和方法就能表示出蛇身的节点特性了。

Snake是整条蛇的抽象表示,代码如下

//蛇
var Snake = function( head ) {
var snake = head;
var isGameover = false;
var self = this; //为蛇增加一个节点
this.addNode = function() {
var lastNode = getLastNode();
var point = lastNode.getPosition();
var reverse;
switch(lastNode.getDirection()) {
case LEFT:
reverse = RIGHT;
break;
case RIGHT:
reverse = LEFT;
break;
case TOP:
reverse = BOTTOM;
break;
case BOTTOM:
reverse = TOP;
break;
}
var newPoint = SnakeNode.getNextPoint(point, reverse);
var node = new SnakeNode(newPoint);
node.setDirection(lastNode.getDirection());
lastNode.setNext(node);
} //获所所有蛇节点的位置
this.getAllNodePos = function() {
var posList = new Array;
var node = snake;
do{
posList.push(node.getPosition());
node = node.getNext();
}while(node);
return posList;
} //获得蛇长度
this.getLength = function() {
var count = 0;
var node = snake;
while(node) {
count ++;
node = node.getNext();
}
return count;
} //游戏是否结束
this.isGameover = function() {
return isGameover;
}
//移动
this.move = function() {
if (!isGameover) {
snake.computePosition();
}
checkGameover();
}
//根据方向导航
this.setDirection = function (direction) {
if( !isGameover ) snake.setDirection(direction);
}
//获得蛇头位置
this.getHeadPos = function() {
return snake.getPosition();
}
//获得蛇头方向
this.getHeadDirection = function() {
return snake.getDirection();
}
var checkGameover = function() {
var l = snake.getPosition();
var cl = self.getAllNodePos();
if(l.x < 0 || l.x >= TRANSVERSE || l.y < 0 || l.y >= VERTICAL ) {
isGameover = true;
return;
}
for(var i = 0 ; i < cl.length ; i ++) {
if(l != cl[i] && cl[i].x == l.x && cl[i].y == l.y) {
isGameover = true;
return;
}
}
} var getLastNode = function() {
var node = snake.getNext();
while( node ){
var nextNode = node.getNext();
if(!nextNode) return node;
node = nextNode;
}
return snake;
}
}

这个类有三个属性

snake是蛇的脑袋节点,因为是一个链表,所以通过蛇的脑袋就可以访问到蛇的尾巴,因此,蛇的脑袋就可以表示一条蛇了。

isGameover游戏是否结束

self是实例自身的引用,跟游戏逻辑的表示没有任何关系。

八个公有方法

addNode 给蛇身增加一个结点,当蛇吃到食物时会调用这个方法,这个方法会把新的节点追加到最后一个节点(蛇尾)的后面。其中局部变量reverse是用来计算新节点的位置用的,假如当前节点的方向是向右的,那么下一个节点肯定在当前节点的左边,以此类推, reverse变量就是当前节点相反方向的值,细节请结合代码理解。

getAllNodePos 获得蛇身所有节点的位置。

getLength 获得蛇身长度(蛇身节点个数)

isGameover 游戏是否结束

move 移动蛇身,调用一次整个蛇身便移动一下,这里的移动仅仅是数据结构变化,具体效果需要将数据结构结果渲染至页面。

setDirection 设置蛇的游动方向

getHeadPos 获得蛇身的第一个节点(蛇头)的位置

getHeadDirection 获得蛇(蛇头)游动的方向

二个私有方法

checkGameover 检查游戏是否结束,分别检测游戏的第一个节点是否落在 TRANSVERSEVERTICAL常量定义的范围之外(撞墙)和是否落在蛇身节点的位置之上(咬到自己)。

getLastNode 获得蛇身的最后一个结果

通过SnakeNodeSnake这两个类,便抽象出了贪食蛇的结构和特性,但是现在这条蛇只是一个逻辑结构,是不会动的, 更不能玩。接下来我们便让这条蛇游动起来, 还可以控制它的方向, 让它去觅食并越长越长越游越快。

//贪食蛇游戏
var SnakeGame = function() {
var snake ;
var moveTimer, randomTimer;
var currDirection;
var foods = [];
var status = GAME_STOP;
var context; var self = this; this.onEatOne = function(){}; var getRandom = function(notin) {
var avaiable = [];
for(var y = 0 ; y < VERTICAL ; y ++)
{
for(var x = 0 ; x < TRANSVERSE; x++ ) {
var j = 0;
var avaiableFlag = true;
while( j < notin.length ){
var el = notin[j];
if( el.x == x && el.y == y ) {
notin.splice(j,1);
avaiableFlag = false;
break;
}
j++;
}
if(avaiableFlag) avaiable.push({ x: x , y: y });
}
}
var rand = Math.floor(Math.random() * avaiable.length);
return avaiable[rand];
} //导航
var navigate = function(direction) {
var sd = snake.getHeadDirection();
var d ;
if((sd == LEFT || sd == RIGHT) && (direction == TOP || direction == BOTTOM)) d = direction;
if((sd == TOP || sd == BOTTOM) && (direction == LEFT || direction == RIGHT)) d = direction;
if(d) currDirection = d;
} var move = function() {
moveTimer = window.setTimeout( move, computeMoveInterval() );
if(currDirection) snake.setDirection( currDirection );
snake.move();
var lc = snake.getHeadPos();
for(var i = 0 ; i < foods.length ; i ++) {
if(lc.x == foods[i].x && lc.y == foods[i].y) {
snake.addNode();
self.onEatOne();
foods.splice( i, 1 );
break;
}
}
if(snake.isGameover()){
gameover();
return;
}
draw();
} var createFood = function() {
var notin = snake.getAllNodePos().concat(foods);
var rand = getRandom(notin);
foods.push(rand);
} var arrayToMap = function(array) {
var map = {};
for(var i = 0 , point ; point = array[i++];) map[[point.x , point.y]] = null;
return map;
} //获得当前游戏数据结构
var getMap = function() {
var board = new Array;
for (var y = 0 ; y < VERTICAL; y++) {
for (var x = 0 ; x < TRANSVERSE ; x++) {
board.push({ x: x, y: y });
}
}
var cl = snake.getAllNodePos();
var food = arrayToMap(foods);
cl = arrayToMap(cl);
board = arrayToMap(board);
for(var key in cl) board[key] = 'snake';
for(var key in food) board[key] = 'food';
return board;
} //获得分数
this.getScore = function() {
return snake.getLength() - 1;
} //获得级别
this.getLevel = function() {
var score = self.getScore();
var level = 0;
if(score <= 5) level = 1;
else if(score <= 12) level = 2;
else if(score <= 22) level = 3;
else if(score <= 35) level = 4;
else if(score <= 50) level = 5;
else if(score <= 75) level = 6;
else if(score <= 90) level = 7;
else if(score <= 100) level = 8;
else level = 9;
return level;
} var computeMoveInterval = function() {
var speed = {
'1':200,
'2':160,
'3':120,
'4':100,
'5':80,
'6':60,
'7':40,
'8':20,
'9':10
}
var level = self.getLevel();
return speed[level];
} var gameover = function () {
status = GAME_OVER;
window.clearTimeout(moveTimer);
window.clearInterval(foodTimer);
unBindEvent();
alert('游戏结束');
} //获得游戏状态
this.gameState = function () {
return status;
} //游戏开始
this.start = function() {
status = GAME_START;
moveTimer = window.setTimeout(move , computeMoveInterval());
foodTimer = window.setInterval(createFood, 5000);
bindEvent();
} //暂停游戏
this.stop = function() {
status = GAME_STOP;
window.clearTimeout(moveTimer);
window.clearInterval(foodTimer);
unBindEvent();
} this.initialize = function( canvasId ) {
var head = new SnakeNode({ x: Math.ceil(TRANSVERSE / 2), y: Math.ceil(VERTICAL / 2) });
head.setDirection([LEFT, RIGHT , TOP , BOTTOM][Math.floor(Math.random() * 4)])
snake = new Snake(head); var canvas = document.getElementById(canvasId);
context = canvas.getContext('2d');
} //画界面
var draw = function () {
context.fillStyle = '#fff';
context.fillRect(0, 0, 300, 400);
var map = getMap();
for (var key in map) {
var pointType = map[key];
var x = key.split(',')[0];
var y = key.split(',')[1]; if (pointType == 'snake') {
context.fillStyle = '#000';
} else if (pointType == 'food') {
context.fillStyle = '#f00';
} else {
continue;
}
context.fillRect( x * 10, y * 10, 10, 10 );
}
} //绑定事件
var bindEvent = function () {
document.body.onkeydown = function (e) {
e = e || window.event;
var keyCode = e.keyCode;
switch (keyCode) {
case 37:
navigate(LEFT);
break;
case 38:
navigate(TOP);
break;
case 39:
navigate(RIGHT);
break;
case 40:
navigate(BOTTOM);
break;
}
}
} //取消绑定
var unBindEvent = function () {
document.body.onkeydown = null;
}
}

SnakeGame类算不上某一种结构抽象, 它仅仅是一组功能的封装, 其中包括人机交互事件、将数据结构转换成界面和一系列组成游戏的功能。此类比较复杂,就不以讲解之前两个类的方法讲解了。我们从类的实例化为入口开始讲解,然后再逐步扩展至类中的其它方法和属性。

var game = new SnakeGame();

实例化对象,调用构造函数后,类的几个属性被声明或初始化。

  var snake ;
var moveTimer, randomTimer;
var currDirection;
var foods = [];
var status = GAME_STOP;
var context; var self = this; this.onEatOne = function(){};

snake 也就是Snake类的实例

moveTimer 使蛇身运动的setTimeout函数的返回值, clearTimeout此值后,表示游戏暂停

randomTimer 随机产生食物的setInterval函数的返回值,clearInterval后停止生成食物,表示游戏暂停

foods 食物,因为会有多个食物产生,因为初始化为数组来存放食物

status 游戏状态,初始化状态为暂停中

context 游戏界面的canvas对象

self 没有表示实例自身, 跟游戏不相关

onEatOne 并不是属性, 而是游戏的一个事件, 当蛇吃到食物时, 此函数(事件)会被调用以用来通知监听者

game.initialize("snake");

初始化游戏,initialize方法的参数是游戏界面的canvas的元素ID,这个方法的细节如下

this.initialize = function( canvasId ) {
var head = new SnakeNode({ x: Math.ceil(TRANSVERSE / 2), y: Math.ceil(VERTICAL / 2) });
head.setDirection([LEFT, RIGHT , TOP , BOTTOM][Math.floor(Math.random() * 4)])
snake = new Snake(head); var canvas = document.getElementById(canvasId);
context = canvas.getContext('2d');
}

执行的操作分别是

  1. 实例化蛇的第一个节点,事实上刚开始也只有一个节点,位置设置在界面的中间。

  2. 随机生成一个方向并设置

  3. 实例化Snake类,以head(第一个节点)作为构造函数参数

  4. 引用canvas,获取canvascontext对象

至此,游戏已经初始化完成,然而,此刻的游戏是静止的,我们还需要调用start方法让游戏开始

    this.start = function() {
status = GAME_START;
moveTimer = window.setTimeout(move , computeMoveInterval());
foodTimer = window.setInterval(createFood, 5000);
bindEvent();
}

此方法执行的操作分别是

  1. 将游戏的状态设置成 GAME_START常量的值(表示游戏开始)

  2. 让蛇身持续移动

  3. 每5秒生成一个食物

  4. 绑定交互事件,也就是我们用键盘的方向键上下左右控制蛇游动的方向的事件

先看被setTimeout调用的move方法

var move = function() {
moveTimer = window.setTimeout( move, computeMoveInterval() );
if(currDirection) snake.setDirection( currDirection );
snake.move();
var lc = snake.getHeadPos();
for(var i = 0 ; i < foods.length ; i ++) {
if(lc.x == foods[i].x && lc.y == foods[i].y) {
snake.addNode();
self.onEatOne();
foods.splice( i, 1 );
break;
}
}
if(snake.isGameover()){
gameover();
return;
}
draw();
}
  1. 方法里面还有一次setTimeout调用,起的到作用和setInterval相同

  2. 设置蛇游动的方向

  3. 调用蛇的move方法移动

  4. 获得蛇头的位置,检查它是否与物品的位置重叠,假如重叠那么表示蛇吃到了食物,因为会调用蛇的addNode方法为蛇增加一个结点,并且触发onEatOne事件用来通知外部的事件监听,再将初吃掉的食物从食物列表中拿掉

  5. 判断游戏是否结束,假如没结束那么就执行draw方法将数据结果渲染至游戏界面

再来看 computeMoveInterval 方法,这个方法是setTimeout的第二个参数,在这里表达的意思就是定时执行move方法的时间间隔。

var computeMoveInterval = function() {
var speed = {
'1':200,
'2':160,
'3':120,
'4':100,
'5':80,
'6':60,
'7':40,
'8':20,
'9':10
}
var level = self.getLevel();
return speed[level];
}

随着游戏的进行,游戏的级别会增加,随着级别增加, 这个值越小, 也就是说move方法被执行的频率就越高,因此蛇游动的速度会越快, 游戏难度也就越大。

createFood每5秒被调用一次生成一个食物

    var createFood = function() {
var notin = snake.getAllNodePos().concat(foods);
var rand = getRandom(notin);
foods.push(rand);
}

蛇身体所占的位置和已有食物的位置被排除掉,显然食物不能生成在已被占用的位置上。

最后,我们来讲一下draw方法,它的作用是将游戏的数据结构转换为可视化界面

    var draw = function () {
context.fillStyle = '#fff';
context.fillRect(0, 0, 300, 400);
var map = getMap();
for (var key in map) {
var pointType = map[key];
var x = key.split(',')[0];
var y = key.split(',')[1]; if (pointType == 'snake') {
context.fillStyle = '#000';
} else if (pointType == 'food') {
context.fillStyle = '#f00';
} else {
continue;
}
context.fillRect( x * 10, y * 10, 10, 10 );
}
}

将游戏结构转换成draw方法可用的数据结构还需要调用两个方法,分别是getMaparrayToMap

var arrayToMap = function(array) {
var map = {};
for(var i = 0 , point ; point = array[i++];) map[[point.x , point.y]] = null;
return map;
} var getMap = function() {
var board = new Array;
for (var y = 0 ; y < VERTICAL; y++) {
for (var x = 0 ; x < TRANSVERSE ; x++) {
board.push({ x: x, y: y });
}
}
var cl = snake.getAllNodePos();
var food = arrayToMap(foods);
cl = arrayToMap(cl);
board = arrayToMap(board);
for(var key in cl) board[key] = 'snake';
for(var key in food) board[key] = 'food';
return board;
}

arrayToMap的作用其实是将一个一维数组转换为二维数组(并不是真正的二维数组,但是为了方便表达就借用二维数组这种结构),只是JavaScript的二维数组表示的有点奇葩,是一个map,所以这个函数的名称就被命名为arrayToMap

getMap函数的逻辑如下

  1. 建一个二维数组,元素个数等于TRANSVERSE * VERTICAL

  2. 获取蛇身所占的位置列表,转换成二维数组

  3. 获得食物所占的位置列表,转换成二维数组

  4. 通过null、snake、food三种值区分空、蛇身节点、食物

最终的数组结构从可视的角度来表示大概是这个样子

[null,null,null,null,null,

null,null,null,food,null,

null,null,null,null,null,

null,null,food,null,null,

null,null,snake,snake,null,

null,null,snake,null,null]

这个结构会随着move方法的调用而不断变化, draw方法就不断的将数据结构渲染至canvas上,整条蛇因此也就动了起来。

最后我们来看bindEvent方法

var bindEvent = function () {
document.body.onkeydown = function (e) {
e = e || window.event;
var keyCode = e.keyCode;
switch (keyCode) {
case 37:
navigate(LEFT);
break;
case 38:
navigate(TOP);
break;
case 39:
navigate(RIGHT);
break;
case 40:
navigate(BOTTOM);
break;
}
}
}

这个方法很简单,就是用来监听方向键的事件,然后控制蛇的方向以达到操作游戏的效果。

至此,整个游戏的逻辑也就开发完成了。麻雀虽小,但五脏俱全,这个游戏玩法虽然很少,但确实是一个正儿八经的贪食蛇游戏。附上可运行的源代码的链接地址

http://pan.baidu.com/s/1o7VIcWy

就一个html文件

游戏是我多年前写的,代码略显青涩,函数和变量的命名也是词不达意,但大致意思能表达清楚,大家就将就着看吧。

JavaScript贪食蛇游戏制作详解的更多相关文章

  1. javascript中=、==、===区别详解

    javascript中=.==.===区别详解今天在项目开发过中发现在一个小问题.在判断n==""结果当n=0时 n==""结果也返回了true.虽然是个小问题 ...

  2. Javascript 调试利器 Firebug使用详解

    Javascript 调试利器 Firebug使用详解 有时候,为了更清楚方便的查看输出信息,我们可能需要将一些调试信息进行分组输出,那么可以使用console.group来对信息进行分组,在组信息输 ...

  3. JavaScript中return的用法详解

    JavaScript中return的用法详解 最近,跟身边学前端的朋友了解,有很多人对函数中的this的用法和指向问题比较模糊,这里写一篇博客跟大家一起探讨一下this的用法和指向性问题. 1定义 t ...

  4. javascript 中合并排序算法 详解

    javascript 中合并排序算法 详解 我会通过程序的执行过程来给大家合并排序是如何排序的...  合并排序代码如下: <script type="text/javascript& ...

  5. javascript中的this作用域详解

    javascript中的this作用域详解 Javascript中this的指向一直是困扰我很久的问题,在使用中出错的机率也非常大.在面向对象语言中,它代表了当前对象的一个引用,而在js中却经常让我觉 ...

  6. JavaScript中this的用法详解

    JavaScript中this的用法详解 最近,跟身边学前端的朋友了解,有很多人对函数中的this的用法和指向问题比较模糊,这里写一篇博客跟大家一起探讨一下this的用法和指向性问题. 1定义 thi ...

  7. 高性能JavaScript模板引擎实现原理详解

    这篇文章主要介绍了JavaScript模板引擎实现原理详解,本文着重讲解artTemplate模板的实现原理,它采用预编译方式让性能有了质的飞跃,是其它知名模板引擎的25.32 倍,需要的朋友可以参考 ...

  8. JavaScript对象的property属性详解

    JavaScript对象的property属性详解:https://www.jb51.net/article/48594.htm JS原型与原型链终极详解_proto_.prototype及const ...

  9. javascript常用经典算法实例详解

    javascript常用经典算法实例详解 这篇文章主要介绍了javascript常用算法,结合实例形式较为详细的分析总结了JavaScript中常见的各种排序算法以及堆.栈.链表等数据结构的相关实现与 ...

随机推荐

  1. UUID错误

    在Archive项目时,出现了“Your build settings specify a provisioning profile with the UUID “”, however, no suc ...

  2. Js 跨域CORS报错 Response for preflight has invalid HTTP status code 405

    问题 公司项目H5调用接口遇到Response for preflight has invalid HTTP status code 405这样的错误,是使用PUT方式提交请求接口.Content-T ...

  3. JS事件流理解

    事件是用户或浏览器自身执行的某种动作,如click,load和mouseover都是事件的名字. 事件是javaScript和DOM之间的桥梁. 你若触发,我便执行--事件发生,调用它的处理函数执行相 ...

  4. 安装msdn出现的问题及解决

    安装msdn出现的问题及解决 用xx.iso 镜象文件安装 运行第一个镜象文件的setup.exe安装到一部分提示:安装程序无法打开文件 C:\Documents and Settings\empty ...

  5. 继续学习ant

    今天由于打电话,打了两个小时的电话,结果一下子错过了学习的时间段,表示很惭愧,不过查了一些资料,感觉还不错,明天继续学习吧! ant入门到精通Ant 的最完整build.xml解释ant实用实例Ant ...

  6. 【WC2015】混淆与破解 (Goldreich-Levin 算法)

    这个嘛= =直接贴VFK的题解就行了吧,感觉自己还是差别人太多 http://vfleaking.blog.uoj.ac/blog/104 讲得挺明白了的说,但还是挺难理解的说,中间实现部分简直不要太 ...

  7. 零基础HTML编码学习笔记

    任务目的 了解HTML的定义.概念.发展简史 掌握常用HTML标签的含义.用法 能够基于设计稿来合理规划HTML文档结构 理解语义化,合理地使用HTML标签来构建页面 任务描述:完成一个HTML页面代 ...

  8. java学习笔记——IO部分(遍历文件夹)

    用File类写的一个简单的工具,遍历文件夹,获取该文件夹下的所以文件(含子目录下的文件)和文件大小: /** * 列出指定目录下(包含其子目录)的所有文件 * @author syskey * */ ...

  9. mongodb c api编译

    1. autoconf-latest.tar.gz http://ftp.gnu.org/gnu/autoconf/ tar xzvf autoconf-latest.tar.gz ./configu ...

  10. Node.js~在linux上的部署~外网不能访问node.js网站的解决方法

    这是上一篇node.js部署到linux上的后续文章,当我们安装完node.js之后,建立了sailsjs的网站,然后在外面电脑上无法访问这个网站,这个问题我们如何去解决? 解决思路: 查看linux ...