塔防小游戏的开发设计

2,589 阅读9分钟

来由

前段时间我也不知道哪来的勇气,竟然雄心勃勃要设计一款拯救泡泡鱼的小游戏,梦想着流量暴涨的美好远景,动手设计实现了好几种玩法,找了批朋友玩了下,原本满怀期望却被吐槽声淹没了。沉寂了一段时间,有天突然发现微信朋友圈里一直出现塔防小游戏的广告,心血来潮,要不也弄个塔防游戏试试。这次我学乖了,网上找了个现成的塔防小游戏叫《Field Runner》,决定按照它的思路重新实现了一遍。

本篇文章通过讲解copy一款塔防游戏的经历,讲解里也包含着一些设计思路,希望能帮到想要开发小游戏的爱好者们。游戏一些代码以及设计思想借鉴了其它作者的,我指出这个只是想告诉大家学习别人的作品是游戏入门的捷径。

游戏参考图:

image

说明

  1. 文章里代码示例基于egret游戏引擎,我会尽量减少使用引擎api带来的阅读影响。若仍不是很清楚,请访问egret引擎官方网站
  2. 讲解代码使用的语言是typescript,代码中egret对象是由游戏引擎提供。
  3. 文章里的代码只是示例,只保留了关键代码,不能保证正确运行。

设计

记得大学期间经常喜欢跟室友一起玩塔防游戏,这个游戏里玩家建造各种防御塔抵御敌人一批一批的进攻,守护好基地。 每次开局看到大批敌人进攻,我就拼命的造防御塔,不把屏幕填满无法抚慰我不安的心。

我们回头看下上面的游戏场景,一群敌人士兵从左边出发进攻基地,玩家在地图各个位置几乎满屏放置了大量的格林机枪炮塔,拦截打击阻止敌人进攻趋势,期间有敌人不断爆炸死亡。游戏上一栏是金币、分数、游戏轮次、玩家(基地)血量,下一栏是暂停、快进、武器种类。

接下来我们一步一步的设计这款单机游戏。

开始

每次开发小游戏,我喜欢先在游戏场景里添加一张背景图片,刷新后看到图片是一件开心的事情,而且我还可以调整UI显示适配。

// Main.ts
class Main extends eui.UILayer {
    //代码省略...
    // 在游戏里添加一个背景
    // Map类是自定义的一个地图类
    protected createGameScene(): void {
        this.addChild(new Map());
    }
}

地图的实现思路

接下来开始实现地图Map类了。根据上面的游戏场景,我需要在地图上标记好几个位置,首先想到了代码写死坐标的方式(代码示例):

// Map.ts
// 说明:以下坐标随意写
class Map extends egret.DisplayObjectContainer {
    //代码省略...
    // 敌人起始坐标
    public static startPoint:[number, number] = [1, 11];
    // 地图高度和宽度
    public static tileWidth:number = 800;
    public static tileHeight:number = 500;
    // 地图瓦片数,格子个数 行 20,列 10
    public static mapWidth:number = 20;
    public static mapHeight:number = 10;
    // 武器选择栏坐标,当前就定义一种武器类型
    public static weaponPoinit:[number, number] = [100, 111];
    // 暂停按钮
    public static stopPoinit:[number, number] = [10, 111];
    // 显示金币位置
    public static moneyPoinit:[number, number] = [10, 10];
    // 显示分数位置
    public static scorePoinit:[number, number] = [30, 10];
    // 显示玩家生命值
    public static lifePoinit:[number, number] = [80, 10];
    // 代码省略... 
}

这些坐标代表的含义已经很清楚了,然后将各个坐标上添加对应的图标。比如我现在添加一个武器选择的一个图标(代码示例):

