Построение фигурки на JavaScript

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

Я создал веб-приложение Shaker Maker, которое позволяет пользователям создавать хореографию, синхронизировать свою хореографию с любой музыкой по своему выбору, слушать свою музыку и наблюдать за ее хореографией в действии. Вы можете увидеть полное демо-видео Shaker Maker здесь. Я создал Shaker Maker с бэкэндом Ruby on Rails и интерфейсом JavaScript, использующим React и Redux. Я хотел бы вернуться к этапам разработки приложения и описать свой мыслительный процесс, то, как я подходил к проблемам, с которыми я столкнулся, и мои решения проблем. Надеюсь, вы узнаете что-нибудь по пути. Это будет частью серии статей о создании Shaker Maker. В части I основное внимание будет уделено построению допустимой фигуры.

Идея

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

Допустимая фигура

Первым элементом, который мне нужно было построить, была фигурка. Я знал, что хочу использовать холст для фигуры, и я использовал библиотеку под названием Konva в предыдущем проекте (игра под названием Code Caverns), поэтому я хотел снова использовать эту библиотеку. Konva предоставляет слой абстракции поверх холста и имеет красивую оболочку React, react-konva, которая позволяет создавать компоненты React из объектов Konva (документацию по Konva можно найти здесь).

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

Фигура состоит из двух компонентов-контейнеров: JointLayer и LineLayer. Компонент JointLayer отображает все стыки на холсте в виде кругов. Положение каждого сустава сохраняется в хранилище Redux и обновляется каждый раз, когда сустав перемещается. Затем есть LineLayer, который отображает все части тела, соединяющие суставы. Этот слой обновляется каждый раз, когда сустав перемещается, так что части тела перемещаются вместе с суставами.

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

JointLayer

Движение перетаскивания, переходящее вниз к дочерним элементам, и все другие аспекты совместного движения контролируются функциями обратного вызова, которые являются методами компонента JointLayer. Эти обратные вызовы передаются каждому суставу в качестве свойств. Четыре основные функции обратного вызова, которые управляют движением суставов: onDragStart(), onDragMove(), onDragEnd() и dragBound().

onDragStart ()

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

onDragStart = (e) => {
  const { name, x, y } = e.target.attrs
  const newState = {}
  jointChildren[name].forEach (child => {
    const distX = this.props.joints[child].x - x
    const distY = this.props.joints[child].y - y
    newState[child] = {x: distX, y: distY} 
   })
   this.setState({
     dragStartDist: newState
   })
}  

jointChildren - это объект, который сопоставляет каждое родительское соединение с массивом его дочерних соединений.

onDragMove ()

Эта функция вызывается каждый раз, когда положение сустава изменяется во время щелчка и перетаскивания. Он выполняет итерацию по дочерним элементам сустава, который перетаскивается, и обновляет их позиции таким образом, чтобы расстояние между каждым дочерним элементом и перетаскиваемым суставом было таким же, как расстояние прямо перед началом перетаскивания сустава (т. Е. Расстояние, которое было сохранено в состоянии функцией onDragStart). Положение каждого сустава обновляется путем отправки действия в магазин Redux путем вызова moveJoint().

onDragMove = (e) => {
  const {name, x, y} = e.target.attrs
  const dist = this.state.dragStartDist
  jointChildren[name].forEach(child => {
    this.props.moveJoint(
      child, 
      dist[child].x + this.props.joints[name].x, 
      dist[child].y + this.props.joints[name].y
    )
  })
  this.props.moveJoint(name, x, y)
}

onDragEnd ()

onDragEnd() просто очищает расстояния, которые были сохранены в локальном состоянии onDragStart().

onDragEnd = () => {
  this.setState({
    dragStartDist: {}
  })
}

dragBound ()

Функция dragBound определяет условия, при которых сустав можно перетаскивать. Главное условие - расстояние между шарниром и его точкой поворота должно оставаться постоянным. Например, опорной точкой локтя является плечо, поэтому при перетаскивании локтя расстояние между локтем и плечом не должно изменяться. Это фактически означает, что соединение может двигаться только по кругу вокруг своей точки поворота. Функция dragBound вызывает findPivot(), чтобы получить шарнирное соединение перетаскиваемого соединения. Затем он вычисляет текущий масштаб части тела - конечности, которая соединяет перетаскиваемый сустав и его шарнирный сустав, - который показывает, насколько он будет растянут или сжат из-за движения сустава. Наконец, он корректирует растяжение или сжатие, так что масштаб всегда равен 1, а длина части тела остается постоянной.

dragBound = function(pos) {
  const pivot = this.attrs.findPivot(this.attrs.name)
  const stretch = Math.sqrt(Math.pow(pos.x - pivot.x, 2) + 
    Math.pow(pos.y - pivot.y, 2))
  const scale = pivot.radius / stretch
  if(scale !== 1 && this.attrs.name !== 'pelvis') {
    return {
      y: Math.round((pos.y - pivot.y) * scale + pivot.y),
      x: Math.round((pos.x - pivot.x) * scale + pivot.x)
    }
  } else {
    return {x: pos.x, y: pos.y}
  }
}

LineLayer

Все части тела фигуры отображаются в LineLayer. По сути, строится линия, которая соединяет каждое соединение с его родительским элементом, а для головы рисуется эллипс. Существует один метод компонента LineLayer, который обрабатывает рендеринг, drawLines().

рисовать линии()

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

const drawLines = () => {
  const joints = props.joints
  const lines = []
  let i = 0
  for(let part in bodyMap) {
    const {start, stop} = bodyMap[part]
    if (part === 'head') {
      const centerX = (joints[stop].x + joints[start].x) / 2
      const centerY = (joints[stop].y + joints[start].y) / 2
      
      lines.push(
        <Ellipse 
          key={++i} 
          x={centerX} 
          y={centerY} 
          radius={{x: 10, y: 13}} 
          stroke='#000' 
          strokeWidth={4}
        />
      )
    } else {
      const points = [
        joints[start].x, 
        joints[start].y, 
        joints[stop].x, 
        joints[stop].y
      ]
      lines.push(
        <Line 
          key={++i} 
          points={points} 
          stroke='#000' 
          strokeWidth={4} 
        />
      )
    }
  }
  props.setCurrentPose(lines)
  return lines
}

Результат

Взгляните на интерактивную фигурку!

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

Это первая часть. Надеюсь, она вам покажется интересной и / или полезной. Посмотрите Часть II, чтобы увидеть, как я создал функцию воспроизведения анимации, и Часть III (скоро), чтобы увидеть, как я реализовал встроенный в приложение музыкальный проигрыватель. Спасибо за чтение!