Углубленный анализ, используйте Threejs для копирования WeChat и перехода (1)

three.js
Углубленный анализ, используйте Threejs для копирования WeChat и перехода (1)

все главы

предисловие

После трех лет фронтенд-работы я поработал в нескольких небольших компаниях и сделал несколько небольших проектов. За последние три года мой менталитет был немного порывистым, и мои бывшие друзья уже выучились на боссов, но я оставался на одном месте очень, очень долго. Поскольку я ушел в отставку некоторое время назад, я собираюсь попытаться исправить это в последнее время, из-за чего день и ночь поменялись местами, и я не могу отличить день от ночи.насильственно внушатьвсегда такой скучный, и мойпроклятая памятьСлишком неудовлетворительно, левое полушарие включается, а правое отключается, поэтому найдите что-нибудь интересное (скопируйте небольшую игру), чтобы найти для себя стимул!

Способность к выражению ограничена, а письмо плохое. Если много неправильных предложений, пожалуйста, пишите много...  

Поскольку эта статья толькопытатьсяУглубленный взгляд на WeChat hopкопировать, есть еще большой разрыв с оригинальной игрой, и threejs используется впервые, поэтому этот анализ является лишь простым руководством, я надеюсь, что он может быть вам полезен, если что-то не так, читатели могут играть свободно.


Предупреждение: длинное текстовое предупреждение длиной 10 000 символов! ! !

Исходный код этой главы был выпущенgithub,Вот пример,Это полуфабрикат, он еще не написан, полная версия выйдет через несколько дней.

Предварительное знание

прыжок в чате, когда эта игра только вышла, я в свободное время написал очень простую версию. Я думал, что за ней будет легко следить, но можно не сомневаться, что это были просто волны без малейших взлетов и падений. Эта переделка заставила меня шагнуть на нем несколько раз.Яма плюс. С виду спокойная вода, если не заходить в воду, то не знаешь, сколько подводных течений бушует под водой.

Прежде чем рассматривать конкретную реализацию, мы должны сначала понять часть знаний о threejs, связанных с этой игрой.

Один, три или три компонента

  1. Место действия

    const scene = new THREE.Scene()
    // 坐标辅助线,在调试阶段非常好用
    scene.add(new THREE.AxesHelper(10e3))
    
  2. Камера, здесь основное внимание уделяется орфографической камере, которую будет использовать игровая реализация.

    • Орфографическая камера

      const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far)
      // 将正交相机放入场景中
      scene.add(camera)
      

      Размер объекта, видимого орфографической камерой, не имеет ничего общего с расстоянием между ним и объектом. Например, диапазон вашего фиксированного поля зрения составляет соотношение сторон: 200x320, и вы можете видеть объекты в пределах 1000 метров в максимальном удалении и объекты за 1 метр самое позднее, тогда:

      const camera = new THREE.OrthographicCamera(-200 / 2, 200 / 2, 320 / 2, -320 / 2, 1, 1000)
      
    • У нас нет перспективной камеры

  3. Рендерер

    const renderer = new THREE.WebGLRenderer({
        antialias: true // 抗锯齿
    })
    
    // 具体渲染
    renderer.render(scene, camera)
    

2. Создайте объекты

Во-первых, объект какой формы вам нужен?几何形状(Geometry), как выглядит объект?材质(Material), затем создайте его网格(Mesh). Вам нужно видеть объекты?灯光(Light)

Тень предметов

  1. Объекты, которые получают тени, например, создание земли для получения теней.

    const geometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
    const meterial = new THREE.MeshLambertMaterial()
    
    const plane = new THREE.Mesh(geometry, meterial)
    // 接收阴影
    plane.receiveShadow = true
    
  2. Объект включает проекцию

    // 创建一个立方体
    const geometry = new THREE.BoxBufferGeometry()
    const meterial = new THREE.MeshLambertMaterial()
    
    const box = new THREE.Mesh(geometry, meterial)
    // 投射我的影子
    box.castShadow = true
    // 别人的影子也可以落在我身上
    box.receiveShadow = true
    
  3. Свет включает тени

    // 平行光
    const lightght = new THREE.DirectionalLight(0xffffff, .8)
    // 投射阴影
    light.castShadow = true
    // 定义可见域的投射阴影
    light.shadow.camera.left = -400
    light.shadow.camera.right = 400
    light.shadow.camera.top = 400
    light.shadow.camera.bottom = -400
    light.shadow.camera.near = 0
    light.shadow.camera.far = 1000
    
  4. В сцене также должны быть включены тени.

    const const renderer = new THREE.WebGLRenderer({ ... })
    renderer.shadowMap.enabled = true
    

В-четвертых, происхождение преобразования threejs

Происхождение вращения и масштаба网格(Mesh)В центре нарисуйте картинку, чтобы описать:

То есть по смещению几何形状(Geometry)Кроме того, для достижения цели управления началом масштабирования также есть три js组(Group), то если вы выполняете операцию масштабирования объекта в группе, соответствующая точка должна управлять исходной точкой масштабирования объекта, управляя положением объекта в группе.

Пять, тройная оптимизация

  • Используйте BufferGeometry вместо Geometry, BufferGeometry кэширует модель сетки, и производительность становится более эффективной.
  • Используйте метод клонирования ()
    // 创建一个立方体,大小默认为 1,1,1
    const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()
    // 克隆几何体
    const geometry = baseBoxBufferGeometry.clone()
    // 通过缩放设置几何体的大小
    geometry.scale(20, 20, 20)
    
  • Объекты, которые больше не нужны, должны быть уничтоженыdispose

Начните анализировать первый шаг

Поскольку мы хотим проанализировать, как начать, нам нужно сначала вынуть мобильный телефон, положитьпрыжок в чатеСначала ознакомьтесь с местностью

Для такой ситуации, когда мы не знаем, с чего начать, мы должны сначала найти точку входа (например, что нужно сделать в первую очередь), а затем расширять слой за слоем в соответствии с этой точкой входа, пока не исчезнет завеса игры. поднимается, что несколько похоже на мир программирования, слово, которое часто встречается面向过程式, не очень высокий, но очень практичный.

  • Сначала мы должны создать сцену, затем создать коробку в сцене, и я хочу посмотреть на коробку с той же точки зрения, что и прыжки в чате.
  • Быть обнаруженным...

создание сцены

Создание сцены очень простое, то есть три компонента threejs. Важно отметить, насколько велика сцена? На самом деле я не знаю...

Откройте WeChat и потанцуйте несколько раз... Не забывайте внимательно наблюдать! ! !

Определить размер сцены

На самом деле, невооруженным глазом невозможно определить, насколько велика сцена, но можно определить, какую камеру следует использовать в сцене. Правильно, ортогональная камера, интерфейс, который прыгает с WeChat, должен четко чувствовать, что размер объекта никак не связан с расстоянием, вот2 изображенияНаглядно показывает разницу между ортогональной и перспективной камерами.

Решение очевидно: нам нужно только определить размер сцены самостоятельно, а затем установить размер объектов внутри в подходящем диапазоне относительно размера сцены.canvasШирина и высота немного похожи на визуальное окно просмотра, размер сцены немного похож на окно просмотра макета, а затем окно просмотра макета масштабируется до размера визуального окна просмотра. Предполагая, что ширина объекта на рисунке составляет половину ширины сцены, если я установлю ширину сцены равной1000, затем, когда я рисую объект, я устанавливаю ширину500Это нормально, или вы можете указать другие размеры. Учитывая, что WeChat jump является полноэкранным и адаптируется к различным мобильным телефонам, мы используемinnerWidth、innerHeightУстановите размер сцены.

Создать камеру

Поскольку используется орфографическая камера, определена размер сцены, то есть ширина и высота просмотра ортографической камеры определены, а затем ближневые и дальние поверхности являются разумными, в зависимости от камеры угол. Мы создаем камеру и устанавливаем положение камеры в-100,100,-100,ПозволятьX轴а такжеZ轴Перед нами причина в том, что нам не нужно использовать отрицательные координаты при дальнейшем движении (направление вспомогательной линии положительное)

  const { innerWidth, innerHeight } = window
  /**
   * 场景
   */
  const scene = new THREE.Scene()
  // 场景背景,用于调试
  scene.background = new THREE.Color( 0xf5f5f5 )
  // 坐标辅助线,在调试阶段非常好用
  scene.add(new THREE.AxesHelper(10e3))

  /**
   * 相机
   */
  const camera = new THREE.OrthographicCamera(-innerWidth / 2, innerWidth / 2, innerHeight / 2, -innerHeight / 2, 0.1, 1000)
  camera.position.set(-100, 100, -100)
  // 看向场景中心点
  camera.lookAt(scene.position)
  scene.add(camera)

  /**
   * 盒子
   */
  const boxGeometry = new THREE.BoxBufferGeometry(100, 50, 100)
  const boxMaterial = new THREE.MeshLambertMaterial({ color: 0x67C23A })
  const box = new THREE.Mesh(boxGeometry, boxMaterial)
  scene.add(box)

  /**
   * 渲染器
   */
  const canvas = document.querySelector('#canvas')
  const renderer = new THREE.WebGLRenderer({
      canvas,
      alpha: true, // 透明场景
      antialias:true // 抗锯齿
  })
  renderer.setSize(innerWidth, innerHeight)

  // 渲染
  renderer.render(scene, camera)

Эти процессы относительно просты, ничего сложного, но обнаружил, что коробка чисто черная, можно увидеть только небольшой контур, это потому, что свет не светит, теперь дайте ему немного света...

