pixijs做一个精灵接文字掉落的游戏

335 阅读8分钟

本文默认你看完了pixijs的官网介绍。pixi.nodejs.cn/8.x/guides

其中包含内容。

  1. 小熊精灵动画,根据键盘左右移动,移动动画。
  2. 文字从屏幕上方往下掉落及阴影。
  3. 小熊精灵部位与文字灯笼碰撞检测。
  4. 碰撞爆炸气泡动画及计数累计文字漂浮。

效果图

image.png

一、template
<template>
  <div class="game-container">
    <div
      class="pixijs"
      ref="pixiDom"
    >
    </div>
    <div
      class="top-home-icon"
      @click="() => router.push('/ModulesPanel')"
    >
      <img
        class="image"
        src="@renderer/assets/img/game/public/home_btn.png"
      />
    </div>
  </div>
</template>

二、 vue变量及props内容

import { shallowRef, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Application, Assets, Text, Sprite, Container, DisplayObject, Graphics, Texture, AnimatedSprite, TextStyle, Filter } from 'pixi.js'
import { DropShadowFilter } from '@pixi/filter-drop-shadow'
import * as PIXI from 'pixi.js'
import yx_bg from '@renderer/assets/img/game/jiezi/stage/yx_bg.png'
import start_btn_png from '@renderer/assets/img/game/jiezi/stage/start_btn.png'
import gress_bg from '@renderer/assets/img/game/public/pro.png'
import text_bg from '@renderer/assets/img/game/jiezi/stage/text_bg.png'
import t_bg from '@renderer/assets/img/game/jiezi/stage/t_bg.png'
import bubble from '@renderer/assets/img/game/jiezi/stage/bubble.png'
import dui from '@renderer/assets/audio/public/dui.mp3'
import cuo from '@renderer/assets/audio/public/cuo.mp3'

import { exImgMap, keyboard, arrTurnObject, initRandomArr, moveValue } from '@renderer/common/utils/util'
Assets.load('@/renderer/assets/font/HYTuanTuanYuanYuan-U') // 加载字体样式文件

const router = useRouter()
const emits = defineEmits(['gameSubmit'])
Assets.add({ alias: 'start_btn_png', src: start_btn_png })
Assets.add({ alias: 'text_bg', src: text_bg })
const sprites = exImgMap(import.meta.glob('@renderer/assets/img/game/jiezi/stage/sprite/*.png', { eager: true }))
const spritesImg = Array.from(sprites.values()) // 小熊精灵雪碧图
const gameOver = ref(false)
const currentBearDirection = ref('right') // 当前小熊精灵朝向
const rtText = ref('')
const duiAndCuoAudio = new Audio(dui) // 击中文字提示音

const props = defineProps({
  isStart: {
    // 是否渲染开始按钮(刚打开游戏时需要)
    type: Boolean,
    default: false
  },
  knowledgeType: {
    // 是否是组合知识点 1单一2组合
    type: Number,
    default: 1
  },
  fontType: {
    type: String,
    default: 'shengpang'
  },
  allDropTextTotalArr: {
    type: Array<{ src: string, answer: boolean }>,
    default: () => []
  },
  topic: {
    // 当前题目内容及配置
    type: Object,
    default: () => { }
  },
  currentMinorIndex: {
    // 当前小题下标
    type: Number,
    default: 0
  },
  currentTopicConfig: {
    // 当前题目内容配置
    type: Object,
    default: () => { }
  }
})

const canvasConfig = {
  width: window.innerWidth > 1920 ? 1920 : window.innerWidth,
  height: window.innerHeight, // > 1080 ? 1080 : window.innerHeight,
  ratioW: window.innerWidth < 1920 ? window.innerWidth / 1920 : 1,
  ratioH: window.innerHeight / 1080 // window.innerHeight < 1080 ? window.innerHeight / 1080 : 1
}
const left = keyboard("ArrowLeft")
// up = keyboard("ArrowUp"),
const right: any = keyboard("ArrowRight")
let bearSpriteContainer: Container, bearSprite: AnimatedSprite | any, progressBar: Graphics, rightContainer: Container<DisplayObject>

