“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

项目上线:http://124.222.20.115/#/

项目以开源,源码访问:github.com/IcicleCrino…

如果这个项目对你有帮助的话,请点一个小星星⭐

“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

前言

本人是一名大二的学生,学习前端。寒假在家颇为无聊,无意间看到稀土掘金的春节比赛,正愁找不到项目练手。于是从1/31开始到2/10花了11天写了一个怼亲戚的小游戏,以这篇文章记录下自己完成这个项目的所有流程,力求做到事无巨细。包括:灵感阶段-MVP版本-版本迭代-打包上线,以及自己在各个阶段用到的一些好方法,希望可以做到让阅读这篇文章的人有所收获。

灵感阶段

灵感1-抢红包小游戏

在详细阅读比赛规则后,我的第一个想法是做一个抢红包小游戏,像是红包从天而降,然后鼠标点击抢红包。随后我使用 excalidraw.com/ 把我的想法画了出来。
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

但是在查阅资料的时候,发现已经有许多人实现了类似的红包雨功能,别人做了我还做,没有创新。于是我的第一个灵感也便被我舍弃了。

灵感2-红包塔防

后来我思考到,要不要把红包和塔防结合起来,建造红包来抵御年兽的攻击。
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程
但是当我深入思考,准备设计年兽形象和红包塔时,发现塔防游戏非常吃美术资源。很遗憾的是本人的美术水平并不高超,琢磨了半天也没有画出满意的形象,于是这个灵感也被我舍弃了

灵感3-过年怼亲戚的文字对话游戏

这个灵感来自于最近刷到的春节嘴替视频:年轻人回家已经很累了,面对春节回家后,家里亲戚各种的灵魂拷问,互联网嘴替出手了。于是我想,我为何不做一个怼亲戚的小游戏,用类似于聊天对话的形式展现出来,每段对话再提供各种不同的选项让玩家选择。一个跑在网页上的文字对话选项游戏。于是我开始找教程,发现并没有文字对话的教程,既然没有教程,那就我来写一个吧。

MVP版本

MVP(Minimum Viable Product)即最小可运行版本。在早期开发阶段,我们优先完成MVP版本。在这个MVP版本里不需要考虑复杂的功能设计、不需要考虑花哨的颜色搭配。我们要做的就是将自己的灵感描述出来,暂时不需要考虑任何无关的设计,同时去除所有可删去的功能,只保留最小的设计实现可运行的网页。

设计图

这里还是推荐使用 excalidraw.com/ 用简图来画出自己的MVP版本,既然我要做的是一个文字对话游戏,那么我必须设计一个对话框和一个选择框。其他的任何可以让产品变得更好的设计暂时不需要考虑,这是后续迭代需要做的事情。

设计图0.0.0版本

“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

设计图0.1.0版本

如果只做一个对话功能未免太过于无聊单调,我希望我的“怼亲戚”的过程中可以有更多的趣味性,为此我需要添加新的机制进去。继续从灵感深挖,如果要怼亲戚的话,怎么让玩家在怼的过程中可以收获到快乐呢?加一个怒气值系统或许会不错,每个选项都会为亲戚增加不同的的怒气值。
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

设计图0.2.0版本

加了一个怒气值系统,游戏确实更加好玩了,如果要继续迭代的话,我觉得可以从增加玩家的代入感入手。为亲戚设计一个反应框,亲戚会对玩家选择的选项做出不同的反应。
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

0.0.0版本代码实现

在进行前期项目设计时期,就投入足够多的时间,将各种需求和后期可能的迭代方向尽可能的考虑进去,之后的代码实现和后期维护迭代也会得心应手。下面我将列举我对于文字对话游戏的设计思路

对话事件处理逻辑

事件的存储:使用数组按照顺序存储对话和选项。

事件的处理:首先写一个事件处理器,传入各种事件对象,并执行相关事件。随后遍历事件存储数组,依次丢进事件处理器里。
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

事件接口设计

使用ts定义各种事件的接口,在大体上可以将文字对话分为三类:

  1. Dialog对话类型:包含姓名和对话内容
  2. Choose选择类型:一个数组,数组里包含各种Item选项
  3. End游戏结束类型:用来标注游戏已经结束
    除了这基本的三类,还有两种基于Choose选择类型的衍生类型

    1. Item选项类型:包含选项的对话、选项增加的怒气值、可能会有衍生对话
    2. ItemDIalog衍生对话类型:包含姓名和对话内容
      “龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

