前言
又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3
+Typescript
,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性. 所以我最后选择做类似这种风格的页面.
选择好了技术栈和制作主题和风格. 就直接开干了. 肝了一天, 以下是制作完成后的成果
先说一下剧本,这个剧本是春光灿烂猪八戒
的后羿(二牛)
和嫦娥
的人物角色加上东成西就
的大理段王爷
飞升桥段. 还有最后一个鬼畜飞升的效果,我先说一下,这个是实在没找到可用的素材,只能凑合的用网上找来的这个动画. o(╥﹏╥)o 好了, 那么就开始说说, 我是怎么实现这个类游戏的页面动画效果的.
页面组织结构
页面使用vite创建出来, 文件的结构是这样的
由于页面只有一个场景,所以整个页面是放在APP.vue
中写的. interface
文件夹存放定义的一些接口对象. 组件里边划分出来了4个组件, 依次是
-
dialogBox
: 底部对话框组件 -
lottie
: 输入咒语
后的一个彩蛋爆炸效果组件 -
sprite
精灵图动画组件 -
typed
输入咒语
的打字效果组件
那么我们就按照页面出现的动画效果依次去讲一下吧.
精灵图动画
页面开头首先是二牛
角色从左边走上桥头的动画. 这个动画我们先来分析一下, 首先是帧动画
, 也就是走路的这个动作的效果, 其次是从左边走上桥头的这个位移动画. 那么我们先说一下帧动画
帧动画
“逐帧动画是一种常见的动画形式(Frame By Frame),其原理是在“连续的关键帧”中分解动画动作,也就是在时间轴的每帧上逐帧绘制不同的内容,使其连续播放而成动画
用我这个项目举例, 二牛
走路的动画其实是一张图片在我们前端这张图也叫雪碧图
,图上有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[1]已经详细的讲清楚了每一个细节. 并且这一次的这个组件其实就是复用的我这篇文章讲的这个自己封装的组件. 基本的效果就这些,如果大家有兴趣的话,可以参照在我这个基础上再加入一些细节在里边. 比如添加云彩
动效,添加水波
动效等. 需要源码的可以点这里[2]看看 充实的一天就是过得这么快呀~ 大家下次再见咯. 提前祝大家中秋节快乐!
.
参考资料
https://juejin.cn/post/6983670062293598221: https://juejin.cn/post/6983670062293598221
[2]https://gitee.com/hacknews/moon.git: https://link.juejin.cn?target=https%3A%2F%2Fgitee.com%2Fhacknews%2Fmoon.git
关于本文:
来源:wzq
https://juejin.cn/post/7007011750746783757
The End
“在看”吗?在看就点一下吧
文章评论