从0开始用CocosCreator写个合成大西瓜(附完整版源码)

5,381 阅读12分钟

这两天玩了下合成大西瓜,来了热情,觉得自己也可以写个。以前没写过游戏,当然作为一个有无限三分钟热度的程序员还是刷过Cocos Creator是啥的。所以折腾了一个,从入门到人生的第一个小游戏。开整:

完整源码和预览

github.com/yieio/daxig…

预览地址

采集素材

合成大西瓜比翼互动使用Cocos Creator v2.2.2做的一款H5小游戏。这是个很厉害的团队,每周都会出一款休闲小游戏,如果你看下他们之前的其他的游戏就会发现做出合成大西瓜是一步一步做到的,在这个游戏之前他们做了一个类似的小游戏叫球球合合,就元素不一样,玩法几乎是一样的。为了尊重别人的劳动,声明下,本文采集的该小游戏的素材都属于该团队所有,这里只做学习练习使用。

Chrome打开游戏g.vsane.com/game/552/,F…]([chrome666.oss-cn-beijing.aliyuncs.com/Save_All_Re…])

image

在chrome://extensions/中安装后重启Chrome可以看到开发者工具中会多了一个ResourcesSaver的标签,最后一个选项Include all assets by XHR requests 需要勾选下,然后 Save All Resources 就可以了。

image

所有的图片和音效素材都在res目录里面了。src目录里面是程序的逻辑代码project.js。

安装CocosCreator

CocosCreator下载地址。本文用的是v2.4.3版本,这里下载的DashBoard相当于是一个CocosCreator的版本和项目管理器,下载安装后,还需要再下载安装对应版本的CocosCreator编辑器。

如果你像我一样之前完全没有接触过CocosCreator强烈建议你先跟这个官方文档快速上手:制作第一个游戏做一遍。熟悉下Cocos Creator涉及的功能和概念。

搭建游戏界面

有了素材我们开始搭建游戏界面,界面很简单就一个背景图,一个底部图片,然后是计分的数字。出水果的位置我们后面再说。

创建一个新项目,这里选的是Helloworld-typescript的项目模板,顺便熟悉下TypeScript,后面发现这个选择还是比较明智的,原来js代码一多,类型声明限制不好,Api没啥提示头都大。TypeScript这些方面都很友好。用起来也很顺手。

image

现在我们有了一个项目,把我们前面采集的素材先导入进来,在上面的res\raw-assets目录中搜索下,把*.png和 *.mp3文件都复制到项目的assets目录里面就好了。

设置背景图片和底部桌子图片

  1. 选中Canvas节点,在右侧的属性检查器中将Design Resolution 设置成720x1280,去掉Fit Height选择Fit Width,选择宽度适配是因为我们做的游戏要左右两边为边界对齐屏幕的,高度其实不那么重要,当然后面也会说怎么适配高度的问题。
  2. 选中Canvas右键新建一个空节点,在属性检查器中改下名字bgLayer。
  3. 把资源管理器中的背景图片拖动到这个节点下面,选中改个名字wall。
  4. 把底部的桌子图片也拖动到bgLayer节点下面,改个名字down。设置属性如下:

这里原图片高度是127px 的,设置的高度是500px,并且选中了SLICED类型,是为了适配高度的时候,底部不会漏出背景。选了SLICED类型,我们要指定下图片的拉伸区域,不然整个图片都会被拉伸,点击Sprite Frame 旁边的编辑,如下图,完成后点绿勾就可以了。

到这里,界面主体部分的背景和底部已经搭建好了。但是直接跑起来看下会发现在iPhoneX的模拟器下上部是黑边。这里直接使用代码来让背景图全屏:

  1. 在资源管理器 assets\Script目录右键,创建一个 TypeScript 脚本 FillScreen,用[VsCode]编辑器打开,内容如下:
@ccclass
export default class FillScreen extends cc.Component {

  onLoad() {
    this.node.setContentSize(cc.winSize.width, cc.winSize.height);
  }

  start() {} 
}
  1. 选中[层级管理器]中的wall节点,在[属性检查器]中,添加组件->用户脚本组件,选中刚刚编写的脚本。这样背景图就全屏填充了。

设置分数面板

