feat(player): вертикальная лестница ladder_vertical + лазание

- перенос из студии: ladder-mode, climb_up/climb_down, climb_to_top
- предзагрузка climb-анимаций (нет дёрга 180° при входе)
- заморозка позы на месте без исчезания скина
- гистерезис выхода, толщина лестницы 0.12
- climb_to_top вылезание на площадку 4с с заморозкой физики

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-14 23:57:19 +03:00
parent 42b3c26382
commit 2294981597
5 changed files with 337 additions and 12 deletions

View File

@ -57,7 +57,7 @@ const EXTRA_STATES = [
"walk_backward", "run_backward", "run_to_stop", "run_slide",
"jump_forward", "jump_backward", "jump_down",
"crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand",
"climb_up", "climb_down", "sit_idle", "lie_idle", "sleeping",
"climb_up", "climb_down", "climb_to_top", "sit_idle", "lie_idle", "sleeping",
"hit_react", "die_forward", "die_back",
"punch_left", "kick_low", "kick_high",
"gun_fire", "gun_reload", "rifle_walk",
@ -338,6 +338,7 @@ export class MixamoAnimator {
"jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
"jump_run_anticipate", "jump_run_air", "jump_run_land",
"crouch_enter", "crouch_to_stand",
"climb_to_top",
"hit_react", "die_forward", "die_back",
"throw_action", "pickup", "push_button", "open_door",
"gun_fire", "gun_reload", "sword_slash",
@ -471,7 +472,12 @@ export class MixamoAnimator {
}
// Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS.
const BLEND_MS = 150;
// Climb-состояния переключаем МГНОВЕННО (0мс) — при blend'е персонаж
// на доли секунды виден в промежуточном развороте (старая поза + новый
// _modelYaw), что выглядит как «дёрг разворота» при входе/выходе с лестницы.
const CLIMB_STATES = new Set(['climb_up', 'climb_down', 'climb_to_top']);
const BLEND_MS = (CLIMB_STATES.has(state) || CLIMB_STATES.has(this._currentState))
? 0 : 150;
try { next.setWeightForAllAnimatables(0); } catch (_) {}
// Снимаем ВСЕ предыдущие blend-observers — rapid-switching
// (Ctrl on/off с интервалом 50ms) оставлял несколько ticker'ов.
@ -560,6 +566,12 @@ export class MixamoAnimator {
group.onAnimationGroupEndObservable.addOnce(onEnd);
}
/** Тихая предзагрузка анимации в кэш (БЕЗ проигрывания). Нужно чтобы
* при первом setState анимация уже была готова (нет дёрга от walk). */
preload(name) {
try { _ensureLoaded(this.scene, name); } catch (e) {}
}
/** Babylon сам тикает AnimationGroup, но оставим для интерфейса. */
// eslint-disable-next-line no-unused-vars
update(dt) { /* noop */ }

View File

@ -1192,4 +1192,24 @@ export class PhysicsAABB {
}
return out;
}
/**
* Найти лестницу (ladder_vertical), которой касается AABB игрока.
* Лестницы проходимы (canCollide=false) НЕ попадают в spatial-grid,
* поэтому итерируем напрямую по инстансам (их на сцене единицы).
* Возвращает data ближайшей пересекающейся лестницы или null.
*/
getOverlappingLadder(cx, cy, cz, hw, hh, hd) {
if (!this.primitiveManager) return null;
let best = null, bestDist = Infinity;
for (const data of this.primitiveManager.instances.values()) {
if (data.type !== 'ladder_vertical') continue;
if (data.visible === false) continue;
if (!this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) continue;
const dx = data.x - cx, dz = data.z - cz;
const d = dx * dx + dz * dz;
if (d < bestDist) { bestDist = d; best = data; }
}
return best;
}
}

View File

