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

Камера и пикинг

Шаг 3 3D-трека. Сцена собрана, лиса бегает — но смотреть на всё это можно только с той точки, куда ты поставил камеру, и потрогать в сцене нечего. Этот шаг про две вещи, которые превращают картинку в приложение: камерой управляет палец, и палец попадает в объекты.

Обе задачи решаются одним и тем же: камера — обычный узел, а тач-события в 3D устроены так же, как в UI, только с одним новым понятием — луч.

Облететь сцену пальцем

Классическая камера-орбита: она сидит на сфере вокруг цели, а палец крутит два угла — yaw (вокруг вертикали) и pitch (высота над горизонтом). Драг ловим через touchstart + ev.track() — тот же API, что у перетаскивания в UI-треке:

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 }))
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
scene.add(
  Mesh.box({ material: wood, size: 0.8, position: [0.6, 0.4, 0.4], castShadows: true }),
  Mesh.box({ material: wood, size: 0.55, position: [0.6, 1.075, 0.4], eulerAngles: [0, 30, 0], castShadows: true }),
  Mesh.box({ material: wood, size: 0.6, position: [-0.7, 0.3, 1.1], eulerAngles: [0, -20, 0], castShadows: true }),
)

// Орбита: камера сидит на сфере вокруг цели. yaw — поворот вокруг вертикали,
// pitch — высота над горизонтом, radius — дистанция. Углы тут в радианах: это чистая математика
const target = [0, 0.5, 0]
let yaw = 0.6, pitch = 0.5, radius = 6
function placeCamera() {
  scene.camera.position = [
    target[0] + radius * Math.cos(pitch) * Math.sin(yaw),
    target[1] + radius * Math.sin(pitch),
    target[2] + radius * Math.cos(pitch) * Math.cos(yaw),
  ]
  scene.camera.lookAt(target)
}
placeCamera()

// Палец лёг на сцену: запоминаем углы в момент касания и прибавляем к ним смещение пальца
scene.addEventListener("touchstart", ev => {
  const yaw0 = yaw, pitch0 = pitch
  ev.track({
    claim: true,
    onMove: p => {
      yaw = yaw0 - (p.clientX - ev.clientX) * 0.01
      pitch = Mathf.clamp(pitch0 + (p.clientY - ev.clientY) * 0.01, 0.1, 1.4)
      placeCamera()
    },
  })
})

// Приблизить/отдалить — обычные кнопки в HUD, меняющие radius
const zoom = (label: string, step: number) =>
  UIButton(UIText(label).style({ color: "#fff", fontSize: 18, fontWeight: 700 }))
    .style({ bgColor: "#ffffff22", width: 40, height: 40, borderRadius: 12, alignItems: "center", justifyContent: "center", $pressed: { opacity: 0.6 } })
    .onClick(() => { radius = Mathf.clamp(radius + step, 3, 12); placeCamera() })
const hud = UIWidget(
  UIText("Потяни, чтобы облететь").style({ fontSize: 13, color: "#c7ccd6" }),
  UIRow(zoom("−", 1), zoom("+", -1)).style({ gap: 8 }),
).style({ top: "max(safe-top, 16px)", left: 16, gap: 10 })
hud.attachTo(scene)
scene.open()
hud.show()
  • Камера — узел, а не режиссёр. scene.camera.position и lookAt(point) — единственное, что нужно; никакого «контроллера камеры» в SDK нет и не надо. Ставь позицию сам — из орбиты, из позиции героя, из чего угодно.
  • Считай позицию, а не крути камеру. Соблазн сделать camera.eulerAngles.y += … заканчивается сползанием и гимбл-локом. Держи в переменных состояние (yaw / pitch / radius) и каждый раз пересчитывай позицию с нуля — это и чинится проще, и сохраняется в одну строчку.
  • Драг — это touchstart + ev.track(), ровно как в UI. Углы, снятые в момент касания (yaw0, pitch0), плюс смещение от точки касания (p.clientX - ev.clientX) — не путай с p.deltaX: тот даёт сдвиг между соседними событиями, а не от начала жеста.
  • claim: true говорит: этот жест мой. В гайде сцена лежит внутри страницы, которая тоже хочет скроллиться пальцем, — без claim прокрутка отберёт палец и придёт onCancel.
  • Mathf.clamp(pitch, 0.1, 1.4) не даёт камере нырнуть под пол и перевернуться в зените — почти всегда нужен именно clamp, а не «свободная» камера.
  • Тач в 3D приходит в логических px — те же координаты, что у ev.clientX в UI. Пригодится через абзац.

Ещё есть линза: camera.fov (вертикальный угол в градусах, по умолчанию 60), camera.near / camera.far — дальность видимости. Полный список — в справочнике Camera.

Попасть пальцем в объект

