vue3+typescript 实现一个中秋RPG游戏

前言


又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性. 所以我最后选择做类似这种风格的页面.


ChMkJ1tpIkuINzThAAUqitDPvVkAAqf4wHB5i0ABSqi715.jpg


选择好了技术栈和制作主题和风格. 就直接开干了. 肝了一天, 以下是制作完成后的成果


GIF.gif


先说一下剧本,这个剧本是春光灿烂猪八戒后羿(二牛)嫦娥的人物角色加上东成西就大理段王爷飞升桥段. 还有最后一个鬼畜飞升的效果,我先说一下,这个是实在没找到可用的素材,只能凑合的用网上找来的这个动画. o(╥﹏╥)o 好了, 那么就开始说说, 我是怎么实现这个类游戏的页面动画效果的.


页面组织结构


页面使用vite创建出来, 文件的结构是这样的


image.png


由于页面只有一个场景,所以整个页面是放在APP.vue中写的. interface文件夹存放定义的一些接口对象. 组件里边划分出来了4个组件, 依次是



  1. dialogBox: 底部对话框组件
  2. lottie: 输入咒语后的一个彩蛋爆炸效果组件
  3. sprite 精灵图动画组件
  4. typed 输入咒语的打字效果组件

那么我们就按照页面出现的动画效果依次去讲一下吧.


精灵图动画


页面开头首先是二牛角色从左边走上桥头的动画. 这个动画我们先来分析一下, 首先是帧动画, 也就是走路的这个动作的效果, 其次是从左边走上桥头的这个位移动画. 那么我们先说一下帧动画


帧动画



“逐帧动画是一种常见的动画形式(Frame By Frame),其原理是在“连续的关键帧”中分解动画动作,也就是在时间轴的每帧上逐帧绘制不同的内容,使其连续播放而成动画



image.png


用我这个项目举例, 二牛走路的动画其实是一张图片在我们前端这张图也叫雪碧图,图上有4个动作,4个动作在不停的切换的时候,在我们人眼中就形成了走路的动效了. 好的,原理解释清楚了,那么我们现在看一下代码


  <div ref="spriteBox">
<div ref="sprite" class="sprite"></div>
</div>

页面的结构很简单, 就三行html代码, 外边包裹的html其实是用来做位移动画用的, 里边的sprite就是做帧动画的. 下面我们看一下javascript代码


// 样式位置
export interface positionInterface {
left?: string,
top?: string,
bottom?: string,
right?: string
}

export interface spriteInterface {
length: number, // 精灵图的长度
url: string, // 图片的路径
width: number, // 图片的宽度
height: number, // 图片的高度
scale?: number, // 缩放
endPosition: positionInterface // 动画结束站的位置
}

import { Ref } from "vue";
import { positionInterface, spriteInterface } from "../../interface";

/**
* 精灵图实现逐帧动画
* @param spriteObj 精灵对象
* @param target 精灵节点
* @param wrap 精灵父节点 [控制精灵移动]
* @param callback 图片加载好回调函数
* @param moveCallback 移动到对应位置的回调函数
*/
export function useFrameAnimation(
spriteObj: spriteInterface,
target: Ref,
wrap: Ref,
callback: Function,
moveCallback: Function
) {
const { width, length, url, endPosition } = spriteObj;
let index = 0;

var img = new Image();
img.src = url;
img.addEventListener("load", () => {
let time;
(function autoLoop() {
callback && callback();
// 如果到达了指定的位置的话,则停止
if (isEnd(wrap, endPosition)) {
if (time) {
clearTimeout(time);
time = null;
moveCallback && moveCallback();
return;
}
}
if (index >= length) {
index = 0;
}
target.value.style.backgroundPositionX = -(width * index) + "px";
index++;
// 使用setTimeout, requestFrameAnimation 是60HZ进行渲染,部分设备会卡,使用setTimeout可以手动控制渲染时间
time = setTimeout(autoLoop, 160);
})();
});

// 走到了对应的位置
function isEnd(wrap, endPosition: positionInterface) {
let keys = Object.keys(endPosition);
for (let key of keys) {
if (window.getComputedStyle(wrap.value)[key] === endPosition[key]) {
return true;
}
}
return false;
}
}

参数


useFrameAnimation 这个帧动画的函数, 函数参数先传递精灵图的描述对象,它主要描述精灵图上是有几个动作组成的,图片的地址是多少,图片在DOM节点上的对象,以及移动到指定位置后,传递给调用函数的父级的回调函数. 其实在代码中的注释也描述的很清楚了.


图片加载


我们在使用这张图片做帧动画的时候,首先得在这张图片是加载好之后再去处理的. 所以我们得先new Image, 然后给它赋值上src, 然后监听它的load事件,


循环切换动画


在load事件句柄内, 写了一个loop循环切换图片的backgroundPositionX属性达到页面动作图片的切换,由于是循环动画,如果动画走到了最后一张图片的时候,得切回第一张图片


添加回调函数钩子


