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

Физика

Шаг 4 3D-трека. До сих пор всё во дворе стояло там, куда ты это поставил, и проходило друг сквозь друга. Сейчас подключим Jolt: ящики начнут падать и сталкиваться, лиса — ходить ногами и упираться в препятствия, а зелёная зона — считать, что в неё затолкали.

Физика в LeCodes — это аспекты на узлах, и их всего четыре:

  • Shape — форма для столкновений (и для пальца: именно её ты уже подключал ради тапов);
  • Physics — твёрдое тело: static, dynamic или kinematic;
  • Trigger — зона-датчик: не мешает движению, но сообщает, кто вошёл;
  • CharacterController — капсула персонажа: ходит, скользит по стенам, поднимается по ступенькам.

Shape идёт первым: остальные три без него бросают ошибку.

Первые тела

Твёрдое тело — это два аспекта подряд: форма и Physics. Полу — motion: "static" (никогда не двигается), ящикам — умолчание dynamic (гравитация, столкновения, масса):

TypeScript
// Мир настраивается один раз, до первого тела (дефолт и так -9.81 — здесь для наглядности)
Physics.configure({ gravity: [0, -9.81, 0] })

const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))

// Пол — тоже тело: Shape (коробка, ПОЛУразмеры) + Physics static. Без него ящики упадут в бесконечность
const floor = Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true })
  .aspect(Shape, { box: [12, 0.05, 12] })
  .aspect(Physics, { motion: "static" })
scene.add(floor)

// Штабель: Shape {} снимает коробку с меша, Physics — динамическое тело. Порядок важен: сначала Shape
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
for (let i = 0; i < 5; i++) {
  scene.add(
    Mesh.box({ material: wood, size: 0.6, position: [0, 0.3 + i * 0.62, 0], eulerAngles: [0, i * 12, 0], castShadows: true })
      .aspect(Shape, {})
      .aspect(Physics, { mass: 1 }),
  )
}

scene.camera.position = [0, 3, 6]
scene.camera.lookAt([0, 1, 0])
const label = UIText(Physics.supported ? "Тапни по ящику — толчок, по земле — новый ящик" : "На этом хосте нет физики")
  .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 ground = new Plane([0, 1, 0], [0, 0, 0])
scene.addEventListener("click", ev => {
  const ray = scene.camera.getRay(ev.clientX, ev.clientY)
  const hit = Physics.raycast(ray.origin, ray.dir, 100)
  if (hit?.node && hit.node !== floor) {
    // Динамическое тело двигают импульсом, а не position: физика — хозяин трансформа
    hit.node.get(Physics)?.applyImpulse(ray.dir.normalize().scale(5))
    return
  }
  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, 4, p.z], eulerAngles: [20, 40, 0], castShadows: true })
      .aspect(Shape, {}).aspect(Physics, { mass: 1 }),
  )
})
  • Порядок аспектов: ShapePhysics. Наоборот — ошибка «Physics requires a Shape aspect». Это самая частая опечатка в физическом коде.
  • box — это ПОЛУразмеры, в мировых единицах, и scale узла на них не влияет. Куб Mesh.box() размером 1×1×1 накрывается box: [0.5, 0.5, 0.5] — или просто {}, чтобы форма снялась с меша автоматически.
  • Физика владеет трансформом динамического тела. Присваивать ему position каждый кадр — значит драться с симуляцией: тело дёргается и проваливается сквозь стены. Двигай его applyImpulse (мгновенный толчок) или physics.velocity (скорость), а читать position можно всегда — движок держит его в актуальном состоянии.
  • Physics.configure() — один раз и до первого тела. Позже он игнорируется. Оттуда же берётся maxBodies, если тел будет больше четырёх тысяч.
  • Physics.supported — хост может быть без физики: тогда всё на этой странице молча ничего не делает (аспекты подключаются, скорости нулевые, событий нет). Если геймплей завязан на физику, проверяй флаг и говори это пользователю.
  • Масса имеет смысл только для dynamic. Тяжёлое тело сложнее сдвинуть тем же импульсом — импульс измеряется в кг·м/с.
Заметка

Тела не «уничтожаются» вместе с мешем: scene.remove(node) убирает узел из отрисовки, а тело живёт до node.destroy(). Спавнишь много — не забывай удалять.

Кинематика и триггеры

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

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 })
  .aspect(Shape, { box: [12, 0.05, 12] }).aspect(Physics, { motion: "static" }))
scene.camera.position = [0, 4.5, 6.5]
scene.camera.lookAt([0, 0.3, 0])