Меш сам по себе для пальца прозрачный: движок рисует его, но не знает, где у него границы. Чтобы по объекту можно было тапнуть, ему нужен аспект Shape — тело для попаданий. {} снимает коробку прямо с меша:

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, 3.5, 5]
scene.camera.lookAt([0, 0.4, 0])

const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
const picked = Material.lit({ color: "#FF4032", roughness: 0.8 })

// Shape делает узел «осязаемым» для пальца — без него тап проходит сквозь меш.
// {} — коробка по размеру самого меша
const crates = [[-1.4, 0.3], [0.2, -0.6], [1.5, 0.5]].map(([x, z], i) =>
  Mesh.box({ material: wood, size: 0.8, position: [x, 0.4, z], eulerAngles: [0, x * 25, 0], castShadows: true, name: `crate ${i + 1}` })
    .aspect(Shape, {}),
)
scene.add(...crates)

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()

// Слушатель на узле — когда реакция принадлежит самому объекту
const taps = crates.map(() => 0)
crates.forEach((crate, i) => crate.addEventListener("click", () => taps[i]++))

// Слушатель на сцене видит каждый тап: ev.target — узел с Shape под пальцем или null.
// Срабатывают оба: сначала узел, потом сцена
let current: Mesh | null = null
scene.addEventListener("click", ev => {
  current?.setMaterial(wood)
  current = ev.target as Mesh | null
  current?.setMaterial(picked)
  label.text = current ? `выбран ${current.name} · тапов по нему: ${taps[crates.indexOf(current)]}` : "мимо — выбор снят"
})
  • Нет Shape — нет попаданий. Это самая частая причина «почему не работает клик»: узел рисуется, но тела для пикинга у него нет, и тап уходит в сцену с ev.target === null.
  • Два места, где слушать. node.addEventListener("click", …) — реакция принадлежит объекту; scene.addEventListener("click", …) — видит каждый тап, включая промахи, и знает, во что попали (ev.target). Промах ловится только сценой — вот вся разница.
  • Shape без физики — это «снимок». Голый Shape создаёт статическое тело для попаданий в момент подключения: если узел потом поедет, тело останется на месте. Для движущегося объекта дай ему настоящее тело — следующий шаг.
  • {} — коробка по мешу, но можно и точнее: { sphere: 0.4 }, { capsule: { halfHeight: 0.5, radius: 0.3 } }, { box: [0.4, 0.4, 0.4] }. У коробки — полуразмеры: box: [0.5, 0.5, 0.5] вокруг куба 1×1×1. И это мировые единицы: scale узла на них не влияет.
  • raycast: false в Shape убирает узел из пикинга, оставляя его физическое поведение, — так помечают декор, который не должен перехватывать тапы.
  • Событие touchstart работает на узлах точно так же и умеет ev.track() — это готовое «схватил объект и потащил».

Луч и плоскость

Тап даёт точку на экране, а мир — трёхмерный: одной точке экрана соответствует целый луч, уходящий вглубь сцены. camera.getRay(x, y) строит этот луч, а дальше есть два способа спросить, во что он попал: Physics.raycast — по телам, Plane.intersectRay — по математической плоскости (пол, стена, стол), у которой нет ни меша, ни тела:

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, 4.5, 6]
scene.camera.lookAt([0, 0, -0.5])
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })

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()

// Пол — математическая плоскость y = 0 (не тело и не меш): луч из камеры пересекаем с ней
const ground = new Plane([0, 1, 0], [0, 0, 0])
let count = 0

scene.addEventListener("click", ev => {
  // Луч из камеры через точку экрана — в логических px, как ev.clientX / ev.clientY
  const ray = scene.camera.getRay(ev.clientX, ev.clientY)

  // 1. Спрашиваем физику: луч попал в существующий ящик? Убираем его
  const hit = Physics.raycast(ray.origin, ray.dir, 100)
  if (hit?.node) {
    scene.remove(hit.node)
    hit.node.destroy()
    label.text = `ящиков: ${--count}`
    return
  }

  // 2. Иначе — пересечение с полом: t — параметр вдоль луча, точку даёт getPoint(t)
  const t = ground.intersectRay(ray)
  if (t === null) return
  const p = ray.getPoint(t)
  scene.add(
    Mesh.box({ material: wood, size: 0.6, position: [p.x, 0.3, p.z], eulerAngles: [0, Math.random() * 90, 0], castShadows: true })
      .aspect(Shape, {}),
  )
  label.text = `ящиков: ${++count}`
})

