Реальные истории:Как тренер футбольной школы создал приложения за 1 неделю скоро

Модели и анимация

Шаг 2 3D-трека. Примитивы из прошлого шага хороши для сцены, но персонажа из кубов не собрать. Персонаж приходит из GLB — файла, в котором лежат меши, скелет и запечённые клипы анимации. В LeCodes это отдельный вид узла: Model.

Во двор въезжает лиса: загрузим модель, разберёмся с её клипами, размножим её без повторной загрузки и отправим бегать по кругу.

Загрузка модели

Model.load() возвращает промис. И тут первая особенность рантайма: верхнеуровневого await в программе нет — бандл выполняется как обычная функция. Всё, что грузится, заворачивай в async-функцию и вызывай её:

TypeScript
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true }))
scene.camera.position = [0, 0.9, 1.9]
scene.camera.lookAt([0, 0.2, 0])

const status = UIText("Загружаю модель…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(status).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()

// Бандл выполняется без верхнеуровневого await — загрузку заворачиваем в async-функцию
async function main() {
  const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
  fox.castShadows = true
  scene.add(fox)

  // Что лежит в файле — единственный источник правды про клипы
  status.text = fox.anim.clips.map(c => `${c.name}${c.duration.toFixed(1)}s`).join("\n")
  fox.anim.play("walk", { loop: true })

  let t = 0
  setLoop(dt => { t += dt; fox.eulerAngles = [0, Math.sin(t * 0.5) * 35, 0] })
}
main()
  • Сцену открывай сразу, модель добавляй потом. Сцена уже нарисована, пока идёт загрузка, — на экране пусто, но не чёрно, и есть куда написать «Загружаю…». Так же поступают с любым долгим ресурсом.
  • Модель — это Node, просто другого вида: position, scale, eulerAngles, castShadows, события — всё как у меша. Внутренние узлы GLB висят детьми, и model.traverse(node => …) обходит их (по node.name можно найти нужную кость или деталь).
  • Проверь масштаб — единицы в GLB какие угодно. Модель приходит в тех единицах, в которых её экспортировали, и часто множитель уже зашит в сам файл (у нашей лисы корневой узел стоит со scale 0.01, поэтому в коде масштаб не трогаем вовсе). Если модель после add не видна или занимает полэкрана — это первое, что нужно посмотреть: model.scale = 0.01 поверх уже сжатого файла даёт точку в десятую долю миллиметра. Ориентир — метры: взрослая лиса сантиметров семьдесят в холке.
  • Источник — путь ассета asset("./fox.glb") в реальном проекте (файл лежит рядом с кодом) или, как здесь, обычный https://-URL. Промис отклоняется при HTTP-ошибке или битом файле — в бою оборачивай в try/catch и показывай понятное сообщение.
Внимание

Верхнеуровневый await в main.ts компилируется без ошибки, но программа молча не запускается: и веб-раннер, и десктоп-хост выполняют бандл как функцию. Если экран пустой и в консоли тихо — первым делом ищи await вне функции.

Клипы

Анимации приходят вместе с GLB и живут в аспекте model.anim — он уже прикреплён, создавать его не нужно. clips — список того, что запечено в файле; play() принимает имя или индекс:

TypeScript
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true }))
scene.camera.position = [0, 0.9, 1.9]
scene.camera.lookAt([0, 0.2, 0])

const label = UIText("…").style({ fontSize: 13, color: "#c7ccd6" })
const row = UIRow().style({ gap: 8 })
const hud = UIWidget(label, row).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 8 })
hud.attachTo(scene)
scene.open()
hud.show()

