夏天与端午,写个简单的小游戏

1,280 阅读5分钟

我正在参加「初夏创意投稿大赛」详情请看:初夏创意投稿大赛

游戏简介

游戏规则

  1. 通过 [W] 或者 [D] 控制底下篮子左右移动, 接住粽子, 时限为一分钟;
  2. 左右的炮台会随机射出粽子和西瓜, 接住粽子或者西瓜会增加相应的分数和重量;
  3. 每个粽子1分, 重量为1;
  4. 每个西瓜0分, 重量为10;
  5. 篮子越重, 移动速度越慢;

技术栈

vue3 + ts + tailwindcss

因为这是一个比较简单的小游戏,所以没有去使用特殊依赖包,没有使用canvas, 纯div+svg。

在线体验

项目地址(个人服务器小水管, 如果加载不成功请多刷新几次。)

开发思路

页面搭建

需要一个包容所有的父盒子,父盒子设定为窗口的大小,里面整体使用定位进行布局, 通过定位将两侧的炮台、西瓜、粽子和底下的篮子进行排放,整体布局如下:

<template>
  <div
    class="absolute top-0 bottom-0 left-0 right-0 font-mono bg-orange-300 select-none"
    @click="inputAutoFocus"
  >
    <!-- 顶边栏 -->
    <div
      class="absolute top-0 left-0 right-0 flex justify-around h-5 font-black leading-10 text-amber-800"
    >
      <div> {{ $t('page.zongziRemainingTime') }}: {{ showTime }}'' </div>
      <div> {{ $t('page.zongziCurrentScore') }}: {{ score }} </div>
      <div> {{ $t('page.zongziCurrentWeight') }}: {{ basketWeight }} </div>
    </div>

    <!-- 左边大炮 -->
    <div class="absolute top-[150px] bottom-0 left-0 w-5 bg-amber-800"></div>
    <div class="absolute top-[132px] h-5 w-14 left-0 bg-amber-800 rounded-sm"></div>
    <n-icon class="absolute top-[86px] left-0" size="50px">
      <SvgIcon icon-class="cannon" />
    </n-icon>

    <!-- 右边大炮 -->
    <div class="absolute top-[150px] bottom-0 w-5 right-0 bg-amber-800"></div>
    <div class="absolute top-[132px] h-5 w-14 right-0 bg-amber-800"></div>
    <n-icon class="absolute top-[86px] right-0" style="transform: rotateY(180deg)" size="50px">
      <SvgIcon icon-class="cannon" />
    </n-icon>

    <!-- 底部 -->
    <div ref="floorEl" class="absolute bottom-0 left-0 right-0 h-5 bg-amber-800"></div>
    <!-- 篮子 -->
    <n-icon
      ref="basketEl"
      class="absolute text-green-600"
      :style="`top: calc(100% - 63px);left: ${basketLeft}px;`"
      size="50px"
    >
      <SvgIcon icon-class="basket" />
    </n-icon>

    <!-- 掉落物体 -->
    <template v-for="(item, index) in droppedObjectList" :key="'droppedObject' + item.id">
      <DroppedObject
        v-if="item"
        :ref="(el: any) => { el && (droppedObjectRefList[index] = el) }"
        :code-id="item.id"
        :object-name="item.objectName"
        :start-position="item.startPosition"
        :end-position="item.endPosition"
      />
    </template>
  </div>
</template>

收集用户操作

通过以上的布局,基本页面已经完成了,但是需要接收用户的键盘操作,需要再加入一个input标签,当用户开始游戏或者点击时,input自动获取焦点,接收用户的输入操作:

<!-- 输入框: 不可见 -->
<input ref="actionInputEl" class="absolute top-0 left-0 w-0 h-0 -z-10" />

同时,在input标签上挂载监听键盘事件,监听用户输入的内容,转换为游戏操作:

function handle(event: KeyboardEvent) {
   // 判断用户输入
   const directionMap = { KeyA: -1, KeyD: 1 }
   const direction = directionMap[event.code] || 0
   // 计算篮子新的left值
   let newLeft = basketLeft.value + direction * basketSpeed
   // 边界判断
   newLeft < basketMinLeft && (newLeft = basketMinLeft)
   newLeft > basketMaxLeft && (newLeft = basketMaxLeft)
   // 赋予新值
   basketLeft.value = newLeft
}

通过改变底下篮子left值来移动篮子,达到用户可操作的目的。

游戏流程

在游戏中,会随机从炮口射出粽子或者西瓜,它们拥有一个水平的速度和向下的重力,最终会形成一个抛物线的轨迹并且落下。

创建抛落物

这个比较简单,因为vue是数据驱动的,只要改变抛落物列表的数据,便可达到增加或者删除抛落物的目的,不再赘述。

抛物线

  1. 最初的想法: 难点在于抛落物下落的抛物线比较难以确定,一开始打算给一个随机的水平初速度来达到分散掉落的目的,那么为了保证不会掉落到屏幕外,必须给这个水平速度添加一些限制条件,让抛落物掉在地面时,处于屏幕之内;然而,最终发现这种方式可行但不是最优的,因为必须得考虑到物体下落的时间,把相关的物理学公式化简后,计算还是比较麻烦,也比较耗时。