Порядок в обработчике не случайный: сначала «попали в объект?», и только если нет — «куда на полу?». Так один тап делает разное в зависимости от того, что под пальцем, — и это стандартная схема для «поставить / выбрать / убрать».

  • Plane — это математика, а не объект сцены. new Plane([0, 1, 0], [0, 0, 0]) — бесконечная плоскость через начало координат нормалью вверх. Она ничего не рисует, ни с чем не сталкивается и стоит ноль: идеально для «куда на полу ткнули».
  • intersectRay возвращает t, а не точку. t — это параметр вдоль луча; точку даёт ray.getPoint(t). null означает «луч параллелен плоскости или смотрит от неё».
  • Physics.raycast(origin, dir, maxDist) находит ближайшее тело с Shape (в том числе голое, для пикинга) и возвращает { node, point, normal, fraction } или null. normal — нормаль поверхности в точке попадания: по ней ставят декали, ориентируют объекты по стене, отражают снаряды.
  • ev.target против raycast — одно и то же попадание, разными путями. Событие удобнее, когда «что-то тапнули»; явный raycast — когда луч нужен не из тапа (взгляд героя, прицел, проверка «видит ли враг игрока») или когда нужны point и normal.
  • destroy() — единственный способ убрать узел насовсем; после него узел непригоден. scene.remove(node) только убирает из сцены.
Заметка

Ограничение по дистанции у raycast — третий аргумент (по умолчанию 1000). fraction считается от него, так что расстояние до попадания = fraction × maxDist × |dir|; если нужны метры, нормализуй dir.

Камера, которая следит

Камера, жёстко привязанная к герою, дёргается и укачивает. Приём, который решает это в одну строку, — плавное догоняние: каждый кадр камера сдвигается не в желаемую точку, а на долю расстояния до неё:

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: 40, receiveShadows: true }))
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
for (let i = 0; i < 8; i++) {
  const a = (i / 8) * Mathf.TAU
  scene.add(Mesh.box({ material: wood, size: 0.6, position: [Math.cos(a) * 5, 0.3, Math.sin(a) * 5], eulerAngles: [0, i * 20, 0], castShadows: true }))
}
scene.camera.position = [0, 4, 8]
scene.camera.lookAt([0, 0, 0])

const follow = signal(true)
const chip = UIButton(UIText(() => follow.value ? "камера: следит" : "камера: стоит").style({ color: "#fff", fontSize: 13, fontWeight: 700 }))
  .style({ bgColor: "#ffffff22", borderRadius: 999, px: 12, py: 7, $pressed: { opacity: 0.6 } })
  .onClick(() => follow.value = !follow.value)
const hud = UIWidget(chip).style({ top: "max(safe-top, 16px)", left: 16 })
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)
  fox.anim.play("run", { loop: true })

  let camPos = scene.camera.position
  let t = 0
  setLoop(dt => {
    // Лиса бежит по кругу радиусом 3
    t += dt * 0.7
    fox.position = [Math.cos(t) * 3, 0, Math.sin(t) * 3]
    fox.eulerAngles = [0, -t * RAD2DEG, 0]
    if (!follow.value) return

    // Желаемая точка — позади лисы и выше. Камера не прыгает в неё, а догоняет:
    // доля пути за кадр, которая не зависит от FPS
    const behind = fox.position.add([Math.sin(t) * 3, 2, -Math.cos(t) * 3])
    camPos = camPos.lerp(behind, 1 - Math.pow(0.02, dt))
    scene.camera.position = camPos
    scene.camera.lookAt(fox.position.add([0, 0.4, 0]))
  })
}
main()
  • 1 - Math.pow(k, dt) — это правильный lerp по времени. Наивное lerp(target, 0.1) каждый кадр даёт разную скорость на 60 и на 120 FPS; в этой форме k — доля оставшегося расстояния за секунду (0.02 ≈ «за секунду сокращаем разрыв в 50 раз»), и картинка одинаковая на любом железе.
  • Догоняет позиция, а не взгляд. lookAt считается от уже сглаженной позиции — этого достаточно; сглаживать ещё и точку взгляда стоит только для медленных кинематографичных камер.
  • Векторы — значения. fox.position.add([...]) возвращает новый Vec3, исходный не меняется, — поэтому так удобно считать «позиция плюс смещение» прямо в аргументе. Изменить узел можно только присваиванием обратно (node.position = …) или скалярно (node.x = …).
  • Смещение [sin, 2, -cos] держит камеру за спиной бегущей по кругу лисы. В настоящей игре его берут от разворота героя: hero.position.sub(hero.forward.scale(4)).add([0, 2, 0])forward у любого узла уже есть.
  • Тот же приём — база для «камера в машине», «камера за плечом» и «камера, которая мягко возвращается в дефолт, когда её отпустили».

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

Собираем всё: двор, лиса, орбита пальцем, тап по земле — цель для лисы, тап по ящику — выбор. Камера при этом мягко держит лису в центре — то есть орбита и слежение работают одновременно:

