potato47

potato47 查看完整档案

厦门编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

potato47 收藏了文章 · 2019-08-09

JavaScript函数式编程入门经典

一个持续更新的github笔记,链接地址:Front-End-Basics,可以watch,也可以star。

此篇文章的地址:JavaScript函数式编程入门经典


正文开始


什么是函数式编程?为何它重要?

数学中的函数

f(x) = y
// 一个函数f,以x为参数,并返回输出y

关键点:

  • 函数必须总是接受一个参数
  • 函数必须总是返回一个值
  • 函数应该依据接收到的参数(例如x)而不是外部环境运行
  • 对于一个给定的x,只会输出唯一的一个y

函数式编程技术主要基于数学函数和它的思想,所以要理解函数式编程,先了解数学函数是有必要的。

函数式编程的定义

函数是一段可以通过其名称被调用的代码。它可以接受参数,并返回值。

与面向对象编程(Object-oriented programming)和过程式编程(Procedural programming)一样,函数式编程(Functional programming)也是一种编程范式。我们能够以此创建仅依赖输入就可以完成自身逻辑的函数。这保证了当函数被多次调用时仍然返回相同的结果(引用透明性)。函数不会改变任何外部环境的变量,这将产生可缓存的,可测试的代码库。

函数式编程具有以下特征

1、引用透明性

所有的函数对于相同的输入都将返回相同的值,函数的这一属性被称为引用透明性(Referential Transparency)

// 引用透明的例子,函数identity无论输入什么,都会原封不动的返回
var identity = (i) => {return i}
替换模型

把一个引用透明的函数用于其他函数调用之间。

sum(4,5) + identity(1)

根据引用透明的定义,我们可以把上面的语句换成:

sum(4,5) + 1

该过程被称为替换模型(Substitution Model),因为函数的逻辑不依赖其他全局变量,你可以直接替换函数的结果,这与它的值是一样的。所以,这使得并发代码缓存成为可能。

并发代码: 并发运行的时候,如果依赖了全局数据,要保证数据一致,必须同步,而且必要时需要锁机制。遵循引用透明的函数只依赖参数的输入,所以可以自由的运行。

缓存: 由于函数会为给定的输入返回相同的值,实际上我们就能缓存它了。比如实现一个计算给定数值的阶乘的函数,我们就可以把每次阶乘的结果缓存下来,下一次直接用,就不用计算了。比如第一次输入5,结果是120,第二次输入5,我们知道结果必然是120,所以就可以返回已缓存的值,而不必再计算一次。

2、声明式和抽象

函数式编程主张声明式编程和编写抽象的代码。

比较命令式和声明式
// 有一个数组,要遍历它并把它打印到控制台

/*命令式*/
var array = [1,2,3]
for(var i = 0; i < array.length; i++)
console(array[i]) // 打印 1,2,3

// 命令式编程中,我们精确的告诉程序应该“如何”做:获取数组的长度,通过数组的长度循环数组,在每一次循环中用索引获取每一个数组元素,然后打印出来。
// 但是我们的任务只是打印出数组的元素。并不是要告诉编译器要如何实现一个遍历。



/*声明式*/
var array = [1,2,3]
array.forEach((element) => console.log(element)) // 打印 1,2,3

// 我们使用了一个处理“如何”做的抽象函数,然后我们就能只关心做“什么”了
函数式编程主张以抽象的方式创建函数,例如上文的forEach,这些函数能够在代码的其他部分被重用。

3、纯函数

大多数函数式编程的好处来自于编写纯函数,纯函数是对给定的输入返回相同的输出的函数,并且纯函数不应依赖任何外部变量,也不应改变任何外部变量。

纯函数的好处

  1. 纯函数产生容易测试的代码
  2. 纯函数容易写出合理的代码
  3. 纯函数容易写出并发代码

纯函数总是允许我们并发的执行代码。因为纯函数不会改变它的环境,这意味着我们根本不需要担心同步问题。

  1. 纯函数的输出结果可缓存

既然纯函数总是为给定的输入返回相同的输出,那么我们就能够缓存函数的输出。

高阶函数

数据和数据类型

程序作用于数据,数据对于程序的执行很重要。每种编程语言都有数据类型。这些数据类型能够存储数据并允许程序作用其中。

JavaScript中函数是一等公民(First Class Citizens)

当一门语言允许函数作为任何其他数据类型使用时,函数被称为一等公民。也就是说函数可被赋值给变量,作为参数传递,也可被其他函数返回。

函数作为JavaScript的一种数据类型,由于函数是类似String的数据类型,所以我们能把函数存入一个变量,能够作为函数的参数进行传递。所以JavaScript中函数是一等公民。

高阶函数的定义

接受另一个函数作为其参数的函数称为高阶函数(Higher-Order-Function),或者说高阶函数是接受函数作为参数并且/或者返回函数作为输出的函数。

抽象和高阶函数

一般而言,高阶函数通常用于抽象通用的问题,换句话说,高阶函数就是定义抽象。

抽象 : 在软件工程和计算机科学中,抽象是一种管理计算机系统复杂性的技术。 通过建立一个人与系统进行交互的复杂程度,把更复杂的细节抑制在当前水平之下。简言之,抽象让我们专注于预定的目标而无须关心底层的系统概念。

例如:你在编写一个涉及数值操作的代码,你不会对底层硬件的数字表现方式到底是16位还是32位整数有很深的了解,包括这些细节在哪里屏蔽。因为它们被抽象出来了,只留下了简单的数字给我们使用。
// 用forEach抽象出遍历数组的操作
const forEach = (array,fn) => {
  let i;
  for(i=0;i<array.length;i++) {
    fn(array[i])
  }
}

// 用户不需要理解forEach是如何实现遍历的,如此问题就被抽象出来了。
//例如,想要打印出数组的每一项
let array = [1,2,3]
forEach(array,(data) => console.log(data)) 

闭包和高阶函数

什么是闭包?简言之,闭包就是一个内部函数。什么是内部函数?就是在另一个函数内部的函数。

闭包的强大之处在于它对作用域链(或作用域层级)的访问。从技术上讲,闭包有3个可访问的作用域。
(1) 在它自身声明之内声明的变量
(2) 对全局变量的访问
(3) 对外部函数变量的访问(关键点)

实例一:假设你再遍历一个来自服务器的数组,并发现数据错了。你想调试一下,看看数组里面究竟包含了什么。不要用命令式的方法,要用函数式的方法来实现。这里就需要一个 tap 函数。

const tap = (value) => {
  return (fn) => {
    typeof fn === 'function' && fn(value)
    console.log(value)
  }
} 

// 没有调试之前
forEach(array, data => {
  console.log(data + data)
})

// 在 forEach 中使用 tap 调试
forEach(array, data => {
  tap(data)(() => {
    console.log(data + data)
  })
})

完成一个简单的reduce函数

const reduce = (array,fn,initialValue) => {
  let accumulator;
  if(initialValue != undefined)
    accumulator = initialValue
  else
    accumulator = array[0]

  if(initialValue === undefined)
    for(let i = 1; i < array.length; i++)
      accumulator = fn(accumulator, array[i])
  else
    for(let value of array)
      accumulator = fn(accumulator,value)
  return accumulator
}

console.log(reduce([1,2,3], (accumulator,value) => accumulator + value))
// 打印出6