const gameParamsConfing = ref({
  gameTotalTime: 60, // 游戏总时长
  timeLine: 60, // 时间轴
  textMaxNum: 5, // 屏幕中文字最多显示个数
  columnCount: 5, // 屏幕分列数
  columnGap: 10, // 列间隔
  columnWidth: canvasConfig.width - 186 * canvasConfig.ratioW / 5, // 每列宽度
  createTextNodeInterval: [100, 500, 900, 300, 200], // 执行创建节点间隔 毫秒
  textBallSpeed: 2, // 文字球掉落速度 2px级
})
const basketPositionRight = ref({ // 朝右位置,默认基于小熊container定位
  x: 127,
  y: 83,
  w: 180,
  h: 114,
})
const createColIndexArr = ref<number[]>([]) // 生成列下标数组
const currentTime = ref(60) // 时间轴时间
const columnCount = ref(5) // 页面分为5列
const columnWidth = ref(canvasConfig.width - 186 * canvasConfig.ratioW / columnCount.value) // 每列宽度

const pixiDom = shallowRef<HTMLDivElement>()
let app: Application // pixi
const catchTextNum = ref(0) // 接住多少正确字累计。用于判断小熊状态
const bearStatus = ref(0) // 精灵小熊状态
// const allDropTextTotalArr = ref<{ src: string, answer: boolean }[]>([]) // 一局游戏需要掉落的文字的总数
const catchTextArr = ref<boolean[]>([]) // 已接住文字答案数组 // true or false
const createTextNodeIndex = ref(0) // 创建文字节点下标
let textImgArr: Container[] = []
let textures_right: Texture<PIXI.Resource>[] = [] // 朝右小熊精灵
let textures_left: Texture<PIXI.Resource>[] = [] // 朝左小熊精灵

三、初始化方法

const initCustomGame = async () => {
  console.log(props.allDropTextTotalArr)
  app = new Application({
    width: canvasConfig.width,
    height: canvasConfig.height,
    antialias: true, // 抗锯齿
    resolution: 1, // 设备像素比 // window。devicePixelRatio
    backgroundAlpha: 1, // 背景透明度
    resizeTo: window,
    hello: true // 在控制台输出当前渲染器的版本和类型信息
  })
  // 设置最大小帧率为 60fps
  app.ticker.minFPS = 60
  app.ticker.maxFPS = 60
  createColIndexArr.value = initRandomArr(5) // 设置随机生成位置
  drawBearSprite() // 创建精灵
  addScreenBackground() // 添加背景图
  createProgressBar() // 创建时间倒计时进度条
  pixiDom.value?.appendChild(app.view as any)
  textImgArr = []
  // columnWidth.value = (app.renderer.width - (canvasConfig.ratioW * 186)) / columnCount.value
  createRightHintText() // 绘制右侧文字
  // 设置游戏循环
  if (!props.isStart) {
    app.ticker.add(delta => gameLoop(delta))
  }
}

onMounted(async () => {
  await initCustomGame()
})

四、绘制背景图
const addScreenBackground = async () => {
  // 绘制背景图
  // const texture = await Assets.load(yx_bg)
  const background = Sprite.from(yx_bg)
  background.width = canvasConfig.width
  background.height = canvasConfig.height
  background.zIndex = 1
  app.stage.addChild(background)
}
五、创建小熊动画精灵及键盘移动监听
const boardListen = () => {
  left.press = () => {
    if (!right.isDown) {
      console.log(bearSpriteContainer.children[1])
      console.log(bearSpriteContainer.children[1].getBounds())
      bearSpriteContainer.children[1].x = -120 // 此时变更定位是基于已定位置
      bearSprite.textures = textures_left
      currentBearDirection.value = 'left'
      bearSprite.x -= 1
      bearSprite.play()
    }
  }
  right.press = () => {
    if (!left.isDown) {
      bearSpriteContainer.children[1].x = 0 // 此时变更定位是基于已定位置
      currentBearDirection.value = 'right'
      bearSprite.textures = textures_right
      bearSprite.x += 1
      bearSprite.play()
    }
  }
  left.release = () => {
    if (!right.isDown) {
      bearSprite.gotoAndStop(0) // 回到第一帧
    } else {
      bearSpriteContainer.children[1].x = 0 // 此时变更定位是基于已定位置
      bearSprite.textures = textures_right
      bearSprite.play()
    }
  }
  right.release = () => {
    if (!left.isDown) {
      bearSprite.gotoAndStop(0) // 回到第一帧
    } else {
      bearSpriteContainer.children[1].x = -120 // 此时变更定位是基于已定位置
      bearSprite.textures = textures_left
      bearSprite.play()
    }
  }
}