async function main() {
  const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
  fox.castShadows = true
  scene.add(fox)

  // Чипы строим по тому, что реально лежит в файле, а не по списку имён «из головы»
  const chip = (name: string) =>
    UIButton(UIText(name).style({ color: "#fff", fontSize: 13, fontWeight: 700 }))
      .style({ bgColor: "#ffffff22", borderRadius: 999, px: 12, py: 7, $pressed: { opacity: 0.6 } })
      .onClick(() => { fox.anim.play(name, { loop: true }); label.text = `клип: ${name}` })
  row.append(...fox.anim.clips.map(c => chip(c.name)))

  // Один цикл шага без loop: по его окончании придёт событие completed
  fox.anim.play("walk", { loop: false })
  label.text = "клип: walk (один раз)"
  fox.addEventListener("completed", () => {
    label.text = "walk доиграл → idle, в цикле"
    fox.anim.play("idle", { loop: true })
  })
  fox.addEventListener("loopReached", clip => console.log("loop", clip))
}
main()

Лиса проходит один цикл шага и переходит в idle — это сделало событие completed. Чипы построены прямо из anim.clips: сколько клипов в файле, столько и кнопок.

  • play(clip, { loop }) — одно и то же и для цикла, и для одиночного проигрывания. Ещё есть anim.speed (множитель скорости), anim.time (перемотка в секундах) и anim.playing = false (пауза).
  • События приходят на узел, а не на аспект: model.addEventListener("completed" | "loopReached", clip => …), аргумент — индекс клипа. После completed аспект сам ставит playing = false.
  • Незнакомое имя игнорируется молча: play("Runn") не бросит ошибку — просто продолжит играть предыдущий клип. Именно поэтому anim.clips — единственный источник правды: перезалили модель с другими именами, и код с зашитыми строками тихо перестал анимировать. play(0) по индексу застрахован от опечаток, но не от перестановки клипов в файле.
  • Кроссфейда нет: play() переключает клип со следующего кадра, жёстко. Для плавных переходов, блендинга и клипов из отдельных файлов есть аспект Animator — он же нужен для Mixamo-ассетов, где каждая анимация лежит своим GLB.

Кнопки в HUD — обычные UIButton из UI-трека: UIWidget над сценой ведёт себя как любой контейнер, включая императивные append/setContent.

Много экземпляров

Второй персонаж — не второй Model.load. Загруженная модель умеет clone(): копия переиспользует уже разобранный GLB (без повторной загрузки и парсинга) и получает собственное состояние анимации:

TypeScript
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 30, receiveShadows: true }))
scene.camera.position = [0, 1.8, 3.2]
scene.camera.lookAt([0, 0.2, 0])
const label = UIText("…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(label).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()

async function main() {
  const template = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
  const foxes = [template, ...Array.from({ length: 4 }, () => template.clone())]
  foxes.forEach((fox, i) => {
    const a = (i / foxes.length) * Mathf.TAU
    fox.position = [Math.cos(a) * 1.1, 0, Math.sin(a) * 1.1]
    fox.eulerAngles = [0, -a * RAD2DEG, 0]
    fox.castShadows = true
    scene.add(fox)
    // Состояние анимации у каждой копии своё: свой клип, своя скорость, своя фаза
    fox.anim.play(i % 2 ? "walk" : "run", { loop: true })
    fox.anim.speed = 0.6 + i * 0.2
    fox.anim.time = i * 0.2
  })
  label.text = `${foxes.length} лис, одна загрузка`
}
main()

Пять лис, один сетевой запрос и один разбор файла. Клоны наследуют трансформ оригинала на момент копирования, но дальше живут сами по себе — своя позиция, свой клип, своя скорость и своя фаза (anim.time разводит их, чтобы не шагали синхронно, как рота).

  • Для «пула» врагов/снарядов делай так же: одна загрузка на старте, дальше clone() по мере надобности. Повторный Model.load того же URL — это лишняя работа и подтормаживание в момент спавна.
  • RAD2DEG и Mathf.TAU — глобалы. Углы узлов в градусах, тригонометрия — в радианах; конвертация нужна ровно на границе.
  • Убирать узел — node.destroy(); после этого узел непригоден. Часто дешевле спрятать (visible = false) и переиспользовать.

Экран целиком

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

TypeScript
// Двор из шага 1 + лисы: одна загрузка GLB, три экземпляра, каждый со своей анимацией.
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))

