"龙年教你怼亲戚"!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程 |
项目上线:http://124.222.20.115/#/ 项目以开源,源码访问:github.com/IcicleCrino... 如果这个项目对你有帮助的话,请点一个小星星⭐
前言
本人是一名大二的学生,学习前端。寒假在家颇为无聊,无意间看到稀土掘金的春节比赛,正愁找不到项目练手。于是从1/31开始到2/10花了11天写了一个怼亲戚的小游戏,以这篇文章记录下自己完成这个项目的所有流程,力求做到事无巨细。包括:灵感阶段-MVP版本-版本迭代-打包上线,以及自己在各个阶段用到的一些好方法,希望可以做到让阅读这篇文章的人有所收获。
灵感阶段
灵感1-抢红包小游戏
在详细阅读比赛规则后,我的第一个想法是做一个抢红包小游戏,像是红包从天而降,然后鼠标点击抢红包。随后我使用 excalidraw.com/ 把我的想法画了出来。
但是在查阅资料的时候,发现已经有许多人实现了类似的红包雨功能,别人做了我还做,没有创新。于是我的第一个灵感也便被我舍弃了。
灵感2-红包塔防
后来我思考到,要不要把红包和塔防结合起来,建造红包来抵御年兽的攻击。 但是当我深入思考,准备设计年兽形象和红包塔时,发现塔防游戏非常吃美术资源。很遗憾的是本人的美术水平并不高超,琢磨了半天也没有画出满意的形象,于是这个灵感也被我舍弃了
灵感3-过年怼亲戚的文字对话游戏
这个灵感来自于最近刷到的春节嘴替视频:年轻人回家已经很累了,面对春节回家后,家里亲戚各种的灵魂拷问,互联网嘴替出手了。于是我想,我为何不做一个怼亲戚的小游戏,用类似于聊天对话的形式展现出来,每段对话再提供各种不同的选项让玩家选择。一个跑在网页上的文字对话选项游戏。于是我开始找教程,发现并没有文字对话的教程,既然没有教程,那就我来写一个吧。
MVP版本
MVP(Minimum Viable Product)即最小可运行版本。在早期开发阶段,我们优先完成MVP版本。在这个MVP版本里不需要考虑复杂的功能设计、不需要考虑花哨的颜色搭配。我们要做的就是将自己的灵感描述出来,暂时不需要考虑任何无关的设计,同时去除所有可删去的功能,只保留最小的设计实现可运行的网页。
设计图
这里还是推荐使用 excalidraw.com/ 用简图来画出自己的MVP版本,既然我要做的是一个文字对话游戏,那么我必须设计一个对话框和一个选择框。其他的任何可以让产品变得更好的设计暂时不需要考虑,这是后续迭代需要做的事情。
设计图0.0.0版本
设计图0.1.0版本
如果只做一个对话功能未免太过于无聊单调,我希望我的"怼亲戚"的过程中可以有更多的趣味性,为此我需要添加新的机制进去。继续从灵感深挖,如果要怼亲戚的话,怎么让玩家在怼的过程中可以收获到快乐呢?加一个怒气值系统或许会不错,每个选项都会为亲戚增加不同的的怒气值。
设计图0.2.0版本
加了一个怒气值系统,游戏确实更加好玩了,如果要继续迭代的话,我觉得可以从增加玩家的代入感入手。为亲戚设计一个反应框,亲戚会对玩家选择的选项做出不同的反应。
0.0.0版本代码实现
在进行前期项目设计时期,就投入足够多的时间,将各种需求和后期可能的迭代方向尽可能的考虑进去,之后的代码实现和后期维护迭代也会得心应手。下面我将列举我对于文字对话游戏的设计思路
1.对话事件处理逻辑
事件的存储:使用数组按照顺序存储对话和选项。 事件的处理:首先写一个事件处理器,传入各种事件对象,并执行相关事件。随后遍历事件存储数组,依次丢进事件处理器里。
事件接口设计
使用ts定义各种事件的接口,在大体上可以将文字对话分为三类:
- Dialog对话类型:包含姓名和对话内容
- Choose选择类型:一个数组,数组里包含各种Item选项
- End游戏结束类型:用来标注游戏已经结束 除了这基本的三类,还有两种基于Choose选择类型的衍生类型
- Item选项类型:包含选项的对话、选项增加的怒气值、可能会有衍生对话
- ItemDIalog衍生对话类型:包含姓名和对话内容
代码接口: 这里我们单独建一个interface.ts文件,用来存放定义的接口。这样其他组件需要用到这些接口时可以直接导入。
ts
export interface Dialog {
type: string
name: string
dialog: string
}
export interface ItemDialog {
type: string
name: string
dialog: string
}
export interface Item {
type: string
name: string
index: number
dialog: string
effect: number
itemDialogNumber: number
next?: ItemDialog[]
}
export interface Choose {
type: string
options: Item[]
length: number
}
export interface End {
type: string
}
事件处理器
这部分是整个游戏最核心的部分,这里可以定义不同事件的处理方式。
- 对话事件的处理:建立一个reavtive类型的数组eventDialog
ts
const eventDialog = reactive<Array<Dialog | Item | ItemDialog>>([])
这个数组存放展示的对话。当处理到Dialog对话事件时,将事件对象添加进数组eventDialog。数组eventDialog将通过v-for渲染到对话框里。这样一来每次处理到对话事件,就会把对话添加并渲染到v-for绑定的元素上。 再通过Vue的transition-group组件去定义元素进入的动画
vue
<transition-group name="game_dialog__item" enter-active-class="animate__animated animate__bounceIn" :duration="1000">
<div class="game-dialog__item" v-for="( item, index ) in eventDialog" :key="index" :class="item.type">
<div class="game-dialog__name">{{ item.name }}</div>
<div class="game-dialog__seprator">:</div>
<div class="game-dialog__dialog">{{ item.dialog }}</div>
</div>
</transition-group>
再按照设计图去写出相应的css,即可实现下图效果:
- 选择事件的处理:建立一个reavtive类型的数组eventChoose,
ts
const eventChoose = reactive<Array<Item>>([])
这个数组存放当前选择事件的多个选项。将数组eventChoose用v-for渲染到选择框中,每当处理到Choose选择事件时,就用forEach将每个选项都添加到数组eventChoose中,使其渲染到页面上。同时为每个选项都绑定点击事件,这个点击事件用来处理用户的点击选择。
vue
<transition-group name="game_choose__item" enter-active-class="animate__animated animate__fadeInUp" leave-active-class="animate__animated animate__fadeOut" v-if="!clickLock">
<div class="game_choose__item" @click="chooseHandler(item.index - 1)" v-for="(item, index) in eventChoose" :key="index">
<div class="game_choose__word">{{ item.dialog }}</div>
</div>
</transition-group>
- 结束事件的处理:如果处理到End结束事件,则停止游戏,播放结算动画。
全代码:
ts
//传入各种事件,执行事件
const eventHandler = (event: Choose | Dialog | End | Item | ItemDialog) => {
if (event.type == "Dialog") {
eventDialog.push(event as Dialog)
}
else if (event.type == "Choose") {
//当为选择事件时,停止自动处理事件
pauseInterval(intervalID);
(event as Choose).options.forEach((el) => {
eventChoose.push(el)
})
}
else if (event.type == "End") {
//当为End事件时,停止自动处理事件
pauseInterval(intervalID)
//播放结算页面
gameOverExposed.value?.appear()
return
}
}
选项点击事件处理
上文中提到,如果遇到了选择事件,则将选项通过forEach添加到reavtive数组eventChoose里,并绑定了v-for渲染到页面中。为了处理用户的点击事件我们为不同的选项都绑定了点击事件@click="chooseHandler(item.index - 1)"
并将下标作为参数传入了点击事件中。这样就可以通过下标访问到eventChoose数组中对应的选择对象。在这个点击事件处理函数中,我们需要执行下面几个步骤
- 将选择的选项添加到对话框里,并且将对话框滚动到最底部:直接将选择对象添加到对话框绑定的v-for数组中,随后通过ref绑定对话框最外层的warpper,随后修改DOM元素的
scrollTop
等于scrollHeight
vue
<div ref="game_dialog__warpper" class="game-dialog__warpper">
<transition-group name="game_dialog__item" enter-active-class="animate__animated animate__bounceIn" :duration="1000">
<div class="game-dialog__item" v-for="( item, index ) in eventDialog" :key="index" :class="item.type">
<div class="game-dialog__name">{{ item.name }}</div>
<div class="game-dialog__seprator">:</div>
<div class="game-dialog__dialog">{{ item.dialog }}</div>
</div>
</transition-group>
</div>
ts
const scrollBottom = () => {
game_dialog__warpper.value.scrollTop = game_dialog__warpper.value.scrollHeight
}
eventDialog.push(eventChoose[index])
nextTick(() => {
scrollBottom()
})
- 处理衍生对话:通过为选项对象添加一个数组, 在这个数组里存放衍生对话对象
随后如果我们点击的选项有衍生对话,则将衍生对话也添加到对话框绑定的reactive数组里。在这里我们要注意,如果直接push进数组里的话,页面会将这些衍生对话全部同时渲染出来,所以我们需要注意时间。 (如果你注意到注释中的上锁和解锁,这是为了防止用户多次点击而写的防抖)
ts
//衍生对话
if (eventChoose[index].itemDialogNumber > 0) {
let time = 1000
for (let i = 0; i < (eventChoose[index].next as ItemDialog[]).length; i++) {
setTimeout(() => {
eventDialog.push((eventChoose[index].next as ItemDialog[])[i])
nextTick(() => {
scrollBottom()
})
}, time, (eventChoose[index].next as ItemDialog[])[i])
time += 1000
}
setTimeout(() => {
eventChoose.splice(0, eventChoose.length)
//如果有衍生对话,则衍生对话结束后再解锁
clickLock = false
restartInterval()
}, time)
}
else {
//没有衍生对话直接解锁
clickLock = false
//选择之后,继续自动执行事件
restartInterval()
eventChoose.splice(0, eventChoose.length)
}
现在我们为完成了0.0.0版本的事件处理器,能够正确的处理对话事件、选择事件以及结束事件。要想作为游戏跑起来,我们需要将事件数组(script1)按照次序,设置一个合理的间隔时间,丢进事件处理器处理就可以了。这里我们使用setInterval来完成功能。
ts
const startInterval = () => {
intervalID = setInterval(() => {
nextEvent()
}, 1000)
}
const nextEvent = () => {
eventHandler(script1[eventPointer])
eventPointer++
nextTick(() => {
scrollBottom()
})
}
- 事件数组的初始化 上文以及使用interface定义了各个事件的接口,我们可以自己编写各个对象并且放到一个脚本数组里,像这样 但是这样用代码去编写脚本,实在算不上优雅,在之后我们可以用node.js+正则表达式+文件读写来自己写一个文本解析器,将txt文本解析成上文的数组。现在先让我们继续进行版本迭代
0.1.0版本代码实现
现在我们需要添加怒气值系统,我们可以在每个选项对象中添加一个新的属性effect,这个属性代表选项对亲戚造成的怒气值。
ts
export interface Item {
type: string
name: string
index: number
dialog: string
//新添的怒气值属性
effect: number
itemDialogNumber: number
next?: ItemDialog[]
}
我们可以用一个ref变量记录下亲戚的怒气值
ts
//对亲戚造成的伤害
var eventStatus = ref(0)
然后在我们写的选项点击事件处理函数chooseHandler()中添加代码
ts
if (eventStatus.value + eventChoose[index].effect < 0) {
//怒气值不能为负
}
else {
eventStatus.value += eventChoose[index].effect
}
然后将怒气值与怒气条的width绑定,设置怒气条的背景颜色
vue
<div class="game-status__warpper">
<div class="game-status__inner" :style="'width: ' + eventStatus + '%'"></div>
</div>
这样一来,我们每次选择选项后,都会增加或减少亲戚的怒气值
txt文本规范
为了我们的文本解析器可以正确的识别文本,我们要对txt文本制定规范。满足某种规范的文字将被解析成对应的对象事件 我们可以一行一行的读取txt文件,每一行代表一种事件对象。
ts
const rl = readline.createInterface({
input: fs.createReadStream(filePath),
output: process.stdout,
terminal: false
})
rl.on("line", (line: string) => {
//先将脚本每一行作为元素放入数组中
tempScript.push(line)
})
随后定义各种事件对象的正则表达式,如果满足正则表达式,则说明这一行是该事件对象。随后通过字符串的.splite()方法将对象的各个属性切出来,然后放创建满足接口的类,然后放进脚本数组里。
- 先将txt文件每一行切出来,将每一行作为string存到临时数组里
ts
const fs = require("fs")
const readline = require("readline")
const filePath = "./stage1.txt"
const outPath = "./stage1.json"
const tempScript: Array<string> = []
export const script: Array<Choose | Dialog | End | Item | ItemDialog> = []
const rl = readline.createInterface({
input: fs.createReadStream(filePath),
output: process.stdout,
terminal: false
})
rl.on("line", (line: string) => {
//先将脚本每一行作为元素放入数组中
tempScript.push(line)
})
- 然后各个事件类型的正则表达式,随后编写解析函数,传入字符串,将满足特定正则表达式的字符串解析成对应的对象
ts
export const patternDialog: RegExp = /^[\u4e00-\u9fa5()]+:/
export const patternChooes: RegExp = /chooes:{1}[0-9]+/
export const patternItem: RegExp = /^[0-9]{1}./
export const patternItemDialog: RegExp = /^\s+[\u4e00-\u9fa5]+:{1}/
export const patternEnd: RegExp = /end/
//将每一行解析成对象返回
export const patterPrase = (str: string): Choose | Dialog | Item | ItemDialog | End | Error => {
try {
if (patternDialog.test(str)) {
let temp: Array<string> = str.split(":")
let dialog = new ClassDialog(temp[0], temp[1])
console.log(dialog);
return dialog
}
else if (patternChooes.test(str)) {
let temp: Array<string> = str.split(":")
let choose = new ClassChoose([], parseInt(temp[1]))
console.log(choose);
return choose
}
else if (patternItem.test(str)) {
let temp: Array<string> = str.split(/[.:|]/)
//如果有冒号,则说明有衍生对话
if (temp.length >= 4) {
var item = new ClassItem(parseInt(temp[0]), temp[1], parseInt(temp[2]), parseInt(temp[3]))
}
else {
var item = new ClassItem(parseInt(temp[0]), temp[1], parseInt(temp[2]))
}
console.log(item);
return item
}
else if (patternItemDialog.test(str)) {
let temp: Array<string> = str.trim().split(":")
let itemDialog = new ClassItemDialog(temp[0], temp[1])
console.log(itemDialog);
return itemDialog
}
else if (patternEnd.test(str)) {
let end = new ClassEnd
console.log(end);
return end
}
else {
throw ("parse error")
}
}
catch {
return Error("parse error")
}
}
- 将txt文件的每一行都丢到解析函数里,将返回的事件对象添加到脚本数组里。至此实现了将txt文件解析成脚本数组的功能。
ts
rl.on("close", () => {
//然后对tempScript的元素做解析,push进script数组中
for (let i = 0; i < tempScript.length; i++) {
let tempStr: string = tempScript[i]
let tempType: Choose | Dialog | Item | ItemDialog | End | Error = patterPrase(tempStr)
if ((tempType as Choose).type == "Choose") {
script.push(tempType as Choose)
let options: Array<Item> = []
//在这里通过i++,跳过Choose下面的n个选项Item
for (let j = 0; j < (tempType as Choose).length; j++) {
i++
let tempItem: Item = patterPrase(tempScript[i]) as Item
//如果有衍生对话的话
if (tempItem.itemDialogNumber > 0) {
let tempNext: Array<ItemDialog> = []
//通过i++,跳过Item下n个衍生对话ItemDialog
for (let k = 0; k < (tempItem.itemDialogNumber); k++) {
i++
let tempItemDialog: ItemDialog = patterPrase(tempScript[i]) as ItemDialog
tempNext.push(tempItemDialog)
}
tempItem.next = tempNext
options.push(tempItem)
}
else {
options.push(tempItem)
}
}
(tempType as Choose).options = options
}
else if ((tempType as Dialog).type == "Dialog") {
script.push(tempType as Dialog)
}
else if ((tempType as End).type == "End") {
script.push(tempType as Dialog)
}
}
fs.writeFileSync(outPath, JSON.stringify(script));
console.log(script);
})
0.2.0版本代码实现
在这次版本迭代中,我们要添加亲戚的反应。我们可以在点击选项时,通过判断是增加怒气值还是减少怒气值,将亲戚的图片换成生气或是正常
首先添加一个亲戚的反应框
vue
<div class="game-reaction__warpper">
<div class="game-reaction__box" :style="reactionStyle">
<div class="game-reaction game-reaction__normal" v-show="normal"></div>
<div class="game-reaction game-reaction__angry" v-show="angry"></div>
</div>
</div>
通过v-show绑定变量,然后在点击事件chooseHandler()中根据条件修改变量的值,实现不同图片的切换。
ts
//根据选择,更换不同的表情
if (eventChoose[index].effect > 0) {
//增加亲属怒气值
normal.value = false
angry.value = true
//奶奶只需要震惊3s就够了
setTimeout(() => {
normal.value = true
angry.value = false
}, 2500);
}
else {
normal.value = true
angry.value = false
}
至此我们文字对话的基本逻辑以及完成,然后稍微写一点样式美化一下。最后来一起看看成果吧
上线
打包
Vue3直接使用vite打包,在命令行输入指令
sh
npm run build
随后会生成dist文件夹
上线
本人用的是ubuntu宝塔面板,下面来讲讲宝塔如何上线网站 点击网站,添加站点。如果出现推荐安装,安装就好
如果你买了域名的话,可以填入域名。没有的话可以直接填入ip地址。 随后通过filezilla或者其他FTP软件将dist文件夹上传到服务器目录/www/wwwroot/下
随后点击提交,就可以通过设置的域名访问了。
注意事项
使用vue-router时,请使用createWebHashHistory()哈希地址。如果使用createWebHistory的话,页面刷新会导致404页面
感谢名单
感谢我的高中同学灏宝,项目中的选项卡片的像素龙和老奶奶的形象都是灏宝帮我画的。感谢灏宝为我提供的美术资源,灏宝学的是Unit引擎,同时参加了这个游戏的设计过程。感谢你,我的灏宝!