Физика
Шаг 4 3D-трека. До сих пор всё во дворе стояло там, куда ты это поставил, и проходило друг сквозь друга. Сейчас подключим Jolt: ящики начнут падать и сталкиваться, лиса — ходить ногами и упираться в препятствия, а зелёная зона — считать, что в неё затолкали.
Физика в LeCodes — это аспекты на узлах, и их всего четыре:
Shape— форма для столкновений (и для пальца: именно её ты уже подключал ради тапов);Physics— твёрдое тело:static,dynamicилиkinematic;Trigger— зона-датчик: не мешает движению, но сообщает, кто вошёл;CharacterController— капсула персонажа: ходит, скользит по стенам, поднимается по ступенькам.
Shape идёт первым: остальные три без него бросают ошибку.
Первые тела
Твёрдое тело — это два аспекта подряд: форма и Physics. Полу — motion: "static" (никогда не двигается), ящикам — умолчание dynamic (гравитация, столкновения, масса):
// Мир настраивается один раз, до первого тела (дефолт и так -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 }),
)
})- Порядок аспектов:
Shape→Physics. Наоборот — ошибка «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(). Спавнишь много — не забывай удалять.
Кинематика и триггеры
Не всё, что движется, должно падать. Кинематическое тело водишь ты: гравитация его не берёт, столкнуть его нельзя, а вот других оно расталкивает — это платформы, двери, лифты, поршни. А триггер вообще не мешает движению: он только сообщает, кто в него вошёл и вышел:
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 — капсула, которая скользит вдоль стен, поднимается по ступенькам и никогда не падает набок. Ты даёшь ей только намерение — «иду туда»:
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, толкает ящики, зелёная зона считает закатанные, тап по экрану стреляет мячом из камеры. Стволы деревьев теперь тоже препятствия:
// Двор с физикой: лиса на контроллере толкает ящики в зелёную зону, тап по экрану стреляет мячом.
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 — двор переезжает на реальный стол.