/**
 * 平行光
 */
const light = new THREE.DirectionalLight(0xffffff, .8)
light.position.set(-200, 600, 300)
// 环境光
scene.add(new THREE.AmbientLight(0xffffff, .4))
scene.add(light)

Теперь, когда мы видим цвет коробки и соответствующий контур, наш первый шаг сделан, хе-хе. Но чего не хватает?

Откройте WeChat, попрыгайте и подумайте об этом...

После прочтения, где тень коробки?

показать тень

В соответствии с необходимыми условиями для теней нам сначала нужно создать землю для получения теней от таких предметов, как коробки, Эта земля служит приемником теней для всех объектов во всей игре.

const planeGeometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
const planeMeterial = new THREE.MeshLambertMaterial({ color: 0xffffff })

const plane = new THREE.Mesh(planeGeometry, planeMeterial)
plane.rotation.x = -.5 * Math.PI
plane.position.y = -.1
// 接收阴影
plane.receiveShadow = true
scene.add(plane)

в то же время

// 让物体投射阴影
box.castShadow = true

// 让平行光投射阴影
light.castShadow = true
// 定义可见域的投射阴影
light.shadow.camera.left = -400
light.shadow.camera.right = 400
light.shadow.camera.top = 400
light.shadow.camera.bottom = -400
light.shadow.camera.near = 0
light.shadow.camera.far = 1000
// 定义阴影的分辨率
light.shadow.mapSize.width = 1600
light.shadow.mapSize.height = 1600

// 场景开启阴影
renderer.shadowMap.enabled = true

хорошо, тень появляется. Однако можно обнаружить, что белый фон не полностью заполняет видимую область камеры, выставляя сцену за пределы земли, что определенно недопустимо. Желаемый эффект должен заключаться в том, что земля покрывает всю видимую область, почему это происходит? (даже если земля в это время очень большая)


Когда писал позже, то случайно увидел в документе threejs материал тени (ShadowMaterial), на который можно заменить материал земли, а затем установить цвет фона для сцены.


Определяем положение камеры

Вертикальный разрез с правой стороны камеры, можно обнаружить, что когда мы фиксируем вертикальный угол с центральной точкой сцены∠aКогда расстояние между камерой и землейyограничено по объему, когдаyменьше, чемminY, появится пустая область под видимой областью, больше, чемmaxYВверху появится пустая область. На данный момент мы можем решить эту пустую проблему, отрегулировав расстояние камеры.

В то же время легко увидетьminYв состоянии пройти∠aРассчитывается от высоты нижней стороны ортогональной камеры

const computeCameraMinY = (radian, bottom) => Math.cos(radian) * bottom

дляmaxY, вы можете сначала рассчитать вертикальное расстояние от центральной точки сцены до дальней части усеченного обзора, а затем вы можете получить расстояние от ближней части до центральной точки сцены, и вы можете вычислить максимальноеmaxYохватывать

const computeCameraMaxY = (radian, top, near, far) => {
    const farDistance = top / Math.tan(radian)
    const nearDistance = far - near - farDistance
    return Math.sin(radian) * nearDistance
}

Когда вертикальный угол зафиксирован, камераyДиапазон значений определен, так есть ли предел диапазона в горизонтальном направлении? Как видно из приведенного выше рисунка, покаyЗначения нормальные, горизонтальные координатыxа такжеzдолжно бытьyопределяется углом между горизонтальными направлениями.

Следовательно, нам также необходимо определить угол в горизонтальном направлении.X轴для его определения фиксированный горизонтальный угол равен∠b

Для простоты понимания приведенный выше рисунок225степень нарисована. Сейчас:

  • известный∠a,Рассчитатьy(может принимать значение в диапазоне)
  • известный∠b,Рассчитатьx,z
/**
 * 根据角度计算相机初始位置
 * @param {Number} verticalDeg 相机和场景中心点的垂直角度
 * @param {Number} horizontalDeg 相机和x轴的水平角度
 * @param {Number} top 相机上侧面
 * @param {Number} bottom 相机下侧面
 * @param {Number} near 摄像机视锥体近端面
 * @param {Number} far 摄像机视锥体远端面
 */
export function computeCameraInitalPosition (verticalDeg, horizontalDeg, top, bottom, near, far) {
  const verticalRadian = verticalDeg * (Math.PI / 180)
  const horizontalRadian = horizontalDeg * (Math.PI / 180)
  const minY = Math.cos(verticalRadian) * bottom
  const maxY = Math.sin(verticalRadian) * (far - near - top / Math.tan(verticalRadian))
  
  if (minY > maxY) {
    console.warn('警告: 垂直角度太小了!')
  }
  // 取一个中间值靠谱
  const y = minY + (maxY - minY) / 2
  const longEdge = y / Math.tan(verticalRadian)
  const x = Math.sin(horizontalRadian) * longEdge
  const z = Math.cos(horizontalRadian) * longEdge

  return { x, y, z }
}

Благодаря заинтересованным друзьям, вы можете попробовать сами, поставить функцию вyустановлен вminY,maxYДля значений вне интервала возникают проблемы, рассмотренные ранее.

Не нужно беспокоиться о диапазоне размеров земли, мы знаем, насколько велик диапазон конуса обзора камеры, поэтому достаточно установить размер земли как можно немного больше.

Теперь земля должна полностью отображаться в визуальной области, а затем провести пальцем и прыгнуть по WeChat, чтобы примерно определить положение камеры в игре.

const { x, y, z } = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, 0.1, 1000)
camera.position.set(x, y, z)

По текущим настройкам будет предупреждать, что вертикальный угол слишком мал.В это время можно увеличить дальний участок камеры по анализу только сейчас.

const camera = new THREE.OrthographicCamera(-innerWidth / 2, innerWidth / 2, offsetHeight / 2, -offsetHeight / 2, 0.1, 2000)
const { x, y, z } = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, 0.1, 2000)
camera.position.set(x, y, z)

Положите коробку на землю

Можно обнаружить, что в это время только половина коробки выставлена ​​на землю, нам нужно положить ее на плоскость земли, потому что, когда в прыжке WeChat генерируется новая коробка, она падает сверху, и там есть объект что отскакивает процесс анимации, то делаем так:

box.translateY(15)

Есть проблема? Это нужно внимательно соблюдать при игре, советую сначала открыть WeChat и прыгнуть...

После внимательного изучения вы обнаружите, что помимо анимации при появлении ящика, есть еще и процесс анимации при зарядке злодея, который представляет собой операцию масштабирования. Затем, в соответствии с четвертым пунктом в предварительных знаниях, нам нужно поместить начало масштабирования окна в нижний центр, поэтому мы имеем:

box.geometry.translate(0, 15, 0)

Теперь, когда ящик стоит на земле, будет гораздо удобнее, когда мы будем писать анимацию и масштабирование приземления ящика.

Определить стиль коробки

Ранее согласованный размер сценыinnerWidth、innerHeight, то для размера окна, чтобы сделать соотношение размеров окна, видимого разными мобильными телефонами, одинаковым, его можно определить в соответствии с размером сцены.В конце концов, это проект копирования, и есть нет проектных спецификаций, поэтому ширина, глубина, высота на наше усмотрение.

В то же время, благодаря опыту и наблюдениям WeChat Jump, некоторые коробки внутри должны быть настроены, а некоторые — случайны, с разными размерами и формами. Тогда мы можем отдать приоритет реализации рандомной части, а затем попробовать поддержать кастомизированную коробку аналогичным настраиваемым способом, ведь это разница во внешнем виде, а игровая логика неизменна.

Поскольку блоки должны генерироваться случайным образом, учитывая, что существует слишком много возможных различий между разными блоками, мы можем абстрагировать только некоторые блоки со сходством из многих блоков, а затем использовать специальную функцию для их генерации, например, реализациюboxCreatorФункция, которая генерирует кубические блоки разных размеров и случайных цветов. Думая об этом, кажется, что мы можем достичь настраиваемых требований, поддерживая коллекцию, в которой хранятся генераторы (то есть функции) различных стилей коробок.Например, более поздним продуктам необходимо добавить форму xxx с прикрепленной рекламой xxx.Коробка, мы можем добавить в этот набор новый генератор реквизита, а стиль этой коробки определяется внешним видом.

Поскольку стиль блока можно определить снаружи, необходима унифицированная спецификация, такая как диапазон ширины, глубины и высоты блока. объект и материал.

// 维护一个道具生成器集合
const boxCreators = []
// 共享立方体
const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()
// 共享材质
const baseMeshLambertMaterial = new THREE.MeshLambertMaterial()
// 随机颜色
const colors = [0x67C23A, 0xE6A23C, 0xF56C6C]
// 盒子大小限制范围
const boxSizeRange = [30, 60]

// 实现一个默认的生成大小不一、颜色随机的立方体盒子的生成器
const defaultBoxCreator = () => {
    const [minSize, maxSize] = boxSizeRange
    const randomSize = ~~(random() * (maxSize - minSize + 1)) + minSize
    const geometry = baseBoxBufferGeometry.clone()
    geometry.scale(randomSize, 30, randomSize)
    
    const randomColor = colors[~~(Math.random() * colors.length)]
    const material = baseMeshLambertMaterial.clone()
    material.setValues({ randomColor })
    
    return new THREE.Mesh(geometry, material)
}

// 将盒子创造起存入管理集合中
boxCreators.push(defaultBoxCreator)