// Кинематическое тело: не падает и его не сдвинуть, но других оно толкает. Двигаем через moveTo
const sweeper = Mesh.box({ material: Material.lit({ color: "#5e9eff", roughness: 0.6 }), size: [0.3, 0.6, 2.4], position: [-3, 0.3, 0], castShadows: true })
  .aspect(Shape, {}).aspect(Physics, { motion: "kinematic" })
const crate = Mesh.box({ material: Material.lit({ color: "#b5854b", roughness: 0.8 }), size: 0.6, position: [-1, 0.3, 0], castShadows: true, name: "crate" })
  .aspect(Shape, {}).aspect(Physics, {})

// Триггер: зона-датчик. Тела проходят сквозь неё, но enter/exit приходят
const zone = Mesh.box({ material: Material.unlit({ color: "#34c759" }), size: [2, 0.02, 2], position: [2, 0.01, 0], name: "zone" })
  .aspect(Shape, { box: [1, 0.5, 1] }).aspect(Trigger)
scene.add(sweeper, crate, zone)

const inside = new Set<Node>()
const label = UIText("в зоне: 0 · тапни по земле — упадёт мяч").style({ fontSize: 13, color: "#c7ccd6" })
const refresh = () => { label.text = `в зоне: ${inside.size} · тапни по земле — упадёт мяч` }
zone.addEventListener("enter", other => { inside.add(other); refresh() })
zone.addEventListener("exit", other => { inside.delete(other); refresh() })

const hud = UIWidget(label).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()

let t = 0
setLoop(dt => {
  t += dt
  sweeper.physics.moveTo([-3 + (Math.sin(t * 0.6) + 1) * 2, 0.3, 0])
})

const rubber = Material.lit({ color: "#FF4032", roughness: 0.6 })
const ground = new Plane([0, 1, 0], [0, 0, 0])
scene.addEventListener("click", ev => {
  const ray = scene.camera.getRay(ev.clientX, ev.clientY)
  const t = ground.intersectRay(ray)
  if (t === null) return
  const p = ray.getPoint(t)
  scene.add(Mesh.sphere({ material: rubber, radius: 0.25, position: [p.x, 3, p.z], castShadows: true, name: "ball" })
    .aspect(Shape, { sphere: 0.25 }).aspect(Physics, { mass: 0.5 }))
})

Три типа движения — и разные способы ими управлять:

motionКто двигаетЧем двигать
staticниктоничем — пол, стены, стволы деревьев
dynamicсимуляцияapplyImpulse, velocity
kinematicтыmoveTo — каждый кадр или разово
  • moveTo — не телепорт, а «поставь тело сюда к следующему шагу». Именно поэтому кинематическая платформа толкает ящики: движок видит её перемещение и разрешает столкновения. Присваивание position такого эффекта не даёт.
  • enter / exit приходят на оба узла контакта, аргумент — второй участник. Триггеру нужен Shape (и он статический), телу — Shape + Physics; между двумя триггерами событий не будет.
  • Считай вошедших множеством, а не счётчиком. Set переживает повторные enter (тело может «подпрыгнуть» на границе) и точно знает состав — счётчик рано или поздно уедет.
  • Триггеру можно двигаться: zone.trigger.moveTo(point) — так делают движущиеся зоны сбора.
  • У контактов нет силы удара в событии: если нужна «громкость» столкновения, сравни velocity до и после — или просто по скорости в момент enter.

Персонаж

Лису можно было бы сделать динамическим телом — и она бы заваливалась на бок, отскакивала от ящиков и застревала в углах. Для персонажей есть отдельный аспект: CharacterController — капсула, которая скользит вдоль стен, поднимается по ступенькам и никогда не падает набок. Ты даёшь ей только намерение — «иду туда»:

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 })
  .aspect(Shape, { box: [15, 0.05, 15] }).aspect(Physics, { motion: "static" }))
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
for (const [x, z] of [[1.5, -1], [-1.5, -1.5], [0, -3], [2.5, 1]]) {
  scene.add(Mesh.box({ material: wood, size: 0.45, position: [x, 0.23, z], castShadows: true }).aspect(Shape, {}).aspect(Physics, { mass: 0.3 }))
}
scene.camera.position = [0, 4, 6]
scene.camera.lookAt([0, 0.5, 0])

// Экранный d-pad: пока палец на кнопке — направление держится, отпустил — ноль.
// track() нужен ради onEnd/onCancel: onClick сработал бы только на отпускании
let mx = 0, mz = 0
const key = (label: string, x: number, z: number) =>
  UIButton(UIText(label).style({ color: "#fff", fontSize: 18 }))
    .style({ bgColor: "#ffffff33", width: 48, height: 48, borderRadius: 12, alignItems: "center", justifyContent: "center", $pressed: { bgColor: "#ffffff66" } })
    .onTouchStart(ev => {
      mx = x; mz = z
      ev.track({ claim: true, onEnd: () => { mx = 0; mz = 0 }, onCancel: () => { mx = 0; mz = 0 } })
    })