const changeBearTexture = async (baseTexture) => {
  // 改变小熊精灵纹理
  textures_right = []
  textures_left = []
  const keyArr = ['x', 'y', 'w', 'h']
  const rightPosition = [[119, 81, 320, 320], [481, 81, 320, 320], [843, 81, 320, 320], [1204, 81, 320, 320], [1566, 81, 320, 320], [1928, 81, 320, 320]]
  const leftPosition = [[119, 422, 320, 320], [481, 422, 320, 320], [843, 422, 320, 320], [1204, 422, 320, 320], [1566, 422, 320, 320], [1928, 422, 320, 320]]
  rightPosition.forEach((v) => {
    const { x, y, w, h } = arrTurnObject(v, keyArr)
    textures_right.push(new PIXI.Texture(baseTexture, new PIXI.Rectangle(x, y, w, h)))
  })
  leftPosition.forEach((v) => {
    const { x, y, w, h } = arrTurnObject(v, keyArr)
    textures_left.push(new PIXI.Texture(baseTexture, new PIXI.Rectangle(x, y, w, h)))
  })
}

const drawBearSprite = async () => {
  // 创建小熊精灵
  await changeBearTexture(loadTexture(spritesImg[bearStatus.value]))
  bearSpriteContainer = new Container()
  app.stage.addChild(bearSpriteContainer)

  const bear = new PIXI.AnimatedSprite(textures_right)
  bearSpriteContainer.addChild(bear)
  bearSprite = bearSpriteContainer.children[0] // 容器的children顺序默认按添加顺序排序。如需更改使用setChildIndex api修改
  // bearSprite.anchor.set(0.5, 0.5) // 锚点位置。值范围为 0~1,表示相对图片宽高位置的百分比,比如设置为 (0.5, 0.5) 就是取宽高一半的位置作为旋转中心,也就是图片的中点
  bearSprite.zIndex = 9
  bearSprite.animationSpeed = 0.2 // 动画速度
  // bearSprite.gotoAndPlay(0) // 从第几帧开始播放
  // 设置位置
  bearSprite.height = 320 * canvasConfig.ratioH
  bearSprite.width = 320 * canvasConfig.ratioW

  bearSpriteContainer.x = canvasConfig.width / 2 - bearSprite.width / 2
  bearSpriteContainer.y = canvasConfig.height - bearSprite.height - (100 * canvasConfig.ratioH)


  const border = new Graphics();
  border.lineStyle(1, 0x000000, 0.001); // 设置边框样式,不能设置为完全透明,否则将无法获取Graphics的xy
  border.beginFill(0xffffff, 0); // 透明填充
  const { x, y, w, h } = basketPositionRight.value
  border.drawRect(x, y, w * canvasConfig.ratioW, h * canvasConfig.ratioH) // 绘制一个矩形,大小与精灵相同
  bearSpriteContainer.addChild(border)
  boardListen() // 监听键盘事件
}
六、掉落文字气泡及移动更新方法
const createTextNode = async () => {
  // const index: number = parseInt(Math.random() * 5)
  try {
    if (!props.allDropTextTotalArr[createTextNodeIndex.value]?.src || textImgArr.length >= gameParamsConfing.value.textMaxNum) return
    if (textImgArr.length < gameParamsConfing.value.textMaxNum) {

      const textContainer: Container<DisplayObject> | any = new Container()
      textContainer.height = 128 * canvasConfig.ratioH
      textContainer.width = 128 * canvasConfig.ratioW
      textContainer.ly = gameParamsConfing.value.textBallSpeed // 可考虑固定掉落速度,防止较快的与之重叠 // (1 * Math.random() + 0.8)
      textContainer.zIndex = 9
      const text_bgTure = PIXI.Texture.from(text_bg)
      const textBg = new Sprite(text_bgTure)
      const textPath = PIXI.Texture.from(props.allDropTextTotalArr[createTextNodeIndex.value]?.src) // 取文字路径
      textContainer.answer = props.allDropTextTotalArr[createTextNodeIndex.value].answer
      const texture = new Sprite(textPath)
      texture.width = texture.height = 50
      textBg.width = textBg.height = 128
      texture.x = texture.y = textBg.width / 2 - texture.width / 2
      textContainer.addChild(textBg)
      textContainer.addChild(texture)

      const m = createColIndexArr.value[createTextNodeIndex.value % 5] // Math.random()
      textContainer.colIndex = m // 设置它的生成位置。当它remove,后续补入

      let x = m * columnWidth.value + (columnWidth.value / 2 - textContainer.width / 2) // 128文字容器宽度 186右侧文字描述宽度

      textContainer.x = x
      textContainer.y = -textContainer.height
      createTextNodeIndex.value += 1

      // 应用阴影滤镜
      const dropShadowFilter = new DropShadowFilter({
        distance: 10, // 阴影偏移距离
        rotation: 75,// 阴影角度
        color: 0x74c6d8, // 阴影颜色
        alpha: 0.75, // 阴影透明度
        // blur: 4.0, // 阴影模糊度
        // quality: 1, // 阴影质量
        resolution: app.renderer.resolution, // 分辨率
        shadowOnly: false, // 是否只显示阴影
      })
      textContainer.filters = [dropShadowFilter] // 为容器添加阴影

      // const border = new Graphics();
      // border.lineStyle(3, 0x0000FF); // 设置边框样式,这里设置为蓝色,线条宽度为 10
      // // border.alpha = 0.5; // 设置边框透明度
      // border.drawRect(0, 0, textContainer.width, textContainer.height); // 绘制一个矩形,大小与精灵相同
      // textContainer.addChild(border)

      textImgArr.push(textContainer)
      app.stage.addChild(textContainer)
    }
  } catch (err) { console.log(err) }
}


