前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【H5游戏】PIXI 人物换装

【H5游戏】PIXI 人物换装

作者头像
神仙朱
发布2021-11-11 14:22:16
2.6K0
发布2021-11-11 14:22:16
举报

本文是总结用pixijs实现一个 人物换装的H5 2D游戏

如果你对这个游戏感兴趣,就跟我走

如果你还不了解pixi的用法,可以看这篇文章

pixijs 需求级入门

本文目录

1、游戏介绍

2、代码实现

3、代码仓库

游戏介绍

如果你体验了上面的地址,就可以知道玩法挺简单

大概就是 添加人物,换发型,换衣服,添加饰品,更换背景

人物可以拖动,缩放,旋转,并且支持多人物

游戏功能看着虽然简单,实现的逻辑可一点都不简单

先来介绍下整个角色的组成部分

1角色组成

一个角色有五个组成部分:头发,配饰,表情,上衣,裤子

这五个部分都可以单独更换素材,这样就可以自定义搭配出各式各样的人物了

2素材介绍

因为人物分为五个部分,所以 素材有五种,但是素材大小不一,位置也不太一样,那不是每个素材都要单独调整位置才能渲染到合适的位置

想想就很麻烦,一百多个素材逐个调整位置的话,想到就要爆炸了

我们的处理是,固定所有素材的大小比例,其中的位置差异交由 设计去调整

甚至我们可以按照人物的宽高 去 固定所有素材的宽高,然后交给设计去控制图片内素材的位置

这样渲染的时候都不用设置位置,直接图片重叠就可以了

但是考虑到会增加图片大小,所以我们还是会适当裁剪,在图片大小和素材位置折中,尽量减少图片大小,也要简化设置素材位置的代码

代码实现

这里主要是讲解代码实现的主要逻辑,不会涉及繁枝细节。

主要分为这几个部分去讲解

1、数据介绍

2、代码架构图

3、人物渲染

4、赋能逻辑

5、事件监听

1

数据介绍

看下素材的数据结构

代码语言:javascript
复制
{
    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 去管理

代码语言:javascript
复制
enum Datatype{
  ROLE = 'role', // 角色
  FACE = 'face', // 脸部
  HAIR = 'hair', // 头发
  JACKET = 'jacket', // 上衣
  TROUSERS = 'trousers', // 裤子
  ACCESSORIES = 'accessories', // 饰品
  SCENE = 'scene', // 场景
  PROP = 'prop', // 道具
}

人物有男女两种

代码语言:javascript
复制
const ROLE_MAP = {
  Man: 1,
  Women: 2,
}

这个ROLE_MAP就是为了给 每个素材添加 [belong] 所属人物,如果选择的人物不同,出现的装扮也会不同

比如 胡子素材只有男性人物才有,裙子只有女性人物才有

图片有两个分为了 缩略图 thumbnail 和 实际渲染图 textureUrl

实际渲染图考虑实际情况包含了很多空白和位置的处理,缩略图则为了清晰体现内容,所以会分两种

2

代码架构图

看下整个代码的主要代码架构图,分为五个部分

1App

功能的入口

作用是 创建PIXI根容器, 控制 人物、道具、背景 的 CRUD

整个功能的总控室,人物道具的选择和创建都需要通知它

代码语言:javascript
复制
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

负责人物渲染的细节。通过外部传入素材信息,完成人物的五个部分(头发,表情,配饰,上衣,裤子)的创建更新

代码语言:javascript
复制
class Person {
  constructor(app,personInfo) {
    super(app);
    this.app = app;
    this.personInfo = personInfo;
  }

  createHair() {...}
  createFace() {...}

  changeHair() {...}
  changeFace() {...}
}

3Props、Scene

道具和背景,比较简单,没有太多内容

代码语言:javascript
复制
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 控制人物道具的渲染,而 人物和道具内部则去实现 渲染的细节

代码语言:javascript
复制
class App{
    addPerson(personInfo){
        new Person(personInfo)
    }
    addProp(propInfo){
        new Prop(propInfo)
    }
}

App 内部不管理素材,所有素材都是由外部传给app,app 再分发给对应的 Person 、Prop、Scene

一个人物包含五种素材,所以就需要创建五个 Sprite 实例,并且需要创建一个人物容器去添加这五种元素

代码语言:javascript
复制
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);
  }
  ...
}

在创建人物的时候,会同时把 他的五个部分都创建出来(代码都是一样的,下面把 创建头发部分放出来)

代码语言:javascript
复制
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

代码语言:javascript
复制
class Person {
  ...
  changeHair(dressInfo) {
    this.hair.texture = app.loader.resources[dressInfo.name].texture;
  }
  ...
}

设置人物位置

体验过游戏就清楚,添加人物的时候,人物都是出现在屏幕中央,位置需要额外设置,不然就会出现在左上角

并且还需要设置 人物的中心为 元素的基点,这样人物缩放和其他操作就以中心为原点,符合视觉习惯

代码语言:javascript
复制
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 添加进来

代码语言:javascript
复制
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 例子

代码语言:javascript
复制
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,否则人物容器就会相对于新容器产生较大偏移

代码语言:javascript
复制
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 相关的

代码语言:javascript
复制
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缩放、旋转

两个事件的触发是 点击 右上角的 按钮,实现效果如下

所以需要给 按钮绑定事件(按下、抬起、移动)

代码语言:javascript
复制
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 库来简化这里的计算

代码语言:javascript
复制
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) 
  }
}

另外我们在第一次点击按钮的时候,保存一份没有缩放的最初人物最初状态

代码语言:javascript
复制
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;
    }
  }
}

然后 用来和 移动后的距离 做比较

代码语言:javascript
复制
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;
  }
}

上面 你也看到了,我们用的是 缩放率乘以 人物最初的宽高,最初的宽高当然是在一开始新建人物的时候就保存了

代码语言:javascript
复制
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)的话

我们只要把 当前移动点的坐标 减去 人物容器中心点坐标,就能得到 当前移动点 相对于 人物中心的 坐标

代码语言:javascript
复制
class EditableObject {
  onCtrlMove = (e) => {
     // .....
     // 得到当前移动点 相对于人物中心 的弧度 
     const rodian = Math.atan2(
        e.data.global.y - (editableObject?.y || 0),
        e.data.global.x - (editableObject?.x || 0)
     )
     // .....
  }
}

但是我们要旋转人物,就要拿到 偏移的弧度,既然是偏移,肯定有参考量,也就是原始弧度

这个原始弧度和 前面缩放一样,我们人物新建一开始计算得到的一个弧度状态

逻辑差不多

在 onCtrlDown 第一次触发的时候 保存 人物 原始弧度

在 onCtrlMove 的时候弧度相减,然后再设置给人物

代码语言:javascript
复制
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 事件

代码语言:javascript
复制
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 中则会去触发这些事件

代码语言:javascript
复制
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 ,人物容器作为值 存进去

代码语言:javascript
复制
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 触发的时候,就是遍历这个池,除了非当前元素,全部失活

代码语言:javascript
复制
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 这个属性

代码语言:javascript
复制
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 视图中移除

代码语言:javascript
复制
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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-11-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 神仙朱 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档