JavaScript в Engine

Скриптовый API движка Engine: жизненный цикл, ловушки координат, три способа создать объект и как двигать его, не проходя сквозь стены.

Коротко#

Любая сущность может нести скрипт на JavaScript. Он выполняется в браузере, в вашей вкладке, против живой сцены, и это канонический скриптовый API: диалект BASIC, а также представления Python, C# и Rust в редакторе, все компилируются в одну и ту же поверхность.

Исчерпывающий список, это справочник API: 538 членов в 50 пространствах имён, по странице на каждое, с поиском. Эта страница нужна для того, что стоит знать до его чтения.

Жизненный цикл#

function start() {}                     // once, when the entity starts
function update(dt) {}                  // every frame, dt in seconds
function fixedUpdate(dt) {}             // fixed timestep, for physics
function onCollisionEnter(other) {}     // first frame of contact
function onCollisionStay(other) {}      // every frame while touching
function onCollisionExit(other) {}      // contact just ended
function onTriggerEnter(other) {}       // entered a trigger volume
function onTriggerExit(other) {}
function onMessage(msg, data, senderId) {}   // entity-to-entity messaging
function onAnimationEvent(name, data) {}
function onDestroy() {}
function main() {}                      // console-game entry, async allowed

deltaTime, time, entityId и entity, это глобальные переменные; getComponent(type) читает компонент с той сущности, которая несёт скрипт.

Столкновения и триггеры срабатывают одинаково в 2D и в 3D, и каждая сторона получает свою собственную нормаль контакта: other.contact.normal указывает от другой сущности к этой. Именно это позволяет скрипту отличить «я приземлился на него» от «он упал на меня»: прыжок сверху на противника читается как other.contact.normal.y > 0.5.

Две ловушки координат#

Обе стоят реальных часов отладки, и ни одна не проявляет себя явно.

Мышь: страница против canvas#

input.mousePosition задана в координатах страницы. scene.pick() хочет координаты canvas. Если передать одно вместо другого, промаха не будет: попадание произойдёт в точку, смещённую на позицию области просмотра внутри окна, поэтому герой, управляемый кликом, пойдёт куда-то правдоподобное, но неверное.

Вызывайте scene.pick() без аргумента (тогда он целится в курсор), либо используйте input.mouseViewport, когда вам нужны сами числа.

Виртуальные элементы управления заданы в пикселях canvas#

input.addVirtualJoystick(id, x, y, size) и input.addVirtualButton(...) размещают свои центры в том же пространстве, что и mouseViewport. Джойстик возвращает нормализованный X/Y после своей радиальной мёртвой зоны; кнопка раздельно сообщает held, pressed-this-frame и released-this-frame. Их указатель захватывается и потребляется, поэтому касание элемента управления не репортится ещё и как обычное действие на canvas.

Джойстик по умолчанию унифицирован. input.getJoystickX/Y() выбирает один цельный вектор из джойстика на экране, физического геймпада или клавиатуры (WASD/стрелки), смотря какой источник активнее. Подключённый, но бездействующий геймпад поэтому не отключает управление с клавиатуры. Его ось Y следует экранному 2D-пространству: верх равен -1.

Три способа создать объект, и они не взаимозаменяемы#

Что это создаётЖивёт
scene.createEntity(def)авторскую сущность: она появляется в Scene Graph и в Inspectorвместе с проектом, она сохраняется
game.spawn(type, pos)голый примитив: без здоровья, без ИИ, без поведенияв рамках игровой сессии
game.spawnFrom(template, pos, opts)копию целой сущности: компоненты, физическое тело, теги, слой, масштаб и скомпилированный стек поведенийв рамках игровой сессии

game.spawn подходит для обломков и прототипов и бесполезен для врагов: спавнер, питающийся им, производит безжизненные шарики. Шаблон, который копирует spawnFrom, это обычная сущность, которую вы собираете в редакторе и отключаете глазом в иерархии: нет отдельного формата, нет второго редактора, поэтому что видите, то и появится.

// Once, at start: a missing template should say so, not fail every wave silently
if (!game.hasTemplate('Skeleton')) console.warn('no Skeleton template');

const mob = game.spawnFrom('Skeleton', { x, y, z }, { tag: 'Enemy' });

template принимает id, имя или тег. Копии, сделанные во время игры, существуют только в рамках сессии: stop() их удаляет, и сцена редактирования никогда не засоряется.

Отключение сущности означает невидима И безмолвна: узел отключён, скрипт остановлен, физическое тело больше не отвечает. Одно лишь скрытие оставило бы невидимого врага, который продолжает вас бить. game.setEnabled(id, on) управляет этим из скрипта, иконка глаза, из иерархии.

Перемещение объекта: толкайте тело, не телепортируйте#

Запись transform.position на каждом кадре проходит сквозь стены, сквозь других монстров и сквозь пол. Управляйте скоростью, но только тогда, когда есть чем управлять:

if (physics.hasBody) {
  const v = physics.getVelocity();
  physics.setVelocity({ x: vx, y: v.y, z: vz });   // pushes a real body
} else {
  transform.position.x += vx * dt;                 // legitimate for a flyer
}

physics.setVelocity без тела заполняет поле, которое никто не интегрирует, и сущность останавливается замертво. Именно поэтому существует physics.hasBody, и именно поэтому каждое подвижное поведение в каталоге задаёт этот вопрос, а не предполагает ответ. Остановка означает обнуление скорости: бездействие оставляет тело катиться по инерции.

Запись позиции теперь действительно двигает физическое тело. Havok управляет трансформацией, а не наоборот: присваивание transform.position динамическому телу раньше перезаписывалось на следующем шаге, поэтому любая телепортация молча проваливалась на физической сущности. Прокси позиции теперь пересинхронизирует тело, так же как это всегда делал прокси вращения.

Поиск пути, это снимок#

pathfinding.createGrid() выполняет трассировку лучей по сетке один раз, центрированную там, где была вызвана. Выход за пределы этой области возвращает null, и большинство вызывающих кодов в этом случае просто переходят на прямую линию, сквозь стены, без единого предупреждения.

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

console.log пишет в журнал. Print рисует.#

Консоль, это console.log / warn / error, как и везде. Print из BASIC рисует на экране и вызывается каждый кадр: это намеренно раздельные команды, и путаница между ними, это то, что заполняет консоль игровым циклом.

Где выполняется скрипт#

В редакторе и в веб-сборке скрипты выполняются в браузере. В нативной сборке, настольной или мобильной, они выполняются без изменений на встроенном движке QuickJS: тот же исходный код, а не порт.

Скрипты хранятся в собственной базе данных браузера во время редактирования, поэтому несохранённый буфер переживает перезагрузку; они сохраняются в проекте вместе со всем остальным.

Ограничения и типичные проблемы#

  • scene.pick() с аргументом хочет координаты canvas. См. выше; это самый частый баг с неверной системой координат.
  • game.spawn создаёт примитив, а не персонажа. Используйте spawnFrom.
  • Тело создаётся и для сущностей, рождённых в середине игры. Раньше физика строилась только при старте Play, поэтому всё, что появлялось позже, молча проваливалось сквозь мир, пока его соседи, размещённые вручную, вели себя нормально.
  • Восемь шагов, это потолок ИИ-напарника, а не ваш. У цикла скрипта нет такого ограничения; неконтролируемый while подвесит кадр точно так же, как где угодно ещё.
  • Остальные четыре представления языка транспилируются из этого. Редактирование представления Python или Rust с последующим возвратом проходит через сохранённый JavaScript, см. страницу BASIC для объяснения причины.