分数面板放置在界面的左上角,这里使用的是Label(文字),在Canvas下创建一个scoreLabel节点。字体用的是艺术字体,制作方法是使用下面这个数字图片新建一个艺术数字图集。在[资源管理器] 中右键新建->艺术数字配置,然后点选新建的艺术数字配置,查看[属性检查器],把下面这张图片拖动到RawTextureFile 的输入框中-拖动的时候不要变成点选图片了,不然选中项就变了。

设置的值如下:

制作好了字体,点选scoreLabel节点,设置[属性检查器],font就是我们刚刚制作的字体。

分数面板放置好了,但是预览下,在iPhoneX下会发现距离顶部的距离不是我们想要的效果

这里还是我们上面的步骤中使用程序改变背景填充黑边导致的相对定位问题,我们也需要通过脚本来自动调整分数面板距离顶部的距离。

我们新建一个脚本 AdjustWithHeight,代码如下:

export default class AdjustWithHeight extends cc.Component {

       @property
    offset:number = 0;

    //是否显示入场动画
    @property
    hasShowEffect:boolean = false;

    onLoad () {
        let start = 0;
        start = cc.winSize.height / 2;
        this.node.y = start;
        if(!this.hasShowEffect){
            this.node.y += this.offset;
        }
    }  

    start () {
        this.showTheNode(); 
    }

    showTheNode(){ 
        if(this.hasShowEffect){
            //从上往下滑动入场
            this.node.runAction(cc.moveTo(.5, 
            cc.v2(this.node.x, 
            this.node.y + this.offset)).easing(cc.easeBackOut())); 
        }
    }

}

将脚本作为组件添加到scoreLabel节点上,上面的脚本留了一个offset参数,是可以设置的,现在相当于将节点置顶放置了,要预留边距,offset需要设置为一个负值,不把offset直接写上值是因为一会其他节点也需要使用这个脚本来控制到顶部的距离。

添加水果

场景都搭的差不多了,现在该制作顶部出水果的节点,这里的主要功能是用户点击后随机出一个水果,然后落下。

添加topNode空节点,给该节点添加编写的AdjustWithHeight脚本组件,设置如下

水果节点

dashLine是红线,超过该位置游戏将结束

这里的水果节点目前只是展示使用,做功能实现的时候我们需要把他拖动到资源管理器中制作成预制资源,具体制作方法在前文提到的官方入门教程中有。制作成预制资源后我们可以把这里的fruit节点删除掉。

点击生成一个水果

我们这里新建一个Fruit脚本添加为fruit节点的脚本组件,同时新建一个MainGame的typescript脚本,添加给Canvas,MainGame控制整个游戏的逻辑功能,首先是监听点击/Touch事件,代码如下:

export default class Fruit extends cc.Component {

    //水果编号,同时用于索引要显示的水果精灵图片
    fruitNumber = 0;
    start () {

    }
}

@ccclass
export default class MainGame extends cc.Component {
  //水果精灵图列表
  @property([cc.SpriteFrame])
  fruitSprites: Array<cc.SpriteFrame> = [];

  //分数label标签
  @property(cc.Label)
  scoreLabel: cc.Label = null;

  //水果预制节点资源
  @property(cc.Prefab)
  fruitPre: cc.Prefab = null;

  //顶部区域节点,生成的水果要添加到这个节点里面
  @property(cc.Node)
  topNode: cc.Node = null;

  //用来暂存生成的水果节点
  targetFruit: cc.Node = null;

  //已创建水果计数
  createFruitCount:number = 0;

  start() {
      this.createOneFruit(0);

      this.bindTouch();
  }

  //创建一个水果
  createOneFruit(index: number) {
    var t = this,
      n = cc.instantiate(this.fruitPre);
    n.parent = this.topNode;
    n.getComponent(cc.Sprite).spriteFrame = this.fruitSprites[index];
    //获取附加给水果节点的Fruit脚本组件,注意名字大小写敏感
    n.getComponent("Fruit").fruitNumber = index; 

    //从新变大的一个展示效果
    n.scale = 0;
    cc.tween(n)
      .to(
        0.5,
        {
          scale: 1,
        },
        {
          easing: "backOut",
        }
      )
      .call(function () {
        t.targetFruit = n;
      })
      .start(); 
  }