最初都想法是根据物理学去计算抛物线,这个方法最终实现起来比较复杂也比较耗时, 如果换一个角度,先设定好抛物线,再去计算其他就会比较简单。

  1. 最终解决办法: 先确定落点的坐标,坐标使用topleft表示,根据起点和落点确定抛物线,抛去时间的概念,采用帧代替,即每个物体落到地面用了多少帧,来表示速度的快慢。
  • 在屏幕内随机取一个落点的坐标,如果是从左侧射出,则left = windowHalfWidth * Math.random() + windowHalfWidth,保证其落点在屏幕内且在右侧;如果在右侧射出, 则left = windowHalfWidth * Math.random(),保证落点在屏幕内且在左侧;
  • 落点top是一个常量,可以通过一次计算直接赋予,其实就是屏幕高度 - 地板高度 - 物体高度
  • 将起点坐标视为(0, 0)
  • 现在有了落点坐标和起点坐标,那么抛物线公式y = a * x * x + b * x + c,因为起点为(0, 0),所以很简单得知c = 0a决定抛物线开口的大小,可以通过多次测试,选择一个自己比较满意的数组即可,那么就剩下一个b,将落点代入公式b = (y - a * x * x) / x求出,个人代码如下,仅供参考:
/** 计算抛物线公式相关信息 */
  function computeInfo(startTop: number, startLeft: number, endTop: number, endLeft: number): void {
    // 计算系数b
    const relativeLeft = endLeft - startLeft
    b = (endTop - startTop - a * relativeLeft * relativeLeft) / relativeLeft
  }

游戏过程

  • 设定帧数,物体从抛出到完全落下所使用的帧数,本项目设置为500帧,理论上从抛出到完全落下需要8秒左右的时间,逐帧更新坐标。
  • 更新坐标,首先,设定每一帧水平移动的距离每帧移动距离 = (起点left - 落点left) / 帧数,每次更新时,newLeft = oldLeft + 每帧移动距离,然后带入上面设定好的抛物线公式,可以求出newTop,个人代码如下,仅供参考:
/** 获取新的坐标 */
  function computeNewPosition(): { top: number; left: number } {
    const position = { top: top.value, left: left.value }
    // 步数(帧数)为零,已经执行完毕,销毁
    if (stepNum === 0) {
      destroyedDroppedObject()
      return position
    }
    // 帧数减一
    stepNum--
    // 计算新值,返回
    left.value += stepWidth
    const x = left.value - props.startPosition.left
    top.value = props.startPosition.top + a * x * x + b * x
    return position
  }
  • 然后需要一个充当发布者的函数,用来在每一帧进行更新物体坐标、判断物体是否进入篮筐、篮筐增加重量之后移动速度更改、判断游戏时间是否结束,都在这个函数之中,在本项目中就是对应的执行动画函数,代码如下:
/** 执行动画 */
  function execAnimation(): void {
    const time = Date.now()
    let allWeight = 0

    // 时间处理
    const isEnd = handleTimer(time)

    // 位置判定及进入basket判定
    for (let i = 0, len = droppedObjectRefList.length; i < len; i++) {
      const target = droppedObjectRefList[i]
      let isIntoBasket = false
      if (target.isShow) {
        const position = target.computeNewPosition && target.computeNewPosition()
        isIntoBasket = checkIntoBasket(position)
      }
      if (isIntoBasket) {
        target.destroyedDroppedObject && target.destroyedDroppedObject()
        const info: { point: number; weight: number } = droppedObjectInfo[target.objectName] || {
          point: 0,
          weight: 0
        }
        basketWeight.value += info.weight
        score.value += info.point
        allWeight += info.weight
      }
    }

    // basket速度调整
    if (basketSpeed > basketMinSpeed && allWeight > 0) {
      let newSpeed = basketSpeed + allWeight * acceleration
      newSpeed < basketMinSpeed && (newSpeed = basketMinSpeed)
      basketSpeed = newSpeed
    }

    // 判断是否添加新的抛落物
    if (time - droppedObjectLastCreateTime >= droppedObjectCreateIntervalTime) {
      droppedObjectLastCreateTime = time
      addDroppedObject(time)
    }
    isEnd || (animationHandler = requestAnimationFrame(execAnimation))
  }
  • 游戏剩余时间的判断没有采用定时器,而是采用递减的方式,使用当前时间减去上一次执行时间,获取时间差,从游戏剩余时间中减去这个时间差,得到新的剩余时间,公式新的游戏剩余时间 = 老的游戏剩余时间 - (当前时间 - 上次执行时间)。因为在执行动画函数中需要获取当前时间,可以使用这个当前时间来计算,所以没有必要额外添加定时器来增加开销了。

参考

  1. 素材: 阿里巴巴矢量图标库
  2. 技术: 小折腾:JavaScript与元素间的抛物线轨迹运动