К настоящему времени у нас должен быть прототип идеи того, как реализовать игру, и я думаю, что нужно что-то сделать, прежде чем мы начнем радикально. Если оглянуться на предыдущий код, то он полностью процедурный, без абстракции и модульной концепции. Это может быть очень полезно для нас на ранней стадии, но на более поздней стадии такое мышление может иметь много негативных последствий для без четкой структуры, процесс реализации может быть болью от сноса восточной стены, чтобы построить западную стену. Итак, теперь подумайте, какую оптимизацию нам нужно сделать для этого игрового фреймворка.

Объектно-ориентированное начало

Жизнь это игра, в игре есть ты и я, и в игре есть правила игры.Что может быть более отсылкой, чем реальный мир?

Мы создаем прыгающий игровой мир, который должен поддерживать работу всей игры:

// index.js
class JumpGameWorld {
    constructor () {
        // ...
    }
}

Точно так же, как мы, люди, живем на земле, и земля является для нас большой сценой, где мы можем высвободиться, как не может быть земного носителя для этого прыжка? Создайте сцену прыжковой игры:

// State.js
class Stage {
    constructor () {}
}

На сцене человечек:

// LittleMan.js
class LittleMan {
    constructor () {}
}

Также на сцене есть реквизит (коробки)

// Prop.js
class Prop {
    constructor () {}
}

Пропы имеют свои особенности и не создаются из воздуха, поэтому реализуйте генератор пропов (типа фабрики):

// PropCreator.js
class PropCreator () {
    constructor () {}
}

Кроме того, есть общая геометрия и материал, управление методом инструмента

// utils.js

// 材质
export const baseMeshLambertMaterial = new THREE.MeshLambertMaterial()
// 立方体
export const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()

// ...

Совершенствуйте сцену

Определите структуру игры, затем следуйте этому скелету, чтобы довести его до совершенства, а затем доведите до совершенства логику этапа:

class Stage {
  constructor ({
    width,
    height,
    canvas,
    axesHelper = false, // 辅助线
    cameraNear, // 相机近截面
    cameraFar, // 相机远截面
    cameraInitalPosition, // 相机初始位置
    lightInitalPosition // 光源初始位置
  }) {
    this.width = width
    this.height = height
    this.canvas = canvas
    this.axesHelper = axesHelper
    // 正交相机配置
    this.cameraNear = cameraNear
    this.cameraFar = cameraFar
    this.cameraInitalPosition = cameraInitalPosition
    this.lightInitalPosition = lightInitalPosition
    
    this.scene = null
    this.plane = null
    this.light = null
    this.camera = null
    this.renderer = null

    this.init()
  }

  init () {
    this.createScene()
    this.createPlane()
    this.createLight()
    this.createCamera()
    this.createRenterer()
    this.render()
    this.bindResizeEvent()
  }

  bindResizeEvent () {
    const { container, renderer } = this
    window.addEventListener('resize', () => {
      const { offsetWidth, offsetHeight } = container

      this.width = offsetWidth
      this.height = offsetHeight

      renderer.setSize(offsetWidth, offsetHeight)
      renderer.setPixelRatio(window.devicePixelRatio)
      this.render()
    }, false)
  }

  // 场景
  createScene () {
    const scene = this.scene = new THREE.Scene()

    if (this.axesHelper) {
      scene.add(new THREE.AxesHelper(10e3))
    }
  }

  // 地面
  createPlane () {
    const { scene } = this
    const geometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
    const meterial = new THREE.ShadowMaterial()
    meterial.opacity = 0.5

    const plane = this.plane = new THREE.Mesh(geometry, meterial)

    plane.rotation.x = -.5 * Math.PI
    plane.position.y = -.1
    // 接收阴影
    plane.receiveShadow = true
    scene.add(plane)
  }

  // 光
  createLight () {
    const { scene, lightInitalPosition: { x, y, z }, height } = this
    const light = this.light = new THREE.DirectionalLight(0xffffff, .8)

    light.position.set(x, y, z)
    // 开启阴影投射
    light.castShadow = true
    // // 定义可见域的投射阴影
    light.shadow.camera.left = -height
    light.shadow.camera.right = height
    light.shadow.camera.top = height
    light.shadow.camera.bottom = -height
    light.shadow.camera.near = 0
    light.shadow.camera.far = 2000
    // 定义阴影的分辨率
    light.shadow.mapSize.width = 1600
    light.shadow.mapSize.height = 1600

    // 环境光
    scene.add(new THREE.AmbientLight(0xffffff, .4))
    scene.add(light)
  }

  // 相机
  createCamera () {
    const {
      scene,
      width, height,
      cameraInitalPosition: { x, y, z },
      cameraNear, cameraFar
    } = this
    const camera = this.camera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, cameraNear, cameraFar)

    camera.position.set(x, y, z)
    camera.lookAt(scene.position)
    scene.add(camera)
  }

  // 渲染器
  createRenterer () {
    const { canvas, width, height } = this
    const renderer = this.renderer = new THREE.WebGLRenderer({
      canvas,
      alpha: true, // 透明场景
      antialias:true // 抗锯齿
    })

    renderer.setSize(width, height)
    // 开启阴影
    renderer.shadowMap.enabled = true
    // 设置设备像素比
    renderer.setPixelRatio(window.devicePixelRatio)
  }

  // 执行渲染
  render () {
    const { scene, camera } = this
    this.renderer.render(scene, camera)
  }

  add (...args) {
    return this.scene.add(...args)
  }
  
  remove (...args) {
    return this.scene.remove(...args)
  }
}

Генератор совершенства PropCreator

Ранее мы примерно определили, что нам нужно поддерживать набор генераторов пропов, в коллекции есть генераторы пропов по умолчанию, а настраиваемые генераторы также могут быть добавлены позже. Исходя из этой логикиPropCreatorдолжен предоставитьapiНапримерcreatePropCreatorчтобы добавить генератор, этоapiОн также должен предоставить соответствующие вспомогательные атрибуты, такие как диапазон размеров реквизита, общие материалы и т. д.

Тогда это снаружиapiЧто необходимо учитывать?

  • Вы должны сообщить окружающим, что размер реквизита ограничен, если настроенный реквизит будет очень большим или маленьким, игра не закончится.
  • Подумайте о производительности, предоставьте некоторые общие материалы, геометрию снаружи
  • ......
  /**
   * 新增定制化的生成器
   * @param {Function} creator 生成器函数
   * @param {Boolean} isStatic 是否是动态创建
   */
  createPropCreator (creator, isStatic) {
    if (Array.isArray(creator)) {
      creator.forEach(crt => this.createPropCreator(crt, isStatic))
    }

    const { propCreators, propSizeRange, propHeight } = this

    if (propCreators.indexOf(creator) > -1) {
      return
    }

    const wrappedCreator = function () {
      if (isStatic && wrappedCreator.box) {
        // 静态盒子,下次直接clone
        return wrappedCreator.box.clone()
      } else {
        const box = creator(THREE, {
          propSizeRange,
          propHeight,
          baseMeshLambertMaterial,
          baseBoxBufferGeometry
        })

        if (isStatic) {
          // 被告知是静态盒子,缓存起来
          wrappedCreator.box = box
        }
        return box
      }
    }

    propCreators.push(wrappedCreator)
  }

Если есть генератор только с одним стилем, его нужно будет каждый раз перегенерировать, он поддерживает передачу вisStaticчтобы сообщить генератору, можно ли его кэшировать, чтобы его не нужно было воссоздавать при последующих итерациях.

Далее реализуем встроенный генератор.Для облегчения расширения здесь создается новый файл для поддержанияdefaultProp.js

const colors = [0x67C23A, 0xE6A23C, 0xF56C6C, 0x909399, 0x409EFF, 0xffffff]

// 静态
export const statics = [
  // ...
]

// 非静态
export const actives = [
  // 默认纯色立方体创造器
  function defaultCreator (THREE, helpers) {
    const {
      propSizeRange: [min, max],
      propHeight,
      baseMeshLambertMaterial,
      baseBoxBufferGeometry
    } = helpers

    // 随机颜色
    const color = randomArrayElm(colors)
    // 随机大小
    const size = rangeNumberInclusive(min, max)

    const geometry = baseBoxBufferGeometry.clone()
    geometry.scale(size, propHeight, size)

    const material = baseMeshLambertMaterial.clone()
    material.setValues({ color })

    return new THREE.Mesh(geometry, material)
  },
]

Реализован дефолтный генератор пропов, возможно дефолтный мне не нужен, могу реализовать следующие конфигурируемые:

  constructor ({
    propHeight,
    propSizeRange,
    needDefaultCreator
  }) {
    this.propHeight = propHeight
    this.propSizeRange = propSizeRange

    // 维护的生成器
    this.propCreators = []

    if (needDefaultCreator) {
      this.createPropCreator(actives, false)
      this.createPropCreator(statics, true)
    }
  }

Тогда для внутри игры вам нужно предоставитьapiДавайте случайным образом запустим генератор для создания реквизита.Здесь мы замечаем, что первые 2 поля в каждом начале прыжка WeChat представляют собой стиль (куб), поэтому вы можете выполнять некоторый контроль и поддерживать передачу индекса для создания указанного поля.

  createProp (index) {
    const { propCreators } = this
    return index > -1
      ? propCreators[index] && propCreators[index]() || randomArrayElm(propCreators)()
      : randomArrayElm(propCreators)()
  }

