为什么80%的码农都做不了架构师?>>>   

作者 Jacob Seidelin · 2008年11月28日

本文翻译自 Creating pseudo 3D games with HTML 5 canvas and raycasting

本文有捷克语版本 - Jak vytvořit pseudo 3D hry v HTML5 canvasu s raycastingem - 由 Martin Hassman 翻译。

简述

随着浏览器的功能越来越强大,用 JavaScript 可以创建比 Tic-Tac-Toe 复杂的多的游戏。不再需要使用 Flash 以创建炫的特效,随着 HTML5 Canvas 元素的问世,创建网页游戏和动态图形特效变得非常容易。我一直想创建像 iD 公司的 Wolfenstein 3D 游戏那样的伪-3D 游戏引擎。我尝试了两种不同方法:第一种是通过 Canvas 创建"常规的" 3D 游戏引擎;第二种是通过 DOM 技巧和光线投射(raycasting) 算法实现游戏。

本文将介绍如何用后一种方法创建伪-3D 游戏引擎。之所以称作伪-3D 是因为本质上创建的是 2D 迷宫游戏,通过限制玩家视角我们可以获得类似 3D 的效果:只允许玩家在水平方向旋转视角,而不允许垂直方向变化视角。这个限制保证了游戏中的垂直线永远都被显示为垂直线——因为我们在DHTML的矩形世 界中。我们也不允许玩家跳或蹲下,尽管这实现起来不是很难。本文不介绍光线投影法的具体理论,即使其理论并不难。我建议读者看看 F. Permadi 写的光线投影算法教程,其中详细介绍了光线投影算法。

本文要求读者有 JavaScript 编程经验、熟悉 HTML5 canvas 元素并了解三角函数。本文中所列出的代码并不全,可在此处下载完整代码。

First steps

游戏的核心是 2D 地图,暂时忘记 3D 并集中精力创建 2D 迷宫。canvas 元素用于绘制游戏世界的俯瞰地图,作为游戏的迷你地图。游戏需要操作 DOM 元素。目前 Firefox, Opera 和 Safari 支持 canvas 元素,但是 Internet Explorer 不支持。幸运的是,我们可以通过 ExCanvas 项目解决此问题:ExCanvas 是一个用 VML 来模拟的 canvas 功能的 JavaScript 文件。

地图

首先需要定义地图。可以通过数组的数组储存地图数据。每一个元素都是一个整数值:1代表墙,2代表障碍物(非零值代表某种障碍物),0代表空地。后面会使用 wall 类型以确定使用的纹理。

// a 32x24 block map
var map = [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,2,0,0,0,0,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,2,0,0,0,0,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
...
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
];

地图如图 1 所示。

图 1:俯瞰地图

可以通过 map[y][x] 获取所需元素值。

接下来创建初始化函数。此函数遍历 canvas 元素的地图数据,在墙和障碍物处绘制方块。这将产生如图1所示效果。

var mapWidth = 0; // number of map blocks in x-direction
var mapHeight = 0; // number of map blocks in y-direction
var miniMapScale = 8; // how many pixels to draw a map block

function init() {
mapWidth = map[0].length;
mapHeight = map.length;

drawMiniMap();
}

function drawMiniMap() {
// draw the topdown view minimap
var miniMap = $("minimap");
miniMap.width = mapWidth * miniMapScale; // resize the internal canvas dimensions 
miniMap.height = mapHeight * miniMapScale;
miniMap.style.width = (mapWidth * miniMapScale) + "px"; // resize the canvas CSS dimensions
miniMap.style.height = (mapHeight * miniMapScale) + "px";

// loop through all blocks on the map
var ctx = miniMap.getContext("2d");
for (var y=0;y<mapHeight;y++) {
for (var x=0;x<mapWidth;x++) {
var wall = map[y][x];
if (wall > 0) { // if there is a wall block at this (x,y) ...
ctx.fillStyle = "rgb(200,200,200)";
ctx.fillRect( // ... then draw a block on the minimap
x * miniMapScale,
y * miniMapScale,
miniMapScale,miniMapScale
);
}
}
}
}移动玩家