  //绑定Touch事件
  bindTouch() {
    this.node.on(cc.Node.EventType.TOUCH_START, this.onTouchStart, this),
      this.node.on(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this),
      this.node.on(cc.Node.EventType.TOUCH_END, this.onTouchEnd, this),
      this.node.on(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
  }

  //touch开始
  onTouchStart(e: cc.Event.EventTouch) {
    if (null == this.targetFruit) {
      return;
    }

    //吧点击位置的x坐标值赋值给水果
    let x = this.node.convertToNodeSpaceAR(e.getLocation()).x,
      y = this.targetFruit.position.y;
    cc.tween(this.targetFruit)
      .to(0.1, {
        position: cc.v3(x, y),
      })
      .start();
  }

  //拖动
  onTouchMove(e: cc.Event.EventTouch) {
    if (null == this.targetFruit) {
      return;
    }

    this.targetFruit.x = this.node.convertToNodeSpaceAR(e.getLocation()).x;
  }

  //Touch结束
  onTouchEnd(e: cc.Event.EventTouch) {
      let t = this;
    if (null == t.targetFruit) {
      return;
    }

    //让水果降落
    //todo...
 
    //去掉暂存指向
    t.targetFruit = null;
    //生成一个新的水果
    this.scheduleOnce(function () {
      0 == t.createFruitCount
        ? (t.createOneFruit(0), t.createFruitCount++)
        : 1 == t.createFruitCount
        ? (t.createOneFruit(0), t.createFruitCount++)
        : 2 == t.createFruitCount
        ? (t.createOneFruit(1), t.createFruitCount++)
        : 3 == t.createFruitCount
        ? (t.createOneFruit(2), t.createFruitCount++)
        : 4 == t.createFruitCount
        ? (t.createOneFruit(2), t.createFruitCount++)
        : 5 == t.createFruitCount
        ? (t.createOneFruit(3), t.createFruitCount++)
        : t.createFruitCount > 5 &&
          (t.createOneFruit(Math.floor(Math.random() * 5)),
          t.createFruitCount++);
    }, 0.5); 
  }
}

fruitSprites属性存的是所有的水果图片,为了更方便的使用图片,这里把图片按0-10进行了改名编号,需要的可以直接在前文的github地址中获取,scoreLabel放置的是分数scoreLabel这个节点,topNode也是对应的节点,附加脚本组件后,脚本定义的@property参数,可以在[属性检查器]中设置对应的值,资源和节点类的,都可以直接拖到放置就可以了,如下:

这样就完成了界面点击生成一个新水果的功能。

水果降落

要让水果降落我们需要用到cocos引擎的物理系统,同时给水果加上刚体RigidBody和物理边界PhysicsCircleCollider,这些都在 组件->物理组件中,设置了重力倍数,摩擦力和弹力等,属性如下:

设置好,我们直接在MainGame的onLoad()事件回调中开启物理系统的重力看下效果。


 onLoad() { 
      this.physicsSystemCtrl(true,false); 
  }

  physicsSystemCtrl(enablePhysics: boolean, enableDebug: boolean) {
    cc.director.getPhysicsManager().enabled = enablePhysics;
    cc.director.getPhysicsManager().gravity = cc.v2(0, -300);
    if(enableDebug){
        cc.director.getPhysicsManager().debugDrawFlags =
        cc.PhysicsManager.DrawBits.e_shapeBit
    }
    cc.director.getCollisionManager().enabled = enablePhysics;
    cc.director.getCollisionManager().enabledDebugDraw = enableDebug;
  }

可以看到水果一创建就落下了,我们对创建方法做些调整,创建后不受重力影响,点击松开后再受影响


//创建一个水果
  createOneFruit(index: number) {
    var t = this,
      n = cc.instantiate(this.fruitPre);
    n.parent = this.topNode;
    n.getComponent(cc.Sprite).spriteFrame = this.fruitSprites[index];
    //获取附加给水果节点的Fruit脚本组件,注意名字大小写敏感
    n.getComponent("Fruit").fruitNumber = index; 

    //创建时不受重力影响,碰撞物理边界半径为0
    n.getComponent(cc.RigidBody).type = cc.RigidBodyType.Static;
    n.getComponent(cc.PhysicsCircleCollider).radius = 0;
    n.getComponent(cc.PhysicsCircleCollider).apply();

    //省略了后面的代码...
     
  }