На данный момент генератор реквизита почти такой же, но не относитесь к этому легкомысленно, я думаю, мне нужно подумать об этом при создании серьезного продукта. Например:

  • Контролируйте, как часто и как часто появляются реквизиты
  • Существуют ли разные анимации входа для разных реквизитов?
  • ......

Улучшить класс реквизита Prop

Класс реквизита необходимо постоянно расширять на более позднем этапе.Помимо нескольких основных свойств, есть и другие вещи, которые необходимо расширить в будущем, такие как доступ и вычисление определенных свойств реквизита, а также анимация реквизита.

class Prop {
  constructor ({
    world, // 所处世界
    stage, // 所处舞台
    body, // 主体
    height
  }) {
    this.world = world
    this.stage = stage
    this.body = body
    this.height = height
  }
  
  getPosition () {
    return this.body.position
  }

  setPosition (x, y, z) {
    return this.body.position.set(x, y, z)
  }
}

Инициализируйте генераторы сцены и реквизита

Далее инициализируем сцену и генератор пропсов в игровом мире, при этом следует отметить, что генератор пропов отвечает только за генерацию пропов, он не знает где должны появиться сгенерированные пропы.JumpGameWorldНам нужно внедрить внутреннююcreatePropметод, чтобы сказать генератору пропса сгенерировать коробку для меня, а затем я должен поместить ее туда.

  constructor ({
    container,
    canvas,
    needDefaultCreator = true,
    axesHelper = false
  }) {
    const { offsetWidth, offsetHeight } = container
    this.container = container
    this.canvas = canvas
    this.width = offsetWidth
    this.height = offsetHeight
    this.needDefaultCreator = needDefaultCreator
    this.axesHelper = axesHelper
    
    // 经过多次尝试
    const [min, max] = [~~(offsetWidth / 6), ~~(offsetWidth / 3.5)]
    this.propSizeRange = [min, max]
    this.propHeight = ~~(max / 2)

    this.stage = null
    this.propCreator = null

    this.init()
  }
  
  // 初始化舞台
  initStage () {
    const { container, canvas } = this
    const { offsetHeight } = container
    const axesHelper = true
    const cameraNear = 0.1
    const cameraFar = 2000
    // 计算相机应该放在哪里
    const cameraInitalPosition = this.cameraInitalPosition = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, cameraNear, cameraFar)
    const lightInitalPosition = this.lightInitalPosition = { x: -300, y: 600, z: 200 }
    
    this.stage = new Stage({
      container,
      canvas,
      axesHelper,
      cameraNear,
      cameraFar,
      cameraInitalPosition,
      lightInitalPosition
    })
  }

  // 初始化道具生成器
  initPropCreator () {
    const { needDefaultCreator, propSizeRange, propHeight } = this

    this.propCreator = new PropCreator({
      propHeight,
      propSizeRange,
      needDefaultCreator
    })
  }
  
  // 对外的新增生成器的接口
  createPropCreator (...args) {
    this.propCreator.createPropCreator(...args)
  }

Итак, куда мне нужно поставить коробку дальше? Откройте WeChat, прыгайте и шлепайте...

Когда вы вернетесь, вы обнаружите, что новый ящик может быть сгенерирован в двух направлениях.X轴а такжеX轴, а расстояние между двумя сгенерированными блоками должно быть случайным, но расстояние должно иметь предел диапазона, и блок не может находиться рядом с блоком или блок появляется за пределами видимой области. Поэтому здесь мы сначала согласовываем диапазон промежутков между блоками в соответствии с диапазоном размеров блока и размером сцены.propDistanceRange = [~~(min / 2), max * 2], сначала по необходимости, если нет, то подкорректируйте.

Думая об этом, кажется, что нам нужно сначала реализовать метод для вычисления позиции входа в поле.computeMyPosition. Для расчета расстояния следующего ящика надо получить предыдущий ящик.При этом в процессе игры будет генерироваться очень много ящиков.Отбрасывать их невозможно.Для того чтобы считать производительность,нужно также регулярно чистить и уничтожать ящики, поэтому также необходимо иметь коллекциюpropsТаким образом, чтобы управлять ящиками, которые были созданы, каждый раз, когда ящик создается, вы можете получить самый последний созданный ящик. Вот некоторые вещи, на которые следует обратить внимание:

  1. Многократно наблюдая за прыжками второго ящика WeChat, было обнаружено, что расстояние между вторым ящиком и первым ящиком всегда одинаково, поэтому мы будем иметь дело с расстоянием второго ящика отдельно.
  2. За исключением того, что расстояние между первыми двумя ящиками такое же, что раньше игнорировалось, размер первых двух ящиков тоже одинаков, поэтому вам нужно повернуть назадPropCreatorГенератор реквизита по умолчанию для обработки, и, судя по тому, что это первые 2 поля, установите фиксированный размер
  3. Анимация входа ящика начинается с третьего ящика, а первые два ящика появляются непосредственно в начале игры, поэтому высота входа первых двух ящиков должна быть0Я не знаю, какая высота коробки будет после этого, это зависит от ситуации
  4. При расчете расстояния коробки нужно рассчитать размер самой коробки, поэтому нужно получить размер коробки
// utils.js
export const getPropSize = box => {
  const box3 = getPropSize.box3 || (getPropSize.box3 = new THREE.Box3())
  box3.setFromObject(box)
  return box3.getSize(new THREE.Vector3())
}

// Prop.js
  getSize () {
    return getPropSize(this.body)
  }

потомPropДобрый

class Prop {
  constructor ({
    // ...
    enterHeight,
    distanceRange,
    prev
  }) {
    // ...
    this.enterHeight = enterHeight
    this.distanceRange = distanceRange
    this.prev = prev
  }

  // 计算位置
  computeMyPosition () {
    const {
      world,
      prev,
      distanceRange,
      enterHeight
    } = this
    const position = {
      x: 0,
      // 头2个盒子y值为0
      y: enterHeight,
      z: 0
    }

    if (!prev) {
      // 第1个盒子
      return position
    }

    if (enterHeight === 0) {
      // 第2个盒子,固定一个距离
      position.z = world.width / 2
      return position
    }

    const { x, z } = prev.getPosition()
    // 随机2个方向 x or z
    const direction = Math.round(Math.random()) === 0
    const { x: prevWidth, z: prevDepth } = prev.getSize()
    const { x: currentWidth, z: currentDepth } = this.getSize()
    // 根据区间随机一个距离
    const randomDistance = rangeNumberInclusive(...distanceRange)

    if (direction) {
      position.x = x + prevWidth / 2 + randomDistance + currentWidth / 2
      position.z = z
    } else {
      position.x = x
      position.z = z + prevDepth / 2 + randomDistance + currentDepth / 2
    }

    return position
  }

  // 将道具放入舞台
  enterStage () {
    const { stage, body, height } = this
    const { x, y, z } = this.computeMyPosition()

    body.castShadow = true
    body.receiveShadow = true
    body.position.set(x, y, z)
    // 需要将盒子放到地面
    body.geometry.translate(0, height / 2, 0)
    
    stage.add(body)
    stage.render()
  }

  // 获取道具大小
  getSize () {
    return getPropSize(this.body)
  }
  
  // ...
}

Логика генерации блоков теперь может быть реализована

  // JumpGameWorld.js
  // 创建盒子
  createProp (enterHeight = 100) {
    const {
      height,
      propCreator,
      propHeight,
      propSizeRange: [min, max],
      propDistanceRange,
      stage, props,
      props: { length }
    } = this
    const currentProp = props[length - 1]
    const prop = new Prop({
      world: this,
      stage,
      // 头2个盒子用第一个创造器生成
      body: propCreator.createProp(length < 3 ? 0 : -1),
      height: propHeight,
      prev: currentProp,
      enterHeight,
      distanceRange: propDistanceRange
    })
    const size = prop.getSize()

    if (size.y !== propHeight) {
      console.warn(`高度: ${size.y},盒子高度必须为 ${propHeight}`)
    }
    if (size.x < min || size.x > max) {
      console.warn(`宽度: ${size.x}, 盒子宽度必须为 ${min} - ${max}`)
    }
    if (size.z < min || size.z > max) {
      console.warn(`深度: ${size.z}, 盒子深度度必须为 ${min} - ${max}`)
    }

    prop.enterStage()
    props.push(prop)
  }

затем инициализируйте его

  init () {
    this.initStage()
    this.initPropCreator()
    // 第一个道具
    this.createProp()
    // 第二个道具
    this.createProp()
  }

На данный момент реализована функция случайной генерации реквизита, но теперь сцена неподвижна, и логику генерации дополнительных реквизитов проверить невозможно, поэтому следующим шагом является реализация движения сцены.

движение сцены

Поднимите трубку, откройте WeChat, прыгайте и продолжайте размышлять......

Независимо от того, существует злодей или нет, мы можем обнаружить, что каждый раз, когда генерируется ящик, сцена начинает двигаться. Так как его переместить? Вы можете добиться эффекта перемещения сцены, перемещая камеру. Не о чем беспокоиться. Это закон. Так же, как при съемке фильма, когда люди движутся, может ли ваша камера не следовать за ними?

Итак, вопрос в том, куда мы должны переместить камеру?

  • При настройке положения камеры, как убедиться, что последние 2 поля занимают правильное положение в видимой области
  • Ящики различаются по размеру, будут ли случаи, когда половина из них окажется за пределами сцены

Неважно, сначала возьмите свой мобильный телефон, откройте WeChat и прыгайте, прыгайте, лизайте...  

Вы обнаружите, что после каждого движения сцены центральная точка находится почти в середине последних двух блоков, но кажется, что она немного смещена вниз, мы могли бы также разбить ее.