创建了游戏世界后,接下来创建可以移动的游戏玩家。首先添加 gameCycle() 函数,初始化函数会循环调用此函数以更新游戏视图。接下来创建表示玩家 (x,y) 位置的变量和玩家前进方向。接下来在 gameCycle 中添加 move() 函数用来移动玩家。

function gameCycle() {
move();
updateMiniMap();
setTimeout(gameCycle,1000/30); // aim for 30 FPS
}

我们把所有与玩家有关的变量都放在 player 对象中,这样有助于将 move 函数扩展到其他实体——只要其他实体的接口与 player 对象一致即可,即有相同的属性。

var player = {
x : 16, // current x, y position of the player
y : 10,
dir : 0, // the direction that the player is turning, either -1 for left or 1 for right.
rot : 0, // the current angle of rotation
speed : 0, // is the playing moving forward (speed = 1) or backwards (speed = -1).
moveSpeed : 0.18, // how far (in map units) does the player move each step/update
rotSpeed : 6 * Math.PI / 180 // how much does the player rotate each step/update (in radians)
}

function move() {
var moveStep = player.speed * player.moveSpeed; // player will move this far along the current direction vector

player.rot += player.dir * player.rotSpeed; // add rotation if player is rotating (player.dir != 0)

var newX = player.x + Math.cos(player.rot) * moveStep; // calculate new player position with simple trigonometry
var newY = player.y + Math.sin(player.rot) * moveStep;

player.x = newX; // set new position
player.y = newY;
}

如上面代码所示,移动和旋转取决于 player.dir 和 player.speed 是否是非零值。我们定义了一些键盘操作来移动玩家:上下箭头来控制速度,左右箭头来控制方向。

function init() {
...
bindKeys();
}

// bind keyboard events to game functions (movement, etc)
function bindKeys() {
document.onkeydown = function(e) {
e = e || window.event;
switch (e.keyCode) { // which key was pressed?
case 38: // up, move player forward, ie. increase speed
player.speed = 1; break;
case 40: // down, move player backward, set negative speed
player.speed = -1; break;
case 37: // left, rotate player left
player.dir = -1; break;
case 39: // right, rotate player right
player.dir = 1; break;
}
}
// stop the player movement/rotation when the keys are released
document.onkeyup = function(e) {
e = e || window.event;
switch (e.keyCode) {
case 38:
case 40:
player.speed = 0; break; 
case 37:
case 39:
player.dir = 0; break;
}
}
}

效果见图 2 (点击下面的链接可以试试游戏效果)

图 2: 移动玩家,障碍物暂时无效

玩家可以自由移动,但是有一个很大的问题:障碍物。我们需要检测障碍物以阻止玩家和鬼一样穿越障碍物。这里我们使用最简单的障碍物检测方法,因为更复杂的障碍物检测方法不是本文目的。我们只检查移动目标位置是否是障碍物,如果是则停止移动。

function move() {
...
if (isBlocking(newX, newY)) { // are we allowed to move to the new position?
return; // no, bail out.
}
...
}

function isBlocking(x,y) {
// first make sure that we cannot move outside the boundaries of the level
if (y < 0 || y >= mapHeight || x < 0 || x >= mapWidth)
return true;
// return true if the map block is not 0, ie. if there is a blocking wall.
return (map[Math.floor(y)][Math.floor(x)] != 0); 
}

如上面代码所示,不仅检测障碍物还会检测是否超出地图边界。尽管只要保证地图边缘都有障碍物就可以省去此判断,但我们还是保留了此判断。点击试验带障碍物检测的游戏效果。

光线投影法

创建了可移动的玩家之后就可以开始创建 3D 效果了。首先我们需要确定哪些是玩家目前视野可见的:为达到此目的我们将使用名为光线投影法(raycasting)的技巧。想像一下从玩家向各个方向发出光线,如果光线碰到了墙或障碍物,也就意味着应该显示此障碍。

如果你还是不明白,强烈建议阅读 Permadi 关于光线投影算法的教程。

考虑视场(FOV)为120°的 320x240 的游戏画面,如果每2个像素发出一条光纤,则需要160条光线,每边80条。这样屏幕被分成2个像素宽的竖直条。在此例子中,我们把分辨率降为4个像素并把视场降为60°。很容易修改这些参数。