  onTouchEnd(e: cc.Event.EventTouch) {
      let t = this;
    if (null == t.targetFruit) {
      return;
    }

    //让水果降落
    let h = t.targetFruit.height;
    t.targetFruit.getComponent(cc.PhysicsCircleCollider).radius = h / 2;
    t.targetFruit.getComponent(cc.PhysicsCircleCollider).apply();
    t.targetFruit.getComponent(cc.RigidBody).type = cc.RigidBodyType.Dynamic;
    t.targetFruit.getComponent(cc.RigidBody).linearVelocity = cc.v2(0, -800);
    
    //省略了后面的代码...
}

设置界面边界接住水果

在bglayer中底部和左右节点都加上刚体和对应物理碰撞边界。

最后效果:

我们把降落的水果挂到Canvas下一个fruitNode的空节点上,这样方便遍历,这里要注意,fruitNode节点的初始坐标应该和topNode的初始坐标一样,这样水果从topNode节点转移到fruitNode节点下的时候坐标才不会出现混乱。因为cocos creator 中的坐标都是相对父节点的。

水果碰撞检测

在Fruit的预制资源的属性检查器中我们已经勾选了Enabled Contact Listener

我们可以在他的Fruit脚本中监听该碰撞事件,因为到时水果会比较多,为了方便碰撞检测我们给有碰撞的节点加下分组,好区分检测,也可以去掉不必要的碰撞检测。水果归到fruit分组,左右边界归到wall分组,底部桌子归到 downwall

可以开始编写碰撞事件的处理了:

//MainGame.ts

//分数变动和结果
  scoreObj = {
    isScoreChanged: false,
    target: 0,
    change: 0,
    score: 0,
  };
  
//设置一个静态单例引用,方便其他类中调用该类方法
  static Instance: MainGame = null;

  onLoad() {
    null != MainGame.Instance && MainGame.Instance.destroy();
    MainGame.Instance = this;

    this.physicsSystemCtrl(true, false);
  }
  
  update(dt){
    this.updateScoreLabel(dt);
  }
  
  /**
   * 创建一个升级的水果
   * @param fruitNumber number 水果编号
   * @param position cc.Vec3 水果位置
   */
  createLevelUpFruit = function (fruitNumber: number, position: cc.Vec3) {
    let _t = this;
    let o = cc.instantiate(this.fruitPre);
    o.parent = _t.fruitNode;
    o.getComponent(cc.Sprite).spriteFrame = _t.fruitSprites[fruitNumber];
    o.getComponent("Fruit").fruitNumber = fruitNumber;
    o.position = position;
    o.scale = 0; 

    o.getComponent(cc.RigidBody).linearVelocity = cc.v2(0, -100);
    o.getComponent(cc.PhysicsCircleCollider).radius = o.height / 2;
    o.getComponent(cc.PhysicsCircleCollider).apply();
    cc.tween(o)
      .to(
        0.5,
        {
          scale: 1,
        },
        {
          easing: "backOut",
        }
      )
      .call(function () {
        
      })
      .start(); 
  };
  
  //#region 分数面板更新

  setScoreTween(score: number) {
    let scoreObj = this.scoreObj;
    scoreObj.target != score &&
      ((scoreObj.target = score),
      (scoreObj.change = Math.abs(scoreObj.target - scoreObj.score)),
      (scoreObj.isScoreChanged = !0));
  }

  updateScoreLabel(dt) {
    let scoreObj = this.scoreObj;
    if (scoreObj.isScoreChanged) {
      (scoreObj.score += dt * scoreObj.change * 5),
        scoreObj.score >= scoreObj.target &&
          ((scoreObj.score = scoreObj.target), (scoreObj.isScoreChanged = !1));
      var t = Math.floor(scoreObj.score);
      this.scoreLabel.string = t.toString();
    }
  }