柯里化与偏应用

一些概念

一元函数

只接受一个参数的函数称为一元(unary)函数。

二元函数

只接受两个参数的函数称为二元(binary)函数。

变参函数

变参函数是接受可变数量的函数。

柯里化

柯里化是把一个多参数函数转换为一个嵌套的一元函数的过程。

例如

// 一个多参数函数
const add = (x,y) => x + y;
add(2,3)

// 一个嵌套的一元函数
const addCurried = x => y => x + y;
addCurried(2)(3)

// 然后我们写一个高阶函数,把 add 转换成 addCurried 的形式。
const curry = (binaryFn) => {
  return function (firstArg) {
    return function (secondArg) {
      return binaryFn(firstArg,secondArg)
    }
  }
}
let autoCurriedAdd = carry(add)
autoCurriedAdd(2)(3)

上面只是简单实现了一个二元函数的柯里化,下面我们要实现一个更多参数的函数的柯里化。

const curry = (fn) => {
  if (typeof fn !== 'function') {
    throw Error('No function provided')
  }
  return function curriedFn (...args) {
    // 判断当前接受的参数是不是小于进行柯里化的函数的参数个数
    if(args.length < fn.length) {
      // 如果小于的话就返回一个函数再去接收剩下的参数
      return function (...argsOther) {
        return curriedFn.apply(null, args.concat(argsOther))
      }
    }else {
      return fn.apply(null,args)
    }
  }
}

 const multiply = (x,y,z) => x * y * z;
 console.log(curry(multiply)(2)(3)(4))

柯里化的应用实例:从数组中找出含有数字的元素

let match = curry(function (expr,str) {
  return str.match(expr)
})
let hasNumber = match(/[0-9]+/)

let initFilter = curry(function (fn,array) {
  return array.filter(fn)
})

let findNumberInArray = initFilter(hasNumber)
console.log(findNumberInArray(['aaa', 'bb2', '33c', 'ddd', ]))
// 打印 [ 'bb2', '33c' ]

偏应用

我们上面设计的柯里化函数总是在最后接受一个数组,这使得它能接受的参数列表只能是从最左到最右。

但是有时候,我们不能按照从左到右的这样严格传入参数,或者只是想部分地应用函数参数。这里我们就需要用到偏应用这个概念,它允许开发者部分地应用函数参数。

const partial = function (fn, ...partialArgs) {
  return function (...fullArguments) {
    let args = partialArgs
    let arg = 0;
    for(let i = 0; i < args.length && arg < fullArguments.length; i++) {
      if(args[i] === undefined) {
        args[i] = fullArguments[arg++]
      }
    }
    return fn.apply(null,args)
  }
}

偏应用的示例:

// 打印某个格式化的JSON
let prettyPrintJson = partial(JSON.stringify,undefined,null,2)
console.log(prettyPrintJson({name:'fangxu',gender:'male'}))

// 打印出
{
  "name": "fangxu",
  "gender": "male"
}

组合与管道

Unix的理念

  1. 每个程序只做好一件事情,为了完成一项新的任务,重新构建要好于在复杂的旧程序中添加新“属性”。
  2. 每个程序的输出应该是另一个尚未可知的程序的输入。
  3. 每一个基础函数都需要接受一个参数并返回数据。

组合(compose)

const compose = (...fns) => {
  return (value) => reduce(fns.reverse(),(acc,fn) => fn(acc), value)
}

compose 组合的函数,是按照传入的顺序从右到左调用的。所以传入的 fns 要先 reverse 一下,然后我们用到了reduce ,reduce 的累加器初始值是 value ,然后会调用 (acc,fn) => fn(acc), 依次从 fns 数组中取出 fn ,将累加器的当前值传入 fn ,即把上一个函数的返回值传递到下一个函数的参数中。

组合的实例:

let splitIntoSpace = (str) => str.split(' ')
let count = (array) => array.length
const countWords = composeN(count, splitIntoSpace)
console.log(countWords('make smaller or less in amount'))
// 打印 6

管道/序列

compose 函数的数据流是从右往左的,最右侧的先执行。当然,我们还可以让最左侧的函数先执行,最右侧的函数最后执行。这种从左至右处理数据流的过程称为管道(pipeline)或序列(sequence)。

// 跟compose的区别,只是没有调用fns.reverse()
const pipe = (...fns) => (value) => reduce(fns,(acc,fn) => fn(acc),value)

函子

什么是函子(Functor)?

定义:函子是一个普通对象(在其它语言中,可能是一个类),它实现了map函数,在遍历每个对象值的时候生成一个新对象。

实现一个函子

1、简言之,函子是一个持有值的容器。而且函子是一个普通对象。我们就可以创建一个容器(也就是对象),让它能够持有任何传给它的值。

const Container = function (value) {
  this.value = value
}

let testValue = new Container(1)
// => Container {value:1}

我们给 Container 增加一个静态方法,它可以为我们在创建新的 Containers 时省略 new 关键字。

Container.of = function (value) {
  return new Container(value)
}

// 现在我们就可以这样来创建
Container.of(1)
// => Container {value:1}

2、函子需要实现 map 方法,具体的实现是,map 函数从 Container 中取出值,传入的函数把取出的值作为参数调用,并将结果放回 Container。

为什么需要 map 函数,我们上面实现的 Container 仅仅是持有了传给它的值。但是持有值的行为几乎没有任何应用场景,而 map 函数发挥的作用就是,允许我们使用当前 Container 持有的值调用任何函数。
Container.prototype.map = function (fn) {
  return Container.of(fn(this.value))
}

// 然后我们实现一个数字的 double 操作
let double = (x) => x + x;
Container.of(3).map(double)
// => Container {value: 6}

3、map返回了一传入函数的执行结果为值的 Container 实例,所以我们可以链式操作。

Container.of(3).map(double).map(double).map(double)
// => Container {value: 24}

通过以上的实现,我们可以发现,函子就是一个实现了map契约的对象。函子是一个寻求契约的概念,该契约很简单,就是实现 map 。根据实现 map 函数的方式不同,会产生不同类型的函子,如 MayBe 、 Either

函子可以用来做什么?之前我们用tap函数来函数式的解决代码报错的调试问题,如何更加函数式的处理代码中的问题,那就需要用到下面我们说的MayBe函子

MayBe 函子

让我们先写一个upperCase函数来假设一种场景

let value = 'string';
function upperCase(value) {
  // 为了避免报错,我们得写这么一个判断
  if(value != null || value != undefined)
    return value.toUpperCase()
}
upperCase(value)
// => STRING

如上面所示,我们代码中经常需要判断一些nullundefined的情况。下面我们来看一下MayBe函子的实现。

// MayBe 跟上面的 Container 很相似
export const MayBe = function (value) {
  this.value = value
}
MayBe.of = function (value) {
  return new MayBe(value)
}
// 多了一个isNothing
MayBe.prototype.isNoting = function () {
  return this.value === null || this.value === undefined;
}
// 函子必定有 map,但是 map 的实现方式可能不同
MayBe.prototype.map = function(fn) {
  return this.isNoting()?MayBe.of(null):MayBe.of(fn(this.value))
}

// MayBe应用
let value = 'string';
MayBe.of(value).map(upperCase)
// => MayBe { value: 'STRING' }
let nullValue = null
MayBe.of(nullValue).map(upperCase)
// 不会报错 MayBe { value: null }