const updateTextPosition = (sprite, ind: number) => {
  //@ts-ignore
  sprite.y += sprite.ly // 随机下落速度
  // const rect1 = bearSprite?.getBounds() // bearSprite?.getBounds()
  const rect1 = bearSpriteContainer.children[1].getBounds()
  const rect2 = sprite?.getBounds() // sprite?.getBounds()
  if (rect1.x < rect2.x + rect2.width &&
    rect1.x + rect1.width > rect2.x &&
    rect1.y < rect2.y + rect2.height &&
    rect1.height + rect1.y > rect2.y) {

    catchTextArr.value.push(sprite.answer)
    if (sprite.answer) { // 接住了正确就累计加一
      catchTextNum.value += 1
      duiAndCuoAudio.src = dui
      duiAndCuoAudio.load()
      duiAndCuoAudio.play().catch(err => {
        console.log(err)
      })
      createCatchTextNum() // 创建文字累加效果
    } else {
      duiAndCuoAudio.src = cuo
      duiAndCuoAudio.load()
      duiAndCuoAudio.play().catch(err => {
        console.log(err)
      })
    }
    // 爆炸效果
    createTextBoom(sprite)
    app.stage.removeChild(sprite)
    textImgArr.splice(ind, 1)
    if (catchTextNum.value % 5 === 0 && bearStatus.value < 3) { // 累计改变小熊纹理
      bearStatus.value += 1
      changeBearTexture(loadTexture(spritesImg[bearStatus.value]))
    }
    updateProgressBar() // 更新进度条
  }
  if (sprite.y > app.renderer.height - (bearSpriteContainer.height / 2)) {
    app.stage.removeChild(sprite)
    textImgArr.splice(ind, 1)
    updateProgressBar() // 更新进度条
  }
}
七、游戏循环gameLoop方法