Это легко сделать, мы вычисляем точку между двумя последними прямоугольниками, смещаем эту точку вниз на значение, а затем добавляем результат к начальному положению камеры, разве мы не получаем положение камеры? Смещение по умолчанию здесь — это высота усеченного обзора.1/10, затем вJumpGameWorldсередина:

  // 计算最新的2个盒子的中心点
  getLastTwoCenterPosition () {
    const { props, props: { length } } = this
    const { x: x1, z: z1 } = props[length - 2].getPosition()
    const { x: x2, z: z2 } = props[length - 1].getPosition()

    return {
      x: x1 + (x2 - x1) / 2,
      z: z1 + (z2 - z1) / 2
    }
  }
  
  // 移动相机,总是看向最后2个小球的中间位置
  moveCamera () {
    const {
      stage,
      height
      cameraInitalPosition: { x: initX, y: initY, z: initZ }
    } = this
    // 将可视区向上偏移一点,这样看起来道具的位置更合理
    const cameraOffsetY = height / 10

    const { x, y, z } = this.getLastTwoCenterPosition()
    const to = {
      x: x + initX + cameraOffsetY,
      y: initY, // 高度是不变的
      z: z + initZ + cameraOffsetY
    }

    // 移动舞台相机
    stage.moveCamera(to)
  }

После получения положения камеры нам нужно предоставить соответствующий метод в классе сцены,Stageсередина

  // 移动相机
  moveCamera ({ x, z }) {
    const { camera } = this
    camera.position.x = x
    camera.position.z = z
    this.render()
  }

Теперь, когда камера может двигаться, давайте установим таймер, чтобы проверить это.y值равномерно установлен на 0

  init () {
    this.initStage()
    this.initPropCreator()
    // 第一个道具
    this.createProp()
    // 第二个道具
    this.createProp()
    // 首次调整相机
    this.moveCamera()

    // 测试
    const autoMove = () => {
      setTimeout(() => {
        autoMove()
        // 每次有新的道具时,需要移动相机
        this.createProp()
        this.moveCamera()
      }, 2000)
    }
    autoMove()    
  }

хорошо, очень хорошо, но проблема возникла во время теста

  1. После того, как камера отошла на определенное расстояние, оказалось, что тень от реквизита не видна.
  2. Положение тени каждый раз меняется, что не является желаемым эффектом.
  3. Камере нужна плавная анимация перехода
  4. Камера сдвинулась, должны быть какие-то ящики вне поля зрения, они все еще полезны? Как его уничтожить, если он бесполезен?

Временно обнаружил так мало проблем, давайте решать их по очереди.

теневая проблема, это потому, что земля недостаточно велика, можно ли установить землю достаточно большой? Согласно нашему предыдущему анализу камеры, это возможно, потому что мы не меняли ни одного угла камеры, а просто переводили, но это слишком мало, и максимальное значение ограничено, поэтому мы можем Земля перемещается на одновременно с перемещением камеры, создавая иллюзию неподвижности земли. Затем вызывается положение земли, которое является положением центральной точки.

теневая проблема, который похож на землю, мы также можем заставить источник света двигаться вместе с камерой, но на свет нужно обращать внимание

Направление направленного света — от его положения к целевому положению. Целевой позицией по умолчанию является исходная точка (0,0,0). Примечание. Для изменения положения цели на любое положение, отличное от положения по умолчанию, его необходимо добавить в сцену.

Это означает, что при изменении целевого положения источника света необходимо создать целевой объект и добавить его в сцену, то есть в дополнение к обновлению положения источника света необходимо обновить целевое положение источника света.

var targetObject = new THREE.Object3D();
scene.add(targetObject);

light.target = targetObject;

переход сцены, в этом нет ничего сложного, используйте его напрямуюTween.jsПлагин, так как в будущем есть еще много мест для использования эффектов перехода, мы можем просто сначала инкапсулировать его.

export const animate = (configs, onUpdate, onComplete) => {
  const {
    from, to, duration,
    easing = k => k,
    autoStart = true // 为了使用tween的chain
  } = configs

  const tween = new TWEEN.Tween(from)
    .to(to, duration)
    .easing(easing)
    .onUpdate(onUpdate)
    .onComplete(() => {
      onComplete && onComplete()
    })

  if (autoStart) {
    tween.start()
  }

  animateFrame()
  return tween
}

const animateFrame = function () {
  if (animateFrame.openin) {
    return
  }
  animateFrame.openin = true

  const animate = () => {
    const id = requestAnimationFrame(animate)
    if (!TWEEN.update()) {
      animateFrame.openin = false
      cancelAnimationFrame(id)
    }
  }
  animate()
}

Уничтожение коробкиДля ящика, который не находится в видимой области, действительно необходимо уничтожить, ведь когда число очень большое, это принесет существенные проблемы с производительностью. Мы можем выбрать подходящее время для этого, например, операцию очистки коробки после завершения работы каждой камеры. Как определить, находится ли коробка в видимой области? Сначала остановитесь, чтобы решить предыдущие проблемы.

В соответствии с приведенными выше вопросами затем резюмируется преобразование того, чтоmoveCamera, не забудьте добавить световой целевой объектlightTarget, а затем также необходимо обеспечить обратный вызов для завершения движения камеры (дождитесь выполнения уничтожения коробки)

  // Stage.js
  // center为2个盒子的中心点
  moveCamera ({ cameraTo, center, lightTo }, onComplete, duration) {
    const {
      camera, plane,
      light, lightTarget,
      lightInitalPosition
    } = this

    // 移动相机
    animate(
      {
        from: { ...camera.position },
        to: cameraTo,
        duration
      },
      ({ x, y, z }) => {
        camera.position.x = x
        camera.position.z = z
        this.render()
      },
      onComplete
    )

    // 灯光和目标也需要动起来,为了保证阴影位置不变
    const { x: lightInitalX, z: lightInitalZ } = lightInitalPosition
    animate(
      {
        from: { ...light.position },
        to: lightTo,
        duration
      },
      ({ x, y, z }) => {
        lightTarget.position.x = x - lightInitalX
        lightTarget.position.z = z - lightInitalZ
        light.position.set(x, y, z)
      }
    )

    // 保证不会跑出有限大小的地面
    plane.position.x = center.x
    plane.position.z = center.z
  }

соответствующий,JumpGameWorldтакже реконструирован

  // 移动相机,总是看向最后2个小球的中间位置
  moveCamera (duration = 500) {
    const {
      stage,
      cameraInitalPosition: { x: cameraX, y: cameraY, z: cameraZ },
      lightInitalPosition: { x: lightX, y: lightY, z: lightZ }
    } = this
    // 向下偏移值,取舞台高度的1/10
    const cameraOffsetY = stage.frustumHeight / 10

    const { x, y, z } = this.getLastTwoCenterPosition()
    const cameraTo = {
      x: x + cameraX + cameraOffsetY,
      y: cameraY, // 高度是不变的
      z: z + cameraZ + cameraOffsetY
    }
    const lightTo = {
      x: x + lightX,
      y: lightY,
      z: z + lightZ
    }

    // 移动舞台相机
    const options = {
      cameraTo,
      lightTo,
      center: { x, y, z }
    }
    stage.moveCamera(
      options,
      () => {
        // 执行盒子销毁操作
      },
      duration
    )
  }

Уничтожение коробки

У нас уже есть идея, когда его уничтожить, так что же является основанием для уничтожения? Очевидно, что пока коробка не находится в зоне видимости, ее можно разрушить, потому что сцена движется вперед, а центр зоны видимости постоянно движется навстречу.X轴илиZ轴направление вперед. Тогда первое, что приходит на ум, это реализовать метод для определения того, находится ли коробка в видимой области, threejs также предоставляет соответствующиеapiОн работоспособен.Заинтересованные друзья могут узнать о соответствующих алгоритмах.Я больше не могу.Математика слишком слаба. Кроме того, алгоритм в threejs вроде как связан с вершинами и лучами, и чем сложнее объект (чем больше вершин), тем он дороже в вычислительном отношении. С таким же успехом можно попытаться посмотреть на эту проблему по-другому, т. е. нужно ли вычислять, находится ли ящик в видимой области?

Смиритесь с этим, это не очень легко рисовать. Предположим, размер нашей сцены200*320, диапазон размеров коробки[30,60], в дополнение к ограничениям расстояния между блоками[20,100], то грубо прикидываем с наименьшим безопасным значением, ставим 2 квадрата30+20+30,уже есть80широкий, так сказать200Не более 4 широких и горизонтальных. Кроме того, центральная точка нашей видимой области является центром двух ближайших прямоугольников (независимо от нижнего смещения камеры), тогда при вертикальном расположении160Вертикально в верхнем диапазоне можно разместить до 3 ящиков, а также один над центральной точкой, который также составляет 4 ящика. То есть по прикидке в видимой области одновременно может быть до 8 ящиков (если хотите подобрать слова, то можете реально протестировать, здесь только прикидка, ошибка должна быть также быть связаны с углом камеры).