Either 函子

MayBe.of("tony")
  .map(() => undefined)
  .map((x)f => "Mr. " + x)

上面的代码结果是 MyaBe {value: null},这只是一个简单的例子,我们可以想一下,如果代码比较复杂,我们是不知道到底是哪一个分支在检查 undefined 和 null 值时执行失败了。这时候我们就需要 Either 函子了,它能解决分支拓展问题。

const Nothing = function (value) {
  this.value = value;
}
Nothing.of = function (value) {
  return new Nothing(value)
}
Nothing.prototype.map = function (fn) {
  return this;
}
const Some = function (value) {
  this.value = value;
}
Some.of = function (value) {
  return new Some(value)
}
Some.prototype.map = function (fn) {
  return Some.of(fn(this.value));
}

const Either = {
  Some,
  Nothing
}

Pointed 函子

函子只是一个实现了 map 契约的接口。Pointed 函子也是一个函子的子集,它具有实现了 of 契约的接口。 我们在 MayBe 和 Either 中也实现了 of 方法,用来在创建 Container 时不使用 new 关键字。所以 MayBe 和 Either 都可称为 Pointed 函子。

ES6 增加了 Array.of, 这使得数组成为了一个 Pointed 函子。

Monad 函子

MayBe 函子很可能会出现嵌套,如果出现嵌套后,我们想要继续操作真正的value是有困难的。必须深入到 MayBe 内部进行操作。

let joinExample = MayBe.of(MayBe.of(5));
// => MayBe { value: MayBe { value: 5 } }

// 这个时候我们想让5加上4,需要深入 MayBe 函子内部
joinExample.map((insideMayBe) => {
  return insideMayBe.map((value) => value + 4)
})
// => MayBe { value: MayBe { value: 9 } }

我们这时就可以实现一个 join 方法来解决这个问题。

// 如果通过 isNothing 的检查,就返回自身的 value
MayBe.prototype.join = function () {
  return this.isNoting()? MayBe.of(null) : this.value
}
let joinExample2 = MayBe.of(MayBe.of(5));
// => MayBe { value: MayBe { value: 5 } }

// 这个时候我们想让5加上4就很简单了。
joinExample2.join().map((value) => value + 4)
// => MayBe { value: 9 }

再延伸一下,我们扩展一个 chain 方法。

MayBe.prototype.chain = function (fn) {
  return this.map(fn).join()
}

调用 chain 后就能把嵌套的 MayBe 展开了。

let joinExample3 = MayBe.of(MayBe.of(5));
// => MayBe { value: MayBe { value: 5 } }


joinExample3.chain((insideMayBe) => {
  return insideMayBe.map((value) => value + 4)
})
// => MayBe { value: 9 }

Monad 其实就是一个含有 chain 方法的函子。只有of 和 map 的 MayBe 是一个函子,含有 chain 的函子是一个 Monad。

总结

JavaScript是函数式编程语言吗?

函数式编程主张函数必须接受至少一个参数并返回一个值,但是JavaScript允许我们创建一个不接受参数并且实际上什么也不返回的函数。所以JavaScript不是一种纯函数语言,更像是一种多范式的语言,不过它非常适合函数式编程范式。

补充

1、纯函数是数学函数

function generateGetNumber() {
  let numberKeeper = {}
  return function (number) {
    return numberKeeper.hasOwnProperty(number) ? 
    number : 
    numberKeeper[number] = number + number
  }
}
const getNumber = generateGetNumber()
getNumber(1)
getNumber(2)
……
getNumber(9)
getNumber(10)

// 此时numberKeeper为:
{
  1: 2
  2: 4
  3: 6
  4: 8
  5: 10
  6: 12
  7: 14
  8: 16
  9: 18
  10: 20
}

现在我们规定,getNumber只接受1-10范围的参数,那么返回值肯定是 numberKeeper 中的某一个 value 。据此我们分析一下 getNumber ,该函数接受一个输入并为给定的范围(此处范围是10)映射输出。输入具有强制的、相应的输出,并且也不存在映射两个输出的输入。

下面我来再看一下数学函数的定义(维基百科)

在数学中,函数是一种输入集合和可允许的输出集合之间的关系,具有如下属性:每个输入都精确地关联一个输出。函数的输入称为参数,输出称为值。对于一个给定的函数,所有被允许的输入集合称为该函数的定义域,而被允许的输出集合称为值域。

根据我们对于 getNumber 的分析,对照数学函数的定义,会发现完全一致。我们上面的getNumber函数的定义域是1-10,值域是2,4,6,……18,20

2、实例

文中所有的概念对应的实例可以在 https://github.com/qiqihaobenben/learning-functional 获取,可以打开对应的注释来实际执行一下。

3、荐书

《JavaScript ES6 函数式编程入门经典》,强烈建议想入门函数式编程的同学看一下,书有点老,可以略过工具介绍之类的,关键看其内在的思想,最重要的是,这本书很薄,差不多跟一本漫画书类似。

4、推荐文章(非引用文章)

  1. 漫谈 JS 函数式编程(一)
  2. 从一道坑人的面试题说函数式编程
  3. 函数式编程入门教程
  4. 函数式编程的一点实战
查看原文

potato47 发布了文章 · 2019-01-07

Cocos Creator 教程(1)——第一个游戏:一步两步

第一个游戏

这节我们从头做一个比较有意思的小游戏——一步两步。

下面是最终效果(跳一步得一分,跳两步得三分,电脑端左右键12步):

一步两步

写在前面

这是本教程第一个游戏,所以我会讲的详细一点,但是也不能避免遗漏,所以有什么问题你可以先尝试查阅文档自己解决或者在下方留言,后面我会跟进完善。

另外这个游戏并非原创,参照的是腾讯的微信小游戏《一步两步H5》,请不要直接搬过去,微信里重复的游戏太多了。

创建工程

选择空白项目创建工程

clipboard.png

你可以从这里下载游戏素材,然后将素材导入工程(直接拖进编辑器,或者放在工程目录)

也可以从https://github.com/potato47/one-two-step下载完整代码进行参照。

clipboard.png

准备工作做完后,我会把这个游戏制作过程分为若干个小过程,让你体会一下实际的游戏制作体验。

从一个场景跳转到另一个场景

在res文件夹下新建一个scenes文件夹,然后在scenes里新建两个场景menu和game(右键->新建->Scene)。然后双击menu进入menu场景。

clipboard.png

在层级管理器中选中Canvas节点,在右侧属性检查器中将其设计分辨率调整为1280x720,然后将background图片拖入Canvas节点下,并为其添加Widget组件(添加组件->UI组件->Widget),使其充满画布。

clipboard.png

在Canvas下新建一个Label节点(右键->创建节点->创建渲染节点->Label),然后调整文字大小,并添加标题文字

clipboard.png

我们知道节点和组件通常是一起出现的,带有常见组件的节点在编辑器里可以直接创建,比如刚才的带有Label组件的节点和带有Sprite组件的节点,但是我们也可以新建一个空节点然后为其添加对应的组件来组装一个带有特殊功能的节点。

