我正在参加「初夏创意投稿大赛」详情请看:初夏创意投稿大赛
游戏简介
游戏规则
- 通过 [W] 或者 [D] 控制底下篮子左右移动, 接住粽子, 时限为一分钟;
- 左右的炮台会随机射出粽子和西瓜, 接住粽子或者西瓜会增加相应的分数和重量;
- 每个粽子1分, 重量为1;
- 每个西瓜0分, 重量为10;
- 篮子越重, 移动速度越慢;
技术栈
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是数据驱动的,只要改变抛落物列表的数据,便可达到增加或者删除抛落物的目的,不再赘述。
抛物线
- 最初的想法: 难点在于抛落物下落的抛物线比较难以确定,一开始打算给一个随机的水平初速度来达到分散掉落的目的,那么为了保证不会掉落到屏幕外,必须给这个水平速度添加一些限制条件,让抛落物掉在地面时,处于屏幕之内;然而,最终发现这种方式可行但不是最优的,因为必须得考虑到物体下落的时间,把相关的物理学公式化简后,计算还是比较麻烦,也比较耗时。
最初都想法是根据物理学去计算抛物线,这个方法最终实现起来比较复杂也比较耗时, 如果换一个角度,先设定好抛物线,再去计算其他就会比较简单。
- 最终解决办法: 先确定落点的坐标,坐标使用
top和left表示,根据起点和落点确定抛物线,抛去时间的概念,采用帧代替,即每个物体落到地面用了多少帧,来表示速度的快慢。
- 在屏幕内随机取一个落点的坐标,如果是从左侧射出,则
left = windowHalfWidth * Math.random() + windowHalfWidth,保证其落点在屏幕内且在右侧;如果在右侧射出, 则left = windowHalfWidth * Math.random(),保证落点在屏幕内且在左侧; - 落点
top是一个常量,可以通过一次计算直接赋予,其实就是屏幕高度 - 地板高度 - 物体高度 - 将起点坐标视为
(0, 0) - 现在有了落点坐标和起点坐标,那么抛物线公式
y = a * x * x + b * x + c,因为起点为(0, 0),所以很简单得知c = 0,a决定抛物线开口的大小,可以通过多次测试,选择一个自己比较满意的数组即可,那么就剩下一个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))
}
- 游戏剩余时间的判断没有采用定时器,而是采用递减的方式,使用当前时间减去上一次执行时间,获取时间差,从游戏剩余时间中减去这个时间差,得到新的剩余时间,公式
新的游戏剩余时间 = 老的游戏剩余时间 - (当前时间 - 上次执行时间)。因为在执行动画函数中需要获取当前时间,可以使用这个当前时间来计算,所以没有必要额外添加定时器来增加开销了。
参考
- 素材: 阿里巴巴矢量图标库
- 技术: 小折腾:JavaScript与元素间的抛物线轨迹运动