使用HTML5的Canvas和raycasting创建一个伪3D游戏(part1)
使用HTML5的Canvas和raycasting创建一个伪3D游戏(part1)
刚来这找到一篇好文,自己翻译了下:(原文:http://dev.opera.com/articles/view/creating-pseudo-3d-games-with-html-5-can-1/)
转载请保留作者信息.--ifree
前言
随着浏览器性能的提升,对html5的实现也越来越完善,js也更牛逼了,实现一个Tic-Tac-Toe好得多的游戏变得轻而易举.有了canvas,创建个性的web游戏和动态图像比较之以前容易许多,我们已经不再需要flash特效的支持了.于是我开始构思一个游戏或者一个游戏引擎,就像以前dos游戏用的伪3D.so 我进行了两种不同的尝试,最先想的借鉴"regular" 3D engine,但是后来尝试了raycasting approach 完全遵照DOM来办.(估计作者dos时代就爱玩重返德军总部,raycasting以前就整过,图形学整人啊~).
在这篇文章,我将为大家分析这个小项目,跟着我的思路走一遍差不多你就懂如何构建伪3D射线追踪引擎了.所谓伪3D是因为我所做的只是通过模拟视角变换来让大家觉得这是3D.正如我们除了二维坐标系不能操作其它维的坐标系一样(这只是个2D游戏嘛).这就决定了在DHTML的二维世界里2维的线条也挣扎不出这个框框...游戏里我们会实现角色的跳、蹲等动作,但是点都不难.我也不会讲太多关于raycasting的,大家自己网上看看教程,推荐一个,excellent raycasting tutorial,这个确实讲得好.
本文假定你有一定的javascript经验,熟悉HTML5的canvas,并且了解线性代数(直译:三角学...),我无法讲得面面俱到,你一个下载我的代码(到原文去下)详细了解. ps:都有注释.
第一步
我之前说过,这个游戏引擎是基于2D地图的(补:RayCasting的主要思想是:地图是2D的正方形格子,每个正方形格子用0代表没有墙,用1 2 3等代表特定的墙,用来做纹理映射.).所以现在不要拘泥于3D,要着眼于这个2D模型.由于canvas的优良特性,它可以用来绘制俯视图.实际的游戏会涉及到DOM元素的操作,所以主流浏览器还是支持的.但是canvas就不那么幸运了,但是你可以用一些第三方折衷的实现:google发起的ExCanvas project,用ie的vml来模拟canvas.
地图
首先我们需要一个地图格式.用矩阵比较好实现.这个二维数组里的1代表外墙,2代表障碍(基本上超过0不是墙就是障碍物),0代表空地.墙用来决定以后的纹理渲染.
1
2
3
4
5
6
7
8
9
10
11
|
// 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]
];
|
一个基本的地图 Figure 1.
Figure 1: Static top-down minimap.
这样我们就任何时候都能通过迭代来访问指定的物体,只需要简单的用map[y][x]来访问就好了.
下一步,我们将构建游戏的初始化函数.首先,通过迭代把不同物件填充到canvas上.这样像Figure 1的俯视图就完成了.点击连接查看.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
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)以及当前的方向和角色.比如说旋转的角度.然后我们再扩展一点,增加一个move()方法来移动角色.
1
2
3
4
5
|
function gameCycle() {
move();
updateMiniMap();
setTimeout(gameCycle,1000/30); // aim for 30 FPS
}
|
我们把与角色相关的变量封装进palyer对象.这样更利于以后对move方法的扩展来移动其他东西;只要其他实体和player有相同的"接口"(有固定相同的属性).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
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;
}
|
可见,移动是基于角色的方向和速度决定的.也就是说,只要它们不为0就可以移动.为了获得更真实的移动效果,我们需要添加键盘监听,上下控制速度,左右控制方向.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
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 ;
}
}
}
|
下面看 Figure 2,点击下面连接看示例
Figure 2: Player movement, no collision detection as yet(没有碰撞检测)
很好!,现在角色已经可以移动了,但是这里有个明显的问题:墙.我们必须进行一些碰撞检测来确保玩家不会想鬼一样穿墙(呵呵).关于碰撞检测可能要用一篇文章来讲,所以我们选择了一个简单的策略(检查坐标不是墙或者障碍就可以移动)来解决这个问题,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
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);
}
|
正如你看到的,我们不仅做了墙内检测,还有射线在墙外的位置判断.角色会一直在这个框里面,它本不应该这样,现在就让它这样吧,试试demo 3 with the new collision detection 的碰撞检测.
追踪射线
现在,角色已经可以移动了,我们要让角色走向第三维.要实现它,我们需要知道角色的当前视角.所以,我们需要使用"raycasting"技术.什么叫"raycasting"? 试着想象射线从角色的当前视角发射出去的情景.当射线碰撞到障碍物,我们由此得知那个障碍物的方向.
如果你还是不清楚的话再去看看教程,我推荐一个 Permadi's raycasting tutorial
试想一个320x240的游戏画布展示了一个120度的FOV(视角Field of view).如果我们每隔2像素发射一条射线,就需要160条射线,分成两个80条在角色的两边.这样,画布被2像素的竖条分成了n段.在这个demo里,我们用60度的FOV并且用4像素的竖条来分割,简单点嘛.
每一轮游戏里,我们遍历这些射线(4像素的竖条),根据角色的旋转角度和射线来找到最近的障碍.射线的角度可以根据角色的视角来计算.然后绘制阴影等.
其实最棘手的是射线追踪部分,但是用这个矩阵就比较好处理了.地图上的一切都根据这个二维坐标均匀的分布,只需要用一点数学知识就可以解决这个问题.最简单的方法就是同时对水平和垂直方向做碰撞检测.
首先我们看看画布上的垂直射线,射线数等于上面说的竖条数.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
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()方法在每轮游戏的逻辑处理后面都会调用一次.下面是具体的 ray casting方法了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
function castSingleRay(rayAngle) {
// make sure the angle is between 0 and 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
// and 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);
}
|
水平测试和垂直测试都差不多,所以这部分我就略过了;再补充一点,如果水平和锤子都有障碍,就取最短的制造阴影.raycasting之后我们需要在地图上绘制真实的射线.电筒装的光线只是方便测试,后门会移除,相关代码可以下载我的示例代码.结果就像Figure 3那样的.
Figure 3: 2D raycasting on minimap
纹理
在继续深入之前,我们看看将要使用的纹理.以前的几个项目,深受德军总部3D的启发,我们也会坚持这点,同时部分借鉴它的墙壁纹理处理.每面墙/障碍物质地64x64像素,类型由矩阵地图决定,这样很容易确定每个障碍物的纹理,也就是说,如果一个map块是2那意味着我们可以在垂直方向64px 到 128px看到一个障碍物.然后我们开始拉伸纹理来模拟距离和高度,这可能有点复杂,但是规律是一样的.看看Figure 4,每个纹理都有两个版本.这样很容易确伪造一面墙的不同朝向.这个我就交给读者当作练习吧.
Figure 4: The sample wall textures used in my implementation.
Opera 和图像插值
Opera浏览器对纹理的处理有点小bug(作者是opera论坛的超哥).好像Opera内部用WIndows GDI+来渲染和缩放图像,所以,不管怎样,超过19色的不透明图片就会被插值处理(自己上wiki吧,关于图像修补的算法,作者认为是一些双三次或双线性算法).这样会大幅度降低本引擎的速度,因为他它每秒都会对图片进行多次像素调整.幸运的是,Opera可以禁用这个选项opera:config.或者你可以减少图片的像素少于19色,或者用透明的图片.然而,即使使用后一种方法,当插值被完全关闭的时候会大大减少纹理的质量,所以最好还是在其他浏览器里面禁用该选项.
1
2
3
4
5
|
function initScreen() {
...
img.src = (window.opera ? "walls_19color.png" : "walls.png" );
...
}
|
开始 3D!
虽然现在这游戏看起来还不咋样,但是它已经为伪3D打下了坚实的基础.对应于屏幕上的每条直线都有一条射线,我们也知道当前方向的射线长短.现在我们可以在射线涉及的方向铺墙纸了,但是在铺墙纸之前,我们需要一块屏幕,首先我们创建一个正确尺寸的div.
1
|
< div id = "screen" ></ div >
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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);
}
}
|
![](http://dev.opera.com/articles/view/creating-pseudo-3d-games-with-html-5-can-1/fisheye0.png)
1
2
3
4
5
6
7
8
9
|
...
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);
|
现在我们可以计算出墙的高度;现在障碍物已经是立方体了,墙和单射宽度一样,尽管我们必须额外地拉伸纹理使之呈现正确.在raycasting循环中我们也要存储障碍物类型,用来判离墙的距离.我们简单的把它和墙高相乘.最后,为了更清楚的描述,我们只是简单的移动竖条和它的child image.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// now calc the position, height and 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" ;
}
|
先这么多吧,看看Figure 6!噢,还没完,把这个叫做游戏之前我们还有很多事要做,但是第一个大障碍我们完成了并且我们的3D世界正等待被扩展.最后要做的事就是添加一个地板和天花板,但是这没什么,用两个div每一个占半屏适当填充下再根据需要改下颜色就好了.
Figure 6: Pseudo-3D raycasting with textured walls
进一步开发的思路
- 分离游戏的逻辑,比如移动,移动和游戏渲染的帧速没有关系.
- 优化.有几个地方可以进行优化,以获得一些性能的提升,比如当竖改变时只改变style属性.
- 静态元件,添加处理静态原件的能力(如灯,桌子等)这样更有趣.
- 敌人/NPC,当引擎可以处理静态原件的时候,它可以四处走动,至少有一个实体,也可以尝试一些简单的AI来填充这个游戏.
- 更好地处理碰撞检测和移动,角色的移动处理得太粗糙,如,一放开按键就停止.用一点加速让运动更流畅.当前的碰撞检测是有一点粗糙;角色死了就在路上站着不动了,如果可以滑下去那更好.
- Sounds - 声音可以给游戏带来不错的体验,用flash+js的实现有很多,作者给了个例子 Scott Schill's SoundManager2
这是第二部分,懒得翻译了有时间再说
http://dev.opera.com/articles/view/3d-games-with-canvas-and-raycasting-part/
原文链接:http://www.cnblogs.com/ifree/archive/2010/11/02/creating-pseudo-3d-games-with-html-5-canvas-1.htmlhttp://www.cnblogs.com/ifree/archive/2010/11/02/creating-pseudo-3d-games-with-html-5-canvas-1.html
使用HTML5的Canvas和raycasting创建一个伪3D游戏(part1)相关推荐
- 使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏
使用 HTML 5 Canvas 和 Raycasting 创建伪 3D 游戏 介绍 地图 Opera浏览器与图像插值 优化 拆分渲染和游戏逻辑 优化渲染 碰撞检测 精灵 敌人 介绍 随着最近浏览器性 ...
- 使用HTML5 canvas和光线投影算法创建伪3D 游戏
为什么80%的码农都做不了架构师?>>> 作者 Jacob Seidelin · 2008年11月28日 本文翻译自 Creating pseudo 3D games with ...
- 学习在Unity中创建一个动作RPG游戏
游戏开发变得简单.使用Unity学习C#并创建您自己的动作角色扮演游戏! 你会学到什么 学习C#,一种现代通用的编程语言. 了解Unity中2D发展的能力. 发展强大的和可移植的解决问题的技能. 了解 ...
- 学习用C#在Unity中创建一个2D Metroidvania游戏
学习用C#在Unity中创建一个2D Metroidvania游戏 你会学到: 构建2D Unity游戏 用C#编程 玩家统计,水平提升,米尔和远程攻击 敌方人工智能系统 制定级别和级别选择 Lear ...
- 本文将引导你使用XNA Game Studio Express一步一步地创建一个简单的游戏
本文将引导你使用XNA Game Studio Express一步一步地创建一个简单的游戏 第1步: 安装软件 第2步: 创建新项目 第3步: 查看代码 第4步: 加入一个精灵 第5步: 使精灵可以移 ...
- 如何使用HTML5,CSS3和PHP创建一个联系表格
就个人而言,我觉得重要的是要注意的[积极]影响HTML5的形式和运作的方式,他们将在今后几年将有.实际上,我们无法实现所有 的新功能,今天,但你不希望要落后其他行业落后时,这些功能最终成为广泛支持. ...
- 使用Godot Engine创建一个2D RPG游戏
学习用对话框,有限状态机,剑攻击,敌人,着色器,用户界面,地下城和更多编码一个2D RPG游戏 你会学到什么 掌握游戏编程的关键概念 学习Godot的语言GDScript 熟悉Godot引擎的界面 创 ...
- 手把手教你用CSS实现一个VR 3D游戏菜单栏效果
3D游戏菜单组件 关于如何建立一个响应性.适应性和无障碍的3D游戏菜单的基础性概述. 在这篇文章中,我想带着大家写一个3D游戏菜单组件的案例.首先让我们看看成品是什么样子的 概述 相信大家都玩过赛博朋 ...
- html5伪3d游戏探索
前言 现在微信推广上面可是有很多小游戏的,譬如,围住神经猫,最强眼力,连连看,消消看,也有一些2d跑酷类的. 但是一个问题在于,html5游戏没有3d出来,或者说,html5的3d技术不被所有浏览器支 ...
最新文章
- A*算法解决八数码问题 Java语言实现
- 保护 ASP.NET 会话状态
- Windows 系统常见操作
- 《剑指offer》链表分割
- 【POJ - 2226】Muddy Fields(匈牙利算法 或 网络流dinic,二分图匹配,最小点覆盖,矩阵中优秀的建图方式 )
- 19年春第十五周学习
- 181124每日一句
- STC学习:可振动感应的电子音乐
- 精读《useEffect 完全指南》
- Intellij IDEA 设置字体的大小
- Android原生框架--Xui使用
- SEI文献整理2:A Review of Radio Frequency Fingerprinting Techniques(2020)
- vgg16_weights_tf_dim_ordering_tf_kernels_notop.h5
- 增减幅度计算机公式,增长率怎么算公式(基数为0的增长率)
- gerrit的第一次提交记录
- 在Apple开发官网测试TestFlight
- 网络安全学习笔记——蓝队实战攻防
- 三甲医院设备科(医工科)
- springboot+vue企业人事人力资源管理系统java公司员工出差考勤办公OA系统
- msxml4.dll加载失败、动态链接库例程失败
热门文章
- WinLicense 2.4.6.30 x86 / x64强大的技术和灵活性相结合
- 删除的数据如何恢复?误删了文件怎么恢复
- 数据结构与算法二:时间/空间复杂度(complexity)
- html5怎么做出旋转效果,HTML5+Canvas制作的幻彩旋转圆盘特效
- 汽车维修保养小程序毕业设计毕设作品开题报告答辩PPT
- ipairs与pairs的区别
- DELL XPS 13 9370 CAZ60 Compal LA-E671P Rev1.0 戴尔笔记本点位图+电路图
- Python制作六款经典的童年游戏(附源码)
- win10 声音增强
- Accelerator更改默认使用的GPU卡号