HTML5 游戏高级教程(三)

原文:Pro HTML5 Games

协议:CC BY-NC-SA 4.0

六、向我们的世界添加实体

在前一章中,我们为 RTS 游戏搭建了基本框架。我们加载了一个关卡,并使用鼠标进行平移。

在这一章中,我们将通过在我们的游戏世界中添加实体来建立它。我们将建立一个通用框架,允许我们轻松地添加实体,如建筑物和单位到一个级别。最后,我们将增加玩家使用鼠标选择这些实体的能力。

我们开始吧。我们将使用第五章中的代码作为起点。

定义实体

这些是我们将添加到游戏中的游戏实体:

  • 建筑 : 我们的游戏将会有四种类型的建筑。

  • 基础:用于建造其他建筑的主要结构

  • 星港:用于传送地面车辆和飞机

  • 收割机:用于从油田中提取资源

  • 地面炮塔:用来防御地面车辆的防御建筑

  • 交通工具:我们的游戏将会有四种交通工具。

  • 运输工具:一种用来运送物资和人员的非武装车辆

  • 收割者:部署在油田收割者建筑内的移动单位

  • 侦察坦克:一种轻型快速移动的坦克,用于侦察

  • 重型坦克:速度较慢的坦克,拥有更重的装甲和武器

  • 飞行器 : 我们的游戏将会有两种类型的飞行器。

  • 斩波器:一种缓慢移动的飞行器,可以攻击陆地和空中

  • 幽灵:一种快速移动的喷气式飞机,只能在空中攻击

  • 地形:除了已经整合到我们地图中的地形,我们将定义两种额外的地形。

  • 油田:可以通过部署收割机提取现金的矿产资源来源

  • 岩石:有趣的岩层

我们将实体类型存储在单独的 JavaScript 文件中,以使代码更易于维护。我们要做的第一件事是在 HTML 文件的 head 部分添加对新 JavaScript 文件的引用。修改后的 head 部分现在看起来像清单 6-1 中的。

清单 6-1。 添加对实体的引用(index.html)

<head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8">
    <title>Last Colony</title>
    <script src="js/common.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/jquery.min.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/game.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/mouse.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/singleplayer.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/maps.js" type="text/javascript" charset="utf-8"></script>

    <!-- Definitions for game entities -->
    <script src="js/buildings.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/vehicles.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/aircraft.js" type="text/javascript" charset="utf-8"></script>
    <script src="js/terrain.js" type="text/javascript" charset="utf-8"></script>

    <link rel="stylesheet" href="styles.css" type="text/css" media="screen" charset="utf-8">
</head>

有了这些代码,我们现在就可以开始为游戏定义我们的第一组实体,建筑物。

定义我们的第一个实体:主基地

我们将定义的第一个建筑是主基地。与游戏中其他可以由玩家建造的建筑不同,主基地总是在关卡开始前就已经建造好了。只要玩家有足够的资源,基地允许玩家传送到其他建筑中。

基础将由单个 sprite 表图像组成,该图像包含基础的不同动画状态(见图 6-1 )。

[外链图片转存中…(img-1bIAmYHT-1723738180258)]

图 6-1。雪碧片为基底

如您所见,该表单由蓝色和绿色团队的两行不同的框架组成。在这种情况下,精灵由一个默认动画(四帧)、一个损坏的基础(一帧)和最后一个基础建造建筑物时的动画(三帧)组成。我们将为游戏中的所有实体使用相似的精灵表和通用的加载和绘制机制。

我们要做的第一件事是在 buildings.js 中定义一个 buildings 对象,如清单 6-2 所示。

清单 6-2。 用第一个建筑类型(buildings.js)定义建筑对象

    var buildings = {
    list:{
        "base":{
            name:"base",
            // Properties for drawing the object
            pixelWidth:60,
            pixelHeight:60,
            baseWidth:40,
            baseHeight:40,
            pixelOffsetX:0,
            pixelOffsetY:20,
            // Properties for describing structure for pathfinding
            buildableGrid:[
                [1,1],
                [1,1]
            ],
            passableGrid:[
                [1,1],
                [1,1]
            ],
            sight:3,
            hitPoints:500,
            cost:5000,
            spriteImages:[
                {name:"healthy",count:4},
                {name:"damaged",count:1},
                {name:"contructing",count:3},
            ],
        },
    },
    defaults:{
        type:"buildings",
        animationIndex:0,
        direction:0,
        orders:{ type:"stand" },
        action:"stand",
        selected:false,
        selectable:true,
        // Default function for animating a building
        animate:function(){
        },
        // Default function for drawing a building
        draw:function(){
        }
    },
    load:loadItem,
    add:addItem,
}

buildings 对象有四个重要的项目。

  • 列表属性将包含我们所有建筑的定义。现在,我们定义基础建筑以及稍后需要的属性。这些属性包括绘制对象的属性(如 pixelWidth)、寻路属性(buildableGrid)、生命值和开销等常规属性,最后是精灵图像列表。
  • defaults 属性包含所有建筑通用的属性和定义。这包括所有建筑物通常使用的 animate()和 draw()方法的占位符。我们将在后面实现这些方法。
  • load()方法指向所有实体的一个公共方法,称为 loadItem(),我们仍然需要定义它。该方法将加载给定实体的 sprite 表和定义。
  • add()方法指向我们需要定义的所有实体的另一个公共方法 addItem()。这个方法将创建一个给定实体的新实例来添加到游戏中。

现在我们已经有了一个基本的构建定义,我们将在 common.js 中定义 loadItem()和 addItem()方法,这样它们就可以被所有的实体使用(见清单 6-3 )。

清单 6-3。 定义 loadItem()和 addItem()方法(common.js)

/* The default load() method used by all our game entities*/
function loadItem(name){
    var item = this.list[name];
    // if the item sprite array has already been loaded then no need to do it again
    if(item.spriteArray){
        return;
    }
    item.spriteSheet = loader.loadImage('img/'+this.defaults.type+'/'+name+'.png');
    item.spriteArray = [];
    item.spriteCount = 0;

    for (var i=0; i < item.spriteImages.length; i++){
        var constructImageCount = item.spriteImages[i].count;
        var constructDirectionCount = item.spriteImages[i].directions;
        if (constructDirectionCount){
            for (var j=0; j < constructDirectionCount; j++) {
                var constructImageName = item.spriteImages[i].name +"-"+j;
                item.spriteArray[constructImageName] = {
                    name:constructImageName,
                    count:constructImageCount,
                    offset:item.spriteCount
                };
                item.spriteCount += constructImageCount;
            };
        } else {
            var constructImageName = item.spriteImages[i].name;
            item.spriteArray[constructImageName] = {
                name:constructImageName,
                count:constructImageCount,
                offset:item.spriteCount
            };
            item.spriteCount += constructImageCount;
        }

    }
}

/* The default add() method used by all our game entities*/
function addItem(details){
    var item = {};
    var name = details.name;
    $.extend(item,this.defaults);
    $.extend(item,this.list[name]);
    item.life = item.hitPoints;
    $.extend(item,details);
    return item;
}

loadItem()方法使用图像加载器将 sprite 工作表图像加载到 sprite sheet 属性中。然后,它遍历 spriteImages 定义并创建一个 spriteArray 对象,该对象存储每个 sprite 动画的起始偏移量。

您会注意到,在创建数组时,代码会检查 count 和 directions 属性是否存在。这允许我们定义多方向的精灵,在绘制像炮塔和车辆这样的实体时会用到。

addItem()方法首先应用实体类型的默认值(例如,buildings),然后使用特定实体的属性(例如,base)扩展它,设置项目的生命周期,最后应用传递到 details 参数中的任何附加属性。

这种创建对象的有趣方式为我们提供了自己的多重继承实现,允许我们在三个不同的级别定义和覆盖属性:建筑属性、基本属性和特定于项目的细节(比如位置和团队颜色)。

既然我们已经定义了我们的第一个实体,我们需要一个简单的方法将实体添加到一个级别中。

向级别添加实体

我们要做的第一件事是修改我们的映射定义,以包含一个需要加载的实体类型列表和一个在开始之前要添加到级别的项目列表。我们将修改我们在 maps.js 中创建的第一个地图,如清单 6-4 所示。

清单 6-4。 加载和添加地图内部实体(maps.js)

var maps = {
    "singleplayer":[
        {
            "name":"Entities",
            "briefing": "In this level you will add new entities to the map.\nYou will also select them using the mouse",

            /* Map Details */
            "mapImage":"img/level-one-debug-grid.png",
            "startX":4,
            "startY":4,

            /* Entities to be loaded */
            "requirements":{
                "buildings":["base"],
                "vehicles":[],
                "aircraft":[],
                "terrain":[]
            },

            /* Entities to be added */
            "items":[
                {"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
                {"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
                {"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50}
            ]

        }
    ]
}

该地图与第五章中的地图非常相似。我们添加了两个新的部分:需求和项目。

requirements 属性包含建筑物、车辆、飞机和地形,以便为该级别预加载。现在,我们只装载基地类型的建筑。

条目数组包含了我们想要添加到这个级别的实体的详细信息。我们提供的细节包括项目类型和名称、x 和 y 坐标以及团队的颜色。这些是我们唯一定义一个实体所需要的最基本的属性。

我们增加了三个随机位置和队伍的基地建筑。items 数组中的最后一个建筑还包含一个附加属性:life。由于我们之前定义 addItem()方法的方式,这个 life 属性将覆盖基础的 life 默认值。这样,我们也将有一个受损建筑的例子。

接下来我们将修改 singleplayer.js 中的 singleplayer.startCurrentLevel()方法,以便在游戏开始时加载和添加实体(参见清单 6-5 )。

清单 6-5。 在 startCurrentLevel()方法(singleplayer.js)中加载和添加实体

startCurrentLevel:function(){
    // Load all the items for the level
    var level = maps.singleplayer[singleplayer.currentLevel];

    // Don't allow player to enter mission until all assets for the level are loaded
    $("#entermission").attr("disabled", true);
    // Load all the assets for the level
    game.currentMapImage = loader.loadImage(level.mapImage);
    game.currentLevel = level;

    game.offsetX = level.startX * game.gridSize;
    game.offsetY = level.startY * game.gridSize;

    // Load level Requirements
    game.resetArrays();
    for (var type in level.requirements){
       var requirementArray = level.requirements[type];
       for (var i=0; i < requirementArray.length; i++) {
           var name = requirementArray[i];
           if (window[type]){
               window[type].load(name);
           } else {
               console.log('Could not load type :',type);
           }
       };
   }

    for (var i = level.items.length - 1; i >= 0; i--){
        var itemDetails = level.items[i];
        game.add(itemDetails);
    };

    // Enable the enter mission button once all assets are loaded
    if (loader.loaded){
        $("#entermission").removeAttr("disabled");
    } else {
        loader.onload = function(){
            $("#entermission").removeAttr("disabled");
        }
    }

    // Load the mission screen with the current briefing
    $('#missonbriefing').html(level.briefing.replace(/\n/g,'<br><br>'));
    $("#missionscreen").show();
},

我们在新添加的代码中做了三件事。我们首先通过调用 game.resetArrays()方法初始化游戏数组。然后我们遍历需求对象,并为每个实体调用适当的 load()方法。load()方法将依次调用加载器在后台异步加载实体的所有图像,并在所有图像加载完毕后启用 entermission 按钮。

最后,我们遍历 items 数组并将详细信息传递给 game.add()方法。

接下来我们将在 game.js 中为游戏对象添加 resetArrays()、add()和 remove()方法(参见清单 6-6 )。

清单 6-6。 向游戏对象(game.js)添加 resetArrays()、add()和 remove()

resetArrays:function(){
    game.counter = 1;
    game.items = [];
    game.sortedItems = [];
    game.buildings = [];
    game.vehicles = [];
    game.aircraft = [];
    game.terrain = [];
    game.triggeredEvents = [];
    game.selectedItems = [];
    game.sortedItems = [];
},
add:function(itemDetails) {
    // Set a unique id for the item
    if (!itemDetails.uid){
        itemDetails.uid = game.counter++;
    }
    var item = window[itemDetails.type].add(itemDetails);
    // Add the item to the items array
    game.items.push(item);
    // Add the item to the type specific array
    game[item.type].push(item);
    return item;
},
remove:function(item){
    // Unselect item if it is selected
    item.selected = false;
    for (var i = game.selectedItems.length - 1; i >= 0; i--){
           if(game.selectedItems[i].uid == item.uid){
               game.selectedItems.splice(i,1);
               break;
           }
    };

    // Remove item from the items array
    for (var i = game.items.length - 1; i >= 0; i--){
        if(game.items[i].uid == item.uid){
            game.items.splice(i,1);
            break;
        }
    };

    // Remove items from the type specific array
    for (var i = game[item.type].length - 1; i >= 0; i--){
        if(game[item.type][i].uid == item.uid){
            game[item.type].splice(i,1);
            break;
        }
    };
},

resetArrays()方法仅仅初始化所有游戏特定的数组和计数器变量。

add()方法使用计数器为项目生成唯一标识符(UID ),调用适当实体的 add()方法,最后将项目保存在适当的游戏数组中。对于基础建筑物,该方法将首先调用 buildings.add(),然后将新建筑物添加到 game.items 和 game.buildings 数组中。

remove()方法从 selectedItems、Items 和特定于实体的数组中移除指定的项。这样,任何时候从游戏中移除一个物品(例如,当它被破坏时),它会自动从选择和物品数组中移除。

既然我们已经设置了定义实体和向层添加实体的代码,我们就可以开始在屏幕上绘制它们了。

绘制实体

为了绘制实体,我们需要在实体对象中实现 animate()和 draw()方法,然后从游戏 animationLoop()和 drawingLoop()方法中调用这些方法。

我们首先在 buildings.js 中的 buildings 对象内部实现 draw()和 animate()方法。buildings 对象的默认 draw()和 animate()方法现在看起来将类似于清单 6-7 。

***清单 6-7。***实现默认的 draw()和 animate()方法(buildings.js)

animate:function(){
    // Consider an item healthy if it has more than 40% life
    if (this.life>this.hitPoints*0.4){
        this.lifeCode = "healthy";
    } else if (this.life <= 0){
        this.lifeCode = "dead";
        game.remove(this);
        return;
    } else {
        this.lifeCode = "damaged";
    }

    switch (this.action){
        case "stand":
            this.imageList = this.spriteArray[this.lifeCode];
            this.imageOffset = this.imageList.offset + this.animationIndex;
            this.animationIndex++;
            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;
            }
            break;
        case "construct":
            this.imageList = this.spriteArray["contructing"];
            this.imageOffset = this.imageList.offset + this.animationIndex;
            this.animationIndex++;
            // Once constructing is complete go back to standing
            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;
                this.action = "stand";
            }
            break;
    }
},
// Default function for drawing a building
draw:function(){
    var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
    var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;

    // All sprite sheets will have blue in the first row and green in the second row
    var colorIndex = (this.team == "blue")?0:1;
    var colorOffset = colorIndex*this.pixelHeight;
    game.foregroundContext.drawImage(this.spriteSheet,
    this.imageOffset*this.pixelWidth,colorOffset, this.pixelWidth, this.pixelHeight,
    x,y,this.pixelWidth,this.pixelHeight);
}

在 animate()方法中,我们首先根据项目的健康状况和生命值设置项目的 lifeCode 属性。任何时候一个物品的生命值降到 0 以下,我们会将生命代码设置为死亡,并将其从游戏中移除。

接下来,我们基于项目的 action 属性实现行为。现在,我们只实现 stand 和 construct 操作。

对于站立动作,我们选择“健康的”或“损坏的”精灵动画,并增加 animationIndex 属性。如果 animationIndex 超过了 sprite 中的帧数,我们将该值回滚到 0。这样,动画会一次又一次地在精灵的每一帧中旋转。

对于构造动作,我们显示构造精灵,并在完成后滚动到 stand 动作。

draw()方法相对简单一些。我们通过转换网格 x 和 y 坐标来计算建筑物的绝对 x 和 y 像素坐标。然后我们计算正确的图像偏移量(基于 animationIndex)和图像颜色行(基于 team)。最后,我们使用 foregroundContext.drawImage()方法在前景画布上绘制适当的图像。

既然 draw()和 animate()方法已经就绪,我们需要从游戏对象中调用它们。我们将修改 game.js 内部的 game.animationLoop()和 game.drawingLoop()方法,如清单 6-8 所示。

清单 6-8。 从游戏循环(game.js)中调用 draw()和 animate()

animationLoop:function(){
    // Animate each of the elements within the game
    for (var i = game.items.length - 1; i >= 0; i--){
        game.items[i].animate();
    };

    // Sort game items into a sortedItems array based on their x,y coordinates
    game.sortedItems = $.extend([],game.items);
    game.sortedItems.sort(function(a,b){
        return b.y-a.y + ((b.y==a.y)?(a.x-b.x):0);
    });
},
drawingLoop:function(){
    // Handle Panning the Map
    game.handlePanning();

    // Since drawing the background map is a fairly large operation,
    // we only redraw the background if it changes (due to panning)
    if (game.refreshBackground){
game.backgroundContext.drawImage(game.currentMapImage,game.offsetX,game.offsetY,game.canvasWidth, game.canvasHeight, 0,0,game.canvasWidth,game.canvasHeight);
        game.refreshBackground = false;
    }

    // Clear the foreground canvas
    game.foregroundContext.clearRect(0,0,game.canvasWidth,game.canvasHeight);

    // Start drawing the foreground elements
    for (var i = game.sortedItems.length - 1; i >= 0; i--){
        game.sortedItems[i].draw();
    };

    // Draw the mouse
    mouse.draw();

    // Call the drawing loop for the next frame using request animation frame
    if (game.running){
        requestAnimationFrame(game.drawingLoop);
    }
},

在 animationLoop()方法中,我们首先遍历所有游戏项目,并调用它们的 animate()方法。然后,我们按 y 值和 x 值对所有项目进行排序,并将它们存储在 game.sortedItems 数组中。

drawingLoop()方法中的新代码只是遍历 sortedItems 数组,并调用每一项的 draw()方法。我们使用 sortedItems 数组,以便根据项目的 y 坐标从后向前依次绘制项目。这是深度排序的一个简单实现,它确保靠近玩家的项目掩盖了后面的项目,产生了深度的错觉。

经过最后的修改,我们现在可以在屏幕上看到我们的第一个游戏实体了。如果我们在浏览器中打开游戏,加载第一关,我们应该会看到我们在地图中定义的三个基地建筑一个挨着一个画出来(见图 6-2 )。

[外链图片转存中…(img-YKAM6mHn-1723738180258)]

图 6-2。三个基地建筑

正如你所看到的,第一个“蓝色”团队基地使用“健康”动画显示了闪烁的蓝光。

第二个“绿色”团队基础绘制在第一个基础之上,并部分遮挡第一个基础。这是我们深度排序步骤的结果,让玩家清楚地看到二垒在一垒前面。

最后,生命值更低的三垒看起来受损。这是因为每当建筑物的寿命低于其最大生命值的 40%时,我们会自动使用“受损”动画。

现在我们已经有了在游戏中展示建筑的框架,让我们添加剩下的建筑,从星港开始。

添加 Starport

星港可以用来购买地面和空中单位。星港精灵表单有一些基地没有的有趣的动画:一个传送动画序列,我们将在第一次创建建筑时使用,一个打开和关闭动画序列,我们将在运输新单位时使用。

我们要做的第一件事是将 starport 定义添加到 buildings.js 中的基本定义下面的 buildings 列表中(见清单 6-9 )。

清单 6-9。 定义为星港大厦(buildings.js)

"starport":{
    name:"starport",
    pixelWidth:40,
    pixelHeight:60,
    baseWidth:40,
    baseHeight:55,
    pixelOffsetX:1,
    pixelOffsetY:5,
    buildableGrid:[
        [1,1],
        [1,1],
        [1,1]
    ],
    passableGrid:[
        [1,1],
        [0,0],
        [0,0]
    ],
    sight:3,
    cost:2000,
    hitPoints:300,
    spriteImages:[
        {name:"teleport",count:9},
        {name:"closing",count:18},
        {name:"healthy",count:4},
        {name:"damaged",count:1},
    ],
},

除了两个新的精灵集,starport 的定义与基本定义非常相似。接下来,我们将需要考虑动画的打开,关闭和瞬间移动的动画状态。我们将通过修改 buildings.js 中建筑物的默认 animate()方法来实现这一点,如清单 6-10 所示。

清单 6-10。 修改 animate()来处理瞬移、开启和关闭

animate:function(){
    // Consider an item healthy if it has more than 40% life
    if (this.life>this.hitPoints*0.4){
        this.lifeCode = "healthy";
    } else if (this.life <= 0){
        this.lifeCode = "dead";
        game.remove(this);
        return;
    } else {
        this.lifeCode = "damaged";
    }

    switch (this.action){
        case "stand":
            this.imageList = this.spriteArray[this.lifeCode];
            this.imageOffset = this.imageList.offset + this.animationIndex;
            this.animationIndex++;
            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;
            }
            break;

case “construct”:

            this.imageList = this.spriteArray["contructing"];
            this.imageOffset = this.imageList.offset + this.animationIndex;
            this.animationIndex++;
            // Once contructing is complete go back to standing
            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;

this.action = "Stand";

}

            break;
        case "teleport":
            this.imageList = this.spriteArray["teleport"];
            this.imageOffset = this.imageList.offset + this.animationIndex;
            this.animationIndex++;
            // Once teleporting is complete, move to either guard or stand mode
            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;
                if (this.canAttack){
                    this.action = "guard";
                } else {
                    this.action = "stand";
                }
            }
            break;
        case "close":
            this.imageList = this.spriteArray["closing"];
            this.imageOffset = this.imageList.offset + this.animationIndex;
            this.animationIndex++;
            // Once closing is complete go back to standing
            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;
                this.action = "stand";
            }
            break;
        case "open":
            this.imageList = this.spriteArray["closing"];
            // Opening is just the closing sprites running backwards
            this.imageOffset = this.imageList.offset + this.imageList.count - this.animationIndex;
            this.animationIndex++;
            // Once opening is complete, go back to close
            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;
                this.action = "close";
            }
            break;
    }
},