TypeScript
// Двор + лиса: потяни — камера облетает лису, тапни по земле — лиса бежит туда, тапни по ящику — он подсветится.
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
}
const wood = lit("#b5854b", 0.8), picked = lit("#FF4032", 0.8)
const Crate = (x: number, z: number) =>
  Mesh.box({ material: wood, size: 0.8, position: [x, 0.4, z], eulerAngles: [0, x * 30, 0], castShadows: true }).aspect(Shape, {})
scene.add(Tree(-3.2, -1.4, 2), Tree(3, -2, 1.6), Tree(-2.6, 2.6, 1.3), Tree(3.2, 2.2, 2.2), Crate(2, 1), Crate(-2.5, -0.8), Crate(1, -2.5))

const hint = UIText("Загружаю лису…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(hint).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)

// Орбита вокруг цели; сама цель плавно едет за лисой
let yaw = 0.6, pitch = 0.5
let target = Vec3.from([0, 0.5, 0])
function placeCamera() {
  scene.camera.position = target.add([7 * Math.cos(pitch) * Math.sin(yaw), 7 * Math.sin(pitch), 7 * Math.cos(pitch) * Math.cos(yaw)])
  scene.camera.lookAt(target)
}
scene.addEventListener("touchstart", ev => {
  const yaw0 = yaw, pitch0 = pitch
  ev.track({ claim: true, onMove: p => {
    yaw = yaw0 - (p.clientX - ev.clientX) * 0.01
    pitch = Mathf.clamp(pitch0 + (p.clientY - ev.clientY) * 0.01, 0.15, 1.4)
  } })
})
placeCamera()
scene.open()
hud.show()

// Тап: по ящику — выбор, по земле — цель для лисы
const ground = new Plane([0, 1, 0], [0, 0, 0])
let goal: Vec3 | null = null, selected: Mesh | null = null
scene.addEventListener("click", ev => {
  if (ev.target) { selected?.setMaterial(wood); selected = ev.target as Mesh; selected.setMaterial(picked); return }
  const ray = scene.camera.getRay(ev.clientX, ev.clientY)
  const t = ground.intersectRay(ray)
  if (t !== null) { const p = ray.getPoint(t); goal = Vec3.from([p.x, 0, p.z]) }
})

async function main() {
  const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
  fox.castShadows = true
  fox.eulerAngles = [0, 90, 0]
  scene.add(fox)
  fox.anim.play("idle", { loop: true })
  hint.text = "Тапни по земле — лиса побежит. Потяни — камера облетит"

  let running = false
  setLoop(dt => {
    if (goal) {
      const to = goal.sub(fox.position)
      if (to.length() < 0.15) { goal = null; running = false; fox.anim.play("idle", { loop: true }) }
      else {
        if (!running) { running = true; fox.anim.play("run", { loop: true }) }
        fox.eulerAngles = [0, Math.atan2(to.x, to.z) * RAD2DEG, 0]   // модель смотрит вдоль своей оси +Z
        fox.position = fox.position.add(to.normalize().scale(3 * dt))
      }
    }
    target = target.lerp(fox.position.add([0, 0.5, 0]), 1 - Math.pow(0.01, dt))
    placeCamera()
  })
}
main()

Три независимых куска состояния — углы орбиты, цель камеры и цель лисы — и один setLoop, который сводит их в кадр. Так и растут 3D-сцены: не «одна большая функция update», а несколько маленьких кусочков состояния, каждый со своим правилом.

Разворот лисы считается через Math.atan2(to.x, to.z): atan2 даёт угол в радианах, RAD2DEG переводит в градусы, которых ждёт eulerAngles. Порядок аргументов зависит от того, какой осью модель смотрит вперёд: наша лиса — вдоль +Z, поэтому (x, z); для модели, смотрящей вдоль +X, было бы (-z, x). Это проверяется один раз, на глаз.

Что ты узнал

  • Камера — узел: position + lookAt; держи в переменных состояние (yaw / pitch / radius) и пересчитывай позицию, а не крути камеру.
  • Драг по сцене — scene.addEventListener("touchstart") + ev.track({ claim: true }); смещение считай от точки касания, delta* — это между событиями.
  • Без аспекта Shape узел невидим для пальца; слушатель на узле — про сам объект, слушатель на сцене — про каждый тап, включая промахи (ev.target === null).
  • camera.getRay(x, y)Physics.raycast(...) для тел или plane.intersectRay(ray) + ray.getPoint(t) для пола.
  • Плавная камера — lerp с долей 1 - Math.pow(k, dt), независимой от FPS.

Справочник: Camera · Ray, Plane & Noise · Физика и формы · Тач-события · Node.

Дальше: Физика — ящики начинают падать, а лиса — ходить ногами.