@ -107,6 +107,11 @@ export class PlayerController {
this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
this._robotBoostLeft = 0; // оставшееся время boost-фазы (с)
// Лестница (ladder_vertical): касание + W/S → ladder-mode (гравитация
// отключена, W/S = вверх/вниз, Space = отпрыг).
this._ladderMode = false;
this._ladderData = null;
this.CLIMB_SPEED = 2.5; // скорость лазания вверх/вниз (м/с)
// Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
// Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
this._autoRunSpeed = 0;
@ -1233,6 +1238,14 @@ export class PlayerController {
animator.attach(this.scene, mixSk, root);
animator.setState('idle');
this._mixamoAnimator = animator;
// Предзагрузим climb-анимации заранее (тихо),
// чтобы при первом касании лестницы не было кадра
// walk с climb-поворотом (дёрг на 180°).
try {
animator.preload('climb_up');
animator.preload('climb_down');
animator.preload('climb_to_top');
} catch (e) {}
// Глобально для отладки/скриптов:
// window.__mixamo.playEmote('dance_hiphop')
try { window.__mixamo = animator; } catch (e) {}
@ -2863,8 +2876,154 @@ export class PlayerController {
moveZ *= 0.5;
}
// === Лестница (ladder_vertical) ===
// Детект касания лестницы. В воде/машине/GD-режиме лестница отключена.
let ladder = null;
if (!inWater && !inGdMode && this.physics?.getOverlappingLadder) {
ladder = this.physics.getOverlappingLadder(
this._pos.x, this._pos.y, this._pos.z,
this.HALF_W, this.HALF_H, this.HALF_D
);
}
// Предзагрузка climb-анимаций при касании лестницы (ДО лазания),
// чтобы при входе в ladder-mode climb_up уже был в кэше. Без этого
// первый кадр играет walk с climb-поворотом → персонаж «дёргается»
// на 180° пока climb_up асинхронно подгружается.
if (ladder && this._mixamoAnimator && !this._climbPreloaded) {
this._climbPreloaded = true;
try {
this._mixamoAnimator.preload('climb_up');
this._mixamoAnimator.preload('climb_down');
this._mixamoAnimator.preload('climb_to_top');
} catch (e) {}
}
const wantUp = c.has('KeyW') || c.has('ArrowUp');
const wantDown = c.has('KeyS') || c.has('ArrowDown');
// Фаза climb_to_top — вылезание на площадку (4с). Блокирует всё:
// управление, физику, обычный ladder-mode. Игрок плавно перемещается
// из _climbTopStart в _climbTopEnd (lerp), анимация climb_to_top играет.
if (this._climbingTop) {
const total = 4000;
const left = this._climbingTopUntil - Date.now();
const t = Math.max(0, Math.min(1, 1 - left / total));
const a = this._climbTopStart, b = this._climbTopEnd;
if (a && b) {
this._pos.x = a.x + (b.x - a.x) * t;
this._pos.y = a.y + (b.y - a.y) * t;
this._pos.z = a.z + (b.z - a.z) * t;
}
this._vy = 0;
if (left <= 0) {
// Завершили вылезание — выходим в обычный режим.
this._climbingTop = false;
this._ladderMode = false;
this._ladderData = null;
this._climbTopStart = null;
this._climbTopEnd = null;
}
// Пропускаем остальную ladder/движение логику в этом кадре.
// Но позволяем анимационной ветке проиграть climb_to_top.
}
// Вход в ladder-mode: касаемся лестницы И жмём вверх/вниз.
if (!this._climbingTop && ladder && !this._ladderMode && (wantUp || wantDown)) {
this._ladderMode = true;
this._ladderData = ladder;
this._vy = 0;
// Прижать игрока к плоскости лестницы и повернуть лицом к ней.
// Лестница плоская: её фронт — вдоль локальной оси -Z, повёрнутой
// на rotationY. Нормаль фронта = (sin(rY), 0, cos(rY)).
const rY = (ladder.rotationY || 0) * Math.PI / 180;
const nx = Math.sin(rY);
const nz = Math.cos(rY);
// Игрок стоит ПЕРЕД лестницей: позиция = центр лестницы по XZ
// + нормаль * (полглубины лестницы + полширины игрока).
const standOff = (ladder.sz || 0.25) / 2 + this.HALF_D + 0.05;
this._pos.x = ladder.x + nx * standOff;
this._pos.z = ladder.z + nz * standOff;
// Повернуть лицом К лестнице (смотрит против нормали).
// climb_up-клип сам разворачивает Hips на 180°, поэтому модель
// доворачиваем на +π, чтобы персонаж смотрел на перекладины.
const faceYaw = Math.atan2(-nx, -nz);
this._yaw = faceYaw; // камера смотрит на лестницу
this._modelYaw = faceYaw + Math.PI; // +180° компенсация анимации
this._ladderMoving = null; // сброс — climb-анимация стартует заново
}
// Пока в ladder-mode: обновляем ссылку на лестницу если ещё касаемся.
// (НЕ во время climb_to_top — там своя логика перемещения.)
if (this._ladderMode && !this._climbingTop) {
if (ladder) this._ladderData = ladder;
const ld = this._ladderData;
// Верх лестницы (мировая координата). Поднялись выше — выходим наверх.
const ladderTop = ld ? (ld.y + (ld.sy || 0) / 2) : Infinity;
// Гистерезис выхода: НЕ выходим по мгновенному !ladder (детект
// нестабилен на грани AABB → мигание climb↔walk каждый кадр).
// Выходим только если игрок РЕАЛЬНО отошёл по XZ от сохранённой
// лестницы (> половины ширины + запас).
let farFromLadder = false;
if (ld) {
const dx = this._pos.x - ld.x;
const dz = this._pos.z - ld.z;
const distXZ = Math.hypot(dx, dz);
const exitDist = Math.max(ld.sx || 1, ld.sz || 0.25) / 2 + this.HALF_D + 0.6;
farFromLadder = distXZ > exitDist;
} else {
farFromLadder = true;
}
// Space → отпрыг назад + выход.
if (c.has('Space')) {
this._ladderMode = false;
this._ladderData = null;
this._vy = 5;
this._jumpHeld = true;
} else if (farFromLadder) {
// Реально отошли от лестницы — выходим (гравитация включится).
this._ladderMode = false;
this._ladderData = null;
} else {
// Лазание: гравитация отключена, A/D заблокированы.
// Вертикальное движение задаём через _vy (climb-скорость),
// чтобы moveAABB обработал коллизию корректно. Прямое
// _pos.y += не годилось: персонаж стоит на земле, и moveAABB
// снапил его обратно (онГраунд держал внизу).
moveX = 0;
moveZ = 0;
if (wantUp) this._vy = this.CLIMB_SPEED;
else if (wantDown) this._vy = -this.CLIMB_SPEED;
else this._vy = 0;
// Достигли верха лестницы И лезем вверх → запускаем переход
// climb_to_top (вылезание на площадку, 4с one-shot). Управление
// блокируется, физика замораживается, в конце игрок ставится
// на площадку над лестницей.
if (this._pos.y + this.HALF_H > ladderTop - 0.3 && wantUp
&& !this._climbingTop) {
this._climbingTop = true;
this._climbingTopUntil = Date.now() + 4000;
this._vy = 0;
// Куда вылезти: вперёд (по нормали от лестницы, внутрь
// площадки) + на верх лестницы.
const ldd = this._ladderData;
const rY = (ldd?.rotationY || 0) * Math.PI / 180;
// Нормаль фронта (откуда лез) — игрок перед лестницей.
// Площадка — за лестницей (противоположная сторона).
const fnx = Math.sin(rY), fnz = Math.cos(rY);
const fwd = (ldd?.sz || 0.25) / 2 + this.HALF_D + 0.4;
this._climbTopStart = { x: this._pos.x, y: this._pos.y, z: this._pos.z };
this._climbTopEnd = {
x: ldd.x - fnx * fwd, // на другую сторону лестницы
y: ladderTop + this.HALF_H, // на верх
z: ldd.z - fnz * fwd,
};
}
}
}
// === Вертикальное ===
if (inWater) {
if (this._ladderMode) {
// На лестнице гравитация НЕ применяется — _vy уже выставлен
// (=CLIMB_SPEED вверх / -CLIMB_SPEED вниз / 0 на месте) выше,
// moveAABB применит его с коллизией.
} else if (inWater) {
// Плавание: лёгкая гравитация + плавучесть к поверхности
const buoyancy = submerged ? 6 : 0;
const swimGravity = -3;
@ -2948,7 +3107,12 @@ export class PlayerController {
// PERF-METRICS: замер физики игрока
const _pt0 = performance.now();
const result = this.physics.moveAABB(
// Во время climb_to_top физику пропускаем — _pos двигается lerp'ом
// вручную (вылезание на площадку), коллизия не нужна.
const result = this._climbingTop
? { x: this._pos.x, y: this._pos.y, z: this._pos.z,
onGround: false, hitY: false, surfaceFollowed: false }
: this.physics.moveAABB(
this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
moveX, this._vy * dt, moveZ
);
@ -3249,10 +3413,26 @@ export class PlayerController {
);
// Поворот модели:
// - на лестнице: лицом К лестнице, yaw зафиксирован при входе.
// - на суше: направление РЕАЛЬНОГО движения (как было).
// - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
// двигает тело вбок без вращения, как на суше при first-person.
if (inWater) {
if (this._climbingTop) {
// climb_to_top: модель смотрит В сторону площадки (куда вылазит).
// Эта анимация имеет другую ориентацию Hips чем climb_up,
// поэтому БЕЗ +π компенсации — иначе развёрнута на 180°.
if (this._climbTopStart && this._climbTopEnd) {
const dx = this._climbTopEnd.x - this._climbTopStart.x;
const dz = this._climbTopEnd.z - this._climbTopStart.z;
if (Math.abs(dx) > 0.001 || Math.abs(dz) > 0.001) {
this._modelYaw = Math.atan2(dx, dz);
}
}
} else if (this._ladderMode) {
// _modelYaw уже выставлен при входе в ladder-mode (лицом к лестнице).
// Анимация climb_up даёт ~180° поворот Hips → персонаж лицом к
// перекладинам. Ничего не доворачиваем.
} else if (inWater) {
const targetYaw = this._yaw;
let diff = targetYaw - this._modelYaw;
while (diff > Math.PI) diff -= Math.PI * 2;
@ -3355,6 +3535,38 @@ export class PlayerController {
if (this._mixamoAnimator) {
let mState;
const now = Date.now();
// climb_to_top — вылезание на площадку (приоритет над всем).
if (this._climbingTop) {
this._mixamoAnimator.setState('climb_to_top');
return;
}
// Лазание по лестнице имеет приоритет над всеми анимациями.
// climb_up — движется вверх (W), climb_down — вниз (S),
// на месте на лестнице — анимация продолжает играть циклично
// (НЕ паузим: g.pause() останавливал обновление скелета →
// bounding box не обновлялся → frustum culling прятал скин).
if (this._ladderMode) {
const climbUp = this._codes.has('KeyW') || this._codes.has('ArrowUp');
const climbDown = this._codes.has('KeyS') || this._codes.has('ArrowDown');
const moving = climbUp || climbDown;
// Меняем state ТОЛЬКО при реальном движении. На месте держим
// текущую анимацию (не дёргаем setState — это убирает мигание
// climb_up↔climb_down и исчезание скина).
if (climbUp) this._mixamoAnimator.setState('climb_up');
else if (climbDown) this._mixamoAnimator.setState('climb_down');
// play/pause трогаем ТОЛЬКО при смене режима движения (как в jump).
if (moving !== this._ladderMoving) {
this._ladderMoving = moving;
try {
const g = this._mixamoAnimator._currentGroup;
if (g) {
if (moving) g.play(true); // возобновить (снять паузу)
else g.pause(); // заморозить позу
}
} catch (e) {}
}
return;
}
const inCrouchTransition = this._crouchTransitionUntil
&& now < this._crouchTransitionUntil;
// 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind:

View File

@ -38,6 +38,11 @@ const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
const STUD_UNIT = 1;
const STUDS_GRID = 4;
// Вертикальный шаг между ступеньками лестницы (юниты). Полная высота
// лестницы = stepCount * LADDER_STEP_SPACING.
export const LADDER_STEP_SPACING = 0.45;
const _studsTexCache = new WeakMap();
function _getStudsTextures(scene) {
let c = _studsTexCache.get(scene);
@ -114,8 +119,15 @@ export class PrimitiveManager {
id = this._nextId++;
}
const sx = opts.sx ?? typeDef.defaultScale.x;
const sy = opts.sy ?? typeDef.defaultScale.y;
let sy = opts.sy ?? typeDef.defaultScale.y;
const sz = opts.sz ?? typeDef.defaultScale.z;
// Лестница: высота ДЕРИВИРУЕТСЯ из stepCount (а не из sy) — даёт
// корректный AABB для детекта касания и совпадает с геометрией меша.
const isLadder = typeDef.id === 'ladder_vertical';
const stepCount = isLadder
? Math.max(2, Math.min(40, Math.round(opts.stepCount != null ? opts.stepCount : 8)))
: undefined;
if (isLadder) sy = stepCount * LADDER_STEP_SPACING;
const color = opts.color ?? typeDef.defaultColor;
// GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики.
// Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции.
@ -126,8 +138,10 @@ export class PrimitiveManager {
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции).
// Лестница — тоже проходима (canCollide=false), чтобы игрок мог войти в её
// объём и лезть (ladder-mode в PlayerController по детекту касания).
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike && !isLadder;
const visible = opts.visible !== false;
const anchored = opts.anchored !== false; // по умолчанию заякорен
// Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков.
@ -143,7 +157,11 @@ export class PrimitiveManager {
const rotationY = opts.rotationY ?? 0;
const rotationZ = opts.rotationZ ?? 0;
// Передаём stepCount в builder через временное поле (читается в
// _buildLadderMesh внутри _createMeshForType).
this._ladderStepCount = stepCount;
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
this._ladderStepCount = undefined;
mesh.position = new Vector3(x, y, z);
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
mesh.isPickable = true;
@ -169,6 +187,8 @@ export class PrimitiveManager {
rotationX, rotationY, rotationZ,
color, material, canCollide, visible, anchored, mass,
textureAsset, studDensity,
// Лестница: число ступенек (высота). undefined для прочих.
...(isLadder ? { stepCount } : {}),
// locked — объект защищён от выделения/перемещения в редакторе
// (Фаза 5.11). На геймплей не влияет.
locked: opts.locked === true,
@ -305,6 +325,10 @@ export class PrimitiveManager {
return this._buildWedgeMesh(name, sx, sy, sz);
case 'cornerwedge':
return this._buildCornerWedgeMesh(name, sx, sy, sz);
case 'ladder_vertical':
// Лестница строится из stepCount ступенек — высота зависит от
// количества ступенек, а не от sy.
return this._buildLadderMesh(name, sx, sz, this._ladderStepCount || 8);
default:
return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene);
@ -452,6 +476,43 @@ export class PrimitiveManager {
return mesh;
}
/**
* Вертикальная лестница: 2 боковые стойки + N перекладин (ступенек).
* Полная высота = stepCount * LADDER_STEP_SPACING. При изменении stepCount
* лестница ПЕРЕСТРАИВАЕТСЯ. Меш центрирован по (0,0,0) как CreateBox.
* sx ширина, sz глубина стоек/перекладин.
*/
_buildLadderMesh(name, sx, sz, stepCount) {
const n = Math.max(2, Math.min(40, Math.round(stepCount || 8)));
const SPACING = LADDER_STEP_SPACING;
const height = n * SPACING;
const railW = Math.min(0.12, sx * 0.12);
const railD = Math.max(0.06, sz);
const rungH = Math.min(0.1, SPACING * 0.3);
const halfH = height / 2;
const railX = sx / 2 - railW / 2;
const parts = [];
const railL = MeshBuilder.CreateBox(name + '_railL',
{ width: railW, height, depth: railD }, this.scene);
railL.position.x = -railX;
parts.push(railL);
const railR = MeshBuilder.CreateBox(name + '_railR',
{ width: railW, height, depth: railD }, this.scene);
railR.position.x = railX;
parts.push(railR);
const rungWidth = sx - railW;
for (let i = 0; i < n; i++) {
const y = -halfH + SPACING * (i + 0.5);
const rung = MeshBuilder.CreateBox(name + '_rung' + i,
{ width: rungWidth, height: rungH, depth: railD }, this.scene);
rung.position.y = y;
parts.push(rung);
}
const merged = Mesh.MergeMeshes(parts, true, true, undefined, false, true);
if (merged) { merged.name = name; return merged; }
return MeshBuilder.CreateBox(name, { width: sx, height, depth: sz }, this.scene);
}
/** Применить цвет и материал. */
_applyMaterial(mesh, typeDef, color, material, textureUrl) {
const matName = `${mesh.name}_mat`;
@ -695,6 +756,14 @@ export class PrimitiveManager {
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
scaleChanged = true;
}
// Лестница: смена числа ступенек → пересборка меша. Высота (sy)
// деривируется из stepCount, поэтому AABB касания остаётся корректным.
if (patch.stepCount !== undefined && data.type === 'ladder_vertical') {
const sc = Math.max(2, Math.min(40, Math.round(patch.stepCount)));
data.stepCount = sc;
data.sy = sc * LADDER_STEP_SPACING;
scaleChanged = true;
}
if (scaleChanged) {
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
// изменения через scaling кажутся правильными. Простой способ —
@ -828,7 +897,10 @@ export class PrimitiveManager {
const oldMat = oldMesh.material;
const typeDef = getPrimitiveType(data.type);
// Лестница: передаём актуальный stepCount в builder.
this._ladderStepCount = data.stepCount;
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
this._ladderStepCount = undefined;
newMesh.position = oldPos;
if (oldRot) newMesh.rotation = oldRot;
// studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос.
@ -904,6 +976,8 @@ export class PrimitiveManager {
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter')
...(d.effect !== undefined ? { effect: d.effect } : {}),
// Число ступенек лестницы (только для type='ladder_vertical')
...(d.type === 'ladder_vertical' ? { stepCount: d.stepCount } : {}),
// Параметры билборда (только для type='billboard')
...(d.billboard ? {
template: d.billboard.template,

View File

@ -66,6 +66,13 @@ export const PRIMITIVE_TYPES = [
{ id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard',
defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' },
// === Вертикальная лестница — по ней можно лазить вверх/вниз ===
// Высота настраивается параметром stepCount (количество ступенек).
// При изменении stepCount лестница перестраивается (НЕ растягивается модель,
// а добавляются/убираются ступеньки). Касание → ladder-mode в PlayerController.
{ id: 'ladder_vertical', name: 'Лестница (вертикальная)', icon: 'prim-ladder', kind: 'ladder',
defaultScale: { x: 1, y: 4, z: 0.12 }, defaultColor: '#a8743a' },
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
@ -96,7 +103,7 @@ export const PRIMITIVE_TYPES = [
/** Категории для группировки в палитре. */
export const PRIMITIVE_CATEGORIES = [
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard'] },
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard', 'ladder_vertical'] },
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
];