// Weapon.ts
private onAddToStage() {
  this.gatingdIcon = this.createBitmapByName("gatingdIcon_png");
  this.gatingdIcon.x = Map.weaponPoinit[0];
  this.gatingdIcon.y = Map.weaponPoinit[1];
  this.gatingdIcon.width = 100;
  this.gatingdIcon.height = 100;
  // 将图标添加到父层上
  this.parent.addChild(this.gatingdIcon);
}

这种代码写死坐标的方式简单,但是需要去调整位置,如果以后换地图,还得重新调整计算各个精灵的位置,不利于扩展。后面我改用Tiled map,这是个2d地图编辑器,具体可查看官方地址。在tiled编辑器上我可以随心标记各个图标的坐标信息并命名,然后游戏里加载导出的地图文件后,可直接根据名称查找坐标,还可以设计各种复杂的地形。

限于篇幅,地图编辑器的使用我这边不做分享,网上有很多教程,用起来非常简单。

导出的地图文件需要开发者自己解析,不过egret引擎提供了第三方解析库,我只要按照引擎文档简单修改下就可引入使用。
在Map类中,我对外提供了getMapObj 方法,用来获取tiled地图上我设计好的标记。该方法有两个参数,第一个表示标记所属的类别名称,第二个表示标记名称(代码示例):

// Map.ts
class Map extends egret.DisplayObjectContainer {
    public static tmxTileMap: tiled.TMXTilemap;
    
    public static getMapObj(parentName:string, targetName: string) {
        let toolMap:any = Map.tmxTileMap.getChildByName(parentName);
        let childrens = toolMap._childrens || [];
        let targetObj;
        childrens.map(child => {
            if (child.$name == targetName) {
                targetObj = child;
            }
        });
        return targetObj;
    }
}

还是用武器选择的图标来说明,在tiled地图中,我创建了一个叫'tool'的父层(类别),然后在这个层下创建了一个叫'gatingdIcon'的层。
接下来在场景里添加武器选择图标(代码示例):

// Weapon.ts
private onAddToStage() {
  const tagetMap = Map.getMapObj('tool', 'gatingdIcon');
  if (tagetMap) {
      this.gatingdIcon = this.createBitmapByName("gatingdIcon_png");
      this.gatingdIcon.x = targetMap.$x;
      this.gatingdIcon.y = targetMap.$y;
      this.gatingdIcon.width = targetMap.$width;
      this.gatingdIcon.height = targetMap.$height;
      // 将图标添加到父层上, this.parent 我外部传入的
      this.parent.addChild(this.gatingdIcon);
  }
}

其它图标的坐标信息也按照上面来获取,不再复述。

在游戏里,敌人士兵行走区域以及武器放置区域有范围限制,比如地图边界、障碍物。Tiled导出的是瓦片地图,就是说地图有两种坐标可以表示游戏元素的位置,瓦片格子坐标和像素XY坐标。

武器放置区域的设计思路

刚刚讲过,地图是被分割成一个个小方块(瓦片)的,武器放置的地方应该就是1个格子(因为我的武器就一个格子大),那我只需要获取当前武器移动时所在的地图格子坐标即可判断是否放置。

获取手指(或鼠标)所在的格子坐标(代码示例):

// x和y,表示当前手指(或鼠标)的坐标
private getAvailablePositionNearby(pointX:number, pointY:number) {
    // tx,ty表示格子坐标,我就四舍五入了
    const tx = Math.round(pointX - startPointX / tileWidth);
    const tx = Math.round(pointY - startPointY / tileHeight);
    // x,y表示实际格子的像素坐标(相对舞台)
    const x = startPointX + tx * tileWidth;
    const y = startPointY + ty * tileHeight;
    return {x: x, y: y, tx: tx, ty: ty};
}

通过上面获取到武器当前放置的坐标后,直接赋值给当前拖动的武器(代码示例):

// 放置武器
private placeWeapon(weapon:Gatling) {
    const point = this.getAvailablePositionNearby(pointX, pointY);
    if (point) {
        this.dragWeapon.x = point.x;
        this.dragWeapon.y = point.y;
        this.dragWeapon.tx = point.tx;
        this.dragWeapon.ty = point.ty;
    }
}