像构造动画状态一样,传送关闭打开动画状态一旦结束就不再重复。瞬间移动动画翻转到站立动画状态(或可以攻击的建筑如炮塔的守卫动画状态)。打开动画(仅仅是向后运行的关闭动画状态)翻转到关闭动画状态,然后翻转到站立动画状态。

这样,我们可以用一个传送打开动画状态来初始化 starport,知道一旦当前动画完成,它最终会移回到站立动画状态。

现在,我们可以通过修改 maps.js 中的需求和项目来将 starport 添加到地图中,如清单 6-11 中的所示。

清单 6-11。 在地图上添加星港

/* Entities to be loaded */
"requirements":{
    "buildings":["base","starport"],
    "vehicles":[],
    "aircraft":[],
    "terrain":[]
},

/* Entities to be added */
"items":[
    {"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
    {"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
    {"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},

    {"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
    {"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
    {"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},
]

当我们在浏览器中打开游戏开始关卡时,应该会看到三个新的星港建筑,如图图 6-3 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-3。三座星港建筑

第一个绿色团队星港打开,然后关闭。第二个蓝队星港首先发光并出现,然后切换到站立模式,而最后一个蓝队星港只是在站立模式下等待。

现在星港已经被添加了,我们要看的下一个建筑是矿车。

添加收割机

收割机是一个独特的实体,因为它既是建筑又是交通工具。与游戏中的其他建筑不同,收割机是通过在油田部署一辆收割机进入建筑而创建的(见图 6-4 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-4。收割机展开成建筑形态

我们要做的第一件事是将收割机定义添加到 buildings.js 中 starport 定义下的 buildings 列表中(见清单 6-12 )。

清单 6-12。 收割机建筑定义(buildings.js)

"harvester":{
    name:"harvester",
    pixelWidth:40,
    pixelHeight:60,
    baseWidth:40,
    baseHeight:20,
    pixelOffsetX:-2,
    pixelOffsetY:40,
    buildableGrid:[
        [1,1]
    ],
    passableGrid:[
        [1,1]
    ],
    sight:3,
    cost:5000,
    hitPoints:300,
    spriteImages:[
        {name:"deploy",count:17},
        {name:"healthy",count:3},
        {name:"damaged",count:1},
    ],
},

接下来,我们需要考虑正在部署的动画状态。我们将通过向 buildings.js 中的默认 animate()方法添加部署案例来实现这一点,如清单 6-13 中的所示。

清单 6-13。 处理部署动画状态(buildings.js)

case "deploy":
    this.imageList = this.spriteArray["deploy"];
    this.imageOffset = this.imageList.offset + this.animationIndex;
    this.animationIndex++;
    // Once deploying is complete, go back to stand
    if (this.animationIndex>=this.imageList.count){
        this.animationIndex = 0;
        this.action = "stand";
    }
    break;

展开状态,就像我们之前定义的传送状态一样,一旦完成就会自动进入站立动画状态。

现在,我们可以通过修改 maps.js 中的需求和项目来将收割机添加到地图中,如清单 6-14 中的所示。

清单 6-14。 向地图添加收割机

/* Entities to be loaded */
"requirements":{
    "buildings":["base","starport","harvester"],
    "vehicles":[],
    "aircraft":[],
    "terrain":[]
},

/* Entities to be added */
"items":[
    {"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
    {"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
    {"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},

    {"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
    {"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
    {"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},

    {"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
    {"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},

]

当我们在浏览器中打开游戏开始关卡时,应该会看到两个新的收割机建筑,如图图 6-5 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-5。两座收割机建筑

蓝色的收割者处于默认的站立模式,而绿色的收割者在部署模式下会变形为一个建筑,然后切换到站立模式。

现在已经添加了矿车,最后一个建筑我们来看看地面炮塔。

添加地面炮塔

地面炮塔是一种防御建筑,只攻击地面威胁。

这是唯一使用基于方向的精灵的建筑。此外,与其他建筑不同,它有一个默认的守卫动画状态,在动画和绘图时会考虑炮塔的方向。

方向属性的取值范围为 0-7,顺时针方向递增,0 指向北方,7 指向西北方向,如图图 6-6 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-6。炮塔的方向精灵范围从 0 到 7

我们要做的第一件事是将炮塔定义添加到 buildings.js 中收割机定义下面的建筑物列表中(见清单 6-15 )。

清单 6-15。 收割机建筑定义(buildings.js)

"ground-turret":{
    name:"ground-turret",
    canAttack:true,
    canAttackLand:true,
    canAttackAir:false,
    weaponType:"cannon-ball",
    action:"guard", // Default action is guard unlike other buildings
    direction:0, // Face upward (0) by default
    directions:8, // Total of 8 turret directions allowed (0-7)
    orders:{type:"guard"},
    pixelWidth:38,
    pixelHeight:32,
    baseWidth:20,
    baseHeight:18,
    cost:1500,
    pixelOffsetX:9,
    pixelOffsetY:12,
    buildableGrid:[
        [1]
    ],
    passableGrid:[
        [1]
    ],
    sight:5,
    hitPoints:200,
    spriteImages:[
        {name:"teleport",count:9},
        {name:"healthy",count:1,directions:8},
        {name:"damaged",count:1},
    ],
}

炮塔有一些额外的属性,表明它是否可以用来攻击敌人,炮塔指向的方向,以及它使用的武器类型。当我们在游戏中实现战斗时,我们将使用这些属性。

健康精灵有一个附加的 directions 属性,itemLoad()方法使用该属性为每个方向生成精灵。

接下来,我们将把 guard case 添加到 buildings.js 中的 animate()方法,如清单 6-16 所示。

清单 6-16。 处理门卫动画状态(buildings.js)

case "guard":
    if (this.lifeCode == "damaged"){
        // The damaged turret has no directions
        this.imageList = this.spriteArray[this.lifeCode];
    } else {
        // The healthy turret has 8 directions
        this.imageList = this.spriteArray[this.lifeCode+"-"+this.direction];
    }
     this.imageOffset = this.imageList.offset;
    break;

与前面的动画状态不同,保护状态不使用 animationIndex,而是使用炮塔方向来拾取适当的图像偏移。

现在,我们可以通过修改 maps.js 中的要求和项目来将炮塔添加到地图中,如清单 6-17 所示。

清单 6-17。 给地图添加地面炮塔

/* Entities to be loaded */
"requirements":{
    "buildings":["base","starport","harvester","ground-turret"],
    "vehicles":[],
    "aircraft":[],
    "terrain":[]
},

/* Entities to be added */
"items":[
    {"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
    {"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
    {"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},

    {"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
    {"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
    {"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},

    {"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
    {"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},

    {"type":"buildings","name":"ground-turret","x":14,"y":9,"team":"blue", "direction":3},
    {"type":"buildings","name":"ground-turret","x":14,"y":12,"team":"green", "direction":1},
    {"type":"buildings","name":"ground-turret","x":16,"y":10,"team":"blue", "action":"teleport"},
]

我们为前两个炮塔指定了起始方向属性,并将第三个炮塔的动作属性设置为传送。当我们在浏览器中打开游戏,开始关卡时,应该会看到三个新的炮塔,如图图 6-7 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-7。三座地面炮塔建筑

前两个炮塔是守卫模式,面向两个不同的方向,第三个是瞬移到面向默认方向,瞬移到后切换到守卫模式。

至此,我们已经实现了我们需要的所有构建。现在是时候开始给我们的游戏增加一些交通工具了。

添加车辆

我们游戏中的所有交通工具,包括运输工具,都会有一个简单的精灵表,上面的交通工具指向八个方向,类似于地面炮塔,如图图 6-8 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-8。运输子画面

我们将通过在 vehicles.js 中定义一个新的 vehicles 对象来为我们的车辆设置代码,如清单 6-18 中的所示。

清单 6-18。 定义车辆对象(vehicles.js)

var vehicles = {
    list:{
        "transport":{
            name:"transport",
            pixelWidth:31,
            pixelHeight:30,
            pixelOffsetX:15,
            pixelOffsetY:15,
            radius:15,
            speed:15,
            sight:3,
            cost:400,
            hitPoints:100,
            turnSpeed:2,
            spriteImages:[
                {name:"stand",count:1,directions:8}
            ],
        },
        "harvester":{
            name:"harvester",
            pixelWidth:21,
            pixelHeight:20,
            pixelOffsetX:10,
            pixelOffsetY:10,
            radius:10,
            speed:10,
            sight:3,
            cost:1600,
            hitPoints:50,
            turnSpeed:2,
            spriteImages:[
                {name:"stand",count:1,directions:8}
            ],
        },
        "scout-tank":{
            name:"scout-tank",
            canAttack:true,
            canAttackLand:true,
            canAttackAir:false,
            weaponType:"bullet",
            pixelWidth:21,
            pixelHeight:21,
            pixelOffsetX:10,
            pixelOffsetY:10,
            radius:11,
            speed:20,
            sight:4,
            cost:500,
            hitPoints:50,
            turnSpeed:4,
            spriteImages:[
                {name:"stand",count:1,directions:8}
            ],
        },
        "heavy-tank":{
            name:"heavy-tank",
            canAttack:true,
            canAttackLand:true,
            canAttackAir:false,
            weaponType:"cannon-ball",
            pixelWidth:30,
            pixelHeight:30,
            pixelOffsetX:15,
            pixelOffsetY:15,
            radius:13,
            speed:15,
            sight:5,
            cost:1200,
            hitPoints:50,
            turnSpeed:4,
            spriteImages:[
                {name:"stand",count:1,directions:8}
            ],
        }
    },
    defaults:{
        type:"vehicles",
        animationIndex:0,
        direction:0,
        action:"stand",
        orders:{type:"stand"},
        selected:false,
        selectable:true,
        directions:8,
        animate:function(){
            // Consider an item healthy if it has more than 40% life
            if (this.life>this.hitPoints*0.4){
                this.lifeCode = "healthy";
            } else if (this.life <= 0){
                this.lifeCode = "dead";
                game.remove(this);
                return;
            } else {
                this.lifeCode = "damaged";
            }

            switch (this.action){
                case "stand":
                    var direction = this.direction;
                    this.imageList = this.spriteArray["stand-"+direction];
                    this.imageOffset = this.imageList.offset + this.animationIndex;
                    this.animationIndex++;
                    if (this.animationIndex>=this.imageList.count){
                        this.animationIndex = 0;
                    }

                break;
            }
        },
        draw:function(){
            var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
            var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;
            var colorIndex = (this.team == "blue")?0:1;
            var colorOffset = colorIndex*this.pixelHeight;
            game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth, this.pixelHeight);
        }
    },
    load:loadItem,
    add:addItem,
}

我们的车辆对象的结构与建筑物对象非常相似。我们有一个列表属性,定义了四种车辆类型:运输车、矿车、侦察兵坦克和重型坦克。

所有车辆精灵都有 directions 属性和 animate()中的默认 stand 动画实现,它使用车辆的方向来选择要绘制的精灵。我们使用 animationIndex 来处理一个 sprite 中的多个图像,以便在需要时添加带有动画的车辆。

车辆还具有速度、视野和成本等属性。运输和采矿车没有任何武器,而这两种坦克有类似于我们之前定义的地面炮塔建筑的武器属性。我们将在后面的章节中使用所有这些属性来实现移动和战斗。

现在,我们可以通过修改 maps.js 中的 requirements 和 items 属性将这些车辆添加到地图中,如清单 6-19 中的所示。

清单 6-19。 向地图添加车辆

/* Entities to be loaded */
"requirements":{
    "buildings":["base","starport","harvester","ground-turret"],
    "vehicles":["transport","harvester","scout-tank","heavy-tank"],
    "aircraft":[],
    "terrain":[]
},

/* Entities to be added */
"items":[
    {"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
    {"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
    {"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},

    {"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
    {"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
    {"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},

    {"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
    {"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},

    {"type":"buildings","name":"ground-turret","x":14,"y":9,"team":"blue", "direction":3},
    {"type":"buildings","name":"ground-turret","x":14,"y":12,"team":"green", "direction":1},
    {"type":"buildings","name":"ground-turret" ,"x":16,"y":10, "team":"blue", "action":"teleport"},

    {"type":"vehicles","name":"transport","x":26,"y":10,"team":"blue","direction":2},
    {"type":"vehicles","name":"harvester","x":26,"y":12,"team":"blue","direction":3},
    {"type":"vehicles","name":"scout-tank","x":26,"y":14,"team":"blue", "direction":4},
    {"type":"vehicles","name":"heavy-tank","x":26,"y":16,"team":"blue", "direction":5},
    {"type":"vehicles","name":"transport","x":28,"y":10,"team":"green", "direction":7},
    {"type":"vehicles","name":"harvester","x":28,"y":12,"team":"green", "direction":6},
    {"type":"vehicles","name":"scout-tank","x":28,"y":14,"team":"green", "direction":1},
    {"type":"vehicles","name":"heavy-tank","x":28,"y":16,"team":"green", "direction":0},
]

当我们在浏览器中打开游戏并开始关卡时,应该会看到车辆,如图图 6-9 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-9。向关卡添加车辆

根据我们在将车辆添加到项目列表时设置的属性,车辆指向不同的方向。随着交通工具的实现,是时候把飞机加入到我们的游戏中了。

添加飞机

我们游戏中的飞机有一个类似于车辆的精灵表,除了一个区别:阴影。飞机精灵表有第三排阴影。此外,斩波器子画面在每个方向都有多个图像,如图图 6-10 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-10。带阴影的斩波器子画面

我们将通过在 aircraft.js 中定义一个新的飞行器对象来为我们的飞行器设置代码,如清单 6-20 中的所示。

清单 6-20。 定义飞机对象(aircraft.js)

var aircraft = {
    list:{
        "chopper":{
            name:"chopper",
            cost:900,
            pixelWidth:40,
            pixelHeight:40,
            pixelOffsetX:20,
            pixelOffsetY:20,
            weaponType:"heatseeker",
            radius:18,
            sight:6,
            canAttack:true,
            canAttackLand:true,
            canAttackAir:true,
            hitPoints:50,
            speed:25,
            turnSpeed:4,
            pixelShadowHeight:40,
            spriteImages:[
                {name:"fly",count:4,directions:8}
            ],
        },
        "wraith":{
            name:"wraith",
            cost:600,
            pixelWidth:30,
            pixelHeight:30,
            canAttack:true,
            canAttackLand:false,
            canAttackAir:true,
            weaponType:"fireball",
            pixelOffsetX:15,
            pixelOffsetY:15,
            radius:15,
            sight:8,
            speed:40,
            turnSpeed:4,
            hitPoints:50,
            pixelShadowHeight:40,
            spriteImages:[
                {name:"fly",count:1,directions:8}
            ],
        }
    },
    defaults:{
        type:"aircraft",
        animationIndex:0,
        direction:0,
        directions:8,
        action:"fly",
        selected:false,
        selectable:true,
        orders:{type:"float"},
        animate:function(){
            // Consider an item healthy if it has more than 40% life
            if (this.life>this.hitPoints*0.4){
                this.lifeCode = "healthy";
            } else if (this.life <= 0){
                this.lifeCode = "dead";
                game.remove(this);
                return;
            } else {
                this.lifeCode = "damaged";
            }
            switch (this.action){
                case "fly":
                    var direction = this.direction;
                     this.imageList = this.spriteArray["fly-"+ direction];
                    this.imageOffset = this.imageList.offset + this.animationIndex;
                    this.animationIndex++;
                    if (this.animationIndex>=this.imageList.count){
                         this.animationIndex = 0;
                    }
                break;
            }
        },
        draw:function(){
            var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
            var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY-this.pixelShadowHeight;
            var colorIndex = (this.team == "blue")?0:1;
            var colorOffset = colorIndex*this.pixelHeight;
            var shadowOffset = this.pixelHeight*2; // The aircraft shadow is on the second row of the sprite sheet

            game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth,this.pixelHeight);
            game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, shadowOffset, this.pixelWidth, this.pixelHeight, x, y+this.pixelShadowHeight, this.pixelWidth, this.pixelHeight);
        }
    },
    load:loadItem,
    add:addItem,
}

我们的飞机对象的结构类似于车辆对象。我们有一个列表属性,其中定义了两种飞机类型:直升机和幽灵。

所有的飞机精灵都有方向属性。animate()中的默认飞行动画实现使用飞机的方向来选择要绘制的精灵。对于斩波器,我们还使用 animationIndex 来处理每个方向的多个图像。

一个很大的区别是 draw()方法的实现方式。我们在飞机的位置画一个阴影,在飞机的位置上面画实际的飞机 pixelShadowHeight 像素。这样,飞机看起来就像是漂浮在地面上,阴影在它下面的地面上。

现在,我们可以通过修改 maps.js 中的 requirements 和 items 属性将这些飞机添加到地图中,如清单 6-21 所示。

清单 6-21。 向地图添加飞机

/* Entities to be loaded */
"requirements":{
    "buildings":["base","starport","harvester","ground-turret"],
    "vehicles":["transport","harvester","scout-tank","heavy-tank"],
    "aircraft":["chopper","wraith"],
    "terrain":[]
},

/* Entities to be added */
"items":[
    {"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
    {"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
    {"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},

    {"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
    {"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
    {"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},

    {"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
    {"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},

    {"type":"buildings","name":"ground-turret","x":14,"y":9,"team":"blue", "direction":3},
    {"type":"buildings","name":"ground-turret","x":14,"y":12,"team":"green", "direction":1},
    {"type":"buildings","name":"ground-turret","x":16,"y":10,"team":"blue", "action":"teleport"},

    {"type":"vehicles","name":"transport","x":26,"y":10,"team":"blue","direction":2},
    {"type":"vehicles","name":"harvester","x":26,"y":12,"team":"blue","direction":3},
    {"type":"vehicles","name":"scout-tank","x":26,"y":14,"team":"blue", "direction":4},
    {"type":"vehicles","name":"heavy-tank","x":26,"y":16,"team":"blue", "direction":5},
    {"type":"vehicles","name":"transport","x":28,"y":10,"team":"green", "direction":7},
    {"type":"vehicles","name":"harvester","x":28,"y":12,"team":"green", "direction":6},
    {"type":"vehicles","name":"scout-tank","x":28,"y":14,"team":"green", "direction":1},
    {"type":"vehicles","name":"heavy-tank","x":28,"y":16,"team":"green", "direction":0},
    {"type":"aircraft","name":"chopper","x":20,"y":22,"team":"blue", "direction":2},    {"type":"aircraft","name":"wraith","x":23,"y":22,"team":"green", "direction":3},
]

当我们在浏览器中打开游戏,开始关卡的时候,应该会看到飞行器悬停在地面上方,如图图 6-11 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-11。漂浮在地面上方的飞行器

阴影有助于创造飞机漂浮在地面上的错觉,也标记出它们在地面上的准确位置。由于动画的缘故,斩波器刀片及其在地面上的阴影似乎在旋转。

随着飞机的实现,我们现在将添加地形到我们的游戏中。

添加地形

除了油田之外,我们游戏中的地形实体都是静态物体,只是为了装饰。油田是一个特殊的实体,在它上面,采矿车可以部署到采矿建筑中。油田精灵表包括两个版本:一个默认版本和一个“提示”版本,上面显示一个模糊的收割机作为对玩家的提示。

我们将通过在 terrain.js 中定义一个新的地形对象来为我们的地形设置代码,如清单 6-22 所示。

清单 6-22。 定义地形对象(terrain.js)

var terrain = {
    list:{
        "oilfield":{
            name:"oilfield",
            pixelWidth:40,
            pixelHeight:60,
            baseWidth:40,
            baseHeight:20,
            pixelOffsetX:0,
            pixelOffsetY:40,
            buildableGrid:[
                [1,1]
            ],
            passableGrid:[
                [1,1]
            ],
            spriteImages:[
                {name:"hint",count:1},
                {name:"default",count:1},
            ],
        },
        "bigrocks":{
            name:"bigrocks",
            pixelWidth:40,
            pixelHeight:70,
            baseWidth:40,
            baseHeight:40,
            pixelOffsetX:0,
            pixelOffsetY:30,
            buildableGrid:[
                [1,1],
                [0,1]
            ],
            passableGrid:[
                [1,1],
                [0,1]
            ],
            spriteImages:[
                {name:"default",count:1},
            ],
        },
        "smallrocks":{
            name:"smallrocks",
            pixelWidth:20,
            pixelHeight:35,
            baseWidth:20,
            baseHeight:20,
            pixelOffsetX:0,
            pixelOffsetY:15,
            buildableGrid:[
                [1]
            ],
            passableGrid:[
                [1]
            ],
            spriteImages:[
                {name:"default",count:1},
            ],
        },
    },
    defaults:{
        type:"terrain",
        animationIndex:0,
        action:"default",
        selected:false,
        selectable:false,
        animate:function(){
            switch (this.action){
                case "default":
                     this.imageList = this.spriteArray["default"];
                     this.imageOffset = this.imageList.offset + this.animationIndex;
                     this.animationIndex++;
                     if (this.animationIndex>=this.imageList.count){
                         this.animationIndex = 0;
                     }
                break;
                case "hint":
                    this.imageList = this.spriteArray["hint"];
                    this.imageOffset = this.imageList.offset + this.animationIndex;
                    this.animationIndex++;
                    if (this.animationIndex>=this.imageList.count){
                        this.animationIndex = 0;
                    }
                break;
            }
        },
        draw:function(){
            var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
            var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;

            var colorOffset = 0; // No team based colors
            game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth, this.pixelHeight);
        }
    },
    load:loadItem,
    add:addItem,
}

我们的地形对象的结构类似于建筑物对象。我们有一个定义地形类型的列表属性:油田、大岩石和小岩石。我们在 animate()方法中实现了默认和提示动画状态。我们还实现了一个更简单的 draw()方法,它不使用基于团队的颜色。

现在,我们可以通过修改 maps.js 中的需求和项目将这些地形添加到地图中,如清单 6-23 所示。

清单 6-23。 给地图添加地形

/* Entities to be loaded */
"requirements":{
    "buildings":["base","starport","harvester","ground-turret"],
    "vehicles":["transport","harvester","scout-tank","heavy-tank"],
    "aircraft":["chopper","wraith"],
    "terrain":["oilfield","bigrocks","smallrocks"]
},

/* Entities to be added */
"items":[
    {"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
    {"type":"buildings","name":"base","x":12,"y":16,"team":"green"},
    {"type":"buildings","name":"base","x":15,"y":15,"team":"green", "life":50},

    {"type":"buildings","name":"starport","x":18,"y":14,"team":"blue"},
    {"type":"buildings","name":"starport","x":18,"y":10,"team":"blue", "action":"teleport"},
    {"type":"buildings","name":"starport","x":18,"y":6,"team":"green", "action":"open"},

    {"type":"buildings","name":"harvester","x":20,"y":10,"team":"blue"},
    {"type":"buildings","name":"harvester","x":22,"y":12,"team":"green", "action":"deploy"},

    {"type":"buildings","name":"ground-turret","x":14,"y":9,"team":"blue","direction":3},
    {"type":"buildings","name":"ground-turret","x":14,"y":12,"team":"green","direction":1},
    {"type":"buildings","name":"ground-turret","x":16,"y":10,"team":"blue","action":"teleport"},

    {"type":"vehicles","name":"transport","x":26,"y":10,"team":"blue", "direction":2},
    {"type":"vehicles","name":"harvester","x":26,"y":12,"team":"blue", "direction":3},
    {"type":"vehicles","name":"scout-tank","x":26,"y":14,"team":"blue","direction":4},
    {"type":"vehicles","name":"heavy-tank","x":26,"y":16,"team":"blue","direction":5},
    {"type":"vehicles","name":"transport","x":28,"y":10,"team":"green", "direction":7},
    {"type":"vehicles","name":"harvester","x":28,"y":12,"team":"green", "direction":6},
    {"type":"vehicles","name":"scout-tank","x":28,"y":14,"team":"green","direction":1},
    {"type":"vehicles","name":"heavy-tank","x":28,"y":16,"team":"green","direction":0},

    {"type":"aircraft","name":"chopper","x":20,"y":22,"team":"blue", "direction":2},
    {"type":"aircraft","name":"wraith","x":23,"y":22,"team":"green", "direction":3},
    {"type":"terrain","name":"oilfield","x":5,"y":7},
    {"type":"terrain","name":"oilfield","x":8,"y":7,"action":"hint"},
    {"type":"terrain","name":"bigrocks","x":5,"y":3},
    {"type":"terrain","name":"smallrocks","x":8,"y":3},
]

我们添加了两个油田,其中一个的 action 属性设置为 hint。当我们在浏览器中打开游戏并开始关卡时,应该会看到岩石和油田,如图图 6-12 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-12。添加岩石和油田

右边有提示的油田有一个微弱发光的矿车图像,让玩家知道矿车可以部署在那里。这个油田的提示版本可以用在我们战役的早期阶段,当玩家刚刚接触到收割的想法时。

这样,我们实现了游戏中所有重要的实体。当然,在这一点上我们所能做的就是看着他们。接下来我们要做的是通过选择它们来与它们互动。

选择游戏实体

我们将允许玩家通过点击或者拖拽选择框来选择实体。

我们将通过修改 mouse.js 中的鼠标对象来启用点击选择,如清单 6-24 所示。

清单 6-24。 通过点击启用选择(mouse.js)

click:function(ev,rightClick){
    // Player clicked inside the canvas

    var clickedItem = this.itemUnderMouse();
    var shiftPressed = ev.shiftKey;

    if (!rightClick){ // Player left clicked
        if (clickedItem){
            // Pressing shift adds to existing selection. If shift is not pressed, clear existing selection
            if(!shiftPressed){
                game.clearSelection();
            }
            game.selectItem(clickedItem,shiftPressed);
        }
    } else { // Player right clicked
        // Handle actions like attacking and movement of selected units
    }
},
itemUnderMouse:function(){
    for (var i = game.items.length - 1; i >= 0; i--){
        var item = game.items[i];
        if (item.type=="buildings" || item.type=="terrain"){
            if(item.lifeCode != "dead"
                && item.x<= (mouse.gameX)/game.gridSize
                && item.x >= (mouse.gameX - item.baseWidth)/game.gridSize
                && item.y<= mouse.gameY/game.gridSize
                && item.y >= (mouse.gameY - item.baseHeight)/game.gridSize
                ){
                    return item;
            }
        } else if (item.type=="aircraft"){
            if (item.lifeCode != "dead" &&
                Math.pow(item.x-mouse.gameX/game.gridSize,2) + Math.pow(item.y-(mouse.gameY+item.pixelShadowHeight)/game.gridSize,2) < Math.pow((item.radius)/game.gridSize,2)){
                return item;
            }
       }else {
            if (item.lifeCode != "dead" && Math.pow(item.x-mouse.gameX/game.gridSize,2) + Math.pow(item.y-mouse.gameY/game.gridSize,2) < Math.pow((item.radius)/game.gridSize,2)){
                return item;
            }
        }
    }
},

mouse.click()方法首先使用 itemUnderMouse()方法检查在点击过程中鼠标下是否有项目。如果鼠标下有一个项目,单击了左键,我们调用 game.selectItem()方法。除非在单击时按下了 Shift 键,否则在选择新项目之前会调用 game.clearSelection()方法。这样,用户可以通过在选择时按住 Shift 键来选择多个项目。

itemUnderMouse()方法遍历列表中的所有项目,并使用针对不同项目类型的不同标准返回鼠标 gameX 和 gameY 坐标下的第一个项目。

  • 在建筑物和地形的情况下,我们检查项目的底部是否在鼠标下面。这样,玩家可以点击建筑的底部来选择它,但在选择建筑后面的车辆时不会有问题。
  • 对于车辆,我们检查鼠标是否在车辆中心的半径范围内。
  • 对于飞机,我们使用 pixelShadowHeight 属性检查鼠标是否在飞机中心的半径范围内,而不是阴影。

接下来,我们将通过修改鼠标对象的 init()方法中的 mouseup 事件处理程序来处理拖动选择(参见清单 6-25 )。

清单 6-25。 在 mouseup 事件处理程序(mouse.js)中实现拖动选择

$mouseCanvas.mouseup(function(ev) {
    var shiftPressed = ev.shiftKey;
    if(ev.which==1){
    //Left key was released
        if (mouse.dragSelect){
            if (!shiftPressed){
                // Shift key was not pressed
                game.clearSelection();
            }

            var x1 = Math.min(mouse.gameX,mouse.dragX)/game.gridSize;
            var y1 = Math.min(mouse.gameY,mouse.dragY)/game.gridSize;
            var x2 = Math.max(mouse.gameX,mouse.dragX)/game.gridSize;
            var y2 = Math.max(mouse.gameY,mouse.dragY)/game.gridSize;
            for (var i = game.items.length - 1; i >= 0; i--){
                var item = game.items[i];
                if (item.type != "buildings" && item.selectable && item.team==game.team && x1<= item.x && x2 >= item.x){
                    if ((item.type == "vehicles" && y1<= item.y && y2 >= item.y)
                    || (item.type == "aircraft" && (y1 <= item.y-item.pixelShadowHeight/game.gridSize) && (y2 >= item.y-item.pixelShadowHeight/game.gridSize))){
                        game.selectItem(item,shiftPressed);
                    }

                }
            };
        }
        mouse.buttonPressed = false;
        mouse.dragSelect = false;
    }
    return false;
});

在 mouseup 事件中,我们检查鼠标是否被拖动过,如果是,遍历每个游戏项目,检查它是否在被拖动的矩形的边界内。然后,我们选择适当的项目。

最重要的是,我们只允许拖拽选择我们自己的车辆和飞机,而不是敌人或者我们自己的建筑。这是因为拖拽选择通常被用来选择一组单位来移动它们或者用它们快速攻击,而选择敌人单位或者我们自己的建筑并不能真正帮助玩家。

接下来,我们将在 game.js 内部的游戏对象中添加一些与选择相关的代码,如清单 6-26 所示。

清单 6-26。 给游戏对象添加选择相关代码(game.js)

/* Selection Related Code */
selectionBorderColor:"rgba(255,255,0,0.5)",
selectionFillColor:"rgba(255,215,0,0.2)",
healthBarBorderColor:"rgba(0,0,0,0.8)",
healthBarHealthyFillColor:"rgba(0,255,0,0.5)",
healthBarDamagedFillColor:"rgba(255,0,0,0.5)",
lifeBarHeight:5,
clearSelection:function(){
    while(game.selectedItems.length>0){
        game.selectedItems.pop().selected = false;
    }
},
selectItem:function(item,shiftPressed){
    // Pressing shift and clicking on a selected item will deselect it
    if (shiftPressed && item.selected){
        // deselect item
        item.selected = false;
        for (var i = game.selectedItems.length - 1; i >= 0; i--){
            if(game.selectedItems[i].uid == item.uid){
                game.selectedItems.splice(i,1);
                break;
            }
        };
        return;
    }

    if (item.selectable && !item.selected){
        item.selected = true;
        game.selectedItems.push(item);
    }
},

我们首先定义一些与颜色和生活条相关的常见的基于选择的属性。然后我们定义用于选择的两种方法。

  • clearSelection()方法遍历 game.selectedItems 数组,清除每个项目的 selected 标志,并从数组中删除该项目。
  • selectItem()方法根据是否按下了 Shift 键,将可选项目添加到 selectedItems()数组中,或者将其从数组中移除。通过这种方式,玩家可以在按住 Shift 键的情况下通过单击来取消选定的项目。

至此,我们已经拥有了在游戏中选择物品所需的所有代码。然而,我们仍然需要一种方法来突出显示选定的项目,以便我们可以在视觉上识别它们。这是我们接下来要实现的。

突出显示选定的实体

当玩家选择一个项目时,我们将使用该项目的 selected 属性来检测它,并在该项目周围绘制一个封闭的选择边界。我们还将添加一个指示器来显示该物品的寿命。

为此,我们将为每个实体定义两个默认方法 drawSelection()和 drawLifeBar(),并修改 draw()方法来调用它们。

首先,我们将在 buildings 对象中实现这些方法(参见清单 6-27 )。

清单 6-27。 为建筑物(buildings.js)实现 drawSelection()和 drawLifeBar()

drawLifeBar:function(){
    var x = this.drawingX+ this.pixelOffsetX;
    var y = this.drawingY - 2*game.lifeBarHeight;

    game.foregroundContext.fillStyle = (this.lifeCode == "healthy") ? game.healthBarHealthyFillColor: game.healthBarDamagedFillColor;

game.foregroundContext.fillRect(x,y,this.baseWidth*this.life/this.hitPoints,game.lifeBarHeight)

    game.foregroundContext.strokeStyle = game.healthBarBorderColor;
    game.foregroundContext.lineWidth = 1;
    game.foregroundContext.strokeRect(x,y,this.baseWidth,game.lifeBarHeight)
},
drawSelection:function(){
    var x = this.drawingX + this.pixelOffsetX;
    var y = this.drawingY + this.pixelOffsetY;
    game.foregroundContext.strokeStyle = game.selectionBorderColor;
    game.foregroundContext.lineWidth = 1;
    game.foregroundContext.fillStyle = game.selectionFillColor;
    game.foregroundContext.fillRect(x-1,y-1,this.baseWidth+2,this.baseHeight+2);
    game.foregroundContext.strokeRect(x-1,y-1,this.baseWidth+2,this.baseHeight+2);
},
// Default function for drawing a building
draw:function(){
    var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
    var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;
    this.drawingX = x;
    this.drawingY = y;
    if (this.selected){
        this.drawSelection();
        this.drawLifeBar();
    }
    // All sprite sheets will have blue in the first row and green in the second row
    var colorIndex = (this.team == "blue")?0:1;
    var colorOffset = colorIndex*this.pixelHeight;
    game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth, colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth, this.pixelHeight);
}

drawLifeBar()方法仅仅是根据建筑物的寿命用绿色或红色在建筑物上方画一个条形。酒吧的长度与建筑的寿命成正比。drawSelection()方法在建筑物底部周围绘制一个黄色矩形。最后,如果项目是从 draw()方法中选择的,我们将调用这两个方法。

接下来,我们将为 vehicles 对象实现这些方法(参见清单 6-28 )。

清单 6-28。 为车辆(vehicles.js)实现 drawSelection()和 drawLifeBar()

drawLifeBar:function(){
    var x = this.drawingX;
    var y = this.drawingY - 2*game.lifeBarHeight;
    game.foregroundContext.fillStyle = (this.lifeCode == "healthy")?game.
healthBarHealthyFillColor:game.healthBarDamagedFillColor;

game.foregroundContext.fillRect(x,y,this.pixelWidth*this.life/this.hitPoints,game.lifeBarHeight)
    game.foregroundContext.strokeStyle = game.healthBarBorderColor;
    game.foregroundContext.lineWidth = 1;
    game.foregroundContext.strokeRect(x,y,this.pixelWidth,game.lifeBarHeight)
},
drawSelection:function(){
    var x = this.drawingX + this.pixelOffsetX;
    var y = this.drawingY + this.pixelOffsetY;
    game.foregroundContext.strokeStyle = game.selectionBorderColor;
    game.foregroundContext.lineWidth = 1;
    game.foregroundContext.beginPath();
    game.foregroundContext.arc(x,y,this.radius,0,Math.PI*2,false);
    game.foregroundContext.fillStyle = game.selectionFillColor;
    game.foregroundContext.fill();
    game.foregroundContext.stroke();
},
draw:function(){
    var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
    var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY;
    this.drawingX = x;
    this.drawingY = y;
    if (this.selected){
        this.drawSelection();
        this.drawLifeBar();
    }
    var colorIndex = (this.team == "blue")?0:1;
    var colorOffset = colorIndex*this.pixelHeight;
    game.foregroundContext.drawImage(this.spriteSheet,
    this.imageOffset*this.pixelWidth,colorOffset,
    this.pixelWidth,this.pixelHeight,x,y,this.pixelWidth,this.pixelHeight);
}

这一次,drawSelection()方法在选定的车辆下绘制了一个黄色的浅填充圆。像以前一样,drawLifeBar()方法在车辆上方绘制一个生命条。

最后,我们将为飞机对象实现这些方法(见清单 6-29 )。

清单 6-29。 为飞机(aircraft.js)实现 drawSelection()和 drawLifeBar()

drawLifeBar:function(){
    var x = this.drawingX;
    var y = this.drawingY - 2*game.lifeBarHeight;
    game.foregroundContext.fillStyle = (this.lifeCode ==
    "healthy")?game.healthBarHealthyFillColor:game.healthBarDamagedFillColor;

game.foregroundContext.fillRect(x,y,this.pixelWidth*this.life/this.hitPoints,game.lifeBarHeight)
    game.foregroundContext.strokeStyle = game.healthBarBorderColor;
    game.foregroundContext.lineWidth = 1;
    game.foregroundContext.strokeRect(x,y,this.pixelWidth,game.lifeBarHeight)
},
drawSelection:function(){
    var x = this.drawingX + this.pixelOffsetX;
    var y = this.drawingY + this.pixelOffsetY;
    game.foregroundContext.strokeStyle = game.selectionBorderColor;
    game.foregroundContext.lineWidth = 2;
    game.foregroundContext.beginPath();
    game.foregroundContext.arc(x,y,this.radius,0,Math.PI*2,false);
    game.foregroundContext.stroke();
    game.foregroundContext.fillStyle = game.selectionFillColor;
    game.foregroundContext.fill();

    game.foregroundContext.beginPath();
    game.foregroundContext.arc(x,y+this.pixelShadowHeight,4,0,Math.PI*2,false);
    game.foregroundContext.stroke();

    game.foregroundContext.beginPath();
    game.foregroundContext.moveTo(x,y);
    game.foregroundContext.lineTo(x,y+this.pixelShadowHeight);
    game.foregroundContext.stroke();
},
draw:function(){
    var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX;
    var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY-this.pixelShadowHeight;
    this.drawingX = x;
    this.drawingY = y;
    if (this.selected){
        this.drawSelection();
        this.drawLifeBar();
    }
    var colorIndex = (this.team == "blue")?0:1;
    var colorOffset = colorIndex*this.pixelHeight;
    var shadowOffset = this.pixelHeight*2; // The aircraft shadow is on the second row of the sprite sheet

    game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,
colorOffset, this.pixelWidth, this.pixelHeight, x, y, this.pixelWidth, this.pixelHeight);
    game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,
shadowOffset, this.pixelWidth, this.pixelHeight, x, y+this.pixelShadowHeight, this.pixelWidth,this.pixelHeight);
}

这一次,drawLifeBar()方法在绘制生活栏时调整阴影高度。drawSelection()方法在飞机周围画一个黄色的圆,从飞机到阴影画一条直线,最后在阴影的中心画一个小圆。

通过最后的更改,我们已经为所有实体实现了绘图选择。我们不需要选择地形,因为它不能在游戏中被选择。

如果我们在浏览器中运行游戏,我们现在应该能够通过点击或者拖动鼠标到多个单位上来选择项目。这些被选中的项目应高亮显示,如图图 6-13 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-13。选中的项目高亮显示

请注意,受损建筑上方的生命栏清楚地向我们展示了它的受损程度。您可以在按住 Shift 键的同时点按项目,从而在选择中添加或减去项目。我们现在已经在游戏中完全实现了实体选择。

摘要

我们在这一章中涉及了很多内容。从上一章的空白开始,我们开发了一个通用框架,通过为这些实体实现 draw()和 animate()方法来在游戏中动画和绘制项目。

在绘制项目之前,我们进行了深度排序,这样靠近屏幕的项目会遮住较远的项目。使用这个框架,我们在游戏中添加了建筑、车辆、飞机和地形。

最后,我们实现了使用鼠标选择这些实体并突出显示这些选定实体的能力。

在下一章,我们将实现发送命令到这些实体,从最重要的一个开始:运动。我们也将着眼于使用寻路和转向算法,使单位智能导航周围的建筑物和其他障碍。

所以,我们继续吧。

七、智能单元运动

在前一章中,我们建立了一个在游戏中制作动画和绘制实体的框架,然后添加了不同类型的建筑、车辆、飞机和地形。最后,我们添加了选择这些实体的功能。

在这一章中,我们将添加一个框架来给选定的单位命令,并让实体遵循命令。然后我们将实现这些命令中最基本的:通过使用寻路和转向算法的组合来智能地移动我们的单位。

现在让我们开始吧。我们将使用第六章中的代码作为起点。

指挥单位

我们将使用现在已经成为大多数现代 RTS 游戏标准的惯例来指挥单位。我们将用左键选择单位,用右键命令它们。

右键单击地图上的可导航点将命令选定的单位移动到该点。右击一个敌人单位或建筑会命令所有选中的可以攻击的单位去攻击敌人。右击一个友军单位会告诉所有选择的单位跟随它并保护它。最后,右键点击一个油田并选择一辆矿车,会告诉矿车移动到油田并在上面部署。

我们需要做的第一件事是修改 mouse.js 中鼠标对象的 click()方法来处理右击事件,如清单 7-1 所示。

清单 7-1。 修改 click()处理右键命令(mouse.js)

click:function(ev,rightClick){
    // Player clicked inside the canvas

    var clickedItem = this.itemUnderMouse();
    var shiftPressed = ev.shiftKey;

    if (!rightClick){ // Player left clicked
        if (clickedItem){
            // Pressing shift adds to existing selection. If shift is not pressed, clear existing selection
            if(!shiftPressed){
                game.clearSelection();
            }
            game.selectItem(clickedItem,shiftPressed);
        }
    } else { // Player right-clicked
        // Handle actions like attacking and movement of selected units
        var uids = [];
        if (clickedItem){ // Player right-clicked on something
            if (clickedItem.type != "terrain"){
                if (clickedItem.team != game.team){ // Player right-clicked on an enemy item
                    // Identify selected items from players team that can attack
                    for (var i = game.selectedItems.length - 1; i >= 0; i--){
                        var item = game.selectedItems[i];
                        if(item.team == game.team && item.canAttack){
                            uids.push(item.uid);
                        }
                    };
                    // then command them to attack the clicked item
                    if (uids.length>0){
                        game.sendCommand(uids,{type:"attack",toUid:clickedItem.uid});
                    }
                } else  { // Player right-clicked on a friendly item
                    //identify selected items from players team that can move
                    for (var i = game.selectedItems.length - 1; i >= 0; i--){
                        var item = game.selectedItems[i];
                        if(item.team == game.team && (item.type == "vehicles" || item.type == "aircraft")){
                            uids.push(item.uid);
                        }
                    };
                    // then command them to guard the clicked item
                    if (uids.length>0){
                        game.sendCommand(uids,{type:"guard", toUid:clickedItem.uid});
                    }
                }
            } else if (clickedItem.name == "oilfield"){ // Player right licked on an oilfield
                // identify the first selected harvester from players team (since only one can deploy at a time)
                for (var i = game.selectedItems.length - 1; i >= 0; i--){
                    var item = game.selectedItems[i];
                    if(item.team == game.team && (item.type == "vehicles" && item.name == "harvester")){
                        uids.push(item.uid);
                        break;
                    }
                };
                // then command it to deploy on the oilfield
                if (uids.length>0){
                    game.sendCommand(uids,{type:"deploy",toUid:clickedItem.uid});
                }
            }
        } else { // Player clicked on the ground
            //identify selected items from players team that can move
            for (var i = game.selectedItems.length - 1; i >= 0; i--){
                var item = game.selectedItems[i];
                if(item.team == game.team && (item.type == "vehicles" || item.type == "aircraft")){
                    uids.push(item.uid);
                }
            };
            // then command them to move to the clicked location
            if (uids.length>0){
                game.sendCommand(uids, {type:"move", to:{x:mouse.gameX/game.gridSize, y:mouse.gameY/game.gridSize}});
            }
        }
    }
},

当玩家在游戏地图内右击时,我们首先检查鼠标是否在一个物体上。

如果玩家没有点击某个对象,我们调用 game.sendCommand()方法向所有被选中的友军车辆和飞机发送移动命令。

如果玩家点击了一个物体,我们同样会向相应的单位发送攻击、守卫或部署命令。我们还在订单中将被点击项目的 UID 作为一个名为 toUid 的参数进行传递。

有了右键逻辑,我们现在必须实现发送和接收游戏命令的方法。

发送和接收命令

我们可以通过在前面修改的 click()方法中修改所选项目的 orders 属性来实现发送命令。然而,我们将使用一个稍微复杂一些的实现。

任何生成命令的点击动作都会调用 game.sendCommand()方法。sendCommand()方法会将调用传递给单人游戏或多人游戏对象。然后,这些对象会将命令细节发送回 game.processCommand()方法。在 game.processCommand()方法中,我们将更新所有适当对象的顺序。我们首先将这些方法添加到 game.js 中的游戏对象,如清单 7-2 所示。

清单 7-2。 实现 sendCommand()和 processCommand() (game.js)

// Send command to either singleplayer or multiplayer object
sendCommand:function(uids,details){
    if (game.type=="singleplayer"){
         singleplayer.sendCommand(uids,details);
    } else {
        multiplayer.sendCommand(uids,details);
    }
},
getItemByUid:function(uid){
    for (var i = game.items.length - 1; i >= 0; i--){
        if(game.items[i].uid == uid){
            return game.items[i];
        }
    };
},
// Receive command from singleplayer or multiplayer object and send it to units
processCommand:function(uids,details){
    // In case the target "to" object is in terms of uid, fetch the target object
    var toObject;
    if (details.toUid){
        toObject = game.getItemByUid(details.toUid);
        if(!toObject || toObject.lifeCode=="dead"){
            // To object no longer exists. Invalid command
            return;
        }
    }

    for (var i in uids){
        var uid = uids[i];
        var item = game.getItemByUid(uid);
        //if uid is a valid item, set the order for the item
        if(item){
            item.orders = $.extend([],details);
            if(toObject) {
                item.orders.to = toObject;
            }
        }
    };
},

sendCommand()方法根据游戏类型将调用传递给单人游戏或多人游戏对象的 sendCommand()方法。使用这一抽象层允许我们对单人游戏和多人游戏使用相同的代码,同时以不同的方式处理命令。

虽然单机版的 sendCommand()只会立即回调 processCommand(),但多人版会将命令发送到服务器,然后服务器会同时将命令转发给所有玩家。

我们还实现了 getItemByUid()方法,该方法查找条目 Uid 并返回实体对象。

由于游戏的多人版本,我们将 uid 而不是实际的游戏对象传递给 sendCommand()方法。一个典型的 item 对象包含许多动画和绘制对象的细节,如方法、sprite 表图像和所有的 item 属性。虽然需要绘制项目,但是将这些额外的数据传输到服务器并取回是浪费带宽,而且是完全不必要的,特别是因为整个对象可以用一个整数(UID)来代替。

processCommand()方法首先查找任何 toUid 属性并获取结果项。如果不存在具有该 UID 的项目,它会认为该命令无效并忽略该命令。然后,该方法查找 uids 数组中传递的商品,并将它们的 orders 对象设置为参数中提供的订单详细信息的副本。

接下来我们要做的是在 singleplayer.js 中实现 singlePlayer 对象的 sendCommand()方法,如清单 7-3 所示。

清单 7-3。 实现单人 sendCommand()方法(singleplayer.js)

sendCommand:function(uids,details){
    game.processCommand(uids,details);
}

如您所见,sendCommand()的实现相当简单。我们只是将呼叫转发给 game.processCommand()。然而,如果我们愿意,我们也可以使用这个方法来添加保存游戏命令的功能,以及关于当前运行的动画周期的细节,以实现重放保存的游戏的能力。

现在我们已经建立了一个指挥单位和设置他们的命令的机制,我们需要建立一个单位处理和执行这些命令的方法。

处理订单

我们处理订单的实现将相当简单。我们将为每个需要它的实体实现一个名为 processOrders()的方法,并从游戏动画循环内部为所有游戏项目调用 processOrders()方法。

我们将从修改 game.js 内游戏对象的 animationLoop()方法开始,如清单 7-4 所示。

清单 7-4。 从动画循环(game.js)内部调用 processOrders()

animationLoop:function(){
    // Process orders for any item that handles it
    for (var i = game.items.length - 1; i >= 0; i--){
        if(game.items[i].processOrders){
            game.items[i].processOrders();
        }
    };

    // Animate each of the elements within the game
    for (var i = game.items.length - 1; i >= 0; i--){
        game.items[i].animate();
    };

    // Sort game items into a sortedItems array based on their x,y coordinates
    game.sortedItems = $.extend([],game.items);
    game.sortedItems.sort(function(a,b){
     return b.y-a.y + ((b.y==a.y)?(a.x-b.x):0);
    });
},

该代码遍历每个游戏项目,并调用该项目的 processOrders()方法(如果存在)。现在,我们可以为游戏实体逐个实现 processOrders()方法,并观察这些实体开始服从我们的命令。

让我们从实现飞机的运动开始。

实现飞机运动

与陆地交通工具不同,移动飞机相当简单,因为飞机不受地形、建筑物或其他交通工具的影响。当一架飞机接到移动命令时,它只会转向目的地,然后直线前进。一旦飞机接近目的地,它将回到漂浮状态。

我们将把它实现为 aircraft.js 中飞机的默认 processOrders()方法,如清单 7-5 中的所示。

清单 7-5。 运动中飞机对象的默认 processOrders()方法(aircraft.js)

processOrders:function(){
    this.lastMovementX = 0;
    this.lastMovementY = 0;
    switch (this.orders.type){
        case "move":
            // Move towards destination until distance from destination is less than aircraft radius
            var distanceFromDestinationSquared = (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2));
            if (distanceFromDestinationSquared < Math.pow(this.radius/game.gridSize,2)) {
                this.orders = {type:"float"};
            } else {
                this.moveTo(this.orders.to);
            }
            break;
    }
},

我们首先重置两个与运动相关的变量,我们稍后会用到它们。然后,我们检查 case 语句中的订单类型。

如果订单类型是 move,我们调用 moveTo()方法,直到飞机到目的地的距离(存储在 To 参数中)小于飞机的半径。一旦飞机到达目的地,我们就把顺序改回浮动。

目前,我们只执行了一个订单。每当飞机收到一个它不知道如何处理的命令时,它将继续在当前位置浮动。随着时间的推移,我们将执行更多的订单。

我们要做的下一件事是实现一个默认的 moveTo()方法,这两个飞行器都将使用它(见清单 7-6 )。

清单 7-6。 飞机对象的默认 moveTo()方法(aircraft.js)

moveTo:function(destination){
    // Find out where we need to turn to get to destination
    var newDirection = findAngle(destination,this,this.directions);
    // Calculate difference between new direction and current direction
    var difference = angleDiff(this.direction,newDirection,this.directions);
    // Calculate amount that aircraft can turn per animation cycle
    var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
    if (Math.abs(difference)>turnAmount){
        this.direction = wrapDirection(this.direction+turnAmount*Math.abs(difference)/difference,this.directions);
    } else {
        // Calculate distance that aircraft can move per animation cycle
        var movement = this.speed*game.speedAdjustmentFactor;
        // Calculate x and y components of the movement
        var angleRadians = -(Math.round(this.direction)/this.directions)*2*Math.PI ;
        this.lastMovementX = - (movement*Math.sin(angleRadians));
        this.lastMovementY = - (movement*Math.cos(angleRadians));
        this.x = (this.x +this.lastMovementX);
        this.y = (this.y +this.lastMovementY);
    }
},

我们首先使用 findAngle()方法计算从飞机到目的地的角度,并使用 angleDiff()方法计算当前方向和新方向之间的差异。newDirection 变量的值在 0 到 7 之间(以反映飞机可以采取的方向),而 difference 变量的值在-4 到 4 之间,负号表示逆时针转弯比顺时针转弯短。

然后,我们根据飞机的转弯速度属性计算飞机可以转弯的量,并通过比较角度差和转弯量来查看该项目是否需要更多的转弯。

如果飞机仍然需要转弯,我们将 turnAmount 值加到它的方向上,同时保持差值变量的符号。我们使用 wrapDirection()方法来确保最终的飞机方向仍然在 0 到 7 之间。

如果飞机已经转向目的地,我们根据它的速度计算移动距离。然后,我们计算运动的 x 和 y 分量,并将其添加到飞机的 x 和 y 坐标中。

当然,既然飞机方向可以采用非整数值,我们需要修改飞机对象的默认 animate()方法,以确保它在选择精灵之前舍入方向(见清单 7-7 )。

清单 7-7。 修改 animate()处理非整数方向值(aircraft.js)

animate:function(){
    // Consider an item healthy if it has more than 40% life
    if (this.life>this.hitPoints*0.4){
        this.lifeCode = "healthy";
    } else if (this.life <= 0){
        this.lifeCode = "dead";
        game.remove(this);
        return;
    } else {
        this.lifeCode = "damaged";
    }
    switch (this.action){
        case "fly":
            var direction = wrapDirection(Math.round(this.direction),this.directions);
            this.imageList = this.spriteArray["fly-"+ direction];
            this.imageOffset = this.imageList.offset + this.animationIndex;
            this.animationIndex++;
            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;
            }
            break;
    }
},

我们首先舍入飞机方向,然后调用 wrapDirection()来确保方向位于 0 和 7 之间。方法的其余部分保持不变。

接下来,我们将把 findAngle()、angleDiff()和 wrapDirection()方法添加到 common.js 中,如清单 7-8 所示。

清单 7-8。 实现 findAngle()、angleDiff()和 wrapDirection() (common.js)

/* Common functions for turning and movement */

// Finds the angle between two objects in terms of a direction (where 0 <= angle < directions)
function findAngle(object,unit,directions){
     var dy = (object.y) - (unit.y);
     var dx = (object.x) - (unit.x);
    //Convert Arctan to value between (0 - directions)
    var angle = wrapDirection(directions/2-(Math.atan2(dx,dy)*directions/(2*Math.PI)),directions);
    return angle;
 }

// returns the smallest difference (value ranging between -directions/2 to +directions/2) between two angles (where 0 <= angle < directions)
function angleDiff(angle1,angle2,directions){
    if (angle1>=directions/2){
        angle1 = angle1-directions;
    }
    if (angle2>=directions/2){
        angle2 = angle2-directions;
    }

    diff = angle2-angle1;

    if (diff<-directions/2){
        diff += directions;
    }
    if (diff>directions/2){
        diff -= directions;
    }

    return diff;
}

// Wrap value of direction so that it lies between 0 and directions-1
function wrapDirection(direction,directions){
    if (direction<0){
        direction += directions;
    }
    if (direction >= directions){
        direction -= directions;
    }
    return direction;
}

我们需要做的最后一个改变是在 game.js 的游戏对象中定义两个与运动相关的属性(参见清单 7-9 )。

清单 7-9。 给游戏对象添加动作相关属性(game.js)

//Movement related properties
speedAdjustmentFactor:1/64,
turnSpeedAdjustmentFactor:1/8,

这两个因素用于将实体的速度和转弯速度值转换为游戏中的移动和转弯单位。

我们现在准备开始在游戏中移动我们的飞行器,但是在此之前,让我们通过从地图上移除所有不必要的物品来简化我们的关卡。新的 maps.js 将看起来像清单 7-10 中的。

清单 7-10。 从地图中删除不必要的项目(maps.js)

var maps = {
    "singleplayer":[
        {
            "name":"Entities",
            "briefing": "In this level you will start commanding units and moving them around the map.",

            /* Map Details */
            "mapImage":"img/level-one-debug-grid.png",
            "startX":2,
            "startY":3,

            /* Entities to be loaded */
            "requirements":{
                "buildings":["base","starport","harvester","ground-turret"],
                "vehicles":["transport","harvester","scout-tank","heavy-tank"],
                "aircraft":["chopper","wraith"],
                "terrain":["oilfield","bigrocks","smallrocks"]
            },

            /* Entities to be added */
            "items":[
                {"type":"buildings","name":"base","x":11,"y":14,"team":"blue"},
                {"type":"buildings","name":"starport","x":18,"y":14, "team":"blue"},
                {"type":"buildings","name":"harvester","x":20,"y":10, "team":"blue"},
                {"type":"buildings","name":"ground-turret","x":24,"y":7, "team":"blue","direction":3},

                {"type":"vehicles","name":"transport","x":24,"y":10, "team":"blue","direction":2},
                {"type":"vehicles","name":"harvester","x":16,"y":12, "team":"blue","direction":3},
                {"type":"vehicles","name":"scout-tank","x":24,"y":14, "team":"blue","direction":4},
                {"type":"vehicles","name":"heavy-tank","x":24,"y":16, "team":"blue","direction":5},

                {"type":"aircraft","name":"chopper","x":7,"y":9, "team":"blue","direction":2},
                {"type":"aircraft","name":"wraith","x":11,"y":9, "team":"blue","direction":3},

                {"type":"terrain","name":"oilfield","x":3,"y":5, "action":"hint"},
                {"type":"terrain","name":"bigrocks","x":19,"y":6},
                {"type":"terrain","name":"smallrocks","x":8,"y":3}
            ]
        }
    ]
}

当你在浏览器中运行游戏时,你应该能够选择两架飞机并在新地图上移动它们,如图 7-1 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-1。在新地图周围移动飞机

当你选择一架飞机,在地图上某处右击,飞机应该会转向,向目的地移动。你会注意到幽灵的飞机比直升机移动得更快,因为我们在幽灵实体的属性中指定了一个更高的速度值。

你可能也会注意到右键点击一个建筑或者一个友军单位并没有任何作用。这是因为右击一个友方物品会生成守卫命令,我们还没有实现这个命令。

为我们的飞机实现运动相当简单,因为我们创造性地假设飞机可以通过调整高度来避开建筑物、车辆和其他飞机。

然而,当涉及到车辆时,我们不能再这样做了。当我们在建筑物和地形等障碍物周围行驶时,我们需要担心找到车辆和目的地之间的最短路径。这就是寻路的用武之地。

寻路

寻路,或称寻路,是寻找两点间最短路径的过程。典型地,它包括使用各种算法来遍历节点图,从一个顶点开始并探索相邻的节点,直到到达目的节点。

基于图的寻路最常用的两种算法是 Dijkstra 算法及其变体 A*(读作“A star”)算法。

A*使用额外的距离启发式算法,帮助它比 Dijkstra 更快地找到路径。由于其性能和准确性,被广泛应用于游戏中。你可以在en.wikipedia.org/wiki/A了解更多算法。我们也将在游戏中使用来表示车辆路径。

我们将使用 Andrea Giammarchi 的 A的一个优秀的 MIT 授权的 JavaScript 实现。代码已经针对 JavaScript 进行了优化,即使在大型图形上,它的性能也相当不错。在devpro.it/javascript_id_137.html,你可以看到最新的代码,也可以玩现场演示。我们将在 index.html 的 head 部分添加一个对 A实现(存储在 astar.js 中)的引用,如清单 7-11 所示。

清单 7-11。 添加引用 A*实现(index.html)

<!-- A* Implementation by Andrea Giammarchi -->
<script src="js/astar.js" type="text/javascript" charset="utf-8"></script>

虽然实现相当复杂,但相对容易使用。代码让我们可以访问 Astar()方法,该方法接受四个参数:我们要使用的地图、起始坐标、结束坐标,以及可选的要使用的启发式名称。

该方法返回一个包含最短路径所有中间步骤的数组,或者在没有可能路径的情况下返回一个空数组。

现在我们已经有了 A*算法,我们需要为它提供一个用于寻路的图或网格。

定义我们的寻路网格

我们已经把地图分成了 20 像素乘 20 像素的方格。我们将把寻路网格存储为一个二维数组,对于可通行和不可通行的正方形,其值分别为 0 和 1。

在我们创建这个数组之前,我们需要修改我们的地图来定义地图上所有不可通行的区域。我们将通过在 maps.js 中的第一层添加一些新的属性来做到这一点,如清单 7-12 中的所示。

清单 7-12。 为关卡添加寻路属性(maps.js)

/* Map coordinates that are obstructed by terrain*/
"mapGridWidth":60,
"mapGridHeight":40,
"mapObstructedTerrain":[
    [49,8], [50,8], [51,8], [51,9], [52,9], [53,9], [53,10], [53,11], [53,12], [53,13], [53,14],
[53,15], [53,16], [52,16], [52,17], [52,18], [52,19], [51,19], [50,19], [50,18], [50,17], [49,17],
[49,18], [48,18], [47,18], [47,17], [47,16], [48,16], [49,16], [49,15], [49,14], [48,14], [48,13],
[48,12], [49,12], [49,11], [50,11], [50,10], [49,10], [49,9], [44,0], [45,0], [45,1], [45,2],
[46,2], [47,2], [47,3], [48,3], [48,4], [48,5], [49,5], [49,6], [49,7], [50,7], [51,7], [51,6],
[51,5], [51,4], [52,4], [53,4], [53,3], [54,3], [55,3], [55,2], [56,2], [56,1], [56,0], [55,0],
[43,19], [44,19], [45,19], [46,19], [47,19], [48,19], [48,20], [48,21], [47,21], [46,21], [45,21],
[44,21], [43,21], [43,20], [41,22], [42,22], [43,22], [44,22], [45,22], [46,22], [47,22], [48,22],
[49,22], [50,22], [50,23], [50,24], [49,24], [48,24], [47,24], [47,25], [47,26], [47,27], [47,28],
[47,29], [47,30], [46,30], [45,30], [44,30], [43,30], [43,29], [43,28], [43,27], [43,26], [43,25],
[43,24], [42,24], [41,24], [41,23], [48,39], [49,39], [50,39], [51,39], [52,39], [53,39], [54,39],
[55,39], [56,39], [57,39], [58,39], [59,39], [59,38], [59,37], [59,36], [59,35], [59,34], [59,33],
[59,32], [59,31], [59,30], [59,29], [0,0], [1,0], [2,0], [1,1], [2,1], [10,3], [11,3], [12,3],
[12,2], [13,2], [14,2], [14,3], [14,4], [15,4], [15,5], [15,6], [14,6], [13,6], [13,5], [12,5],
[11,5], [10,5], [10,4], [3,9], [4,9], [5,9], [5,10], [6,10], [7,10], [8,10], [9,10], [9,11],
[10,11], [11,11], [11,10], [12,10], [13,10], [13,11], [13,12], [12,12], [11,12], [10,12], [9,12],
[8,12], [7,12], [7,13], [7,14], [6,14], [5,14], [5,13], [5,12], [5,11], [4,11], [3,11], [3,10],
[33,33], [34,33], [35,33], [35,34], [35,35], [34,35], [33,35], [33,34], [27,39], [27,38], [27,37],
[28,37], [28,36], [28,35], [28,34], [28,33], [28,32], [28,31], [28,30], [28,29], [29,29], [29,28],
[29,27], [29,26], [29,25], [29,24], [29,23], [30,23], [31,23], [32,23], [32,22], [32,21], [31,21],
[30,21], [30,22], [29,22], [28,22], [27,22], [26,22], [26,21], [25,21], [24,21], [24,22], [24,23],
[25,23], [26,23], [26,24], [25,24], [25,25], [24,25], [24,26], [24,27], [25,27], [25,28], [25,29],
[24,29], [23,29], [23,30], [23,31], [24,31], [25,31], [25,32], [25,33], [24,33], [23,33], [23,34],
[23,35], [24,35], [24,36], [24,37], [23,37], [22,37], [22,38], [22,39], [23,39], [24,39], [25,39],
[26,0], [26,1], [25,1], [25,2], [25,3], [26,3], [27,3], [27,2], [28,2], [29,2], [29,3], [30,3],
[31,3], [31,2], [31,1], [32,1], [32,0], [33,0], [32,8], [33,8], [34,8], [34,9], [34,10], [33,10],
[32,10], [32,9], [8,29], [9,29], [9,30], [17,32], [18,32], [19,32], [19,33], [18,33], [17,33]
, [18,34], [19,34], [3,27], [4,27], [4,26], [3,26], [2,26], [3,25], [4,25], [9,20], [10,20], [11,20],
[11,21], [10,21], [10,19], [19,7], [15,7], [29,12], [30,13], [20,14], [21,14], [34,13], [35,13],
[36,13], [36,14], [35,14], [34,14], [35,15], [36,15], [16,18], [17,18], [18,18], [16,19], [17,19],
[18,19], [17,20], [18,20], [11,19], [58,0], [59,0], [58,1], [59,1], [59,2], [58,3], [59,3], [58,4],
[59,4], [59,5], [58,6], [59,6], [58,7], [59,7], [59,8], [58,9], [59,9], [58,10], [59,10], [59,11],
[52,6], [53,6], [54,6], [52,7], [53,7], [54,7], [53,8], [54,8], [44,17], [46,32], [55,32], [54,28],
[26,34], [34,34], [4,10], [6,11], [6,12], [6,13], [7,11], [8,11], [12,11], [27,0], [27,1], [26,2],
[28,1], [28,0], [29,0], [29,1], [30,2], [30,1], [30,0], [31,0], [33,9], [46,0], [47,0], [48,0],
[49,0], [50,0], [51,0], [52,0], [53,0], [54,0], [55,1], [54,1], [53,1], [52,1], [51,1], [50,1],
[49,1], [48,1], [47,1], [46,1], [48,2], [49,2], [50,2], [51,2], [52,2], [53,2], [54,2], [52,3],
[51,3], [50,3], [49,3], [49,4], [50,4], [50,5], [50,6], [50,9], [51,10], [52,10], [51,11], [52,11],
[50,12], [51,12], [52,12], [49,13], [50,13], [51,13], [52,13], [50,14], [51,14], [52,14], [50,15],
[51,15], [52,15], [50,16], [51,16], [51,17], [48,17], [51,18], [44,20], [45,20], [46,20], [47,20],
[42,23], [43,23], [44,23], [45,23], [46,23], [47,23], [48,23], [49,23], [44,24], [45,24], [46,24],
[44,25], [45,25], [46,25], [44,26], [45,26], [46,26], [44,27], [45,27], [46,27], [44,28], [45,28],
[46,28], [44,29], [45,29], [46,29], [11,4], [12,4], [13,4], [13,3], [14,5], [25,22], [31,22],
[27,23], [28,23], [27,24], [28,24], [26,25], [27,25], [28,25], [25,26], [26,26], [27,26], [28,26],
[26,27], [27,27], [28,27], [26,28], [27,28], [28,28], [26,29], [27,29], [24,30], [25,30], [26,30],
[27,30], [26,31], [27,31], [26,32], [27,32], [26,33], [27,33], [24,34], [25,34], [27,34], [25,35],
[26,35], [27,35], [25,36], [26,36], [27,36], [25,37], [26,37], [23,38], [24,38], [25,38], [26,38],
[26,39], [2,25], [9,19], [36,31]
],

我们首先定义了两个名为 mapGridWidth 和 mapGridHeight 的属性,最后定义了一个非常大而且看起来很吓人的数组,名为 mapObstructedTerrain。这个数组仅仅包含了地图中每个不可通过的网格的 x 和 y 坐标。这包括有树、山、水、火山口和熔岩的地区。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 如果你打算给你的游戏增加很多关卡,你应该花时间设计一个关卡编辑器,自动为你生成这个数组,而不是试图手工创建。

现在我们已经有了这些属性,我们需要在加载关卡时从这些数据中生成一个地形网格。我们将在 singleplayer.js 中的 singleplayer 对象的 startCurrentLevel()方法中完成这项工作(参见清单 7-13 )。

清单 7-13。 开始关卡时创建地形网格(singleplayer.js)

startCurrentLevel:function(){
    // Load all the items for the level
    var level = maps.singleplayer[singleplayer.currentLevel];

    // Don't allow player to enter mission until all assets for the level are loaded
    $("#entermission").attr("disabled", true);

    // Load all the assets for the level
    game.currentMapImage = loader.loadImage(level.mapImage);
    game.currentLevel = level;

    game.offsetX = level.startX * game.gridSize;
    game.offsetY = level.startY * game.gridSize;

    // Load level Requirements
    game.resetArrays();
    for (var type in level.requirements){
        var requirementArray = level.requirements[type];
        for (var i=0; i < requirementArray.length; i++) {
            var name = requirementArray[i];
            if (window[type]){
                window[type].load(name);
            } else {
                console.log('Could not load type :',type);
            }
        };
    }

    for (var i = level.items.length - 1; i >= 0; i--){
        var itemDetails = level.items[i];
        game.add(itemDetails);
    };

    // Create a grid that stores all obstructed tiles as 1 and unobstructed as 0
    game.currentMapTerrainGrid = [];
    for (var y=0; y < level.mapGridHeight; y++) {
        game.currentMapTerrainGrid[y] = [];
        for (var x=0; x< level.mapGridWidth; x++) {
           game.currentMapTerrainGrid[y][x] = 0;
        }
    };
    for (var i = level.mapObstructedTerrain.length - 1; i >= 0; i--){
        var obstruction = level.mapObstructedTerrain[i];
        game.currentMapTerrainGrid[obstruction[1]][obstruction[0]] = 1;
    };
    game.currentMapPassableGrid = undefined;

    // Enable the enter mission button once all assets are loaded
    if (loader.loaded){
        $("#entermission").removeAttr("disabled");
    } else {
        loader.onload = function(){
            $("#entermission").removeAttr("disabled");
        }
    }

    // Load the mission screen with the current briefing
    $('#missonbriefing').html(level.briefing.replace(/\n/g,'<br><br>'));
    $("#missionscreen").show();
},

我们在游戏对象中初始化一个名为 currentMapTerrainGrid 的数组,并使用 mapGridWidth 和 mapGridHeight 将它设置为地图的尺寸。然后,我们将所有被遮挡的方块设为 1,将未被遮挡的方块设为 0。

如果我们在地图上突出显示当前主栅格中被遮挡的方块,它看起来会像图 7-2 。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-2。当前主栅格中定义的障碍栅格方块

虽然 currentMapTerrainGrid 在地图地形中标出了所有的障碍物,但它仍然不包括地图上的建筑物和地形实体。

我们将在游戏对象中保留另一个名为 currentMapPassableGrid 的数组,该数组将结合建筑和地形实体以及我们之前定义的 currentMapTerrainGrid 数组。每次在游戏中添加或删除建筑或地形时,都需要重新创建这个数组。我们将在游戏对象中的 rebuildPassableGrid()方法中实现这一点(参见清单 7-14 )。

清单 7-14。 游戏对象(game.js)中的 rebuildPassableGrid()方法

rebuildPassableGrid:function(){
    game.currentMapPassableGrid = $.extend(true,[],game.currentMapTerrainGrid);
    for (var i = game.items.length - 1; i >= 0; i--){
        var item = game.items[i];
        if(item.type == "buildings" || item.type == "terrain"){
            for (var y = item.passableGrid.length - 1; y >= 0; y--){
                for (var x = item.passableGrid[y].length - 1; x >= 0; x--){
                    if(item.passableGrid[y][x]){
                        game.currentMapPassableGrid[item.y+y][item.x+x] = 1;
                    }
                };
            };
        }
    };
},

我们首先将 currentMapTerrainGrid 数组复制到 currentMapPassableGrid 中。然后,我们遍历所有游戏项目,并使用我们为所有建筑物和地形定义的 passableGrid 属性来标记出不可通过的网格方块。如果我们基于 currentMapPassableGrid 在地图上高亮显示被遮挡的方块,它看起来会像图 7-3 。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-3。在 currentMapPassableGrid 中定义的障碍网格方块

由于我们为每个建筑定义 passableGrid 的方式,允许部分建筑是可通行的是可能的(例如,星门的下部)。

我们需要确保在游戏中添加或移除建筑时,game.currentMapPassableGrid 会被重置。我们通过在游戏对象的 add()和 remove()方法中添加一个额外的条件来做到这一点,如清单 7-15 所示。

清单 7-15。 清除 currentMapPassableGrid 里面的 add()和 remove() (game.js)

add:function(itemDetails) {
    // Set a unique id for the item
    if (!itemDetails.uid){
        itemDetails.uid = game.counter++;
    }

    var item = window[itemDetails.type].add(itemDetails);

    // Add the item to the items array
    game.items.push(item);
    // Add the item to the type specific array
    game[item.type].push(item);

    if(item.type == "buildings" || item.type == "terrain"){
        game.currentMapPassableGrid = undefined;
    }
    return item;
},
remove:function(item){
    // Unselect item if it is selected
    item.selected = false;
    for (var i = game.selectedItems.length - 1; i >= 0; i--){
           if(game.selectedItems[i].uid == item.uid){
               game.selectedItems.splice(i,1);
               break;
           }
       };

    // Remove item from the items array
    for (var i = game.items.length - 1; i >= 0; i--){
        if(game.items[i].uid == item.uid){
            game.items.splice(i,1);
            break;
        }
    };

    // Remove items from the type specific array
    for (var i = game[item.type].length - 1; i >= 0; i--){
        if(game[item.type][i].uid == item.uid){
           game[item.type].splice(i,1);
           break;
        }
    };

    if(item.type == "buildings" || item.type == "terrain"){
        game.currentMapPassableGrid = undefined;
    }
},

在这两种方法中,我们检查被添加或删除的项目是建筑物类型还是地形类型,如果是,重置 currentMapPassableGrid 变量。

现在我们已经为 A*算法定义了运动网格,我们准备好实现车辆运动。

实现车辆移动

我们将从在 vehicles.js 中为 vehicles 对象添加一个默认的 processOrders()方法开始,如清单 7-16 中的所示。

清单 7-16。 默认为车辆的 processOrders()方法(vehicles.js)

processOrders:function(){
    this.lastMovementX = 0;
    this.lastMovementY = 0;
    switch (this.orders.type){
        case "move":
            // Move towards destination until distance from destination is less than vehicle radius
            var distanceFromDestinationSquared = (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2));
            if (distanceFromDestinationSquared < Math.pow(this.radius/game.gridSize,2)) {
                this.orders = {type:"stand"};
                return;
            } else {
                // Try to move to the destination
                var moving = this.moveTo(this.orders.to);
                if(!moving){
                    // Pathfinding couldn't find a path so stop
                    this.orders = {type:"stand"};
                    return;
                }
            }
            break;
    }
},

该方法非常类似于我们为飞机定义的 processOrders()方法。一个微妙的区别是,我们检查 moveTo()方法是否返回 true 值,表明它能够向目的地移动,并在它不能移动时重置订单。我们这样做是因为寻路算法可能找不到有效的路径,moveTo()将返回一个指示这一点的值。

接下来,我们将为车辆实现默认的 moveTo()方法,如清单 7-17 所示。

清单 7-17。 默认为车辆的 moveTo()方法(vehicles.js)

moveTo:function(destination){
    if(!game.currentMapPassableGrid){
        game.rebuildPassableGrid();
    }

    // First find path to destination
    var start = [Math.floor(this.x),Math.floor(this.y)];
    var end = [Math.floor(destination.x),Math.floor(destination.y)];

    var grid = $.extend(true,[],game.currentMapPassableGrid);
    // Allow destination to be "movable" so that algorithm can find a path
    if(destination.type == "buildings"||destination.type == "terrain"){
        grid[Math.floor(destination.y)][Math.floor(destination.x)] = 0;
    }

    var newDirection;
    // if vehicle is outside map bounds, just go straight towards goal
    if (start[1]<0 || start[1]>=game.currentLevel.mapGridHeight || start[0]<0 || start[0]>= game.currentLevel.mapGridWidth){
        this.orders.path = [this,destination];
        newDirection = findAngle(destination,this,this.directions);
    } else {
        //Use A* algorithm to try and find a path to the destination
        this.orders.path = AStar(grid,start,end,'Euclidean');
        if (this.orders.path.length>1){
            var nextStep = {x:this.orders.path[1].x+0.5,y:this.orders.path[1].y+0.5};
            newDirection = findAngle(nextStep,this,this.directions);
        } else if(start[0]==end[0] && start[1] == end[1]){
            // Reached destination grid;
            this.orders.path = [this,destination];
            newDirection = findAngle(destination,this,this.directions);
        } else {
            // There is no path
            return false;
        }
    }

    // Calculate turn amount for new direction
    var difference = angleDiff(this.direction,newDirection,this.directions);
    var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;

    // Move forward, but keep turning as needed
    var movement = this.speed*game.speedAdjustmentFactor;
    var angleRadians = -(Math.round(this.direction)/this.directions)*2*Math.PI;
    this.lastMovementX = - (movement*Math.sin(angleRadians));
    this.lastMovementY = - (movement*Math.cos(angleRadians));
    this.x = (this.x +this.lastMovementX);
    this.y = (this.y +this.lastMovementY);

    if (Math.abs(difference)>turnAmount){
        this.direction = wrapDirection(this.direction + turnAmount*Math.abs(difference)/difference, this.directions);
    }

    return true;
},

我们首先检查 game.currentMapPassableGrid 是否已定义,如果未定义,则调用 game.rebuildPassableGrid()。然后,我们通过截断车辆和目的地位置来定义路径的开始和结束值。

接下来,我们将 game.currentMapPassableGrid 复制到一个网格变量中,并将目的地网格正方形定义为可通行,以防目的地是建筑物或地形。这种黑客让 A*算法找到一条通往建筑物的路径,即使目的地无法通行。

下一步是计算路径和新方向。我们首先检查车辆是否在地图边界之外,如果是,通过使用车辆和目的地定义路径的开始和结束位置,并使用 findAngle()方法计算 newDirection,直接驶向目的地。我们这样做是因为如果我们传递给 AStar()方法的起始坐标在网格之外,它就会失败。

如果车辆在地图范围内,我们调用 AStar()方法,同时向它传递 start、end 和 grid 值。我们指定了欧几里得的启发式方法,它允许对角线移动,似乎对我们的游戏很有效。

如果 AStar()方法返回一条至少有两个元素的路径,我们通过找到从车辆到下一个网格中间的角度来计算 newDirection。

如果路径不包含至少两个元素,我们检查这是否是因为我们已经到达目的地网格方块,如果是,则朝着最终目的地前进。如果不是,我们假设这是因为 AStar()找不到路径并返回 false。

最后,我们使用 newDirection 和 turnSpeed 和 Speed 值来向前移动车辆并使其转向 newDirection。与我们的飞机不同,车辆不应该原地转向,我们通过使运动和转向同时发生来实现这一点。

实现了寻路方法的核心之后,我们需要对车辆对象进行最后一次修改。我们将修改默认的 animate()方法来考虑方向的非整数值,如清单 7-18 所示。

清单 7-18。 修改 animate()处理非整数方向值(vehicles.js)

animate:function(){
    // Consider an item healthy if it has more than 40% life
    if (this.life>this.hitPoints*0.4){
        this.lifeCode = "healthy";
    } else if (this.life <= 0){
        this.lifeCode = "dead";
        game.remove(this);
        return;
    } else {
        this.lifeCode = "damaged";
    }

    switch (this.action){
        case "stand":
            var direction = wrapDirection(Math.round(this.direction),this.directions);
            this.imageList = this.spriteArray["stand-"+direction];
            this.imageOffset = this.imageList.offset + this.animationIndex;
            this.animationIndex++;

            if (this.animationIndex>=this.imageList.count){
                this.animationIndex = 0;
            }
            break;
    }
},

如果你现在运行这个游戏,你应该能够通过右击地图上的一个点来选择车辆并在地图上移动它们。车辆将沿着避开所有地形和建筑障碍物的路径行驶。图 7-4 显示了寻路算法返回的典型路径。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-4。使用寻路算法的典型运动路径

你会注意到的一件事是,当车辆避开无法通行的地形时,它们仍然会驶过其他车辆。

解决这个问题的一个简单方法是将所有被车辆占据的方格标记为不可通行。然而,这种简单的方法可能会阻塞地图的很大一部分,因为车辆经常穿过多个网格方块。这种方法的另一个缺点是,如果我们试图移动一堆车辆通过一条狭窄的通道,第一辆车将阻塞通道,导致后面的车辆试图寻找一条更长的替代路线,或者更糟的是,假设没有可能的路径而放弃。

一个更好的替代方案是实现一个转向步骤,该步骤检查与其他物体的碰撞并修改车辆的方向,同时仍然尽可能地保持原始路径。

碰撞检测和转向

转向和寻路一样,是一个相当庞大的人工智能课题。在游戏中应用转向行为的想法由来已久,但它是在 20 世纪 80 年代中后期由克雷格·雷诺兹(Craig Reynolds)的工作推广开来的。他的论文“自主角色的操纵行为”和他的 Java 演示仍然被认为是开发游戏中操纵机制的基本起点。你可以阅读更多关于他的研究,并在 http://www.red3d.com/cwr/steer/观看各种转向机制的演示。

我们将为我们的游戏使用一个相当简单的实现。我们将首先检查沿当前方向移动车辆是否会导致与任何物体的碰撞。如果有碰撞的物体,我们将从任何碰撞的物体对我们的车辆产生排斥力,并对寻路路径中的下一个网格方块产生温和的吸引力。

然后,我们将所有这些力结合起来作为矢量,来看车辆需要向哪个方向移动以避开碰撞。我们将把车辆转向这个方向,直到车辆不再与任何物体碰撞,此时车辆将返回到基本寻路模式。

我们将根据碰撞物体的距离来区分硬碰撞和软碰撞。即将发生软碰撞的车辆在转弯时仍可能移动;然而,一辆即将发生硬碰撞的车辆根本不会向前行驶,只会转向。

我们将首先为 vehicles.js 中的 vehicle 对象实现一个默认的 checkCollisionsObject()方法,如清单 7-19 所示。

清单 7-19。 默认的 checkCollisionObjects()方法(vehicles.js)

// Make a list of collisions that the vehicle will have if it goes along present path
checkCollisionObjects:function(grid){
    // Calculate new position on present path
    var movement = this.speed*game.speedAdjustmentFactor;
    var angleRadians = -(Math.round(this.direction)/this.directions)*2*Math.PI;
    var newX = this.x - (movement*Math.sin(angleRadians));
    var newY = this.y - (movement*Math.cos(angleRadians));

    // List of objects that will collide after next movement step
    var collisionObjects = [];
    var x1 = Math.max(0,Math.floor(newX)-3);
    var x2 = Math.min(game.currentLevel.mapGridWidth-1,Math.floor(newX)+3);
    var y1 = Math.max(0,Math.floor(newY)-3);
    var y2 = Math.min(game.currentLevel.mapGridHeight-1,Math.floor(newY)+3);
    // Test grid upto 3 squares away
    for (var j=x1; j <= x2;j++){
        for(var i=y1; i<= y2 ;i++){
            if(grid[i][j]==1){ // grid square is obsutructed
                if (Math.pow(j+0.5-newX,2)+Math.pow(i+0.5-newY,2) < Math.pow(this.radius/game.gridSize+0.1,2)){
                    // Distance of obstructed grid from vehicle is less than hard collision threshold
                    collisionObjects.push({collisionType:"hard", with:{type:"wall",x:j+0.5,y:i+0.5}});
                } else if (Math.pow(j+0.5-newX,2)+Math.pow(i+0.5-newY,2) < Math.pow(this.radius/game.gridSize+0.7,2)){
                    // Distance of obstructed grid from vehicle is less than soft collision threshold
                     collisionObjects.push({collisionType:"soft", with:{type:"wall",x:j+0.5,y:i+0.5}});
                }
            }
        };
    };

    for (var i = game.vehicles.length - 1; i >= 0; i--){
        var vehicle = game.vehicles[i];
        // Test vehicles that are less than 3 squares away for collisions
        if (vehicle != this && Math.abs(vehicle.x-this.x)<3 && Math.abs(vehicle.y-this.y)<3){
            if (Math.pow(vehicle.x-newX,2) + Math.pow(vehicle.y-newY,2) < Math.pow((this.radius+vehicle.radius)/game.gridSize,2)){
                // Distance between vehicles is less than hard collision threshold (sum of vehicle radii)
                   collisionObjects.push({collisionType:"hard",with:vehicle});
            } else if (Math.pow(vehicle.x-newX,2) + Math.pow(vehicle.y-newY,2) < Math.pow((this.radius*1.5+vehicle.radius)/game.gridSize,2)){
                // Distance between vehicles is less than soft collision threshold (1.5 times vehicle radius + colliding vehicle radius)
                collisionObjects.push({collisionType:"soft",with:vehicle});
            }
        }
    };

    return collisionObjects;
},

如果车辆沿着其当前方向移动,我们首先计算车辆的新位置。然后,我们通过将中心之间的距离与基于车辆半径的特定阈值进行比较,来检查附近是否有任何不可通过的网格方块可能与新位置的车辆发生碰撞。如果碰撞正在发生,我们将它们标记为“硬”碰撞,如果它们即将发生碰撞,我们将它们标记为“软”碰撞。然后,所有碰撞都添加到 collisionObjects 数组中。

然后,我们对车辆阵列重复这一过程,通过使用它们的半径之和作为阈值距离来测试附近的所有车辆的可能碰撞。

现在我们有了一个碰撞对象的列表,我们将修改我们之前定义的默认 moveTo()方法来处理碰撞(见清单 7-20 )。

清单 7-20。 处理碰撞内部默认 moveTo()方法(vehicles.js)

moveTo:function(destination){
    if(!game.currentMapPassableGrid){
        game.rebuildPassableGrid();
    }

    // First find path to destination
    var start = [Math.floor(this.x),Math.floor(this.y)];
    var end = [Math.floor(destination.x),Math.floor(destination.y)];

    var grid = $.extend(true,[],game.currentMapPassableGrid);
    // Allow destination to be "movable" so that algorithm can find a path
    if(destination.type == "buildings"||destination.type == "terrain"){
        grid[Math.floor(destination.y)][Math.floor(destination.x)] = 0;
    }

    var newDirection;
    // if vehicle is outside map bounds, just go straight towards goal
    if (start[1]<0 || start[1]>=game.currentLevel.mapGridHeight || start[0]<0 || start[0]>= game.currentLevel.mapGridWidth){
        this.orders.path = [this,destination];
        newDirection = findAngle(destination,this,this.directions);
    } else {
        //Use A* algorithm to try and find a path to the destination
        this.orders.path = AStar(grid,start,end,'Euclidean');
        if (this.orders.path.length>1){
            var nextStep = {x:this.orders.path[1].x+0.5,y:this.orders.path[1].y+0.5};
            newDirection = findAngle(nextStep,this,this.directions);
        } else if(start[0]==end[0] && start[1] == end[1]){
            // Reached destination grid square
            this.orders.path = [this,destination];
            newDirection = findAngle(destination,this,this.directions);
        } else {
            // There is no path
            return false;
        }
    }

    // check if moving along current direction might cause collision..
    // If so, change newDirection
    var collisionObjects = this.checkCollisionObjects(grid);
    this.hardCollision = false;
    if (collisionObjects.length>0){
        this.colliding = true;

        // Create a force vector object that adds up repulsion from all colliding objects
        var forceVector = {x:0,y:0}
        // By default, the next step has a mild attraction force
        collisionObjects.push({collisionType:"attraction", with:{x:this.orders.path[1].x+0.5,y:this.orders.path[1].y+0.5}});
        for (var i = collisionObjects.length - 1; i >= 0; i--){
            var collObject = collisionObjects[i];
            var objectAngle = findAngle(collObject.with,this,this.directions);
            var objectAngleRadians = -(objectAngle/this.directions)* 2*Math.PI;
            var forceMagnitude;
            switch(collObject.collisionType){
                case "hard":
                    forceMagnitude = 2;
                    this.hardCollision = true;
                    break;
                case "soft":
                    forceMagnitude = 1;
                    break;
                case "attraction":
                    forceMagnitude = -0.25;
                    break;
            }

            forceVector.x += (forceMagnitude*Math.sin(objectAngleRadians));
            forceVector.y += (forceMagnitude*Math.cos(objectAngleRadians));
        };
        // Find a new direction based on the force vector
        newDirection = findAngle(forceVector,{x:0,y:0},this.directions);
    } else {
        this.colliding = false;
    }

    // Calculate turn amount for new direction
    var difference = angleDiff(this.direction,newDirection,this.directions);
    var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;

    // Either turn or move forward based on collision type
    if (this.hardCollision){
        // In case of hard collision, do not move forward, just turn towards new direction
        if (Math.abs(difference)>turnAmount){
            this.direction = wrapDirection(this.direction+ turnAmount*Math.abs(difference)/difference, this.directions);
        }
    } else {
        // Otherwise, move forward, but keep turning as needed
        var movement = this.speed*game.speedAdjustmentFactor;
        var angleRadians = -(Math.round(this.direction)/this.directions)* 2*Math.PI ;
        this.lastMovementX = - (movement*Math.sin(angleRadians));
        this.lastMovementY = - (movement*Math.cos(angleRadians));
        this.x = (this.x +this.lastMovementX);
        this.y = (this.y +this.lastMovementY);
        if (Math.abs(difference)>turnAmount){
            this.direction = wrapDirection(this.direction+ turnAmount*Math.abs(difference)/difference, this.directions);
        }
    }
    return true;
},

在初始寻路步骤之后,我们调用 checkCollisionObjects()方法,并获得车辆将与之碰撞的对象列表。

然后,我们遍历这个对象列表,根据碰撞是“软”还是“硬”,为每个对象定义一个大小为 1 或 2 的排斥力我们还定义了一个对下一个寻路方格的吸引力。最后,我们将所有这些力加到一个 forceVector 对象中,并使用它来计算使车辆离所有力最远的方向,并将它赋给新的 direction 变量。

这意味着,只要没有碰撞物体,车辆就会朝着其路径中定义的下一个网格方块前进。当车辆感觉到碰撞时,它的主要动机将是通过采取规避动作来避免碰撞。一旦避免了碰撞威胁,车辆将返回到其最初的路径跟踪行为。

我们增加了一个额外的检查,以防止车辆向前移动,如果移动将导致严重碰撞。因此,车辆将完全停止,而不是实际上与另一个物体相撞。

如果你现在运行游戏并试图移动一辆车,你会发现它会绕过其他车辆以避免与它们相撞。

您可能会注意到的一个问题是,如果您试图将多辆车移动到同一个地点,第一辆车会停在正确的位置,而其他车会不停地兜圈子,徒劳地试图到达被占用的站点。我们将需要通过增加一些智能来解决这个问题,让车辆处理试图移动到堵塞点的方式。

理想的行为是,如果目的地被阻挡,则在离目的地不远的地方停下来,如果车辆碰撞了很长时间而没有到达目的地,则在更远的地方停下来。

我们将通过修改默认的 processOrders()方法来实现这一点,如清单 7-21 所示。

清单 7-21。 修改 processOrders()处理停止(vehicles.js)

processOrders:function(){
    this.lastMovementX = 0;
    this.lastMovementY = 0;
    switch (this.orders.type){
        case "move":
            // Move towards destination until distance from destination is less than vehicle radius
            var distanceFromDestinationSquared = (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2));
            if (distanceFromDestinationSquared < Math.pow(this.radius/game.gridSize,2)) {
                //Stop when within one radius of the destination
                this.orders = {type:"stand"};
                return;
            } else if (distanceFromDestinationSquared <Math.pow(this.radius*3/game.gridSize,2)) {
                //Stop when within 3 radius of the destination if colliding with something
                this.orders = {type:"stand"};
                return;
            } else {
                if (this.colliding && (Math.pow(this.orders.to.x-this.x,2) + Math.pow(this.orders.to.y-this.y,2))<Math.pow(this.radius*5/game.gridSize,2)) {
                    // Count collsions within 5 radius distance of goal
                    if (!this.orders.collisionCount){
                        this.orders.collisionCount = 1
                    } else {
                        this.orders.collisionCount ++;
                    }
                    // Stop if more than 30 collisions occur
                    if (this.orders.collisionCount > 30) {
                        this.orders = {type:"stand"};
                        return;
                    }
                }
                var moving = this.moveTo(this.orders.to);
                // Pathfinding couldn't find a path so stop
                if(!moving){
                    this.orders = {type:"stand"};
                    return;
                }
            }
            break;
    }
},

如果车辆在目的地的 1 个半径范围内,我们首先尝试在目的地停车。如果车辆发生碰撞,并且在目的地的 3 个半径范围内,我们也会停下来。最后,如果车辆在距离目的地 5 个半径的范围内碰撞超过 30 次,我们就停下来。最后一个条件处理车辆在拥挤的区域颠簸了一段时间而没有找到到达目的地的方法的情况。

如果你现在运行游戏,并试图移动多辆车在一起,你会看到他们智能地停在他们的目的地附近,即使在拥挤的地区。

在这一点上,我们有一个相当好的智能单元运动的寻路和转向解决方案。这个系统可以进一步开发,以提高性能和增加其他智能行为,如排队,成群结队,领导跟随,这取决于你的游戏要求。当你在自己的游戏中实现单位运动时,你一定要进一步研究这个话题,从克雷格·雷诺兹(www.red3d.com/cwr/steer/)的作品开始。

现在我们已经有了车辆运动,让我们花点时间来实现另一个与运动相关的命令:部署收割机。

部署收割机

我们将收割机设计为可展开的车辆,当部署在油田上时,它可以打开进入收割机建筑。我们已经设置了代码,当玩家右键单击油田时,将部署命令传递给收割机。现在我们将在 vehicles.js 中的 vehicle 对象的 processOrders()方法中实现 deploy case,如清单 7-22 所示。

***清单 7-22。***process orders()(vehicles . js)内部部署案例的实现

case "deploy":
    // If oilfield has been used already, then cancel order
    if(this.orders.to.lifeCode == "dead"){
        this.orders = {type:"stand"};
        return;
    }
    // Move to middle of oil field
    var target = {x:this.orders.to.x+1,y:this.orders.to.y+0.5,type:"terrain"};
    var distanceFromTargetSquared = (Math.pow(target.x-this.x,2) + Math.pow(target.y-this.y,2));
    if (distanceFromTargetSquared<Math.pow(this.radius*2/game.gridSize,2)) {
        // After reaching oil field, turn harvester to point towards left (direction 6)
        var difference = angleDiff(this.direction,6,this.directions);
        var turnAmount = this.turnSpeed*game.turnSpeedAdjustmentFactor;
        if (Math.abs(difference)>turnAmount){
            this.direction = wrapDirection(this.direction+turnAmount*Math.abs(difference)/difference,this.directions);
        } else {
            // Once it is pointing to the left, remove the harvester and oil field and deploy a harvester building
            game.remove(this.orders.to);
            this.orders.to.lifeCode="dead";
            game.remove(this);
            this.lifeCode="dead";
            game.add({type:"buildings", name:"harvester", x:this.orders.to.x, y:this.orders.to.y, action:"deploy", team:this.team});
        }
    } else {
        var moving = this.moveTo(target);
        // Pathfinding couldn't find a path so stop
        if(!moving){
            this.orders = {type:"stand"};
        }
    }
    break;

我们首先使用 moveTo()方法将收割机移动到油田的中间。一旦收割机到达油田,我们使用 angleDiff()方法将收割机转向左侧(方向 6)。最后,我们从游戏中移除矿车和油田物品,并在油田位置添加一个矿车建筑,动作设置为部署。

如果我们运行我们的游戏,选择矿车,然后右键单击一个油田,我们应该看到矿车移动到油田并部署到一个建筑中,如图图 7-5 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-5。收割机部署到收割机建筑中

矿车移动到油田,转入位置,然后似乎扩展成一个矿车建筑。如您所见,有了移动框架,处理不同的订单变得非常容易。

在我们结束单位运动之前,我们将解决最后一件事。你可能已经注意到了单位的移动,特别是像幽灵这样的快速单位,看起来有点起伏不定。我们将努力使这个单位运动平稳。

更平滑的单位运动

我们的游戏动画循环目前以稳定的每秒 10 帧的速度运行。尽管我们的绘制循环运行得更快(通常每秒 30 到 60 帧),但在这些额外的循环中它没有新的信息要绘制,所以实际上它也以每秒 10 帧的速度绘制。这导致了我们看到的起伏不定的运动。

使动画看起来更平滑的一个简单方法是在动画帧之间插入车辆运动。我们可以计算自上次动画循环以来的时间,并使用它来创建插值因子,该因子用于在中间绘制循环期间定位单元。这个小小的调整将使单位看起来以更高的帧速率移动,即使它们实际上仅以每秒 10 帧的速度移动。

我们将首先修改游戏对象的 animationLoop()方法来保存最后的动画时间,并修改 drawingLoop()方法来根据当前绘制时间和最后的动画时间计算插值因子。animationLoop()和 drawingLoop()的最终版本将类似于清单 7-23 。

清单 7-23。 计算一个运动插值因子(game.js)

animationLoop:function(){
    // Process orders for any item that handles it
    for (var i = game.items.length - 1; i >= 0; i--){
        if(game.items[i].processOrders){
            game.items[i].processOrders();
        }
    };

    // Animate each of the elements within the game
    for (var i = game.items.length - 1; i >= 0; i--){
        game.items[i].animate();
    };

    // Sort game items into a sortedItems array based on their x,y coordinates
    game.sortedItems = $.extend([],game.items);
    game.sortedItems.sort(function(a,b){
        return b.y-a.y + ((b.y==a.y)?(a.x-b.x):0);
    });

    //Save the time that the last animation loop completed
    game.lastAnimationTime = (new Date()).getTime();
},
drawingLoop:function(){
    // Handle Panning the Map
    game.handlePanning();

    // Check the time since the game was animated and calculate a linear interpolation factor (-1 to 0)
    // since drawing will happen more often than animation
    game.lastDrawTime = (new Date()).getTime();
       if (game.lastAnimationTime){
           game.drawingInterpolationFactor = (game.lastDrawTime-game.lastAnimationTime)/game.animationTimeout - 1;
           if (game.drawingInterpolationFactor>0){ // No point interpolating beyond the next animation loop ...
               game.drawingInterpolationFactor = 0;
           }
       } else {
        game.drawingInterpolationFactor = -1;

    }

    // Since drawing the background map is a fairly large operation,
    // we only redraw the background if it changes (due to panning)
    if (game.refreshBackground){
        game.backgroundContext.drawImage(game.currentMapImage, game.offsetX, game.offsetY,game.
canvasWidth, game.canvasHeight, 0, 0, game.canvasWidth, game.canvasHeight);
        game.refreshBackground = false;
    }

    // Clear the foreground canvas
    game.foregroundContext.clearRect(0,0,game.canvasWidth,game.canvasHeight);

    // Start drawing the foreground elements
    for (var i = game.sortedItems.length - 1; i >= 0; i--){
        game.sortedItems[i].draw();
    };

    // Draw the mouse
    mouse.draw();

    // Call the drawing loop for the next frame using request animation frame
    if (game.running){
        requestAnimationFrame(game.drawingLoop);
    }
},

我们在 animationLoop()方法的末尾将当前时间保存到 game.lastAnimationTime 中。然后,我们使用这个变量和当前时间来计算 game . drawingininterpolationfactor 变量,它是一个介于-1 和 0 之间的数字。值-1 表示我们在先前的位置绘制单元,而值 0 表示我们在当前位置绘制单元。-1 和 0 之间的任何值都意味着我们在两点之间的中间位置绘制单位。我们将该值限制在 0,以防止任何外推的发生(即,将单元绘制到它已经被动画化的点之外)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意在多人第一人称射击游戏中,使用外推和客户端预测等技术来定位单位更为常见,以补偿由于高延迟造成的滞后。

现在我们已经计算了插值因子,我们将使用它和单位 lastMovementX 和 lastMovementY 值在绘制时定位元素。首先,我们将修改 aircraft.js 中 aircraft 对象的默认 draw()方法,如清单 7-24 所示。

清单 7-24。 绘制飞机时插补运动(aircraft.js)

draw:function(){
    var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX + this.lastMovementX*game.drawingInterpolationFactor*game.gridSize;
    var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY-this.pixelShadowHeight +
this.lastMovementY*game.drawingInterpolationFactor*game.gridSize;
    this.drawingX = x;
    this.drawingY = y;
    if (this.selected){
        this.drawSelection();
        this.drawLifeBar();
    }
    var colorIndex = (this.team == "blue")?0:1;
    var colorOffset = colorIndex*this.pixelHeight;
    var shadowOffset = this.pixelHeight*2; // The aircraft shadow is on the second row of the sprite sheet

    game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.
pixelWidth,colorOffset,this.pixelWidth,this.pixelHeight,x,y,this.pixelWidth,this.pixelHeight);
    game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.
pixelWidth,shadowOffset,this.pixelWidth,this.pixelHeight,x,y+this.pixelShadowHeight,this.
pixelWidth,this.pixelHeight);
}

我们所做的唯一改变是在 x 和 y 坐标计算中加入了与外推相关的项。接下来,我们将对 vehicles.js 中车辆的默认 draw()方法进行同样的更改(参见清单 7-25 )。

清单 7-25。 绘制车辆时插补运动(vehicles.js)

draw:function(){
    var x = (this.x*game.gridSize)-game.offsetX-this.pixelOffsetX + this.lastMovementX*game.drawingInterpolationFactor*game.gridSize;
    var y = (this.y*game.gridSize)-game.offsetY-this.pixelOffsetY + this.lastMovementY*game.drawingInterpolationFactor*game.gridSize;
    this.drawingX = x;
    this.drawingY = y;

    if (this.selected){
        this.drawSelection();
        this.drawLifeBar();
    }

    var colorIndex = (this.team == "blue")?0:1;
    var colorOffset = colorIndex*this.pixelHeight;

    game.foregroundContext.drawImage(this.spriteSheet, this.imageOffset*this.pixelWidth,colorOffset,
           this.pixelWidth,this.pixelHeight,x,y,this.pixelWidth,this.pixelHeight);
}

如果我们运行游戏并四处移动单位,移动现在应该比以前更平滑了。有了这最后一个变化,我们现在可以认为单位运动结束了。

摘要

在这一章中,我们为我们的游戏实现了智能单位运动。

我们从开发一个框架开始,给选定的单位命令,然后让实体执行命令。

我们通过将飞机直接移向目的地来实现移动命令,通过将 A*用于寻路,将排斥力用于转向来实现车辆的移动命令。然后,我们使用我们开发的移动代码实现了矿车的部署命令。

最后,我们通过在绘图代码中集成一个插值步骤来平滑单元移动。

在下一章,我们将实现更多的游戏规则:建造和放置建筑,从星际港口传送车辆和飞机,以及收获金钱。所以,我们继续吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值