  //#endregion

Fruit.ts脚本中的碰撞事件

import MainGame from "./MainGame";


//和底部边界的碰撞次数,用来标记第一次碰撞时播放音效
downWallColl: number = 0; 
  
//碰撞开始事件
onBeginContact(
    contact: cc.PhysicsContact,
    self: cc.PhysicsCollider,
    other: cc.PhysicsCollider
  ) {
    let _t = this;

    let fruitNode = MainGame.Instance.fruitNode;

    //是否碰撞到底部边界
    if (other.node.group == "downwall") {
      //碰撞后将其加入到fruitNode节点下
      self.node.parent = fruitNode;

      //是否第一次碰撞
      if (_t.downWallColl == 0) {
        //播放碰撞音效
      }

      _t.downWallColl++;
    }

    //是否碰撞到其他水果
    if (other.node.group == "fruit") {
      self.node.parent = fruitNode;

      null != self.node.getComponent(cc.RigidBody) &&
        (self.node.getComponent(cc.RigidBody).angularVelocity = 0);
      //下面的水果碰撞上面的水果跳过
      if (self.node.y < other.node.y) {
        return;
      }

      let otherFruitNumber = other.node.getComponent("Fruit").fruitNumber,
        selfFruitNumber = _t.fruitNumber;

      //两个水果编号一样
      if (otherFruitNumber == selfFruitNumber) {
        //两个都已经是西瓜的情况
        if (selfFruitNumber == 10) {
          return;
        }

        let pos = other.node.position;

        //合并效果,音效,得分,生成一个新的水果

        //得分
        let score = MainGame.Instance.scoreObj.target + selfFruitNumber + 1;
        MainGame.Instance.setScoreTween(score);

        //去掉碰撞边界,避免再次碰撞
        other.node.getComponent(cc.PhysicsCircleCollider).radius = 0;
        other.node.getComponent(cc.PhysicsCircleCollider).apply();
        self.node.getComponent(cc.PhysicsCircleCollider).radius = 0;
        self.node.getComponent(cc.PhysicsCircleCollider).apply();

        cc.tween(self.node)
          .to(0.1, {
            position: pos, //合成到被碰撞的水果的位置
          })
          .call(function () {
            //创建爆浆效果,果汁飞溅的效果

            //创建合成的水果
            MainGame.Instance.createLevelUpFruit(selfFruitNumber + 1, pos);
            //销毁两个碰撞的水果
            self.node.active = !1;
            other.node.active = !1;
            other.node.destroy();
            self.node.destroy();
          })
          .start();
      }
    }
  }
  

这里的碰撞要留意水果刚体类型需要是动态的,开始研究的时候预制资源Fruit的刚体类型设置的static之后合并生成的新的水果刚体类型也没有通过程序改成Dynamic,导致新生成的水果与底部桌面和其他水果碰撞时会导致刚体穿透,足足卡了几个小时才找到问题,也是想写下来希望其他新手朋友研究这个的时候不会被同样的问题卡到。

这里的分数更新是把总分传过去然后和原分值比较出差值,通过update函数把差值一点点更新到scoreLabel中,形成分数滚动效果。

static类型的水果和static类型的桌面碰撞造成刚体穿透:

到此水果已经可以碰撞合并生成新的水果了。

水果碰撞果汁效果

我们先把果汁效果图放到MainGame属性中。

这里是3种图片叠加 FruitL是长条形果粒,GuozhiL 是圆形果汁颗粒,GzuozhiZ 是果汁液

//MainGame.ts
//果汁效果图,水果颗粒
  @property([cc.SpriteFrame])
  fruitL: Array<cc.SpriteFrame> = [];
  //果粒散溅
  @property([cc.SpriteFrame])
  guozhiL: Array<cc.SpriteFrame> = [];
  //果汁效果
  @property([cc.SpriteFrame])
  guozhiZ: Array<cc.SpriteFrame> = [];