代码接口:
这里我们单独建一个interface.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
const eventDialog = reactive<Array<Dialog | Item | ItemDialog>>([])

这个数组存放展示的对话。当处理到Dialog对话事件时,将事件对象添加进数组eventDialog。数组eventDialog将通过v-for渲染到对话框里。这样一来每次处理到对话事件,就会把对话添加并渲染到v-for绑定的元素上。
再通过Vue的transition-group组件去定义元素进入的动画

<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,即可实现下图效果:“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

  • 选择事件的处理:建立一个reavtive类型的数组eventChoose,
const eventChoose = reactive<Array<Item>>([])

这个数组存放当前选择事件的多个选项。将数组eventChoose用v-for渲染到选择框中,每当处理到Choose选择事件时,就用forEach将每个选项都添加到数组eventChoose中,使其渲染到页面上。同时为每个选项都绑定点击事件,这个点击事件用来处理用户的点击选择。

<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结束事件,则停止游戏,播放结算动画。

全代码:

//传入各种事件,执行事件
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
<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>
const scrollBottom = () => {
   game_dialog__warpper.value.scrollTop = game_dialog__warpper.value.scrollHeight
}

eventDialog.push(eventChoose[index])
nextTick(() => {
   scrollBottom()
})

  • 处理衍生对话:通过为选项对象添加一个数组, 在这个数组里存放衍生对话对象
    “龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

随后如果我们点击的选项有衍生对话,则将衍生对话也添加到对话框绑定的reactive数组里。在这里我们要注意,如果直接push进数组里的话,页面会将这些衍生对话全部同时渲染出来,所以我们需要注意时间。
(如果你注意到注释中的上锁和解锁,这是为了防止用户多次点击而写的防抖)

    //衍生对话
    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来完成功能。

const startInterval = () => {
    intervalID = setInterval(() => {
        nextEvent()
    }, 1000)
}

const nextEvent = () => {
    eventHandler(script1[eventPointer])
    eventPointer++
    nextTick(() => {
        scrollBottom()
    })
}
  • 事件数组的初始化
    上文以及使用interface定义了各个事件的接口,我们可以自己编写各个对象并且放到一个脚本数组里,像这样
    “龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程
    但是这样用代码去编写脚本,实在算不上优雅,在之后我们可以用node.js+正则表达式+文件读写来自己写一个文本解析器,将txt文本解析成上文的数组。现在先让我们继续进行版本迭代

0.1.0版本代码实现

现在我们需要添加怒气值系统,我们可以在每个选项对象中添加一个新的属性effect,这个属性代表选项对亲戚造成的怒气值。
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

export interface Item {
    type: string
    name: string
    index: number
    dialog: string
	//新添的怒气值属性
    effect: number
    itemDialogNumber: number
    next?: ItemDialog[]
}

我们可以用一个ref变量记录下亲戚的怒气值

//对亲戚造成的伤害
var eventStatus = ref(0)

然后在我们写的选项点击事件处理函数chooseHandler()中添加代码

    if (eventStatus.value + eventChoose[index].effect < 0) {
	    //怒气值不能为负
    }
    else {
        eventStatus.value += eventChoose[index].effect
    }

然后将怒气值与怒气条的width绑定,设置怒气条的背景颜色
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

<div class="game-status__warpper">
    <div class="game-status__inner" :style="'width: ' + eventStatus + '%'"></div>
</div>

这样一来,我们每次选择选项后,都会增加或减少亲戚的怒气值“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

0.2.0版本代码实现

在这次版本迭代中,我们要添加亲戚的反应。我们可以在点击选项时,通过判断是增加怒气值还是减少怒气值,将亲戚的图片换成生气或是正常
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

首先添加一个亲戚的反应框

<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()中根据条件修改变量的值,实现不同图片的切换。

//根据选择,更换不同的表情
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
}

使用txt文本记录对话

现在我们来优雅的解决对话数组问题,使用txt文本编写剧本,随后使用node.js解析成数组。

  • 前置知识点:

    1.node.js的fs模块进行文件读写

    2.正则表达式

现在让我们快速过一遍这些知识点

node.js的fs模块

