码上开火车是一款 3D 单机策略游戏。创作这款游戏主要是为了参加 AMD 和码上掘金举办的马上掘金编程挑战赛。
欢迎大家体验:code.juejin.cn/pen/7163229…
设计和开发这款游戏,一共花费了大概两周的业余时间。
现在和大家聊聊这款游戏从设计到开发这个过程的总结。
团队
游戏的作者是我的独立游戏团队,一共两个人,我和 Z 哥。我主要负责策划、程序、项目管理等工作,Z 哥负责美术、模型、音乐、素材等工作。
很多小型游戏的开发,特别是 Web 游戏,一个人都可以独立完成。不要等到团队有多少人后才开始进行开发。
维护一个游戏团队是很难的,因为一款游戏通常是需要插图、音效、模型、程序、策划、剧情、运营等多个部分组成。维持这种团队需要各种人才,成本非常之高。而依靠游戏开发赚钱又比较难。自己做游戏,很多时候还不如直接接一些外包项目来做来钱更快。
所以,在很早的时候我就想好了,如果以后要组织一个游戏团队,人一定要少,而且每个人都应该是一个六边形战士,具有极强的学习能力和抗压能力,可以身兼数职、独当一面。我的目标也不高,首先能赚些钱,然后做一些我们自己喜欢的、小而美的东西。
很幸运,我遇到了 Z 哥这个技术狂。三十多岁的年纪,仍然每天下班后仍钻研数小时技术,并以一己之力将某超大型国企的信息技术部门的技术水平提升了数个档次。
我自己也可以做一些设计方面的工作,只是没有那么多足够的经验。让专业的人负责专业的事,可以让整个团队更具有战斗力。
设计
码上开火车是一款简单的策略游戏,没有复杂的剧情,也没有管线。所以它的设计可以简单分为 UI、模型、玩法三个方面。
UI 设计
UI 设计与模型设计都属于美工的范围。
通常设计 UI 时不会从零开始设计,而是会在一些设计网站上面寻找灵感,然后模仿一些 UI 进行修改。
可以从专业的游戏网站,比如 indienova 上面找一些资源或灵感。或者从专业的设计网站上寻找图标和模型。
模型设计
通常模型也是从一些资源网站上面进行下载,然后做一些调整再进行使用。比如 clara 上面就有一大堆 3D 模型。
推荐一款 gltf 格式的模型在线查看器:techbrood.com/tool?p=gltf…。虽然网页的 UI 设计有一股上个世纪的感觉,但确实非常好用。
玩法设计
游戏规则设计其实挺简单的。
第一版草图如下:
码上跑火车本质上就是一个无限循环单机游戏。
后面又经过一系列调整,最终就是现在大家看到的效果。
开发
一款游戏采用什么技术并没有什么太大区别。特别是单机游戏,技术不是关键,可玩性才是。
但还是要简单提一下技术。
首先在模式上可以简单分为 2D 和 3D。2D 游戏在技术上比较简单,可以跳过建模这一步,当然 2D 游戏也可以建模。一些简单的 2D 游戏,只使用图片等资源就可以了。3D 游戏则必须使用模型,对应的还需要有一整套的渲染引擎、物理引擎、粒子系统等。相比较 2D 游戏会复杂很多。
市面上有一些第三方的游戏引擎,会涵盖游戏设计、开发、测试、部署全流程。而大公司一般会有自己研发的游戏引擎。
比较流行的有 unity、cocos、egret、layabox 等,这些游戏引擎通常都支持脚本语言的开发,比如 JavaScript/TypeScript,并且大多数都支持多平台构建,比如一套源码可以打包成移动端、HTML 或者微信小游戏。
专注于 Web 游戏开发的引擎有 createjs 和 pixijs 等。
当然如果游戏足够简单的话,可以不使用游戏引擎。比如码上开火车就没有使用任何游戏引擎。它主要使用了四个主要的库/框架:
- three.js:负责 3D 效果呈现。
- jquery:负责 DOM 操作。
- preloadjs:负责资源预加载。
- tailwindcss:CSS 框架。
基于 jQuery 的数据响应式和组件化
游戏场景主体是 canvas,UI 是 DOM。UI 的数量非常少,而且没有页面切换。所以完全没有必要使用像 React 这类框架,jQuery 反而更简单粗暴。
但是 jQuery 没有数据响应式和组件化,难免在修改状态的时候同时维护 UI。
在游戏第一版完成的时候,就是这么做的。但是继续增加功能会很累。
所以我对它进行了简单地重构,利用 jQuery 实现了简易的数据响应式和组件化。这样更新数据后不再需要关注 UI 的变化。
class Reactivity {
constructor({ state, updateCallback } = {}) {
this.state = state;
this.updateState = this.updateState.bind(this)
this.updateCallback = updateCallback
this.initUpdate()
this.bindEvents()
}
updateState(key, value) {
if (typeof value === 'function') {
$(this).trigger(`state.change`, [key, value(this.state)]);
return
}
$(this).trigger(`state.change`, [key, value]);
}
bindEvents() {
$(this).on(`state.change`, this.updateCallback);
}
initUpdate() {
Object.keys(this.state).forEach((key) => {
this.updateCallback(null, key, this.state[key]);
});
}
}
对,只用了 27 行,就实现了 jQuery 数据响应式。
核心代码就是 .on 这两个 API。
使用方式如下:
const { updateState } = new Reactivity({
state: {
'key': 'value'
},
updateCallback: (event, key, value) => {
}
})
updateState('key', 'hello')
组件化是基于 Reactivity 进行封装的,实现了事件绑定、指令、动态组件、静态组件、ref 等,代码不多,有 100 多行。
class Component {
constructor({ template, state, methods } = {}) {
this.refs = {}
this.template = template || ``
this.state = state || {}
this.methods = methods || {}
this.type = typeof this.template === 'string' ? 'static' : 'dynamic'
this.el = typeof this.template === 'string' ? $(this.template) : $(this.template(this.state))
this.mount()
this.#bindEvents()
this.#bindRef()
this.updateState = new Reactivity({
state: this.state,
updateCallback: this.#updateElements.bind(this)
}).updateState
}
mount() {
$('body').append(this.el)
}
unmount() {
this.el.remove()
}
#bindEvents() {
const events = [
'click',
'change',
'input',
'blur',
'focus',
'keydown',
'keyup',
'keypress',
'mouseenter',
'mouseleave',
'mouseover',
'mouseout',
'mousedown',
'mouseup',
'touchstart',
'touchend',
'touchmove',
'touchcancel',
'wheel',
'scroll',
'resize',
'load',
'unload',
'abort',
'error',
'select',
'contextmenu',
'dblclick',
'drag',
'dragend',
'dragenter',
'dragleave',
'dragover',
'dragstart',
'drop',
'copy',
'cut',
'paste',
'reset',
'submit',
'focusin',
'focusout',
'animationstart',
'animationend',
'animationiteration',
'transitionend',
'transitionstart',
'transitioncancel',
'transitionrun',
'mousewheel'
]
events.forEach(evt => {
Array.from([this.el, ...this.el.find(`[on-${evt}]`)]).forEach(el => {
const methodName = $(el).attr(`on-${evt}`)
if (methodName) {
$(el).on(evt, this.methods[methodName])
}
})
})
}
#bindRef() {
Array.from([this.el, ...this.el.find('[ref]')]).forEach(el => {
const refName = $(el).attr('ref')
if (refName) {
this.refs[refName] = $(el)
}
})
}
#updateElements(evt, key, value) {
this.state[key] = value
if (this.type === 'dynamic') {
this.unmount()
this.el = $(this.template(this.state))
this.mount()
this.#bindEvents()
this.#bindRef()
}
this.#render(this.el, key, value)
}
#render(el, key, value) {
// data-class
Array.from([el, ...el.find(`*[data-class*='${key}']`)]).forEach(el => {
const reg = new RegExp(`{${key}}`, 'g')
const classTemp = $(el).attr('data-class')
if (classTemp) {
const classRaw = classTemp.replaceAll(reg, value)
$(el).attr('class', (i, val) => `${val} ${classRaw}`)
}
})
// data-style
Array.from([el, ...el.find(`*[data-style*=${key}]`)]).forEach(el => {
const styleTemp = $(el).data('style')
if (styleTemp) {
const reg = new RegExp(`{${key}}`, 'g')
const styleRaw = styleTemp.replaceAll(reg, value)
$(el).attr('style', (i, val) => `${val || ''} ${styleRaw} `)
}
})
// data-bind
Array.from([el, ...el.find(`[data-bind=${key}]`)]).forEach(el => {
const reg = new RegExp(`{${key}}`, 'g')
// data-temp
const temp = $(el).data('temp')
if (temp) {
$(el).text(temp.replaceAll(reg, value))
}
})
// data-show
const dataShowEls = Array.from(el.find(`[data-show=${key}]`))
const show = el.data('show')
if (show) {
dataShowEls.push(el)
}
dataShowEls.forEach(el => {
if (value) {
$(el).show()
} else {
$(el).hide()
}
})
}
}
如何用 HTML 模拟一个滚动条?
游戏中有一个帮助面板,右侧的滚动条 UI 是高度定制的。
实现原理比较简单,左侧内容区使用一个容器元素包裹真正的内容元素。容器元素设置溢出隐藏,内容元素通过 translate-y 进行位置调整。
监听滚动条按钮的 mousedown 事件,然后在其中再监听 mousemove 事件,计算移动的 x 距离,然后设置按钮的 top 属性,然后计算按钮高度和整个滚动条高度的比例,将这个比例同步给左侧内容区,进行偏移调整。
最后监听 mouseup 事件,取消 mousedown 和 mousemove 的监听。
除了 mousedown 事件外,还需要处理 mousewheel 事件,也就是滚轮事件。处理逻辑基本与上述相同。
预加载的原理
为什么需要预加载呢?
因为游戏中通常会包含大量的图片、音频、字体包等资源。如果不预加载,那么在游戏的过程中容易出现各种卡顿或者页面某个部分空白的情况。而且现代浏览器一般会对 network 进行限制,最多只能同时发出 3 个请求,超过这个数量会出现取消请求的现象。
预加载就是维护了一个资源下载池,保证永远同时最多只有 3 个资源请求,每当有资源请求成功后,再发起下一个请求。
合作
关于码上开火车这款游戏的开发就聊到这里了。
如果你对游戏开发感兴趣,或者需要开发一些 3D 模型展示、3D 游戏、元宇宙等项目,欢迎联系我。