新建节点->UI节点下有一个Button,如果你直接创建Button,你会发现它是一个带有Button组件的节点,并且有一个Label的子节点。现在我们用另一种方法创建Button:在Canvas右键新建一个空节点,然后为其添加Button组件,这时你会发现按钮并没有背景,所以我们再添加一个Sprite组件,拖入资源中的按钮背景图片,最后添加一个Label子节点给按钮添加上文字。

clipboard.png

下面我们给这个按钮添加点击事件。

在资源管理器中新建src文件夹用来存放脚本,然后新建一个TypeScript脚本,名字为Menu(注意脚本组件名称区分大小写,这里建议首字母大写)。

clipboard.png

双击用VS Code打开脚本,更改如下:

const { ccclass } = cc._decorator;

@ccclass // 让编辑器能够识别这是一个组件
export class Menu extends cc.Component {

    private onBtnStart() {
        cc.director.loadScene('game'); //加载game场景
    }

}
一个类只有加上@ccclass才能被编辑器识别为脚本组件,如果你去掉@ccclass,你就不能把这个组件拖到节点上。另外可以看到代码中出现了几次cc这个东西,cc其实是Cocos的简称,在游戏中是引擎的主要命名空间,引擎代码中所有的类、函数、属性和常量都在这个命名空间中定义。

很明显,我们想在点击开始按钮的时候调用onBtnStart函数,然后跳转到game场景。为了测试效果我们先打开game场景,然后放一个测试文字(将Canvas的设计分辨率也改为1280x720)。

clipboard.png

保存game场景后再回到Menu场景。

Button组件点击后会发出一个事件,这个事件可以跟某个节点上的某个脚本内的某个函数绑定在一起。听着有点绕,动手做一遍就会明白这个机制。

首先将Menu脚本添加为Canvas节点的组件,然后在开始按钮的Button组件里添加一个Click Event,将其指向Canvas节点下的Menu脚本里的onBtnStart函数。

clipboard.png

我们再调整一下Button的点击效果,将Button组件的Transition改为scale(伸缩效果),另外还有颜色变化和图片变化,可以自己尝试。

clipboard.png

最后点击上方的预览按钮,不出意外的话就可以在浏览器中看见预期效果。

clipboard.png

组织代码结构

现在我们来编写游戏逻辑。

首先我来讲一下我看到的一种现象:

很多新手非常喜欢问,“看代码我都能看懂啊,但是要我自己写我就没思路啊”

这时一位经验颇多的长者就会甩给他一句,“多写写就有思路了“

不知道你们发现没有,这竟然是一个死循环。

对于一个刚开始学习做游戏的人,首先要了解的是如何组织你的代码,这里我教给大家一个最容易入门的代码结构——单向分权结构(这是我想了足足两分钟的自认为很酷炫的一个名字)

脚本分层:

这个结构最重要的就是“权”这个字,我们把一个场景中使用的脚本按照“权力”大小给它们分层,权力最大的在最上层且只有一个,这个脚本里保存着它直接控制的若干个脚本的引用,被引用的脚本权力就小一级,被引用的脚本还会引用比它权力更小的脚本,依此类推。

脚本互操作:

  1. 上一层的脚本由于保存着下一层脚本的引用,所以可以直接操作下一层的脚本。
  2. 下一层的脚本由上一层的脚本初始化,在初始化的时候会传入上一层的引用(可选),这样在需要的时候会反馈给上一层,由上一层执行更具体的操作。
  3. 同层的脚本尽量不要互相操作,统一交给上层处理,同层解耦。
  4. 不可避免的同层或跨层脚本操作可以使用全局事件来完成。
  5. 具有通用功能的脚本抽离出来,任意层的脚本都可以直接使用。

写了这么多,但你肯定没看懂,现在你可以翻到最上面再分析一下游戏的game场景,如何组织这个场景的脚本结构?

首先,一个场景的根节点会挂载一个脚本,通常以场景名命名,这里就是Game。

然后跳跃的人物也对应着一个脚本Player。

跟Player同层的还应该有Block也就是人物踩着的地面方块。

因为Player和Block之间互相影响并且我想让Game脚本更简洁,所以这里再加一个Stage(舞台)脚本来控制Player和Block。

最终它们的层级关系如下:

  • Game

    • Stage

      • Player
      • Block

上面这些都是我们的思考过程,下面我们落实到场景中。

先新建几个脚本

clipboard.png

现在搭建场景,先添加一个跟menu场景一样的全屏背景

clipboard.png

然后添加一个空节点Stage,在Stage下添加一个Player节点和一个Block节点

clipboard.png

在Stage同层添加两个按钮来控制跳一步两步

先添加第一个按钮,根据实际效果调整文字大小(font size)颜色(node color)和按钮的缩放倍数(scale)

clipboard.png

第二个按钮可以直接由第一个按钮复制

clipboard.png

这两个按钮显然是要放置在屏幕左下角和右下角的,但是不同屏幕大小可能导致这两个按钮的位置跑偏,所以最好的方案是给这两个按钮节点添加Widget组件,让它们跟左下角和右下角保持固定的距离,这里就不演示了,相信你可以自己完成(其实是我忘录了。。。)

添加一个Label节点记录分数,系统字体有点丑,这里替换成我们自己的字体

clipboard.png

最后把脚本挂在对应的节点上。

clipboard.png

场景搭建到这里基本完成了,现在可以编写脚本了。

Game作为一个统领全局的脚本,一定要控制关键的逻辑,,比如开始游戏和结束游戏,增加分数,还有一些全局的事件。

Game.ts

import { Stage } from './Stage';

const { ccclass, property } = cc._decorator;

@ccclass
export class Game extends cc.Component {

    @property(Stage)
    private stage: Stage = null;
    @property(cc.Label)
    private scoreLabel: cc.Label = null;

    private score: number = 0;

    protected start() {
        this.startGame();
    }

    public addScore(n: number) {
        this.score += n;
        this.scoreLabel.string = this.score + '';
    }

    public startGame() {
        this.score = 0;
        this.scoreLabel.string = '0';
        this.stage.init(this);
    }

    public overGame() {
        cc.log('game over');
    }

    public restartGame() {
        cc.director.loadScene('game');
    }

    public returnMenu() {
        cc.director.loadScene('menu');
    }

    private onBtnOne() {
        this.stage.playerJump(1);
    }

    private onBtnTwo() {
        this.stage.playerJump(2);
    }
}

Stage作为Game直接控制的脚本,要给Game暴露出操作的接口并且保存Game的引用,当游戏状态发生改变时,通知Game处理。

Stage.ts

import { Game } from './Game';
import { Player } from './Player';

const { ccclass, property } = cc._decorator;

@ccclass
export class Stage extends cc.Component {

    @property(Player)
    private player: Player = null;

    private game: Game = null;

    public init(game: Game) {
        this.game = game;
    }

    public playerJump(step: number) {
        this.player.jump(step);
    }

}

而Player作为最底层的一个小员工,别人让你做啥你就做啥。

Player.ts

const {ccclass, property} = cc._decorator;

@ccclass
export class Player extends cc.Component {

    public jump(step: number) {
        if (step === 1) {
            cc.log('我跳了1步');
        } else if (step === 2) {
            cc.log('我跳了2步');
        }
    }

    public die() {
        cc.log('我死了');
    }

}

之前讲了@ccclass是为了让编辑器识别这是一个组件类,可以挂在节点上,现在我们又看到了一个@property,这个是为了让一个组件的属性暴露在编辑器属性中,观察最上面的Game脚本,发现有三个成员变量,stage,scoreLabelscore,而只有前两个变量加上了@property,所以编辑器中只能看到stagescoreLabel