在每个游戏周期中,循环计算每一根竖直条:根据玩家前进方向计算角度范围,并在此范围内发出光线以确定最近的墙。通过玩家与屏幕中的连线可确定光线的角度。

这里最难的就是发出光纤。我们可以利用地图格式的简单性。由于地图中的线间距相等,通过简单的数学计算就可以解决此问题。最简单的方法就是进行两遍循环测试,一遍检测"垂直"方向的墙,另一遍检测"水平"方向的墙。

首先遍历屏幕垂直条,所需光线数目等于垂直条的数目。

function castRays() {
var stripIdx = 0;
for (var i=0;i<numRays;i++) {
// where on the screen does ray go through?
var rayScreenPos = (-numRays/2 + i) * stripWidth;

// the distance from the viewer to the point on the screen, simply Pythagoras.
var rayViewDist = Math.sqrt(rayScreenPos*rayScreenPos + viewDist*viewDist);

// the angle of the ray, relative to the viewing direction.
// right triangle: a = sin(A) * c
var rayAngle = Math.asin(rayScreenPos / rayViewDist);
castSingleRay(
player.rot + rayAngle, // add the players viewing direction to get the angle in world space
stripIdx++
);
}
}

每个游戏周期都会调用 castRays() 函数。下面是实际的光线投影代码:

function castSingleRay(rayAngle) {
// make sure the angle is between 0 和 360 degrees
rayAngle %= twoPI;
if (rayAngle > 0) rayAngle += twoPI;

// moving right/left? up/down? Determined by which quadrant the angle is in.
var right = (rayAngle > twoPI * 0.75 || rayAngle < twoPI * 0.25);
var up = (rayAngle < 0 || rayAngle > Math.PI);

var angleSin = Math.sin(rayAngle), angleCos = Math.cos(rayAngle);

var dist = 0; // the distance to the block we hit
var xHit = 0, yHit = 0 // the x and y coord of where the ray hit the block
var textureX; // the x-coord on the texture of the block, ie. what part of the texture are we going to render
var wallX; // the (x,y) map coords of the block
var wallY;

// first check against the vertical map/wall lines
// we do this by moving to the right or left edge of the block we're standing in
// 和 then moving in 1 map unit steps horizontally. The amount we have to move vertically
// is determined by the slope of the ray, which is simply defined as sin(angle) / cos(angle).

var slope = angleSin / angleCos; // the slope of the straight line made by the ray
var dX = right ? 1 : -1; // we move either 1 map unit to the left or right
var dY = dX * slope; // how much to move up or down

var x = right ? Math.ceil(player.x) : Math.floor(player.x); // starting horizontal position, at one of the edges of the current map block
var y = player.y + (x - player.x) * slope; // starting vertical position. We add the small horizontal step we just made, multiplied by the slope.

while (x >= 0 && x < mapWidth && y >= 0 && y < mapHeight) {
var wallX = Math.floor(x + (right ? 0 : -1));
var wallY = Math.floor(y);

// is this point inside a wall block?
if (map[wallY][wallX] > 0) {
var distX = x - player.x;
var distY = y - player.y;
dist = distX*distX + distY*distY; // the distance from the player to this point, squared.

xHit = x; // save the coordinates of the hit. We only really use these to draw the rays on minimap.
yHit = y;
break;
}
x += dX;
y += dY;
}

// horizontal run snipped, basically the same as vertical run
...

if (dist) 
drawRay(xHit, yHit);
}

水平墙的检测几乎如上面完全一样,这里就不再赘述。需要指出的是,如果水平和竖直扫描都检测到了墙,那么取最短距离。最后我们在迷你地图中显示光线 ——这 只是用于测试目的因此是暂时的。这在有些浏览器中消耗较多 CPU 资源,因此在开始 3D 渲染后将删除这些光线。效果如图 3所示。

图 3: 2D 光线投影

纹理

