码上开火车-Three.js 3D Web 游戏案例分享

简介: 码上开火车-Three.js 3D Web 游戏案例分享

码上开火车是一款 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 设计有一股上个世纪的感觉,但确实非常好用。


玩法设计


游戏规则设计其实挺简单的。

第一版草图如下:

image.png

码上跑火车本质上就是一个无限循环单机游戏。

后面又经过一系列调整,最终就是现在大家看到的效果。


开发


一款游戏采用什么技术并没有什么太大区别。特别是单机游戏,技术不是关键,可玩性才是。

但还是要简单提一下技术。

首先在模式上可以简单分为 2D 和 3D。2D 游戏在技术上比较简单,可以跳过建模这一步,当然 2D 游戏也可以建模。一些简单的 2D 游戏,只使用图片等资源就可以了。3D 游戏则必须使用模型,对应的还需要有一整套的渲染引擎、物理引擎、粒子系统等。相比较 2D 游戏会复杂很多。

市面上有一些第三方的游戏引擎,会涵盖游戏设计、开发、测试、部署全流程。而大公司一般会有自己研发的游戏引擎。

比较流行的有 unitycocosegretlayabox 等,这些游戏引擎通常都支持脚本语言的开发,比如 JavaScript/TypeScript,并且大多数都支持多平台构建,比如一套源码可以打包成移动端、HTML 或者微信小游戏。

专注于 Web 游戏开发的引擎有 createjspixijs 等。

当然如果游戏足够简单的话,可以不使用游戏引擎。比如码上开火车就没有使用任何游戏引擎。它主要使用了四个主要的库/框架:

  • 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 数据响应式。

核心代码就是 .trigger和.trigger 和 .trigger.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 是高度定制的。

image.png

实现原理比较简单,左侧内容区使用一个容器元素包裹真正的内容元素。容器元素设置溢出隐藏,内容元素通过 translate-y 进行位置调整。

监听滚动条按钮的 mousedown 事件,然后在其中再监听 mousemove 事件,计算移动的 x 距离,然后设置按钮的 top 属性,然后计算按钮高度和整个滚动条高度的比例,将这个比例同步给左侧内容区,进行偏移调整。

最后监听 mouseup 事件,取消 mousedown 和 mousemove 的监听。

除了 mousedown 事件外,还需要处理 mousewheel 事件,也就是滚轮事件。处理逻辑基本与上述相同。


预加载的原理


为什么需要预加载呢?

因为游戏中通常会包含大量的图片、音频、字体包等资源。如果不预加载,那么在游戏的过程中容易出现各种卡顿或者页面某个部分空白的情况。而且现代浏览器一般会对 network 进行限制,最多只能同时发出 3 个请求,超过这个数量会出现取消请求的现象。

预加载就是维护了一个资源下载池,保证永远同时最多只有 3 个资源请求,每当有资源请求成功后,再发起下一个请求。


合作


关于码上开火车这款游戏的开发就聊到这里了。

如果你对游戏开发感兴趣,或者需要开发一些 3D 模型展示、3D 游戏、元宇宙等项目,欢迎联系我。



相关文章
|
14天前
|
Web App开发 移动开发 HTML5
html5 + Three.js 3D风雪封印在棱镜中的梅花鹿动效源码
html5 + Three.js 3D风雪封印在棱镜中的梅花鹿动效源码。画面中心是悬浮于空的梅花鹿,其四周由白色线段组成了一个6边形将中心的梅花鹿包裹其中。四周漂浮的白雪随着多边形的转动而同步旋转。建议使用支持HTML5与css3效果较好的火狐(Firefox)或谷歌(Chrome)等浏览器预览本源码。
47 2
|
24天前
|
JavaScript
使用Node.js创建一个简单的Web服务器
使用Node.js创建一个简单的Web服务器
|
28天前
|
机器学习/深度学习 人工智能 JavaScript
JavaScript和TypeScript的未来发展趋势及其在Web开发中的应用前景
本文探讨了JavaScript和TypeScript的未来发展趋势及其在Web开发中的应用前景。JavaScript将注重性能优化、跨平台开发、AI融合及WebAssembly整合;TypeScript则强调与框架整合、强类型检查、前端工程化及WebAssembly的深度结合。两者结合发展,特别是在Vue 3.0中完全采用TypeScript编写,预示着未来的Web开发将更加高效、可靠。
42 4
|
28天前
|
开发框架 JavaScript 前端开发
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势。通过明确的类型定义,TypeScript 能够在编码阶段发现潜在错误,提高代码质量;支持组件的清晰定义与复用,增强代码的可维护性;与 React、Vue 等框架结合,提供更佳的开发体验;适用于大型项目,优化代码结构和性能。随着 Web 技术的发展,TypeScript 的应用前景广阔,将继续引领 Web 开发的新趋势。
36 2
|
2月前
|
XML JavaScript 前端开发
JavaScript控制台:提升Web开发技能的秘密武器
作为Web开发人员,掌握JavaScript控制台中的各种方法至关重要。本文介绍了22种实用的console方法,从基本的log()到高级的profile()和memory,每种方法都配有示例和说明,帮助开发者更高效地调试和记录信息。通过了解这些工具,您可以优化代码、提高开发速度,减少错误,使编码过程更加顺畅愉快。
44 1
JavaScript控制台:提升Web开发技能的秘密武器
|
1月前
|
JavaScript 前端开发 持续交付
构建现代Web应用:Vue.js与Node.js的完美结合
【10月更文挑战第22天】随着互联网技术的快速发展,Web应用已经成为了人们日常生活和工作的重要组成部分。前端技术和后端技术的不断创新,为Web应用的构建提供了更多可能。在本篇文章中,我们将探讨Vue.js和Node.js这两大热门技术如何完美结合,构建现代Web应用。
38 4
|
2月前
|
存储 JavaScript 前端开发
深入探索 Vue.js:构建现代 Web 应用的利器
【10月更文挑战第11天】深入探索 Vue.js:构建现代 Web 应用的利器
34 1
|
2月前
|
JavaScript 前端开发 网络架构
如何使用Vue.js构建响应式Web应用
【10月更文挑战第9天】如何使用Vue.js构建响应式Web应用
|
2月前
|
JavaScript 前端开发
如何使用Vue.js构建响应式Web应用程序
【10月更文挑战第9天】如何使用Vue.js构建响应式Web应用程序
|
2月前
|
XML JSON API
ServiceStack:不仅仅是一个高性能Web API和微服务框架,更是一站式解决方案——深入解析其多协议支持及简便开发流程,带您体验前所未有的.NET开发效率革命
【10月更文挑战第9天】ServiceStack 是一个高性能的 Web API 和微服务框架,支持 JSON、XML、CSV 等多种数据格式。它简化了 .NET 应用的开发流程,提供了直观的 RESTful 服务构建方式。ServiceStack 支持高并发请求和复杂业务逻辑,安装简单,通过 NuGet 包管理器即可快速集成。示例代码展示了如何创建一个返回当前日期的简单服务,包括定义请求和响应 DTO、实现服务逻辑、配置路由和宿主。ServiceStack 还支持 WebSocket、SignalR 等实时通信协议,具备自动验证、自动过滤器等丰富功能,适合快速搭建高性能、可扩展的服务端应用。
164 3