clipboard.png

@property括号里通常要填一个编辑器可以识别的类型,比如系统自带的cc.Label,cc.Node,cc.Sprite,cc.Integer,cc.Float等,也可以是用户脚本类名,比如上面的StagePlayer

回到编辑器,我们把几个脚本暴露在编辑器的变量通过拖拽的方式指向带有类型组件的节点。

clipboard.png

再把one,two两个按钮分别绑定在game里的onBtnOne,onBtnTwo两个函数上。

clipboard.png

这时我们已经有了一个简单的逻辑,点击1或2按钮,调用Game里的onBtnOne或onBtnTwo,传递给Stage调用playerJump,再传递给Player调用jump,player就会表现出跳一步还是跳两步的反应。

点击预览按钮,进行测试:

clipboard.png

你可以按F12(windows)或cmd+opt+i(mac)打开chrome的开发者工具。

人物跳跃动作

现在我们来让Player跳起来,人物动作的实现大概可以借助以下几种方式实现:

  • 动画系统
  • 动作系统
  • 物理系统
  • 实时计算

可以看到这个游戏人物动作比较简单,跳跃路径是固定的,所以我们选择用动作系统实现人物的跳跃动作。

creator自带一套基于节点的动作系统,形式如node.runAction(action)

修改Player.ts,添加几个描述跳跃动作的参数,并且添加一个init函数由上层组件即Stage初始化时调用并传入所需参数。另外更改jump函数内容让Player执行jumpBy动作。

Player.ts

...

private stepDistance: number; // 一步跳跃距离
private jumpHeight: number; // 跳跃高度
private jumpDuration: number; // 跳跃持续时间
public canJump: boolean; // 此时是否能跳跃

public init(stepDistance: number, jumpHeight: number, jumpDuration: number) {
    this.stepDistance = stepDistance;
    this.jumpHeight = jumpHeight;
    this.jumpDuration = jumpDuration;
    this.canJump = true;
}

public jump(step: number) {
    this.canJump = false;
    this.index += step;
    let jumpAction = cc.jumpBy(this.jumpDuration, cc.v2(step * this.stepDistance, 0), this.jumpHeight, 1);
    let finishAction = cc.callFunc(() => {
        this.canJump = true;
    });
    this.node.runAction(cc.sequence(jumpAction, finishAction));
}

...

Stage.ts

...

@property(cc.Integer)
private stepDistance: number = 200;
@property(cc.Integer)
private jumpHeight: number = 100;
@property(cc.Float)
private jumpDuration: number = 0.3;

@property(Player)
private player: Player = null;

private game: Game = null;

public init(game: Game) {
    this.game = game;
    this.player.init(this.stepDistance, this.jumpHeight, this.jumpDuration);
}

public playerJump(step: number) {
    if (this.player.canJump) {
        this.player.jump(step);
    }
}

...

这里要介绍一下 Cocos Creator 的动作系统,动作系统基于节点,你可以让一个节点执行一个瞬时动作或持续性的动作。比如让一个节点执行一个“3秒钟向右移动100”的动作,就可以这样写

let moveAction = cc.moveBy(3, cc.v2(100, 0)); // cc.v2可以创建一个二位的点(向量),代表方向x=100,y=0
this.node.runAction(moveAction);

更多的动作使用可查询文档 http://docs.cocos.com/creator...

回头看Player的jump方法,这里我们的意图是让Player执行一个跳跃动作,当跳跃动作完成时将this.canJump改为true,cc.CallFunc也是一个动作,这个动作可以执行你传入的一个函数。所以上面的finishAction执行的时候就可以将this.canJump改为true,cc.sequence用于将几个动作连接依次执行。

可以看到jumpAction传入了很多参数,有些参数可以直接根据名字猜到,有一些可能不知道代表什么意思,这时你就要善于搜索api,另外要充分利用ts提示的功能,你可以直接按住ctrl/cmd+鼠标单击进入定义文件查看说明示例。

clipboard.png

再来看Stage,可以看到Player初始化的几个参数是由Stage传递的,并且暴露在了编辑器界面,我们可以直接在Stage的属性面板调整参数,来直观的编辑动作效果,这也是Creator编辑器的方便之处。

clipboard.png

上面的几个参数是我调整过后的,你也可以适当的修改,保存场景后预览效果。

clipboard.png

动态添加地面和移动场景

显而易见,我们不可能提前设置好所有的地面(Block),而是要根据Player跳跃的时机和地点动态添加Block,这就涉及到一个新的知识点——如何用代码创建节点?

每一个Block节点都是一样的,对于这样相同的节点可以抽象出一个模板,Creator里管这个模板叫做预制体(Prefab),想要一个新的节点时就可以通过复制Prefab得到。

制作一个Prefab很简单,我们先在res目录下新建一个prefabs目录,然后将Block节点直接拖到目录里就可以形成一个Prefab了。

clipboard.png

你可以双击这个prefab进入其编辑模式,如果之前忘了将Block脚本挂在Block节点上,这里也可以挂在Block的Prefab上。

clipboard.png

有了Prefab后,我们就可以利用函数cc.instance来创建出一个节点。

根据之前讲的组织代码原则,创建Block的职责应该交给他的上级,也就是Stage。

之前编写Player代码时设置了一个index变量,用来记录Player跳到了“第几格”,根据游戏逻辑每当Player跳跃动作完成后就要有新的Block出现在前面。修改Stage如下:

Stage.ts

import { Game } from './Game';
import { Player } from './Player';
import { Block } from './Block';

const { ccclass, property } = cc._decorator;

@ccclass
export class Stage extends cc.Component {

    @property(cc.Integer)
    private stepDistance: number = 200;
    @property(cc.Integer)
    private jumpHeight: number = 100;
    @property(cc.Float)
    private jumpDuration: number = 0.3;
    @property(Player)
    private player: Player = null;

    @property(cc.Prefab)
    private blockPrefab: cc.Prefab = null; // 编辑器属性引用

    private lastBlock = true; // 记录上一次是否添加了Block
    private lastBlockX = 0; // 记录上一次添加Block的x坐标
    private blockList: Array<Block>; // 记录添加的Block列表

    private game: Game = null;

    public init(game: Game) {
        this.game = game;
        this.player.init(this.stepDistance, this.jumpHeight, this.jumpDuration);
        this.blockList = [];
        this.addBlock(cc.v2(0, 0));
        for (let i = 0; i < 5; i++) {
            this.randomAddBlock();
        }
    }

    public playerJump(step: number) {
        if (this.player.canJump) {
            this.player.jump(step);
            this.moveStage(step);
            let isDead = !this.hasBlock(this.player.index);
            if (isDead) {
                cc.log('die');
                this.game.overGame();
            } else {
                this.game.addScore(step === 1 ? 1 : 3); // 跳一步得一分,跳两步的三分
            }
        }
    }

    private moveStage(step: number) {
        let moveAction = cc.moveBy(this.jumpDuration, cc.v2(-this.stepDistance * step, 0));
        this.node.runAction(moveAction);
        for (let i = 0; i < step; i++) {
            this.randomAddBlock();
        }
    }