在图片加载完成的时候,回调一个callback函数,告诉外边图片已经加载完成了,如果有一些需要图片加载完成的事情做的话,可以在这个回调函数里边去写. 代码里边还有一个isEnd函数, 去判断位移动画是否已经完成,如果位移动画完成了的话,则停止帧动画的循环,让它静止下来成为一张图片. 然后再执行moveCallback告诉调用函数的父级,位移动画已经执行完成了. 这个函数大致做的事情就是这些了.


位移动画


位移动画就比较简单了, 我们先看下代码:


<script lang="ts">
import {
computed,
defineComponent,
defineEmit,
PropType,
reactive,
ref,
toRefs,
watchEffect,
} from "vue";
import { spriteInterface } from "../../interface";
import { useFrameAnimation } from "./useFrameAnimation";

export default defineComponent({
props: {
action: {
type: Boolean,
default: false,
},
spriteObj: Object as PropType<spriteInterface>,
},
defineEmit: ["moveEnd"],
setup(props, { emit }) {
const spriteBox = ref(null);
const sprite = ref({ style: "" });
const spriteObj = reactive(props.spriteObj || {}) as spriteInterface;
const { width, height, url, length } = toRefs(spriteObj);
watchEffect(() => {
if (props.action) {
useFrameAnimation(
spriteObj,
sprite,
spriteBox,
() => {
triggerMove();
},
() => {
emit("moveEnd");
}
);
}
});
// 给宽度后边加上单位
const widthRef = computed(() => {
return width.value + "px";
});
// 给高度后边加上单位
const heightRef = computed(() => {
return height.value + "px";
});
// 给背景图片连接添加url
const urlImg = computed(() => {
return `url("${url.value}")`;
});
// 移动到目标位置
function triggerMove() {
if (spriteObj.scale || spriteObj.scale === 0) {
spriteBox.value.style.transform = `scale(${spriteObj.scale})`;
}
if (spriteObj.endPosition) {
Object.keys(spriteObj.endPosition).forEach((o) => {
if (spriteBox.value && sprite.value.style) {
spriteBox.value.style[o] = spriteObj.endPosition[o];
}
});
}
}
return {
widthRef,
heightRef,
urlImg,
length,
sprite,
spriteBox,
triggerMove,
};
},
});
</script>

代码中主要的是这个watchEffect, 根据使用精灵组件传递的props.action去开始决定是否开始帧动画,在调用我们上一段讲的useFrameAnimation函数后,第四个参数回调函数是图片加载完成,图片加载完成的时候,我们可以在这里做位移动画,也就是triggerMove,triggerMove函数里实际上就是把在spriteObj配置好的一些位置以及缩放信息放到对应的DOM节点上,要说动画的话,其实是css去做的. 在监听到位移动画结束后,传递给父级一个moveEnd自定义事件.


<style lang="scss" scoped>
.sprite {
width: v-bind(widthRef);
height: v-bind(heightRef);
background-image: v-bind(urlImg);
background-repeat: no-repeat;
background-position: 0;
background-size: cover;
}
</style>

这里的css只描述了关于精灵图的宽度高度和图片路径,上边这种写法v-bind是vue3后可以使用的一种方式,这样就可以把动态的变量直接写在CSS里边了, 用过的都说好~ 关于精灵图真正的动画效果是写在了APP.vue里边的css里


  .boy {
position: absolute;
bottom: 90px;
left: 10px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}
.girl {
position: absolute;
bottom: 155px;
right: 300px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}

上面描述了二牛嫦娥的初始位置,以及动效.


对话框组件


二牛走到嫦娥旁边后,APP.vue就通过前面说的moveEnd自定义事件知晓了动画结束,然后在动画结束后,弹出对话框. 对话的话, 其实就得先想好一个对话的剧本以及对话剧本的格式了.


对话剧本


const dialogueContent = [
{
avatar: "/images/rpg_male.png",
content: "二牛:嫦娥你终于肯和我约会了, 哈哈",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:二牛对不起,我是从月宫来的,我不能和人间的你在一起!",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:今天是中秋节,我只有今天这个机会可以重新回月宫",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:回月宫的条件是找到真心人,让他念起咒语,我才能飞升!",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:而你就是我的真心人,你可以帮我嘛?",
},
{
avatar: "/images/rpg_male.png",
content: "二牛:好的,我明白了! 我会帮你的.",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:好的。 谢谢你!",
},
];

以上就是我这个小游戏的剧本了, 因为是别人先说一段,我再说一段,或者别人说了一段,再接着说一段. 这种的话,就是直接按照对话顺序写下来就好了, 然后我们在代码里边就可以通过点击时间的交互来按照顺序一个一个展现出来. 对话的结构主要就人物头像人物内容, 这里我为了省事,把人物的名称也直接在内容里边展现出来, 其实如果需要的话,可以提出来.


结构


我们先看一下它的html结构


  <div v-if="isShow" class="rpg-dialog" @click="increase">
<img :src="dialogue.avatar" class="rpg-dialog__role" />
<div class="rpg-dialog__body">
{{ contentRef.value }}
</div>
</div>

结构其实也很简单,里边就是一个头像和内容,我们用isShow去控制对话框的显示隐藏,用increase去走到下一个对话内容里边.