Теперь логика понятна, по предположению, когда мы управляем коллекцией ящиковpropsдлиннее, чем8При уничтожении ящика можно выполнить операцию уничтожения ящика, и не обязательно убирать каждый раз при движении камеры.Можно исправить несколько раз, чтобы убирать каждый раз.Например, мы договорились убирать каждый раз .4, затем уничтожайте 4 каждый раз, когда есть 12 ящиков, и так далее...

  // JumpGameWorld.js
  // 销毁道具
  clearProps () {
    const {
      width,
      height,
      safeClearLength,
      props, stage,
      props: { length }
    } = this
    const point = 4

    if (length > safeClearLength) {
      props.slice(0, point).forEach(prop => prop.dispose())
      this.props = props.slice(point)
    }
  }
  
  // 估算销毁安全值
  computeSafeClearLength () {
    const { width, height, propSizeRange } = this
    const minS = propSizeRange[0]
    const hypotenuse = Math.sqrt(minS * minS + minS * minS)
    this.safeClearLength = Math.ceil(width / minS) + Math.ceil(height / hypotenuse / 2) + 1
  }
  
  // Prop.js
  // 销毁
  dispose () {
    const { body, stage } = this

    body.geometry.dispose()
    body.material.dispose()
    stage.remove(body)
    // 解除对前一个的引用
    this.prev = null
  }

Напомним, что если для уничтожения ящика используется алгоритм, он также может иметь безопасное значение, почему?

Если возникает ситуация на картинке и безопасное значение не задано, алгоритм сообщит вам, что предпоследний квадрат на картинке находится вне видимой области, так что мы должны его очистить? В соответствии с возможным направлением следующего ящика, если он соответствует картинке, сцена будет двигаться вправо, при этом ящик должен появиться в видимой области, а не уничтожиться.

Вопросы идут один за другим, и на следующем шаге мы понимаем, что пинбол для входа в коробку упал.

Коробка пинболов падает

Добавить анимацию на самом деле довольно просто, вы можете справиться с этим, когда бокс будет создан и войдет в сцену, теперь реализуйтеentranceTransitionметод

  // 放入舞台
  enterStage () {
    // ...

    this.entranceTransition()
  }
  // 盒子的入场动画
  entranceTransition (duration = 400) {
    const { body, enterHeight, stage } = this

    if (enterHeight === 0) {
      return
    }

    animate(
      {
        to: { y: 0 },
        from: { y: enterHeight },
        duration,
        easing: TWEEN.Easing.Bounce.Out
      },
      ({ y }) => {
        body.position.setY(y)
        stage.render()
      }
    )
  }

На данный момент мы поняли основную логику сцены и реквизита, и она начала обретать форму.

Маленький человек понимает LittleMan

Теперь давайте осознаем логику злодея, откроем WeChat и еще немного попрыгаем и шлепнем...

Затем проанализируйте, какие моменты связаны со злодеем?

  1. У него 2 части, голова и тело
  2. Идет процесс зарядки перед взлетом
  3. При зарядке коробка имеет процесс сжатия
  4. Вокруг есть спецэффекты при зарядке (как называется непонятно)
  5. При зарядке тело масштабируется, а голова перемещается вниз, то есть часть тела должна поместить начало масштабирования в ногу злодея.
  6. Коробка имеет анимацию подпрыгивания при взлете
  7. кувыркаться в воздухе
  8. Послеобразы в воздухе
  9. Тело имеет короткий процесс буферизации при приземлении
  10. Есть спецэффекты на земле при посадке

Ниже распакуйте их один за другим...

нарисовать злодея

Во-первых, голова очень простая, просто круг. Часть тела представляет собой неправильный цилиндр. Поскольку я новичок в трехмерной графике, я не знаю никаких сокращений для рисования этой части тела, поэтому здесь я использую три геометрических тела для объединения тела. Прежде чем рисовать, мы должны оглянуться на анализируемые точки см. Нужно ли обращать внимание при рисовании? Первое, что должно иметь влияние, это функция масштабирования (обратите внимание, что голова не масштабируется), которая требует, чтобы начало масштабирования тела помещалось под ноги при рисовании, а затем переворачивалось в воздухе. сальто пока (Слишком быстро), оно может быть центральной точкой всего тела и головы, а может и не быть, но это не влияет на нашу способность определять, что тело и голова составляют единое целое ( группа threejs), что касается того, где находится источник флипа, мы его сделаем. Затем выполните обработку при отладке эффекта, поэтому, чтобы быть в безопасности, используйте картинку, чтобы описать, как рисовать

Каждая пунктирная рамка представляет собой слой упаковки (сетка или группа).Для злодея, если вы хотите изменить начало вращения, вам нужно только отрегулировать верхнее и нижнее смещения групп головы и тела.

Я обдумывал начальный экран WeChat Jump (то есть когда игра еще не началась), злодей прыгнул на ящик с пустого места, а после запуска игры упал с воздуха на ящик, так что злодей должен иметь Как войтиenterStageа потом метод создания телаcreateBody, там тоже должен быть метод прыжкаjump. так:

class LittleMan {
  constructor ({
    world,
    color
  }) {
    this.world = world
    this.color = color

    this.stage = null
  }

  // 创建身体
  createBody () {}

  // 进入舞台
  enterStage () {}

  // 跳跃
  jump () {}
}

Давайте сначала нарисуем тело, так как ширина сцены задается в соответствии с шириной области просмотра, размер злодея должен рассчитываться динамически.

  // 创建身体
  createBody () {
    const { color, world: { width } } = this
    const material = baseMeshLambertMaterial.clone()
    material.setValues({ color })

    // 头部
    const headSize = this.headSize = width * .03
    const headTranslateY = this.headTranslateY = headSize * 4.5
    const headGeometry = new THREE.SphereGeometry(headSize, 40, 40)
    const headSegment = this.headSegment = new THREE.Mesh(headGeometry, material)
    headSegment.castShadow = true
    headSegment.translateY(headTranslateY)

    // 身体
    this.width = headSize * 1.2 * 2
    this.bodySize = headSize * 4
    const bodyBottomGeometry = new THREE.CylinderBufferGeometry(headSize * .9, this.width / 2, headSize * 2.5, 40)
    bodyBottomGeometry.translate(0, headSize * 1.25, 0)
    const bodyCenterGeometry = new THREE.CylinderBufferGeometry(headSize, headSize * .9, headSize, 40)
    bodyCenterGeometry.translate(0, headSize * 3, 0)
    const bodyTopGeometry = new THREE.SphereGeometry(headSize, 40, 40)
    bodyTopGeometry.translate(0, headSize * 3.5, 0)

    const bodyGeometry = new THREE.Geometry()
    bodyGeometry.merge(bodyTopGeometry)
    bodyGeometry.merge(new THREE.Geometry().fromBufferGeometry(bodyCenterGeometry))
    bodyGeometry.merge(new THREE.Geometry().fromBufferGeometry(bodyBottomGeometry))

    // 缩放控制
    const translateY = this.bodyTranslateY = headSize * 1.5
    const bodyScaleSegment = this.bodyScaleSegment = new THREE.Mesh(bodyGeometry, material)
    bodyScaleSegment.castShadow = true
    bodyScaleSegment.translateY(-translateY)

    // 旋转控制
    const bodyRotateSegment = this.bodyRotateSegment = new THREE.Group()
    bodyRotateSegment.add(headSegment)
    bodyRotateSegment.add(bodyScaleSegment)
    bodyRotateSegment.translateY(translateY)

    // 整体身高 = 头部位移 + 头部高度 / 2 = headSize * 5
    const body = this.body = new THREE.Group()
    body.add(bodyRotateSegment)
  }

Затем нам нужно отпустить злодея на указанную позицию на сцене.

  // 进入舞台
  enterStage (stage, { x, y, z }) {
    const { body } = this
    
    body.position.set(x, y, z)

    this.stage = stage
    stage.add(body)
    stage.render()
  }

Инициализировать в игре и позволить злодею выйти на сцену

  // JumpGameWorld.js
  // 初始化小人
  initLittleMan () {
    const { stage, propHeight } = this
    const littleMan = this.littleMan = new LittleMan({
      world: this,
      color: 0x386899
    })
    littleMan.enterStage(stage, { x: 0, y: propHeight, z: 0 })
  }

Первый шаг пройден, теперь нам нужно заставить злодея двигаться и реализовать его функцию отскока.

Реализовать отскок злодея

Открывайте WeChat и прыгайте, это нужно хорошенько обдумать...

Мы можем разложить весь процесс отказов,蓄力 -> 起跳 -> 抛物线运动 -> 着地 -> 缓冲,здесь蓄力это нажатие мыши (touchstartилиmousedown) возникает, когда起跳это при освобождении(touchendилиmouseup)происходить. Следует отметить, что если вы постоянно нажимаете и отпускаете, вы не можете ничего сделать, пока злодей не приземлился.Другая ситуация: если злодей находится в воздухе и нажата мышь, мышь отпускается через некоторое время. В этот момент мы ничего не можем сделать, поэтому мы можем привязать событие освобождения после нажатия, а затем удалить его, как только произойдет событие освобождения.

  bindEvent () {
    const { container } = this.world
    const isMobile = 'ontouchstart' in document
    const mousedownName = isMobile ? 'touchstart' : 'mousedown'
    const mouseupName = isMobile ? 'touchend' : 'mouseup'
    
    // 该起跳了
    const mouseup = () => {
      if (this.jumping) {
        return
      }
      this.jumping = true
      // 蓄力动作应该停止
      this.poweringUp = false

      this.jump()
      container.removeEventListener(mouseupName, mouseup)
    }

    // 蓄力的时候
    const mousedown = event => {
      event.preventDefault()
      // 跳跃没有完成不能操作
      if (this.poweringUp || this.jumping) {
        return
      }
      this.poweringUp = true
      
      this.powerStorage()
      container.addEventListener(mouseupName, mouseup, false)
    }

    container.addEventListener(mousedownName, mousedown, false)
  }
  // 进入舞台
  enterStage (stage, { x, y, z }) {
    // ...
    this.bindEvent()
  }