    private randomAddBlock() {
        if (!this.lastBlock || Math.random() > 0.5) {
            this.addBlock(cc.v2(this.lastBlockX + this.stepDistance, 0));
        } else {
            this.addBlank();
        }
        this.lastBlockX = this.lastBlockX + this.stepDistance;
    }

    private addBlock(position: cc.Vec2) {
        let blockNode = cc.instantiate(this.blockPrefab);
        this.node.addChild(blockNode);
        blockNode.position = position;
        this.blockList.push(blockNode.getComponent(Block));
        this.lastBlock = true;
    }

    private addBlank() {
        this.blockList.push(null);
        this.lastBlock = false;
    }

    private hasBlock(index: number): boolean {
        return this.blockList[index] !== null;
    }

}

首先我们在最上面添加了几个成员变量又来记录Block的相关信息。
然后修改了playerJump方法,让player跳跃的同时执行moveStage,moveStage方法里调用了一个moveBy动作,这个动作就是把节点相对移动一段距离,这里要注意的是moveStage动作和player里的jump动作水平移动的距离绝对值和时间都是相等的,player向前跳,stage向后移动,这样两个相反的动作,就会让player始终处于屏幕中的固定位置而不会跳到屏幕外了。

再看moveStage方法里会调用randomAddBlock,也就是随机添加block,随机算法要根据游戏规则推理一下:

这个游戏的操作分支只有两个:1步或者是2步。所以每2个Block的间隔只能是0步或者1步。因此randomAddBlock里会判断最后一个Block是否为空,如果为空那新添加的一定不能为空。如果不为空则50%的概率随机添加或不添加Block。这样就能得到无限随机的地图了。

为了激励玩家多按2步,所以设定跳1步的1分,跳2步得3分。

另外Player跳几步randomAddBlock就要调用几次,这样才能保证地图与Player跳跃距离相匹配。

再说一下addBlock方法,blockNode是由blockPrefab复制出来的,你必须通过addChild方法把它添加场景中的某个节点下才能让它显示出来,这里的this.node就是Stage节点。为了方便我们把lastBlockX初始值设为0,也就是水平第一个block的横坐标应该等于0,所以我们要回到编辑器调整一下stage,player,block三个节点的位置,让block和player的x都等于0,并且把Block的宽度设为180(一步的距离设为200,为了让两个相邻的Block有一点间距,要适当窄一些),最后不要忘记把BlockPrefab拖入对应的属性上。

clipboard.png

playerJump的的最后有一段判断游戏结束的逻辑,之前我们在player里设置了一个变量index,记录player当前跳到第几格,stage里也有一个数组变量blockList保存着所有格子的信息,当player跳完后判断一下落地点是否有格子就可以判断游戏是否结束。

捋顺上面的逻辑后,你就可以预览一下这个看起来有点样子的游戏了

clipboard.png

地面下沉效果

如果每一步都让玩家想很久,那这个游戏就没有尽头了。现在我们给它加点难度。

设置的效果是:地面每隔一段时间就会下落,如果玩家没有及时跳到下一个格子就会跟着地面掉下去,为了实现这个人物和地面同时下坠的效果,我们要让Player和Block执行相同的动作,所以在Stage上新加两个变量fallDuration和fallHeight用来代表下落动作的时间和高度,然后传给Player和Block让它们执行。

另外这种小游戏的难度一定是要随着时间增加而增大的,所以Block的下落时间要越来越快。

下面我们来修改Block,Player,Stage三个脚本

Block.ts

const { ccclass } = cc._decorator;

@ccclass
export class Block extends cc.Component {

    public init(fallDuration: number, fallHeight: number, destroyTime: number, destroyCb: Function) {
        this.scheduleOnce(() => {
            let fallAction = cc.moveBy(fallDuration, cc.v2(0, -fallHeight)); // 下沉动作
            this.node.runAction(fallAction);
            destroyCb();
        }, destroyTime);
    }

}

这里补充了Block的init方法,传入了四个参数,分别是坠落动作的持续时间,坠落动作的高度,销毁时间,销毁的回调函数。

scheduleOnce是一个一次性定时函数,存在于cc.Component里,所以你可以在脚本里直接通过this来调用这个函数,这里要实现的效果就是延迟destroyTime时间执行下落动作。

Player.ts

const { ccclass } = cc._decorator;

@ccclass
export class Player extends cc.Component {

    private stepDistance: number; // 一步跳跃距离
    private jumpHeight: number; // 跳跃高度
    private jumpDuration: number; // 跳跃持续时间
    private fallDuration: number; // 坠落持续时间
    private fallHeight: number; // 坠落高度
    public canJump: boolean; // 此时是否能跳跃
    public index: number; // 当前跳到第几格

    public init(stepDistance: number, jumpHeight: number, jumpDuration: number, fallDuration: number, fallHeight: number) {
        this.stepDistance = stepDistance;
        this.jumpHeight = jumpHeight;
        this.jumpDuration = jumpDuration;
        this.fallDuration = fallDuration;
        this.fallHeight = fallHeight;
        this.canJump = true;
        this.index = 0;
    }

...

    public die() {
        this.canJump = false;
        let dieAction = cc.moveBy(this.fallDuration, cc.v2(0, -this.fallHeight));
        this.node.runAction(dieAction);
    }

}

首先将init里多传入两个变量fallDuration和fallHeight用来实现下落动作,然后补充die方法,这里的下落动作其实是个上面的Block里的下落动作是一样的。

Stage.ts

...

@property(cc.Integer)
private fallHeight: number = 500;
@property(cc.Float)
private fallDuration: number = 0.3;
@property(cc.Float)
private initStayDuration: number = 2; // 初始停留时间
@property(cc.Float)
private minStayDuration: number = 0.3; // 最小停留时间,不能再快了的那个点,不然玩家就反应不过来了
@property(cc.Float)
private speed: number = 0.1;

private stayDuration: number; // 停留时间

...

public init(game: Game) {
    this.game = game;
    this.stayDuration = this.initStayDuration;
    this.player.init(this.stepDistance, this.jumpHeight, this.jumpDuration, this.fallDuration, this.fallHeight);
    this.blockList = [];
    this.addBlock(cc.v2(0, 0));
    for (let i = 0; i < 5; i++) {
        this.randomAddBlock();
    }
}

public addSpeed() {
    this.stayDuration -= this.speed;
    if (this.stayDuration <= this.minStayDuration) {
        this.stayDuration = this.minStayDuration;
    }
    cc.log(this.stayDuration);
}

public playerJump(step: number) {
    if (this.player.canJump) {
        this.player.jump(step);
        this.moveStage(step);
        let isDead = !this.hasBlock(this.player.index);
        if (isDead) {
            cc.log('die');
            this.scheduleOnce(() => { // 这时还在空中,要等到落到地面在执行死亡动画
                this.player.die();
                this.game.overGame();
            }, this.jumpDuration);
        } else {
            let blockIndex = this.player.index;
            this.blockList[blockIndex].init(this.fallDuration, this.fallHeight, this.stayDuration, () => { 
                if (this.player.index === blockIndex) { // 如果Block下落时玩家还在上面游戏结束
                    this.player.die();
                    this.game.overGame();
                }
            });
            this.game.addScore(step === 1 ? 1 : 3);
        }
        if (this.player.index % 10 === 0) {
            this.addSpeed();
        }
    }
}