下面我们来看看所使用的纹理。由于这个例子是受 Wolfenstein 3D 启发,这里将使用此游戏中的部分纹理。每个墙纹理都是64x64像素。由于地图中设置了墙的类型,因此很容易为不同类型的墙找到相应的纹理:如地图中障碍 物类型是2则意味着竖直方向应使用从64像素到128像素的图像。后面为了模拟距离和高度不同,将拉伸这些纹理,但原理是一致的。如图 4所示,有两个版本的纹理,一种颜色正常一种稍微有些暗。可以通过使用这两种纹理模拟光照:如东面或北面使用正常颜色版本纹理,而南面或者西面使用另一种 暗的纹理。

图 4: 本例所用的墙的纹理

Opera 和图形插值

Opera 浏览器中的纹理渲染碰到一点小问题。Opera 使用 Windows GDI+ 方法来渲染和缩放图像,这导致了超过19个像素的不透明图像将被插值(使用某种双三次插值算法或双线性插值算法)。这将极大的影响渲染速度,因为需要在一 秒内多次的缩放大量图像。幸运的是可在 opera:config 中禁用此特性:在 "Multimedia" 中取消选择 "Show Animation",然后保存退出即可。另一种解决方法是把纹理图片存为少于20种颜色的图像,或者至少在纹理中设置一处透明像素。使用后一种方案还是 会影响计算速度。后一种方法也将极大的影响画面质量,所以其他浏览器中最好还是不要使用此方法:

function initScreen() {
...
img.src = (window.opera ? "walls_19color.png" : "walls.png");
...
}创建 3D 渲染

上面为渲染伪-3D 视角打下了坚实的基础。每一条光线都对应屏幕的一个垂直条,而且我们也知道了每个方向上到墙的距离。下面需要给墙贴上合适的“墙纸”——纹理。在此之前先要创建游戏屏幕:首先创建 div 元素,并设置合适的大小。

<div id="screen"></div>

然后创建垂直条作为上面 div 元素的子元素。垂直条也是 div 元素,其大小等于上面所说的条宽度。所有条元素合起来就表示了整个屏幕。注意垂直条元素的 overflow 属性值被设为 hidden ,以保证隐藏不属于此条元素的部分纹理。为每一个垂直条添加一个包含纹理图像的图像元素作为子元素。我们在 init() 函数中实现初始化工作。

var screenStrips = [];

function initScreen() {
var screen = $("screen");
for (var i=0;i<screenWidth;i+=stripWidth) {
var strip = dc("div");
strip.style.position = "absolute";
strip.style.left = i + "px";
strip.style.width = stripWidth+"px";
strip.style.height = "0px";
strip.style.overflow = "hidden";

var img = new Image();
img.src = "walls.png";
img.style.position = "absolute";
img.style.left = "0px";

strip.appendChild(img);
strip.img = img; // assign the image to a property on the strip element so we have easy access to the image later

screenStrips.push(strip);
screen.appendChild(strip);
}
}

确定每个条元素所用的纹理图像只需要上下移动纹理图像;通过拉伸和左右移动,我们可以只绘制纹理元素的一部分。为实现墙的距离感,我们需要调整条元 素高度 并在竖直方向拉伸纹理图像以使之适应新高度。我们保持条元素的水平中心不变,剩下的就是把竖直条移动到屏幕中心减其高度的一半位置即可。

条元素和其图像子元素都被存在数组中,以便通过序号查找。

让我们重新来看渲染循环。在光线投影循环中现在需要记录更多的信息,如光线与墙的相遇位置和墙的类型。这决定了如何移动纹理图像以显示正确的部分。现在用操纵屏幕竖直条的代码替换迷你地图中的光线投影测试代码。

我们已经知道了到墙的距离的平方,只需要开平方即可得到真正的距离。我们还需要对此距离做出调整,以消除"鱼眼"畸变——见图 5。

图 5: 未修正"鱼眼"畸变效果

注意到墙似乎被"弯曲"了。幸运的是改正方法很简单——我们只需计算到墙的垂直距离。只要用到墙的距离乘以光线的余弦值即可。更多细节请参考 Permadi 的教程。