let lastTime = 0
const gameLoop = async (ticker) => {
  const now = Date.now()
  if (now - lastTime >= gameParamsConfing.value.createTextNodeInterval[createTextNodeIndex.value % 5]) { // 控制生成时间间隔
    // 执行游戏逻辑
    console.log('gameloop:' + createTextNodeIndex.value)
    await createTextNode()
    if (currentTime.value <= 0 && !textImgArr.length) { // 时间结束 且 文字全部掉完
      gameOver.value = true
      app.ticker.stop() // 停止循环
      const correctRatio = (catchTextArr.value.filter(Boolean).length / props.currentTopicConfig.ratioAccuracy) * 100 // 整数百分比
      const mistakeRatio = (catchTextArr.value.filter(i => i === false).length / props.currentTopicConfig.ratioDisturbnum) * 100
      if (correctRatio >= props.currentTopicConfig.trueMin && mistakeRatio <= props.currentTopicConfig.falseMax) {
        emits('gameSubmit', true)
      } else {
        emits('gameSubmit', false)
      }
    }
    lastTime = now
  }

  for (let i = 0; i < textImgArr.length; i++) {
    updateTextPosition(textImgArr[i], i)
  }
// 移动限制,防止精灵跑出画布
  if (left.isDown) {
    bearSpriteContainer.x <= 0 ? bearSpriteContainer.x = 0 : bearSpriteContainer.x -= 12
  }
  if (right.isDown) {
    bearSpriteContainer.x + bearSpriteContainer.width >= (canvasConfig.width - (canvasConfig.ratioW) * 186) ? bearSpriteContainer.x = (canvasConfig.width - (canvasConfig.ratioW) * 186) - bearSpriteContainer.width : bearSpriteContainer.x += 12
  }
}
八、创建进度条以及更新进度条方法
const createProgressBar = async () => {
  const gressBg = Sprite.from(gress_bg)
  gressBg.width = 450 * canvasConfig.ratioW
  gressBg.height = 35 * canvasConfig.ratioH
  gressBg.anchor.set(0.5, 0.5)
  gressBg.zIndex = 1
  gressBg.x = canvasConfig.width / 2 // - 225
  gressBg.y = canvasConfig.height - (65 * canvasConfig.ratioH)
  app.stage.addChild(gressBg)
  progressBar = new Graphics()
  progressBar.beginFill('#f4981bFF')
  progressBar.drawRoundedRect(0, 0, 435 * canvasConfig.ratioW, 31 ** canvasConfig.ratioH, 10)
  progressBar.endFill()
  progressBar.x = canvasConfig.width / 2 - (218 * canvasConfig.ratioW)
  progressBar.y = canvasConfig.height - (75 * canvasConfig.ratioH)
  app.stage.addChild(progressBar)
}

const updateProgressBar = () => {
  currentTime.value -= 1
  progressBar.scale.x = progressBar.scale.x ? (gameParamsConfing.value.gameTotalTime - createTextNodeIndex.value) / gameParamsConfing.value.gameTotalTime : 0
}
九、创建碰撞数字累计动画

