本文是总结用pixijs实现一个 人物换装的H5 2D游戏
如果你对这个游戏感兴趣,就跟我走
如果你还不了解pixi的用法,可以看这篇文章
本文目录
1、游戏介绍
2、代码实现
3、代码仓库
游戏介绍
如果你体验了上面的地址,就可以知道玩法挺简单
大概就是 添加人物,换发型,换衣服,添加饰品,更换背景
人物可以拖动,缩放,旋转,并且支持多人物
游戏功能看着虽然简单,实现的逻辑可一点都不简单
先来介绍下整个角色的组成部分
1角色组成
一个角色有五个组成部分:头发,配饰,表情,上衣,裤子
这五个部分都可以单独更换素材,这样就可以自定义搭配出各式各样的人物了
2素材介绍
因为人物分为五个部分,所以 素材有五种,但是素材大小不一,位置也不太一样,那不是每个素材都要单独调整位置才能渲染到合适的位置
想想就很麻烦,一百多个素材逐个调整位置的话,想到就要爆炸了
我们的处理是,固定所有素材的大小比例,其中的位置差异交由 设计去调整
甚至我们可以按照人物的宽高 去 固定所有素材的宽高,然后交给设计去控制图片内素材的位置
这样渲染的时候都不用设置位置,直接图片重叠就可以了
但是考虑到会增加图片大小,所以我们还是会适当裁剪,在图片大小和素材位置折中,尽量减少图片大小,也要简化设置素材位置的代码
代码实现
这里主要是讲解代码实现的主要逻辑,不会涉及繁枝细节。
主要分为这几个部分去讲解
1、数据介绍
2、代码架构图
3、人物渲染
4、赋能逻辑
5、事件监听
1
数据介绍
看下素材的数据结构
{
name: '基础上衣', // 对应的唯一名字
dataType: DataType.JACKET, // 素材类型
width: 0,
height: 0,
belong: [ROLE_MAP.Man, ROLE_MAP.Women], // 素材所属人物
thumbnail: "xxxx-min.png", // 缩略图
textureUrl:"xxxx.png", // 实际图
},
{
name: '无语',
dataType: DataType.FACE,
width: 0,
height: 0,
belong: [ROLE_MAP.Man, ROLE_MAP.Women],
thumbnail: "xxxx-min.png", // 缩略图
textureUrl:"xxxx.png", // 实际图
}
所有素材类型会使用一个map 去管理
enum Datatype{
ROLE = 'role', // 角色
FACE = 'face', // 脸部
HAIR = 'hair', // 头发
JACKET = 'jacket', // 上衣
TROUSERS = 'trousers', // 裤子
ACCESSORIES = 'accessories', // 饰品
SCENE = 'scene', // 场景
PROP = 'prop', // 道具
}
人物有男女两种
const ROLE_MAP = {
Man: 1,
Women: 2,
}
这个ROLE_MAP就是为了给 每个素材添加 [belong] 所属人物,如果选择的人物不同,出现的装扮也会不同
比如 胡子素材只有男性人物才有,裙子只有女性人物才有
图片有两个分为了 缩略图 thumbnail 和 实际渲染图 textureUrl
实际渲染图考虑实际情况包含了很多空白和位置的处理,缩略图则为了清晰体现内容,所以会分两种
2
代码架构图
看下整个代码的主要代码架构图,分为五个部分
1App
功能的入口
作用是 创建PIXI根容器, 控制 人物、道具、背景 的 CRUD
整个功能的总控室,人物道具的选择和创建都需要通知它
class App{
constructor(defaultInfo) {
super();
this.app = new PIXI.Application({
width: window.innerWidth,
height: window.innerHeight - 195,
antialias: true, // default: false 反锯齿
autoDensity: true, // canvas视图的CSS尺寸是否应自动调整为屏幕尺寸。
resolution:2, // default: 1 分辨率
});
}
addPerson(){ ... }
addProp(){ ... }
}
2Person
负责人物渲染的细节。通过外部传入素材信息,完成人物的五个部分(头发,表情,配饰,上衣,裤子)的创建更新
class Person {
constructor(app,personInfo) {
super(app);
this.app = app;
this.personInfo = personInfo;
}
createHair() {...}
createFace() {...}
changeHair() {...}
changeFace() {...}
}
3Props、Scene
道具和背景,比较简单,没有太多内容
class Props {
constructor(app, propsInfo) {
super(app);
this.propsInfo = propsInfo;
}
create() { ... }
}
class Scene {
constructor(app, sceneInfo) {
super(app);
this.sceneInfo = sceneInfo;
}
create() { ... }
change() { ... }
}
4EditableObject
给 (人物、道具)赋能,给它们注入能力,完成 [拖动] [缩放] [渲染] [删除] [激活] 的能力
Person 和 Props 继承 EditableObject ,从而继承这些能力
这一块代码会比较复杂,下面会详细说明
3
人物渲染
App 控制人物道具的渲染,而 人物和道具内部则去实现 渲染的细节
class App{
addPerson(personInfo){
new Person(personInfo)
}
addProp(propInfo){
new Prop(propInfo)
}
}
App 内部不管理素材,所有素材都是由外部传给app,app 再分发给对应的 Person 、Prop、Scene
一个人物包含五种素材,所以就需要创建五个 Sprite 实例,并且需要创建一个人物容器去添加这五种元素
class Person {
constructor(app, personInfo) {
super(app);
this.app = app;
// personInfo 包含这个人物的所有素材信息
this.personInfo = personInfo;
this.createRole();
}
createPerson() {
const container = new PIXI.Container();
this.obj = container;
this.createHair();
// ... 省略创建表情,上衣,裤子等其他相同部分
this.app.stage.addChild(container);
}
...
}
在创建人物的时候,会同时把 他的五个部分都创建出来(代码都是一样的,下面把 创建头发部分放出来)
class Person {
...
createHair() {
this.hair = this.createSprite(DataType.HAIR);
// 创建出来之后加入创建的人物容器
this.obj.addChild(this.hair);
}
getMaterialByType = (type) => {
// materialList 包含了这个人物的五种素材数据,筛选出对应的素材类型
return this.personInfo.materialList.find((item) => item.type === type);
};
createSprite(dateType) {
const dress = this.getMaterialByType(dateType);
const { x, y, name, width, height } = dress;
// 从缓存中获取
const texture = app.loader.resources[name].texture;
const sprite = PIXI.Sprite.from(texture);
sprite.x = x || 0;
sprite.y = y || 0;
width && (sprite.width = width);
height && (sprite.height = height);
return sprite;
}
...
}
之后,在点击其他素材,需要更新人物装扮的时候,就可以直接替换对应部分的 texture
class Person {
...
changeHair(dressInfo) {
this.hair.texture = app.loader.resources[dressInfo.name].texture;
}
...
}
设置人物位置
体验过游戏就清楚,添加人物的时候,人物都是出现在屏幕中央,位置需要额外设置,不然就会出现在左上角
并且还需要设置 人物的中心为 元素的基点,这样人物缩放和其他操作就以中心为原点,符合视觉习惯
let addedNum = 0; // 当前人物是添加的第几个
const offsetList = [0, 20, 40];
class Person{
createPerson(){
// ...省略创建container 等其他代码
this.setCenterPosition()
}
setCenterPosition(container) {
const { screen } = this.app;
// 人物放置在中央,并且有一定的偏差
container.x = screen.width / 2 + offsetList[addedNum++ % 3];
container.y = screen.height / 2;
// 设置人物容器的基点为中心点
container.pivot.x = container.width / 2;
container.pivot.y = container.height / 2;
}
}
上面的代码你看到了,我们在把人物放置在中央的同时,会做一个横向偏差,是为了保证添加多个人物的时候,不会互相重叠,从而避免难以操作
4
赋能逻辑
赋能的逻辑代码会房子啊 EditableObject 这个类中,人物 和 道具 会继承这个类,从而被赋能
赋能的逻辑是 最复杂的,因为他完成了很多功能
1、人物激活态
2、拖动
3、缩放
4、旋转
下面来一一讲解
1人物激活态
当你添加人物或者选择某个人物的时候,可以发现改人物会有虚线框和 两个操作按钮
处于激活态的时候,意味着这个人物可以操作
并且场上有且只有一个人物会处于激活态,当一个激活,其他的就会失活
所以现在,我们需要再创建 三个 Sprite(删除btn,控制 btn,虚线框)
然后把 这三个 Sprite 添加进 人物容器里面吗?
不不不
这三个Sprite 理论上并不属于 人物的一部分,所以不应该耦合进去
我们会创建一个新的容器,把 人物容器 和 这三个 Sprite 添加进来
class Person extends EditableObject{
createPerson(){
// .... 省略其他
this.makeSpriteEditable()
}
}
class EditableObject {
makeSpriteEditable=()=>{
// ....
// 编辑态有虚线框、两个按钮
this.dashLine = new Dashline(obj).create();
this.delBtn = new DelBtn(this.app, obj).create();
this.controlBtn = new ControlBtn(this.app, obj).create();
// 创建一个新的容器
const container = new PIXI.Container();
container.addChild(this.dashLine, this.obj, this.delBtn, this.controlBtn);
this.editableObject = container; // 保存新的容器
// ....
}
}
其中 创建 DelBtn 、ControlBtn 这些代码也比较简单,给一个 DelBtn 例子
const name = "deleteIcon";
class DeleteIcon {
constructor(app, obj) {
this.app = app;
this.obj = obj;
}
create() {
// 优先从预加载缓存中取出
const texture = this.app.loader.resources[name].texture;
const sprite = PIXI.Sprite.from(texture);
sprite.width = 21;
sprite.height = 21;
sprite.anchor.set(0.5); // 设置中心点
this.obj.y = 0;
const { x, y } = this.obj;
sprite.x = x;
sprite.y = y;
this.icon = sprite;
// 给一个名字,是为了方便后面从父容器直接找到这个sprite
sprite.name = "delBtn";
return this.icon;
}
}
新容器的 基点 和 位置
虽然我们创建了一个新容器去 包含 按钮、人物,但是只是为了代码上的结构清晰,实际上新容器效果和 人物容器是一样的
所以我们需要把人物容器的所有 位置数据 [基点] [坐标x,y]全都转移到 新容器上
并且原来的人物容器基点和位置都需要重置成0,否则人物容器就会相对于新容器产生较大偏移
class EditableObject {
makeSpriteEditable = () => {
// ....
// 先保存人物容器的位置数据
const obj = this.obj;
const originX = obj.x;
const originY = obj.y;
const originPX = obj.pivot.x;
const originPY = obj.pivot.y;
// 转移 人物容器位置数据 给 新容器
container.x = originX;
container.y = originY;
container.pivot.x = originPX;
container.pivot.y = originPY;
// 转移之后,人物位置信息要重置,因为 obj 被添加到 新的 container 了,所以obj 是相对于contianer,防止相对于新容器 发生位置偏移
// 中心点是人物obj的中心,如果设置为0,那么就变成左上角中间了
obj.x = 0;
obj.y = 0;
obj.pivot.x = 0;
obj.pivot.y = 0;
// ....
};
}
2拖动
拖动的实现比较一般了,和我们平常实现DOM 元素拖动差不多
这里就不放全部代码了,具体可以去仓库看,这里放 pixi 相关的
unction makeObjectDraggable(obj) {
obj.interactive = true; // 使之可以被监听到事件
// 事件按下触发
const onDragStart = (e) => {};
// 事件抬起触发
const onDragEnd = () => {};
// 事件移动触发
const onDragMove = (e) => {};
// 绑定事件
obj.on("pointerdown", onDragStart);
obj.on("pointerup", onDragEnd);
obj.on("pointerupoutside", onDragEnd);
obj.on("pointermove", onDragMove);
return obj;
}
class EditableObject {
makeSpriteEditable = () => {
// .....
makeObjectDraggable(container)
}
}
3缩放、旋转
两个事件的触发是 点击 右上角的 按钮,实现效果如下
所以需要给 按钮绑定事件(按下、抬起、移动)
class EditableObject {
makeSpriteEditable = () => {
// .....
this.initCtrlEvent(container)
}
initCtrlEvent() {
// 使操作按钮可交互
this.controlBtn.interactive = true;
// 光标选中事件
this.controlBtn.on("pointerdown", this.onCtrlDown);
// 光标脱离事件
this.controlBtn.on("pointerup", this.onCtrlUp);
// 光标拖动事件
this.controlBtn.on("pointermove", this.onCtrlMove);
}
onCtrlUp = () => { ... }
onCtrlDown = (e) => { ... }
onCtrlMove = (e) => { ... }
}
缩放
主要是拿到一个 缩放率,乘以 容器的宽高,便得出最终缩放的结果
缩放率是通过 对比 两个点的 拖动前后距离 得到的
新建人物的时候,会保存一份最原始的两个点距离 defaultDistance,之后所有拖动都会和 这个距离相比,得到缩放率
而中间这个点,就是 容器的 x y,因为容器把基点设置成了 中心点,所以它的 x y 是 中心点
两个点的距离怎么计算?
不就是勾股定理嘛,不过这里用到一个 Vec2 库来简化这里的计算
class EditableObject {
onCtrlMove = (e) => {
const a = new Vec2(editableObject?.x || 0, editableObject?.y || 0);
const b = new Vec2(e.data.global.x, e.data.global.y);
// 得到 两点间的距离
const distance = a.distance(b)
}
}
另外我们在第一次点击按钮的时候,保存一份没有缩放的最初人物最初状态
class EditableObject {
defaultDistance = 0
onCtrlDown = (e) => {
const editableObject = this.editableObject;
// 只有第一次点击的时候才会保存一份默认值
if (!this.defaultDistance) {
const a = new Vec2(editableObject?.x || 0, editableObject?.y || 0);
const b = new Vec2(e.data.global.x, e.data.global.y);
// 得到 两点间的距离
const distance = a.distance(b)
this.defaultDistance = distance;
}
}
}
然后 用来和 移动后的距离 做比较
class EditableObject {
onCtrlMove = (e) => {
const a = new Vec2(editableObject?.x || 0, editableObject?.y || 0);
const b = new Vec2(e.data.global.x, e.data.global.y);
// 得到 两点间的距离
const distance = a.distance(b)
const vScale = distance/ this.defaultDistance
// 用缩放率 乘以 人物最原始的宽高
this.editableObject.width = vScale * editObjOriginData.width;
this.editableObject.height = vScale * editObjOriginData.height;
}
}
上面 你也看到了,我们用的是 缩放率乘以 人物最初的宽高,最初的宽高当然是在一开始新建人物的时候就保存了
class EditableObject {
// 保存人物最初状态的所有数据,用于后面计算
originDataMap = {}
makeSpriteEditable = () => {
// .....
this.saveObjectOriginData()
}
saveObjectOriginData = ()=>{
this.originDataMap.editableObject = {
width: this.editableObject.width,
height: this.editableObject.height,
x: this.editableObject?.x,
y: this.editableObject?.y,
}
}
}
旋转
旋转就是 设置角度,但是 pixi 设置旋转的单位是 弧度,所以这里我们只能计算弧度
怎么计算弧度?
这里用到的是 Math.atan2( y,x ),传入一个坐标,就得到这个坐标对应的弧度,就是下面这样
具体可了解
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math/atan2
那我们是不是把 当前移动的坐标 放进 Math.atan2 就能得到弧度了
是可以得到,但是得到的弧度是 相对于 整个容器 (0,0) 的
但是我们要的是相对于 我们人物容器中心的 弧度
所以我们需要换算一下,计算拿到 相对于人物中心的坐标
人物中心点 和 移动点坐标 一样相对于 根容器 左上角,而我们把人物中心点 当做成 坐标轴原点(0,0)的话
我们只要把 当前移动点的坐标 减去 人物容器中心点坐标,就能得到 当前移动点 相对于 人物中心的 坐标
class EditableObject {
onCtrlMove = (e) => {
// .....
// 得到当前移动点 相对于人物中心 的弧度
const rodian = Math.atan2(
e.data.global.y - (editableObject?.y || 0),
e.data.global.x - (editableObject?.x || 0)
)
// .....
}
}
但是我们要旋转人物,就要拿到 偏移的弧度,既然是偏移,肯定有参考量,也就是原始弧度
这个原始弧度和 前面缩放一样,我们人物新建一开始计算得到的一个弧度状态
逻辑差不多
在 onCtrlDown 第一次触发的时候 保存 人物 原始弧度
在 onCtrlMove 的时候弧度相减,然后再设置给人物
class EditableObject {
onCtrlMove = (e) => {
// ....
const rodian = Math.atan2(
e.data.global.y - (editableObject?.y || 0),
e.data.global.x - (editableObject?.x || 0)
)
const vRotain = angle- this.defaultRotain
this.editableObject.rotation = vRotain
// ....
}
}
5
事件监听
前面已经说过 ,App入口类 用来管理人物和道具 的生成、移除、激活,而具体细节会交给 人物和道具 类 去处理
而他们是怎么进行通信的呢,通过 eventemitter3 这个库去实现事件监听
在 App 中 创建人物的时候,就会监听人物的 Select 和 Delete 事件
class App{
addPerson(personInfo){
const person = new Person(this.app, personInfo);
this.MaterialPool[person.key] =person;
person.on('Selected', this.onSelect);
person.on('Delete', this.onDelete);
}
onSelect =()=>{}
onDelete =()=>{}
}
而在 Person 中则会去触发这些事件
class Person extends EditableObject {
constructor(){
this.createRole()
}
createRole() {
.....
// 给人物赋能
this.makeSpriteEditable()
.....
}
}
class EditableObject extends EventEmitter {
initDeleteIconEvent() {
// 使删除按钮可交互
this.delBtn.interactive = true;
this.delBtn.on('pointerdown', () => {
this.emit('Delete', this);
this.editableObject = null;
});
}
makeSpriteEditable = () =>{
.....
this.delBtn.interactive = true;
// 容器点击的时候,表示激活这个人物,需要出发 select 事件
this.editableObject.on('pointerup', ()=>{
this.emit("onSelect",this)
});
.....
}
}
那我们监听这两个事件有什么用?
Select 事件是为了 选择某个人物的时候,把其他所有人物都 失活(隐藏编辑框)
Delete 事件 是为了在 App 中移除
Select 事件
因为选择某个人物的时候,我们需要让其他人物失活,所以我们在 App 用一个池 管理了所有人物和道具
当生成的时候,就往池了添加一个,移除就从池里移除
这个池就是一个 对象 map,把对象的唯一id作为 key ,人物容器作为值 存进去
class App {
MaterialPool = {}
addPerson(personInfo){
const person = new Person(this.app, personInfo);
this.MaterialPool[person.key] = person
...
}
addProp(propInfo){
const prop = new Person(this.app, propInfo);
this.MaterialPool[prop.key] =prop
...
}
}
所以 Select 触发的时候,就是遍历这个池,除了非当前元素,全部失活
class App {
......
onSelect = (selectItem) => {
for (const item of Object.entries(this.MaterialPool)) {
const [, obj] = item;
if (obj.key !== selectItem.key) {
obj.makeObjectUnEditable(); // 非选择元素失活
} else {
obj.recoverEditable(); // 激活选择的元素
}
}
}
......
}
失活的处理就是,找到 [删除btn] [控制btn] [虚线框] 然后把他们隐藏掉
而激活则是 显示,设置的 sprite.visible 这个属性
class Person extends EditableObject { ... }
class EditableObject extends EventEmitter {
getAllControlSprite = () => {
const getSprite = this.editableObject?.getChildByName.bind(
this.editableObject
);
const delBtn = getSprite?.('delBtn');
const ctrlBtn = getSprite?.('ctrlBtn');
const dashline = getSprite?.('dashline');
return { delBtn, ctrlBtn, dashline};
}
makeObjectUnEditable() {
const { delBtn, ctrlBtn, dashline } = this.getAllControlSprite();
delBtn.visible = false;
ctrlBtn.visible = false;
dashline.visible = false;
}
recoverEditable = () => {
const { delBtn, ctrlBtn, dashline } = this.getAllControlSprite();
delBtn.visible = true;
ctrlBtn.visible = true;
dashline.visible = true;
}
}
Delete 事件
删除就比较简单,只是从app 视图中移除
class App {
onDelete = (item) => {
delete this.MaterialPool[item.key];
this.app.stage.removeChild(item?.editableObject);
}
}
代码仓库
更多细节可以参考完整代码
https://gitee.com/hoholove/study-code-snippet/tree/master/PIXI/PERSON_DRESS