Цель накопления силы - прыгнуть дальше, то есть сила определяет расстояние, мы можем力度大小 * 系数Чтобы смоделировать и рассчитать диапазон, когда дело доходит до этого, у меня в голове всплывает слово斜抛运动, кажется, я не трогал его n лет, а потом молча открываю Baidu: косое бросковое движение

Наклонное метательное движение: Объект выбрасывается наискось с определенной начальной скоростью.При условии, что сопротивлением воздуха можно пренебречь, такое движение, совершаемое объектом, называется наклонным метательным движением. Объект движется по равномерной кривой скорости, а его траектория представляет собой параболу.

Прыжки WeChat — это наклонное метание? Открой и подумай...

После долгого наблюдения вверх и вниз я могу почти заключить, что это «не должно» быть равномерной кривой скорости, основанной на моем ощущении пространства.斜抛运动,после всего斜抛运动公式да在空气阻力可以忽略的情况下Это эффективно, и траектория прыжка WeChat совсем не похожа на симметричную параболу, она выглядит так:

Это должно быть больше похоже на наклонный бросок с сопротивлением, но я не нашел в Интернете формулы наклонного броска, учитывающей сопротивление, поэтому мы используем斜抛运动возможно придется немного изменить. Без модификации,yзначение требуется для прохожденияxвычисляется из значения , так что мы не можем напрямую контролироватьyКривая. Повернитесь сейчас, вместо того, чтобы поворачиватьсяyдвижение отделяется и сохраняетсяxравномерной скорости оси, создаваяxПереход оси, создание двух одновременноyПри переходе оси восходящий отрезок замедляется, а нисходящий ускоряется, при этом время подъема составляет 60 % от общего времени. Затем, согласно斜抛运动Соответствующая формула для , мы можем вычислить水平射程а также射高,运动时间Я чувствую, что прыжки в WeChat — фиксированное значение, поэтому здесь они не учитываются.

Поскольку вам нужно использовать формулу косого броска, вам нужно создать 2 переменные, скоростьv0а такжеtheta, при этом накапливая мощность за счет увеличенияv0и уменьшениеthetaдля имитации траектории. Сначала приготовьте формулу, а заодноJumpGameWorldДобавлен новый параметр гравитации.G, сначала по умолчанию9.8

// 斜抛计算
export const computeObligueThrowValue = function (v0, theta, G) {
  const sin2θ = sin(2 * theta)
  const sinθ = sin(theta)

  const rangeR = pow(v0, 2) * sin2θ / G
  const rangeH = pow(v0 * sinθ, 2) / (2 * G)

  return {
    rangeR,
    rangeH
  }
}

заряжать

Теперь мы реализуем базовую логику накопления силы.Что нам нужно сделать, это увеличить параметры косого броска и масштабировать злодея.Здесь мы не обращаем внимание на значения параметра косого броска.Настроим его после того, как мы заставить злодея двигаться. Еще момент, на который следует обратить внимание, это то, что после окончания зарядки злодей нужно восстановить, но напрямую его восстановить нельзя, необходимо сохранить значение в конце эффективной зарядки, а затем восстановить злодея во время параболического фаза движения, так что эффект относительно гладкий.

  resetPowerStorageParameter () {
    this.v0 = 20
    this.theta = 90

    // 由于蓄力导致的变形,需要记录后,在空中将小人复原
    this.toValues = {
      headTranslateY: 0,
      bodyScaleXZ: 0,
      bodyScaleY: 0
    }
    this.fromValues = this.fromValues || {
      headTranslateY: this.headTranslateY,
      bodyScaleXZ: 1,
      bodyScaleY: 1
    }
  }

  // 蓄力
  powerStorage () {
    const { stage, bodyScaleSegment, headSegment, fromValues, bodySize } = this

    this.resetPowerStorageParameter()

    const tween = animate(
      {
        from: { ...fromValues },
        to: {
          headTranslateY: bodySize - bodySize * .6,
          bodyScaleXZ: 1.3,
          bodyScaleY: .6
        },
        duration: 1500
      },
      ({ headTranslateY, bodyScaleXZ, bodyScaleY  }) => {
        if (!this.poweringUp) {
          // 抬起时停止蓄力
          tween.stop()
        } else {
          this.v0 *= 1.008
          this.theta *= .99

          headSegment.position.setY(headTranslateY)
          bodyScaleSegment.scale.set(bodyScaleXZ, bodyScaleY, bodyScaleXZ)
          
          // 保存此时的位置用于复原
          this.toValues = {
            headTranslateY,
            bodyScaleXZ,
            bodyScaleY
          }

          stage.render()
        }
      }
    )
  }

Теперь нажмите мышку, вы должны увидеть эффект накопления силы злодея, а затем нам нужно добиться эффекта сжатия злодея на ящике.

эффект сжатия коробки

Есть несколько вопросов, о которых я раньше даже не задумывался, я (типа я злодей на сцене) вышел на сцену, где я стою? После того, как я получу следующий шаг, куда мне идти дальше?

  1. Я могу стоять на ящике, а могу быть на земле (например, перед тем, как нажать, чтобы начать игру, злодей прыгает с земли на ящик)
  2. Моя цель должна быть следующей коробкой

Согласно приведенному выше анализу, злодей должен знать, в каком ящике он сейчас находится.currentPropЧто, значение может быть нулевым, и, конечно, знать следующее целевое полеnextPropкоторый из.

Сначала определите, когда установить текущее полеcurrentProp, мы ранее добились выхода на сценуenterStageметод, то должно быть понятно, что этот метод только для выхода на сцену, и не имеет никакого отношения к ящику, поэтому теперь нам нужно прыгнуть на первый ящик после выхода злодея на сцену, и прыгнуть по наблюдению WeChat :

  1. Не нажимая для запуска игры, подойдите к краю сцены (кроме реквизита), затем斜抛运动перейти к первому ящику
  2. После нажатия для запуска игры злодей появляется прямо над первым ящиком, затем弹球下落运动Перейти к первому боксу, для этого действия нам нужно реализовать его отдельно

Если приведенный выше анализ не очень ясен, я предлагаю вам взять свой мобильный телефон и открыть WeChat, чтобы попрыгать и поиграть еще несколько...

Итак, что делать, очень ясно.Когда злодей выходит на сцену, установите целевое поле для следующего прыжка, а затем выполните операцию прыжка.После прыжка установите его в качестве текущего поля и установите следующее поле этого поля. как следующий.Jump target, здесь можно вернуться к месту генерации ящика и связать его со следующим, что удобно для обработки

// JumpGameWorld.js
  // 创建盒子
  createProp (enterHeight = 100) {
    // ...
    
    // 关联下一个用于小人寻找目标
    if (currentProp) {
      currentProp.setNext(prop)
    }

    prop.enterStage()
    props.push(prop)
  }
  
// Prop.js
  setNext (next) {
    this.next = next
  }

  getNext (next) {
    return this.next
  }
  // 销毁
  dispose () {
    const { body, stage, prev, next } = this
    // 解除关联的引用
    this.prev = null
    this.next = null
    if (prev) {
      prev.next = null
    }
    if (next) {
      next.prev = null
    }

    body.geometry.dispose()
    body.material.dispose()
    stage.remove(body)
  }
  // LittleMan.js
  enterStage (stage, { x, y, z }, nextProp) {
    const { body } = this

    body.position.set(x, y, z)
    this.stage = stage
    // 进入舞台时告诉小人目标
    this.nextProp = nextProp

    stage.add(body)
    stage.render()
    this.bindEvent()
  }
  
  // 跳跃
  jump () {
    const {
        stage, body,
        currentProp, nextProp,
        world: { propHeight }
    } = this
    const { x, z } = body.position
    const { x: nextX, z: nextZ } = nextProp.position

    // 开始游戏时,小人从第一个盒子正上方入场做弹球下落
    if (!currentProp && x === nextX && z === nextZ) {
      body.position.setY(propHeight)
      this.currentProp = nextProp
      this.nextProp = nextProp.getNext()
    } else {
      // ...
    }
    
    stage.render()
  }

Конкретная анимация прыжка будет решена позже. Теперь, когда вы знаете, на какой коробке вы сейчас стоите, вы можете с радостью добиться эффекта сжатия. Так как же нам добиться определенного эффекта экструзии? Запас энергии злодея был реализован раньше.Согласно эффекту прыжков WeChat, эффект сжатия также переходит во время периода зарядки.Когда коробка сжимается, злодею также необходимо обновить свое общее положение по оси Y.Поэтому , теперь Изменить анимацию зарядки

  // 初始化斜抛相关参数
  resetPowerStorageParameter () {
    // ...
    
    this.toValues = {
      // ...
      propScaleY: 0
    }
    this.fromValues = this.fromValues || {
      // ...
      propScaleY: 1
    }
  }

  // 蓄力
  powerStorage () {
    const {
      stage,
      body, bodyScaleSegment, headSegment,
      fromValues,
      currentProp,
      world: { propHeight }
    } = this

    // ...

    const tween = animate(
      {
        from: { ...fromValues },
        to: {
          // ...
          propScaleY: .8
        },
        duration: 1500
      },
      ({ headTranslateY, bodyScaleY, bodyScaleXZ, propScaleY }) => {
        if (!this.poweringUp) {
          // 抬起时停止蓄力
          tween.stop()
        } else {
          // ...
          
          currentProp.scale.setY(propScaleY)
          body.position.setY(propHeight * propScaleY)

          // ...

          stage.render()
        }
      }
    )
  }

