码上开火车-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 游戏、元宇宙等项目,欢迎联系我。



相关文章
|
4月前
|
Java 数据库 网络架构
菜鸟之路Day36一一Web开发综合案例(部门管理)
本文详细记录了基于Spring Boot的Web开发综合案例——部门管理功能的实现过程。从环境搭建到功能开发,涵盖数据库表设计、Spring Boot项目创建、依赖引入、配置文件设置以及Mapper、Service、Controller的基础结构构建。文章重点讲解了查询、删除、新增和修改部门信息的业务逻辑实现,遵循RESTful规范设计接口,并通过统一响应结果类`Result`优化前后端交互体验。借助Spring的IoC容器管理与MyBatis的SQL映射,实现了高效的数据操作与业务处理,最终完成部门管理的全功能开发。
114 12
|
4月前
|
前端开发 算法 API
构建高性能图像处理Web应用:Next.js与TailwindCSS实践
本文分享了构建在线图像黑白转换工具的技术实践,涵盖技术栈选择、架构设计与性能优化。项目采用Next.js提供优秀的SSR性能和SEO支持,TailwindCSS加速UI开发,WebAssembly实现高性能图像处理算法。通过渐进式处理、WebWorker隔离及内存管理等策略,解决大图像处理性能瓶颈,并确保跨浏览器兼容性和移动设备优化。实际应用案例展示了其即时处理、高质量输出和客户端隐私保护等特点。未来计划引入WebGPU加速、AI增强等功能,进一步提升用户体验。此技术栈为Web图像处理应用提供了高效可行的解决方案。
|
3月前
|
XML SQL 前端开发
菜鸟之路Day37一一Web开发综合案例(员工管理)
本文介绍了基于Web开发的员工管理综合案例,涵盖分页查询、条件分页查询、删除员工和新增员工四大功能模块。通过前后端交互,前端传递参数(如页码、每页记录数、查询条件等),后端使用MyBatis与PageHelper插件处理数据查询与操作。代码结构清晰,包括Controller层接收请求、Service层业务逻辑处理以及Mapper层数据访问,并结合XML动态SQL实现灵活的条件查询。此外,新增与删除功能分别通过POST与DELETE请求完成,确保系统功能完整且高效。
112 7
|
3月前
|
存储 前端开发 Java
菜鸟之路Day38一一Web开发综合案例(三)
本文介绍了Web开发中的文件上传与员工信息修改的综合案例,涵盖前端到后端的完整流程。重点讲解了阿里云OSS的集成,包括Bucket创建、密钥获取及SDK使用,并通过Spring Boot实现文件上传功能。同时,详细描述了员工信息查询与修改的操作逻辑,涉及Controller、Service和Mapper层代码实现。最后探讨了配置文件的优化,对比@Value与@ConfigurationProperties注解,展示了如何通过实体类批量注入配置参数,提升代码可维护性与灵活性。
80 1
|
4月前
|
Web App开发 前端开发 JavaScript
鸿蒙5开发宝藏案例分享---Web适配一多开发实践
这是一份实用的鸿蒙Web多设备适配开发指南,针对开发者在不同屏幕尺寸下的布局难题提供了解决方案。文章通过三大法宝(相对单位、媒体查询和窗口监听)详细介绍如何实现智能适配,并提供了多个实战案例,如宫格布局、对话框变形和自适应轮播图等。此外,还分享了调试技巧及工具推荐,帮助开发者快速上手并优化性能。最后鼓励读者实践探索,并提示更多官方资源等待发现。
|
4月前
|
JavaScript 数据可视化 前端开发
three.js简单实现一个3D三角函数学习理解
1.Three.js简介 Three.js是一个基于JavaScript编写的开源3D图形库,利用WebGL技术在网页上渲染3D图形。它提供了许多高级功能,如几何体、纹理、光照、阴影等,以便开发者能够快速地创建复杂且逼真的3D场景。同时,Three.js还具有很好的跨平台和跨浏览器兼容性,让用户无需安装任何插件就可以在现代浏览器上观看3D内容。
149 0
|
6月前
|
JavaScript 前端开发 Java
深入理解 JavaScript 中的 Array.find() 方法:原理、性能优势与实用案例详解
Array.find() 是 JavaScript 数组方法中一个非常实用和强大的工具。它不仅提供了简洁的查找操作,还具有性能上的独特优势:返回的引用能够直接影响原数组的数据内容,使得数据更新更加高效。通过各种场景的展示,我们可以看到 Array.find() 在更新、条件查找和嵌套结构查找等场景中的广泛应用。 在实际开发中,掌握 Array.find() 的特性和使用技巧,可以让代码更加简洁高效,特别是在需要直接修改原数据内容的情形。 只有锻炼思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一
|
6月前
|
监控 JavaScript 前端开发
MutationObserver详解+案例——深入理解 JavaScript 中的 MutationObserver:原理与实战案例
MutationObserver 是一个非常强大的 API,提供了一种高效、灵活的方式来监听和响应 DOM 变化。它解决了传统 DOM 事件监听器的诸多局限性,通过异步、批量的方式处理 DOM 变化,大大提高了性能和效率。在实际开发中,合理使用 MutationObserver 可以帮助我们更好地控制 DOM 操作,提高代码的健壮性和可维护性。 只有锻炼思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
MutationObserver详解+案例——深入理解 JavaScript 中的 MutationObserver:原理与实战案例
|
7月前
|
JavaScript 前端开发 索引
40个JS常用使用技巧案例
大家好,我是V哥。在日常开发中,JS是解决页面交互的利器。V哥总结了40个实用的JS小技巧,涵盖数组操作、对象处理、函数使用等,并附带案例代码和解释。从数组去重到异步函数,这些技巧能显著提升开发效率。先赞再看后评论,腰缠万贯财进门。关注威哥爱编程,全栈开发就你行!
184 16
|
10月前
|
JavaScript
使用Node.js创建一个简单的Web服务器
使用Node.js创建一个简单的Web服务器

热门文章

最新文章