但前面说过,实际地图上有很多地方是不允许放置武器的,比如地图边界、障碍物、该格子已经放置了武器、无道路等这些情况都需要判断(代码示例):

private allowBoolean(point) {
    let bool = false;
    if (point) {
        if (
          (point.tx==0 && point.ty==1) ||
          point.tx < 0 ||
          point.tx > mapWidth ||
          point.ty < mapHeight ||
          // 这个格子玩家已经放置武器了
          this.player.getWeaponAt(point.tx, point.ty) ||
          // 这个格子不在敌人可行军道路上(寻路算法生成)
          !this.player.buildPath(point.tx, point.ty)
        ) {
            bool = false;
        } else {
            bool = true;
        }
    }
    return bool;
}

敌人士兵行走区域的设计思路

先思考下敌人的行走路线:敌人是从起始地一波一波的发起进攻,行军过程中,遇到武器塔防得绕道,不能超出地图边界,到玩家基地后消失。故编辑地图时,标记好敌人的起始地(出生地)位置、玩家基地位置。

代码里获取位置(代码示例):

private setPoint() {
    const startMap = Map.getMapObj('soldierBirthPool', 'pointStart');
    const endMap = Map.getMapObj('soldierBirthPool', 'pointEnd');
    this.startPoint = [parseInt(startMap.$x, 10), parseInt(startMap.$y, 10)];
    this.endPoint = [parseInt(endMap.$x, 10), parseInt(endMap.$y, 10)];
}

刚刚说过,敌人行走是从一个点到另一个点,中间行军路线会因遇到武器塔防而改变,这个一般通过寻路算法来实现。
要实现寻路算法,依赖一种数据结构-图。游戏中背景图并没有存在清晰的道路规划,故可以将整个地图分割成一个一个的小格子,在某一个格子上的敌人行军,只能往上下左右四个方向在格子中移动。图是由顶点和边组成的,所以把每个格子看成一个顶点,两个相邻格子之间,连两条有向边,边的权值为1。寻找最接近最短路径的路线,使用Astar算法来实现,同时计算顶点之间的距离的启发式函数,这里使用曼哈顿距离(两个点之间横纵坐标的距离和),计算简单耗时少。

曼哈顿距离计算(示例代码):

private manhattan(start, end) {
    return Math.abs(start.x - end.x) + Math.abs(start.y - end.y);
}

了解了这方面知识的后,并非要去实现A*算法,这样开发成本划不来。游戏中我就引入了现成的Astar算法库,根据上面所述,启发式函数选择了曼哈顿距离计算,接下来就是构造图了。

由于地图实际上已经被分割成格子(瓦片),故简单处理即可。这里需要说明下,游戏里得给每个敌人士兵设计好路线,而且随着玩家在地图上添加武器防御塔,以及不断前进后,路线需要实时更新,直到敌人不在地图上才无需更新。

生成路线集合(代码示例):

// 生成路径坐标集合
public buildPath(tx?, ty?, start?, end?) {
    // 图
    let map = [];
    // 当前敌人起始坐标
    let s = start;
    // 当前敌人终点坐标
    let e = end;
    if (!s || s[0] < 0) {
        s = this.startPoint;
    }
    if (!end) {
        e = this.endPoint;
    }
    // 遍历地图格子,生成集合
    for (let i = 0; i < this.mapHeight; i++) {
        map[i] = [];
        for (let j = 0; j < this.mapWidth; j++) {
            let hasWeaponAt = this.getWeaponAt(j, i);
            // 说明:遍历地图,若发现该格子上有武器防御塔,那么设置为 1,否则设置为0 。即 1表示该格子敌人不能走
            map[i][j] = hasWeaponAt ? 1 : 0;
        }
    }
    // 若外面传入指定区域,该区域值设置为 1, 无法行走
    if (tx || ty) {
       map[ty][tx]= 1;
    }
    // Astar算法的使用,查找最接近优解的路径集合
    let pathArr = Astar.findPath(map, s, e) || [];
    
    return pathArr;
}