Теперь, когда эффект сжатия достигнут, следующим шагом будет анализ процесса прыжка, пожалуйста, откройте WeChat и прыгайте...

Отгул

Скорость супер быстрая, четко не видно, давайте сами проанализируем. Во-первых, по здравому смыслу в жизни, начальная скорость прыжка злодея должна быть больше скорости отскока ящика, и он не должен сталкиваться до того, как ящик отскочит в вершину, тогда можно открыть 2 анимации на В то же время, один ящик Отскок, один косой бросок злодея.

Первая анимация, сначала реализуем функцию отскока для коробкиspringbackTransition:

  // 回弹动画
  springbackTransition (duration) {
    const { body, stage } = this
    const y = body.scale.y
    
    animate(
      {
        from: { y },
        to: { y: 1 },
        duration,
        easing: TWEEN.Easing.Bounce.Out
      },
      ({ y }) => {
        body.scale.setY(y)
        stage.render()
      }
    )
  }

Вторая анимация, параболическое движение злодея, была проанализирована,X轴делать равномерные движения,Y轴Подразделяется на 2 этапа: восходящий участок — замедление, а нисходящий участок — ускорение. Весь процесс прыжка, помимо параболического движения, включает в себя еще и буфер приземления.Теоретически буфер тоже меняется в 2 этапа, но поскольку изменения очень быстрые, думаю, невооруженным глазом определить сложно, поэтому я установите только вторую половину, чтобы увидеть эффект.В то же время точка окончания буфера должна быть точкой окончания всего процесса прыжка.

Кроме того, направление движения злодея может бытьX轴илиZ轴, поэтому нам нужно сначала определить направление злодея, мы можем сравнитьxценность иzзначение для определения направления,xравноZ轴方向, иначеX轴

Теперь, когда направление, кривая движения и диапазон определены, мы можем начать? слишком молодой слишком простой, это射程Мы можем напрямую использовать злодея вX轴илиZ轴компенсировать?

Как показано на рисунке выше, сначала предположим, что злодей не будет выпрыгивать из ящика.Понятно, что злодей должен целиться в центральную точку следующего ящика каждый раз, когда он прыгает.Что касается того, может ли он точно упасть на центральная точка, это неопределенно.射程Решите, но он не должен выпрыгивать из линии, соединяющей начальную точку с центральной точкой следующего ящика.Теперь давайте разберем это дальше:

Как видно из рисунка, известно, чтоc1прибытьp2Линейное расстояние и разность координат , а затем могут быть рассчитаны по характеристикам подобных треугольных линийX轴а такжеZ轴Смещение направления. Следующим шагом является установка формулы для нахождения реальногоxа такжеz, мы реализуемcomputePositionByRangeRметод.

/**
 * 根据射程算出落地点
 * @param {Number} range 射程
 * @param {Object} c1 起跳点
 * @param {Object} p2 目标盒子中心点
 */
export const computePositionByRange = function (range, c1, p2) {
  const { x: c1x, z: c1z } = c1
  const { x: p2x, z: p2z } = p2

  const p2cx = p2x - c1x
  const p2cz = p2z - c1z
  const p2c = sqrt(pow(p2cz, 2) + pow(p2cx, 2))

  const jumpDownX = p2cx * range / p2c
  const jumpDownZ = p2cz * range / p2c

  return {
    jumpDownX: c1x + jumpDownX,
    jumpDownZ: c1z + jumpDownZ
  }
}

Затем мы реализуем логику прыжков, которую мы обобщили ранее, включая местонахождение первого пинбола злодея, потому что после того, как я это понял, я обнаружил, что если время зарядки очень короткое, рассчитанное射高Значение немного низкое (и опыт WeChat немного отличается), поэтому я прямо указал射高Записывается минимальное значение😄, которое кажется более близким к опыту прыжков WeChat.

  // 跳跃
  jump () {
    const {
      stage, body,
      currentProp, nextProp,
      world: { propHeight }
    } = this
    const duration = 400
    const start = body.position
    const target = nextProp.getPosition()
    const { x: startX, y: startY, z: startZ } = start

    // 开始游戏时,小人从第一个盒子正上方入场做弹球下落
    if (!currentProp && startX === target.x && startZ === target.z) {
      animate(
        {
          from: { y: startY },
          to: { y: propHeight },
          duration,
          easing: TWEEN.Easing.Bounce.Out
        },
        ({ y }) => {
          body.position.setY(y)
          stage.render()
        },
        () => {
          this.currentProp = nextProp
          this.nextProp = nextProp.getNext()
          this.jumping = false
        }
      )
    } else {
      if (!currentProp) {
        return
      }

      const { bodyScaleSegment, headSegment, G } = this
      const { v0, theta } = this.computePowerStorageValue()
      const { rangeR, rangeH } = computeObligueThrowValue(v0, theta * (Math.PI / 180), G)

      // 水平匀速
      const { jumpDownX, jumpDownZ } = computePositionByRangeR(rangeR, start, target)
      animate(
        {
          from: {
            x: startX,
            z: startZ,
            ...this.toValues
          },
          to: {
            x: jumpDownX,
            z: jumpDownZ,
            ...this.fromValues
          },
          duration
        },
        ({ x, z, headTranslateY, bodyScaleXZ, bodyScaleY }) => {
          body.position.setX(x)
          body.position.setZ(z)
          headSegment.position.setY(headTranslateY)
          bodyScaleSegment.scale.set(bodyScaleXZ, bodyScaleY, bodyScaleXZ)
        }
      )

      // y轴上升段、下降段
      const rangeHeight = Math.max(60, rangeH) + propHeight
      const yUp = animate(
        {
          from: { y: startY },
          to: { y: rangeHeight },
          duration: duration * .65,
          easing: TWEEN.Easing.Cubic.Out,
          autoStart: false
        },
        ({ y }) => {
          body.position.setY(y)
        }
      )
      const yDown = animate(
        {
          from: { y: rangeHeight },
          to: { y: propHeight },
          duration: duration * .35,
          easing: TWEEN.Easing.Cubic.In,
          autoStart: false
        },
        ({ y }) => {
          body.position.setY(y)
        }
      )

      // 落地后,生成下一个方块 -> 移动镜头 -> 更新关心的盒子 -> 结束
      const ended = () => {
        const { world } = this
        world.createProp()
        world.moveCamera()

        this.currentProp = nextProp
        this.nextProp = nextProp.getNext()
        // 跳跃结束了
        this.jumping = false
      }
      // 落地缓冲段
      const bufferUp = animate(
        {
          from: { s: .8 },
          to: { s: 1 },
          duration: 100,
          autoStart: false
        },
        ({ s }) => {
          bodyScaleSegment.scale.setY(s)
        },
        () => {
          // 以落地缓冲结束作为跳跃结束时间点
          ended()
        }
      )

      // 上升 -> 下降 -> 落地缓冲
      yDown.chain(bufferUp)
      yUp.chain(yDown).start()

      // 需要处理不同方向空翻
      const direction = currentProp.getPosition().z === nextProp.getPosition().z
      this.flip(duration, direction)

      // 从起跳开始就回弹
      currentProp.springbackTransition(500)
    }

    stage.render()
  }

  

  // 空翻
  flip (duration, direction) {
    const { bodyRotateSegment } = this
    let increment = 0

    animate(
      {
        from: { deg: 0 },
        to: { deg: 360 },
        duration,
        easing: TWEEN.Easing.Sinusoidal.InOut
      },
      ({ deg }) => {
        if (direction) {
          bodyRotateSegment.rotateZ(-(deg - increment) * (Math.PI/180))
        } else {
          bodyRotateSegment.rotateX((deg - increment) * (Math.PI/180))
        }
        increment = deg
      }
    )
  }

хорошо, теперь злодей может взлететь и всегда смотреть в центр следующей коробки, игра в масштабе. Но теперь есть относительно очевидная проблема, то есть изменение значения заряда, а затем настройка значения заряда.

Оптимизация стоимости заряда

Логика изменения текущего значения тарификации вынесена в анимацию, фактически она находится вrequestAnimationFrameсередина,requestAnimationFrameВремя выполнения нестабильно, поэтому с ним нужно обращаться по-другому, а если использовать таймер? На самом деле, таймер не обязательно рассчитан по времени (по времени), самый надежный метод — записывать время, когда нажата мышь, а затем вычислять значение зарядки по разнице во времени, когда мышь отпускается, но на этот раз разница имеет максимальное значение, которое является значением зарядки максимальное время. Теперь реализуйтеcomputePowerStorageValueМетод вычисляет значение заряда во времени, а затем преобразуетjumpЗаменить параметры в методе (коэффициенты перепробованы много раз, чтобы определить, что это больше похоже на скачки WeChat)

  computePowerStorageValue () {
    const { powerStorageDuration, powerStorageTime, v0, theta } = this
    const diffTime = Date.now() - powerStorageTime
    const time = Math.min(diffTime, powerStorageDuration)
    const percentage = time / powerStorageDuration

    return {
      v0: v0 + 30 * percentage,
      theta: theta - 50 * percentage
    }
  }

Я думал, что смогу написать о том же через несколько дней, но я не ожидал, что ошибка оценки будет слишком большой, я продолжу обновлять его через несколько дней...

Если это было полезно для вас, пожалуйста, поставьте лайк, спасибо старому железу!