  //果汁预制资源
  @property(cc.Prefab)
  juicePre: cc.Prefab = null;
  //效果挂载的节点
  @property(cc.Node)
  effectNode: cc.Node = null;
  

特效代码直接借用的原游戏中的实现,去除了节点缓存功能:

createFruitBoomEffect(fruitNumber: number, t: cc.Vec3, width: number) {
    let _t = this;

    for (var o = 0; o < 10; o++) {
      let c = cc.instantiate(_t.juicePre);
      c.parent = _t.effectNode;
      c.getComponent(cc.Sprite).spriteFrame = _t.guozhiL[fruitNumber];
      var a = 359 * Math.random(),
        i = 30 * Math.random() + width / 2,
        l = cc.v2(
          Math.sin((a * Math.PI) / 180) * i,
          Math.cos((a * Math.PI) / 180) * i
        );
      c.scale = 0.5 * Math.random() + width / 100;
      var p = 0.5 * Math.random();
      (c.position = t),
        c.runAction(
          cc.sequence(
            cc.spawn(
              cc.moveBy(p, l),
              cc.scaleTo(p + 0.5, 0.3),
              cc.rotateBy(p + 0.5, _t.randomInteger(-360, 360))
            ),
            cc.fadeOut(0.1),
            cc.callFunc(function () {
              c.active = !1;
            }, this)
          )
        );
    }
    for (var f = 0; f < 20; f++) {
      let h = cc.instantiate(_t.juicePre);
      h.parent = _t.effectNode;
      (h.getComponent(cc.Sprite).spriteFrame = _t.fruitL[fruitNumber]),
        (h.active = !0);
      (a = 359 * Math.random()),
        (i = 30 * Math.random() + width / 2),
        (l = cc.v2(
          Math.sin((a * Math.PI) / 180) * i,
          Math.cos((a * Math.PI) / 180) * i
        ));
      h.scale = 0.5 * Math.random() + width / 100;
      p = 0.5 * Math.random();
      (h.position = t),
        h.runAction(
          cc.sequence(
            cc.spawn(cc.moveBy(p, l), cc.scaleTo(p + 0.5, 0.3)),
            cc.fadeOut(0.1),
            cc.callFunc(function () {
              h.active = !1;
            }, this)
          )
        );
    }

    var m = cc.instantiate(_t.juicePre);
    m.parent = _t.effectNode;
    m.active = true;
    (m.getComponent(cc.Sprite).spriteFrame = _t.guozhiZ[fruitNumber]),
      (m.position = t),
      (m.scale = 0),
      (m.angle = this.randomInteger(0, 360)),
      m.runAction(
        cc.sequence(
          cc.spawn(cc.scaleTo(0.2, width / 150), cc.fadeOut(1)),
          cc.callFunc(function () {
            m.active = !1;
          })
        )
      );
  }

碰撞音效

在MainGame的脚本中添加音效属性把音效加进来,有几处有音效,分别是水果和桌子碰撞,水果合并音效,还有是合成大西瓜后有个喝彩音效。Canvas的属性检查器,音效资源:

//MainGame.ts 播放音效的方法,在对应的地方调用该方法即可

/**  
     * @param clipIndex AudioClip The audio clip to play.
     * @param loop Boolean Whether the music loop or not.
     * @param volume Number Volume size.
     */
    playAudio(clipIndex:number, loop:boolean, volume:number) {
        cc.audioEngine.play(this.audios[clipIndex], loop, volume)
    }

合成大西瓜的效果

合成大西瓜除了有上述的效果外,还额外增加了一个抛撒彩带和暗屏显示一个大西瓜的效果,如下:

我们添加一个sprite节点,做成预制资源maskBg 属性如下:

把彩带和光圈图片放置到MainGame脚本的添加的caidai属性中,同时添加一个空节点daxiguaEffectNode用来挂载合成大西瓜的特效。

游戏结束

最后我们需要检测水果是否超过红线来判断游戏结束。我们在Fruit脚本的update刷新回调中判断水果挂载到fruitNode节点后是否过线,另外每次碰撞检测后获取下是否快到达红线距离了,显示红线提示。

游戏结束画面

image

就不在贴代码了,可以直接获取源码查看。也没有特别要说明的地方了。

最后

游戏并不复杂,作为新手参考入门还是挺不错的。还可以优化的地方是一些特效的节点缓存起来可以重复使用,这个可以在研究下。原来的游戏代码有一堆不相关的代码夹在中间(也可能是我水平不够没看明白,但是的确不影响游戏功能的代码),所以特意记录下自己入门的研究过程,就到这里吧,写的足够啰嗦了。