Node.js 的 fs 模块是一个用于与文件系统交互的模块,提供了一套用于文件的操作API。它可以用来读取、写入文件,以及与文件系统进行交互的其他功能,比如创建和删除目录、读取目录内容、监听文件变化等。

如何实现逐行读取txt文件呢?

readFile()方法

readFile()方法将文件读取到内存中,并且是阻塞的

const fs = require("fs")
const filePath="path/to/your/file.txt"

fs.readFile(filePath, "utf-8", (err, data) => {
	if(err){
		console.log(err)
		return
	}
	console.log(data)
	line = data.split(/\r?\n/)
})
readline模块

readline模块是非阻塞

const fs = require("fs")
const readline = require("readline")
const filePath = "path/to/your/file.txt"

const rl = readline.createInterface({
		input: fs.createReadStream(filePath),
		output: process.stdout,
		terminal: false
})

rl.on("line", (line) => {
	console.log(line)
})

rl.on("close", ()=>{
	//当读取结束后的操作
})

//在这里的操作会在事件监听器之前执行

正则表达式

正则表达式(Regular Expression,简称 Regex)是一种强大的文本处理工具,它定义了搜索或匹配字符串的模式(pattern)。通过使用正则表达式,可以执行复杂的文本搜索、替换和验证操作。

正则表达式由匹配规则和修饰符组成

const pattern = /匹配规则/修饰符

创建正则表达式

使用字面量

const pattern = /abc/

使用RegExp构造函数

const pattern = new RegExp("abc")

匹配正则表达式

检查某字符串是否满足正则表达式

使用正则表达式的.test()方法,传入字符串,返回布尔值

const str = "abcdefg"
const pattern = /abc/

console.log(pattern.test(str))
匹配所有中文

中文字符的 Unicode 范围通常是 \u4e00\u9fa5

const chinesePattern = /[\u4e00-\u9fa5]/;

代码实现

为了我们的文本解析器可以正确的识别文本,我们要对txt文本制定规范。满足某种规范的文字将被解析成对应的对象事件
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程
我们可以一行一行的读取txt文件,每一行代表一种事件对象。

const rl = readline.createInterface({
    input: fs.createReadStream(filePath),
    output: process.stdout,
    terminal: false
})

rl.on("line", (line: string) => {
    //先将脚本每一行作为元素放入数组中
    tempScript.push(line)
})

随后定义各种事件对象的正则表达式,如果满足正则表达式,则说明这一行是该事件对象。随后通过字符串的.splite()方法将对象的各个属性切出来,然后放创建满足接口的类,然后放进脚本数组里。

  • 先将txt文件每一行切出来,将每一行作为string存到临时数组里
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)
})
  • 然后各个事件类型的正则表达式,随后编写解析函数,传入字符串,将满足特定正则表达式的字符串解析成对应的对象
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文件解析成脚本数组的功能。
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);
})

至此我们文字对话的基本逻辑以及完成,然后稍微写一点样式美化一下。最后来一起看看成果吧
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

上线

打包

Vue3直接使用vite打包,在命令行输入指令

npm run build

随后会生成dist文件夹

上线

本人用的是ubuntu宝塔面板,下面来讲讲宝塔如何上线网站
点击网站,添加站点。如果出现推荐安装,安装就好
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

如果你买了域名的话,可以填入域名。没有的话可以直接填入ip地址。
随后通过filezilla或者其他FTP软件将dist文件夹上传到服务器目录/www/wwwroot/下
“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程

“龙年教你怼亲戚”!| Vue+Ts+node.js实现文字对话小游戏 | 超详细上线全过程
随后点击提交,就可以通过设置的域名访问了。

注意事项

使用vue-router时,请使用createWebHashHistory()哈希地址。如果使用createWebHistory的话,页面刷新会导致404页面

感谢名单

感谢我的高中同学灏宝,项目中的选项卡片的像素龙和老奶奶的形象都是灏宝帮我画的。感谢灏宝为我提供的美术资源,灏宝学的是Unit引擎,同时参加了这个游戏的设计过程。感谢你,我的灏宝!

原文链接:https://juejin.cn/post/7333215594202169379 作者:Icicle_Crino

(0)
上一篇 2024年2月13日 上午10:10
下一篇 2024年2月13日 上午10:21

相关推荐

发表回复

登录后才能评论