通过调用buildPath方法,设置好士兵的路径后,随着游戏帧率的变动,不断更改士兵的像素坐标,士兵就往前移动了。在生成图的原理讲过,在某一个格子上的敌人行军,只能往上下左右四个方向在格子中移动。敌人移动(坐标变动)过程中,当走完所在的格子时,得判断接下来行走的方向(上下左右),其实就是遍历敌人的路径(瓦片)集合,通过找到当前行走所在的格子位置,找到下一个临近的格子。

查找敌人下一个行走的格子,即行走方向(代码示例):

 // solider 士兵 
 private getNextDirection(solider:Soldier) {
   for(let i = 0; i < solider.path.length - 1; i++) {
       let path = solider.path[i];
       // 查找到当前士兵行走的格子位置,这样就可以确定下个格子的位置 i+1
       if (path[0] == solider.tx && path[1] == solider.ty) {
           // next:下一个行走的格子(瓦片)
           let next = solider.path[i+1];
           return [next[0]-solider.tx, next[1]-solider.ty];
       }
   }
   return null;
 }

敌人士兵获取到的行军路线,是一个个格子坐标集合,实际士兵不可能从一个格子里瞬移到下一个格子,士兵是有行军速度的,为了达到平滑的行军效果,需要先判断敌人是否已走完所在格子,然后根据行军速度和行军方位设置敌人xy坐标。

首先定义好士兵的行军方向表示,当行军方向改变,士兵行军动画方向也随着改变(代码示例):

// Soldier.ts
// direction[0]值表示士兵左右方向行军,direction[0] === 1 右边行军,direction[0] === -1 左边行军
// direction[1]值表示士兵左右方向行军,direction[1] === 1 下边行军,direction[1] === -1 上边行军
// 等于 0 表示停止行军
public direction: [number, number] = [1,0];
// avatar 角色 它可以代表角色的位置、方向、运动状态和姿势
public avatar: egret.MovieClip;
// 士兵的行军速度
public speed:number = 0:
private setDirection() {
    // 代码省略...
    if (this.direction[0] == 1) {
      // 往右边行军,播放对应的动画,gotoAndPlay 是egret的播放帧动画的方法
      this.avatar.gotoAndPlay("solider_walk_right", -1);
    }
    // 代码省略...
}

根据获取的行军方向值乘以行军速度,坐标值累加设置士兵的xy坐标(代码示例):

// Player.ts
// 根据移动方位设置敌人士兵的xy坐标
private moveByDirection(solider:Soldier) {
   if (!solider.direction) {
       return;
   }
   if (solider.direction[0] != 0) {
       solider.x += solider.speed * solider.direction[0];
   } else
   if (solider.direction[1] != 0) {
       solider.y += solider.speed * solider.direction[1];
   }
}

// 士兵移动
private moveSoldier(solider:Soldier) {
    // direction[0] !=0 表示目标在x轴方向移动
    if (solider.direction[0] != 0) {
        // 格子是否走完
        let dx = target.x - ( this.startPoint[0] + tile[0] *  this.tileWidth );
        if (dx === 0) {
            // 根据移动方位设置敌人士兵的xy坐标
            solider.setDirection(this.getNextDirection(target));
        }
    }
    // 代码省略...
}

上面 direction[0] 需要说明下,由于敌人行军的格子之间是相邻的(看上面数据结构图的解释),故敌人要么左右方向移动,要么上下方向移动,不会出现xy轴同时移动的情况。