const lit = (color: string, roughness = 1) => Material.lit({ color, roughness })
scene.add(Mesh.plane({ material: lit("#2f4a35"), normal: [0, 1, 0], scale: 30, receiveShadows: true }))

function Tree(x: number, z: number, height = 1.6) {
  const tree = new Node()
  tree.position = [x, 0, z]
  const crown = Geometry.cylinder({ radiusTop: 0, radiusBottom: 0.55, edges: 8, smooth: false })
    .scale(1, height, 1).translate(0, height / 2 + 0.6, 0)
  tree.add(
    Mesh.cylinder({ material: lit("#6b4a2b"), radius: 0.12, scale: [1, 0.7, 1], position: [0, 0.35, 0], castShadows: true }),
    Mesh.from(crown, { material: lit("#3d8b4f"), castShadows: true }),
  )
  return tree
}
scene.add(Tree(-1.9, -2.2, 2), Tree(1.9, -2.8, 1.6), Tree(-2, 1.8, 1.3), Tree(2, 1.4, 2.2))

scene.camera.position = [0, 3.2, 5.5]
scene.camera.lookAt([0, 0.3, 0])

const status = UIText("Загружаю лис…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(
  UIText("Двор").style({ fontSize: 22, fontWeight: 800, color: "#ffffff" }),
  status,
).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 2 })
hud.attachTo(scene)
scene.open()
hud.show()

async function main() {
  const template = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")

  // Каждая лиса бежит по своему кругу; clone() — копия без повторной загрузки
  const walkers = [
    { model: template, radius: 1.2, speed: 0.5, clip: "walk" },
    { model: template.clone(), radius: 2, speed: 0.9, clip: "run" },
    { model: template.clone(), radius: 0.6, speed: 0.3, clip: "walk" },
  ]

  walkers.forEach(({ model, clip }, i) => {
    model.castShadows = true
    scene.add(model)
    model.anim.play(clip, { loop: true })
    model.anim.speed = 0.9 + i * 0.2
  })

  status.text = `${walkers.length} лисы · клипы: ${template.anim.clips.map(c => c.name).join(", ")}`

  let t = 0
  setLoop(dt => {
    t += dt
    for (const w of walkers) {
      const a = t * w.speed
      w.model.position = [Math.cos(a) * w.radius, 0, Math.sin(a) * w.radius]
      w.model.eulerAngles = [0, -a * RAD2DEG, 0]
    }
  })
}
main()

Скорость шага и скорость клипа связаны только на глаз: anim.speed подобран так, чтобы лапы не «скользили» по земле. Это нормальная ручная настройка — автоматической привязки анимации к перемещению (root motion) у anim нет; она есть у Animator.

Двигать узел присваиванием position можно ровно до тех пор, пока за него не взялась физика — про это следующие шаги.

Что ты узнал

  • Model.load(url | asset("./file.glb")) — промис; верхнеуровневого await нет, оборачивай в async function main().
  • Масштаб модели проверяй по файлу: часто он уже зашит в корневой узел GLB, и лишний scale делает модель невидимой.
  • Model — это Node: трансформ, scale под метры, castShadows, traverse по внутренним узлам.
  • model.anim: clips, play(name | index, { loop }), speed, time, playing; события completed / loopReached — на узле, с индексом клипа.
  • Незнакомое имя клипа игнорируется молча; кроссфейда нет — для переходов и блендинга есть Animator.
  • clone() — дешёвая копия загруженной модели с собственным состоянием анимации; так делают пулы.

Справочник: Model & ModelAnimation · Node · Аспекты · Глобалы хоста.

Дальше: Камера и пикинг — облетаем двор пальцем и попадаем по объектам.