game.html 80 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649
  1. <!DOCTYPE html><html lang="zh"><head>
  2. <meta charset="utf-8">
  3. <title>制作一个游戏</title>
  4. <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
  5. <meta name="twitter:card" content="summary_large_image">
  6. <meta name="twitter:site" content="@threejs">
  7. <meta name="twitter:title" content="Three.js – 制作一个游戏">
  8. <meta property="og:image" content="https://threejs.org/files/share.png">
  9. <link rel="shortcut icon" href="../../files/favicon_white.ico" media="(prefers-color-scheme: dark)">
  10. <link rel="shortcut icon" href="../../files/favicon.ico" media="(prefers-color-scheme: light)">
  11. <link rel="stylesheet" href="../resources/lesson.css">
  12. <link rel="stylesheet" href="../resources/lang.css">
  13. <script type="importmap">
  14. {
  15. "imports": {
  16. "three": "../../build/three.module.js"
  17. }
  18. }
  19. </script>
  20. </head>
  21. <body>
  22. <div class="container">
  23. <div class="lesson-title">
  24. <h1>制作一个游戏</h1>
  25. </div>
  26. <div class="lesson">
  27. <div class="lesson-main">
  28. <p>很多人想用 three.js 来写游戏。这篇文章希望能给你一些如何开始的思路。</p>
  29. <p>至少在我写这篇文章的时候,它可能会成为本站最长的文章。这里的代码可能过度设计了,但在我编写每个新功能时,都会遇到需要解决的问题,而这些解决方案都来自我以前写过的其他游戏。换句话说,每个新的解决方案看起来都很重要,所以我会尽量解释为什么需要它们。当然,你的游戏越小,就越不需要这里展示的某些解决方案,但这本身是一个相当小的游戏,然而由于 3D 角色的复杂性,许多事情比 2D 角色需要更多的组织。</p>
  30. <p>举个例子,如果你在制作 2D 版的吃豆人,吃豆人转弯时会瞬间完成 90 度旋转,没有中间过程。但在 3D 游戏中,我们通常需要角色在多帧之间旋转。这个简单的变化就会增加很多复杂性,并需要不同的解决方案。</p>
  31. <p>这里的大部分代码实际上并不是 three.js 的代码,这一点很重要,<strong>three.js 不是一个游戏引擎</strong>。Three.js 是一个 3D 库。它提供了一个<a href="scenegraph.html">场景图</a>以及在场景图中显示 3D 对象的功能,但它不提供制作游戏所需的所有其他东西。没有碰撞检测,没有物理引擎,没有输入系统,没有寻路等等...所以,我们必须自己提供这些功能。</p>
  32. <p>我最终写了相当多的代码来制作这个简单的<em>未完成的</em>游戏原型,而且我觉得可能过度设计了,应该有更简单的解决方案,但我觉得我实际上还没有写够代码,希望我能解释我认为还缺少什么。</p>
  33. <p>这里的许多想法深受 <a href="https://unity.com">Unity</a> 的影响。如果你不熟悉 Unity,那可能并不重要。我提到它只是因为有数以万计的游戏是使用这些理念发布的。</p>
  34. <p>让我们从 three.js 部分开始。我们需要为游戏加载模型。</p>
  35. <p>在 <a href="https://opengameart.org">opengameart.org</a> 上我找到了这个由 <a href="https://opengameart.org/users/quaternius">quaternius</a> 制作的<a href="https://opengameart.org/content/lowpoly-animated-knight">动画骑士模型</a></p>
  36. <div class="threejs_center"><img src="../resources/images/knight.jpg" style="width: 375px;"></div>
  37. <p><a href="https://opengameart.org/users/quaternius">quaternius</a> 还制作了<a href="https://opengameart.org/content/lowpoly-animated-farm-animal-pack">这些动画动物</a>。</p>
  38. <div class="threejs_center"><img src="../resources/images/animals.jpg" style="width: 606px;"></div>
  39. <p>这些看起来是很好的起步模型,所以我们首先需要加载它们。</p>
  40. <p>我们之前讲过<a href="load-gltf.html">加载 glTF 文件</a>。这次的不同之处在于我们需要加载多个模型,而且在所有模型加载完成之前不能开始游戏。</p>
  41. <p>幸运的是 three.js 提供了 <a href="/docs/#api/en/loaders/managers/LoadingManager"><code class="notranslate" translate="no">LoadingManager</code></a> 来满足这个需求。我们创建一个 <a href="/docs/#api/en/loaders/managers/LoadingManager"><code class="notranslate" translate="no">LoadingManager</code></a> 并将它传递给其他加载器。<a href="/docs/#api/en/loaders/managers/LoadingManager"><code class="notranslate" translate="no">LoadingManager</code></a> 提供了 <a href="/docs/#api/en/loaders/managers/LoadingManager#onProgress"><code class="notranslate" translate="no">onProgress</code></a> 和 <a href="/docs/#api/en/loaders/managers/LoadingManager#onLoad"><code class="notranslate" translate="no">onLoad</code></a> 属性供我们附加回调函数。当所有文件加载完成时会调用 <a href="/docs/#api/en/loaders/managers/LoadingManager#onLoad"><code class="notranslate" translate="no">onLoad</code></a> 回调。每个单独的文件加载完成后会调用 <a href="/docs/#api/en/loaders/managers/LoadingManager#onProgress"><code class="notranslate" translate="no">onProgress</code></a> 回调,让我们有机会显示加载进度。</p>
  42. <p>从<a href="load-gltf.html">加载 glTF 文件</a>的代码开始,我移除了所有与场景取景相关的代码,并添加了以下代码来加载所有模型。</p>
  43. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const manager = new THREE.LoadingManager();
  44. manager.onLoad = init;
  45. const models = {
  46. pig: { url: 'resources/models/animals/Pig.gltf' },
  47. cow: { url: 'resources/models/animals/Cow.gltf' },
  48. llama: { url: 'resources/models/animals/Llama.gltf' },
  49. pug: { url: 'resources/models/animals/Pug.gltf' },
  50. sheep: { url: 'resources/models/animals/Sheep.gltf' },
  51. zebra: { url: 'resources/models/animals/Zebra.gltf' },
  52. horse: { url: 'resources/models/animals/Horse.gltf' },
  53. knight: { url: 'resources/models/knight/KnightCharacter.gltf' },
  54. };
  55. {
  56. const gltfLoader = new GLTFLoader(manager);
  57. for (const model of Object.values(models)) {
  58. gltfLoader.load(model.url, (gltf) =&gt; {
  59. model.gltf = gltf;
  60. });
  61. }
  62. }
  63. function init() {
  64. // 待实现
  65. }
  66. </pre>
  67. <p>这段代码会加载上面所有的模型,<a href="/docs/#api/en/loaders/managers/LoadingManager"><code class="notranslate" translate="no">LoadingManager</code></a> 会在完成后调用 <code class="notranslate" translate="no">init</code>。我们稍后会使用 <code class="notranslate" translate="no">models</code> 对象来访问已加载的模型,所以每个模型的 <a href="/docs/#examples/loaders/GLTFLoader"><code class="notranslate" translate="no">GLTFLoader</code></a> 回调会将加载的数据附加到该模型的信息上。</p>
  68. <p>所有模型及其动画目前大约 6.6MB。这是一个相当大的下载量。假设你的服务器支持压缩(本站的服务器就支持),可以将它们压缩到大约 1.4MB。这肯定比 6.6MB 好,但仍然不是很小的数据量。如果我们添加一个进度条,让用户知道还需要等待多长时间,那就好了。</p>
  69. <p>所以,让我们添加一个 <a href="/docs/#api/en/loaders/managers/LoadingManager#onProgress"><code class="notranslate" translate="no">onProgress</code></a> 回调。调用时会传入 3 个参数:最后加载的对象的 <code class="notranslate" translate="no">url</code>,到目前为止已加载的项目数量,以及项目总数。</p>
  70. <p>让我们设置一些 HTML 来做加载条</p>
  71. <pre class="prettyprint showlinemods notranslate lang-html" translate="no">&lt;body&gt;
  72. &lt;canvas id="c"&gt;&lt;/canvas&gt;
  73. + &lt;div id="loading"&gt;
  74. + &lt;div&gt;
  75. + &lt;div&gt;...loading...&lt;/div&gt;
  76. + &lt;div class="progress"&gt;&lt;div id="progressbar"&gt;&lt;/div&gt;&lt;/div&gt;
  77. + &lt;/div&gt;
  78. + &lt;/div&gt;
  79. &lt;/body&gt;
  80. </pre>
  81. <p>我们会查找 <code class="notranslate" translate="no">#progressbar</code> div,并将其宽度从 0% 设置到 100% 来显示进度。我们只需要在回调中设置它即可。</p>
  82. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const manager = new THREE.LoadingManager();
  83. manager.onLoad = init;
  84. +const progressbarElem = document.querySelector('#progressbar');
  85. +manager.onProgress = (url, itemsLoaded, itemsTotal) =&gt; {
  86. + progressbarElem.style.width = `${itemsLoaded / itemsTotal * 100 | 0}%`;
  87. +};
  88. </pre>
  89. <p>我们已经设置了 <code class="notranslate" translate="no">init</code> 在所有模型加载完成时被调用,所以我们可以通过隐藏 <code class="notranslate" translate="no">#loading</code> 元素来关闭进度条。</p>
  90. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function init() {
  91. + // 隐藏加载条
  92. + const loadingElem = document.querySelector('#loading');
  93. + loadingElem.style.display = 'none';
  94. }
  95. </pre>
  96. <p>这是一堆用于样式化进度条的 CSS。CSS 使 <code class="notranslate" translate="no">#loading</code> <code class="notranslate" translate="no">&lt;div&gt;</code> 占满整个页面并居中其子元素。CSS 创建了一个 <code class="notranslate" translate="no">.progress</code> 区域来包含进度条。CSS 还为进度条添加了对角条纹的 CSS 动画。</p>
  97. <pre class="prettyprint showlinemods notranslate lang-css" translate="no">#loading {
  98. position: absolute;
  99. left: 0;
  100. top: 0;
  101. width: 100%;
  102. height: 100%;
  103. display: flex;
  104. align-items: center;
  105. justify-content: center;
  106. text-align: center;
  107. font-size: xx-large;
  108. font-family: sans-serif;
  109. }
  110. #loading&gt;div&gt;div {
  111. padding: 2px;
  112. }
  113. .progress {
  114. width: 50vw;
  115. border: 1px solid black;
  116. }
  117. #progressbar {
  118. width: 0;
  119. transition: width ease-out .5s;
  120. height: 1em;
  121. background-color: #888;
  122. background-image: linear-gradient(
  123. -45deg,
  124. rgba(255, 255, 255, .5) 25%,
  125. transparent 25%,
  126. transparent 50%,
  127. rgba(255, 255, 255, .5) 50%,
  128. rgba(255, 255, 255, .5) 75%,
  129. transparent 75%,
  130. transparent
  131. );
  132. background-size: 50px 50px;
  133. animation: progressanim 2s linear infinite;
  134. }
  135. @keyframes progressanim {
  136. 0% {
  137. background-position: 50px 50px;
  138. }
  139. 100% {
  140. background-position: 0 0;
  141. }
  142. }
  143. </pre>
  144. <p>现在我们有了进度条,让我们来处理模型。这些模型有动画,我们希望能够访问这些动画。动画默认存储在数组中,但我们希望能够通过名称轻松访问它们,所以让我们为每个模型设置一个 <code class="notranslate" translate="no">animations</code> 属性来实现这一点。当然,这意味着动画必须有唯一的名称。</p>
  145. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+function prepModelsAndAnimations() {
  146. + Object.values(models).forEach(model =&gt; {
  147. + const animsByName = {};
  148. + model.gltf.animations.forEach((clip) =&gt; {
  149. + animsByName[clip.name] = clip;
  150. + });
  151. + model.animations = animsByName;
  152. + });
  153. +}
  154. function init() {
  155. // 隐藏加载条
  156. const loadingElem = document.querySelector('#loading');
  157. loadingElem.style.display = 'none';
  158. + prepModelsAndAnimations();
  159. }
  160. </pre>
  161. <p>让我们显示带动画的模型。</p>
  162. <p>与<a href="load-gltf.html">之前加载 glTF 文件的例子</a>不同,这次我们可能想要显示每个模型的多个实例。为此,我们不是像在<a href="load-gltf.html">加载 glTF 文章</a>中那样直接添加加载的 gltf 场景,而是要克隆场景,特别是为蒙皮动画角色克隆场景。幸运的是有一个工具函数 <code class="notranslate" translate="no">SkeletonUtils.clone</code> 可以用来做这件事。所以,首先我们需要引入这个工具。</p>
  163. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">import * as THREE from 'three';
  164. import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
  165. import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
  166. +import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
  167. </pre>
  168. <p>然后我们可以克隆刚刚加载的模型</p>
  169. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function init() {
  170. // 隐藏加载条
  171. const loadingElem = document.querySelector('#loading');
  172. loadingElem.style.display = 'none';
  173. prepModelsAndAnimations();
  174. + Object.values(models).forEach((model, ndx) =&gt; {
  175. + const clonedScene = SkeletonUtils.clone(model.gltf.scene);
  176. + const root = new THREE.Object3D();
  177. + root.add(clonedScene);
  178. + scene.add(root);
  179. + root.position.x = (ndx - 3) * 3;
  180. + });
  181. }
  182. </pre>
  183. <p>上面的代码中,对于每个模型,我们克隆了加载的 <code class="notranslate" translate="no">gltf.scene</code> 并将其挂载到一个新的 <a href="/docs/#api/en/core/Object3D"><code class="notranslate" translate="no">Object3D</code></a> 上。我们需要将它挂载到另一个对象上,因为播放动画时,动画会将动画位置应用到加载场景中的节点上,这意味着我们将无法控制这些位置。</p>
  184. <p>要播放动画,每个克隆的模型都需要一个 <a href="/docs/#api/en/animation/AnimationMixer"><code class="notranslate" translate="no">AnimationMixer</code></a>。一个 <a href="/docs/#api/en/animation/AnimationMixer"><code class="notranslate" translate="no">AnimationMixer</code></a> 包含一个或多个 <a href="/docs/#api/en/animation/AnimationAction"><code class="notranslate" translate="no">AnimationAction</code></a>。<a href="/docs/#api/en/animation/AnimationAction"><code class="notranslate" translate="no">AnimationAction</code></a> 引用一个 <a href="/docs/#api/en/animation/AnimationClip"><code class="notranslate" translate="no">AnimationClip</code></a>。<a href="/docs/#api/en/animation/AnimationAction"><code class="notranslate" translate="no">AnimationAction</code></a> 有各种播放设置,可以链接到另一个动作或在动作之间交叉淡入淡出。让我们先获取第一个 <a href="/docs/#api/en/animation/AnimationClip"><code class="notranslate" translate="no">AnimationClip</code></a> 并为它创建一个动作。默认情况下,动作会永远循环播放其片段。</p>
  185. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+const mixers = [];
  186. function init() {
  187. // 隐藏加载条
  188. const loadingElem = document.querySelector('#loading');
  189. loadingElem.style.display = 'none';
  190. prepModelsAndAnimations();
  191. Object.values(models).forEach((model, ndx) =&gt; {
  192. const clonedScene = SkeletonUtils.clone(model.gltf.scene);
  193. const root = new THREE.Object3D();
  194. root.add(clonedScene);
  195. scene.add(root);
  196. root.position.x = (ndx - 3) * 3;
  197. + const mixer = new THREE.AnimationMixer(clonedScene);
  198. + const firstClip = Object.values(model.animations)[0];
  199. + const action = mixer.clipAction(firstClip);
  200. + action.play();
  201. + mixers.push(mixer);
  202. });
  203. }
  204. </pre>
  205. <p>我们调用了 <a href="/docs/#api/en/animation/AnimationAction#play"><code class="notranslate" translate="no">play</code></a> 来启动动作,并将所有 <code class="notranslate" translate="no">AnimationMixer</code> 存储在一个名为 <code class="notranslate" translate="no">mixers</code> 的数组中。最后我们需要在渲染循环中更新每个 <a href="/docs/#api/en/animation/AnimationMixer"><code class="notranslate" translate="no">AnimationMixer</code></a>,计算自上一帧以来经过的时间并将其传递给 <a href="/docs/#api/en/animation/AnimationMixer.update"><code class="notranslate" translate="no">AnimationMixer.update</code></a>。</p>
  206. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+let then = 0;
  207. function render(now) {
  208. + now *= 0.001; // 转换为秒
  209. + const deltaTime = now - then;
  210. + then = now;
  211. if (resizeRendererToDisplaySize(renderer)) {
  212. const canvas = renderer.domElement;
  213. camera.aspect = canvas.clientWidth / canvas.clientHeight;
  214. camera.updateProjectionMatrix();
  215. }
  216. + for (const mixer of mixers) {
  217. + mixer.update(deltaTime);
  218. + }
  219. renderer.render(scene, camera);
  220. requestAnimationFrame(render);
  221. }
  222. </pre>
  223. <p>这样我们应该能加载每个模型并播放其第一个动画了。</p>
  224. <p></p><div translate="no" class="threejs_example_container notranslate">
  225. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/game-load-models.html"></iframe></div>
  226. <a class="threejs_center" href="/manual/examples/game-load-models.html" target="_blank">点击此处在新标签页中打开</a>
  227. </div>
  228. <p></p>
  229. <p>让我们能够检查所有动画。我们将所有片段作为动作添加,然后一次只启用一个。</p>
  230. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">-const mixers = [];
  231. +const mixerInfos = [];
  232. function init() {
  233. // 隐藏加载条
  234. const loadingElem = document.querySelector('#loading');
  235. loadingElem.style.display = 'none';
  236. prepModelsAndAnimations();
  237. Object.values(models).forEach((model, ndx) =&gt; {
  238. const clonedScene = SkeletonUtils.clone(model.gltf.scene);
  239. const root = new THREE.Object3D();
  240. root.add(clonedScene);
  241. scene.add(root);
  242. root.position.x = (ndx - 3) * 3;
  243. const mixer = new THREE.AnimationMixer(clonedScene);
  244. - const firstClip = Object.values(model.animations)[0];
  245. - const action = mixer.clipAction(firstClip);
  246. - action.play();
  247. - mixers.push(mixer);
  248. + const actions = Object.values(model.animations).map((clip) =&gt; {
  249. + return mixer.clipAction(clip);
  250. + });
  251. + const mixerInfo = {
  252. + mixer,
  253. + actions,
  254. + actionNdx: -1,
  255. + };
  256. + mixerInfos.push(mixerInfo);
  257. + playNextAction(mixerInfo);
  258. });
  259. }
  260. +function playNextAction(mixerInfo) {
  261. + const {actions, actionNdx} = mixerInfo;
  262. + const nextActionNdx = (actionNdx + 1) % actions.length;
  263. + mixerInfo.actionNdx = nextActionNdx;
  264. + actions.forEach((action, ndx) =&gt; {
  265. + const enabled = ndx === nextActionNdx;
  266. + action.enabled = enabled;
  267. + if (enabled) {
  268. + action.play();
  269. + }
  270. + });
  271. +}
  272. </pre>
  273. <p>上面的代码为每个 <a href="/docs/#api/en/animation/AnimationClip"><code class="notranslate" translate="no">AnimationClip</code></a> 创建了一个 <a href="/docs/#api/en/animation/AnimationAction"><code class="notranslate" translate="no">AnimationAction</code></a> 数组。它创建了一个 <code class="notranslate" translate="no">mixerInfos</code> 对象数组,其中包含对每个模型的 <a href="/docs/#api/en/animation/AnimationMixer"><code class="notranslate" translate="no">AnimationMixer</code></a> 和所有 <a href="/docs/#api/en/animation/AnimationAction"><code class="notranslate" translate="no">AnimationAction</code></a> 的引用。然后它调用 <code class="notranslate" translate="no">playNextAction</code>,将除了一个动作之外的所有动作的 <code class="notranslate" translate="no">enabled</code> 设为 false。</p>
  274. <p>我们需要为新数组更新渲染循环</p>
  275. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">-for (const mixer of mixers) {
  276. +for (const {mixer} of mixerInfos) {
  277. mixer.update(deltaTime);
  278. }
  279. </pre>
  280. <p>让我们实现按键 1 到 8 来播放每个模型的下一个动画</p>
  281. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">window.addEventListener('keydown', (e) =&gt; {
  282. const mixerInfo = mixerInfos[e.keyCode - 49];
  283. if (!mixerInfo) {
  284. return;
  285. }
  286. playNextAction(mixerInfo);
  287. });
  288. </pre>
  289. <p>现在你应该能点击示例,然后按 1 到 8 键来循环切换每个模型的可用动画。</p>
  290. <p></p><div translate="no" class="threejs_example_container notranslate">
  291. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/game-check-animations.html"></iframe></div>
  292. <a class="threejs_center" href="/manual/examples/game-check-animations.html" target="_blank">点击此处在新标签页中打开</a>
  293. </div>
  294. <p></p>
  295. <p>这基本上就是本文 three.js 部分的全部内容了。我们讲解了加载多个文件、克隆蒙皮模型以及在它们上播放动画。在真正的游戏中,你需要对 <a href="/docs/#api/en/animation/AnimationAction"><code class="notranslate" translate="no">AnimationAction</code></a> 对象做更多的操作。</p>
  296. <p>让我们开始构建游戏基础架构</p>
  297. <p>制作现代游戏的一个常见模式是使用<a href="https://www.google.com/search?q=entity+component+system">实体组件系统</a>(Entity Component System)。在实体组件系统中,游戏中的对象被称为<em>实体</em>(entity),由一组<em>组件</em>(component)组成。你通过决定将哪些组件附加到实体上来构建实体。那么,让我们来构建一个实体组件系统。</p>
  298. <p>我们将实体称为 <code class="notranslate" translate="no">GameObject</code>。它实际上只是组件的集合和一个 three.js <a href="/docs/#api/en/core/Object3D"><code class="notranslate" translate="no">Object3D</code></a>。</p>
  299. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function removeArrayElement(array, element) {
  300. const ndx = array.indexOf(element);
  301. if (ndx &gt;= 0) {
  302. array.splice(ndx, 1);
  303. }
  304. }
  305. class GameObject {
  306. constructor(parent, name) {
  307. this.name = name;
  308. this.components = [];
  309. this.transform = new THREE.Object3D();
  310. parent.add(this.transform);
  311. }
  312. addComponent(ComponentType, ...args) {
  313. const component = new ComponentType(this, ...args);
  314. this.components.push(component);
  315. return component;
  316. }
  317. removeComponent(component) {
  318. removeArrayElement(this.components, component);
  319. }
  320. getComponent(ComponentType) {
  321. return this.components.find(c =&gt; c instanceof ComponentType);
  322. }
  323. update() {
  324. for (const component of this.components) {
  325. component.update();
  326. }
  327. }
  328. }
  329. </pre>
  330. <p>调用 <code class="notranslate" translate="no">GameObject.update</code> 会调用所有组件的 <code class="notranslate" translate="no">update</code>。</p>
  331. <p>我添加 name 只是为了帮助调试,这样在调试器中查看 <code class="notranslate" translate="no">GameObject</code> 时可以看到一个名称来帮助识别。</p>
  332. <p>有些东西可能看起来有点奇怪:</p>
  333. <p><code class="notranslate" translate="no">GameObject.addComponent</code> 用于创建组件。我不确定这是好主意还是坏主意。我的想法是,组件存在于游戏对象之外没有意义,所以我认为如果创建组件时自动将该组件添加到游戏对象并将游戏对象传递给组件的构造函数会比较好。换句话说,添加组件时你这样做</p>
  334. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const gameObject = new GameObject(scene, 'foo');
  335. gameObject.addComponent(TypeOfComponent);
  336. </pre>
  337. <p>如果我不这样做,你就需要这样写</p>
  338. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const gameObject = new GameObject(scene, 'foo');
  339. const component = new TypeOfComponent(gameObject);
  340. gameObject.addComponent(component);
  341. </pre>
  342. <p>第一种方式更短更自动化,这是更好还是更差,因为它看起来不太常规?我不知道。</p>
  343. <p><code class="notranslate" translate="no">GameObject.getComponent</code> 通过类型查找组件。这意味着你不能在一个游戏对象上有两个相同类型的组件,或者至少如果你有的话,在不添加其他 API 的情况下只能查找到第一个。</p>
  344. <p>一个组件查找另一个组件是很常见的,查找时必须按类型匹配,否则你可能会找到错误的组件。我们也可以给每个组件一个名称,然后按名称查找。这样会更灵活,因为你可以有多个相同类型的组件,但也会更繁琐。同样,我不确定哪种更好。</p>
  345. <p>现在来看组件本身。这是它们的基类。</p>
  346. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">// 所有组件的基类
  347. class Component {
  348. constructor(gameObject) {
  349. this.gameObject = gameObject;
  350. }
  351. update() {
  352. }
  353. }
  354. </pre>
  355. <p>组件需要基类吗?JavaScript 不像大多数严格类型语言,所以实际上我们可以没有基类,让每个组件在其构造函数中做它想做的事,知道第一个参数始终是组件的游戏对象。如果它不关心游戏对象就不存储它。但我还是觉得这个公共基类是好的。它意味着如果你有一个组件的引用,你总是可以找到它的父游戏对象,从父对象你可以轻松查找其他组件以及查看它的变换。</p>
  356. <p>要管理游戏对象,我们可能需要某种游戏对象管理器。你可能认为我们可以只维护一个游戏对象数组,但在真正的游戏中,游戏对象的组件可能在运行时添加和移除其他游戏对象。例如,一个枪游戏对象可能在每次开火时添加一个子弹游戏对象。一个怪物游戏对象可能在被杀死时移除自己。这样我们就会遇到一个问题,我们可能有这样的代码</p>
  357. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">for (const gameObject of globalArrayOfGameObjects) {
  358. gameObject.update();
  359. }
  360. </pre>
  361. <p>如果在某个组件的 <code class="notranslate" translate="no">update</code> 函数中在循环中途向 <code class="notranslate" translate="no">globalArrayOfGameObjects</code> 添加或移除游戏对象,上面的循环就会失败或产生意外行为。</p>
  362. <p>为了防止这个问题,我们需要一些更安全的东西。这是一个尝试。</p>
  363. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class SafeArray {
  364. constructor() {
  365. this.array = [];
  366. this.addQueue = [];
  367. this.removeQueue = new Set();
  368. }
  369. get isEmpty() {
  370. return this.addQueue.length + this.array.length &gt; 0;
  371. }
  372. add(element) {
  373. this.addQueue.push(element);
  374. }
  375. remove(element) {
  376. this.removeQueue.add(element);
  377. }
  378. forEach(fn) {
  379. this._addQueued();
  380. this._removeQueued();
  381. for (const element of this.array) {
  382. if (this.removeQueue.has(element)) {
  383. continue;
  384. }
  385. fn(element);
  386. }
  387. this._removeQueued();
  388. }
  389. _addQueued() {
  390. if (this.addQueue.length) {
  391. this.array.splice(this.array.length, 0, ...this.addQueue);
  392. this.addQueue = [];
  393. }
  394. }
  395. _removeQueued() {
  396. if (this.removeQueue.size) {
  397. this.array = this.array.filter(element =&gt; !this.removeQueue.has(element));
  398. this.removeQueue.clear();
  399. }
  400. }
  401. }
  402. </pre>
  403. <p>上面的类允许你向 <code class="notranslate" translate="no">SafeArray</code> 添加或移除元素,但在遍历时不会直接修改数组本身。新元素会被添加到 <code class="notranslate" translate="no">addQueue</code>,要移除的元素添加到 <code class="notranslate" translate="no">removeQueue</code>,然后在循环之外进行实际的添加或移除。</p>
  404. <p>使用它,这是我们管理游戏对象的类。</p>
  405. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class GameObjectManager {
  406. constructor() {
  407. this.gameObjects = new SafeArray();
  408. }
  409. createGameObject(parent, name) {
  410. const gameObject = new GameObject(parent, name);
  411. this.gameObjects.add(gameObject);
  412. return gameObject;
  413. }
  414. removeGameObject(gameObject) {
  415. this.gameObjects.remove(gameObject);
  416. }
  417. update() {
  418. this.gameObjects.forEach(gameObject =&gt; gameObject.update());
  419. }
  420. }
  421. </pre>
  422. <p>有了这些,现在让我们创建第一个组件。这个组件只负责管理像我们刚才创建的那种蒙皮 three.js 对象。为了简单起见,它只有一个方法 <code class="notranslate" translate="no">setAnimation</code>,接受要播放的动画名称并播放它。</p>
  423. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class SkinInstance extends Component {
  424. constructor(gameObject, model) {
  425. super(gameObject);
  426. this.model = model;
  427. this.animRoot = SkeletonUtils.clone(this.model.gltf.scene);
  428. this.mixer = new THREE.AnimationMixer(this.animRoot);
  429. gameObject.transform.add(this.animRoot);
  430. this.actions = {};
  431. }
  432. setAnimation(animName) {
  433. const clip = this.model.animations[animName];
  434. // 关闭所有当前动作
  435. for (const action of Object.values(this.actions)) {
  436. action.enabled = false;
  437. }
  438. // 获取或创建该片段的动作
  439. const action = this.mixer.clipAction(clip);
  440. action.enabled = true;
  441. action.reset();
  442. action.play();
  443. this.actions[animName] = action;
  444. }
  445. update() {
  446. this.mixer.update(globals.deltaTime);
  447. }
  448. }
  449. </pre>
  450. <p>你可以看到,它基本上就是我们之前的代码,克隆加载的场景,然后设置一个 <a href="/docs/#api/en/animation/AnimationMixer"><code class="notranslate" translate="no">AnimationMixer</code></a>。<code class="notranslate" translate="no">setAnimation</code> 为特定的 <a href="/docs/#api/en/animation/AnimationClip"><code class="notranslate" translate="no">AnimationClip</code></a> 添加一个 <a href="/docs/#api/en/animation/AnimationAction"><code class="notranslate" translate="no">AnimationAction</code></a>(如果还不存在的话),并禁用所有现有的动作。</p>
  451. <p>代码引用了 <code class="notranslate" translate="no">globals.deltaTime</code>。让我们创建一个 globals 对象</p>
  452. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const globals = {
  453. time: 0,
  454. deltaTime: 0,
  455. };
  456. </pre>
  457. <p>并在渲染循环中更新它</p>
  458. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">let then = 0;
  459. function render(now) {
  460. // 转换为秒
  461. globals.time = now * 0.001;
  462. // 确保 deltaTime 不会太大
  463. globals.deltaTime = Math.min(globals.time - then, 1 / 20);
  464. then = globals.time;
  465. </pre>
  466. <p>上面确保 <code class="notranslate" translate="no">deltaTime</code> 不超过 1/20 秒的检查是因为,如果我们隐藏标签页,就会得到一个巨大的 <code class="notranslate" translate="no">deltaTime</code> 值。我们可能隐藏标签页几秒或几分钟,然后当标签页被切回前台时 <code class="notranslate" translate="no">deltaTime</code> 会非常大,如果我们有这样的代码,可能会把角色传送到游戏世界的另一端</p>
  467. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">position += velocity * deltaTime;
  468. </pre>
  469. <p>通过限制 <code class="notranslate" translate="no">deltaTime</code> 的最大值可以防止这个问题。</p>
  470. <p>现在让我们为玩家创建一个组件。</p>
  471. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class Player extends Component {
  472. constructor(gameObject) {
  473. super(gameObject);
  474. const model = models.knight;
  475. this.skinInstance = gameObject.addComponent(SkinInstance, model);
  476. this.skinInstance.setAnimation('Run');
  477. }
  478. }
  479. </pre>
  480. <p>玩家用 <code class="notranslate" translate="no">'Run'</code> 调用 <code class="notranslate" translate="no">setAnimation</code>。为了知道有哪些可用的动画,我修改了之前的示例来打印动画名称</p>
  481. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function prepModelsAndAnimations() {
  482. Object.values(models).forEach(model =&gt; {
  483. + console.log('-------&gt;:', model.url);
  484. const animsByName = {};
  485. model.gltf.animations.forEach((clip) =&gt; {
  486. animsByName[clip.name] = clip;
  487. + console.log(' ', clip.name);
  488. });
  489. model.animations = animsByName;
  490. });
  491. }
  492. </pre>
  493. <p>运行后在 <a href="https://developers.google.com/web/tools/chrome-devtools/console/javascript">JavaScript 控制台</a>中得到了这个列表。</p>
  494. <pre class="prettyprint showlinemods notranslate notranslate" translate="no"> -------&gt;: resources/models/animals/Pig.gltf
  495. Idle
  496. Death
  497. WalkSlow
  498. Jump
  499. Walk
  500. -------&gt;: resources/models/animals/Cow.gltf
  501. Walk
  502. Jump
  503. WalkSlow
  504. Death
  505. Idle
  506. -------&gt;: resources/models/animals/Llama.gltf
  507. Jump
  508. Idle
  509. Walk
  510. Death
  511. WalkSlow
  512. -------&gt;: resources/models/animals/Pug.gltf
  513. Jump
  514. Walk
  515. Idle
  516. WalkSlow
  517. Death
  518. -------&gt;: resources/models/animals/Sheep.gltf
  519. WalkSlow
  520. Death
  521. Jump
  522. Walk
  523. Idle
  524. -------&gt;: resources/models/animals/Zebra.gltf
  525. Jump
  526. Walk
  527. Death
  528. WalkSlow
  529. Idle
  530. -------&gt;: resources/models/animals/Horse.gltf
  531. Jump
  532. WalkSlow
  533. Death
  534. Walk
  535. Idle
  536. -------&gt;: resources/models/knight/KnightCharacter.gltf
  537. Run_swordRight
  538. Run
  539. Idle_swordLeft
  540. Roll_sword
  541. Idle
  542. Run_swordAttack
  543. </pre><p>幸运的是,所有动物的动画名称都是一样的,这在之后会很方便。目前我们只关心玩家有一个叫 <code class="notranslate" translate="no">Run</code> 的动画。</p>
  544. <p>让我们使用这些组件。这是更新后的 init 函数。它所做的就是创建一个 <code class="notranslate" translate="no">GameObject</code> 并添加一个 <code class="notranslate" translate="no">Player</code> 组件。</p>
  545. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const globals = {
  546. time: 0,
  547. deltaTime: 0,
  548. };
  549. +const gameObjectManager = new GameObjectManager();
  550. function init() {
  551. // 隐藏加载条
  552. const loadingElem = document.querySelector('#loading');
  553. loadingElem.style.display = 'none';
  554. prepModelsAndAnimations();
  555. + {
  556. + const gameObject = gameObjectManager.createGameObject(scene, 'player');
  557. + gameObject.addComponent(Player);
  558. + }
  559. }
  560. </pre>
  561. <p>我们需要在渲染循环中调用 <code class="notranslate" translate="no">gameObjectManager.update</code></p>
  562. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">let then = 0;
  563. function render(now) {
  564. // 转换为秒
  565. globals.time = now * 0.001;
  566. // 确保 deltaTime 不会太大
  567. globals.deltaTime = Math.min(globals.time - then, 1 / 20);
  568. then = globals.time;
  569. if (resizeRendererToDisplaySize(renderer)) {
  570. const canvas = renderer.domElement;
  571. camera.aspect = canvas.clientWidth / canvas.clientHeight;
  572. camera.updateProjectionMatrix();
  573. }
  574. - for (const {mixer} of mixerInfos) {
  575. - mixer.update(deltaTime);
  576. - }
  577. + gameObjectManager.update();
  578. renderer.render(scene, camera);
  579. requestAnimationFrame(render);
  580. }
  581. </pre>
  582. <p>如果我们运行它,会得到一个单独的玩家。</p>
  583. <p></p><div translate="no" class="threejs_example_container notranslate">
  584. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/game-just-player.html"></iframe></div>
  585. <a class="threejs_center" href="/manual/examples/game-just-player.html" target="_blank">点击此处在新标签页中打开</a>
  586. </div>
  587. <p></p>
  588. <p>仅仅为了一个实体组件系统就写了这么多代码,但这是大多数游戏需要的基础设施。</p>
  589. <p>让我们添加一个输入系统。与其直接读取按键,我们将创建一个类,让代码的其他部分可以检查 <code class="notranslate" translate="no">left</code> 或 <code class="notranslate" translate="no">right</code>。这样我们可以分配多种方式来输入 <code class="notranslate" translate="no">left</code> 或 <code class="notranslate" translate="no">right</code> 等。我们先从按键开始</p>
  590. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">// 保持按键/按钮的状态
  591. //
  592. // 你可以检查
  593. //
  594. // inputManager.keys.left.down
  595. //
  596. // 来查看左键是否当前被按住
  597. // 你也可以检查
  598. //
  599. // inputManager.keys.left.justPressed
  600. //
  601. // 来查看左键是否在这一帧被按下
  602. //
  603. // 按键有 'left', 'right', 'a', 'b', 'up', 'down'
  604. class InputManager {
  605. constructor() {
  606. this.keys = {};
  607. const keyMap = new Map();
  608. const setKey = (keyName, pressed) =&gt; {
  609. const keyState = this.keys[keyName];
  610. keyState.justPressed = pressed &amp;&amp; !keyState.down;
  611. keyState.down = pressed;
  612. };
  613. const addKey = (keyCode, name) =&gt; {
  614. this.keys[name] = { down: false, justPressed: false };
  615. keyMap.set(keyCode, name);
  616. };
  617. const setKeyFromKeyCode = (keyCode, pressed) =&gt; {
  618. const keyName = keyMap.get(keyCode);
  619. if (!keyName) {
  620. return;
  621. }
  622. setKey(keyName, pressed);
  623. };
  624. addKey(37, 'left');
  625. addKey(39, 'right');
  626. addKey(38, 'up');
  627. addKey(40, 'down');
  628. addKey(90, 'a');
  629. addKey(88, 'b');
  630. window.addEventListener('keydown', (e) =&gt; {
  631. setKeyFromKeyCode(e.keyCode, true);
  632. });
  633. window.addEventListener('keyup', (e) =&gt; {
  634. setKeyFromKeyCode(e.keyCode, false);
  635. });
  636. }
  637. update() {
  638. for (const keyState of Object.values(this.keys)) {
  639. if (keyState.justPressed) {
  640. keyState.justPressed = false;
  641. }
  642. }
  643. }
  644. }
  645. </pre>
  646. <p>上面的代码跟踪按键是按下还是松开,你可以通过检查例如 <code class="notranslate" translate="no">inputManager.keys.left.down</code> 来判断一个键是否当前被按下。它还为每个键提供了 <code class="notranslate" translate="no">justPressed</code> 属性,这样你可以检查用户是否刚刚按下了该键。例如跳跃键,你不想知道按钮是否被持续按住,你想知道用户是否现在按下了它。</p>
  647. <p>让我们创建一个 <code class="notranslate" translate="no">InputManager</code> 实例</p>
  648. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const globals = {
  649. time: 0,
  650. deltaTime: 0,
  651. };
  652. const gameObjectManager = new GameObjectManager();
  653. +const inputManager = new InputManager();
  654. </pre>
  655. <p>并在渲染循环中更新它</p>
  656. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function render(now) {
  657. ...
  658. gameObjectManager.update();
  659. + inputManager.update();
  660. ...
  661. }
  662. </pre>
  663. <p>它需要在 <code class="notranslate" translate="no">gameObjectManager.update</code> 之后调用,否则 <code class="notranslate" translate="no">justPressed</code> 在组件的 <code class="notranslate" translate="no">update</code> 函数中永远不会为 true。</p>
  664. <p>让我们在 <code class="notranslate" translate="no">Player</code> 组件中使用它</p>
  665. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+const kForward = new THREE.Vector3(0, 0, 1);
  666. const globals = {
  667. time: 0,
  668. deltaTime: 0,
  669. + moveSpeed: 16,
  670. };
  671. class Player extends Component {
  672. constructor(gameObject) {
  673. super(gameObject);
  674. const model = models.knight;
  675. this.skinInstance = gameObject.addComponent(SkinInstance, model);
  676. this.skinInstance.setAnimation('Run');
  677. + this.turnSpeed = globals.moveSpeed / 4;
  678. }
  679. + update() {
  680. + const {deltaTime, moveSpeed} = globals;
  681. + const {transform} = this.gameObject;
  682. + const delta = (inputManager.keys.left.down ? 1 : 0) +
  683. + (inputManager.keys.right.down ? -1 : 0);
  684. + transform.rotation.y += this.turnSpeed * delta * deltaTime;
  685. + transform.translateOnAxis(kForward, moveSpeed * deltaTime);
  686. + }
  687. }
  688. </pre>
  689. <p>上面的代码使用 <a href="/docs/#api/en/core/Object3D.transformOnAxis"><code class="notranslate" translate="no">Object3D.transformOnAxis</code></a> 来向前移动玩家。<a href="/docs/#api/en/core/Object3D.transformOnAxis"><code class="notranslate" translate="no">Object3D.transformOnAxis</code></a> 在本地空间中工作,所以只有当对象在场景的根级别时才有效,如果它是其他东西的子对象则不行 <a class="footnote" href="#parented" id="parented-backref">1</a></p>
  690. <p>我们还添加了一个全局 <code class="notranslate" translate="no">moveSpeed</code>,并基于移动速度计算 <code class="notranslate" translate="no">turnSpeed</code>。转向速度基于移动速度,以确保角色能够足够快地转向以到达目标。如果 <code class="notranslate" translate="no">turnSpeed</code> 太小,角色会围绕目标转圈但永远无法到达。我没有费心去计算给定移动速度所需的转向速度,只是猜的。</p>
  691. <p>到目前为止的代码可以工作,但如果玩家跑出屏幕就无法知道他们在哪里了。让我们实现如果他们离开屏幕超过一定时间就传送回原点。我们可以使用 three.js 的 <a href="/docs/#api/en/math/Frustum"><code class="notranslate" translate="no">Frustum</code></a> 类来检查一个点是否在摄像机的视锥体内。</p>
  692. <p>我们需要从摄像机构建一个视锥体。我们可以在 Player 组件中做这件事,但其他对象可能也想使用它,所以让我们添加另一个带有管理视锥体组件的游戏对象。</p>
  693. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class CameraInfo extends Component {
  694. constructor(gameObject) {
  695. super(gameObject);
  696. this.projScreenMatrix = new THREE.Matrix4();
  697. this.frustum = new THREE.Frustum();
  698. }
  699. update() {
  700. const {camera} = globals;
  701. this.projScreenMatrix.multiplyMatrices(
  702. camera.projectionMatrix,
  703. camera.matrixWorldInverse);
  704. this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
  705. }
  706. }
  707. </pre>
  708. <p>然后让我们在初始化时设置另一个游戏对象。</p>
  709. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function init() {
  710. // 隐藏加载条
  711. const loadingElem = document.querySelector('#loading');
  712. loadingElem.style.display = 'none';
  713. prepModelsAndAnimations();
  714. + {
  715. + const gameObject = gameObjectManager.createGameObject(camera, 'camera');
  716. + globals.cameraInfo = gameObject.addComponent(CameraInfo);
  717. + }
  718. {
  719. const gameObject = gameObjectManager.createGameObject(scene, 'player');
  720. gameObject.addComponent(Player);
  721. }
  722. }
  723. </pre>
  724. <p>现在我们可以在 <code class="notranslate" translate="no">Player</code> 组件中使用它了。</p>
  725. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class Player extends Component {
  726. constructor(gameObject) {
  727. super(gameObject);
  728. const model = models.knight;
  729. this.skinInstance = gameObject.addComponent(SkinInstance, model);
  730. this.skinInstance.setAnimation('Run');
  731. this.turnSpeed = globals.moveSpeed / 4;
  732. + this.offscreenTimer = 0;
  733. + this.maxTimeOffScreen = 3;
  734. }
  735. update() {
  736. - const {deltaTime, moveSpeed} = globals;
  737. + const {deltaTime, moveSpeed, cameraInfo} = globals;
  738. const {transform} = this.gameObject;
  739. const delta = (inputManager.keys.left.down ? 1 : 0) +
  740. (inputManager.keys.right.down ? -1 : 0);
  741. transform.rotation.y += this.turnSpeed * delta * deltaTime;
  742. transform.translateOnAxis(kForward, moveSpeed * deltaTime);
  743. + const {frustum} = cameraInfo;
  744. + if (frustum.containsPoint(transform.position)) {
  745. + this.offscreenTimer = 0;
  746. + } else {
  747. + this.offscreenTimer += deltaTime;
  748. + if (this.offscreenTimer &gt;= this.maxTimeOffScreen) {
  749. + transform.position.set(0, 0, 0);
  750. + }
  751. + }
  752. }
  753. }
  754. </pre>
  755. <p>在试运行之前还有一件事,让我们为移动端添加触摸屏支持。首先添加一些 HTML 用于触摸</p>
  756. <pre class="prettyprint showlinemods notranslate lang-html" translate="no">&lt;body&gt;
  757. &lt;canvas id="c"&gt;&lt;/canvas&gt;
  758. + &lt;div id="ui"&gt;
  759. + &lt;div id="left"&gt;&lt;img src="../resources/images/left.svg"&gt;&lt;/div&gt;
  760. + &lt;div style="flex: 0 0 40px;"&gt;&lt;/div&gt;
  761. + &lt;div id="right"&gt;&lt;img src="../resources/images/right.svg"&gt;&lt;/div&gt;
  762. + &lt;/div&gt;
  763. &lt;div id="loading"&gt;
  764. &lt;div&gt;
  765. &lt;div&gt;...loading...&lt;/div&gt;
  766. &lt;div class="progress"&gt;&lt;div id="progressbar"&gt;&lt;/div&gt;&lt;/div&gt;
  767. &lt;/div&gt;
  768. &lt;/div&gt;
  769. &lt;/body&gt;
  770. </pre>
  771. <p>以及一些 CSS 来样式化它</p>
  772. <pre class="prettyprint showlinemods notranslate lang-css" translate="no">#ui {
  773. position: absolute;
  774. left: 0;
  775. top: 0;
  776. width: 100%;
  777. height: 100%;
  778. display: flex;
  779. justify-items: center;
  780. align-content: stretch;
  781. }
  782. #ui&gt;div {
  783. display: flex;
  784. align-items: flex-end;
  785. flex: 1 1 auto;
  786. }
  787. .bright {
  788. filter: brightness(2);
  789. }
  790. #left {
  791. justify-content: flex-end;
  792. }
  793. #right {
  794. justify-content: flex-start;
  795. }
  796. #ui img {
  797. padding: 10px;
  798. width: 80px;
  799. height: 80px;
  800. display: block;
  801. }
  802. </pre>
  803. <p>这里的想法是有一个 <code class="notranslate" translate="no">#ui</code> div 覆盖整个页面。里面有两个 div,<code class="notranslate" translate="no">#left</code> 和 <code class="notranslate" translate="no">#right</code>,它们都几乎是页面宽度的一半,高度为整个屏幕。中间有一个 40px 的分隔。如果用户在左侧或右侧滑动手指,我们需要更新 <code class="notranslate" translate="no">InputManager</code> 中的 <code class="notranslate" translate="no">keys.left</code> 和 <code class="notranslate" translate="no">keys.right</code>。这使整个屏幕都对触摸敏感,这比仅使用小箭头要好。</p>
  804. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class InputManager {
  805. constructor() {
  806. this.keys = {};
  807. const keyMap = new Map();
  808. const setKey = (keyName, pressed) =&gt; {
  809. const keyState = this.keys[keyName];
  810. keyState.justPressed = pressed &amp;&amp; !keyState.down;
  811. keyState.down = pressed;
  812. };
  813. const addKey = (keyCode, name) =&gt; {
  814. this.keys[name] = { down: false, justPressed: false };
  815. keyMap.set(keyCode, name);
  816. };
  817. const setKeyFromKeyCode = (keyCode, pressed) =&gt; {
  818. const keyName = keyMap.get(keyCode);
  819. if (!keyName) {
  820. return;
  821. }
  822. setKey(keyName, pressed);
  823. };
  824. addKey(37, 'left');
  825. addKey(39, 'right');
  826. addKey(38, 'up');
  827. addKey(40, 'down');
  828. addKey(90, 'a');
  829. addKey(88, 'b');
  830. window.addEventListener('keydown', (e) =&gt; {
  831. setKeyFromKeyCode(e.keyCode, true);
  832. });
  833. window.addEventListener('keyup', (e) =&gt; {
  834. setKeyFromKeyCode(e.keyCode, false);
  835. });
  836. + const sides = [
  837. + { elem: document.querySelector('#left'), key: 'left' },
  838. + { elem: document.querySelector('#right'), key: 'right' },
  839. + ];
  840. +
  841. + const clearKeys = () =&gt; {
  842. + for (const {key} of sides) {
  843. + setKey(key, false);
  844. + }
  845. + };
  846. +
  847. + const handleMouseMove = (e) =&gt; {
  848. + e.preventDefault();
  849. + // 这是必要的,因为我们调用了 preventDefault();
  850. + // 我们还给 canvas 添加了 tabindex 以便它可以
  851. + // 获得焦点
  852. + canvas.focus();
  853. + window.addEventListener('pointermove', handleMouseMove);
  854. + window.addEventListener('pointerup', handleMouseUp);
  855. +
  856. + for (const {elem, key} of sides) {
  857. + let pressed = false;
  858. + const rect = elem.getBoundingClientRect();
  859. + const x = e.clientX;
  860. + const y = e.clientY;
  861. + const inRect = x &gt;= rect.left &amp;&amp; x &lt; rect.right &amp;&amp;
  862. + y &gt;= rect.top &amp;&amp; y &lt; rect.bottom;
  863. + if (inRect) {
  864. + pressed = true;
  865. + }
  866. + setKey(key, pressed);
  867. + }
  868. + };
  869. +
  870. + function handleMouseUp() {
  871. + clearKeys();
  872. + window.removeEventListener('pointermove', handleMouseMove, {passive: false});
  873. + window.removeEventListener('pointerup', handleMouseUp);
  874. + }
  875. +
  876. + const uiElem = document.querySelector('#ui');
  877. + uiElem.addEventListener('pointerdown', handleMouseMove, {passive: false});
  878. +
  879. + uiElem.addEventListener('touchstart', (e) =&gt; {
  880. + // 阻止滚动
  881. + e.preventDefault();
  882. + }, {passive: false});
  883. }
  884. update() {
  885. for (const keyState of Object.values(this.keys)) {
  886. if (keyState.justPressed) {
  887. keyState.justPressed = false;
  888. }
  889. }
  890. }
  891. }
  892. </pre>
  893. <p>现在我们应该能用左右方向键或在触摸屏上用手指来控制角色了</p>
  894. <p></p><div translate="no" class="threejs_example_container notranslate">
  895. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/game-player-input.html"></iframe></div>
  896. <a class="threejs_center" href="/manual/examples/game-player-input.html" target="_blank">点击此处在新标签页中打开</a>
  897. </div>
  898. <p></p>
  899. <p>理想情况下,如果玩家离开屏幕我们会做其他事情,比如移动摄像机或者离开屏幕就死亡,但这篇文章已经够长了,所以目前传送回中心是最简单的方案。</p>
  900. <p>让我们添加一些动物。我们可以像 <code class="notranslate" translate="no">Player</code> 类似地开始,创建一个 <code class="notranslate" translate="no">Animal</code> 组件。</p>
  901. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class Animal extends Component {
  902. constructor(gameObject, model) {
  903. super(gameObject);
  904. const skinInstance = gameObject.addComponent(SkinInstance, model);
  905. skinInstance.mixer.timeScale = globals.moveSpeed / 4;
  906. skinInstance.setAnimation('Idle');
  907. }
  908. }
  909. </pre>
  910. <p>上面的代码设置 <a href="/docs/#api/en/animation/AnimationMixer.timeScale"><code class="notranslate" translate="no">AnimationMixer.timeScale</code></a> 来设置动画相对于移动速度的播放速度。这样如果我们调整移动速度,动画也会相应加速或减速。</p>
  911. <p>首先我们可以设置每种动物各一个</p>
  912. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function init() {
  913. // 隐藏加载条
  914. const loadingElem = document.querySelector('#loading');
  915. loadingElem.style.display = 'none';
  916. prepModelsAndAnimations();
  917. {
  918. const gameObject = gameObjectManager.createGameObject(camera, 'camera');
  919. globals.cameraInfo = gameObject.addComponent(CameraInfo);
  920. }
  921. {
  922. const gameObject = gameObjectManager.createGameObject(scene, 'player');
  923. globals.player = gameObject.addComponent(Player);
  924. globals.congaLine = [gameObject];
  925. }
  926. + const animalModelNames = [
  927. + 'pig',
  928. + 'cow',
  929. + 'llama',
  930. + 'pug',
  931. + 'sheep',
  932. + 'zebra',
  933. + 'horse',
  934. + ];
  935. + animalModelNames.forEach((name, ndx) =&gt; {
  936. + const gameObject = gameObjectManager.createGameObject(scene, name);
  937. + gameObject.addComponent(Animal, models[name]);
  938. + gameObject.transform.position.x = (ndx + 1) * 5;
  939. + });
  940. }
  941. </pre>
  942. <p>这样我们会得到站在屏幕上的动物,但我们希望它们做些什么。</p>
  943. <p>让我们让它们在玩家靠近时跟随玩家排成康加舞队列。为此我们需要几种状态。</p>
  944. <ul>
  945. <li><p>空闲(Idle):</p>
  946. <p>动物等待玩家靠近</p>
  947. </li>
  948. <li><p>等待队尾(Wait for End of Line):</p>
  949. <p>动物被玩家标记了,但现在需要等待队列末尾的动物过来,这样它才能加入队尾。</p>
  950. </li>
  951. <li><p>走向队尾(Go to Last):</p>
  952. <p>动物需要走到它跟随的动物之前所在的位置,同时记录它跟随的动物当前的位置历史。</p>
  953. </li>
  954. <li><p>跟随(Follow)</p>
  955. <p>动物需要持续记录它跟随的动物的位置历史,同时移动到它跟随的动物之前所在的位置。</p>
  956. </li>
  957. </ul>
  958. <p>处理这样的不同状态有很多方式。一种常见的方式是使用<a href="https://www.google.com/search?q=finite+state+machine">有限状态机</a>(Finite State Machine),并构建一些类来帮助我们管理状态。</p>
  959. <p>那么,让我们来实现它。</p>
  960. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class FiniteStateMachine {
  961. constructor(states, initialState) {
  962. this.states = states;
  963. this.transition(initialState);
  964. }
  965. get state() {
  966. return this.currentState;
  967. }
  968. transition(state) {
  969. const oldState = this.states[this.currentState];
  970. if (oldState &amp;&amp; oldState.exit) {
  971. oldState.exit.call(this);
  972. }
  973. this.currentState = state;
  974. const newState = this.states[state];
  975. if (newState.enter) {
  976. newState.enter.call(this);
  977. }
  978. }
  979. update() {
  980. const state = this.states[this.currentState];
  981. if (state.update) {
  982. state.update.call(this);
  983. }
  984. }
  985. }
  986. </pre>
  987. <p>这是一个简单的类。我们传给它一个包含一堆状态的对象。每个状态有 3 个可选函数:<code class="notranslate" translate="no">enter</code>、<code class="notranslate" translate="no">update</code> 和 <code class="notranslate" translate="no">exit</code>。要切换状态,我们调用 <code class="notranslate" translate="no">FiniteStateMachine.transition</code> 并传入新状态的名称。如果当前状态有 <code class="notranslate" translate="no">exit</code> 函数就会被调用。然后如果新状态有 <code class="notranslate" translate="no">enter</code> 函数也会被调用。最后每一帧 <code class="notranslate" translate="no">FiniteStateMachine.update</code> 会调用当前状态的 <code class="notranslate" translate="no">update</code> 函数。</p>
  988. <p>让我们用它来管理动物的状态。</p>
  989. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">// 如果 obj1 和 obj2 足够近则返回 true
  990. function isClose(obj1, obj1Radius, obj2, obj2Radius) {
  991. const minDist = obj1Radius + obj2Radius;
  992. const dist = obj1.position.distanceTo(obj2.position);
  993. return dist &lt; minDist;
  994. }
  995. // 将 v 限制在 -min 和 +min 之间
  996. function minMagnitude(v, min) {
  997. return Math.abs(v) &gt; min
  998. ? min * Math.sign(v)
  999. : v;
  1000. }
  1001. const aimTowardAndGetDistance = function() {
  1002. const delta = new THREE.Vector3();
  1003. return function aimTowardAndGetDistance(source, targetPos, maxTurn) {
  1004. delta.subVectors(targetPos, source.position);
  1005. // 计算我们想要面朝的方向
  1006. const targetRot = Math.atan2(delta.x, delta.z) + Math.PI * 1.5;
  1007. // 沿最短方向旋转
  1008. const deltaRot = (targetRot - source.rotation.y + Math.PI * 1.5) % (Math.PI * 2) - Math.PI;
  1009. // 确保转向速度不超过 maxTurn
  1010. const deltaRotation = minMagnitude(deltaRot, maxTurn);
  1011. // 将旋转保持在 0 到 Math.PI * 2 之间
  1012. source.rotation.y = THREE.MathUtils.euclideanModulo(
  1013. source.rotation.y + deltaRotation, Math.PI * 2);
  1014. // 返回到目标的距离
  1015. return delta.length();
  1016. };
  1017. }();
  1018. class Animal extends Component {
  1019. constructor(gameObject, model) {
  1020. super(gameObject);
  1021. + const hitRadius = model.size / 2;
  1022. const skinInstance = gameObject.addComponent(SkinInstance, model);
  1023. skinInstance.mixer.timeScale = globals.moveSpeed / 4;
  1024. + const transform = gameObject.transform;
  1025. + const playerTransform = globals.player.gameObject.transform;
  1026. + const maxTurnSpeed = Math.PI * (globals.moveSpeed / 4);
  1027. + const targetHistory = [];
  1028. + let targetNdx = 0;
  1029. +
  1030. + function addHistory() {
  1031. + const targetGO = globals.congaLine[targetNdx];
  1032. + const newTargetPos = new THREE.Vector3();
  1033. + newTargetPos.copy(targetGO.transform.position);
  1034. + targetHistory.push(newTargetPos);
  1035. + }
  1036. +
  1037. + this.fsm = new FiniteStateMachine({
  1038. + idle: {
  1039. + enter: () =&gt; {
  1040. + skinInstance.setAnimation('Idle');
  1041. + },
  1042. + update: () =&gt; {
  1043. + // 检查玩家是否靠近
  1044. + if (isClose(transform, hitRadius, playerTransform, globals.playerRadius)) {
  1045. + this.fsm.transition('waitForEnd');
  1046. + }
  1047. + },
  1048. + },
  1049. + waitForEnd: {
  1050. + enter: () =&gt; {
  1051. + skinInstance.setAnimation('Jump');
  1052. + },
  1053. + update: () =&gt; {
  1054. + // 获取康加舞队列末尾的游戏对象
  1055. + const lastGO = globals.congaLine[globals.congaLine.length - 1];
  1056. + const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
  1057. + const targetPos = lastGO.transform.position;
  1058. + aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
  1059. + // 检查康加舞队列的最后一个是否靠近
  1060. + if (isClose(transform, hitRadius, lastGO.transform, globals.playerRadius)) {
  1061. + this.fsm.transition('goToLast');
  1062. + }
  1063. + },
  1064. + },
  1065. + goToLast: {
  1066. + enter: () =&gt; {
  1067. + // 记住我们跟随的是谁
  1068. + targetNdx = globals.congaLine.length - 1;
  1069. + // 将自己加入康加舞队列
  1070. + globals.congaLine.push(gameObject);
  1071. + skinInstance.setAnimation('Walk');
  1072. + },
  1073. + update: () =&gt; {
  1074. + addHistory();
  1075. + // 走向历史记录中最旧的点
  1076. + const targetPos = targetHistory[0];
  1077. + const maxVelocity = globals.moveSpeed * globals.deltaTime;
  1078. + const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
  1079. + const distance = aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
  1080. + const velocity = distance;
  1081. + transform.translateOnAxis(kForward, Math.min(velocity, maxVelocity));
  1082. + if (distance &lt;= maxVelocity) {
  1083. + this.fsm.transition('follow');
  1084. + }
  1085. + },
  1086. + },
  1087. + follow: {
  1088. + update: () =&gt; {
  1089. + addHistory();
  1090. + // 移除最旧的历史记录并将自己放到那个位置
  1091. + const targetPos = targetHistory.shift();
  1092. + transform.position.copy(targetPos);
  1093. + const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
  1094. + aimTowardAndGetDistance(transform, targetHistory[0], deltaTurnSpeed);
  1095. + },
  1096. + },
  1097. + }, 'idle');
  1098. + }
  1099. + update() {
  1100. + this.fsm.update();
  1101. + }
  1102. }
  1103. </pre>
  1104. <p>这是一大段代码,但它实现了上面描述的功能。希望你逐步浏览每个状态时会觉得很清晰。</p>
  1105. <p>我们还需要添加一些东西。我们需要让玩家将自己添加到 globals 中,以便动物可以找到它,并且我们需要用玩家的 <code class="notranslate" translate="no">GameObject</code> 来开始康加舞队列。</p>
  1106. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function init() {
  1107. ...
  1108. {
  1109. const gameObject = gameObjectManager.createGameObject(scene, 'player');
  1110. + globals.player = gameObject.addComponent(Player);
  1111. + globals.congaLine = [gameObject];
  1112. }
  1113. }
  1114. </pre>
  1115. <p>我们还需要计算每个模型的大小</p>
  1116. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function prepModelsAndAnimations() {
  1117. + const box = new THREE.Box3();
  1118. + const size = new THREE.Vector3();
  1119. Object.values(models).forEach(model =&gt; {
  1120. + box.setFromObject(model.gltf.scene);
  1121. + box.getSize(size);
  1122. + model.size = size.length();
  1123. const animsByName = {};
  1124. model.gltf.animations.forEach((clip) =&gt; {
  1125. animsByName[clip.name] = clip;
  1126. // 这个应该在 .blend 文件中修复
  1127. if (clip.name === 'Walk') {
  1128. clip.duration /= 2;
  1129. }
  1130. });
  1131. model.animations = animsByName;
  1132. });
  1133. }
  1134. </pre>
  1135. <p>我们还需要让玩家记录自己的大小</p>
  1136. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class Player extends Component {
  1137. constructor(gameObject) {
  1138. super(gameObject);
  1139. const model = models.knight;
  1140. + globals.playerRadius = model.size / 2;
  1141. </pre>
  1142. <p>现在想想,让动物瞄准康加舞队列的头部而不是特定的玩家可能会更聪明。也许我以后会回来改。</p>
  1143. <p>刚开始时我对所有动物只用一个半径,但这当然不好,因为哈巴狗比马小得多。所以我添加了不同的大小,但我想要能够可视化这些东西。为此我创建了一个 <code class="notranslate" translate="no">StateDisplayHelper</code> 组件。</p>
  1144. <p>它使用 <a href="/docs/#api/en/helpers/PolarGridHelper"><code class="notranslate" translate="no">PolarGridHelper</code></a> 在每个角色周围画一个圆圈,并使用 HTML 元素让每个角色显示一些状态,使用的是<a href="align-html-elements-to-3d.html">将 HTML 元素对齐到 3D 的文章</a>中介绍的技术。</p>
  1145. <p>首先我们需要添加一些 HTML 来承载这些元素</p>
  1146. <pre class="prettyprint showlinemods notranslate lang-html" translate="no">&lt;body&gt;
  1147. &lt;canvas id="c"&gt;&lt;/canvas&gt;
  1148. &lt;div id="ui"&gt;
  1149. &lt;div id="left"&gt;&lt;img src="../resources/images/left.svg"&gt;&lt;/div&gt;
  1150. &lt;div style="flex: 0 0 40px;"&gt;&lt;/div&gt;
  1151. &lt;div id="right"&gt;&lt;img src="../resources/images/right.svg"&gt;&lt;/div&gt;
  1152. &lt;/div&gt;
  1153. &lt;div id="loading"&gt;
  1154. &lt;div&gt;
  1155. &lt;div&gt;...loading...&lt;/div&gt;
  1156. &lt;div class="progress"&gt;&lt;div id="progressbar"&gt;&lt;/div&gt;&lt;/div&gt;
  1157. &lt;/div&gt;
  1158. &lt;/div&gt;
  1159. + &lt;div id="labels"&gt;&lt;/div&gt;
  1160. &lt;/body&gt;
  1161. </pre>
  1162. <p>并添加一些 CSS</p>
  1163. <pre class="prettyprint showlinemods notranslate lang-css" translate="no">#labels {
  1164. position: absolute; /* 让我们可以在容器内定位自己 */
  1165. left: 0; /* 将位置设为容器的左上角 */
  1166. top: 0;
  1167. color: white;
  1168. width: 100%;
  1169. height: 100%;
  1170. overflow: hidden;
  1171. pointer-events: none;
  1172. }
  1173. #labels&gt;div {
  1174. position: absolute; /* 让我们可以在容器内定位它们 */
  1175. left: 0; /* 将它们的默认位置设为容器的左上角 */
  1176. top: 0;
  1177. font-size: large;
  1178. font-family: monospace;
  1179. user-select: none; /* 禁止文本被选中 */
  1180. text-shadow: /* 创建黑色描边 */
  1181. -1px -1px 0 #000,
  1182. 0 -1px 0 #000,
  1183. 1px -1px 0 #000,
  1184. 1px 0 0 #000,
  1185. 1px 1px 0 #000,
  1186. 0 1px 0 #000,
  1187. -1px 1px 0 #000,
  1188. -1px 0 0 #000;
  1189. }
  1190. </pre>
  1191. <p>然后这是组件</p>
  1192. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const labelContainerElem = document.querySelector('#labels');
  1193. class StateDisplayHelper extends Component {
  1194. constructor(gameObject, size) {
  1195. super(gameObject);
  1196. this.elem = document.createElement('div');
  1197. labelContainerElem.appendChild(this.elem);
  1198. this.pos = new THREE.Vector3();
  1199. this.helper = new THREE.PolarGridHelper(size / 2, 1, 1, 16);
  1200. gameObject.transform.add(this.helper);
  1201. }
  1202. setState(s) {
  1203. this.elem.textContent = s;
  1204. }
  1205. setColor(cssColor) {
  1206. this.elem.style.color = cssColor;
  1207. this.helper.material.color.set(cssColor);
  1208. }
  1209. update() {
  1210. const {pos} = this;
  1211. const {transform} = this.gameObject;
  1212. const {canvas} = globals;
  1213. pos.copy(transform.position);
  1214. // 获取该位置的归一化屏幕坐标
  1215. // x 和 y 的范围在 -1 到 +1 之间,x = -1 在左边
  1216. // y = -1 在底部
  1217. pos.project(globals.camera);
  1218. // 将归一化位置转换为 CSS 坐标
  1219. const x = (pos.x * .5 + .5) * canvas.clientWidth;
  1220. const y = (pos.y * -.5 + .5) * canvas.clientHeight;
  1221. // 将元素移动到该位置
  1222. this.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
  1223. }
  1224. }
  1225. </pre>
  1226. <p>然后我们可以这样将它们添加到动物上</p>
  1227. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class Animal extends Component {
  1228. constructor(gameObject, model) {
  1229. super(gameObject);
  1230. + this.helper = gameObject.addComponent(StateDisplayHelper, model.size);
  1231. ...
  1232. }
  1233. update() {
  1234. this.fsm.update();
  1235. + const dir = THREE.MathUtils.radToDeg(this.gameObject.transform.rotation.y);
  1236. + this.helper.setState(`${this.fsm.state}:${dir.toFixed(0)}`);
  1237. }
  1238. }
  1239. </pre>
  1240. <p>趁此机会让我们也实现用 lil-gui 来开关它们,就像我们在其他地方使用的那样</p>
  1241. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">import * as THREE from 'three';
  1242. import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
  1243. import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
  1244. import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
  1245. +import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
  1246. </pre>
  1247. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+const gui = new GUI();
  1248. +gui.add(globals, 'debug').onChange(showHideDebugInfo);
  1249. +showHideDebugInfo();
  1250. const labelContainerElem = document.querySelector('#labels');
  1251. +function showHideDebugInfo() {
  1252. + labelContainerElem.style.display = globals.debug ? '' : 'none';
  1253. +}
  1254. +showHideDebugInfo();
  1255. class StateDisplayHelper extends Component {
  1256. ...
  1257. update() {
  1258. + this.helper.visible = globals.debug;
  1259. + if (!globals.debug) {
  1260. + return;
  1261. + }
  1262. ...
  1263. }
  1264. }
  1265. </pre>
  1266. <p>这样我们就有了一个游戏的雏形</p>
  1267. <p></p><div translate="no" class="threejs_example_container notranslate">
  1268. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/game-conga-line.html"></iframe></div>
  1269. <a class="threejs_center" href="/manual/examples/game-conga-line.html" target="_blank">点击此处在新标签页中打开</a>
  1270. </div>
  1271. <p></p>
  1272. <p>最初我打算做一个<a href="https://www.google.com/search?q=snake+game">贪吃蛇游戏</a>,随着你将动物添加到队列中,游戏会变得更难,因为你需要避免撞到它们。我还会在场景中放置一些障碍物,也许还有围栏或围绕周边的某种屏障。</p>
  1273. <p>不幸的是,这些动物又长又细。从上面看,这是斑马。</p>
  1274. <div class="threejs_center"><img src="../resources/images/zebra.png" style="width: 113px;"></div>
  1275. <p>目前的代码使用圆形碰撞,这意味着如果我们有像围栏这样的障碍物,那么这将被视为碰撞</p>
  1276. <div class="threejs_center"><img src="../resources/images/zebra-collisions.svg" style="width: 400px;"></div>
  1277. <p>这不行。即使是动物与动物之间也会有同样的问题。</p>
  1278. <p>我考虑过写一个 2D 矩形对矩形的碰撞系统,但很快意识到这可能需要很多代码。检查两个任意方向的矩形是否重叠本身代码量不大,对于只有少量对象的游戏可能够用,但当对象多了之后你很快就需要优化碰撞检测。首先你可能需要遍历所有可能相互碰撞的对象,检查它们的包围球、包围圆或轴对齐包围盒。一旦你知道哪些对象<em>可能</em>碰撞,你还需要做更多工作来检查它们是否<em>实际</em>碰撞了。通常即使检查包围球也太费劲,你需要某种更好的空间结构来更快地只检查可能彼此靠近的对象。</p>
  1279. <p>然后,一旦你写了检查两个对象是否碰撞的代码,你通常想要做一个碰撞系统,而不是手动询问"我是否与这些对象碰撞"。碰撞系统会发出事件或调用与碰撞相关的回调。优势在于它可以一次检查所有碰撞,这样没有对象会被检查多次,而如果你手动调用某个"我是否碰撞"的函数,对象往往会被多次检查,浪费时间。</p>
  1280. <p>制作这样的碰撞系统可能只需要 100-300 行代码来检查任意方向的矩形,但这仍然是很多额外的代码,所以最好先不做。</p>
  1281. <p>另一个解决方案是尝试找一些从顶部看大致是圆形的其他角色。例如其他人形角色而不是动物,这样圆形检测可能适用于动物之间的碰撞。但对于动物与围栏之间则不行,我们必须添加圆形对矩形的检测。我考虑过把围栏做成灌木丛或柱子,圆形的东西,但那样我可能需要 120 到 200 个来围绕游戏区域,这就会遇到上面提到的优化问题。</p>
  1282. <p>这就是为什么很多游戏使用现有的解决方案。这些解决方案通常是物理库的一部分。物理库需要知道对象是否相互碰撞,所以在提供物理效果的基础上还可以用来检测碰撞。</p>
  1283. <p>如果你在寻找解决方案,一些 three.js 示例使用了 <a href="https://github.com/kripken/ammo.js/">ammo.js</a>,这可能是一个选择。</p>
  1284. <p>另一个解决方案可能是将障碍物放在网格上,让每个动物和玩家只需要查看网格。虽然这样性能会很好,但我觉得这最好留作读者的练习 😜</p>
  1285. <p>还有一件事,很多游戏系统有一种叫做<a href="https://www.google.com/search?q=coroutines"><em>协程</em></a>(coroutines)的东西。协程是可以在运行时暂停并在之后继续的例程。</p>
  1286. <p>让我们让主角发出音符,就像它在通过唱歌带领队伍一样。我们有很多方式可以实现这个,但现在让我们用协程来做。</p>
  1287. <p>首先,这是一个管理协程的类</p>
  1288. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function* waitSeconds(duration) {
  1289. while (duration &gt; 0) {
  1290. duration -= globals.deltaTime;
  1291. yield;
  1292. }
  1293. }
  1294. class CoroutineRunner {
  1295. constructor() {
  1296. this.generatorStacks = [];
  1297. this.addQueue = [];
  1298. this.removeQueue = new Set();
  1299. }
  1300. isBusy() {
  1301. return this.addQueue.length + this.generatorStacks.length &gt; 0;
  1302. }
  1303. add(generator, delay = 0) {
  1304. const genStack = [generator];
  1305. if (delay) {
  1306. genStack.push(waitSeconds(delay));
  1307. }
  1308. this.addQueue.push(genStack);
  1309. }
  1310. remove(generator) {
  1311. this.removeQueue.add(generator);
  1312. }
  1313. update() {
  1314. this._addQueued();
  1315. this._removeQueued();
  1316. for (const genStack of this.generatorStacks) {
  1317. const main = genStack[0];
  1318. // 处理一个协程移除另一个协程的情况
  1319. if (this.removeQueue.has(main)) {
  1320. continue;
  1321. }
  1322. while (genStack.length) {
  1323. const topGen = genStack[genStack.length - 1];
  1324. const {value, done} = topGen.next();
  1325. if (done) {
  1326. if (genStack.length === 1) {
  1327. this.removeQueue.add(topGen);
  1328. break;
  1329. }
  1330. genStack.pop();
  1331. } else if (value) {
  1332. genStack.push(value);
  1333. } else {
  1334. break;
  1335. }
  1336. }
  1337. }
  1338. this._removeQueued();
  1339. }
  1340. _addQueued() {
  1341. if (this.addQueue.length) {
  1342. this.generatorStacks.splice(this.generatorStacks.length, 0, ...this.addQueue);
  1343. this.addQueue = [];
  1344. }
  1345. }
  1346. _removeQueued() {
  1347. if (this.removeQueue.size) {
  1348. this.generatorStacks = this.generatorStacks.filter(genStack =&gt; !this.removeQueue.has(genStack[0]));
  1349. this.removeQueue.clear();
  1350. }
  1351. }
  1352. }
  1353. </pre>
  1354. <p>它和 <code class="notranslate" translate="no">SafeArray</code> 做了类似的事情,确保在其他协程运行时添加或移除协程是安全的。它还处理嵌套协程。</p>
  1355. <p>要创建协程,你需要创建一个 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*">JavaScript 生成器函数</a>。生成器函数前面有关键字 <code class="notranslate" translate="no">function*</code>(星号很重要!)</p>
  1356. <p>生成器函数可以 <code class="notranslate" translate="no">yield</code>。例如</p>
  1357. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function* count0To9() {
  1358. for (let i = 0; i &lt; 10; ++i) {
  1359. console.log(i);
  1360. yield;
  1361. }
  1362. }
  1363. </pre>
  1364. <p>如果我们将这个函数添加到上面的 <code class="notranslate" translate="no">CoroutineRunner</code> 中,它会每帧打印一个数字(0 到 9),或者更准确地说是每次调用 <code class="notranslate" translate="no">runner.update</code> 时打印一个。</p>
  1365. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const runner = new CoroutineRunner();
  1366. runner.add(count0To9);
  1367. while(runner.isBusy()) {
  1368. runner.update();
  1369. }
  1370. </pre>
  1371. <p>协程在完成时会自动被移除。要提前移除一个协程,在它结束之前你需要保持对其生成器的引用,像这样</p>
  1372. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const gen = count0To9();
  1373. runner.add(gen);
  1374. // 稍后某个时候
  1375. runner.remove(gen);
  1376. </pre>
  1377. <p>无论如何,在玩家中让我们使用协程每隔 0.5 到 1 秒发出一个音符</p>
  1378. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class Player extends Component {
  1379. constructor(gameObject) {
  1380. ...
  1381. + this.runner = new CoroutineRunner();
  1382. +
  1383. + function* emitNotes() {
  1384. + for (;;) {
  1385. + yield waitSeconds(rand(0.5, 1));
  1386. + const noteGO = gameObjectManager.createGameObject(scene, 'note');
  1387. + noteGO.transform.position.copy(gameObject.transform.position);
  1388. + noteGO.transform.position.y += 5;
  1389. + noteGO.addComponent(Note);
  1390. + }
  1391. + }
  1392. +
  1393. + this.runner.add(emitNotes());
  1394. }
  1395. update() {
  1396. + this.runner.update();
  1397. ...
  1398. }
  1399. }
  1400. function rand(min, max) {
  1401. if (max === undefined) {
  1402. max = min;
  1403. min = 0;
  1404. }
  1405. return Math.random() * (max - min) + min;
  1406. }
  1407. </pre>
  1408. <p>你可以看到我们创建了一个 <code class="notranslate" translate="no">CoroutineRunner</code> 并添加了一个 <code class="notranslate" translate="no">emitNotes</code> 协程。这个函数会永远运行,等待 0.5 到 1 秒然后创建一个带有 <code class="notranslate" translate="no">Note</code> 组件的游戏对象。</p>
  1409. <p>对于 <code class="notranslate" translate="no">Note</code> 组件,首先让我们制作一个带有音符的纹理,我们不加载音符图片,而是像<a href="canvas-textures.html">画布纹理文章</a>中介绍的那样使用画布来制作。</p>
  1410. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function makeTextTexture(str) {
  1411. const ctx = document.createElement('canvas').getContext('2d');
  1412. ctx.canvas.width = 64;
  1413. ctx.canvas.height = 64;
  1414. ctx.font = '60px sans-serif';
  1415. ctx.textAlign = 'center';
  1416. ctx.textBaseline = 'middle';
  1417. ctx.fillStyle = '#FFF';
  1418. ctx.fillText(str, ctx.canvas.width / 2, ctx.canvas.height / 2);
  1419. return new THREE.CanvasTexture(ctx.canvas);
  1420. }
  1421. const noteTexture = makeTextTexture('♪');
  1422. </pre>
  1423. <p>我们创建的纹理是白色的,这意味着使用时我们可以设置材质的颜色来获得任意颜色的音符。</p>
  1424. <p>现在我们有了 noteTexture,这是 <code class="notranslate" translate="no">Note</code> 组件。它使用了 <a href="/docs/#api/en/materials/SpriteMaterial"><code class="notranslate" translate="no">SpriteMaterial</code></a> 和 <a href="/docs/#api/en/objects/Sprite"><code class="notranslate" translate="no">Sprite</code></a>,就像我们在<a href="billboards.html">广告牌文章</a>中介绍的那样</p>
  1425. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class Note extends Component {
  1426. constructor(gameObject) {
  1427. super(gameObject);
  1428. const {transform} = gameObject;
  1429. const noteMaterial = new THREE.SpriteMaterial({
  1430. color: new THREE.Color().setHSL(rand(1), 1, 0.5),
  1431. map: noteTexture,
  1432. side: THREE.DoubleSide,
  1433. transparent: true,
  1434. });
  1435. const note = new THREE.Sprite(noteMaterial);
  1436. note.scale.setScalar(3);
  1437. transform.add(note);
  1438. this.runner = new CoroutineRunner();
  1439. const direction = new THREE.Vector3(rand(-0.2, 0.2), 1, rand(-0.2, 0.2));
  1440. function* moveAndRemove() {
  1441. for (let i = 0; i &lt; 60; ++i) {
  1442. transform.translateOnAxis(direction, globals.deltaTime * 10);
  1443. noteMaterial.opacity = 1 - (i / 60);
  1444. yield;
  1445. }
  1446. transform.parent.remove(transform);
  1447. gameObjectManager.removeGameObject(gameObject);
  1448. }
  1449. this.runner.add(moveAndRemove());
  1450. }
  1451. update() {
  1452. this.runner.update();
  1453. }
  1454. }
  1455. </pre>
  1456. <p>它所做的就是设置一个 <a href="/docs/#api/en/objects/Sprite"><code class="notranslate" translate="no">Sprite</code></a>,然后选择一个随机速度,以该速度移动变换 60 帧,同时通过设置材质的 <a href="/docs/#api/en/materials/Material#opacity"><code class="notranslate" translate="no">opacity</code></a> 使音符淡出。循环结束后,它将变换从场景中移除,并将音符本身从活动游戏对象中移除。</p>
  1457. <p>最后一件事,让我们添加更多动物</p>
  1458. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function init() {
  1459. ...
  1460. const animalModelNames = [
  1461. 'pig',
  1462. 'cow',
  1463. 'llama',
  1464. 'pug',
  1465. 'sheep',
  1466. 'zebra',
  1467. 'horse',
  1468. ];
  1469. + const base = new THREE.Object3D();
  1470. + const offset = new THREE.Object3D();
  1471. + base.add(offset);
  1472. +
  1473. + // 将动物排列成螺旋形
  1474. + const numAnimals = 28;
  1475. + const arc = 10;
  1476. + const b = 10 / (2 * Math.PI);
  1477. + let r = 10;
  1478. + let phi = r / b;
  1479. + for (let i = 0; i &lt; numAnimals; ++i) {
  1480. + const name = animalModelNames[rand(animalModelNames.length) | 0];
  1481. const gameObject = gameObjectManager.createGameObject(scene, name);
  1482. gameObject.addComponent(Animal, models[name]);
  1483. + base.rotation.y = phi;
  1484. + offset.position.x = r;
  1485. + offset.updateWorldMatrix(true, false);
  1486. + offset.getWorldPosition(gameObject.transform.position);
  1487. + phi += arc / r;
  1488. + r = b * phi;
  1489. }
  1490. </pre>
  1491. <p></p><div translate="no" class="threejs_example_container notranslate">
  1492. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/game-conga-line-w-notes.html"></iframe></div>
  1493. <a class="threejs_center" href="/manual/examples/game-conga-line-w-notes.html" target="_blank">点击此处在新标签页中打开</a>
  1494. </div>
  1495. <p></p>
  1496. <p>你可能会问,为什么不用 <code class="notranslate" translate="no">setTimeout</code>?<code class="notranslate" translate="no">setTimeout</code> 的问题是它与游戏时钟无关。例如上面我们将帧之间允许的最大时间设为 1/20 秒。我们的协程系统会遵守这个限制,但 <code class="notranslate" translate="no">setTimeout</code> 不会。</p>
  1497. <p>当然我们可以自己做一个简单的计时器</p>
  1498. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class Player ... {
  1499. update() {
  1500. this.noteTimer -= globals.deltaTime;
  1501. if (this.noteTimer &lt;= 0) {
  1502. // 重置计时器
  1503. this.noteTimer = rand(0.5, 1);
  1504. // 创建一个带有音符组件的游戏对象
  1505. }
  1506. }
  1507. </pre>
  1508. <p>对于这个特定情况这可能更好,但随着你添加越来越多的东西,你的类中会添加越来越多的变量,而使用协程你通常可以<em>触发后就不用管了</em>。</p>
  1509. <p>鉴于我们动物的简单状态,我们也可以用以下形式的协程来实现它们</p>
  1510. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">// 伪代码!
  1511. function* animalCoroutine() {
  1512. setAnimation('Idle');
  1513. while(playerIsTooFar()) {
  1514. yield;
  1515. }
  1516. const target = endOfLine;
  1517. setAnimation('Jump');
  1518. while(targetIsTooFar()) {
  1519. aimAt(target);
  1520. yield;
  1521. }
  1522. setAnimation('Walk')
  1523. while(notAtOldestPositionOfTarget()) {
  1524. addHistory();
  1525. aimAt(target);
  1526. yield;
  1527. }
  1528. for(;;) {
  1529. addHistory();
  1530. const pos = history.unshift();
  1531. transform.position.copy(pos);
  1532. aimAt(history[0]);
  1533. yield;
  1534. }
  1535. }
  1536. </pre>
  1537. <p>这样做是可行的,但当然一旦我们的状态不再是线性的,我们就不得不切换到 <code class="notranslate" translate="no">FiniteStateMachine</code>。</p>
  1538. <p>我也不确定协程是否应该独立于它们的组件运行。我们可以创建一个全局的 <code class="notranslate" translate="no">CoroutineRunner</code> 并将所有协程放在上面。但这会使清理变得更难。目前如果游戏对象被移除,它的所有组件都会被移除,因此创建的协程运行器不再被调用,一切都会被垃圾回收。如果我们有一个全局运行器,那么每个组件都有责任移除它添加的任何协程,否则需要某种其他机制将协程注册到特定组件或游戏对象,以便移除一个时也移除其他的。</p>
  1539. <p>一个正常的游戏引擎会处理更多问题。目前游戏对象或其组件的运行没有顺序。它们只是按添加顺序运行。许多游戏系统会添加优先级,以便可以设置或更改顺序。</p>
  1540. <p>我们遇到的另一个问题是 <code class="notranslate" translate="no">Note</code> 从场景中移除其游戏对象的变换。这似乎应该在 <code class="notranslate" translate="no">GameObject</code> 中发生,因为最初是 <code class="notranslate" translate="no">GameObject</code> 添加的变换。也许 <code class="notranslate" translate="no">GameObject</code> 应该有一个 <code class="notranslate" translate="no">dispose</code> 方法,由 <code class="notranslate" translate="no">GameObjectManager.removeGameObject</code> 调用?</p>
  1541. <p>还有一个问题是我们手动调用 <code class="notranslate" translate="no">gameObjectManager.update</code> 和 <code class="notranslate" translate="no">inputManager.update</code>。也许应该有一个 <code class="notranslate" translate="no">SystemManager</code>,这些全局服务可以将自己添加进去,每个服务的 <code class="notranslate" translate="no">update</code> 函数都会被调用。这样如果我们添加了像 <code class="notranslate" translate="no">CollisionManager</code> 这样的新服务,我们只需要将它添加到系统管理器中,而不必编辑渲染循环。</p>
  1542. <p>我会把这些问题留给你。希望这篇文章给了你一些关于制作自己游戏引擎的思路。</p>
  1543. <p>也许我应该搞一个 Game Jam。如果你点击最后一个示例上方的 <em>jsfiddle</em> 或 <em>codepen</em> 按钮,它们会在这些网站上打开,准备好编辑。添加一些功能,把游戏改成一只哈巴狗带领一群骑士。用骑士的翻滚动画做保龄球,制作一个动物保龄球游戏。制作一个动物接力赛。如果你做出了很酷的游戏,请在下面的评论中发布链接。</p>
  1544. <div class="footnotes">
  1545. [<a id="parented">1</a>]: 从技术上讲,如果所有父对象都没有任何平移、旋转或缩放,它仍然可以工作 <a href="#parented-backref">§</a>。
  1546. </div>
  1547. </div>
  1548. </div>
  1549. </div>
  1550. <script src="../resources/prettify.js"></script>
  1551. <script src="../resources/lesson.js"></script>
  1552. </body></html>
粤ICP备19079148号