至此,敌人士兵行走区域的设计实现已讲完。这块设计的几个主要点:

  1. 分析敌人行走策略,利用Astar寻路算法获取行军路线;
  2. 将地图分割成格子,构造寻路算法所需的数据图;
  3. 设计敌人的行走方式,行军方向获取方式通过计算下一个行走格子瓦片坐标减去当前行走格子瓦片坐标,即 [next[0]-solider.tx, next[1]-solider.ty];
  4. 敌人平滑行军的实现设计;

武器防御塔的实现思路

地图设计好后,接下来开始设计在地图上添加武器防御塔。为了提高游戏趣味,游戏里设计了多种武器供玩家选择,本文就讲解机关炮武器的设计思路。机关炮大家都比较熟悉,是一种能连续自动射击的武器,而且一般固定在一个底盘上。地图设计章节讲过,游戏里士兵上下左右四个方向可行走,那么机关炮的底盘也要支持360度旋转,而且武器旋转角度是跟着士兵移动而改变的。

首先在地图右下角的武器栏,添加图标(示例代码):

private onAddToStage() {
    this.gatingdIcon = this.createBitmapByName("gatingdIcon_png");
    const targetMap = Map.getMapObj('tool', 'gatingdIcon');
    if (targetMap) {
        this.gatingdIcon.x = targetMap.$x;
        this.gatingdIcon.y = targetMap.$y;
        this.gatingdIcon.width = targetMap.$width;
        this.gatingdIcon.height = targetMap.$height;
    }
    this.parent.addChild(this.gatingdIcon);
}

再添加触摸监听事件(示例代码):

private onAddToStage() {
    // 省略代码...
    // 移动
    this.stage.addEventListener(egret.TouchEvent.TOUCH_MOVE, this.touchMoveHandler, this);
    // 开始(按下 -down)
    this.stage.addEventListener(egret.TouchEvent.TOUCH_BEGIN, this.touchBeginHandler, this);
    // 结束(离开 -up)
    this.stage.addEventListener(egret.TouchEvent.TOUCH_END, this.touchEndHandler, this);
}

选择图标后,就会触发移动武器的效果,放开后将武器添加到地图上,放置武器位置实现在上节的'武器放置区域的设计思路'里已讲解过。

添加好武器后,接下来设计武器的属性。

攻击力

最简单的就是给武器设置一个恒定的攻击力:

public damage:number = 10;

然后士兵受到攻击后,生命值减去伤害值:

// 士兵中弹
public getShot(damage:number) {
    // 中弹后扣掉生命值
    this.health -= damage;
}

现实中士兵中弹后所受伤害严重程度不是固定的,游戏中通过设计武器浮动攻击力来模拟实现,设置一个最大伤害,再设置一个最小伤害,随机取值:

private maxDamage: number = 20:
private minDamage: number = 10;
// 随机获取攻击力值
public getDamange() {
    return Math.round(Math.random()*(this.maxDamage - this.minDamage)) + this.minDamage;
}

攻击范围

先给武器赋予固定的攻击范围:

public attackRadius: nunmber = 200;

然后检测是否有敌人进入攻击范围:

要判断敌人是否进入武器的攻击范围,在平面直角坐标系中计算出武器和士兵的距离,然后距离值跟攻击范围相比,距离计算的数学公式如下:

image

实现检测函数(示例代码):

public isInAttackRadius(solider: Solider) {
    // 计算武器和士兵的距离(在平面直角坐标系中用两点间距离公式)
    const dx = solider.x - this.x;
    const dy = solider.y - this.y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    return distance <= this.attackRadius;
}

子弹攻击到敌人的判断

之前讲过,游戏里武器底盘是随着敌人移动,可以360度自动旋转的。当敌人进入攻击范围后,武器自动旋转指向敌人,然后开火。

武器开火流程如下:

image

击中敌人也是有时间延迟的,为了模拟该场景,开火时间大于某个毫秒值后表示击中敌人了,开火后时间变量重置为0,同时重置武器转动时间。

部分实现(示例代码):