...
if (dist) {
var strip = screenStrips[stripIdx];

dist = Math.sqrt(dist);

// use perpendicular distance to adjust for fish eye
// distorted_dist = correct_dist / cos(relative_angle_of_ray)
dist = dist * Math.cos(player.rot - rayAngle);

现在可以计算墙的投影高度;由于墙是正方体的且同一个垂直条中的墙宽度相同,尽管我们需要以和垂直条宽度相同的系数拉伸纹理,以保证正确渲染。在光 线投影 循环中,我们也保存了墙的类型,这可以告诉我们需要如何移动纹理图像。我们把类型数乘以墙的投影高度即可。最终我们将垂直条元素和其子图像移动到合适位 置。

// now calc the position, height 和 width of the wall strip
// "real" wall height in the game world is 1 unit, the distance from the player to the screen is viewDist,
// thus the height on the screen is equal to wall_height_real * viewDist / dist
var height = Math.round(viewDist / dist);

// width is the same, but we have to stretch the texture to a factor of stripWidth to make it fill the strip correctly
var width = height * stripWidth;

// top placement is easy since everything is centered on the x-axis, so we simply move
// it half way down the screen and then half the wall height back up.
var top = Math.round((screenHeight - height) / 2);

strip.style.height = height+"px";
strip.style.top = top+"px";

strip.img.style.height = Math.floor(height * numTextures) + "px";
strip.img.style.width = Math.floor(width*2) +"px";
strip.img.style.top = -Math.floor(height * (wallType-1)) + "px";

var texX = Math.round(textureX*width);

if (texX > width - stripWidth) // make sure we don't move the texture too far to avoid gaps.
texX = width - stripWidth;

strip.img.style.left = -texX + "px";

}

图 6 显示的是最终效果!好吧,在成为真正的游戏之前还有很多工作需要做,但是我们已经迈出了第一步并创建了一个 3D 世界!最后需要添加天花板和屋顶——这很简单,我们使用均色填充实现。只需要添加两个 div 元素,每个占据一半屏幕空间。通过 z-index 属性把他们置于条元素之下并设置合适颜色即可。

图 6: 伪-3D 光线投影法

未来改进把渲染逻辑和其他游戏逻辑(如移动角色)分开。玩家移动应独立于渲染帧率。优化 —— 有很多地方可以优化,如当垂直条改变时只设置样式属性等。静物 —— 创建静物 (如灯、桌子和可以拣的物品等),这将使的 3D 世界更加有趣。敌人/ NPC —— 成功创建静物后,给其赋予移动能力,并创建简单的 AI 后就可以创建其他游戏角色。更好地移动处理和障碍物检测 —— 现在的玩家移动非常原始,当释放键盘键后立刻停止移动。可以为移动和旋转增加加速度能力,以获得更平滑的游戏体验。当前的障碍物碰撞检测非常原始,玩家遇到障碍物会立刻停下。可能的改进是让玩家遇到墙后沿着墙滑动。音效 —— 通过 Scott Schill's SoundManager2 之类的 Flash/JavaScript sound bridge 可以轻松的为各种事件添加音效。下一篇文章—使用 HTML 5 canvas 和光线投影算法创建伪 3D 游戏:第二部分

本文采用的授权是创作共用的“署名-非商业性使用-相同方式共享 2.5 通用许可”。

转载于:https://my.oschina.net/cmw/blog/14964