const pad = UIWidget(
  UIColumn(UIRow(key("↑", 0, -1)), UIRow(key("←", -1, 0), key("↓", 0, 1), key("→", 1, 0)).style({ gap: 6 })).style({ gap: 6, alignItems: "center" }),
).style({ bottom: "max(safe-bottom, 24px)", left: 0, right: 0, alignItems: "center" })
pad.attachTo(scene)
scene.open()
pad.show()

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

  // Контроллер живёт на пустом узле-капсуле; лиса — его ребёнок, сдвинутый вниз, чтобы лапы стояли на земле.
  // Позицию задаём ДО аспектов: контроллер стартует там, где узел был в момент подключения
  const hero = new Node()
  hero.position = [0, 0.26, 1.5]
  hero.aspect(Shape, { capsule: { halfHeight: 0.06, radius: 0.2 } })
    .aspect(CharacterController, { speed: 2.5 })
  fox.position = [0, -0.26, 0]
  hero.add(fox)
  scene.add(hero)

  fox.anim.play("walk", { loop: true })
  let camPos = scene.camera.position
  setLoop(dt => {
    hero.controller.move(mx, mz)                       // «липко»: действует, пока не пришлёт 0
    const moving = mx !== 0 || mz !== 0
    fox.anim.playing = moving
    if (moving) fox.eulerAngles = [0, Math.atan2(mx, mz) * RAD2DEG, 0]

    // Камера догоняет героя — приём из прошлого шага, иначе он уйдёт за край кадра
    camPos = camPos.lerp(hero.position.add([0, 3.4, 4.5]), 1 - Math.pow(0.02, dt))
    scene.camera.position = camPos
    scene.camera.lookAt(hero.position)
  })
}
main()
  • move(x, z) — это намерение, а не скорость и не сдвиг. Оба числа в диапазоне −1…1, скорость берётся из speed. Значение липкое: контроллер будет идти туда, пока не получит move(0, 0) — отсюда onEnd и onCancel в кнопках.
  • Контроллер — не твёрдое тело. Physics ему не нужен и не подключается; своя гравитация (gravity, по умолчанию −20), свой прыжок (jump(), скорость в jumpSpeed), свой уклон (maxSlope). Он сталкивается с телами, но проходит сквозь триггеры и сам не считается пикируемым.
  • Отдельный узел под контроллер, модель — ребёнком. Так капсула и визуал не мешают друг другу: капсулу подгоняешь под столкновения, модель — сдвигом вниз, чтобы лапы стояли на земле, а разворот модели не крутит саму капсулу.
  • Позиция — до аспектов. Тело и контроллер создаются там, где узел стоит в момент подключения; передвинуть потом — controller.teleport(x, y, z).
  • Полезные чтения: controller.grounded (стоит ли на земле — для «прыжок только с земли» и для выбора анимации падения) и controller.velocity.
  • Толкать ящики контроллер умеет, но мягко — это не таран. Если нужен настоящий удар, дай персонажу отдельное динамическое тело-«кулак» или бей импульсом по цели вручную.

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

Собираем маленькую игру: лиса ходит по двору с d-pad, толкает ящики, зелёная зона считает закатанные, тап по экрану стреляет мячом из камеры. Стволы деревьев теперь тоже препятствия:

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 })
  .aspect(Shape, { box: [15, 0.05, 15] }).aspect(Physics, { motion: "static" }))

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.aspect(Shape, { cylinder: { halfHeight: 1, radius: 0.15 } }).aspect(Physics, { motion: "static" })   // ствол — препятствие
}
const wood = lit("#b5854b", 0.8)
const Crate = (x: number, y: number, z: number) =>
  Mesh.box({ material: wood, size: 0.45, position: [x, y, z], castShadows: true, name: "crate" }).aspect(Shape, {}).aspect(Physics, { mass: 0.3 })
scene.add(Tree(-3.5, -1.5, 2), Tree(3.2, -2.5, 1.6), Tree(-3, 2.8, 1.3), Tree(3.5, 2, 2.2))
scene.add(Crate(-0.5, 0.23, -1.5), Crate(0.5, 0.23, -1.5), Crate(0, 0.7, -1.5), Crate(1.8, 0.23, 0.5), Crate(-1.8, 0.23, 0.8))

// Зона-триггер считает ящики внутри
const zone = Mesh.box({ material: Material.unlit({ color: "#34c759" }), size: [2, 0.02, 2], position: [0, 0.01, 3], name: "zone" })
  .aspect(Shape, { box: [1, 0.5, 1] }).aspect(Trigger)