逻辑实现


    function increase() {
dialogueIndex.value++;
if (dialogueIndex.value >= dialogueArr.length) {
isShow.value = false;
emit("close");
return;
}
// 把下个内容做成打字的效果
contentRef.value = useType(dialogue.value.content);
}

increase方法里边也很简单,点击后,申明的索引(默认是0开始)+1,如果索引等于剧本的长度了的时候, 就把对话框关掉,然后给APP.vue一个close自定义事件, 如果小于剧本的长度的话,则走到下一个剧本内容,并且以打字的效果呈现. 也就是useType方法.


/**
* 打字效果
* @param { Object } content 打字的内容
*/
export default function useTyped(content: string): Ref<string> {
let time: any = null
let i:number = 0
let typed = ref('_')
function autoType() {
if (typed.value.length < content.length) {
time = setTimeout(() =>{
typed.value = content.slice(0, i+1) + '_'
i++
autoType()
}, 200)
} else {
clearTimeout(time)
typed.value = content
}
}
autoType()
return typed
}

打字效果实现也很简单,默认给一个_,然后逐一拿到字符串的每一个字符,一个一个的加在新字符串后边. 如果拿到完整的字符串的时候,则停止循环.


打字框(咒语)组件


在结束了剧本后, APP.vue会拿到组件跑出来的close自定义事件,在这里面,我们可以把诅咒组件给显示出来,


结构


<div v-if="isShow" class="typed-modal">
<div class="typed-box">
<div class="typed-oldFont">{{ incantation }}</div>
<div
@input="inputChange"
ref="incantainerRef"
contenteditable
class="typed-font"
>
{{ font }}
</div>
</div>
</div>

诅咒组件,这里的html结构,我们可以看一下,里边用到了contenteditable这个属性,设置了这个属性后,div就可以变的和输入框类似,我们可以直接在div上面的文字上自由修改. 所以我们就需要在用户修改的时候,监听它的input事件. incantation 这个放的就是底部的提示咒语, font放的就是我们需要输入的咒语.


逻辑实现


export default defineComponent({
components: {
ClickIcon,
},
emits: ["completeOver"],
setup(props, { emit }) {
const isShow = ref(true);
const lottie = ref(null);
const incantainerRef = ref(null);
const defaultOption = reactive(defaultOptions);
const incantation = ref("Happy Mid-autumn Day");
let font = ref("_");

nextTick(() => {
incantainerRef.value.focus();
});

function inputChange(e) {
let text = e.target.innerText.replace("_", "");
if (!incantation.value.startsWith(text)) {
e.target.innerText = font.value;
} else {
if (incantation.value.length === text.length) {
emit("completeOver");
font.value = text;
isShow.value = false;
lottie.value.toggle();
} else {
font.value = text + "_";
}
}
}

return {
font,
inputChange,
incantation,
incantainerRef,
defaultOption,
lottie,
isShow,
};
},
});
</script>

在组件弹窗的时候,我们用incantainerRef.value.focus();让它自动获取焦点. 在inputChange事件里边, 我们去判断输入的咒语是否和提示的咒语相同,如果不同的话,则无法继续输入, 并停留在输入正确的咒语上, 如果都输入正确了的话, 则会自动关闭咒语弹窗,并弹出一个类似恭喜通过的烟花效果. 传入一个completeOver自定义事件给APP.vue.


页面主题APP.vue


页面的话,其实就像一个导演了. 接收到演员的各种回馈后, 然后安排下一个演员就位


  setup() {
let isShow = ref(false); // 对话框窗口开关
let typedShow = ref(false); // 咒语窗口开关
let girlAction = ref(false); // 女孩动作开关, 导演喊一句action后,演员开始演绎
const boy = reactive(boyData);
const girl = reactive(girlData);
const dialogueArr = reactive(dialogueContent);
// 男孩移动动画结束
function boyMoveEnd() {
isShow.value = true;
}
// 完成输入咒语
function completeOver() {
girlAction.value = true;
}
function girlMoveEnd() {}
// 对话窗口关闭
function dialogClose() {
// 对话框关闭后,弹出咒语的窗口,二牛输入咒语后,嫦娥开始飞仙动作
typedShow.value = true;
}
return {
dialogueArr,
boy,
girl,
isShow,
boyMoveEnd,
girlMoveEnd,
girlAction,
dialogClose,
typedShow,
completeOver,
};

大家看看就好,其实没啥特别好说的.


写在最后


关于那个烟花效果的话,我就不讲了,因为我上次的文章如何在vue中使用Lottie已经详细的讲清楚了每一个细节. 并且这一次的这个组件其实就是复用的我这篇文章讲的这个自己封装的组件. 基本的效果就这些,如果大家有兴趣的话,可以参照在我这个基础上再加入一些细节在里边. 比如添加云彩动效,添加水波动效等. 需要源码的可以点这里看看 充实的一天就是过得这么快呀~ 大家下次再见咯. 提前祝大家中秋节快乐!.



链接:https://juejin.cn/post/7007011750746783757

0 个评论

要回复文章请先登录注册