使用HTML5 canvas和光线投影算法创建伪3D 游戏相关推荐

  1. 使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏

    使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏 介绍 地图 Opera浏览器与图像插值 优化 拆分渲染和游戏逻辑 优化渲染 碰撞检测 精灵 敌人 介绍 随着最近浏览器性 ...

  2. 使用HTML5的Canvas和raycasting创建一个伪3D游戏(part1)

    使用HTML5的Canvas和raycasting创建一个伪3D游戏(part1) 刚来这找到一篇好文,自己翻译了下:(原文:http://dev.opera.com/articles/view/cr ...

  3. 用HTML5 Canvas为Web图形创建特效

    HTML5 Canvas 将使用像素在屏幕上绘制图形图像. 本节演示了五种用于操作像素以创建摄影特效的 Canvas 技术. 您可使用这些技术来生成独具特色的图像,为您的网站.博客.视频游戏画面.广告 ...

  4. 用html做个猜字游戏,HTML5 Canvas API制作简单的猜字游戏

    这篇文章主要介绍了借助HTML5 Canvas API制作一个简单的猜字游戏的实例分享,游戏中每局会自动生成一个字母,玩家按键盘来猜测该字母是哪一个,需要的朋友可以参考下 二话不说,先上效果图以及源代 ...

  5. html5+canvas+javascript开发打灰机小游戏

    今天不出太阳,整个人都有点颓废.为了我的大前端计划,不得已找点代码练练手. 打灰机是很早就流行的手机游戏,那时候智能手机还很贵,我还是学生一枚.现在出来工作了,发现别人写的打灰机游戏,然后游戏逻辑很差 ...

  6. html5伪3d游戏探索

    前言 现在微信推广上面可是有很多小游戏的,譬如,围住神经猫,最强眼力,连连看,消消看,也有一些2d跑酷类的. 但是一个问题在于,html5游戏没有3d出来,或者说,html5的3d技术不被所有浏览器支 ...

  7. html5 游戏制作教程,利用HTML5 Canvas制作一个简单的打飞机游戏

    之前在当耐特的DEMO里看到个打飞机的游戏,然后就把他的图片和音频扒了了下来....自己凭着玩的心情重新写了一个.仅供娱乐哈......我没有用框架,所有js都是自己写的......所以就可以来当个简 ...

  8. 网页游戏制作html5,利用HTML5 Canvas制作一个简单的打飞机游戏

    之前在当耐特的DEMO里看到个打飞机的游戏,然后就把他的图片和音频扒了了下来....自己凭着玩的心情重新写了一个.仅供娱乐哈......我没有用框架,所有js都是自己写的......所以就可以来当个简 ...

  9. html5 canvas纯js开发战棋类rpg游戏

    一.效果 游戏是用纯js开发的,我也不是经常用js,代码有写的不好的地方还请见谅. 这个项目是上班闲着的时候做的,目前正在开发中,代码地址:https://github.com/zxf20180725 ...

最新文章

  1. 2021 年中国敏捷行业现状调查全面启动
  2. java语言用什么编程_使用什么编程语言开发Java?
  3. 字节跳动客户开发_实习|字节跳动 客户端实习生 1-5面 面经
  4. 皮一皮:皇上,他在下毒!
  5. 第三次学JAVA再学不好就吃翔(part34)--多态的成员访问
  6. 【ArcGIS风暴】ArcGIS中制作GPS点位轨迹线及多边形
  7. c语言线性表库函数大全,数据结构(C语言版)-线性表习题详解
  8. 视觉设计基础知识整理
  9. Office Communications Server 和客户端使用的端口和协议
  10. 刚创建了蕝薱嚣张IT部落
  11. 知识词典 »网站地图
  12. 实验室计算机维修申请条件,计算机实验室管理制度
  13. 比例尺分辨率转换(openlayers)
  14. 广州白云国际机场IT信息化历程及信息化系统介绍
  15. 阿里巴巴社招笔试题——多线程打印
  16. 虚拟机创作ubuntu18的ISO镜像
  17. 2021年山东省安全员C证模拟考试及山东省安全员C证作业模拟考试
  18. 济南少儿学国画培训班
  19. java面试题-捕获异常
  20. 《黄金时代-王小波》

热门文章

  1. (OK) 图解几个与Linux网络虚拟化相关的虚拟网卡-VETH/MACVLAN/MACVTAP/IPVLAN
  2. 笔记本电脑优化以解决降频(提高稳定性,在散热和功耗无问题下寻找降频原因)
  3. C++——多态、异常、转化函数
  4. 无盘服务器怎么设置客户端启动,网吧无盘客户端配置向导
  5. 如何维护接口文档供外部调用——在线接口文档管理
  6. 这份中文pandas速查表,真不错!
  7. WRONG_DOCUMENT_ERR: A node is used in a different document than the one th
  8. Spring源码深度解析(郝佳)-学习-源码解析-基于注解切面解析(一)
  9. 我的单片机之路姗姗来迟
  10. vue项目运行自动打开浏览器,默认设置为google浏览器的方法