const createCatchTextNum = async () => {
  // 创建累计数字动效
  const textStyle = new TextStyle({
    fontFamily: 'HYTuanTuanYuanYuan-U',
    fontSize: 46,
    // fontStyle: 'italic',
    fontWeight: 'bold', // 加粗
    fill: ['#FAD9611F', '#F76B1C'], // 填充颜色
    align: 'left',
    strokeThickness: 3, // 描边宽度
    stroke: '#FFF',
    padding: 20
  })
  const scoreText = new Text(`+${catchTextNum.value}`, textStyle)
  // scoreText.anchor.set(0.5, 0.5)
  // scoreText.rotation = 5 * (Math.PI / 180)
  const bearPosition = bearSprite.getBounds()
  // scoreText.x = bearPosition.x + bearPosition.width * 0.25 // currentBearDirection.value === 'right' ? bearPosition.x + bearPosition.width * 0.25 : bearPosition.x + bearPosition.width * 0.75
  // scoreText.y = bearPosition.y
  scoreText.alpha = 1 // 初始透明度为完全不透明

  const textContainer = new Container()
  textContainer.width = textContainer.height = 200

  textContainer.x = currentBearDirection.value === 'right' ? bearPosition.x + bearPosition.width * 0.25 : bearPosition.x + bearPosition.width * 0.75
  textContainer.y = bearPosition.y
  textContainer.addChild(scoreText)
  app.stage.addChild(textContainer)

  // const border = new Graphics();
  // border.lineStyle(3, 0x0000FF); // 设置边框样式,这里设置为蓝色,线条宽度为 10
  // // border.alpha = 0.5; // 设置边框透明度
  // border.drawRect(0, 0, textContainer.width, textContainer.height); // 绘制一个矩形,大小与精灵相同
  // scoreText.addChild(border)

  // 向上移动并渐变消失
  const moveUpAndFade = () => {
    scoreText.y -= 0.5 // 向上移动
    scoreText.alpha -= 0.02 // 透明度减少
    if (scoreText.alpha <= 0) {
      scoreText.alpha = 0
      app.stage.removeChild(scoreText)
    }
  }
  app.ticker.add(moveUpAndFade)
}
十、创建爆炸动画
const createTextBoom = async (sprite) => { // 创建爆炸气泡
  const baseTexture = PIXI.BaseTexture.from(bubble)
  const bubbleTexture: any = []
  bubbleTexture.push(new PIXI.Texture(baseTexture, new PIXI.Rectangle(0, 0, 200, 200)))
  bubbleTexture.push(new PIXI.Texture(baseTexture, new PIXI.Rectangle(216, 0, 200, 200)))
  bubbleTexture.push(new PIXI.Texture(baseTexture, new PIXI.Rectangle(432, 0, 200, 200)))
  const bubbleSprite = new AnimatedSprite(bubbleTexture)
  bubbleSprite.anchor.set(0.5)
  // const bearPosition = bearSprite.getBounds()
  // bubbleSprite.x = currentBearDirection.value === 'right' ? bearPosition.x + bearPosition.width * 0.7 : bearPosition.x + bearPosition.width / 3
  bubbleSprite.x = sprite.x + sprite.width / 2
  bubbleSprite.y = sprite.y + sprite.height / 2
  bubbleSprite.animationSpeed = 0.1 // 动画速度
  bubbleSprite.loop = false // 设置循环为 false
  app.stage.addChild(bubbleSprite)
  bubbleSprite.play()// 播放动画
  const clearBoom = setTimeout(() => {
    app.stage.removeChild(bubbleSprite)
    clearTimeout(clearBoom)
  }, 500)
}

十一、创建绘制右侧文字内容
const createRightHintText = () => {
  // 创建右侧提示文字内容
  rtText.value = props.topic?.description.slice(0, 16) || '本关要接住的文字为'
  rightContainer = new Container()
  const textStyle = new TextStyle({
    fontFamily: 'SourceHanSerifCN-Medium',
    fontSize: 32,
    fill: '#AC9558FF',
    wordWrap: true,
    wordWrapWidth: 40,
    align: 'center'
  })
  let dispose_text = ''
  for (let i = 0; i < rtText.value.length; i++) {
    dispose_text += rtText.value[i] + '\n'
  }
  const text = new Text(dispose_text, textStyle)
  rightContainer.addChild(text)
  const tb = new Sprite(Texture.from(t_bg))
  tb.y = text.getBounds().height
  tb.anchor.set(0.5)
  tb.scale.x = tb.scale.y = 0.6
  tb.x = 15
  rightContainer.addChild(tb)
  const tishiImg = +props.currentTopicConfig.rightAnswers1 === 1 ? props.currentTopicConfig.tishi[0] : props.currentTopicConfig.tishi[1]

  const wz = new Sprite(Texture.from(tishiImg))
  wz.y = text.getBounds().height
  wz.anchor.set(0.5)
  wz.scale.x = wz.scale.y = +props.currentTopicConfig.rightAnswers1 === 1 ? 0.5 : 0.6
  wz.x = 15
  rightContainer.addChild(wz)
  // tb.y = text.getBounds().height
  // tb.anchor.set(0.5)
  // tb.scale.x = tb.scale.y = 0.6
  // tb.x = 15
  rightContainer.addChild(tb)

  rightContainer.x = canvasConfig.width - ((canvasConfig.width / 1920) * 186) / 2 - 10
  rightContainer.y = (canvasConfig.height - text.getBounds().height) / 2
  app.stage.addChild(rightContainer)
}
十二、游戏开始方法
  const startViwe = () => {
      clearResetGameData() // 开始前先重置数据
      start_btn.on('pointertap', () => { // pointertap集成点击和触摸事件
        app.stage.removeChild(start_btn)
        app.ticker.add(delta => gameLoop(delta))
        app.ticker.start() // 开始循环
      })
  }