前情提要: 微信小游戏0基础学习记录:0.一些准备知识&起步
上一篇博客介绍到了官方教程“制作简单游戏的新手引导”的第一阶段,“创建并编译3D场景”。这一篇将继续完成新手引导的剩余内容,包括 2D 场景的创建与编辑、游戏项目的播放与构建等等。
官方文档:快速上手 | 微信开放文档
起步:制作简单游戏的新手引导(2)
1、创建并编辑 2D 场景
2D 系统的主要内容可以查看这部分文档:微信小游戏/框架/二维系统
创建一个 2D 场景
首先,了解 2D 场景。框架提供 3D 和 2D 这两种场景模板,创建一个 2D 场景并打开之后,可以看到如下的 Hierarchy 栏和 Scene 编辑面板。其中,不同于 3D 场景创建后默认添加一个相机节点和一个光节点,2D 场景创建起始时,Hierarchy 栏只有一个场景 Scene 根节点;而 Scene 编辑面板更是只有一个粉紫色的方框。
根据文档介绍,一个 2D 场景的Scene 根节点会作为子节点添加到二维世界的根节点,而二维世界根节点则挂载着一个用于渲染的 UICanvas 组件;除此之外,二维世界也有一个框架提供的、不暴露给用户的内部单例相机,负责主屏 UI 绘制、触摸事件管理等工作。
打开开发工具的内置编辑器,可以查看到部分关于 UICanvas 和 UICamera 定义的代码,比较直观的有:engine.game 模块持有 rootUICanvas 和 rootUICamera 两个属性,分别为二维世界的画布和相机;而其中,2D 相机组件 UICamera, 它持有 touchManager、touchEvent 等属性以及事件坐标向 UICanvas 世界坐标转化的方法,这些应该就使得它可以管理触摸事件。
// 文件 assets/node_modules/@types/engine/index.d.ts
export default class Game {
...
readonly rootUICanvas: UICanvas;
readonly rootUICamera: UICamera;
...
}
export default class UICamera extends BaseCamera {
...
readonly touchManager: TouchManager;
readonly touchEvent: Emitter<RawTouchEvents, TouchEvent>;
constructor(entity: Entity2D);
...
// 将事件坐标转换为UICanvas世界坐标
convertEventPositionToUICanvas(eventPosition: V2ReadOnly, dst?: Vector2): Vector2;
// 将事件坐标转换为齐次裁剪坐标
convertEventPositionToClip(point: V2ReadOnly, dst?: Vector2): Vector2;
// 将齐次裁剪坐标转换为UICanvas世界坐标
convertClipPositionToCanvas(point: V2ReadOnly, dest?: Vector2): Vector2;
}
export default class UICanvas extends MeshRenderer {
...
// 正交模式,屏幕适配的模式
get adaptationType(): UIAdaptationType;
set adaptationType(val: UIAdaptationType);
// 设计分辨率
get designResolution(): Vector2;
set designResolution(designSize: Vector2);
get deviceResolution(): Vector2;
set deviceResolution(resolution: Vector2);
...
constructor(entity: Entity3D & Entity2D);
...
addUILayerRoot(rootEntity: Entity2D): void;
...
}
UICanvas 组件则继承自 MeshRenderer 组件,它可以设置正交模式下的UI适配模式和设计分辨率,其中,正交模式应该是指 2D 模式中视图的投影方式,即正交投影,它意味着没有透视即没有近大远小; UI 适配模式则主要有4种,FitHeight(高度适配,左右方向可能会被剪裁或者越界)、FitWidth(宽度适配,上下方向可能会被剪裁或者越界)、Contain(根据屏幕大小选择 FitHeight 还是 FitWidth,以确保设计界面不会被剪裁)和Custom(不适配,两个方向都可能被剪裁或者越界);设计分辨率则是制作场景时使用的一种假定的、逻辑的分辨率,等到了运行时,它会再根据设备的实际比例和适配模式,计算出一个新的二维逻辑分辨率,作为这个二维世界的画布大小(也就是 engine.game.rootUICanvas.entity.transform2D.size)。稍后再详细介绍它的计算方式。
通过开发工具顶栏的“界面-新增标签-Scene Setting”,可以打开当前场景的设置面板。不同于 3D 场景主要进行天空盒、雾效等光照方面的设置,2D 场景可以设置的是当前的设计分辨率与适配模式,默认为如下的 1280 * 720 设计分辨率和 Contain 适配模式。
设计分辨率 -> 二维逻辑分辨率
然后,就可以了解 Scene 编辑面板中粉紫色方框的含义。文档中介绍它代表了游戏运行时的屏幕区域。实验发现,这个“屏幕区域”应该就是指二维世界的画布,因为在开发工具里它的大小一直是设备实际比例为 16: 9 (也就是 1280:720的比例,应该是开发工具目前默认这个值) 时由设计分辨率计算得出的二维逻辑分辨率。例如默认设置下,它就是一个 1280 * 720 的矩形;如果将设计宽度修改为 1000,设计高度修改为 900,适配模式依旧是 Contain,那么为了不被剪裁,Contain 便会实际采用 FitHeight模式,这时二维逻辑分辨率的高度依旧为 900,并按照 16:9 的比例计算出二维逻辑宽度为 1600,也就是粉紫色方框是个 1600 * 900 的矩形(下图左);如果再把适配模式强制修改为FitWidth,那么二维逻辑宽度便为 1000,高度则为 1000 * 9 / 16 = 562.5 (下图中);如果再把适配模式修改为Custom,那么二维逻辑分辨率就和设计分辨率一样,还是 1000 * 900(下图右)。
(个人觉得官方文档中给出的 Contain适配示例 有点问题,1280 * 720 的设计分辨率计算出 1280 * 586.79 的二维逻辑分辨率,这不就发生剪裁、违背Contain的适配逻辑了吗?所以个人猜测是不是应该是 aspectDevice > aspectDesign 时采用 FitHeight 。)
踩坑/小技巧:换到2D场景后,发现这个粉紫色方框没办法像在3D里那样拖动,也没找到有没有相关的快捷键。后来发现方框收放的时候会固定住鼠标的位置不动,例如下图左,如果方框的位置靠左下、想把它移动到中间的位置,就可以把鼠标悬停在方框左下角,放大,变成下图右的效果,然后再把鼠标移动到方框中间缩小,这样就相当于把方框移动到中间位置了,算是个小技巧吧hh。
创建一个 UI 节点 Label
首先,了解二维节点。按照新手引导,添加一个 Label UI 节点,可以看到 Scene 编辑面板中出现一段“Label”文本和一个蓝色方框,这个蓝色方框就负责标识当前 UI 节点的区域。
查看 Label 节点的 Inspector 栏。类似于一个 3D 节点必须挂载一个 Transform3D 组件,一个 2D 节点也需得挂载一个 Transform2D 组件,其默认设置如下。可以看到,相较于 Transform3D,Transform2D 也一样持有着位置、旋转、缩放等属性,不过少了 z 轴而已;此外它还额外持有了 size、pivot 这两个属性。其中,size 指定了 2D 组件的大小,也就是蓝色方框对应的区域 size;pivot 则指定了 2D 组件的包围盒(应该就是蓝框区域)锚点在x、y方向上的相对位置,初始默认(0.5, 0.5)就代表着包围盒的中心点。个人理解,锚点便是二维组件自身的坐标系原点。而二维组件在父节点中的位置,便是锚点的位置;二维组件的子节点的位置,便是其在二维组件坐标系中相对于锚点的位置。
二维节点的适配逻辑
至于上图 Transform2D 组件中被红框圈住的图标,则负责指定二维组件的适配逻辑。点击该图标,可以打开如下图左所示的一个5 * 5 方格的锚点预置面板,其中,行0 代表在 x 方向上的锚点设置,从左到右依次为 custom、left、center、right、stretchX(x方向拉伸),列0 也就是 y 方向从上到下则依次为 custom、top、middle、bottom、stretchY(y方向拉伸)。注意,这里的“锚点”是指当前二维组件相对于父节点包围盒进行偏移的锚点,例如如果选择了 行1、列1 对应的 left-top 锚点,就意味着当前二维组件包围盒的左上角会相对于父节点的包围盒的左上角进行对齐或者偏移。而面板上“shift:同时设置 entity 锚点”中的“锚点”,它才是先前介绍过的当前二维组件包围盒的锚点 pivot,例如如果选择 left-top 的同时点击 Shift,那么当前节点的 pivot 也会被设置为 (0, 1)。
适配策略默认为 custom-custom(也就是没有适配),而当选择了除默认以外的适配策略时,Transform2D 组件便会像上图右那样,新增对应方向上的 XXAnchor(相对父节点包围盒的锚点的相对位置)、offset(相对父节点包围盒的偏移绝对值) 和 relative(相对自身包围盒的偏移相对值) 的设置栏。如果适配策略选择了哪个方向上的位置锚点,那么就不可以再设置该方向上的 position,只能修改该方向上对应的那一个 XXAnchor 来调整相对位置;如果选择了哪个方向上的stretch,那么就不可以再设置该方向上的 size,只能修改该方向上的两个 XXAnchor 来调整相对比例。
举个例子,假设 Label 节点的初始设置为 position(0, 0)、size(32, 32),为它选择适配策略为 stretchX-middle,那么它的 position 和 x 方向上的 size 就不可以再自定义设置,新增的设置栏中 bottomAnchor 和 bottom offset 也不可设置;此时将 XXAnchors 设置为 (0.2,0.8,0.6,*),offset 设置为 (300, 32, 100, *),relative 设置为(0.1, 0.6),那么会得到一个新的 size(500, 32),其中 500 = 280 * (0.8 - 0.2) - 300 + 32;新的 position(216, 175.2),其中 216 = 300 / 2 + 32 / 2 + 500 * 0.1,175.2 = 720 * (0.6 - 0.5) + 100 + 32 * (0.6 - 0.5) 。(这部分虽然设置项很多但还是比较容易理解的。)
然后,了解 UILabel 组件。Label 节点会默认挂载一个 UILabel 组件,它负责框架中基础的文本渲染,继承自 Renderable2D(所有需要绘制的 2D 组件都继承自该类,包括 UIGraphic、UISprite等)。下面简单介绍几个它比较常用的属性:UILabel 组件支持 ttf 和位图字体(其中,ttf 也就是 TrueTypeFont 是一种矢量字体,通过数学曲线来描述字形;位图字体也就是 BitmapFont 是一种点阵字体,用一组二维像素来描述字形),并提供 bitmapFont、font、fontFamily (优先级递降)这3个属性来设置字体(前两个分别是位图字体和 ttf 字体类型资源,后一个是字体名称);align 和 valign 这两个属性则分别设置水平与垂直方向上的对齐方式;还有wordWrap 指定是否自动换行,autoSize 指定是否自动变化节点大小(也就是 Label 的 size),bestFit 指定是否自动适配文本大小(也就是字体的 size),它们合作使用可以实现不同的适配效果。
最后,大致看一下这一步骤中挂载的脚本组件 ScoreScript。它的工作比较简单,就只在 onAwake() 阶段注册两个事件监听器,分别监听 得分 与 游戏结束 这两类事件,并依此更新 UILabel 的文本内容。
@engine.decorators.serialize("ScoreScript")
export default class ScoreScript extends engine.Script {
...
public onAwake() {
// 直接设置 UILabel 的文本内容
this.uilabel = this.entity.getComponent(engine.UILabel);
this.uilabel.text = "0";
engine.game.customEventEmitter.on(EventTypes.GET_SCORE, (getScore) => {
...
// 根据得分更新 UILabel 文本
});
engine.game.customEventEmitter.on(EventTypes.GAME_END, () => {
...
// 根据游戏阶段更新 UILabel 文本
});
}
public onDestroy() {
...
// 移除事件监听器
}
}
添加 TouchInput 组件
这一步骤创建了一个 UI 节点 Sprite ,来作为游戏中的操作摇杆。创建起始它默认挂载了一个 Transform2D 组件和一个 UISprite 组件,后者同样继承自Renderable2D,在二维世界中负责二维图片的渲染,关于它的细节下一小节再介绍。本小节重点介绍操作摇杆挂载的触摸输入组件 TouchInputComponent 和脚本组件。
首先,了解 TouchInputComponent。二维系统内置提供了两个Event类组件,KeyboardInputComponent 和 TouchInputComponent,其中,前者负责提供唤起或收起键盘、监听键盘输入等能力,后者则负责提供监听和处理触摸事件的能力。
查看 TouchInputComponent 组件,可以看到它主要有两个可设置的属性:touchThrough,用于指定是否允许触摸事件穿透(也就是是否将触摸事件传递给被当前二维组件遮挡住的其它元素);hitArea,用于指定可响应触摸事件的区域,默认为 null 时,它覆盖的便是当前二维组件的整个矩形区域,自定义设置则是使用 x、y 指定 hitArea 中心点相对于二维组件锚点 pivot 的位置,width 和 height 指定 hitArea 大小。
然后,了解添加触摸事件处理回调的两种方式。
触摸事件监听与处理方式一:Script 内置函数
在上一篇博客对 3D 场景的介绍中有提到,新手引导示例里的触摸事件是由二维世界接收、处理,再通过框架的事件系统 engine.game.customEventEmitter 传递给 3D 世界的。而接收并处理触摸事件的这部分功能,正是由这一步骤中的 PanelScript提供。
(框架的事件系统 engine.game.customEventEmitter 实现 2D 与 3D 世界之间的通信)
查看 PanelScript 代码如下。可以看到,脚本组件内实现了一系列的 onTouchXXX 触摸事件处理函数,根据具体的处理逻辑,可以将它们大概分为3个阶段:onTouchStart与onTouchEnter 触摸开始,onTouchMove 触摸操作过程中,和 onTouchEnd、onTouchLeave、onTouchCancel 触摸结束。每个阶段,Panel 都会接收一个触摸输入事件 e,在基于它计算出相应的移动方向 direction 后,再使用 emit 触发事件 TOUCH_MOVE 的监听器,将触摸操作的 direction 传递给 3D 世界。
// 文件 assets/tutorial/DemoGameTutorial/script/components/PanelScript.ts
...
@engine.decorators.serialize("PanelScript")
export default class PanelScript extends engine.Script {
...
public onAwake() {
...
// 注册监听 GAME_END 事件
engine.game.customEventEmitter.on(EventTypes.GAME_END, () => {
...
});
}
// 触摸开始事件
// onTouchEnter 触摸进入事件 与 onTouchStart 方法体一致
public onTouchStart(s: engine.TouchInputComponent, e: TouchInputEvent) {
...
this.handleTouch(e);
}
// 触摸移动事件
public onTouchMove(s: engine.TouchInputComponent, e: TouchInputEvent) {
this.handleTouch(e);
}
// 触摸结束事件
// onTouchLeave 触摸离开事件、onTouchCancel 触摸取消事件 与 onTouchEnd 方法体一致
public onTouchEnd(s: engine.TouchInputComponent, e: TouchInputEvent) {
...
this.emitDirection({ x: 0, y: 0, z: 0 });
}
// 处理触摸事件 e
public handleTouch(e: TouchInputEvent) {
// buttonRadius 指的是当前 Panel 组件的半径 (size.x 与 size.y 的一半)
this.direction.x = e.touches[0].position.x / this.buttonRadius.x;
this.direction.y = e.touches[0].position.y / this.buttonRadius.y;
// 转换到 3D 环境中,只在 x 与 z 方向上移动
this.emitDirection({ x: -this.direction.x, y: 0, z: this.direction.y });
}
// 触发事件 TOUCH_MOVE
public emitDirection(direction: { x: number; y: number; z: number; }): void {
engine.game.customEventEmitter.emit(EventTypes.TOUCH_MOVE, direction);
}
public onDestroy() {
// 移除事件监听器
...
}
}
先了解触摸输入事件 TouchInputEvent,它持有一个重要属性 touches,这是一个触点 TouchPoint 数组,每个触点包含了3个记录坐标信息的属性,position、worldPosition和startWorldPosition。至于为什么触点会放到一个数组中,则是因为可能会同时有多个触点触发同一种事件(就是 TOUCH_START、TOUCH_MOVE 这些事件),这种情况下不会产生多个同种事件,而是只产生一个该种事件,并由它的 touches 数组管理多个触点。在此基础上,查看 PanelScript 中处理 TouchInputEvent 的代码,e.touches[0].position,可以发现它取出了当前触摸事件的第一个触点的 position。而根据它对 position 的处理逻辑,可以推断出这个 position 便是触点在 Panel 中的本地坐标。由此,也就理解了新手引导对用户触摸操作的处理逻辑:依据用户触摸点在 Panel 中的坐标计算调整 3D Player 的移动方向。
接着,了解这些 onTouchXXX 函数是如何监听触摸事件的。查看 Script 类的定义代码如下,可以发现,Script 这个脚本组件的基类除了定义了 onAwake、onDestroy 这些生命周期回调外,还定义了触摸事件与键盘事件的相关回调 onXXX。参考 KeyboardInputComponent 对监听原理的介绍,可以知道,只需将脚本组件与触摸输入/键盘输入组件挂载到同一个 entity 上,那么输入组件便会自动寻找 entity 中实现了相应的 onXXX 类回调函数的组件,并在事件发生时调用相应的回调函数。
// 来自文件 assets/node_modules/@types/engine/index.d.ts
export default class Script extends Component {
constructor(entity: Entity);
// 生命周期相关
onAwake?(): void;
onStart?(): void;
onUpdate?(dt?: number): void;
onLateUpdate?(dt?: number): void;
onDestroy?(): void;
onFixedUpdate?(): void;
...
// 触摸事件相关
onTouchStart?(comp: TouchInputComponent, event: TouchInputEvent): void;
onTouchEnd?(comp: TouchInputComponent, event: TouchInputEvent): void;
onTouchMove?(comp: TouchInputComponent, event: TouchInputEvent): void;
onTouchCancel?(comp: TouchInputComponent, event: TouchInputEvent): void;
onClick?(comp: TouchInputComponent, event: TouchInputEvent): void;
...
// 键盘事件相关
onKeyboardShow?(comp: KeyboardInputComponent, event: engineWX.KeyboardEvent): void;
onKeyboardInput?(comp: KeyboardInputComponent, event: engineWX.KeyboardEvent): void;
onKeyboardComplete?(comp: KeyboardInputComponent, event: engineWX.KeyboardEvent): void;
onKeyboardConfirm?(comp: KeyboardInputComponent, event: engineWX.KeyboardEvent): void;
...
}
所以简单总结,监听与处理触摸事件的第一个方式:1、将 TouchInputComponent 与 脚本组件同时挂载到目标 entity 上;2、在脚本组件中注册相关触摸事件回调函数 onXXX (comp, event);3、在回调函数内部根据 comp 和 event 实现处理逻辑。
触摸事件监听与处理方式二:组件添加委托回调
官方文档中,还介绍了另一种 直接向 TouchInputComponent 组件添加触摸回调 的方式,示例代码如下。可以看到,这种方式是直接获取 entity 挂载的 TouchInput 组件对象,并向该组件对象的一系列 onXXX 属性直接 add 添加相应的回调函数。
// 来自官方文档示例
const comp = entity.getComponent(engine.KeyboardInputComponent);
comp.onTouchStart.add(function (comp, event) {
...
});
comp.onTouchEnd.add(function (comp, event) {
...
});
...
那么这些 onXXX 属性是什么含义呢?又是怎么通过 add 添加回调的?继续查看 TouchInputComponent 的定义代码,可以看到它继承自一个触摸响应基类 Touchable,再查看 Touchable 的定义代码,可以看到它声明了一系列的类型为 Delegate<Touchable, TouchInputEvent> 的 onXXX 属性;再查看 Delegate<S, E>的定义代码,可以发现这个泛型类主要是通过 add、remove、invoke 等方法来管理 DelegateHandler<S, E> ;而 DelegateHandler<S, E> ,便是 (S, E) => any 类型的回调函数。
// 2D点击组件,负责点击判定和事件分发。
export default class TouchInputComponent extends Touchable {
// 触摸事件类型枚举值,如 "TOUCH_START"、"TOUCH_MOVE"、"TOUCH_END"等
static TOUCH_EVENTS: typeof TouchEventNames;
// 是否允许事件穿透。
touchThrough: boolean;
// 点击判定区域。
hitArea: Nullable<Rect>;
// 点击判定的移动阈值,超出阈值则不视为点击事件。
clickMovementThreshold: number;
}
// 触摸响应类
export class Touchable extends Component {
get onTouchStart(): Delegate<this, TouchInputEvent>;
get onTouchEnd(): Delegate<this, TouchInputEvent>;
get onTouchMove(): Delegate<this, TouchInputEvent>;
get onTouchEnter(): Delegate<this, TouchInputEvent>;
get onTouchLeave(): Delegate<this, TouchInputEvent>;
get onTouchOver(): Delegate<this, TouchInputEvent>;
get onTouchCancel(): Delegate<this, TouchInputEvent>;
get onTouchOut(): Delegate<this, TouchInputEvent>;
get onTouchUp(): Delegate<this, TouchInputEvent>;
get onClick(): Delegate<this, TouchInputEvent>;
}
...
// Delegate 委托 与 DelegateHandler 委托回调
type DelegateHandler<S, E> = (sender: S, eventArgs: E) => any;
export class Delegate<S, E> {
add(handler: DelegateHandler<S, E>, clearable?: boolean): void;
remove(handler: DelegateHandler<S, E>): void;
invoke(context: S, eventArgs: E): void;
clear(): void;
dispose(): void;
}
也就是说,TouchInputComponent 持有着各个触摸事件对应的 Delegate 属性 onXXX,只需通过 Delegate 的 add 方法向其添加 (TouchInputComponent, TouchInputEvent) => any 类型的回调函数,组件就可以进行后续的回调管理了。
所以简单总结,监听与处理触摸事件的第二个方式:1、获取 entity 挂载的 TouchInputComponent 组件;2、向该组件对应的 onXXX 属性 add 添加 (comp, event) => any 类型的回调函数;3、在回调函数内部根据 comp 和 event 实现处理逻辑 。
添加图片资源 SpriteFrame
首先,了解 UISprite 组件、SpriteFrame 图片切片资源 和 Texture2D 纹理资源之间的关系。
前面介绍过,UISprite 组件是二维世界中负责图片渲染的基础组件。而对 UISprite 来说,这个要被渲染的图片资源,就是由一个 SpriteFrame 类型的图片切片属性指定的;而对 SpriteFrame 来说,这个图片的纹理的数据源,便是由一个 Texture2D 类型的贴图属性提供。
查看它们的代码:UISprite 继承自 Renderable2D 这个渲染组件,并持有着一个指定图片切片资源的属性 spriteFrame,和一系列指定图片渲染方式的属性,例如图片显示类型 type、翻转类型 flip 等;SpriteFrame 和 Texture2D 则都是最终继承自 BaseResource 这个资源基类,其中,SpriteFrame 持有一个指定贴图资源的属性 texture,和一系列指定贴图区域的属性,例如展示贴图区域 rect、九宫格区域 slicedRect 等,而 Texture2D 作为一个数据源,除了纹理宽高、纹理过滤模式 filterMode、纹理包裹模式 wrapU 和 wrapV 等属性外,还需要传入真正的纹理数据“源头”,例如图片 Image、画布 Canvas、原始 RGBA buffer 等,来进行纹理的初始化。
// 来自文件 assets/node_modules/@types/engine/index.d.ts
export default class UISprite extends Renderable2D {
...
// 图片的显示类型
type : UISpriteType // get + set
// enum UISpriteType { Simple = 0, Sliced = 1, Tiled = 2, Filled = 3 }
// 图片的翻转类型
flip : UISpriteFlipType // get + set
// enum UISpriteFlipType { Nothing = 0, Horizontally = 1, Vertically = 2, Both = 3 }
// 图片 tiled 模式下的缩放
tileScale : number // get + set
// 图片填充模式下的填充方式
fillDir : UISpriteFillDirectionType // get + set
// enum UISpriteFillDirectionType { Horizontal = 0, Vertical = 1, Radial90 = 2, Radial180 = 3, Radial360 = 4 }
// 图片填充模式下的填充程度,范围0~1
fillAmount : number // get + set
// 图片填充模式下是否反向填充
invertFill : boolean // get + set
// 图片资源
spriteFrame : Nullable<SpriteFrame | OpenData> // get + set
// 是否使用灰阶
grayScale : boolean // get + set
...
}
export default class SpriteFrame extends BaseResource {
// 贴图。
get texture(): Nullable<Texture2D>;
// 贴图在图集中的区域。
get rect(): Rect;
// 贴图在图集中的边框大小。
get trim(): Vector4;
// 九宫格裁剪位置在Rect中的相对位置。
get slicedRect(): Rect;
// 从贴图资源创建图片资源。
static createFromTexture(texture: Texture2D | RenderTexture, rect?: Rect, slicedRect?: Rect, trim?: Vector4): SpriteFrame;
...
}
export default class Texture extends BaseResource {
}
export default class Texture2D extends Texture {
// 贴图的填充模式。
get filterMode(): TextureFilterMode;
// 各向异性参数,有效值为1-16。
get anisoLevel(): number;
// 横向采样包围模式。
get wrapU(): Kanata.EWrapMode;
// 纵向采样包围模式。
get wrapV(): Kanata.EWrapMode;
// 贴图高。
get height(): number;
// 贴图宽。
get width(): number;
// 创建Texture2D。
constructor(desc?: ITexture2DDesc);
// 使用Image来初始化。
initWithImage(image: Kanata.IImage, generateMipmap?: boolean, needUnpackPremultiplyAlpha?: boolean): boolean;
// 使用wxCanvas来初始化。
initWithCanvas(canvas: HTMLCanvasElement): boolean;
// 使用原始RGBA buffer进行初始化
initWithRGBABuffer(arraybuffer: ArrayBufferView, width: number, height: number, generateMipmap?: boolean, needUnpackPremultiplyAlpha?: boolean): boolean;
...
}
简单总结一张图片的渲染所经过的路径
Image/Canvas/Buffer... -> Texture2D 资源 -> SpriteFrame 资源 -> UISprite 组件 -> Sprite 节点
文档 二维资源制作 一节还介绍了 Texture2D 和 SpriteFrame 资源的制作方法。一般来说,向 Project 添加一张图片,它便会自动处理为一个 Texture2D;以一张新添加的图片 sprite_demo.png 为例,点击查看 Inspector 栏,可以看到这个 image 的初始默认类型为 texture2d,也就是一个纹理资源(这时是无法拖动到 UISprite 的 spriteframe 框的);将其更改为 spriteframe 后,就可以在相应的 SpriteframeSetting 栏设置它的 rect 和 slicedRect 区域(这时才可以拖动图片把它当作一个 SpriteFrame 资源使用)。另外还可以新建 SpriteFrame 资源,设置不同的 rect 来让它展示一张贴图的不同区域,也就是说,一个 Texture2D 可以对应多个 SpriteFrame,而 SpriteFrame 简单来看就是 Texture2D + Rect。
然后,了解 UISprite 组件的 4 种渲染模式,Simple 基础矩形图像渲染,Sliced 九宫格图像渲染,Tiled 平铺渲染 和 Filled 填充渲染。
先要明确的一点是,UISprite 的渲染区域即是节点的 Transform2D 组件指定的包围盒区域,渲染内容则是 SpriteFrame 的 rect 指定的 Texture2D 的对应矩形区域。在此基础上,默认的 Simple 模式便是简单地将这个 rect 矩形区域缩放并填充到包围盒中;Sliced 模式则是像常规的九宫格图片那样,缩放时保持 slicedRect 指定的四角不变;而Tiled 平铺模式,它是先根据指定的 tileScale 对原始 rect 区域进行缩放,然后再从包围盒的左上角开始,一块块地铺满这个缩放后的 rect;Filled 模式则是在填充到包围盒时允许指定填充方向(例如水平、垂直、放射状)和填充比例。
踩坑:为什么 UISprite 的 Tiled 平铺渲染模式没有生效?
上图从左到右依次展示了4种模式下的渲染效果,可以看到,Tiled 模式并没有按照定义说的那样一块块重复铺满整个渲染区域。我先是在微信官方社区搜索了相关 bug,发现也有人遇到了这个情况,但暂时还没有提供解决方法(链接),所以这一小节我就尝试独立探索了一下这个问题,又因为实在不了解这块,着实绕了好大一圈弯路。好在最后还是靠着 Unity 的官方文档和相关社区确定了原因和解决方案。
首先,很多微信小游戏框架没有提到的知识点,其实都可以在 Unity、Cocos 这些比较流行的游戏引擎的官方文档和社区里搜索试试看,它们的资料和问答会更丰富些。例如 Unity 的 Image.Type.Tiled 一节,就同样介绍了图像在 Sprite 中的平铺模式,并提到该模式需要“将 Sprite.texture 包裹模式设置为 TextureWrapMode.Repeat”。
所以 Tiled 模式失效的问题探索,就先从包裹模式入手。
前面对 Texture2D 的介绍中,曾提到过它的用于指定横向与纵向包裹模式的两个属性, wrapU 与 wrapV,其中,包裹模式是指“当纹理坐标超出了0到1的范围时,纹理的表现方式”。个人理解,以 UISprite 的渲染场景为例,“超出了0到1的范围”就是指当渲染区域大于纹理区域时,渲染区域超出纹理区域的那一部分,这时“纹理的表现方式”,就是指纹理填充超出的这部分渲染区域的方式。而横向与纵向的包裹模式,就分别指定了横向与纵向上的纹理填充方式。
查看 WrapMode 的相关定义代码,可以发现框架目前主要提供3种包裹模式,REPEAT、CLAMP_TO_EDGE 和 MIRRORED_REPEAT。其中,REPEAT 表示纹理会整块地重复并平铺到超出的渲染区域,MIRRORED_REPEAT 则是先镜像再重复平铺,而 CLAMP_TO_EDGE,它只重复纹理边缘部分。
// 来自文件 assets/node_modules/@types/engine/index.d.ts
export enum EWrapMode {
REPEAT = 1,
CLAMP_TO_EDGE = 2,
MIRRORED_REPEAT = 3
}
了解了 CLAMP_TO_EDGE 的定义后,我发现之前选用的示例图片 sprite_demo.png 恰好是一个边缘透明的图片,于是又重新尝试向 Tiled 模式下的 SpriteFrame 拖入其它不透明图片,如下两图,可以看到的确是使用纹理的边缘部分重复铺满了整个渲染区域。由此可知,目前 Tiled 模式不生效应该是 Texture2D 的包裹模式为 CLAMP_TO_EDGE 造成的。
再查看各个图片的 TextureSetting 栏,如下左图为 Tutorial 提供的 plane.jpg 和 particle.png 的设置面板,右图则对应 panel.png 和新添加图片,这时就发现问题所在了:有些图片只有 CLAMP_TO_EDGE 这一种包裹模式可选。
在比较这些图片的基础信息、项目位置、资源配置并均无果后,终于根据 Wrap Mode、REPEAT 等关键字找到了相关资料:Unity图片Wrap Mode设置为Repeat在WebGL1.0上不支持。
原来,在 WebGL1.0 上,只有尺寸为2的整数次幂的图片才可以设置 REPEAT 包裹模式。再查看上图左右对应的图片尺寸,发现确实是这样。于是又修改 sprite_demo.png 的尺寸为2的次方,Tiled 模式就终于生效了,渲染效果如下图:(初学者真的完全没想到是图片尺寸的问题......)
保存 2D 场景
这步就没什么要介绍的了,2D 场景的创建与编辑也先学习到这里。
2、播放场景
指定播放场景
首先,简单了解微信小游戏的 资源工作流 和 播放态。官方文档中,将整个资源流程分为了 导入、创建、构建、上传、下载、使用 这六个阶段。目前的学习记录其实才主要涉及到前两个阶段:导入,主要指将图片、音频等资源导入到 Project 中,并把它们存放到 assets 这个本地资源目录下;创建,主要指基于已导入的资源,在 assets 目录下进行场景编辑、预制体编辑、脚本组件编辑等游戏开发工作,这样编辑的过程也被称作是一种编辑态。而在创建阶段中、正式的游戏构建也就是打包之前,框架还提供了一个可在编辑态之间来回切换的播放态,该状态允许小游戏在开发工具中快速地运行与调试,例如预览场景、预览粒子特效等等。
根据文档对 项目结构 的介绍, 播放态工作在 minigame/_temp 目录下。其中,minigame 目录主要用于存放小游戏的运行产物,例如构建阶段打包生成的资源文件包,而 _temp 则是一个临时资源目录,主要存储编译后的资源以支持播放态,例如 .ts 脚本编译后的 .js 文件。如下展示了一个小游戏项目在创建阶段时的文件目录结构,可以看到这时在 minigame 下只有一个 _temp 目录。
├─assets *** 存放本地资源文件
│ ├─node_modules // npm 包,包括 eventemitter3、框架的 d.ts (本篇频繁提到的index.d.ts) index.d.ts)
│ ├─.buildin
│ ├─workers // 多线程 Worker 代码的指定目录
│ ├─openDataContext // 开放数据域代码的指定目录
│ ├─tutorial // 新手引导的代码与资源
│ │ └─DemoGameTutorial
│ │ ├─materials
│ │ ├─script
│ │ └─tutorial
│ └─Assets // 自定义的代码与资源
│ └─DemoGame
├─minigame *** 存放运行产物,
│ └─__temp // _temp,存放编译后的资源,只在播放态时使用
│ ├─__scripts
│ │ ├─node_modules
│ │ ├─miniprogram_npm
│ │ ├─.buildin
│ │ └─tutorial
│ │ └─DemoGameTutorial
│ │ ├─script
│ │ └─tutorial
│ ├─tempProcess
│ ├─__effect
│ └─Db
而当按照本步骤引导点击“2d/3d演示”框时,可以看到如下操作面板,它要求指定 3D 与 2D 各自的入口场景。上一篇博客曾介绍过 构建时的入口场景:整个游戏启动的首场景,它静态依赖的所有美术资源都会被打包进主包。而播放态时的“入口场景”意义可能会更简单些,它就是要预览的首场景。
播放场景
这一步比较简单。点击“播放”后视图如下,可以在 Game 栏进行真实的触摸操作,也可以在 Scene 栏点击 绿色3D/2D小框 切换 2D/3D 场景,或者调整视角查看实时的游戏场景。注意,在播放态“停止”之前都是无法编辑并保存的,如果出现编辑总是不生效的状况,可以检查一下是不是没有停止播放态。
3、构建游戏场景
关于微信小游戏的 构建与产物,上一篇博客有简单介绍过 主包、资源包、入口资源、入口场景这些基础概念,本步骤将从实际构建过程出发,继续了解小游戏构建与产物的相关知识点。
首先,按照新手引导操作,可以看到如下两图所示的 Build 栏 与 Build 选项框,其中,Build 选项框需要指定构建时的 3D 与 2D 入口场景和远程资源(也就是上传到 CDN 的资源包)上传方式,默认为“工具本地局域网预览能力”。
点击“确定”并等待构建完成后,就遇到实际问题了:提示主包超过 4M,无法上传。点击“分析构建结果能否上传”,可以看到是“首场景及跟随主包的入口资源体积”过大。
这时,再次查看 minigame 目录结构,结果如下。可以看到,构建之后新增了一个存放构建产物的 minigame/assets 目录,而它下面主要有两个目录 IDEPack 和 IDEBuildIn,用来存放打包后的资源文件,其中,IDEPack 对应需被上传到 CDN 的资源包,IDEBuildIn 对应主包。它们各自持有一个 register_XXX.json 文件来记录一个个资源包的地址与资源映射关系,例如 IDEPack 的 register 文件中就记录了入口资源 Cube.prefab 所对应的资源包地址,IDEBuildIn 记录的则是系统内置资源(如 Image2D 等)和3D 与 2D 入口场景资源。
├─minigame
│ │
│ ├─__temp
│ │
│ └─assets // 构建出来的资源目录
│ │
│ │ game.js // 启动文件 js
│ │ registerInfo.json // buildIn 和 idePack 的资源映射文件信息
│ │
│ ├─tutorial
│ │
│ ├─miniprogram_npm
│ │
│ ├─IDEPack // 资源文件打包,要被上传到远程服务器 (大小 3.17KB)
│ │ │ register_404a7962.json // 资源映射文件
│ │ │
│ │ ├─uid_6628c6R-2d5290E-e11621S-739969R_1023fd72
│ │ │ combine_0.bin
│ │ │
│ │ └─uid_7cdd9eR-33670aE-1002d4S-590f2aR_3c0aa196
│ │ combine_0.bin
│ │
│ └─IDEBuildIn // 资源文件打包,会被传到主包中 (大小 8.11M)
│ │ register_ab56e239.json // 资源映射文件
│ │
│ ├─in_uid_769610R-923901E-302c63S-76c641R_f256591b
│ │
│ ├─in_uid_822a17R-a8d0cfE-45d126S-6f7b6bR_7bf055a8
| ................................................
│ │
│ ├─in_dep_54336deeda64997b64ebf26767348226_861a193b
│ │
│ ├─in_dep_c36d78395a5d34fd4f173ab172bf659a_18c1eb99
│ │
│ └─in_dep_cfa4f617892e8cc2a5fdb7506f52fbc5_6ddc59b8
│
// IDEPack 的 register_XXX.json
{
"groups":{
"uid_6628c6R-2d5290E-e11621S-739969R":{
"url":"IDEPack/uid_6628c6R-2d5290E-e11621S-739969R_1023fd72","size":1263
},
"uid_7cdd9eR-33670aE-1002d4S-590f2aR":{
"url":"IDEPack/uid_7cdd9eR-33670aE-1002d4S-590f2aR_3c0aa196","size":1403
}
},
"assets":{
// 入口资源 Cube.prefab
"Assets/DemoGame/Cube.prefab":[
"uid_6628c6R-2d5290E-e11621S-739969R",
"IDEbuildScript",
"in_dep_54336deeda64997b64ebf26767348226",
"in_dep_c36d78395a5d34fd4f173ab172bf659a"
]
}
}
// IDEBuildIn 的 register_XXX.json
{
"groups":{
"IDEbuildScript":{
"url":"file:///assets","size":0
},
"in_uid_769610R-923901E-302c63S-76c641R":{
"url":"file:///assets/IDEBuildIn/in_uid_769610R-923901E-302c63S-76c641R_f256591b",
"size":2513
},
...
"in_dep_54336deeda64997b64ebf26767348226":{
"url":"file:///assets/IDEBuildIn/in_dep_54336deeda64997b64ebf26767348226_861a193b",
"size":386456
},
...
},
"assets":{
// 内置资源
"System::Effect::Image2D":["in_uid_769610R-923901E-302c63S-76c641R"],
"System::Effect::Blit":["in_uid_998444R-4e8cd1E-52b60bS-d1c239R"],
...
// 入口场景资源
"Assets/DemoGame/DemoGame3d.scene":[
"in_uid_d1af7bR-e305ddE-6993e4S-2eea83R",
"IDEbuildScript",
"in_dep_54336deeda64997b64ebf26767348226",
"in_dep_c36d78395a5d34fd4f173ab172bf659a",
"in_dep_cfa4f617892e8cc2a5fdb7506f52fbc5"],
"Assets/DemoGame/DemoGame2d.scene":[
"in_uid_80ec1cR-e8659cE-c8e1d1S-869393R",
"IDEbuildScript"]
}
}
查看 minigame/assets/game.js 这个启动文件, 可以看到它大致的流程为:加载 BuildIn 内置资源 -> 创建 game、加载入口场景 -> 加载 Pack 资源、开启入口场景、运行 game。
// 来自文件 minigame/assets/game.js
function main() {
...
// register_XXX.json 资源映射表
var registerInfos = [{
"platform": ["default"],
"buildin": "register_ab56e239.json",
"idePack": "register_404a7962.json"
}]
...
engine.loader.register(buildinRegisterPath, urlPrefix, true).then(function fn() {
var buidinAssets = ["System::Effect::Blit", "System::Effect::FastBloom", ...];
var buidinAssetsTasks = [];
for (var i = 0; i < buidinAssets.length; i++) {
buidinAssetsTasks.push(engine.loader.load(buidinAssets[i], {...}).promise);
}
// 加载 IDEBuildIn 内置资源
engine.LitePromise.all(buidinAssetsTasks).then(function(assets) {
engine.Effect.BuildInEffects = assets;
// 创建 global game
var game = GameGlobal.game = new engine.Game(720, 1280);
// 加载 entry scenes 入口场景
var scenesPromises = [
engine.loader.load("Assets/DemoGame/DemoGame3d.scene", { ... }).promise,
engine.loader.load("Assets/DemoGame/DemoGame2d.scene", { ... }).promise
];
engine.LitePromise.all(scenesPromises).then(function(scenes) {
// 加载pack资源
if(true){ // 如果 packRegister 过于巨大,就需要手动 register
engine.loader.register(packRegisterPath, urlPrefix).catch(...);
}
// 开启入口场景
game.playScene(scenes[0]);
game.playScene(scenes[1]);
// 运行 game.run()
runGame();
}).catch(...);
}).catch(...);
}, function fn(error) {
console.error('buildin 内置资源注册表 加载失败', error.message, error.stack);
});
}
已知目前的问题是 IDEBuildIn 也就是包含首场景在内的主包体积过大,所以期望的解决方案是减少首场景资源。我主要尝试了两个方法:1、在构建选项选择“将首场景放在远程”(失败);2、动态加载场景(成功)。
减小主包体积方式一:首场景放在远程(失败)
Build 选项框中有一个选项“将首场景放在远程”,选中之后再次构建,发现主包体积确实可以减少到 4M 以下。此时查看 minigame/assets 目录结构,可以看到两个入口场景对应的资源文件包都从 IDEBuildIn 移动到了 IDEPack 目录。
│ └─assets
│ ├─IDEPack // 首场景资源移动到了这里
│ │ │
│ │ ├─uid_6628c6R-2d5290E-e11621S-739969R_1023fd72
│ │ │
│ │ ├─uid_7cdd9eR-33670aE-1002d4S-590f2aR_3c0aa196
│ │ │
│ │ ├─uid_d1af7bR-e305ddE-6993e4S-2eea83R_89298bfa // 3D Scene
│ │ │
│ │ ├─uid_80ec1cR-e8659cE-c8e1d1S-869393R_86931620 // 2D Scene
│ │ │
│ │ ├─dep_90f6c9b5d81c5034fb8fbd18bd7c1cea_861a193b // 3D Scene
│ │ │
│ │ ├─dep_1c7c3943206944c0c4c2f33b059cff46_18c1eb99 // 3D Scene
│ │ │
│ │ └─dep_5757cf7420db6bb72a7e17c8c560e3b2_6ddc59b8 // 3D Scene
│ │
│ └─IDEBuildIn
│ │ register_c3fe958e.json
│ │
│ ├─in_uid_769610R-923901E-302c63S-76c641R_f256591b
│ .................................................
构建成功后,在 Build 栏选择“预览”或者“真机调试”,就可以扫描二维码并在真机运行这个小游戏。但是这时又出现了一个问题,那就是真机上只有 3D 的入口场景,2D Scene不见了。
再次查看 game.js 中入口场景加载部分的相关代码,可以发现,在这种构建模式下,启动文件先设置了全局性的 _main2DsceneKey 和 _main3DsceneKey,然后加载了一个内置的 loadingScene 作为首场景。
// 来自文件 minigame/assets/game.js
...
engine.LitePromise.all(buidinAssetsTasks).then(function(assets) {
...
// 加载入口场景: 只设置了 GameGlobal 全局 sceneKey
GameGlobal.__main2DsceneKey = "Assets/DemoGame/DemoGame2d.scene"
GameGlobal.__main3DsceneKey = "Assets/DemoGame/DemoGame3d.scene"
...
// 加载内置的 loading.scene
engine.loader.load(".buildin/Editor/loadingScene/loading.scene", {...}).promise.then(function (scene) {
// 只开启 loadingScene
game.playScene(scene);
runGame();
}).catch(...);
}).catch(...);
继续查看 loadingScene 处理 _main2DsceneKey 和 _main3DsceneKey 的相关代码,发现疑似问题如下:它根据全局性的 _mainXXsceneKey 同时开始加载 2D 和 3D 场景,但都赋值给了 this.game3dScene。猜测这是否是构建过程的一个 bug,又因为 3D 场景的加载要慢于 2D,所以 3D 场景最终总能覆盖并展示出来。
// 来自文件 minigame/assets/.buildin/Editor/loadingScene/script/loadMainScenes.js
loadMainScenes.prototype.loadMainScene = function () {
...
var main3DsceneKey = GameGlobal.__main3DsceneKey;
var main2DsceneKey = GameGlobal.__main2DsceneKey;
...
this.lt3d = engine_1.default.loader.load(main3DsceneKey);
this.lt3d.promise.then(function (_game3dScene) {
...
_this.game3dScene = _game3dScene;
...
});
...
this.lt2d = engine_1.default.loader.load(main2DsceneKey);
this.lt2d.promise.then(function (_game2dScene) {
...
_this.game3dScene = _game2dScene; // !!!!疑似问题
...
});
由于内置的 loadingScene 脚本难以修改,所以不确定是否是这个原因,目前只能暂时放弃这种方式。
减少主包体积方式二:动态加载场景(成功)
第二种方式的思路比较直接,就是 保留 2D Scene 作为入口场景 + 动态加载 3D Scene。
首先,回忆一下之前对入口资源的介绍:凡是会在代码中动态加载的资源,都需要设置为入口资源。所以,要动态加载 3D Scene,就需要先将 DemoGame3d.scene 设置为入口资源。如下图所示,直接在 Inspector 栏设置“Set As Entry Resource” 即可:
然后,在代码中加载 .scene 资源文件并播放。因为我目前还不清楚这种应用场景的最佳实践方案,所以暂时选择在 2D 场景的脚本组件 PanelScript 的 onAwake() 回调中加载 3D Scene,示例代码如下:
// 文件 PanelScript.ts
public onAwake() {
...
// 动态加载 3D Scene
engine.loader.load("Assets/DemoGame/DemoGame3d.scene").promise.then(function (scene: engine.Scene) {
// 播放 3D Scene
engine.game.playScene(scene);
});
}
选择不启用 3D 入口场景后重新构建项目(具体 Build 选项设置如下图左),构建成功后,主包体积同样成功缩减到 4M 以下(如下图右)。
这里只查看 IDEBuildIn 目录的 register_XXX.json 文件,如下所示,可以看到此时该目录下就只剩下了 2D Scene 的资源映射。
{
"groups":{
...
},
"assets":{
"System::Effect::Image2D":[...],
...
"Assets/DemoGame/DemoGame2d.scene":[
"in_uid_80ec1cR-e8659cE-c8e1d1S-869393R",
"IDEbuildScript"]
}
}
再次在真机预览,可以发现,现在游戏就可以成功运行并进行触摸操作了,效果如下:
总结
官方教程 “制作简单游戏的新手引导” ——包括游戏场景的编辑、播放、构建与真机预览等阶段——到这里就基本完成了。本篇博客重点介绍了 2D 场景与二维世界的渲染与编辑、触摸事件的监听与处理、小游戏的资源工作流等基础知识,并新增了对部分框架内置代码的解析以加深理解。
此外,本章还尝试探索并解决了一些实际遇到的 bug,可以发现,微信小游戏作为一个游戏引擎框架,是有相当多的知识点需要后续继续扩展了解的。所以下一阶段,我计划就从开发一个真实的小游戏入手,并在解决实际问题的过程中学习相关知识点。
标签:engine,...,场景,触摸,微信,2D,小游戏,组件,新手 From: https://blog.csdn.net/mimilabo/article/details/140440270