...

Player和Block下落动作都需要的fallDuration和fallHeight我们提取到Stage里,然后又添加了几个属性来计算Block存留时间。

在playerJump方法里,补充了Player跳跃后的逻辑:如果Player跳空了,那么就执行死亡动画也就是下落动作,如果Player跳到Block上,那么这个Block就启动下落计时器,当Block下落时Player还没有跳走,那就和Player一起掉下去。

最后增加下落速度的方式是每隔十个格子加速一次。

回到编辑器,调整fallDuration,fallHeight,initStayDuration,minStayDuration,speed的值。

clipboard.png

预览游戏

clipboard.png

添加结算面板

前面讲了这么多,相信你能自己拼出下面这个界面。

clipboard.png

上面挂载的OverPanel脚本如下:

OverPanel.ts

import { Game } from "./Game";

const { ccclass, property } = cc._decorator;

@ccclass
export class OverPanel extends cc.Component {

    @property(cc.Label)
    private scoreLabel: cc.Label = null;

    private game: Game;

    public init(game: Game) {
        this.game = game;
    }

    private onBtnRestart() {
        this.game.restartGame();
    }

    private onBtnReturnMenu() {
        this.game.returnMenu();
    }

    public show(score: number) {
        this.node.active = true;
        this.scoreLabel.string = score + '';
    }

    public hide() {
        this.node.active = false;
    }

}

不要忘了将两个按钮绑定到对应的方法上。

最后修改Game,让游戏结束时显示OverPanel

Game.ts

import { Stage } from './Stage';
import { OverPanel } from './OverPanel';

const { ccclass, property } = cc._decorator;

@ccclass
export class Game extends cc.Component {

    @property(Stage)
    private stage: Stage = null;
    @property(cc.Label)
    private scoreLabel: cc.Label = null;
    @property(OverPanel)
    private overPanel: OverPanel = null;

    private score: number = 0;

    protected start() {
        this.overPanel.init(this);
        this.overPanel.hide();
        this.startGame();
    }

    public addScore(n: number) {
        this.score += n;
        this.scoreLabel.string = this.score + '';
    }

    public startGame() {
        this.score = 0;
        this.scoreLabel.string = '0';
        this.stage.init(this);
    }

    public overGame() {
        this.overPanel.show(this.score);
    }

    public restartGame() {
        cc.director.loadScene('game');
    }

    public returnMenu() {
        cc.director.loadScene('menu');
    }

    private onBtnOne() {
        this.stage.playerJump(1);
    }

    private onBtnTwo() {
        this.stage.playerJump(2);
    }
}

将OverPanel的属性拖上去。

clipboard.png

为了不影响编辑器界面,你可以将OverPanel节点隐藏

clipboard.png

预览效果

clipboard.png

添加声音和键盘操作方式

如果你玩过这个游戏,肯定知道声音才是其灵魂。

既然是Player发出的声音,就挂在Player身上吧

Player.ts

const { ccclass, property } = cc._decorator;

@ccclass
export class Player extends cc.Component {

    @property({
        type: cc.AudioClip
    })
    private oneStepAudio: cc.AudioClip = null;
    @property({
        type:cc.AudioClip
    })
    private twoStepAudio: cc.AudioClip = null;
    @property({
        type:cc.AudioClip
    })
    private dieAudio: cc.AudioClip = null;

    ...

    public jump(step: number) {

        ...

        if (step === 1) {
            cc.audioEngine.play(this.oneStepAudio, false, 1);
        } else if (step === 2) {
            cc.audioEngine.play(this.twoStepAudio, false, 1);
        }
    }

    public die() {
        
        ...

        cc.audioEngine.play(this.dieAudio, false, 1);
    }

}

clipboard.png

这里你可能比较奇怪的为什么这样写

@property({
    type: cc.AudioClip
})
private oneStepAudio: cc.AudioClip = null;

而不是这样写

@property(cc.AudioClip)
private oneStepAudio: cc.AudioClip = null;

其实上面的写法才是完整写法,除了type还有displayName等参数可选,当只需要type这个参数时可以写成下面那种简写形式,但例外的是有些类型只能写完整形式,不然就会抱警告,cc.AudioClip就是其一。

在电脑上点击两个按钮很难操作,所以我们添加键盘的操作方式。

Game.ts

import { Stage } from './Stage';
import { OverPanel } from './OverPanel';

const { ccclass, property } = cc._decorator;

@ccclass
export class Game extends cc.Component {

    ...

    protected start() {
        
        ...

        this.addListeners();
    }

    ...

    private addListeners() {
        cc.systemEvent.on(cc.SystemEvent.EventType.KEY_DOWN, (event: cc.Event.EventKeyboard) => {
            if (event.keyCode === cc.macro.KEY.left) {
                this.onBtnOne();
            } else if (event.keyCode === cc.macro.KEY.right) {
                this.onBtnTwo();
            }
        }, this);
    }

}

在游戏初始化的时候通过cc.systemEvent注册键盘事件,按左方向键跳一步,按右方向键跳两步。

至此我们的游戏就做完了。

一步两步

如果你有基础,这个游戏并不难,如果这是你的第一篇教程,你可能会很吃力,无论前者后者,遇到任何问题都可以在下方留言,我也会随时更新。

另外不要忘了加QQ交流群哦 863758586

查看原文

赞 14 收藏 10 评论 4

potato47 发布了文章 · 2019-01-07

Cocos Creator 教程(0)——准备

Cocos Creator

简介什么的,最没意思了。

安装

http://www.cocos.com/download

Windows 安装过程中会询问是否安装 Visual Studio,其作用是编译 Windows 程序,我们暂时以 Web 平台开发为主,所以可以先跳过不安装,当然你非要安装我也不能拿你怎么样。

注册账号

如果你第一次使用 Cocos Creator,会提示你登录账号。你可以从下面的地址注册 Cocos 开发者账号。

https://passport.cocos.com/au...

Dashboard

clipboard.png

  • 空白项目:自己做游戏选择这个。
  • 范例集合:官方的一些组件用法参考,有很多文档没有的内容,没事看一下。
  • Hello World:自带一个场景,一个脚本,两张图片,可以用来做测试工程。
  • Hello TypeScript:同上,这个为 TypeScript 脚本。

Visual Studio Code

如果你有偏爱的编辑器也可以继续使用,但是 VS Code 与Cocos Creator 结合的更好。

安装

https://code.visualstudio.com/


Chrome

浏览器通常是我们的调试场景,所以建议统一用Chrome。

安装

https://www.google.cn/chrome/

吃惊地发现下载谷歌浏览器竟然不用翻墙了。。。

TypeScript

”我能不能用JavaScript啊?"

"不能。"

学习TypeScript

如果你没有接触过TS,也不用担心,你只要有一门语言的基础,入门别的语言其实很简单。你可以先看一下官方文档,然后跟着教程做一个游戏,遇到不会的地方搜索提问、搜索,如此循环即可。

https://www.tslang.cn/docs/ho...

快速预览

混个脸熟。

首先选择Hello TypeScript,新建一个工程。

clipboard.png

不出意外的话你就会看到这个界面

clipboard.png