// 检测是否击中,加了个300ms的判断,模拟
private checkShot() {
    if (this.fireTime > 300) {
        this.fireTime = 0;
        this.turnTime = new Date().getTime();
        return true;
    }
    return false;
}
// 
public hit(soldier: Soldier) {
    let status;
    // 武器跟士兵的角度,武器默认指向左边(+180)
    const angle = (180 / Math.PI) * Math.atan2(dy, dx) + 180;
    // 帧,假设每帧等于10个角度
    const frame = Math.round(angle/10);
    const hitBool = this.isInAttackRadius(soldier);
    this.status = status;
    this.currentAngleFrame = frame;
    
    if (hitBool) {
        if (this.status === 'idle') {
            this.trunTime = new Date().getTime();
            this.fireTime = 0;
        } else {
            this.fireTime = new Date().getTime() - this.trunTime;
        }
        return this.checkShot();
    }
    return false;
}

说明:角度的计算通过atan2函数,并非atan函数,atan函数对(y/x)、(-y/-x)是没办法区分的,算出来的角度不是实际的值。

武器升级

游戏中,玩家可以通过花费一定的游戏币升级武器,升级越高所花费的游戏币越多, 升级后武器的各项属性也随着提高(示例代码):

// 一般武器等级是有限制的
private canUpgrade() {
    return this.level < 8;
}
public upgrade() {
    if (!this.canUpgrade()) {
        return false;
    }
    this.level ++;
    this.cost += 20;
    this.minDamage += 12;
    // 代码省略...
}

当玩家点击某个武器升级按钮后,就从游戏币里扣除当前武器的升级所需的价钱。

其它

每个武器都是自动追踪一个敌人士兵的,代码要做些简单修改,在玩家类里,需要做些简单的判断,自动给武器赋予目标:


public autoAttack() {
    // 省略代码...
    if (weapon.solider === null 
      || !weapon.checkInAttackRadius(solider)
      || (weapon.solider.x >= (this.stage.stageWidth + weapon.width))
    ) {
        weapon.solider = this.findSolider(weapon);
    }
}

敌人士兵的实现思路

士兵主要就几个点:行军路线、行军速度、生命值、生命条、死亡爆金币、等级提升。
士兵的部分实现其实跟武器差不了多少,其最主要的设计难点就是行军,本文已在地图章节做了详细讲解。故关于士兵的设计不再做详细介绍了。

受到伤害后血条的变化:

public getShot(damage:number) {
    // 中弹后扣掉生命值
    this.health -= damage;
    // 当扣除生命值小于0时,生命值重新设置为0.(伤害大小不一定)
    if (this.health < 0) {
        this.health = 0;
    }
    const percent = this.health / this.maxHealth;
    // 更新生命血条,四舍五入,默认血条长度为60
    let healthWidth = Math.round(60 * percent);
    // this.healthBar 士兵的血条图标
    if (this.healthBar) {
        this.healthBar.width = healthWidth;
    }
}

游戏积分、游戏币、轮次、结束

积分游戏币

public autoAttack() {
    if (solider.isDead()) {
        // 积分
        this.score += solider.score;
        // 游戏币
        this.money += solider.money;
    }
}

轮次

轮次就是每轮士兵都出现后,轮次加一。

结束

结束就是玩家生命值为零的时候。

当敌人进攻到基地后,玩家扣除生命值:

public autoAttack() {
    // 省略代码...
    if (solider.x >= this.stage.stageWidth + solider.width) {
        this.life-- ;
    }
}

玩家类的设计

上面所有的设计,都是在玩家类整合的,实际文章已经讲解了大量的例子,留给大家设计吧。

总结

游戏的设计围绕玩家类,总结里就画了个图,希望能帮助理解思路:

image

@作者:白云飘飘(534591395@qq.com)

@github: github.com/534591395 欢迎关注我的微信公众号:

微信公众号
或者微信公众号搜索 新梦想兔,关注我哦。