const inside = new Set<Node>()
const score = UIText("Загружаю лису…").style({ fontSize: 14, color: "#ffffff", fontWeight: 700 })
const refresh = () => { score.text = `Ящиков в зоне: ${inside.size} из 5` }
zone.addEventListener("enter", o => { if (o.name === "crate") { inside.add(o); refresh() } })
zone.addEventListener("exit", o => { inside.delete(o); refresh() })
scene.add(zone)

// d-pad
let mx = 0, mz = 0
const key = (label: string, x: number, z: number) =>
  UIButton(UIText(label).style({ color: "#fff", fontSize: 18 }))
    .style({ bgColor: "#ffffff33", width: 48, height: 48, borderRadius: 12, alignItems: "center", justifyContent: "center", $pressed: { bgColor: "#ffffff66" } })
    .onTouchStart(ev => { mx = x; mz = z; ev.track({ claim: true, onEnd: () => { mx = 0; mz = 0 }, onCancel: () => { mx = 0; mz = 0 } }) })
const hud = UIWidget(score, UIText("Тап по экрану — выстрел мячом").style({ fontSize: 12, color: "#c7ccd6" })).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 2 })
const pad = UIWidget(UIColumn(UIRow(key("↑", 0, -1)), UIRow(key("←", -1, 0), key("↓", 0, 1), key("→", 1, 0)).style({ gap: 6 })).style({ gap: 6, alignItems: "center" }))
  .style({ bottom: "max(safe-bottom, 24px)", left: 0, right: 0, alignItems: "center" })
hud.attachTo(scene); pad.attachTo(scene)
scene.camera.position = [0, 4.2, 6.5]
scene.camera.lookAt([0, 0.4, 0.3])
scene.open(); hud.show(); pad.show()

// Пушка: мяч рождается у камеры и получает импульс вдоль луча тапа
const rubber = lit("#FF4032", 0.6)
scene.addEventListener("click", ev => {
  const ray = scene.camera.getRay(ev.clientX, ev.clientY)
  const ball = Mesh.sphere({ material: rubber, radius: 0.2, position: ray.origin, castShadows: true, name: "ball" })
    .aspect(Shape, { sphere: 0.2 }).aspect(Physics, { mass: 2 })
  scene.add(ball)
  ball.physics.applyImpulse(ray.dir.normalize().scale(24))
})

async function main() {
  const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
  fox.castShadows = true; fox.position = [0, -0.26, 0]
  const hero = new Node()
  hero.position = [0, 0.26, 1]
  hero.aspect(Shape, { capsule: { halfHeight: 0.06, radius: 0.2 } }).aspect(CharacterController, { speed: 2.5 })
  hero.add(fox)
  scene.add(hero)
  fox.anim.play("walk", { loop: true })
  refresh()
  let camPos = scene.camera.position
  setLoop(dt => {
    hero.controller.move(mx, mz)
    fox.anim.playing = mx !== 0 || mz !== 0
    if (fox.anim.playing) fox.eulerAngles = [0, Math.atan2(mx, mz) * RAD2DEG, 0]

    // Камера мягко держит лису в кадре (шаг «Камера и пикинг»)
    camPos = camPos.lerp(hero.position.add([0, 4, 5.5]), 1 - Math.pow(0.02, dt))
    scene.camera.position = camPos
    scene.camera.lookAt(hero.position.add([0, 0.2, 0]))
  })
}
main()

Это уже игра: цель, средства, обратная связь. И почти весь её код — не про физику, а про то, что вокруг неё: кнопки, счёт, спавн. Физика в этом примере занимает четыре строки аспектов — так и должно быть.

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

Что ты узнал

  • Физика — это аспекты: Shape (форма и пикинг) → Physics / Trigger / CharacterController. Shape всегда первый.
  • motion: static — пол и стены, dynamic — симуляция (applyImpulse, velocity), kinematic — ведёшь ты через moveTo.
  • Динамическому телу нельзя присваивать position — физика владеет его трансформом; читать можно всегда.
  • Trigger — зона-датчик; enter / exit приходят на оба узла, состав удобнее держать в Set.
  • CharacterController — капсула персонажа: move(x, z) липкое, jump(), grounded, своя гравитация; отдельный узел под капсулу, модель — ребёнком.
  • Physics.supported — хост может быть без физики, и тогда всё это молча не работает.
  • Physics.configure() — один раз до первого тела; box в Shape — полуразмеры в мировых единицах.

Справочник: Физика и формы · Аспекты · Node · Ray, Plane & Noise.

Дальше: Та же сцена в AR — двор переезжает на реальный стол.