序号名称功能备注
1层级管理器显示场景中的节点层级关系-
2场景编辑器预览场景效果-
3控件库一些常用的可视化控件个人来讲基本不用,我会把这个面板隐藏掉
4资源管理器存储游戏资源-
5控制台输出系统和游戏信息-
6属性检查器显示节点属性-
当然你可以随意调整各个面板的位置,或者隐藏掉。

关于资源管理器你可能还需要知道:

  • Scene文件夹下的helloworld是一个场景文件,里面包含着一个游戏场景的相关信息,你可以双击打开它,进入对应的场景编辑界面。
  • Script文件夹下Helloworld是一个脚本文件,里面包含着游戏逻辑,脚本通常会挂在节点上。
  • Texture文件夹下存放着两张图片叫做贴图资源,你可以直接将其拖入场景编辑器或层级管理器使其变为场景中的一个带有Sprite组件的节点。

简单介绍一下场景(Scene)、节点(Node)、组件(Component)的关系:

学到后面你会发现游戏开发里的很多概念跟拍电影有些类似,游戏的场景可以简单的类比电影里的场景,场景里有角色有背景和一些“剧本”,还有对其拍摄的摄像机。

节点可以简单的理解为游戏中的物体载体,这个载体有位置有大小等基本信息,可能是玩家控制的角色,可能是天空中飘着的一段云,也可能是可以点击的一个按钮。

组件是游戏中最重要的一个概念,组件是赋予节点特殊能力的一个,额,一个东东。。。比如你可以在一个节点上添加Sprite组件使其显示一张图片,添加Label组件使其显示文字,添加Collider组件使其具有物理碰撞功能,还可以添加你自己写的用户脚本组件,使其具有特定的功能。

下面双击helloworld场景将其打开,你就会看到一个编辑好的场景

clipboard.png

点击顶部的三角形播放按钮,即可在浏览器中看到游戏场景。

clipboard.png

你可以在1处调整预览的设备型号,在2处调整设备方向。

现在再回过头来看一下场景的节点树结构。

clipboard.png

从上向下(你可以点击对应节点,在右边属性检查器查看节点属性)

  • Canvas

clipboard.png

Canvas是每个场景的默认根节点,你不能删除,也不能移动,它在游戏运行时会根据你设定的屏幕分辨率和适配方式自动调整大小。可以看到Node下挂载的第一个组件就是Canvas组件,你可以在这里调整设计分辨率以及适配方式。Canvas下面还有一个Helloworld组件,这个其实是我们自己写的组件,对应着左边的Helloworld脚本,里面的脚步逻辑我们回头再看。

  • Main Camera

clipboard.png

Main Camera也是每个场景都会有的,其作用是控制场景如何显示的。现阶段不用去修改它,后面我们会专门用一章来讲Camera。

  • background

clipboard.png

先看background下面的Sprite组件,可以看到Sprite里有一个Sprite Frame属性,你可以点击它,会发现它指向的是左边资源管理器里一张白色图片。可以看出Sprite组件就是用来让节点显示图片的。再看上面的Widget组件,应该很明显可以看出它是用来对其四边的,上下左右都是0,那么就可以让这个节点铺满屏幕。你可能又发现了这个背景在场景中显示的不是白色,颜色其实是在Node属性下设置的。

clipboard.png

这里可以看出游戏中的一个物体其实是由节点和组件共同影响的。

  • cocos

clipboard.png

跟background一样是一个可以显示图片的节点(场景中间的Cocos图标),没有Widget组件,就不会有对齐效果,你可以任意调整其位置。

  • label

clipboard.png

这个也很明显就是一个可以显示文字的节点,你可以修改Label组件下的String属性里的值来改变其显示的文字。

最后我们在回过头来看Canvas上的Helloworld组件,选中资源管理器的Helloworld脚本文件,可以在属性检查器里看到脚本内容。

clipboard.png

可以看到里面声明了两个属性:一个是label,其类型是cc.Label,label指向的就是场景中的label节点。另一个是text,其类型是string,并且默认值是‘hello’,你也可以在属性面板修改它的值。然后在start函数里将label的string改为text的值(start函数会在所有节点加载完成后自动执行)。

现在我们尝试一下修改脚本里的内容,但是打开脚本之前还有几个前提操作。将下图几个选项一次点一遍。

clipboard.png

然后就可以双击脚本用 VS Code 打开了(Windows 系统默认会打开文件夹,但是 Mac 系统默认只会打开一个文件,暂时我还没找到解决办法,我通常会先打开 VS Code 然后选择工程文件夹再打开)。

打开脚本后修改在start里的内容如下:

start () {
    // init logic
    this.label.string = this.text;
    this.label.node.color = cc.Color.RED;
}

然后选中Canvas节点并且在右边的属性面板里更改text的值

clipboard.png

点击上方三角形运行按钮,即可看到下面效果

clipboard.png

最后补充几点常用操作

操作步骤
新建场景在资源管理器里右键选择新建->Scene
新建节点在层级管理器右键创建节点,如果是带有图片的节点可以直接讲图片拖入场景或层级管理器中
添加组件选中节点并在属性面板的最下方选择添加组件,或者直接讲用户脚本拖入属性面板
脚本属性绑定将其对应类型的节点或带有组件的节点或资源拖入

帮助

在学习的这条道路上,一定 不孤单 是非常孤单的,首先你要学会自己寻找解决方法,途径通常有以下几种

官方文档

http://docs.cocos.com/creator...

当你比较生疏的名词或想深入了解的概念,首先应该想到的是去文档里搜索查看一下。

官方API

http://docs.cocos.com/creator...

查找某个变量的含义,或某个函数的用法。

官方论坛

http://forum.cocos.com/

当你自己真的解决不了的问题时,你可以去论坛发帖寻求帮助。因为你遇到的问题可能别人早就遇到过,所以发帖前一定要先搜索一下是否有类似的帖子,解决时间就是节约生命。

另外,多逛论坛也是一种学习的方式。

找个组织

这是我建的一个QQ群,供新手学习交流
863758586

查看原文

赞 3 收藏 1 评论 1

potato47 关注了用户 · 2019-01-07

SegmentFault @segmentfault

SegmentFault 社区管理媛 - 思否小姐姐

纯粹的技术社区离不开所有开发者的支持和努力 biubiu

更多技术内容与动态欢迎关注 @SegmentFault 官方微博与微信公众号!

点击添加思否小姐姐个人微信号

关注 83846

potato47 关注了标签 · 2018-12-20

关注 53

potato47 关注了用户 · 2018-04-24

Yujiaao @yujiaao

https://bixuebihui.com

我以为我摸到的是象腿,实际上是🐘的屁股,不要问我咋知道的

欢迎用微信联系我: yujiabuao

关注 3689

potato47 关注了用户 · 2018-04-24

枫凌一 @fenglingyi

关注 1624

potato47 关注了用户 · 2018-04-24

toBeTheLight @tobethelight

熟练掌握 TS(0.5/1)
学习经济学(1/1)
学习数学知识(0/n)
学习编译原理(0/1)
文笔更好一些(1/1)

关注 26541

potato47 关注了用户 · 2018-04-24

老鼠拧刀满街找猫 @laoshuningdaomanjiezhaomao_58bd0186554b9

java攻城狮

关注 1353

potato47 关注了用户 · 2018-04-24

我养了一窝汪汪 @side9527

有趣的hello world

关注 607

认证与成就

  • 获得 17 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-04-24
个人主页被 420 人浏览