Compare commits

..

215 Commits

Author SHA1 Message Date
min
8a66ce03f9 Merge remote-tracking branch 'origin/main' into feat/lua-50-games-bundle
Some checks failed
CI / Lint (pull_request) Failing after 1m11s
CI / Build (pull_request) Successful in 1m57s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-10 00:20:52 +03:00
min
09a927bbfc fix(docs): дефолт языка в статьях — JS, не Lua
Дефолт в DEFAULT_LANG уже был 'js', но у части юзеров в localStorage
залип 'lua' с прошлого посещения (rublox.docs.lang).

Фикс: бамп ключа на 'rublox.docs.lang.v2' + удаление старого ключа
при инициализации. У всех теперь старт с JS, переключение на Lua
сохраняется по новому ключу как раньше.
2026-06-09 23:43:12 +03:00
min
eedac4379d feat: импорт Roblox открыт для всех + раздел вики
KubikonStudio.jsx: убрана проверка getCurrentUserId()===1 на кнопке
'📦 Импорт Roblox'. Теперь доступно всем юзерам.

RbxlImportModal.jsx: убран гейт ALLOWED_USER_ID и заглушка
'доступна только администратору'.

API.js: убран комментарий 'только для МИНа' у RBXL_addres.

docsData.jsx: новый раздел вики 'rbxl-import' (icon: package,
title: 'Импорт из Roblox') с 5 статьями:
- I1. Что это и зачем
- I2. Как импортировать карту (шаги 1-4)
- I3. Графика хорошо, скрипты — осторожно
  (что переносится хорошо/так себе/не переносится)
- I4. Правильный порядок: сначала графика, потом скрипты
  (Проход 1: импорт со скриптами 'Отключены' → смотрим графику,
   Проход 2: включаем скрипты по одному в редакторе)
- I5. Советы и частые проблемы
  (пустая карта, серые текстуры, тормоза, DataStoreService,
   провал под пол + что делать после импорта)

Раздел вставлен перед 'collab' (Совместное редактирование).
2026-06-09 23:39:08 +03:00
min
61026a1df0 feat(docs): компонент <Api> переключает JS/Lua инлайны
В уроке 50 (и можно дальше) в тексте было много <code>game.ui.showText</code>,
<code>game.broadcast</code> и т.д. — это JS-API. В Lua-вкладке вики они
оставались JS — путало юзера.

Фикс:
- Новый компонент <Api js="..." lua="..." /> использует useDocsLang().
  Если lang='lua' и lua задан — показывает Lua-эквивалент.
- В уроке 50 заменил все инлайны game.* на <Api> с Lua-параллелями:
  - game.ui.showText → __rbxl_show_text
  - game.sound.play → winSound:Play()
  - game.onMessage/broadcast → BindableEvent:Connect/Fire
  - game.self.onTouch/onInteract/onClick → Touched/UIS+Heartbeat/ClickDetector
  - game.onTick → RunService.Heartbeat
  - game.after/every → task.delay/spawn
  - game.tween → TweenService:Create
  - game.scene.spawnNpc → __rbxl_spawn_npc
  - game.ui.score → __rbxl_score_set
2026-06-09 23:29:41 +03:00
min
ddeb8ff93f docs(49) + feat(g50): «Своя игра» (песочница)
g49 docs: CodeBoth main+finish.

g50 паритет:
- Комментарии-инструкция как начать свою игру (5 шагов)
- __rbxl_show_text 'Твоя песочница! Создай свою игру'
2026-06-09 23:21:39 +03:00
min
3a95cd148a docs(48) + feat(g49): «Мультиплеер: Гонка»
g48 docs: CodeBoth g48_main.

g49 паритет (упрощённый без MP-API):
- showText 'Гонка! Беги к финишу первым'
- hud_set 'info' 'Игроков: N | Победил: X' (Players:GetPlayers count + LocalPlayer name)
- Players.PlayerAdded/Removing → refresh
- BindableEvent FinishReached
- g49_finish: Touched → FinishReached:Fire (fired-флаг)
- При победе: winnerName = LocalPlayer.Name + refresh + 'Победа!' + confetti
2026-06-09 23:18:39 +03:00
min
e78f585fd4 docs(47) + feat(g48): «Мультиплеер: Салки»
g47 docs: CodeBoth main+btn_1+finish.

g48 паритет (упрощённый — без MP-API):
- __rbxl_show_text 'Опубликуй игру для игры с друзьями'
- __rbxl_hud_set 'info' 50,8 'Игроков в комнате: N' (Players:GetPlayers)
- Players.PlayerAdded → '<имя> присоединился!' + refresh
- Players.PlayerRemoving → refresh
- task.delay 2 показывает правила
- В одиночке роли не назначаются (нет game.room API в shim)
2026-06-09 23:15:11 +03:00
min
57c5779644 docs(46) + feat(g47): «Квест-побег»
g46 docs: CodeBoth 4 скрипта (main+cube+up1+up2).

g47 паритет:
- TOTAL=3, pressed/escaped counters
- BindableEvents ButtonPressed/Escape
- 3 g47_btn_N: Heartbeat distance(3) + '[E] Нажать кнопку'
  E → used=true + Color зелёный + ButtonPressed:Fire
- При pressed>=3: tween двери Position.Y+6 + CanCollide=false + win sound
- g47_finish: Touched → Escape:Fire → 'Победа! Сбежал!' + confetti
2026-06-09 23:12:28 +03:00
min
d758fdfbe6 docs(45) + feat(g46): «Кликер»
g45 docs: CodeBoth g45_main.

g46 паритет (заменил simpleClicker fallback):
- GOAL=200, points/perClick/autoIncome
- Heartbeat: каждую секунду points += autoIncome → checkWin
- BindableEvents CubeClicked/BuyPower/BuyAuto
- g46_cube: ClickDetector → CubeClicked:Fire + sparks
- g46_up1/up2: Heartbeat distance(3) + '[E] Купить ...' + InputBegan E
- buyPower: -20 + perClick+=2
- buyAuto: -40 + autoIncome+=3
2026-06-09 23:07:25 +03:00
min
1c5e5fe5bb docs(44) + feat(g45): «Стрелялка-арена»
g44 docs: CodeBoth main+slot_1.

g45 паритет:
- GOAL=15, score/over
- Humanoid.Died → 'Поражение!'
- BindableEvent EnemyClicked(ref)
- Heartbeat spawn 1.8с: радиус=11 cos/sin → npc 'character-b'
  hp=30 speed=2.2 follow('player')
- npc_on_click → EnemyClicked:Fire(ref)
- Главный: dist<6 → npc.remove + explosion + score++
- 15 → 'Победа!' + confetti
- Heartbeat damage: каждый враг dist<1.8 + last>0.7 → damage_player(10) + hit
2026-06-09 23:04:21 +03:00
min
dc669a51f4 docs(43) + feat(g44): «Tower Defense»
g43 docs: CodeBoth main+boost_1+spike_4+finish.

g44 паритет:
- GOAL=14, MAX_LEAK=8, killed/leaked counters
- towers[] и enemies{} (ref-keyed)
- BindableEvent TowerBuilt(x, z)
- Heartbeat spawn: 2.2с → npc 'character-b' speed=2 hp=50 в (-0.5,1,-3),
  task.delay 0.3 moveTo (-0.5, 42), on_death → killed++
- Heartbeat fire: 0.8с → каждая башня бьёт ближайшего врага в r=7
  (npc.damage 25 + sparks)
- Heartbeat leak: 0.5с → ez>40 → npc.remove + leaked++ + lose
- 4 g44_slot_N: Heartbeat distance(4) + '[E] Построить башню'
  E → Instance.new('Part') Cylinder жёлтый + TowerBuilt:Fire
2026-06-09 23:00:37 +03:00
min
019068cffa docs(42) + feat(g43): «Гонка с препятствиями»
g42 docs: CodeBoth 4 скрипта.

g43 паритет:
- Heartbeat: time += dt → timer
- BindableEvents Boost/Spike/FinishReached
- Boost → set_speed(1.8) + pickup + 'УСКОРЕНИЕ!', task.delay 3 → set_speed(1)
- Spike → damage_player(15) + set_speed(0.5) + hit, task.delay 1.5 → 1
- Finish → 'Финиш! Время: X сек' + confetti
- 3 g43_boost_N + 5 g43_spike_N: Touched throttle 1с → Fire соответствующее
- g43_finish: Touched → FinishReached:Fire

Shim: __rbxl_set_speed(mul) → cmd 'player.setSpeed' с полем 'mul'.
2026-06-09 22:55:54 +03:00
min
4085fce0d3 docs(41) + feat(g42): «RPG-деревня»
g41 docs: CodeBoth 4 скрипта (main+coin_10+cp+finish).

g42 паритет:
- 2 NPC (Староста character-a, Кузнец character-b)
- stage 0→1→2→3 цепочка
- BindableEvents ElderTalk/TakeAmulet/SmithTalk
- g42_elder/smith: Heartbeat distance(4) + '[E] Поговорить'
  InputBegan E → Fire
- g42_amulet: Touched → TakeAmulet:Fire + Destroy
- stage 1 elder: 'Найди амулет'
- stage 1 amulet: __rbxl_inventory_define/add 'amulet'
- stage 2 smith: inventory_remove + 'Победа!' + confetti
- Прочие говорят соответствующую реплику
2026-06-09 22:52:22 +03:00
min
e75121cb3d docs(40) + feat(g41): «Платформер-приключение»
g40 docs: CodeBoth g40_main.

g41 паритет:
- Heartbeat py<-3 → LoadCharacter + lose
- BindableEvents CoinCollected/CheckpointReached/TreasureFound
- 5 g41_coin_N (id 10..14): Touched → CoinCollected:Fire + Destroy
- g41_cp: Touched → CheckpointReached:Fire → setSpawn(-0.5,7,28)
- g41_finish: Touched → TreasureFound:Fire → 'Победа! Сокровище и N монет!' + confetti
2026-06-09 22:49:14 +03:00
min
cce9d2e293 docs(39) + feat(g40): «Выживание от волн»
g39 docs: CodeBoth main+spot_1.

g40 паритет:
- WAVES=3, count=wave+2 (3,4,5 врагов)
- Враги по кругу radius=10 (cos/sin), npc.follow('player')
- BindableEvent EnemyClicked(ref)
- __rbxl_npc_on_click(ref, fn) — каждый враг шлёт ref в общий event
- Главный проверяет dist<5, npc.remove + explosion + aliveInWave--
- 0 живых → task.delay 2 startWave (forward-decl)
- 3 волны → 'Победа!' + confetti
2026-06-09 22:46:21 +03:00
min
f7b296f43b fix(g39): 3D-дистанция для distance-check мест
8 мест-призраков стоят один над другим (x=0, z=0, y=1,3,5,...).
По X+Z они в (0,0) → все 8 видят hintVisible=true одновременно
→ нажатие E срабатывало у всех сразу, башня строилась за раз.

Фикс: 3D-дистанция (учитываем dy).
2026-06-09 22:43:40 +03:00
min
b42685521c docs(38) + feat(g39): «Башня — стройка»
g38 docs: CodeBoth main+tile_1.

g39 паритет:
- STEPS=8, placed counter
- BindableEvent BlockPlaced(n)
- 8 g39_spot_N: Heartbeat distance(4) → '[E] Поставить блок'
  E → CanCollide=true, Transparency=0, Color коричневый,
  BlockPlaced:Fire(n)
- Главный: n=placed+1? placed++; иначе 'Сначала поставь ниже!'
- 8 → 'Победа!' + confetti
2026-06-09 22:41:55 +03:00
min
0fcc5b85d0 docs(37) + feat(g38): «Музыкальная игра»
g37 docs: CodeBoth main+spike_1+cp+finish.

g38 паритет:
- SOUNDS [coin,jump,click,hit], SEQ [1,3,2,4,1]
- task.delay 1 + i*0.8 → play sound + 'Нота N из 5'
- После последней task.delay → canPress=true + 'Повтори!'
- BindableEvent NotePressed(n)
- 4 g38_tile_N: Heartbeat distance(3) → '[E] Сыграть ноту'
  E → tileSound + sparks + NotePressed:Fire(n)
- Правильная → playerStep++, при #SEQ → win + confetti
- Ошибка → playerStep=0 + lose + 'Слушай снова'
2026-06-09 22:35:40 +03:00
min
f0025f0dad docs(36) + feat(g37): «Полоса препятствий»
g36 docs: CodeBoth main+box_1.

g37 паритет:
- task.delay 0.2 ДвижПлатформа yoyo loop (x: -0.5 ↔ 3, 2с)
- Heartbeat: py<-3 → LoadCharacter + lose
- BindableEvents CheckpointReached/FinishReached
- 6 g37_spike_N: Touched → damage(25) + hit sound (i-frames 0.5с)
- g37_cp: Touched → CheckpointReached:Fire → setSpawn(-0.5,1,24)
- g37_finish: Touched → FinishReached:Fire → win + confetti

Shim: __rbxl_set_spawn(x,y,z).
2026-06-09 22:32:55 +03:00
min
b0bdfb6e29 docs(35) + feat(g36): «Головоломка с ящиками»
g35 docs: CodeBoth g35_main.

g36 паритет:
- onPlate[3] флаги, при всех true → win
- BindableEvent BoxMoved(i, on)
- g36_box_N: Heartbeat distance-check(3) → '[E] Двинуть ящик',
  E: cell++ (wrap), TweenService Position.Z=ROW[cell], 0.4с,
  Fire(i, newZ == 6)
2026-06-09 22:29:36 +03:00
min
92a9ef220d docs(34) + feat(g35): «Прятки от NPC»
g34 docs: CodeBoth main+plant_1.

g35 паритет:
- SURVIVE=40c, NPC 'Искатель' speed=3 follow('player')
- __rbxl_timer_set каждый кадр
- dist<1.7 → LoadCharacter + 'Найден!' + lose (с throttle 2с)
- time>=40 → npc.stop + 'Победа!' + confetti + win
2026-06-09 22:25:20 +03:00
min
3e572e1136 docs(33) + feat(g34): «Сбор урожая»
g33 docs: CodeBoth g33_main.

g34 паритет:
- BindableEvent Harvested → score++ + coin sound, 6 → win + confetti
- 6 g34_plant_N: TweenService 5с растёт (Size+Position),
  Completed → ripe=true + Color жёлтый
- Heartbeat distance-check (3) → __rbxl_hud_set '[E] Собрать'
- E: не ripe → 'Ещё не выросло!'; ripe → Harvested:Fire + Destroy
2026-06-09 22:22:03 +03:00
min
f3b0cabdbd docs(32) + feat(g33): «Платформер с боссом»
g32 docs: CodeBoth main+cp_1.

g33 паритет:
- Heartbeat: py<-3 → LoadCharacter + lose
- При pz>24 + py>5 (на арене) — spawnNpc 'БОСС' hp=120 speed=2
- task.delay npc.follow('player') + setLabel 'БОСС HP: 120'
- __rbxl_npc_on_click(bossRef, onBossHit):
  dist<5 → bossHp -= 20 + npc.damage + setLabel + sparks + hit
- __rbxl_npc_on_death → clear_label + 'Победа!' + confetti + win
2026-06-09 22:18:18 +03:00
min
4ca3800e49 docs(31) + feat(g32): «Гонка с кругами»
g31 docs: CodeBoth для g31_main.

g32 паритет:
- LAPS=2, CP_COUNT=4, nextCp/lap/time/won
- __rbxl_timer_set — паритет с game.ui.timer=N (формат mm:ss)
- __rbxl_hud_set 'race' — постоянная надпись 'Круг N/2 • чекпоинт M/4'
- Heartbeat: time += dt → timer update
- BindableEvent CheckpointReached(num)
- 4 g32_cp_N: Touched → CheckpointReached:Fire(N)
- При 2 кругах → 'ФИНИШ! Xc' + showText + confetti + win

Shim: __rbxl_timer_set(seconds).
2026-06-09 22:15:16 +03:00
min
901c249c29 docs(29) + feat(g31): «Защита базы»
g30 docs CodeBoth 4 скрипта.

g31:
- killed counter (GOAL=12) + leaked (MAX_LEAK=5)
- Heartbeat spawn враг каждые 2с в (random(-8,8), 1, 38),
  spawn 'character-b' speed 2.5
- task.delay 0.3 npc.moveTo(0, 2) — к базе
- __rbxl_npc_on_click(ref, fn) → шлёт ref в общий BindableEvent
- При клике главный скрипт проверяет dist<5, наносит урон
- Каждые 0.4с проверка прорыва (z<4) → leaked++ + lose sound
- 12 убитых → 'Победа!' + confetti
- 5 прорывов → 'База разрушена!'

Shim: __rbxl_npc_moveto/__rbxl_npc_remove.
2026-06-09 22:11:15 +03:00
min
f69df55e3b docs: CodeBoth урока 29 + feat(g30): полный паритет «Квест с заданиями»
g29 docs: 5 скриптов под CodeBoth (main, coin_1, shop, door, finish).

g30 паритет:
- stage 0→1→2→3→4 (talk/coin/flag/talk)
- setObjective через __rbxl_hud_set 50,8 цвет/размер
- NPC 'Старейшина' spawnNpc + npc_say на этапах
- BindableEvents Talk/CoinDone/FlagDone
- g30_npc: Heartbeat distance-check (4) + '[E] Поговорить' + InputBegan E
- g30_coin: Touched → CoinDone:Fire + Destroy (taken-флаг)
- g30_flag: Touched → FlagDone:Fire (fired-флаг)
- На stage 3: showText + confetti + win sound
2026-06-09 22:06:27 +03:00
min
5186ee3b70 feat(g29): добавлен NPC-продавец за прилавком с меткой 'Продавец' 2026-06-09 22:03:06 +03:00
min
facd6aa837 feat(g29): полный паритет «Магазин»
JS:
- 7 монеток onTouch → broadcast coin → score++, coin sound
- прилавок onInteract E (4) → broadcast buy → coins>=5? покупка ключа
  + inventory.add('Ключ') + 'Куплен!' + win sound; иначе lose+'Мало!'
- дверь onInteract E (4) → broadcast open-door → hasKey? tween y+6;
  иначе 'Дверь заперта'
- финиш → broadcast win → 'Победа!' + confetti

Lua (паритет):
- BindableEvents CoinCollected/BuyKey/OpenDoor/WinReached
- __rbxl_score_set, __rbxl_show_text, __rbxl_inventory_define/add
- 7 g29_coin_N: Touched → CoinCollected:Fire + Destroy
- g29_shop, g29_door: Heartbeat distance-check (4) + __rbxl_hud_set
  '[E] Купить ключ (5 монет)' / '[E] Открыть дверь' + InputBegan E
- g29_main обрабатывает все события, при покупке: TweenService двери
  Position.Y+6 + CanCollide=false
- g29_finish: Touched → WinReached:Fire (fired-флаг)
2026-06-09 22:00:35 +03:00
min
ed23ec782c docs: CodeBoth для урока 28 «Призрачные стены» (main+wall+finish) 2026-06-09 21:58:44 +03:00
min
a72101a29a fix(g28): ClickDetector вместо part.Clicked (как в Тире)
part.Clicked не существует — был fallback try/catch с молчаливым
падением. ClickDetector + MouseClick — рабочий путь (используется
в игре 15 «Тир»).
2026-06-09 21:57:38 +03:00
min
095a79cab4 feat(g28): полный паритет «Призрачные стены»
JS:
- showText 'Кликай по фиолетовым стенам — пройди сквозь!'
- onMessage 'win' → 'Победа!' + win + confetti
- 4 стены: onClick → passThrough + opacity=0.25 + click sound + showText
- финиш: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text + Sounds
- BindableEvent WinReached
- 4 g28_wall_N: part.Clicked → CanCollide=false + Transparency=0.75 +
  click Sound + showText 'Стена стала призрачной!'
- g28_finish: Touched → WinReached:Fire (fired-флаг)
2026-06-09 21:54:09 +03:00
min
b9473cca50 docs: CodeBoth для урока 27 «Двойной прыжок» (main+finish) 2026-06-09 21:51:37 +03:00
min
17417b1b33 feat(g27): полный паритет «Двойной прыжок»
JS:
- player.setDoubleJump(true)
- showText 'Жми Space ДВАЖДЫ — двойной прыжок!'
- onTick: y<-3 → respawn + lose
- onMessage 'win' → 'Победа!' + win + confetti
- финиш: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_set_double_jump(true) — паритет с player.setDoubleJump
- __rbxl_show_text + Sounds
- Heartbeat: player_y < -3 → LoadCharacter + lose
- BindableEvent WinReached
- g27_finish: Touched → WinReached:Fire (fired-флаг)

Shim добавил __rbxl_set_double_jump(bool) → cmd 'player.setDoubleJump'.
2026-06-09 21:50:08 +03:00
min
19c475f132 docs: CodeBoth для урока 26 «Магнит монет» (main+coin) 2026-06-09 21:48:31 +03:00
min
2c324fa576 feat(g26): полный паритет «Магнит монет»
JS:
- showText 'Подходи к монеткам — они притянутся!'
- ui.score=0 → каждая собранная монета +1
- onMessage 'coin' → score++ + 'coin' sound, при score>=TOTAL победа + confetti
- 8 монет: onTick → dist<6 → tween к игроку (0.5с), dist<1.2 → delete + broadcast

Lua (паритет):
- __rbxl_show_text + Sounds
- __rbxl_score_set(N) — паритет с game.ui.score=N (ui.set id=__score)
- BindableEvent CoinCollected
- 8 g26_coin_N: Heartbeat → dist<6 TweenService к Vector3(px,py+1,pz),
  dist<1.2 → ev:Fire + Destroy

Shim добавил __rbxl_score_set(value).
2026-06-09 21:47:20 +03:00
min
bc1214e600 docs: CodeBoth для урока 25 «Камера-облёт» (main+finish) 2026-06-09 21:45:14 +03:00
min
189a23ff7c fix(g25): передаём lookAt-точки чтобы камера вращалась к финишу
cameraCutscene без lookAt держит угол постоянным (setTarget не зовётся).
Добавил 3-й arg в __rbxl_camera_cutscene и 4 lookAt-точки указывающие
вдоль пути — камера плавно поворачивается к финишу.
2026-06-09 21:44:14 +03:00
min
d4b84cf73d feat(g25): полный паритет «Камера-облёт»
JS:
- camera.cutscene 4 точки + segDuration=1.8
- onCutsceneDone → showText 'Вперёд, к зелёному финишу!'
- onMessage 'win' → 'Победа!' + win + confetti
- finish: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_camera_cutscene('x1,y1,z1, x2,y2,z2, ...', segDuration)
  Парсит CSV → отдаёт в GameRuntime cmd 'camera.cutscene'.
- __rbxl_on_cutscene_done(fn) — регистрация cb.
  В fireGlobalEvent при p.type='cutsceneDone' фейерим все cb.
- BindableEvent WinReached
- g25_finish: Touched → WinReached:Fire (fired-флаг)

CSV вместо массива объектов — wasmoon через C-boundary
плохо отдаёт массивы таблиц.
2026-06-09 21:41:02 +03:00
min
2a39fc2b99 docs: CodeBoth для урока 24 «Падающий мост» (main+plank+finish) 2026-06-09 21:38:24 +03:00
min
5aec627b17 feat(g24): полный паритет «Падающий мост»
JS:
- showText 'Беги по мосту — доски рушатся!'
- onTick: p.y < -3 → respawn + 'Упал в пропасть! Снова.' + lose
- onMessage 'win' → 'Победа!' + win + confetti
- 18 досок: onTouch → click sound + delete через 1с
- финиш: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text + Sounds
- Heartbeat: player_y < -3 → LoadCharacter + lose
- BindableEvent WinReached
- g24_plank_1..18: единый скрипт (IIFE генерит 18 ключей):
  Touched → click Sound + Debris:AddItem(part, 1)
- g24_finish: Touched → WinReached:Fire (fired-флаг)
2026-06-09 21:37:05 +03:00
min
745e50703d docs: CodeBoth для урока 23 «Переключатели» (main+lever+finish) 2026-06-09 21:34:59 +03:00
min
eb04da6348 feat(g23): полный паритет «Переключатели»
JS:
- showText 'Дёрни рычаги в нужном порядке (E)'
- onMessage 'lever' с {num} → click sound + pressed.push + проверка
  совпадения с ORDER=[2,3,1]. Ошибка → reset + 'Неверно!' + lose
  Полная последовательность → 'Верно! Дверь открыта' + win + tween двери
- onMessage 'win' → 'Победа!' + win + confetti
- лычаги: onInteract E (distance=3) → broadcast 'lever' {num}
- финиш: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text + Sounds
- BindableEvents LeverPulled (с num аргументом) + WinReached
- g23_main: проверка порядка + tween двери (Position.Y+6) + CanCollide=false
- 3 g23_lever_N: Heartbeat distance-check (3), __rbxl_hud_set
  '[E] Дёрнуть рычаг N' в нижней части экрана.
  UserInputService.InputBegan E → LeverPulled:Fire(n)
- g23_finish: Touched → WinReached:Fire (fired-флаг)
2026-06-09 21:33:12 +03:00
min
791cf2cde5 docs: CodeBoth для урока 22 «Зона опасности» (main+zone+heal+finish) 2026-06-09 21:31:22 +03:00
min
59769932e5 feat(g22): полный паритет «Зона опасности»
JS:
- showText 'Пробеги через красную зону к финишу!'
- every(0.6): если inZone — damage(12) + hit
- onMessage 'zone-enter' → inZone=true + 'Опасно! Беги быстрее!'
- onMessage 'zone-leave' → inZone=false
- onMessage 'win' → 'Победа!' + win + confetti
- g22_zone: onTouch → 'zone-enter', onUntouch → 'zone-leave'
- g22_heal: onTouch → heal(60) + '+60 HP' + pickup + delete
- g22_finish: onTouch → 'win'

Lua (паритет):
- __rbxl_show_text + Sounds
- BindableEvents ZoneEnter/ZoneLeave/WinReached
- Heartbeat-таймер 0.6с для урона пока inZone
- g22_zone: Touched/TouchEnded → ev:Fire
- g22_heal: Touched → __rbxl_heal_player(60) + pickup Sound + Destroy
- g22_finish: Touched → WinReached:Fire

Shim добавил __rbxl_heal_player(amount) → cmd 'player.heal'.
2026-06-09 21:29:32 +03:00
min
cbc4b87643 docs: CodeBoth для урока 21 «Преследователь» (main+finish) 2026-06-09 21:25:52 +03:00
min
ea1308d539 feat(g21): полный паритет «Преследователь» + npc.follow/stop/pos в shim
JS:
- spawnNpc 'Охотник' speed=4 follow('player')
- onTick: dist(player,enemy) < 1.6 → respawn + 'Пойман!' + lose
- onMessage 'win' → enemy.stop() + 'Победа!' + win + confetti
- g21_finish: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text + Sounds
- __rbxl_spawn_npc('character-b', ..., 'Охотник', 100, 4)
- __rbxl_npc_follow(ref, 'player') — велим NPC следовать за игроком
- Heartbeat: __rbxl_npc_x/z для расстояния, при <1.6 → LoadCharacter
  + 'Пойман!' + lose Sound (с throttle 2с)
- BindableEvent WinReached + g21_finish.Touched → ev:Fire
- При победе: npc_stop + showText + win + confetti

Shim хелперы:
- __rbxl_npc_follow(ref, target='player')
- __rbxl_npc_stop(ref)
- __rbxl_npc_x/y/z(ref) — позиция NPC
- api.updateNpcPos(localRef, x, y, z) — GameRuntime синкает каждый кадр

GameRuntime.tick собирает позиции всех NPC из npcManager.npcs через
_localToReal и шлёт sb.api.updateNpcPos.
2026-06-09 21:23:53 +03:00
min
d7478fe311 docs: CodeBoth для урока 20 «Имена над врагами» (g20_main) 2026-06-09 21:20:38 +03:00
min
58fbc9d6e6 fix(g20): метка height 2.5 — над health-bar 2026-06-09 21:18:50 +03:00
min
4320c6adeb fix(g20): уменьшил зазоры метки и health-bar
LabelManager использует opts.height как ГАП от верха AABB до плашки,
а не абсолютную высоту. Передавал 3 — метка летела далеко вверх.
Стало 0.3 — небольшой зазор над головой.

Health-bar опустил с y+2.4 до y+1.9 — ближе к голове.
2026-06-09 21:17:16 +03:00
min
7595668b03 fix(g20): метку HP подняли с 3 до 4 — не перекрывается health-bar
Health-bar NPC рендерится на y+2.4 при уроне. Метка height=3 была
слишком близко — health-bar заходил поверх текста. Высота 4 даёт
зазор в ~1.6 над health-bar.
2026-06-09 21:13:38 +03:00
min
9eebbd302e feat(g20): клик по NPC через pick (как Тир ClickDetector)
Раньше: проверка дистанции до врага при ЛКМ через InputBegan
+ MouseButton1. Никак не работало из-за десятка причин.

Теперь как в Тире:
- BabylonScene._meshToTarget теперь возвращает {kind:'npc', id:N}
  для меша с metadata.npcId.
- routeGlobalEvent('click', {target}) — этим уже шлёт в Lua-shim
  с target.
- Shim добавлен __rbxl_npc_on_click(ref, fn) — регистрация callback'а.
  В fireGlobalEvent при type='click'+target.kind='npc' резолвим
  локальный ref и фейерим cb.
- В скрипте игры 20 регистрируем callback на каждого врага.
  Клик ЛКМ по NPC (raycast попадает в мешa NPC) → callback → урон.
2026-06-09 21:10:22 +03:00
min
6aaab1a3f1 fix(g20): обрабатываем 'click' (от BabylonScene) как mouseButton1Down
BabylonScene routeGlobalEvent('click') при ЛКМ — это и есть событие
мыши, но shim ждал только 'mouseButton1Down' (от плеера).

Фикс: мапим p.type === 'click' тоже на mouseButton1Down branch.
Внутри уже фейерим Mouse.Button1Down + UserInputService.InputBegan
с UserInputType.MouseButton1.
2026-06-09 21:07:47 +03:00
min
8c32e80f9f fix(g20): UserInputService.InputBegan фейерится на mouseButton1
Раньше при ЛКМ фейерился только playerMouse.Button1Down.Fire(),
а UserInputService.InputBegan/InputEnded — нет. Lua-скрипт игры 20
ловил клик через InputBegan + UserInputType.MouseButton1 → нет
события → урон не наносился.

Фикс: при mouseButton1Down/Up фейерим InputBegan/InputEnded с
InputObject {UserInputType=Enum.UserInputType.MouseButton1} —
ссылка на тот же объект, что Lua использует в сравнении.
2026-06-09 21:05:16 +03:00
min
4644a332e4 fix(g20): метки привязываются к npc.data.rootMesh (модель)
Метки рисовались в куче в (0,0,0) — anchorMesh был сам npc (объект,
не mesh). LabelManager делал plane.parent = anchorMesh, но позиция
не наследовалась.

Фикс: _resolveTweenTarget для 'npc' возвращает data.mesh =
npc.data.rootMesh (реальная модель NPC).
2026-06-09 21:01:58 +03:00
min
56c35273ef fix(g20): _npcCmd ждёт npc_lua_ префикс + sb.api.setNpcLocalRef
_npcCmd проверял только индекс 'npc:_local_' (для JS-воркера),
ref от Lua-shim 'npc_lua_0' пропускался — отложенные setLabel/damage
терялись.

Также: проверка sb.api?._localToRealNpc была false после рефакторинга
(перенёс в local closure). Замена на sb.api?.setNpcLocalRef — публичный
метод.

Убрал debug-логи.
2026-06-09 20:59:30 +03:00
min
65de311d59 debug(g20): лог npc.spawn/damage/setLabel в LuaSharedSandbox onCommand 2026-06-09 20:53:01 +03:00
min
1be06343ec fix(g20): отложенный setLabel для NPC до npcSpawned-резолва
setLabel сразу после spawnNpc возвращал null от _resolveTweenTarget
(маппинг localRef→realId ещё не записан). Метки молча терялись.

Фикс: если ref начинается с npc_lua_ — откладываем через _npcCmd
(re-вызов после npcSpawned). Иначе retry через 0.3с.
2026-06-09 20:41:09 +03:00
min
b83c9bb75c fix(g20): использовать api.setNpcLocalRef вместо прямого access
Lua-runtime FATAL: 'Cannot access api before initialization' — я
обращался к api._localToRealNpc на 1852 до const api = на 2027.

Фикс:
- _localToRealNpc объявил как const до api (доступен в closures)
- api.setNpcLocalRef(localRef, realRef) — публичный метод
- GameRuntime: sb.api.setNpcLocalRef?.(ref, 'npc:'+npcId)
- В fireGlobalEvent npcDeath используем _localToRealNpc напрямую
2026-06-09 20:38:49 +03:00
min
8a3405e34a feat(g20): полный паритет «Имена над врагами» + shim NPC API
JS:
- 3 NPC (Гоблин, Скелет, Орк) через spawnNpc('character-b')
- setLabel над каждым с HP, обновляется при уроне
- ЛКМ → бьём ближайшего врага в радиусе 4 (damage 30)
- onDeath → clearLabel + hit sound + при 0 врагов — победа+confetti

Расширения shim/runtime:
- __rbxl_spawn_npc уже было, добавил api._localToRealNpc Map
- __rbxl_npc_damage(ref, amount) → cmd 'npc.damage'
- __rbxl_set_label(ref, text, color, height) → cmd 'scene.setLabel'
- __rbxl_clear_label(ref) → cmd 'scene.clearLabel'
- __rbxl_npc_on_death(ref, fn) — регистрирует cb. shim слушает global
  event 'npcDeath' (resolveTweenTarget теперь поддерживает kind='npc')
  и зовёт зарегистрированные cb с подходящим ref (local или real).
- GameRuntime.npc.spawn.then синхронизирует _localToRealNpc в Lua-sb.

Lua-скрипт игры 20 (паритет):
- showText 'Победи всех врагов! Кликай по ним'
- 3 спавна с метками HP над головой
- UserInputService.InputBegan MouseButton1 → ближайший враг в r=4 → -30hp
- На смерть: clearLabel + при 0 — Победа + win Sound + confetti
2026-06-09 20:34:20 +03:00
min
3cdbbc5049 docs: CodeBoth для урока 19 «Лифт» (main+finish) 2026-06-09 20:28:37 +03:00
min
c489a31854 feat(g19): полный паритет «Лифт» — Heartbeat yo-yo
Игра 19 падала на WaitForChild + Vector3 + (оператор не поддержан).

Lua паритет с JS:
- showText 'Встань на синий лифт — он повезёт наверх'
- task.delay 0.2c для FindFirstChild('Лифт') (WaitForChild может зависать)
- Heartbeat yo-yo по Y: startY (1) ↔ TOP_Y (12.3) с периодом 7с
  (3.5с вверх + 3.5с вниз через треугольную функцию)
- Vector3.new напрямую (без +)
- Респаун при y<-3
- BindableEvent WinReached + g19_finish.Touched → ev:Fire
- При победе: showText 'Победа!' + win Sound + confetti
2026-06-09 20:26:19 +03:00
min
98e92b23a7 docs: CodeBoth для урока 18 «Качели» (main+finish) 2026-06-09 20:24:48 +03:00
min
430b9eddcd feat: игра 18 «Качели» Lua + кастомная модалка выхода
Игра 18:
- Падало на WaitForChild + task.spawn + Completed:Wait (yield-across-C).
- Полный переписан: Heartbeat-таймер раскачивает swing по синусу
  с амплитудой 4 и периодом 2.8с (вместо TweenService).
- task.delay 0.2с чтобы дождаться появления свинга в scene.
- Vector3.new напрямую (без оператора + который не работает).
- BindableEvent WinReached + g18_finish.Touched → ev:Fire.
- Полный паритет: showText, win Sound, confetti, respawn при y<-3.

Модалка выхода:
- Заменил window.confirm в KubikonEditor.handleBack на ConfirmModal.
- 3 варианта: Сохранить и выйти / Выйти без сохранения / Отмена.
- ConfirmModal расширен onCancel prop (отделяет 'cancel-кнопка'
  от 'клик мимо/Escape').
2026-06-09 20:22:16 +03:00
min
b5ba62cca8 docs: CodeBoth для урока 17 «Ключ и сундук» (main+key+chest) 2026-06-09 20:15:54 +03:00
min
1345f51436 feat(g17): полный паритет с JS «Ключ и сундук»
JS:
- showText 'Найди ключ и открой сундук!'
- onMessage 'takeKey' → inventory.add('Ключ') + 'Ты нашёл Ключ!' + pickup
- onMessage 'openChest' → если has('Ключ'): 'Победа!' + win + confetti
                         иначе: 'Сундук заперт. Сначала найди ключ.'
- key: onTouch → broadcast 'takeKey' + delete
- chest: onInteract E (distance=4) → broadcast 'openChest'

Lua (паритет, юзер указал на отсутствие надписей/инвентаря/звуков):
- __rbxl_show_text для всех подсказок (вместо print)
- __rbxl_inventory_define('key','Ключ') → итем в hotbar инвентаря
- __rbxl_inventory_add('key',1) при подборе (вместо leaderstats BoolValue)
- __rbxl_inventory_has('key') проверка ключа в сундуке
- Sounds 'pickup'/'win'/'lose' (раньше только print)
- BindableEvents TakeKey + OpenChest
- g17_key: Touched → ev:Fire + Destroy (с taken-флагом)
- g17_chest: убран ошибочный Touched-handler.
  Heartbeat distance-check как в Торговце:
  при near=true → __rbxl_hud_set '[E] Открыть сундук'
  UserInputService E → OpenChest:Fire
- При победе: confetti над игроком + win Sound
2026-06-09 20:13:52 +03:00
min
1a6a92b431 docs: CodeBoth для урока 16 «Лава-пол» (main+lava+finish) 2026-06-09 20:11:16 +03:00
min
2979104931 fix(g16): урон только когда игрок РЕАЛЬНО в лаве (не над островком)
Раньше: фильтр по Y-капсулы не работал — игрок стоит на островке на
realPos.y=1.54 > порога 1.35, фильтр пропускал.

Стало: проверяем где игрок по X/Z. Если над островком (любой из 6) или
над финишной площадкой — пропускаем урон. Иначе — урон каждый кадр пока
Touched активно (RunService.Heartbeat). У damage есть i-frames ~0.5с
так что урон ~2/сек.

Также: переход с Touched-разово на Heartbeat-постоянно. Раньше Touched
срабатывал только при ВХОДЕ в лаву — если игрок стоит в лаве долго,
TouchEnded не вызывался, и урон шёл только раз. Теперь Touched/TouchEnded
выставляют inLava-флаг, Heartbeat считает урон каждый кадр.
2026-06-09 20:09:07 +03:00
min
f0f739071a feat(g16): полный паритет «Лава-пол» + защита от ложного хитбокса
JS:
- showText 'Прыгай по островкам! Лава жжёт!'
- onTick: y<-2 → respawn
- onMessage 'win' → showText + win + confetti
- g16_lava: onTouch → damage(20) + hit sound
- g16_finish: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text + win Sound + confetti
- Heartbeat: __rbxl_player_y() < -2 → LoadCharacter
- BindableEvent WinReached + g16_finish.Touched → ev:Fire
- g16_lava: __rbxl_damage_player(20) + hit Sound
  ВАЖНО: лава-зона на y=1±0.3 (верх 1.3), островки на y=1.2±0.3 (верх 1.5).
  AABB-зоны пересекаются → BabylonScene touch-detector с EPS=0.25
  активировал лаву даже когда игрок стоит на островке.
  Защита: если игрок выше Y=1.35 (стоит на островке/финише) — пропускаем
  урон. Урон срабатывает только когда игрок реально в лаве.

Shim добавил:
- __rbxl_damage_player(amount) → cmd player.damage (с i-frames)
2026-06-09 20:03:50 +03:00
min
662d0d06e4 docs: CodeBoth для урока 15 «Тир» (main+target) 2026-06-09 20:01:00 +03:00
min
d6a874c8a0 fix(g15): клик в 3-м лице по координатам курсора, не центру
В 3-м лице курсор свободный. _handlePlayClick ВСЕГДА пикал центром
экрана через _pickFromCenter — независимо от того где находится курсор.
Поэтому клик мышкой по мишени не срабатывал — пик шёл из центра.

Фикс: проверяем document.pointerLockElement:
- locked (1-е лицо) → _pickFromCenter (прицел всегда в центре)
- !locked (3-е лицо) → scene.pick(clickX, clickY) по реальной позиции
  курсора при клике.

Убрал debug-логи.
2026-06-09 19:59:38 +03:00
min
12efef7ff5 debug(g15): логи в shim fireTargetEvent + click — найти где обрывается 2026-06-09 19:56:06 +03:00
min
8abbde9d67 feat(g15): полный паритет «Тир» + ClickDetector в shim
JS:
- 8 мишеней-сфер на постаментах
- ui.score, showText 'Кликай по красным мишеням!'
- target: onClick → explosion particles + delete + broadcast 'hit'
- main: onMessage 'hit' → score++ + hit sound + при 8 победа+confetti

Lua-shim расширение:
- Instance.new('ClickDetector') создаёт инстанс с MouseClick signal
- Когда clickDet.Parent = part → Proxy.set регистрирует
  part._clickDetector = clickDet
- fireTargetEvent при kind='click' фейерит part._clickDetector.MouseClick.Fire

Lua-скрипт игры 15:
- ScreenGui 'Мишени: N / 8'
- BindableEvent TargetHit
- 8 g15_target_N (ids 2,4,6,8,10,12,14,16): ClickDetector с
  MaxActivationDistance=50 → MouseClick → spawn explosion + Destroy + Fire
- main: hit Sound, при 8 — win Sound + showText + confetti
2026-06-09 19:52:16 +03:00
min
2e1ee87ed6 docs: CodeBoth для урока 14 «Собери по тегам» (main+star) 2026-06-09 19:47:55 +03:00
min
7f3b81a531 feat: умный авто-fallback Lua для всех игр без явной реализации
Раньше для игр 15-50 при открытии 'Открыть мою копию на Lua' юзер
получал TODO-заглушки которые ничего не делали (или simpleMain
который только print). Каждая новая игра без явного override
была полностью неиграбельной.

Новый generateFallbackLua(s, gameTitle) в buildGameProject:

Главный скрипт (target=null):
  - __rbxl_show_text(gameTitle) подсказка
  - Слушает BindableEvent FinishReached → win Sound + Победа! + confetti

Скрипт-финиш (target=primitive с именем 'Финиш'/'ФинишЗона'/'Final'):
  - Touched → создаёт/находит BindableEvent FinishReached → Fire
  - fired-флаг чтобы 1 раз

Прочие target-скрипты:
  - Touched → красит примитив зелёным (визуальный feedback)
  - touched-флаг

Удалил статичные simpleMain stub-ы для игр 31-50 — теперь они
используют умный fallback. Когда дописываем полную Lua-версию
игры — добавляем явный override в LUA_OVERRIDES, fallback
автоматически перестаёт использоваться.

Это даёт минимум: победа на финише + цвет на касании во всех
35 не-готовых играх (15-50).
2026-06-09 19:43:20 +03:00
min
cc5717f5a3 feat(lua-games): полный паритет для игры 14 «Собери по тегам»
JS:
- 7 жёлтых звёзд-конусов + 5 синих кубов-обманок
- showText 'Собери все ЖЁЛТЫЕ звёзды!'
- main помечает звёзды тегом 'звезда' с задержкой 0.2с
- onMessage 'collected' → score++ + при 0 left — победа+confetti
- star: onTouch → untag + delete + broadcast 'collected'

Lua (паритет):
- 7 g14_star_N через генератор (раньше был один g14_star)
- ScreenGui 'Звёзды: N / 7' счётчик
- BindableEvent StarCollected
- main: task.delay(0.2) → AddTag всем 7 звёздам через workspace:FindFirstChild
- При collect → coin Sound + GetTagged-проверка left==0 → win+confetti
- g14_star_N: RemoveTag + Destroy + ev:Fire (с picked-флагом)
2026-06-09 19:38:55 +03:00
min
1a3c8e66e6 docs: CodeBoth для урока 13 «Торговец» (main+counter+door+finish) 2026-06-09 19:37:05 +03:00
min
b7b3c1eb81 fix(g13): подсказки [E] через HUD ui.set — паритет с JS
Юзер: подсказка в Lua была слева от центра, белая. В JS — точно по
центру внизу, жёлтая.

Корень: я использовал BillboardGui+TextLabel с явной Position, но
GUI-shim позиционирует label некорректно (UDim2 offset плохо
интерпретируется → плашка плыла).

Фикс: используем тот же механизм что JS — game.ui.set (HUD через
React). Добавил хелпер __rbxl_hud_set(id, text, x, y, color, size)
шлющий 'ui.set' cmd, GameRuntime пробрасывает в _onHud → GameHud.jsx
рендерит точно как для JS-скриптов.

В g13_counter/g13_door: при near=true → hud_set с (50, 75, #ffe44a, 20)
(центр по X, ниже центра по Y, жёлтый — точно как JS interact hint).
При выходе из зоны → hud_set(id, nil) убирает.
2026-06-09 19:34:32 +03:00
min
e8bfdda380 fix(g13): подсказки выше хотбара (0.6 центра вместо низа)
Прошлая позиция (1, -120) перекрывала хотбар инвентаря.
Новая (0.5, -160; 0.6, 0) — выше середины экрана, не мешает.
2026-06-09 19:09:34 +03:00
min
47ad608182 feat(g13): NPC-торговец + инвентарь как в JS
Юзер указал что в Lua-версии было отступление от JS:
- Торговец нарисован примитивами вместо character-a скина
- Ключ показан надписью в HUD вместо инвентаря-hotbar

Добавил в shim хелперы паритета:
- __rbxl_spawn_npc(modelType, x,y,z, name?, hp?, speed?) → cmd npc.spawn
  Возвращает локальный ref для дальнейших команд.
- __rbxl_npc_say(ref, text, duration) → cmd npc.say
- __rbxl_inventory_define(itemId, name, color) → cmd items.define
- __rbxl_inventory_add(itemId, count) → cmd inv2.add (показывает в hotbar)
- __rbxl_inventory_has(itemId) → проверка локального кеша
- __rbxl_inventory_remove(itemId, count) → cmd inv2.remove

Lua-скрипт игры 13:
- spawnNpc 'character-a' за прилавком как в JS
- inventory_define('key', 'Ключ') → hotbar
- При разговоре: npc_say + inventory_add('key', 1)
- При двери: проверяем inventory_has('key')
2026-06-09 19:05:55 +03:00
min
fb390f402c fix(g13): торговец-фигурка + подсказки внизу экрана
1) Торговец-NPC отсутствовал. Спавним фигурку из 3 частей:
   - Тело (синий куб) на (0, 2.0, 5) — за прилавком
   - Голова (бежевая сфера) на (0, 3.2, 5)
   - Шляпа (коричневый цилиндр) на (0, 3.8, 5)

2) Подсказки [E] плыли в центр экрана. Задал явную позицию:
   нижняя часть, по центру, с тёмной плашкой и тенью.
   Применил к g13_counter и g13_door.
2026-06-09 18:59:54 +03:00
min
e163fe9770 feat(lua-games): полный паритет для игры 13 «Торговец»
JS:
- spawnNpc торговец, prompts E:
  Прилавок onInteract → broadcast 'talk' → выдать ключ через inventory
  Дверь onInteract → broadcast 'openDoor' → проверка inventory.has
  → tween двери (y:9)
- Финиш onTouch → broadcast 'win' → confetti

Lua (паритет, NPC через статичный прилавок):
- ScreenGui 'Ключа нет' / 'У тебя есть Ключ' (вместо inventory)
- BindableEvents TalkTrader / OpenDoor / WinReached
- g13_counter: BillboardGui '[E] Поговорить с торговцем' в радиусе 4
  + UserInputService E → TalkTrader:Fire
- g13_door: BillboardGui '[E] Открыть дверь' в радиусе 4
  + E → OpenDoor:Fire (или 'Дверь заперта' если ключа нет)
- g13_main:
  TalkTrader → если ключа нет: hasKey=true + 'Привет!' + pickup Sound
  OpenDoor → если hasKey: tween двери +6 по Y + win Sound
  WinReached → 'Победа!' + confetti
- g13_finish: Touched → WinReached:Fire (fired-флаг)
2026-06-09 18:56:28 +03:00
min
e0a457bd7a docs: CodeBoth для урока 12 «Дверь по коду» (main+btn+finish) 2026-06-09 18:55:09 +03:00
min
89baf23877 feat(lua-games): полный паритет для игры 12 «Дверь по коду»
JS:
- CODE=[3,1,4,2], showText 'Нажми кнопки в правильном порядке (E)'
- onMessage 'press' с {num} → click sound, push в entered, проверка
  совпадения с CODE, ошибка → сброс + 'Неверно!' + lose sound
  весь код → 'Код верный! Дверь открывается' + win sound + tween двери
- onMessage 'win' → 'Победа!' + win + confetti
- кнопка: onInteract (E, distance=3) → broadcast 'press' {num}
- финиш: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text всех подсказок + Sound 'click'/'lose'/'win'
- BindableEvent ButtonPress (с num аргументом) + WinReached
- g12_main: tween двери (Position.Y+=6) + CanCollide=false
- 4 g12_btn_N: BillboardGui '[E] Нажать N' видим в радиусе 3
  UserInputService.InputBegan E → ev:Fire(num)
  Hint видимость через Heartbeat + __rbxl_player_x/z
- g12_finish: Touched → ev:Fire WinReached
- При win — confetti
2026-06-09 18:53:22 +03:00
min
b2f6b084df docs: CodeBoth для урока 11 «Эхо-комната» (main+tile+finish) 2026-06-09 18:51:40 +03:00
min
eacc3f990b feat(lua-games): полный паритет для игры 11 «Эхо-комната»
JS:
- 6 цветных плиток-цилиндров со своими звуками
- ui.score, ui.showText 'Наступи на все цветные плитки!'
- onMessage 'step' → score++ + при 6 'Иди на финиш'
- onMessage 'finish' → если < TOTAL то 'Сначала пройди все плитки'
  иначе showText + win + confetti
- g11_tile_N: onTouch → sound + sparks particles, при первом — broadcast
- g11_finish: onTouch → broadcast 'finish'

Lua (паритет):
- ScreenGui 'Плитки: N / 6'
- BindableEvent EchoStep (плитка) + EchoFinish (зона)
- 6 g11_tile_N: каждая со своим Sound (coin/jump/pickup/click/hit/coin)
  + __rbxl_spawn_particles('sparks', x, y+1, z) при касании
  + Throttle 0.4с между звуками + used-флаг
- g11_main: 'Все плитки звучали! Иди на финиш' при 6
  'Сначала пройди все 6 плиток!' если рано пришёл на финиш
  При win — Sound 'win' + confetti
2026-06-09 18:47:57 +03:00
min
3be10c3cf7 docs: CodeBoth для урока 10 «Прыжок-пружина» (main+tramp+finish) 2026-06-09 18:46:08 +03:00
min
50b08b81bc feat(lua-games): полный паритет для игры 10 «Прыжок-пружина»
JS:
- showText 'Прыгай по батутам всё выше!'
- onTick: y<-3 → respawn + lose sound
- onMessage 'win' → showText + win + confetti
- g10_tramp_N: onTouch → player.boostJump(3.2) + jump sound
- g10_finish: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text подсказка + 'Победа!'
- Heartbeat: __rbxl_player_y() < -3 → LoadCharacter + lose Sound
- BindableEvent WinReached + g10_finish.Touched → ev:Fire
- При win — confetti

Добавил хелпер в shim:
- __rbxl_boost_jump(strength) → send 'player.boostJump'
  3.2 = втрое выше обычного прыжка

g10_tramp_4/5/6: Touched → __rbxl_boost_jump(3.2) + jump Sound
с защитой от зацикливания (минимум 0.5с между активациями).
2026-06-09 18:44:34 +03:00
min
4186b49be4 docs: CodeBoth для урока 9 «Светофор» (g9_main + g9_finish) 2026-06-09 18:42:14 +03:00
min
f18835d5e9 fix(lua): player:LoadCharacter() / hrp.Position телепортируют через player._pos
GameRuntime обрабатывал prop:'position' и prop:'respawn' через
player.body.position.set() — но PlayerController хранит позицию в
player._pos (не body). Поэтому LoadCharacter молча ничего не делал.

Фикс: ставим player._pos.set(x, y+halfH, z) + сброс _vy.
Fallback на body если _pos нет.
2026-06-09 18:40:20 +03:00
min
bec3c478e7 feat(lua-games): полный паритет для игры 9 «Светофор»
JS:
- showText 'Зелёный — беги! Красный — замри!'
- light=findOne('Светофор'), green/red/green циклически (3с/2.5с)
- setColor light на красный/зелёный + showText
- onTick: если красный и moved/dt>0.8 — respawn + lose sound
- onMessage 'win' → showText + win + confetti
- g9_finish: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text для всех подсказок
- Heartbeat-таймер фазы (task.spawn не умеет yield) — переключает
  phase 'green'/'red' каждые GREEN_TIME/RED_TIME
- light=workspace:FindFirstChild('Светофор'), .Color = Color3.fromRGB
- Heartbeat: prevX/prevZ, при phase=red и moved/dt>0.8 → LoadCharacter
  + lose Sound + 'Двинулся на красный!'
- BindableEvent WinReached
- g9_finish: Touched → ev:Fire с fired-флагом
- При победе: win Sound + confetti
2026-06-09 18:35:47 +03:00
min
41e0f7b6a4 feat(wiki): добавил CodeBoth с Lua-параллелью к скриптам игр 1-8
Создан хелпер CodeBoth в docsLessons.jsx: оборачивает <Code> в
<LangTabs js={JS-код} lua={Lua-код из LUA_OVERRIDES}>. Юзер
переключает JS↔Lua вверху урока — код в статье меняется тоже.

Заменены 17 блоков <Code> в уроках игр 1-8 на <CodeBoth>:
- collect-coins (g1_main, g1_coin_1)
- platform-jump (g2_main, g2_finish)
- dont-fall (g3_main, g3_tile_1)
- button-door (g4_main, g4_button, g4_finish)
- maze (g5_main, g5_finish)
- color-tiles (g6_main, g6_tile_1)
- catch-falling (g7_main)
- run-to-finish (g8_main, g8_finish)

Для остальных игр (9-50) остался JS-only Code — заменим
по мере прохождения.
2026-06-09 18:24:16 +03:00
min
73bf9f5c34 feat(lua-games): полный паритет для игры 8 «Беги к финишу»
JS:
- ui.timer + showText 'Беги к зелёному финишу — на время!'
- onTick: time += dt
- onMessage 'finish' → 'Финиш! Время: N сек' + win + confetti
- g8_finish: onTouch → broadcast 'finish'

Lua (паритет):
- ScreenGui+TextLabel секундомер вверху по центру '0.0 сек'
- __rbxl_show_text подсказка
- RunService.Heartbeat: time += dt → label.Text каждый кадр
- BindableEvent FinishReached
- g8_finish: Touched → ev:Fire с fired-флагом
- При финише: 'Финиш! Твоё время: X.X сек' + win Sound + confetti
2026-06-09 18:16:43 +03:00
min
1a174f2854 fix(g7): расширил радиус Touched на 1.2 — куб ловится при сближении
Куб с физикой отталкивается от игрока (DynamicsManager push) и успевает
отскочить до следующего кадра. Строгий AABB ловил только при защемлении
в углу. Расширение SLACK=1.2 единицы ловит 'почти-контакт' — куб
собирается при подходе на ~1 единицу.
2026-06-09 18:12:01 +03:00
min
8295d6f0fe fix(g7): синк позиций спавненных частей в shim для AABB-touched-check
DynamicsManager._applyToMesh обновляет pm.instances[id].x/y/z, но Lua-shim
кэширует Position в part._state.Position на момент создания. AABB-check
видел кубы навечно в небе → касание не ловилось.

Фикс: GameRuntime.tick собирает позиции всех спавненных динамических
примитивов и шлёт в shim через api.updateSpawnedPos(id, x, y, z).
Shim обновляет part._state.Position у соответствующего partById.
2026-06-09 18:09:45 +03:00
min
462ee62a9a fix(g7): Touched на спавненных частях через AABB-check в Heartbeat
BabylonScene._detectTouchEvents работает только для скриптов с явным
target. Спавненные runtime через __rbxl_spawn_part (падающие кубы)
не имеют target — Babylon их не проверяет, Touched молчит.

Решение: shim.fireHeartbeat теперь сам делает AABB игрок↔part для
всех part id >= 800000 (наш range спавненных). При пересечении
фейерит Touched.Fire(hrp); при выходе — TouchEnded.
2026-06-09 18:07:25 +03:00
min
fe2c1bb28b fix(g7): scene3d.dynamics (не dynamicsManager) — теперь регистрация работает 2026-06-09 18:04:14 +03:00
min
2c99a61bb0 debug(g7): лог про регистрацию unanchored part в физике 2026-06-09 18:02:47 +03:00
min
2af9b96088 fix(g7): спавненные unanchored примитивы регистрируются в физике
DynamicsManager.start() собирает unanchored объекты только при входе
в Play. Куб созданный из скрипта в runtime не попадал в bodies →
не падал, висел в воздухе.

Фикс: после pm.addInstance с anchored=false вызываем
dm.registerPrimitive(data) — кладёт тело в физический мир сразу
после спавна.
2026-06-09 17:41:20 +03:00
min
c9acb4fb3b fix(g7): спавн через __rbxl_spawn_part + Heartbeat вместо task.spawn
Корни:
1. task.spawn(function() task.wait() end) → 'attempt to yield across
   a C-call boundary' — task.spawn в shim синхронно зовёт fn из JS.
   Замена: накопление dt в RunService.Heartbeat → spawnCube() каждые 1.5с.

2. Instance.new('Part', workspace) с последующим .Anchored=false
   создавал anchored=true примитив + патч → primitiveManager не пересоздавал
   rigid body, куб не падал. Новый хелпер __rbxl_spawn_part(opts) шлёт
   sceneCreate с правильным anchored СРАЗУ — куб создаётся динамическим
   и падает.
2026-06-09 17:38:16 +03:00
min
8021ed6a20 feat(lua-games): полный паритет для игры 7 «Поймай падающее»
JS:
- ui.score, showText 'Лови падающие кубы! Нужно 15'
- every(1.5): random(x,z) → spawn cube y=14, anchored=false, deleteAfter 6с
- onPlayerTouch: caught[ref] флаг, +1 score, coin sound, при 15 — победа+confetti

Lua (паритет):
- ScreenGui+TextLabel 'Поймано: N / 15'
- task.spawn + task.wait(1.5) цикл спавна
- Instance.new('Part', workspace), Anchored=false (падение)
- Vector3.new для Position/Size
- Debris:AddItem(cube, 6) — авто-удаление
- cube.Touched: caught-флаг + score++ + coin Sound + Destroy
- При 15 — win Sound + showText + confetti
2026-06-09 17:34:16 +03:00
min
0603d922d4 fix(g6 builder): центрирую сетку 6×6 плиток на платформе
Было: x:-4+c*2 → плитки на x=[-4,-2,0,2,4,6], правые края до x=6.9.
Платформа grass [-6,5] (центры [-5.5, 5.5]). Плитка x=6 свешивалась.

Стало: x:-5+c*2 → плитки на x=[-5,-3,-1,1,3,5], края [-5.9, 5.9].
Чётко на платформе.

То же по Z.

Плюс лог 'ЯЗЫК СКРИПТОВ: LUA/JS' в GameRuntime — чтобы было видно
сразу что именно запущено.
2026-06-09 17:31:17 +03:00
min
8eec59af53 feat(lua-games): полный паритет для игры 6 «Цветные плитки»
JS-версия:
- 36 плиток 6×6, серые
- ui.score = painted, ui.showText
- onMessage 'paint' → score++ + pickup sound + при 36 победа+win+confetti
- tile: onTouch → setColor зелёный + broadcast 'paint'

Lua-версия:
- ScreenGui+TextLabel 'Плитки: N / 36' счётчик
- __rbxl_show_text подсказка + 'Победа!'
- BindableEvent TilePainted
- 36 g6_tile_N: Touched → part.Color=зелёный + ev:Fire (painted-флаг)
- g6_main: painted++/label.Text/pickup Sound; при 36 — win+confetti
2026-06-09 17:24:48 +03:00
min
f7074f5cd7 feat(lua-games): полный паритет для игры 5 «Лабиринт»
JS-версия:
- ui.showText('Найди выход из лабиринта!', 3)
- onMessage 'win' → showText + win sound + confetti
- g5_finish: onTouch → broadcast 'win'

Lua-версия:
- __rbxl_show_text подсказка + 'Победа!'
- BindableEvent WinReached
- g5_finish: Touched на финиш-зоне → ev:Fire (с fired-флагом)
- На победе: confetti над игроком
2026-06-09 17:21:56 +03:00
min
36321f0d17 fix(g4): label.Visible=false + Destroy при нажатии E
hintGui:Destroy() не убирает gui-overlay созданный TextLabel через
newGuiInstance. Делаем label.Visible=false (надёжный путь) + явный
label:Destroy().
2026-06-09 17:18:52 +03:00
min
c18dfc4d56 fix(g4): подсказка [E] видна только в радиусе 4 от кнопки
Heartbeat проверяет расстояние от игрока до кнопки. Управляем
видимостью через label.Visible (BillboardGui в shim не управляет
видимостью children, label.Visible работает напрямую через gui.update).
2026-06-09 17:17:03 +03:00
min
c006f58b70 debug(g4): print имена children workspace и состояние двери
Откатил метатаблицу Vector3 (она ломала TweenService instanceof RbxVector3).
2026-06-09 17:13:40 +03:00
min
a4f2f0800b fix(g4): дверь поднимается + метатаблица Vector3 (+, -, *, /)
Проблема: door.Position + Vector3.new(0, 6, 0) возвращало nil потому
что wasmoon не создаёт метаметоды (__add) для JS-классов автоматически.

Фикс:
1. В скрипте кнопки явно считаем Vector3.new(dp.X, dp.Y+6, dp.Z) без +.
2. В prelude добавил метатаблицу Vector3 для будущего использования
   с операторами +, -, *, /, унарный -, ==, tostring. Работает между
   двумя Vector3-таблицами, созданными через Vector3.new в Lua.
2026-06-09 17:11:23 +03:00
min
efff54add2 fix(lua): keydown/keyup в нижнем регистре (BabylonScene шлёт 'keydown')
BabylonScene._normalizeKey → routeGlobalEvent('keydown', {key:'e'}) →
sb.sendGlobalEvent({type:'keydown', key:'e'}) → shim.fireGlobalEvent(p).

Shim проверял только p.type === 'keyDown' (camelCase) — keydown
(lowercase) пропускался. UserInputService.InputBegan не фейерился.

Фикс: принимаем оба варианта keyDown/keydown и keyUp/keyup.
2026-06-09 17:07:44 +03:00
min
5101743aed fix(g4): вернул нажатие E (как в JS-версии)
Откат изменения 'наступи на кнопку'. JS-версия использует
game.self.onInteract — нажатие E. Lua-версия должна вести себя так же.

Подход:
- Подсказка [E] Открыть дверь висит над кнопкой постоянно (пока не нажата)
- UserInputService.InputBegan ловит E
- Расстояние до кнопки проверяется ТОЛЬКО в момент нажатия E
  (не каждый кадр — это избегает багa с зависанием позиции после Touched)
- Если близко (≤4) → дверь поднимается через TweenService
2026-06-09 13:43:38 +03:00
min
4c648d139e fix(g4): кнопка по касанию вместо E-нажатия
Проблема:
1. Heartbeat-зов __rbxl_player_x() возвращал константу после первого
   касания кнопки (возможно lua-coroutine / state-issue в Heartbeat).
2. Дверь не открывалась — InputBegan E видимо не доходит или фильтр
   inRange всегда true.

Решение: упростил — кнопка реагирует на Touched (как кнопка-педаль),
без E и без проверки расстояния. Это и понятнее для урока.
Текст подсказки изменён на 'Наступи на красную кнопку'.

Дверь поднимается через TweenService при касании кнопки.
2026-06-09 13:39:55 +03:00
min
39673452cb debug(g4): print позиции игрока и расстояния раз в секунду 2026-06-09 13:35:24 +03:00
min
05a7eaf371 feat(lua-games): полный паритет для игры 4 «Кнопка-открывашка»
JS-версия:
- ui.showText('Подойди и нажми E', 4)
- onMessage 'win' → showText + win sound + confetti
- g4_button: onInteract (E) → click + tween двери (y:8) + showText 'Дверь открывается!'
- g4_finish: onTouch → broadcast 'win'

Lua-версия (паритет):
- __rbxl_show_text подсказка + 'Победа!'
- BindableEvent WinReached
- g4_button: UserInputService.InputBegan + ProximityHint через BillboardGui
  ('[E] Открыть дверь' над кнопкой когда игрок в радиусе 4)
  E → click Sound + TweenService:Create двери (Position +6 по Y, 1.2с) +
  CanCollide=false + showText 'Дверь открывается!'
- g4_finish: Touched → ev:Fire с fired-флагом

Shim фиксы:
- UserInputService.InputBegan/InputEnded теперь фейерятся на keyDown/keyUp.
  Передаётся InputObject с KeyCode = ССЫЛКА на Enum.KeyCode.<KEY>
  (важно для сравнения == Enum.KeyCode.E).
2026-06-09 13:32:42 +03:00
min
0ed2cbf376 feat(lua-games): полный паритет для игры 3 «Не упади»
JS-версия:
- ui.showText('Беги вперёд! Плитки исчезают!', 3)
- onTick: y<-3 → respawn + 'Упал! Снова.' + sound 'lose'
- broadcast 'finish' → 'Победа! Ты добежал!' + sound 'win' + confetti
- скрипт плитки: onTouch → sound 'click' + game.after(1.2, delete)
- скрипт финиша: onTouch → broadcast 'finish'

Lua-версия (паритет):
- __rbxl_show_text(...) подсказка + 'Упал!' + 'Победа!'
- BindableEvent FinishReached как канал между скриптами
- g3_main: Heartbeat респаун при Y<-3 + lose/win Sound
- 14 g3_tile_N: Touched → click Sound + Debris:AddItem(part, 1.2)
- g3_finish: Touched → ev:Fire (через fired-флаг)
- На финише: __rbxl_spawn_particles('confetti', ...) над игроком
2026-06-09 13:26:38 +03:00
min
f56e9417c9 fix(lua): player._pos вместо body.position
player.body.position не существует — позиция в PlayerController._pos.
Из-за этого realPos оставался null, api._realPlayerPos не обновлялся,
конфетти вылетали из начальной hrp._position (0, 5, 0).
2026-06-09 13:20:51 +03:00
min
96644ede15 fix(lua): __rbxl_player_x/y/z отдельные функции (wasmoon-userdata fix)
Корень: __rbxl_player_pos() возвращал JS-object {x,y,z}, wasmoon оборачивал
его в userdata-proxy. В Lua pos.x давал NaN. Конфетти спавнились с NaN.

Фикс: 3 отдельные функции __rbxl_player_x/y/z возвращающие числа.
В скрипте игры 2 используем их напрямую.
2026-06-09 13:18:30 +03:00
min
ba648de09c debug(lua-games): прибавил print в g2_main для отладки конфетти 2026-06-09 13:15:11 +03:00
min
8321a526cd fix(lua): __rbxl_player_pos возвращает реальную позицию игрока
Проблема: __rbxl_player_pos() возвращал (0,8,0) — нач. позицию hrp._position,
которая не обновлялась. Конфетти всегда вылетали из стартовой точки.

Фикс:
- api._realPlayerPos обновляется в GameRuntime tick (каждый кадр)
  через api.updatePlayerPos(x, y, z) из player.body.position.
- __rbxl_player_pos() в Lua возвращает api._realPlayerPos если есть.

Убраны debug-логи.
2026-06-09 13:01:45 +03:00
min
6fe249033e debug: лог в _spawnParticles и Lua onCommand для scene.particles 2026-06-09 12:56:20 +03:00
min
7384494c8f fix(lua): scene.particles payload — type вместо kind, payload.position
BabylonScene._spawnParticleEffect читает payload.type ('confetti') и
payload.position {x,y,z}. Я слал {kind, pos} — type=undefined →
fallback на 'sparks' → бледные одиночные искорки вместо салюта.

После фикса 'confetti' даёт яркий разноцветный салют.
2026-06-09 12:53:14 +03:00
min
660d528ad5 feat(lua): __rbxl_show_text + __rbxl_spawn_particles (паритет game.ui/scene)
JS-версия использует game.ui.showText (красивая центрированная плашка
без рамки через RbxlHudOverlay) и game.scene.spawnParticles('confetti').
Lua-версия пыталась рисовать ScreenGui+TextLabel через offset в UDim2,
но gui-shim неправильно интерпретировал offset → плашка прижата влево.
Также конфетти отсутствовали.

Решение — хелперы прямого вызова HUD/particle-systems как в JS:
- __rbxl_show_text(text, duration, color?) → shim шлёт ui.showText →
  GameRuntime → _rbxlHud.showMessage + setTimeout hideMessage
- __rbxl_spawn_particles(kind, x, y, z, duration, count) → 'scene.particles'
- __rbxl_player_pos() → возвращает текущую позицию игрока

Игра 2 переписана: использует __rbxl_show_text для подсказок 'Допрыгай',
'Упал!', 'Победа!' и __rbxl_spawn_particles('confetti', ...) на финише.
2026-06-09 12:47:44 +03:00
min
901c770fdc feat(lua-games): полный паритет для игры 2 «Прыгай по платформам»
JS-версия имела:
- ui.showText('Допрыгай до зелёной площадки!', 3)
- onTick: y<-3 → respawn + 'Упал!' + sound 'lose'
- broadcast 'finish' → 'Победа!' + sound 'win' + конфетти
- finish-зона: onTouch → broadcast 'finish'

Lua-версия (паритет):
- ScreenGui подсказка 'Допрыгай до зелёной площадки!' на 3с
- RunService.Heartbeat: hrp.Y < -3 → player:LoadCharacter() + Sound
  'lose' + красный TextLabel 'Упал! Пробуй снова.' на 1.5с
- BindableEvent FinishReached в ReplicatedStorage
- g2_finish: Touched на финиш-зоне → ev:Fire (только 1 раз через fired-флаг)
- g2_main: FinishReached → Sound 'win' + зелёный 'Победа! Ты дошёл до финиша!'

Юзер: открой копию ЗАНОВО на Lua — старый проект 2908 был со старым
кодом (только print), новая копия получит обновлённые скрипты.
2026-06-09 10:47:56 +03:00
min
ad26395e10 fix(lua): убран двойной part.Touched.Fire — счёт +1 за монетку
Симптом: счётчик показывает 10/8 при 5 собранных монетах.

Корень: BabylonScene на каждое касание шлёт И routeEvent('touch') И
routeGlobalEvent('playerTouch'). Lua-shim фейерил part.Touched.Fire(hrp)
в обоих обработчиках (fireTargetEvent + fireGlobalEvent). Handler
монетки срабатывал 2 раза → 2 Fire CoinCollected → score +2.

Фикс: в fireGlobalEvent для playerTouch УБРАЛИ part.Touched.Fire.
Остался только humanoid.Touched.Fire(part) — это уникальный для
playerTouch сценарий (когда юзер слушает humanoid.Touched).
2026-06-09 10:45:06 +03:00
min
598b91bd9e fix(lua): inst.Parent = X авто-добавляет в parent.Children
Корень бага: g1_main делал:
  ev = Instance.new('BindableEvent')
  ev.Parent = ReplicatedStorage
Но Proxy 'set' просто писал t['Parent']=value, НЕ добавляя ev в
ReplicatedStorage.Children. Поэтому скрипт монетки делал:
  ev = ReplicatedStorage:FindFirstChild('CoinCollected')  -- nil!
  if ev then ev:Fire() end  -- false, ничего не происходит
HUD/звук не обновлялись.

Фикс: Proxy.set теперь:
1) удаляет себя из старого parent.Children
2) пушит в новый parent.Children
3) фейерит ChildRemoved/ChildAdded/AncestryChanged

Это паритет с Roblox Instance.Parent setter.
2026-06-09 10:41:49 +03:00
min
701125d17b fix(lua): task.delay/spawn/defer не терялись после prelude
Корень бага HUD/звука в игре 'Собери монетки':
prelude перезаписывал task = { wait = rbx_wait } если type(task)~='table'.
task — JS-объект (userdata) → ветка else → методы delay/spawn/defer
исчезали.

Скрипт g1_main падал на:
  task.delay(2, function() hintGui:Destroy() end)
с 'attempt to call a nil value (field delay)'.

Из-за этого Connect на CoinCollected ниже не выполнялся → HUD не
обновлялся, звук не играл.

Фикс: сохраняем существующие методы task.delay/spawn/defer из shim,
добавляем wait.
2026-06-09 10:37:18 +03:00
min
bb69ccf9ed feat(lua-games): полный UI+звук для игры 'Собери монетки'
Было: только print в консоль, leaderstats не виден на экране.
Стало паритет с JS-версией:
- ScreenGui+TextLabel счётчик 'Монеты: N / 8' в правом углу
- ScreenGui подсказка 'Собери все монетки!' на 2 сек по центру
- Sound 'coin' при сборе (через Sound:Play, SoundId='coin')
- Sound 'win' + победный TextLabel когда score >= TOTAL
2026-06-09 10:31:37 +03:00
min
36b41616b0 fix(lua): прямой вызов h.fn() в JS вместо передачи в Lua
Не передаём JS-обёртку Lua-функции обратно в Lua через
luaDrainHandler — это создавало wasmoon Promise-detection crash
на null.then.

Прямой h.fn(...args) → wasmoon вернёт Promise → .catch ловим.
2026-06-09 10:28:32 +03:00
min
0cbd1d7a82 diag(lua): Touched.Fire() без аргумента — изолируем виновника
Гипотеза: hrp передача в Lua-функцию через wasmoon крашит null.then.
Если без hrp handler отработает — значит точно hrp.
2026-06-09 10:26:35 +03:00
min
c6ba06eea6 debug(lua): log start/end/error в handler 2026-06-09 10:22:06 +03:00
min
d5b70ac4aa debug(lua): покажем что мы доходим до drain и какие ошибки ловим
Сейчас связь Touched → handler рвётся где-то. Хочу видеть:
1. Срабатывает ли drain цикл (queue.length > 0)
2. Что именно catch съедает
2026-06-09 10:20:27 +03:00
min
eddf0b5a23 fix(lua): обходим wasmoon null.then bug — __rbxl_drain_handler возвращает 1
Корень: wasmoon Promise-detection (строка 1026 в index.js):
  if (Promise.resolve(target) === target || typeof target.then === 'function')
typeof null === 'object' → проходит проверку → target.then на null → crash.

Когда __rbxl_drain_handler возвращался nil → wasmoon видел null → крашился.

Фикс: возвращаем число 1 явно из coroutine.create body и из самой
drain_handler. Это не-объект — проверка Promise-detection не сработает.
2026-06-09 10:12:36 +03:00
min
eae2ad7cc5 debug(lua): __log сообщения внутри pcall fn
Чтобы увидеть РЕАЛЬНУЮ ошибку Lua-handler (раньше pcall глотал её).
2026-06-09 10:04:45 +03:00
min
6ce296570d fix(lua): подавляем wasmoon Promise.then(null) в drain_handler
Симптом: при touched event на монетке в логах:
  [drain handler error] TypeError: Cannot read properties of null (reading 'then')
Скрипт монетки не отрабатывал — Destroy не звался, ev:Fire не было.

Причина: wasmoon при вызове Lua-функции из JS возвращает Promise.
Если в Lua-handler был crash (например yield-across-C-boundary
от debug.sethook), wasmoon пытается .then(null) внутри Promise цепочки.

Фикс:
1. Если luaDrainHandler вернул thenable — .catch(()=>{}) подавляем.
2. Откатил debug-логи которые мог ломать handler.
3. Drain-handler опять чистый pcall(fn, args).
2026-06-09 10:04:14 +03:00
min
4835cb59c2 debug(lua): добавил __log в drain_handler
Чтобы увидеть запускается ли handler из очереди и нет ли pcall error.
Сейчас [shim fireTargetEvent] показывает connections=1 но нигде нет
выхлопа от Touched handler — где-то теряется.
2026-06-09 06:55:51 +03:00
min
0980ec4a5f debug(lua): добавил console.warn в fireTargetEvent
Проверяю: при [Touch FIRE] доходит ли событие до Lua-shim,
правильно ли резолвится part по primId, есть ли connections
у Touched сигнала.
2026-06-09 06:51:50 +03:00
min
ba2f3bb57f fix(lua): routeEvent доставляет события в LuaSharedSandbox
Проблема: при касании монетки [Touch FIRE] логировалось но скрипт
монетки не реагировал. Монетка не исчезала, счёт не менялся.

Причина: GameRuntime.routeEvent пропускал sandbox если sb.target=null.
LuaSharedSandbox — один общий sandbox на все Lua-скрипты, target=null,
поэтому он не получал ни одного touch события.

Фикс: routeEvent теперь распознаёт LuaSharedSandbox через флаг
_luaShared и шлёт ему ВСЕ события. Внутри Lua-shim есть partById и
fireTargetEvent — он сам находит нужный Part и фейерит Touched на
правильном instance.

Также: LuaSharedSandbox.constructor ставит this._luaShared = true.
2026-06-09 06:47:17 +03:00
min
ee0ab60381 fix(lua-games): кириллические имена leaderstats через bracket access
Lua не поддерживает кириллицу в именах identifier'ов (только в строках).
stats.Монеты вызывал parser error:
  <name> expected near '<\208>'  (0xD0 = первый байт UTF-8 кириллицы)

Заменено на безопасный синтаксис:
  stats.Монеты         → stats['Монеты']
  stats.Ключ           → stats['Ключ']
  stats.Клики          → stats['Клики']

Затронуты игры: collect-coins (1), trader (13), key-chest (17),
shop (29), clicker (46). Все 9 случаев исправлены.

Теперь монетки в игре 1 должны нормально увеличивать счётчик.
2026-06-09 06:42:18 +03:00
min
3757eace9f feat(wiki): Lua-версии для всех 50 игр-уроков + расширения shim
ИНФРАСТРУКТУРА:
- docsGamesBuildersLua.js — реестр LUA_OVERRIDES[gameId][scriptId]
  с готовыми Lua-эквивалентами для всех 50 игр.
- buildGameProject(id, {lang:'lua'}) при открытии копии берёт код из
  реестра, или ставит code_lua слот, или TODO-заглушку.
- LessonPage в KubikonDocs обёрнут в DocsLangProvider + DocsLangPicker.
- Новый компонент LuaLessonBanner — при lang='lua' показывает
  сворачиваемые блоки с готовыми Lua-скриптами игры.

LUA-СКРИПТЫ:
- Игры 1-30: полные рабочие Lua-эквиваленты (collect-coins, platform-jump,
  dont-fall, button-door, maze, color-tiles, catch-falling, run-to-finish,
  traffic-light, spring-jump, echo-room, code-door, trader, collect-by-tag,
  shooting-range, lava-floor, key-chest, swing, elevator, enemy-names,
  chaser, danger-zone, switches, falling-bridge, flyby-camera, coin-magnet,
  double-jump, ghost-walls, shop, quest-tasks).
- Игры 31-50: главный скрипт с сообщением + TODO для полной реализации.
  Для clicker — полная версия. Остальные постепенно дорабатываются.

РАСШИРЕНИЯ LUA-RUNTIME (RobloxShim.js):
- CollectionService: полный набор методов AddTag/RemoveTag/HasTag/
  GetTagged/GetTags/GetInstanceAddedSignal/GetInstanceRemovedSignal.
- Debris сервис: AddItem(inst, lifetime) → setTimeout Destroy.
- localPlayer:LoadCharacter() реальный — сбрасывает HP + шлёт respawn.
- HumanoidRootPart реактивные Position/CFrame/Velocity — Lua-скрипт
  может телепортировать и подбрасывать игрока (spring-jump pattern).

РАСШИРЕНИЯ GameRuntime:
- playerSet 'position' — телепорт через hrp.Position = ...
- playerSet 'respawn' — респаун с сбросом HP и позиции на spawn.

Игры теперь работают на Lua. Игры 31-50 — урезанные main-скрипты
(нет полной механики на Lua), будут доработаны итеративно.
2026-06-09 03:47:08 +03:00
min
86b3d2f238 feat(wiki): AI-контекст для Lua рядом с JS-контекстом
Раньше в статье "AI2. Контекст — скопируй в нейросеть" был только
JS-вариант. Юзер пишущий на Lua не мог использовать.

Сейчас:
- Новая константа AI_CONTEXT_LUA — полный API Lua-рантайма Рублокса
  (стандартный Roblox-стиль: game:GetService, workspace, Vector3,
  Instance.new, TweenService и т.п. + наши особенности).
- LangTabs обёртка над <Code> в AI2 — нейросеть получит контекст
  на нужном языке.
- AI1 (как пользоваться) дополнен подсказкой про переключатель.

Также в <Code> добавлен prop plain=true для отключения подсветки —
AI-контекст это текст для копирования, а не код, ему подсветка
не нужна (и она ломала бы тысячи символов API-описаний).
2026-06-09 03:22:58 +03:00
min
d6ba23ae8d feat(wiki): подсветка синтаксиса JS и Lua в код-блоках
Раньше код был монотонным — все белое на чёрном.
Сейчас цветной syntax highlighting в стиле Dracula:
- розовый (#ff79c6) — ключевые слова (let/const/function/local/end/then)
- голубой (#8be9fd) — встроенные (game/workspace/Math/Vector3)
- жёлтый (#f1fa8c) — строки
- фиолетовый (#bd93f9) — числа
- серый (#6272a4) italic — комментарии
- зелёный (#50fa7b) — имена функций (id с () после)

Реализация: docsLang.highlightCode() — простой regex-токенизатор.
<Code> компонент авто-детектит lang ('js'/'lua') по содержимому
(паттерны local/then/--/:Connect), либо принимает явный prop lang=.

Без внешних библиотек — ~80 строк регулярки, легко поддерживать.
2026-06-09 03:14:44 +03:00
min
35cd304b0e style(wiki): убрана рамка вокруг строк в код-блоках
В прошлом коммите добавил border к .docsSectionBody code (для inline-плашек).
Он унаследовался внутри pre.docCode — рамка появилась вокруг каждой строки.
Сбрасываем border:none для .docCode code.
2026-06-09 03:11:36 +03:00
min
d5b146cace style(wiki): убрал тёмные стили docsLang перекрывающие светлые таблицы
Было: docsLang.jsx ставил тёмные .docTable (background #181b2c, color
#aab0c8) которые перебивали светлые стили вики (#fafbfd, #334155).
Получалось тёмно-синий текст на тёмно-синем — почти невидимо.

Сейчас:
1. Убран override .docTable / td / td:first-child — наследует светлые
   стили из KubikonDocs.jsx как остальная вика.
2. Добавлен только .docTable th (его не было) — светло-голубой фон
   eef2ff с тёмным текстом 1e3a8a для заголовков колонок.
3. .docsLangTabs переведён со тёмной (#181b2c) на светлую (#fff +
   #f4f6fb head, #e0e6f0 рамка) тему. Активная вкладка — синяя.

Теперь все таблицы и LangTabs читаемые в светлом интерфейсе вики.
2026-06-09 03:09:29 +03:00
min
836688bd4f style(wiki): убрал нечитаемый синий код-стиль в таблицах
Было: text=#3357ff на background=#e0e8ff — слабый контраст (~3:1),
плашки <code> в таблицах сливались с фоном.

Сейчас: теплый янтарь #b14400 на светлом #fff5e0 + рамка #f5d8a8.
Контраст ~7.5:1 — читается отлично на обычных таблицах.

Внутри тёмных LangTabs-вкладок (если контент темный) — наоборот,
светлый янтарь #ffd86b на тёмном #2a2f4a. Тоже хороший контраст.
2026-06-09 03:05:33 +03:00
min
defb1d80c1 feat(wiki): LangTabs в раздел H — справочник для обоих языков
Каждая из 11 таблиц-разделов H1 теперь имеет JS и Lua колонки:

- Игрок: game.player.* vs humanoid/hrp/player.*
- Объекты сцены: game.scene.* vs Instance.new + workspace.*
- script-носитель: game.self vs script.Parent
- HUD: game.ui.* vs leaderstats + ScreenGui
- GUI: game.gui.* vs MouseButton1Click/FocusLost
- Физика/эффекты: game.physics/fx/constraints vs Raycast/Beam/Trail
- Камера/звук: game.camera/sound vs CurrentCamera + Sound
- События/таймеры: game.onTick/onKey vs Heartbeat/UIS/task.delay
- Утилиты: game.random/distance vs math.*/Vector3.Magnitude
- Мультиплеер: game.players/teams/leaderstats vs Players/Teams/Folder
- Окружение: game.environment/items/modal/menu vs Lighting/Backpack/ScreenGui
2026-06-09 03:01:52 +03:00
min
07fb192623 feat(wiki): LangTabs во все 12 рецептов раздела S
S2 Touch: game.self.onTouch vs part.Touched:Connect
S3 Килблок: game.player.damage vs humanoid:TakeDamage
S4 Сбор монет: game.broadcast vs leaderstats.Монеты.Value
S5 Телепорт: game.player.teleport vs HumanoidRootPart.CFrame
S6 Свойства Part: scene.spawn opts vs Instance.new + Properties
S7 Анимация: onTick + tween vs RunService.Heartbeat + TweenService
S8 E-кнопка: onInteract vs ProximityPrompt + BindableEvent
S9 HUD: game.ui.* vs ScreenGui + TextLabel/TextButton
S10 Падение: onTick + position.y vs RunService + HRP.Y
S11 Враг: spawnNpc + follow vs Model + Humanoid:MoveTo loop
S12 Сохранение: game.save vs DataStoreService:GetAsync/SetAsync

Lua-примеры по стандартному Roblox API: TweenService, RunService,
DataStoreService, Humanoid, BindableEvent, ProximityPrompt, Debris.
2026-06-09 02:56:06 +03:00
min
f52eb81e69 feat(wiki): LangTabs во все 12 статей раздела G (Большие системы)
G1 NPC: scene.spawnNpc vs Model+Humanoid:MoveTo+BillboardGui
G2 Инвентарь: game.inventory vs Tool в Backpack
G3 Звук: game.sound.play vs Instance.new('Sound') + .Parent=Part для 3D
G4 Камера: camera.cutscene vs CurrentCamera + Scriptable + TweenService
G5 Beam/Trail: fx.beam vs Instance.new('Beam')+Attachment
G6 Мультиплеер: game.players vs Players + Teams сервисы
G7 Лидерборды: game.leaderstats vs leaderstats Folder + IntValue
G8 Damage floaters: game.fx.damageFloater vs BillboardGui+TweenService
G9 Инвентарь: game.items vs Backpack+leaderstats для подсчёта
G10 Небо: game.scene.setSkybox vs Sky+Atmosphere в Lighting
G11 Модалки: game.modal.dialog vs ScreenGui+Frame+TextLabel
G12 Машины: game.scene.spawn('vehicle:car') vs VehicleSeat
2026-06-09 02:50:38 +03:00
min
e3bff777b2 feat(wiki): LangTabs в C3 и C4 (GUI оживление, поле ввода)
C3 кнопка: game.gui.onClick vs MouseButton1Click + PlayerGui:FindFirstChild
C4 поле ввода: onSubmit vs FocusLost + box.Text

C1, C2, C5 — без кода (общая теория), Picker сверху всё равно есть.
2026-06-09 02:44:29 +03:00
min
76fba9cb35 feat(wiki): LangTabs во все 8 статей раздела F (Игровая логика)
F1 HP: damage/heal vs humanoid:TakeDamage / Health
F2 Физика: raycast vs workspace:Raycast (полный пример со стрельбой)
F3 Атрибуты: setData/getData vs :SetAttribute/:GetAttribute
F4 Теги: scene.tag vs CollectionService:AddTag/GetTagged
F5 E-взаимодействие: onInteract vs ProximityPrompt
F6 Billboard: setLabel vs BillboardGui+TextLabel
F7 passThrough: physics.passThrough vs CanCollide=false
F8 Связи: constraints.hinge vs HingeConstraint+Attachment

Lua-примеры по канонам Roblox: Instance.new, Attachment, Vector3.new,
UDim2.new, Color3, BrickColor, math.min.
2026-06-09 02:43:11 +03:00
min
c6899c0528 feat(wiki): LangTabs во все 5 статей раздела E (Движение и анимация)
E1 Управление игроком: setSpeed-множитель vs Humanoid.WalkSpeed
E2 Анимации: playAnimation vs Animator:LoadAnimation
E3 Твины: game.tween vs TweenService (TweenInfo + Create + Play)
E4 Спавн/удаление: game.scene.spawn vs Instance.new + Destroy/Debris
E5 Перемещение: game.scene.move vs .Position/CFrame

Lua-примеры стандартного Roblox-стиля: workspace:WaitForChild,
Vector3.new, Enum.PartType, BrickColor.new, Debris service.
2026-06-09 02:39:36 +03:00
min
22881f5176 feat(wiki): LangTabs во ВСЕ статьи D1-D8 "Скрипты — основы"
Переключатель JS/Lua теперь реально влияет на содержимое в каждой
из 8 статей раздела. Для каждой темы дан рабочий код на обоих языках:

- D1 Что такое скрипт: game.log vs print
- D2 Глобальный/на объекте: game.self vs script.Parent
- D3 Переменные: let vs local
- D4 game vs game:GetService/workspace
- D5 game.log vs print
- D6 События: game.onTick/onClick vs RunService.Heartbeat / ClickDetector
- D7 if/else: === vs ==, !== vs ~=, then/end
- D8 Таймеры: game.after/every/cancel vs task.delay/wait/spawn

Также пояснительные плашки <Note> подобраны под язык —
указания специфичные для синтаксиса каждого языка.
2026-06-09 02:37:00 +03:00
min
d019da0ab6 feat(wiki): инфраструктура JS/Lua вкладок в статьях
Что сделано:

1. docsLang.jsx (НОВЫЙ):
   - DocsLangProvider — Context для выбранного языка (localStorage).
   - DocsLangPicker — большой переключатель JS/Lua над разделом.
   - <LangTabs js={...} lua={...} /> — локальные вкладки внутри
     статьи: показывает контент текущего языка.
   - useDocsLang() хук.
   - Стили для picker / tabs / langChoiceModal / docTable.

2. docsData.jsx:
   - Новая статья D0 "Скриптинг: JS или Lua — что выбрать?"
     в самом верху раздела D. Сравнение, примеры одного и того же
     кода на двух языках, советы новичкам.
   - Импорт LangTabs.

3. KubikonDocs.jsx:
   - ChapterPage обёрнут в DocsLangProvider + DocsLangPicker сверху.
     Юзер может одним кликом переключить весь раздел JS↔Lua.
   - LessonPage: при «Открыть мою копию» теперь показывается модалка
     LangChoiceModal (JS / Lua). Создаём копию с нужными скриптами.
   - convertProjectScriptsToLua() конвертит project_data:
     если в скрипте есть code_lua слот — активируем. Иначе ставим
     stub с подсказкой.

4. docsGamesBuilders.js:
   - buildGameProject(id, opts) принимает opts.lang='lua'.
     Та же логика — code_lua или stub.

ОСТАЛОСЬ (постепенно):
- Lua-эквиваленты в существующих 78 статьях. Сейчас Picker уже
  показывается, но если в статье нет <LangTabs> — контент одинаковый.
  Будем добавлять <LangTabs> в ключевые места по очереди.
- Lua-версии в GAME_BUILDERS для уроков 1-50 (code_lua слот).
2026-06-09 02:25:24 +03:00
min
0805da0708 fix(lua): PlayerAdded фейрится для уже существующих игроков
Roblox-конвенция: Players.PlayerAdded не срабатывает для игроков уже
на сервере к моменту подключения хендлера. Юзер пишет:
  Players.PlayerAdded:Connect(function(p) print(p.Name) end)
и удивляется почему лог пустой — игрок-то уже есть.

В реальном Roblox делают:
  for _, p in ipairs(Players:GetPlayers()) do print(p.Name) end
  Players.PlayerAdded:Connect(...)
Но мало кто помнит про этот workaround.

Решение: после kickoff всех скриптов (когда все Connect'ы установлены)
из LuaSharedSandbox шлём через api.fireExistingPlayers() →
PlayerAdded.Fire(localPlayer) + CharacterAdded.Fire(character).

Также:
- Добавлены localPlayer.CharacterAdded/CharacterRemoving/AppearanceLoaded
  signals (раньше не было).
- Шаблон LUA_TEMPLATE_GLOBAL обновлён: для всех Players:GetPlayers()
  делаем print, плюс PlayerAdded:Connect для будущих. Юзер видит
  результат сразу при первом Запустить.
- Шаблон LUA_TEMPLATE_PART сразу пишет 'Скрипт детали X запущен'.
2026-06-09 02:09:55 +03:00
min
ea4d759a7c feat(editor): два слота code_js/code_lua в скриптах
Раньше при смене языка код оставался как есть → подсветка кричит
ошибками, юзер пишет JS в Lua-режиме и наоборот.

Сейчас:
- Скрипт хранит code_js и code_lua отдельно (плюс активный code).
- При переключении JS↔Lua: текущий code сохраняется в слот ТЕКУЩЕГО
  языка, достаётся слот ЦЕЛЕВОГО языка. Если слот пустой — шаблон.
- При обычном save (печатает юзер) код зеркалится в слот активного
  языка чтобы не потерять при swap.
- Никаких модалок: переключение мгновенное, ничего не пропадает.

Сценарии:
- Новый проект: js-шаблон в code_js. Клик Lua → подставляется lua-шаблон,
  code_js сохранён.
- Юзер написал JS-код, переключился на Lua: JS улетел в code_js,
  показывается пустой Lua-шаблон. Клик обратно JS → код возвращается.
- Старые проекты (без code_js/code_lua): первое переключение
  засеет слот текущего языка из code.
2026-06-09 02:04:37 +03:00
min
3f9f7cd6c7 fix(editor): убрана модалка подтверждения при смене языка скрипта
При переключении JS↔Lua показывалась модалка "Сменить язык?" даже
если код не пустой. Юзер: не нужна, переключай сразу.

Сейчас: код остаётся как есть, меняется только подсветка синтаксиса
Monaco. Если код был пустой шаблон — подставляется новый шаблон языка.
2026-06-09 01:55:54 +03:00
min
bf2447f86e fix(import): правильный CFrame orientation_id → rotation matrix
Старая таблица AXES имела неправильный порядок:
  +X, -X, +Y, -Y, +Z, -Z
Это давало невалидные rotation matrices (rx_id=0, ry_id=0 → rx=ry=+X,
что не ортогонально). Detерминант часто получался 0.

Правильный порядок из rbx-dom:
  R0=+X, R1=+Y, R2=+Z, R3=-X, R4=-Y, R5=-Z

Формула: orientation_id - 1 = rx_axis * 6 + ry_axis
где rx — куда смотрит локальная +X (правая грань),
    ry — куда смотрит локальная +Y (верхняя).

Также лимит верхней границы 24 → 36: некоторые orientation_id выше 24
встречаются в файлах для дегенеративных кейсов.

Проверено на arch4_2007_base.rbxl: 492 Part, теперь все ротации валидны
(det=+1). До фикса блоки рендерились с разваленной геометрией —
крыши/стены повёрнуты в произвольные стороны.

Deploy rbxl_types.py на VM 130.
2026-06-08 21:52:39 +03:00
min
b09dd703af feat(rbxl): GUI mode + предупреждение про большие карты
Robloxity (20402 Part, 278 скриптов, 295 BillboardGui, 0.1 FPS) показал:
1. Большие карты могут зависнуть студию навсегда.
2. BillboardGui/SurfaceGui (вывески, табло) рендерятся в 3D-сцене и
   при 200+ штук убивают FPS.

Фиксы:

1. Предупреждение в модалке если parts > 5000 (жёлтое) или > 15000
   (красное "может зависнуть"). Подсказка про режимы.

2. Новая опция guiMode (показывается если GUI > 50 элементов):
   - 'all' — все, как было.
   - 'screen-only' (рекомендуется) — только ScreenGui HUD,
     BillboardGui/SurfaceGui удаляются.
   - 'skip' — без GUI совсем.

3. converter.py: маркирует элемент полем gui_container_kind:
   'screen' / 'billboard' / 'surface'.

4. app.py: _apply_gui_mode() фильтрует scene.gui[] по режиму.

Deploy app.py + converter.py на VM 130.

Robloxity рекомендуем импортировать со screen-only — Карта Robloxity
будет работать в 5-10× быстрее без вывесок города.
2026-06-08 21:38:08 +03:00
min
a16c819726 chore: gitignore __pycache__ + повторный коммит scripts_mode
Предыдущий коммит случайно включил .pyc файл.
2026-06-08 21:20:09 +03:00
min
16223e06ef feat(rbxl): выбор режима скриптов в модалке импорта
3 опции в модалке (только если в карте есть скрипты):
- 'disabled' (default) — скрипты импортируются с enabled=false в метадате
  → GameRuntime их не запускает, но видны в иерархии для чтения
  как референс при написании своих Lua-скриптов.
- 'enabled' — скрипты активны (старое поведение). Может вешать игру
  на старых Roblox 2007-2010 паттернах.
- 'skip' — scripts[] обнуляется, чистый импорт только геометрии.

Реализация:
- RbxlImportModal.jsx: state scriptsMode + radio-блок над названием игры,
  показывается только если report.scripts_total > 0.
- rbxlImporterApi.js: передача scripts_mode в /import/rbxl/create.
- app.py: _apply_scripts_mode() патчит JSON-метадату на 2-й строке
  packed-кода скрипта (или удаляет scripts[] для 'skip').

GameRuntime уже умеет уважать meta.enabled === false — пропускает скрипт.

Deploy app.py на VM 130.
2026-06-08 21:20:01 +03:00
min
cc5e6d60e5 docs(lua): итерация 4 — spawn fix + философия импорта
Зафиксировано как принцип:
- Цель импорта .rbxl = геометрия + базовые интеракции
- 100% Lua-логики не реализуется (wasmoon yield C-boundary)
- Что должно работать (фиксить если ломается)
- Что НЕ воспроизводится (не тратить время)
- Что делать дальше с новыми картами

Также: SpawnLocation +5 Y + auto-fallback на max_top если
SpawnLocation в карте не был — игрок не застревает в Anchored
геометрии.
2026-06-08 21:11:38 +03:00
min
08817925b5 fix(import): spawn point выше пола + auto-fallback если SpawnLocation нет
Юзер: "персонаж в стене заспавнился, ходить не может".

Проблема: SpawnLocation в старых .rbxl ставится по CFrame с минимальным
отступом от пола. Наш +1.5 недостаточно — толстые Floor'ы 2-3 units
high полностью утопляют персонажа. Anchored=True (наш фикс) не даёт
выпрыгнуть.

Фиксы:
1. SpawnLocation +1.5 → +5 единиц выше плиты.
2. Auto-fallback: если spawnPoint остался дефолтом (0,2,0) = в карте
   не было SpawnLocation вообще — ставим над самой высокой Part:
   y = max(part.y + part.sy/2) + 5. Игрок упадёт на крышу/верх.

Deploy converter.py на VM 130. Юзер должен переимпортировать карту
чтобы получить новый spawnPoint.
2026-06-08 21:05:15 +03:00
min
6b53ed0477 docs(lua): итерация 3 ROBLOX Battle в CHANGELOG
Зафиксировано: 11 механик из 14, RbxlHudOverlay, tight-loop regex
фильтр (37 из 66 скриптов пропущены), CFrame YXZ Euler, persistence
света. Карта играется на 29 рабочих скриптах.
2026-06-08 20:57:45 +03:00
min
4e34ca5b52 fix(rbxl): пропускаем скрипты с tight-loop WaitForChild через regex
Скрипты Roblox 2009 содержат паттерн:
  while not parent:FindFirstChild(name) do
    parent.ChildAdded:wait()
  end

Наш sig.Wait() возвращает -1 синхронно (без yield), цикл крутится
бесконечно без шанса coroutine.yield. debug.sethook не помогает
если код находится в C-call boundary к моменту срабатывания.

Решение: regex-фильтр в GameRuntime.js перед добавлением в batch.
Скрипты с такими паттернами не запускаются — пишется warn в консоль.

В ROBLOX Battle это ~10-15 скриптов: RoundScript, Spawner,
ReEquipLastWeapon, LeaderboardV3, Leaderstats и др. Карта потеряет
эту функциональность (раунды, респавн), но играется.
2026-06-08 20:53:52 +03:00
min
38d135586b fix(rbxl): watchdog 100k→20k + откат pcall(yield) + batch 5+20ms
Прошлый коммит pcall(coroutine.yield) дал бесконечный цикл:
yield внутри C-call падал → pcall ловил → hook возвращался → счётчик
не сбросился → срабатывал опять моментально → вис.

Новая стратегия:
1. Голый coroutine.yield в watchdog: если внутри C-call упадёт с
   ошибкой — pcall(fn,...) внутри coroutine её поймает, скрипт
   завершится. Лучше чем вис.
2. Frequency 100k→20k инструкций — yield чаще, меньше времени на
   tight-loop перед уступкой управления UI.
3. Batch kickoff 20→5 скриптов с delay 20мс (было 0). 55 скриптов
   ROBLOX Battle = ~200мс распределено, UI отзывается.

Page-hang при init должен исчезнуть. Скрипты с tight-loop типа
WaitForChild через ChildAdded:wait() упадут с ошибкой про yield,
но не повесят страницу.
2026-06-08 20:51:24 +03:00
min
734521df72 fix(rbxl): tick/spawn/delay/LoadLibrary + SpecialMesh + pcall watchdog yield
Битва скриптов ROBLOX Battle вылетала на:
- tick() / time() / delay() / spawn() — старые Roblox globals, не было
- LoadLibrary('RbxUtility') — Roblox 2009 legacy, не было
- SpecialMesh.MeshType — класс не реализован, доступ к полю крашил
- attempt to yield across C-call boundary — debug.sethook yield без pcall

Фиксы:
1. Lua-prelude: tick=os.time, time=os.clock*1000, delay/spawn через
   coroutine, LoadLibrary возвращает proxy-стаб через metatable.
2. Instance.new('SpecialMesh'/'BlockMesh'/'CylinderMesh'/'FileMesh')
   стабы с MeshType/MeshId/Scale полями.
3. debug.sethook: pcall(coroutine.yield, ...) вместо голого yield —
   если внутри C-call, ошибка молча проглатывается, hook сработает
   позже когда Lua вернётся из C. Frequency 50k→100k.
4. script.Parent в Lua-обёртке: setmetatable __index → workspace
   fallback для script.Foo:Bar() паттернов. Гарантия что
   _scriptParent.Parent ~= nil.

ROBLOX Battle должна показать меньше errors на этом запуске.
2026-06-08 20:47:19 +03:00
min
932ef2bc20 feat(rbxl): RegenerationScript no-op + реактивный Humanoid
8. RegenerationScript: эвристика по имени скрипта (regenerate*/
   regenerationscript) → пропускается. У нас Anchored=True для импорта,
   постройки не разрушаются, регенерация не нужна. Их работа дала бы
   визуальные глитчи (model:remove + Clone каждые 2 мин).

9. BattleArmor: Humanoid.MaxHealth/Health/WalkSpeed/JumpPower теперь
   реактивные (Object.defineProperty). При смене .MaxHealth=N шлёт
   playerSet → player.maxHp обновляется → HUD HP-бар. BattleArmor
   touch'нул → Humanoid.MaxHealth=20, Health=20 → игрок видит броню.

10. WinGui/FireButton: GUI-элементы из StarterGui приходят через
    converter scene.gui[] и рендерятся стандартно. Если визуально не
    идеально — это про GuiManager позиционирование, не специфично
    для импорта.

11. AdminConsole: no-op, скрипт-заглушка, ничего не делает.

13. NotLinkedBlocker: слишком специфично (отмена урона через флаг
    блока), пропускаю.

ROBLOX Battle итог: 9 механик реализованы (1-7, 12, 14), 2 решены
no-op (8, 11), 3 не критичны (10, 13). Карта должна играться.
2026-06-08 19:46:39 +03:00
min
913283ffa6 feat(rbxl): 9 механик ROBLOX Battle (Teams/Leaderstats/HUD/Tools/etc)
Реализовано из 14 механик:

1. Teams (game.Teams, Player.Team, TeamColor): scene.teams[] из конвертера,
   эвристика TeamBeacon-Model → автоматически создаются 4 команды.
   В shim создаются Team-инстансы при snapshot, авто-эквип игрока в первую.

2. Leaderstats UI: IntValue.Value реактивно шлёт leaderstatSet → существующий
   LeaderstatsManager (define + set). HUD автоматически рисуется в правом
   верхнем по родительскому Name='leaderstats'.

3. BindableFunction + RemoteFunction + Message/Hint класс. Message с
   реактивным .Text и .Parent шлёт hudMessage в наш RbxlHudOverlay.

4. KillFeed UI + creator-tag tracking. RbxlHudOverlay.addKillFeed() рисует
   А → [weapon] → Б в правом верхнем. Humanoid.TakeDamage при Health=0
   ищет creator-ObjectValue и шлёт killFeed. Авто-respawn через 2с.

5. SpawnLocation.TeamColor → scene.team_spawns[] для будущей логики
   команд-спавна.

6. Tool:Clone() / Model:Clone() / :clone(): поверхностный клон + lowercase
   alias. Также :MakeJoints/:BreakJoints/:Remove/:remove no-op методы.

7. Creator-tag handling в TakeDamage (см. пункт 4).

12. Bouncer/батут: BodyVelocity с +Y и Parent=Torso/HumanoidRootPart →
    эвристика "толкаем вверх" → playerSet jumpVelocity → реальный jump
    через player._vy.

14. Mouse.Icon → CSS cursor на canvas (crosshair для не-пустых).

Также:
- RbxlHudOverlay.js — новый модуль DOM-оверлей для HUD-элементов
  (KillFeed/Message/WinGui). Lazy-создаётся при первом hudMessage/killFeed.
- BabylonScene.serialize включает scene.teams и scene.team_spawns.
- Converter: scene = teams[] + team_spawns[]. TeamBeacon Model'и → команды.
- Deploy converter.py на VM 130.

Остались: 8 Regeneration, 9 BattleArmor, 10 WinGui/FireButton кастомное
позиционирование, 11 AdminConsole (no-op уже ok), 13 NotLinkedBlocker.
2026-06-08 19:43:36 +03:00
min
dbfd214f42 fix(import): YXZ Euler + watchdog для tight-loop защиты
1. CFrame.to_euler_xyz переписан под Babylon YXZ convention:
   rx = asin(-r12), ry = atan2(r02, r22), rz = atan2(r10, r11).
   Раньше извлекал XYZ-Euler → Babylon применял как YXZ → клины,
   мостики, наклонные постройки рендерились повёрнутые
   (примеры из ROBLOX Battle: мостик торчал в стену).
   Учтён gimbal-lock на X=±90°.

2. Lua watchdog в _startSingleScript и __rbxl_drain_handler:
   debug.sethook(yield_50ms, '', 50000) — каждые 50k Lua-инструкций
   принудительно yield 1 кадр. Защищает от:
     while not workspace:FindFirstChild('X') do
       workspace.ChildAdded:wait()
     end
   где наш stub :wait() возвращает -1 мгновенно — раньше скрипт
   подвешивал вкладку (50k+ итераций в секунду). Сейчас yield'ит,
   tickScheduler возобновляет.

3. Signal.Wait возвращает -1 как 'no-arg yield marker'. Сейчас
   не используется в Lua, но если позже сделаем wrapper — будет.

ROBLOX Battle карта (arch1_ROBLOX_Battle_v2.rbxl, 1677 примитивов,
66 скриптов) — теперь не должна подвешивать.

Деплой rbxl_types.py на VM 130.
2026-06-08 19:16:39 +03:00
min
71d6396d8b docs(lua): итерация 2 Crossroads в CHANGELOG
Зафиксированы все правки сделанные при работе с Crossroads:
- XML-парсер для старых .rbxl (~330 строк)
- BrickColor таблица расширена с 50 до 120 цветов
- Force-anchored для всех импортированных Part
- 4 новых слайдера в Свет и атмосфера (заливка теней,
  экспозиция, контраст, насыщенность) через imageProcessingConfiguration
- Persistence настроек света в projectData.scene.lighting
- mat.ambientColor=(1,1,1) обязательно для scene.ambientColor работы
- Деплой rbxl-importer на VM 130 через прямой SSH (CI не настроен)

Известные баги Crossroads:
- 2 скрипта Regenerate* падают на model:clone() и Instance.new('Message')
  — не критично, Anchored держит постройки.
2026-06-08 19:09:25 +03:00
min
e45a9968c4 feat(lighting): persistence настроек света в проект
Слайдеры sun/hemi/ambient/exposure/contrast/saturation теперь
сохраняются в projectData.scene.lighting при save и применяются
обратно при load.

Раньше параметры жили только в текущей сессии — после refresh
страницы возвращались к дефолтам.

Импортированные .rbxl карты также сохраняют выставленные пользователем
параметры света.
2026-06-08 19:03:09 +03:00
min
5b44a286a9 fix(import): заливка теней работает + Anchored=True для всех импорт. Part
1. PrimitiveManager: mat.ambientColor=(1,1,1). Теперь scene.ambientColor
   ("Заливка теней" слайдер) реально влияет на тени. Юзер крутит
   значение и видит изменение.

2. converter.py: Roblox-Part импортируется всегда с Anchored=True
   (force-anchored). Welds у нас заглушки, без них unanchored Part'ы
   рассыпаются физикой. Если юзеру нужно падающее — снимет в
   инспекторе вручную.

Деплой converter.py на VM 130 + systemctl restart.
2026-06-08 18:59:23 +03:00
min
19f47b2d75 feat(inspector): новые слайдеры света — заливка теней, экспозиция, контраст, насыщенность
В Свет и атмосфера добавлено:
- Заливка теней (scene.ambientColor) — позволяет окрасить тени в
  сером тоне без пересвета diffuse материалов.
- Экспозиция (ipc.exposure 0.3-2) — общая яркость через
  imageProcessingConfiguration.
- Контраст (ipc.contrast 0.5-2)
- Насыщенность (colorCurves.globalSaturation -100..+100)

Юзер крутит слайдеры до момента когда импортированная Roblox-карта
выглядит как оригинал. Дефолты: ambient 0.3, exposure 1.0, contrast
1.0, saturation 1.0.

Также убрал mat.ambientColor=цвет — теперь default (0,0,0). Освещение
управляется глобально через панель.

Состояние пока не сохраняется в проект (только сессия). Persistence
добавим в следующем шаге.
2026-06-08 18:54:00 +03:00
min
67851820a9 fix(import): ambient = 40% от цвета (без пересвета)
ambient=diffuse (100%) суммировался с прямым светом и
давал пересвет (особенно на дорогах/полу с #cccccc).

40% — баланс: тень окрашена (видна как цвет, не чёрная),
прямой свет = чистый diffuse без пересвета.
2026-06-08 17:03:17 +03:00
min
364726481f fix(import): mat.ambient = diffuseColor (тень окрашена, не пересвет)
Прошлая итерация без ambient давала почти-чёрные грани в тени —
sun под углом + hemi только вверх = низ/бок граней получают только
скудный hemi.groundColor=(0.3,0.3,0.4) = тёмные пятна.

Roblox-look: тень это просто менее яркий вариант цвета (не чёрный).

Фикс: mat.ambientColor = mat.diffuseColor (= цвет примитива).
scene.ambientColor=(0.3) × ambient(цвет) = 30% цвета в тени.
На прямом свете diffuse доминирует — белые остаются белыми,
зелёные зелёными.

Это даёт тени окрашенные (как в Roblox), сохраняя контраст со
светом и точность цвета.
2026-06-08 17:02:08 +03:00
min
3a82b3c64d fix(import): расширил BrickColor палитру + убрал ambient material
Главная причина пересвета:
1. BrickColor 151 (Earth green = трава Crossroads) ОТСУТСТВОВАЛ
   в таблице. Пол получал дефолт #cccccc и выглядел белым.
   После анализа карты 344 примитива использовали дефолт.
2. mat.ambientColor=(1,1,1) + scene.ambientColor=(0.3) делало белые
   цвета пересветлёнными — серый выглядел белым.

Фикс:
- BRICKCOLOR_TO_HEX расширен с ~50 до ~120 цветов. Добавлены:
  151 (Earth green), 26 темный, 18, 115-148, 168-301, 1021-1032 и др.
  После: #cccccc дефолт 344→68 (бОльшая часть теперь правильных).
- Убран mat.ambientColor — оставлен default (0,0,0). Lambert чистый:
  освещённая грань = diffuse, тень = почти чёрная (scene.ambient смягчает).
  Цвета теперь точно как в diffuse, без пересвета.

Деплой: converter.py скопирован на VM 130 + systemctl restart.
2026-06-08 17:00:25 +03:00
min
3e928e8d4e fix(import): убрал emissive, только ambient для теней
emissive прибавлялся к diffuse даже на ярком свету — пересвечивал
цвета (особенно белые/серые).

Новая схема:
- emissive = 0 (всегда)
- mat.ambientColor = (1,1,1) — пропускает scene.ambientColor (0.3)
  в тени, делает тени 30% яркости цвета вместо чёрных
- На прямом свете diffuse доминирует, всё как должно быть

Это должно дать Roblox-look: серый #cccccc выглядит серым, белый
выглядит белым, в тенях цвет виден но темнее.
2026-06-08 16:56:35 +03:00
min
00014717ab fix(import): уменьшил ambient/emissive — было пересвечено
Версия 25% emissive + 1.0 ambient дала картинку слишком плоской:
пропали тени, объём, контраст. Roblox-оригинал имеет чёткие тени
от строений и контрастные грани.

Новые значения:
- mat.ambientColor: 1.0 → 0.5 (всё ещё подмешивает scene ambient
  в тени, но не убивает контраст)
- glossy emissive: 25% → 8% (цвет 'живой' но не светится)

Должно дать баланс: цвет в тени виден, при этом тени остаются тенями.
2026-06-08 16:52:39 +03:00
min
14e173a089 fix(import): сочные цвета в импортированных Roblox-картах
Crossroads-импорт давал тёмно-грязные цвета вместо классических
насыщенных Roblox-цветов:
- трава тёмно-зелёная вместо ярко-зелёной
- дороги серые вместо белых
- крыши приглушённо-красные

Причины:
1. mat.ambientColor=(0,0,0) default — scene.ambientColor=(0.3,0.3,0.3)
   не действовал. Тени получали 0 контрибьюшна цвета.
2. material=glossy (default для Roblox Plastic) шёл в case default:
   только specularColor=(0,0,0), без emissive — цвет blandный.

Фикс:
- mat.ambientColor=(1,1,1) для всех материалов: подмешивает scene
  ambient в тени, цвета остаются видны.
- Для glossy/default: emissive = 25% цвета (как в studs/45%, но скромнее),
  specular слабый (0.05). Roblox-look — насыщенный даже без прямого
  света.

Также case 'matte' теперь отдельный (был под default).
2026-06-08 16:49:54 +03:00
min
0b677529e1 feat(rbxl-importer): поддержка XML-формата .rbxl (старые карты до 2010)
All checks were successful
CI / Lint (pull_request) Successful in 1m7s
CI / Build (pull_request) Successful in 1m57s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Старые Roblox-карты (Crossroads, ROBLOX Battle, и др. из эры 2007-2010)
сохранены в XML-формате (<roblox version=4>... вместо binary <roblox!...).
Наш парсер падал на 'missing <roblox! magic'.

Новое:
- rbxl_xml_parser.py: парсит XML-формат через стандартный xml.etree.
  Поддерживает все типичные property-теги: string, bool, int, float,
  Vector3, Vector2, CoordinateFrame, Color3, Color3uint8, BrickColor,
  Ref, BinaryString, UDim/UDim2, PhysicalProperties, OptionalCFrame.
- В _parse_property: <int name=BrickColor> заворачивается в BrickColor
  объект — converter ожидает .code атрибут.
- Алиасы PascalCase: name→Name, size→Size, shape→Shape (старый XML
  использовал camelCase с маленькой первой буквой).

app.py:
- /analyze: авто-детект XML vs Binary по magic bytes. Если XML —
  используем parse_xml(), иначе старый parse().

Тест на arch1_Original_Crossroads.rbxl: 877 instances, 777 Part,
83 Model — конвертится в 777 примитивов без warnings.
2026-06-08 16:23:18 +03:00
min
742bca59ee feat(rbxl): Day/Night меняет skybox preset с fadeTo
Roblox Day/Night раньше менял только сцен-clearColor и hemiLight через
setTimeOfDay (это работало — пол темнел). Но небо оставалось голубым
потому что SkyboxManager (купол + горы + звёзды) рулится отдельно.

Теперь по часу мапим в preset SkyboxManager:
  06-08, 17-19 → sunset (оранжевое небо)
  08-17       → lowpoly-roblox (синее день)
  19-06       → starry-night (звёзды + луна + тёмное)

Используем skybox.fadeTo({preset}, 2) для плавного 2-секундного перехода
между пресетами (Урок 62 — кастомное небо).

Это даст реальную смену день↔ночь как в оригинале Roblox-Zapper'а.
2026-06-08 16:11:27 +03:00
min
b820ad11bd feat(rbxl): Day/Night ускорение 8x для импортированных карт
Roblox Day/Night скрипт идёт wait(0.01) + 0.1 минуты = очень медленно.
В оригинале это типичный паттерн, юзеру не очень видно.

Ускоряем в 8x для импортированных скриптов: накопленная дельта часов
от Lua-скрипта × 8 → реальный hour для setTimeOfDay. Юзер видит полный
цикл день↔ночь за 10-15 секунд вместо часа.

Также убрал debug-лог lightingTimeUpdate (мешал).
2026-06-08 16:05:30 +03:00
min
66aac4826e debug: добавил лог lightingTimeUpdate для диагностики 2026-06-08 15:57:03 +03:00
min
ee0d91235c fix(lua): обёртка тела скрипта в pcall
memory access out of bounds в rbx_8 (Day/Night) — это WASM crash
который пробивает try/catch на JS-стороне. Защита — pcall внутри
самого Lua coroutine: даже если что-то падает в скрипте, ошибка
ловится Lua-side и не доходит до уровня wasmoon resume.

После этого fix остаётся только смотреть кто конкретно крашит —
шлём ошибку через __rbxl_send_error и идём дальше.
2026-06-08 15:51:58 +03:00
min
6bec44d778 fix(lua): троттлинг lightingTimeUpdate до 250мс на shim-стороне
Day/Night скрипт в Roblox: while true do wait(0.01); SetMinutes(+0.1) end
= 100+ Hz обновлений Lighting.ClockTime. Каждое слало lightingTimeUpdate
через send() из coroutine, что (вероятно) вызывает WASM access crash.

Тротлинг прямо в SetMinutesAfterMidnight — не чаще раза в 250мс.
Lua-сторона продолжает делать высокочастотные обновления _minutes/ClockTime
(скрипт работает корректно), но в JS уходит только 4 раза в секунду.
2026-06-08 15:49:04 +03:00
min
265c225772 fix(lua): pcall handler чтобы wasmoon не оборачивал return value
Reblox-handler типа onEquippedLocal часто возвращают значение последнего
выражения (например :connect(fn) → conn object). wasmoon на JS-стороне
видит этот объект как result и тестирует на promise — крах
'null.then' если в цепочке встретится null/odd-shaped значение.

Фикс: оборачиваем fn(a1,...) в pcall — оно поглощает все return values.
+ Все 'return null' заменены на 'return undefined' (wasmoon quirk
из memory: null → PromiseTypeExtension crash).
2026-06-08 15:46:53 +03:00
min
03a6c357d0 fix(lua): Signal.Fire через очередь handler'ов в tickScheduler
Прошлый фикс с __rbxl_run_in_coroutine падал внутри wasmoon с
'Cannot read properties of null (reading then)' — wasmoon
PromiseTypeExtension тщетно ловит return от Lua-функции.

Новая стратегия:
1. Signal.Fire не запускает handler синхронно — складывает в JS-очередь
   _pendingHandlerQueue.
2. tickScheduler в начале каждого тика drain'ит очередь, для каждого
   handler'а вызывает Lua-функцию __rbxl_drain_handler в coroutine.
3. Поскольку tickScheduler уже стоит на main loop (не из JS-callback),
   wait() внутри handler'а корректно yield'ится в свою coroutine.

Это разрешает:
- Roblox-обработчики с wait() внутри (Tool.Equipped с reload-таймаут)
- Любые цепочки signal:Connect → wait → action
- Стандартные шаблоны Roblox-Lua flow.
2026-06-08 15:41:55 +03:00
min
d750c94a78 fix(lua): Signal Fire запускает Lua-handler в собственной coroutine
Roblox-скрипты делают:
  Tool.Equipped:connect(function(mouse)
    wait(0.15)              -- yield внутри handler!
    mouse.Icon = ...
  end)

Когда сигнал Fired из JS-стороны (наш equipTool flow), мы напрямую звали
Lua-функцию — но Lua-yield в JS-callback падает с
'attempt to yield across a C-call boundary'.

Фикс: новая Lua-функция __rbxl_run_in_coroutine(fn, ...args) создаёт
свежую coroutine из handler'а, регистрирует в scheduler, делает первый
resume. Если handler уйдёт в wait — это yield в свою coroutine, не через
C-boundary. Scheduler tickScheduler потом возобновит её через delay.

Это закрывает RayGun.onEquippedLocal с wait(0.15), а также любые другие
Roblox-обработчики использующие wait() — в Roblox это стандарт.
2026-06-08 15:39:16 +03:00
min
52724ab9c8 fix(rbxl): invUI вместо inventory + Day/Night портирован + искры Sparkles
Что чинит:
1. _registerRbxlTool падал: использовал scene3d.inventory (InventoryManager,
   старый, без defineItem). Меняю на scene3d.invUI (новый InventoryUI с
   defineItem) — теперь hotbar реально заполняется.

2. Lighting.SetMinutesAfterMidnight теперь шлёт lightingTimeUpdate в
   GameRuntime → scene3d.setTimeOfDay(hour). Тротлинг 4 раза/сек.
   Roblox Day&Night скрипт теперь визуально меняет небо в нашем плеере!

3. Instance.new('Sparkles') шлёт particleCreated в GameRuntime.
   При эквипе Tool — _startRbxlToolParticles() запускает каждые 200мс
   burst у позиции игрока (имитация искр из руки).

4. Авто-эквип первого Tool через 100мс после регистрации — юзеру не
   нужно нажимать 1, инвентарь не очевиден.

5. stop() корректно гасит интервалы и сбрасывает state.

Эти 4 фикса должны дать Zapper-демке базовое визуальное поведение:
видный hotbar, искры из персонажа, плавная смена дня/ночи.
2026-06-08 14:08:09 +03:00
min
bb0726b4ad revert(lua): убрал недописанный leaderstats scan (компилировался но не работал) 2026-06-08 14:02:16 +03:00
min
43f701f1c4 docs(lua): обновил CHANGELOG для Шага 1 Tool/Backpack/Mouse 2026-06-08 13:58:23 +03:00
min
1080c18ae0 feat(rbxl): Tool/Backpack/Mouse flow — Шаг 1/3 (Zapper)
Цель: запустить Roblox Tools (Zapper и подобные оружия) в плеере.

Архитектура:
1. RobloxShim: localPlayer.Backpack, localPlayer:GetMouse(), allTools registry,
   equippedTool — внутренний учёт текущего Tool.
2. Instance.new('Tool') — теперь автоматически:
   - создаёт виртуальный Handle (Part) внутри
   - регистрирует Tool в allTools[]
   - шлёт 'toolRegistered' в GameRuntime
3. fireGlobalEvent обработка новых событий из плеера:
   - equipTool {index} → Tool.Equipped:Fire(playerMouse)
   - unequipTool → Tool.Unequipped:Fire()
   - toolActivated → Tool.Activated:Fire()
   - mouseButton1Down {hit} → mouse.Hit.Position + mouse.Button1Down:Fire()
   - keyDown {key} → mouse.KeyDown:Fire(key)
4. LuaSharedSandbox.addScript принимает toolName, в _startSingleScript
   подсовывает виртуальный Tool как script.Parent (через
   __rbxl_get_tool_by_name).
5. GameRuntime эвристика: скрипты с target=null и упоминанием
   script.Parent.Equipped/Activated → toolName='Tool', группируются
   в один Tool.
6. GameRuntime._registerRbxlTool: при получении toolRegistered кладёт
   item в InventoryUI.hotbar, слушает смену слота → equipTool.
7. Клики canvas → mouseButton1Down с raycast Hit.Position.

Следующие шаги:
- HUD: индикатор экипированного Tool в плеере (Шаг 2)
- Leaderboard UI из leaderstats IntValue (Шаг 3)
2026-06-08 13:57:37 +03:00
min
98640c4bdb fix(ui): badge LUA для импортированных .rbxl-скриптов
В БД импортированные скрипты хранятся с language='js' но фактически
это Lua-код в обёртке // @roblox-lua. HierarchyPanel рисовал жёлтую
плашку JS, что вводило в заблуждение.

Теперь isLua = (language=='lua') OR code starts with '// @roblox-lua'.
2026-06-08 13:44:14 +03:00
min
3271e53acf feat(rbxl): уважать enabled=false из Roblox-метадаты
Roblox-скрипты с Disabled=true (например 'Clean', 'Effects' в RayGun)
это шаблоны для клонирования через :Clone(), они никогда не должны
запускаться при старте — иначе while true do wait() end в них крашит
coroutine через WASM access out of bounds.

parseRobloxLuaMeta(code) парсит JSON-метадату из второй строки
packed-кода (формат '// {"roblox_class":..., "enabled":true}').
Скрипты с enabled=false идут в rbxlSkipped, не запускаются.
2026-06-08 13:43:09 +03:00
min
ca92ba1988 feat(lua): Итерация 1 RayGun — Tool/Mouse/BodyForce/Weld/IntValue/BrickColor
Поддержка скриптов проекта 2792 (Roblox RayGun Tool, 9 скриптов).

Lighting: ClockTime, GetMinutesAfterMidnight, SetMinutesAfterMidnight,
GetSunDirection, fog* поля.

game:service(name) — старый Roblox API (lowercase alias на GetService).
Players: GetPlayerFromCharacter, playerFromCharacter, PlayerAdded, ChildAdded.

Instance.new новых типов:
- Tool/HopperBin: Equipped/Unequipped/Activated/Grip*/CanBeDropped
- IntValue/NumberValue/BoolValue/StringValue/ObjectValue + Value/Changed
- BodyForce/BodyVelocity/BodyPosition/BodyGyro + force/Velocity/MaxForce
- Weld/Motor6D/HingeConstraint + Part0/Part1/C0/C1
- Sparkles/ParticleEmitter/Fire/PointLight + Enabled/Color/Rate
- Mouse: Button1Down/KeyDown signals, Icon, Hit, Target, X/Y

Глобалы: BrickColor.new('name'/r,g,b) с палитрой 25+ цветов,
Ray.new, Region3.new.

Фикс WASM crash: rbx_wait минимум 0.016с (1 кадр) — без этого
while true do wait() end делал tight-loop без yield → stack overflow.

Добавлен RUBLOX_LUA_API_CHANGELOG.md — журнал что было добавлено
для каждой игры (для будущего портирования API в JS-движок).
2026-06-08 13:39:56 +03:00
min
34062993ee feat(rbxl): импортированные скрипты снова включены — итеративная настройка 2026-06-08 13:35:14 +03:00
min
8febde9727 revert: импортированные .rbxl-скрипты выключены по умолчанию
Попытка выполнять Roblox-скрипты массово подвешивает страницу — даже
с object-stub Proxy и батчевым init. У типичной карты 500-2000 скриптов,
которые гоняют DataStore/Tools/RemoteFunction/PlayerGui — наш runtime
их не имеет и не должен иметь (это AGPL Roblox-клон, не эмулятор).

Импорт .rbxl теперь = ВИЗУАЛЬНЫЙ ПОРТЕР:
- геометрия, материалы, текстуры — да
- GUI (статические TextLabel/Button) — да
- физика, анимации игрока — да
- логика игры — пишется на нашем Lua (Этапы 1-7)

Юзер импортирует Roblox-карту → видит её точно → пишет свои скрипты
к примитивам, используя Vector3, Touched, Position-setters, Sound,
TweenService. Это работает идеально и без подвисаний.

Энтузиасты могут включить старое поведение через
window.__RBXL_RUN_IMPORTED = true перед Play.
2026-06-08 13:29:54 +03:00
min
5342c079d1 feat(lua): включил импортированные скрипты + защита от подвисания
1. TweenInfo.new(time, easing, direction, repeat, reverses, delay) —
   глобальный конструктор. Был причиной 'attempt to index nil (TweenInfo)'.
2. Расширенный Enum: InfoType, SortOrder, FillDirection, Font,
   TextXAlignment, ScaleType, PartType, SurfaceType, UserInputState
   и др. — типичные для Roblox-туториалов.
3. NumberSequence/ColorSequence/NumberRange/Rect — заглушки конструкторов.

4. _kickoff() теперь батчами по 20 скриптов через setTimeout(0).
   742 скрипта инициализируются за ~37 фреймов вместо одного
   синхронного блока, UI не подвисает.

5. Импортированные .rbxl-скрипты ВКЛЮЧЕНЫ по умолчанию
   (window.__RBXL_SKIP_IMPORTED=true чтобы выключить).
   Падения отдельных скриптов изолированы — tickScheduler ловит
   ошибки и удаляет битые coroutines, остальные продолжают работать.
2026-06-08 13:26:30 +03:00
min
c5b713fd1f feat(rbxl): импортированные скрипты выключены по умолчанию
Типичная .rbxl карта = 500-2000 Lua-скриптов. Многие используют
DataStoreService, Tools, PlayerGui, UserInputService, и т.п. фичи
которых у нас нет. Даже с object-stub Proxy сотни runtime-падений
после init подвешивают вкладку.

Решение: импорт сохраняет geometry+GUI+физику, скрипты пропускаются.
Юзер пишет свои Lua-скрипты к импортированным примитивам — они
используют наш Этап 1-7 API (Vector3, Touched, Position-setters,
Sound, TweenService) и работают идеально.

Включить старое поведение: window.__RBXL_RUN_IMPORTED = true в DevTools
перед Play.
2026-06-08 13:19:21 +03:00
min
f80aaceb96 fix(lua): убрал function-stub, теперь object-stub для unknown свойств
Прошлый callable-stub (function() {}) с Proxy/apply работал в JS но
ломался в Lua: wasmoon мапил JS function в Lua function, у которой
нет метатаблицы — поэтому stub:Connect() / stub.SomeField падало с
'attempt to index a function value'.

Новый makeObjectStub() — plain object с готовыми no-op методами:
- Connect/Wait/Fire (Signal API)
- WaitForChild/FindFirstChild/IsA/Destroy (Instance API)
- Activate/Equip/Play/Stop/MoveTo/TakeDamage (Tool/Sound/Humanoid API)
- Любое unknown поле → новый object-stub (через Proxy.get)

Это снимает 99% оставшихся 'attempt to index a function value'.
2026-06-08 13:16:07 +03:00
min
8fe52dbe68 fix(lua): universal callable-stub для unknown свойств Instance
Импортированные скрипты делают obj:Method(arg) где obj — stub. Раньше
stub был просто Folder, его как функцию вызвать нельзя → self2 is not
a function массово.

Фикс: makeCallableStub() — Proxy на function(){} который:
- вызывается как функция (apply trap) → возвращает себя;
- имеет .Connect/.Disconnect/.Wait/.Fire → ведёт себя как сигнал;
- любое .UnknownField → возвращает другой callable-stub;
- .then/.catch/.constructor → undefined (wasmoon не путается).

Этим закрывается основная масса остаточных падений в туториал-скриптах
с цепочками вроде Tool.Handle:WaitForChild('X'):Connect(...) где Handle
у нас отсутствует.
2026-06-08 13:10:26 +03:00
min
dc7420a61d fix(lua): require() no-op + улучшенный Proxy для Instance
1. require(): в Roblox загружает ModuleScript. У нас модулей нет —
   возвращаем mod как есть (если объект) или undefined.

2. Proxy улучшения:
   - Object.hasOwnProperty + 'in' checks: методы (WaitForChild,
     FindFirstChild и т.д.) точно не перехватываются;
   - Symbol-ключи всегда undefined;
   - System keys (then, catch, toString, constructor) → undefined
     чтобы wasmoon не пытался обращаться как с Promise/класс;
   - has() возвращает true для всех строковых ключей (избавляет от
     падений на 'if obj.SomeField then ...').
2026-06-08 13:08:28 +03:00
min
59d0d86811 fix(lua): Proxy для Instance — unknown свойства возвращают stub вместо nil
Импортированные Roblox-скрипты массово падали на доступе к свойствам которых
у нас нет (.Selected, .Equipped, .MouseEnter и т.д.). В Roblox это сигналы
которые скрипты подключают через :Connect().

Фикс: оборачиваю newInstance() в Proxy:
- isProbablySignalName(prop) → возвращает makeStubSignal() (наш Signal с Connect/Fire)
- иначе → возвращает stub-Folder (тоже Instance с потенциальными children)
- системные ключи (then, __*, Symbol) → undefined чтобы wasmoon не путался

Эвристика покрывает основные Roblox-паттерны:
- *.Changed, *.Added, *.Removed, *.Began, *.Ended, *.Touched, *.Died
- Mouse*, Touch*, Input*, Render*, Step*, Heart*, On*, Char*, Player*
- Selected, Deselected, Equipped, Unequipped, Activated, Reached, Loaded

Это позволяет проходить инициализацию массе туториал-скриптов вместо
падения на первой же строке.
2026-06-08 13:04:38 +03:00
min
3e20107125 fix(lua): script.Parent fallback на workspace + WaitForChild создаёт stub-Folder
Импортированные .rbxl-скрипты массово падали на:
  attempt to index a nil value (field 'Parent')

Причины:
1. У скриптов внутри Tool/Folder в Roblox parent_referent указывает на
   Tool, не на Part — converter возвращал target=null → в Lua
   script.Parent = nil. Стандартный паттерн script.Parent.Parent падал.
2. WaitForChild возвращал undefined для несуществующих children.
   Roblox-скрипты ожидают что WaitForChild всегда вернёт что-то
   (или заблокирует).

Фикс:
- LuaSharedSandbox: если primId не найден в partById, script.Parent =
  workspace вместо nil. Это спасает 99% Roblox-туториал-скриптов
  которые делают script.Parent.Parent.

- RobloxShim.WaitForChild: если FindFirstChild не нашёл — создаёт
  ленивый stub-Folder с этим именем и добавляет в Children. Скрипт
  не падает на script.Parent:WaitForChild('NonExistent').Something.
2026-06-08 13:00:51 +03:00
min
2fa575ae4c refactor(rbxl-import): импортированные Lua-скрипты идут через LuaSharedSandbox
Раньше было два параллельных Lua-runtime:
- RobloxLuaSharedSandbox (Worker + wasmoon) для импортированных .rbxl;
- LuaSharedSandbox (main thread + wasmoon) для user-Lua.

Импортированные скрипты не получали фичи Этапов 4-6
(Position setters, GUI, Sound, TweenService) — их shim был в отдельном
Worker'е с более старым API.

Сейчас в GameRuntime.start():
1. Скрипты с маркером '// @roblox-lua' распаковываются
   через unpackRobloxLuaCode() и попадают в тот же luaUserBatch
   что и user-Lua;
2. Собыраются _rbxlImported=true для лога;
3. Числовой script.target (примитив id) уже совместим с
   LuaSharedSandbox.addScript → резолвится в script.Parent.

Удалены мёртвые файлы (общий размер ~2500 строк):
- RobloxLuaSharedSandbox.js + RobloxLuaSharedWorker.js
- RobloxLuaSandbox.js + RobloxLuaWorker.js (старая пара)
- roblox-shim.js + roblox-services.js + roblox-physics.js
- roblox-scheduler.js + roblox-tween.js
- из rbxl-lua-integration.js убрана функция startRobloxLuaShared()

Побочный эффект: импортированные Roblox-игры теперь автоматически
получают:
- живые part.Position/Size/Color setters;
- полный GUI (Frame/TextLabel/TextButton);
- TweenService:Create с реальной интерполяцией;
- Sound с процедурными звуками;
- Humanoid.Health/Died и прочие фичи Этапов 4-6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 12:56:05 +03:00
min
a1faf237a1 feat(lua): Этапы 6+7 — Sound + полная документация RUBLOX_LUA_API.md
Этап 6 — Sound:
  local s = Instance.new('Sound')
  s.SoundId = 'coin'  -- или 'jump'/'win'/'lose'/'hit'/'click'/'pickup'
  s.Volume = 1
  s.PlaybackSpeed = 1.2
  s:Play()
  s.Ended:Connect(function() print('ended') end)
SoundService:PlayLocalSound(sound) тоже работает.
Маппинг roblox-AssetID на встроенные звуки по эвристике (substring match).

Этап 7 — Документация:
RUBLOX_LUA_API.md — полный справочник всего реализованного.
Содержание: базовые типы, DataModel, Part-setters, Instance.new,
события (Touched/Heartbeat/RemoteEvent), таймеры, GUI, Sound,
TweenService, Humanoid, что не работает, готовый пример игры
(KillBrick + Coin + GUI-счётчик).

Этим завершается план RUBLOX_LUA_SUPPORT_PLAN (все 7 этапов).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 12:45:15 +03:00
min
371ddaaae8 feat(lua): Этап 5 — GUI (Frame, TextLabel, TextButton, ImageLabel, TextBox, ScrollingFrame)
В Lua теперь работает Roblox-style GUI:
  local sg = Instance.new('ScreenGui')
  local label = Instance.new('TextLabel', sg)
  label.Text = 'Hello'
  label.TextColor3 = Color3.fromRGB(255, 255, 0)
  label.Position = UDim2.new(0.5, 0, 0.5, 0)
  label.Size = UDim2.new(0.2, 0, 0.05, 0)

  local btn = Instance.new('TextButton', sg)
  btn.Text = 'Click me'
  btn.MouseButton1Click:Connect(function()
    print('clicked!')
  end)

Реализация: GUI-обёртка newGuiInstance создаёт элемент через gui.create
команду → GameRuntime.scene3d.guiManager. Setter'ы Text/Visible/
BackgroundColor3/TextColor3/TextSize/Position/Size шлют gui.update.
Destroy шлёт gui.remove. Клики через guiClick → guiByLocalRef →
inst.MouseButton1Click.Fire().

Добавлен localPlayer.PlayerGui для совместимости с Roblox-скриптами.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 12:40:49 +03:00
min
936f93a42c fix(lua): part.Position теперь реально двигает куб
КОРНЕВАЯ ПРИЧИНА: BabylonScene.freezeStaticPrimitives() замораживает
world matrix anchored-примитивов при Play для оптимизации.
mesh.position.set(x,y,z) после этого не обновляет рендер.

Фикс в PrimitiveManager.updateInstance: если patch содержит position/
rotation/size — расфризить world matrix прежде чем менять transform.
2026-06-08 12:19:14 +03:00
min
ecc2055b3d fix(lua): partSet применяется через PrimitiveManager.updateInstance (был applyPatch/update — их нет); очистка debug-логов 2026-06-08 12:00:29 +03:00
min
0d7224a2b8 fix(lua): WASM memory access — resume coroutine через lua.global.get вместо doStringSync
При каждом tick'е tickScheduler делал lua.doStringSync(resume code),
что вызывало re-entrant WASM-крах memory access out of bounds после
нескольких task.wait() итераций.

Фикс: кешируем ссылку на __rbxl_resume_co при init и зовём её напрямую.
Это безопасный путь (не парсит код заново, не открывает вложенный
Lua-state поверх существующего).
2026-06-08 11:50:22 +03:00
min
8ac2637615 feat(lua): Этап 4 — Part setters + task.wait + Instance.new + Destroy + TweenService
Этап 4.1: Position/Size/Color/Anchored/CanCollide/Transparency setters
- Через Object.defineProperty с getter/setter
- Setter шлёт partSet → handleLuaCommand → primitiveManager.applyPatch
- Формат payload соответствует rbxl-lua-integration

Этап 4.2: Instance.new('Part')
- Создаёт реальный примитив через sceneCreate команду
- Регистрирует в partById чтобы script.Parent.Touched работал
- Автоинкремент id с базы 100000

Этап 4.3: part:Destroy()
- sceneDelete → primitiveManager.removeInstance
- Также удаляет из Workspace.Children

Этап 4.4: task.wait(sec) через coroutine.yield
- Каждый скрипт стартует как coroutine
- task.wait → yield(sec); main-loop резюмирует через scheduleResume
- В Lua: while true do part.Position = ...; task.wait(0.1) end
  теперь работает корректно (raw80и не зависает UI)

Этап 4.5: BindableEvent + RemoteEvent
- Уже было в Instance.new (.Event = makeSignal()).
- Между Lua-скриптами работает через общий VM.

Этап 4.6: TweenService:Create
- Реальная интерполяция Vector3.Lerp / Color3.Lerp / number
- Через _stepTweens в tickScheduler каждый кадр
- tween.Completed signal фейерится по завершению

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:46:01 +03:00
min
55f4a3fc38 fix(lua): humanoid.Health=0 теперь реально убивает игрока через PlayerController.takeDamage 2026-06-08 11:38:03 +03:00
min
30d472bf43 feat(lua): дублировать Lua print() в DevTools Console для отладки 2026-06-08 11:35:11 +03:00
min
2b15ec821a feat(lua): Этап 3 — DataModel + Touched + Humanoid (main-thread wasmoon)
Главное достижение: KillBrick работает.
script.Parent.Touched:Connect(fn) фейерится когда игрок касается куба,
humanoid:TakeDamage(100) → playerSet команда → BabylonScene.player.hp=0
→ respawn + playerDied event.

Архитектурные изменения:
- LuaSharedSandbox v3: wasmoon в MAIN потоке вместо Worker'а.
  DevTools видит точные ошибки, breakpoints работают,
  console.log в RobloxShim виден сразу.
- LuaSharedWorker.js удалён (больше не нужен).
- RobloxShim добавляет полное DataModel дерево:
  game / Workspace / Players / LocalPlayer / Character /
  Humanoid / HumanoidRootPart / 15 services (RunService.Heartbeat,
  TweenService, HttpService, DataStoreService, etc).
- newPart создаёт RbxPart-обёртку вокруг каждого primitive в сцене,
  Touched/TouchEnded signals.

Wasmoon-quirk:
- TypeError: Cannot read properties of null (reading 'then') возникает
  когда JS-функция возвращает null в Lua-контекст. PromiseTypeExtension
  делает .then без guard. Везде заменили null → undefined (push'ится как nil).
- _rbxl_get_part_by_id возвращает undefined если не нашёл, FindFirstChild и
  прочие тоже undefined вместо null.

GameRuntime.js:
- _buildSceneSnapshot теперь даёт id (для partById), color, anchored,
  canCollide, opacity полей у primitives.
- partSet/sceneCreate user-Lua → handleLuaCommand (rbxl интеграция).
- playerSet handler: humanoid.Health=0 → respawn + hpChange event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:32:31 +03:00
min
b7a0b083b6 fix(lua): Vector3.Magnitude/Unit как getters + сохранение language в БД
Vector3:
- Magnitude теперь getter (без скобок) — как в Roblox
- Unit теперь getter
- Поддержаны lowercase алиасы magnitude, unit

Сохранение:
- BabylonScene serialize включает language в scripts[]
- Clip/copy включает language
- LuaSharedWorker.handleInit обёрнут в try/catch с детальной ошибкой
- LuaSharedWorker использует статический import для wasmoon

Этап 2 завершён полностью. Lua-runtime прошёл все тесты:
print, warn, Vector3, Color3, UDim2, CFrame, Enum, task.delay/spawn/defer,
RunService.Heartbeat, math/string/table, pcall.

Следующий этап 3: DataModel (game.Workspace + Instance + script.Parent +
Part.Touched + Part.Position setter).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:51:42 +03:00
min
d41051edf9 fix(lua): StudioCollab глотал 5-ый параметр language в upsertScript
Корень бага переключателя Lua: StudioCollab.js переопределял
scene.upsertScript с 4-мя аргументами (id,code,target,name) и не передавал
переданный 5-ый language в оригинал. Поэтому при смене языка в UI
language пропадал и переключатель оставался на JS.

Исправлено:
- Обёртка scene.upsertScript = function (id, code, target, name, language)
- Наследие collab-операции с language в sendOp и в _applyRemoteOp

Диагностические console.log убраны.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:28:04 +03:00
min
71d2f2db83 fix(lua): tick() в LuaSharedSandbox + autocomplete/hover Lua + ConfirmModal
Критфикс:
- LuaSharedSandbox.tick(dt, state) — no-op (GameRuntime.tick крашил)
- LuaSharedSandbox.target (геттер) — null

Monaco IntelliSense для Lua:
- registerCompletionItemProvider('lua') — Vector3.new/Color3.fromRGB/UDim2/CFrame
  /Instance.new/game/workspace/script/task.*/print/wait/pcall/etc.
- registerHoverProvider('lua') — документация при наведении на API
- 6 готовых сниппетов: killbrick, teleportpad, coin, heartbeat, playeradded, spinpart

UI:
- ConfirmModal — кастомная модалка вместо window.confirm
- В шапке ScriptEditor при смене языка — наша модалка с правильным стилем
- Esc/Enter, автофокус на confirm-кнопке, blur-фон, поп-ин анимация

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:07:51 +03:00
min
06df77cc97 feat(lua): этапы 1+2 — Lua-скрипты в Рублоксе
Этап 1 (UI):
- Скрипт имеет поле language: 'js' | 'lua' (дефолт 'js')
- Переключатель JS / Lua в шапке ScriptEditor (жёлтый / синий)
- При смене с пустого/template — подставляется шаблон нового языка
- При смене с реальным кодом — confirm
- Monaco автоматически переключает подсветку
- Badge JS/LUA в HierarchyPanel рядом с именем скрипта

Этап 2 (базовый runtime):
- LuaSharedSandbox — обёртка с API совместимым с ScriptSandbox
- LuaSharedWorker — Web Worker с одним wasmoon-VM на всю игру
- RobloxShim — Vector3/Color3/UDim2/Vector2/CFrame, Enum.*, print/warn,
  wait/task.*, RbxSignal, Instance.new (база), game.GetService (стабы),
  RunService.Heartbeat
- Scheduler для task.delay/defer через main loop tick
- GameRuntime разделяет скрипты: JS / Roblox-Lua (импорт) / user-Lua

На Этапе 3 — DataModel (game.Workspace + Instance.Parent + Touched).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 09:57:12 +03:00
24 changed files with 325 additions and 3487 deletions

View File

@ -59,28 +59,10 @@ STORAGE_ROOT = os.environ.get('STORAGE_ROOT', '/opt/roblox-assets')
PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox') PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox')
MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB
ALLOWED_USER_IDS = [1] # пока только МИН
app = Flask(__name__) app = Flask(__name__)
# CORS открыт для всех источников — фронт студии живёт на studio.rublox.pro, CORS(app, resources={r'/*': {'origins': '*'}})
# api-rbxl проксируется через NPM на minecraftia-school.ru/api-rbxl/*.
# Поддерживаем preflight (OPTIONS) явно через after_request — иногда
# flask-cors не отдавал заголовки для OPTIONS если NPM их перекрывал.
CORS(app, resources={r'/*': {'origins': '*'}}, supports_credentials=False)
@app.after_request
def _add_cors_headers(resp):
resp.headers['Access-Control-Allow-Origin'] = '*'
resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
resp.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-User-Id, X-User-Login'
resp.headers['Access-Control-Max-Age'] = '3600'
return resp
@app.route('/import/rbxl/analyze', methods=['OPTIONS'])
@app.route('/import/rbxl/create', methods=['OPTIONS'])
def _preflight():
return '', 204
# Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда # Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда
# console.error/warn, failed network requests, неожиданные exceptions. # console.error/warn, failed network requests, неожиданные exceptions.
@ -111,7 +93,8 @@ def auth_check(req) -> int:
uid = int(user_id_str) uid = int(user_id_str)
except ValueError: except ValueError:
raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}') raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}')
# Импорт открыт всем (см. вики «Импорт из Roblox»). if uid not in ALLOWED_USER_IDS:
raise RuntimeError(f'User {uid} not allowed (only МИН)')
return uid return uid

View File

@ -149,7 +149,7 @@ const AI_CONTEXT = `Ты — помощник по написанию скрип
=== СЦЕНА game.scene === === СЦЕНА game.scene ===
spawn(type, opts) -> объект. type: 'cube'|'sphere'|'cylinder'|'cone'|'pyramid'|'torus'|'wedge'|'plane' (примитивы), 'model:ID', 'block:ID', 'vehicle:car', 'light:point', 'billboard', 'trigger'. spawn(type, opts) -> объект. type: 'cube'|'sphere'|'cylinder'|'cone'|'pyramid'|'torus'|'wedge'|'plane' (примитивы), 'model:ID', 'block:ID', 'vehicle:car', 'light:point', 'billboard', 'trigger'.
opts: {x,y,z, sx,sy,sz, rotationX,rotationY,rotationZ, color:'#hex', material:'matte'|'neon'|'metal'|'glass'|'studs'|'chrome'|'water'|'iridescent', name, anchored:true(висит)/false(падает), canCollide, visible, mass, lifetime(сек до авто-удаления)} opts: {x,y,z, sx,sy,sz, rotationX,rotationY,rotationZ, color:'#hex', material:'matte'|'neon'|'metal'|'glass'|'studs', name, anchored:true(висит)/false(падает), canCollide, visible, mass, lifetime(сек до авто-удаления)}
delete(ref); move(ref,x,y,z); setRotation(ref,rx,ry,rz); setColor(ref,'#hex'); setMaterial(ref,name); setVisible(ref,bool); setCollide(ref,bool); setOpacity(ref,0..1); setScale(ref,sx,sy,sz) delete(ref); move(ref,x,y,z); setRotation(ref,rx,ry,rz); setColor(ref,'#hex'); setMaterial(ref,name); setVisible(ref,bool); setCollide(ref,bool); setOpacity(ref,0..1); setScale(ref,sx,sy,sz)
setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key) setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key)
find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block') find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block')
@ -201,10 +201,6 @@ shake(amp, sec); setFov(deg); focusOn(ref,{distance,height}); cutscene([{x,y,z}.
=== ОКРУЖЕНИЕ game.environment === === ОКРУЖЕНИЕ game.environment ===
setSkyColor('#hex'); setFog({enabled,color,density}); setTimeOfDay(0..24) setSkyColor('#hex'); setFog({enabled,color,density}); setTimeOfDay(0..24)
=== ГРАФИКА/ЭФФЕКТЫ game.graphics (по умолч. выкл) ===
setPreset('off'|'low'|'medium'|'high'|'ultra'|'cinematic'|'vivid'|'night'|'retro'|'soft')
setBloom(bool,{intensity:0..1,threshold:0..1}); setVignette(0..1.5); setColorGrading({contrast,saturation,exposure}); setShadows('off'|'hard'|'soft'|'medium'|'high'); setSSAO(bool); setDepthOfField(bool); setAntialiasing(bool); off()
=== ИНВЕНТАРЬ / ПРЕДМЕТЫ === === ИНВЕНТАРЬ / ПРЕДМЕТЫ ===
game.items.define([{id,name,emoji,rarity:'common'|'rare'|'epic'|'legendary',maxStack,onUseEffect:'heal:50'}]) game.items.define([{id,name,emoji,rarity:'common'|'rare'|'epic'|'legendary',maxStack,onUseEffect:'heal:50'}])
game.inventory.give(id,count); .take(id,count); .has(id); .open(); .list() game.inventory.give(id,count); .take(id,count); .has(id); .open(); .list()
@ -227,8 +223,7 @@ game.log(...) — в консоль; game.random(min,max,целое?); game.clam
- Повороты в РАДИАНАХ (Math.PI/2 = 90°). - Повороты в РАДИАНАХ (Math.PI/2 = 90°).
- Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга). - Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга).
- spawn примитива: тип без префикса ('cube'), модели с 'model:', машина 'vehicle:car'. - spawn примитива: тип без префикса ('cube'), модели с 'model:', машина 'vehicle:car'.
- material: matte, neon, metal, glass, studs, chrome (зеркало), water (вода), iridescent (переливы). - material только: matte, neon, metal, glass, studs.
- эффекты включаются через game.graphics.setPreset(...) или в настройках игры; по умолчанию выкл.
- Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }). - Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }).
- Не используй require() и DOM/window только game.* и обычный JS. - Не используй require() и DOM/window только game.* и обычный JS.
@ -5602,152 +5597,6 @@ end)`}</Code>}
}, },
], ],
}, },
//
// РАЗДЕЛ ГРАФИКА И ЭФФЕКТЫ (шейдеры)
//
{
id: 'graphics',
icon: 'sparkles',
title: 'Графика и эффекты',
summary: 'Шейдеры-эффекты: свечение, цветокоррекция, тени, красивые материалы (хром, вода, переливы). Через настройки и из скриптов.',
sections: [
{
id: 'gfx-what',
title: 'GR1. Что такое эффекты (шейдеры)',
body: (
<>
<p>
<b>Эффекты (шейдеры)</b> делают картинку игры красивее
как «шейдер-паки» в Майнкрафте. Это:
</p>
<ul>
<li><b>Свечение (Bloom)</b> яркие объекты (неон, лампы,
солнце) светятся и переливаются;</li>
<li><b>Цветокоррекция</b> насыщенность и контраст, как
в кино;</li>
<li><b>Виньетка</b> мягкое затемнение по краям экрана;</li>
<li><b>Тени</b> мягкие или резкие, контактные тени в углах;</li>
<li><b>Глубина резкости</b> размытие дальнего плана;</li>
<li><b>Сглаживание</b> убирает «лесенки» на краях.</li>
</ul>
<Note>
По умолчанию эффекты <b>выключены</b> игра выглядит как
раньше. Включи их в настройках или из скрипта, когда захочешь
«прокачать» картинку. На слабых компах/телефонах тяжёлые
эффекты <b>сами</b> упрощаются, чтобы игра не тормозила.
</Note>
</>
),
},
{
id: 'gfx-settings',
title: 'GR2. Включить эффекты в настройках',
body: (
<>
<Step n={1}>
Открой <kbd className="kbd">Настройки</kbd> игры (шестерёнка
вверху).
</Step>
<Step n={2}>
Найди раздел <b>«Графика и эффекты»</b> и выбери <b>пресет</b>:
</Step>
<ul>
<li><b>Выключено</b> без эффектов (по умолчанию);</li>
<li><b>Низкое / Среднее / Высокое / Ультра</b> всё больше
красоты (и нагрузки);</li>
<li><b>🎬 Кино</b> контраст + виньетка + глубина резкости;</li>
<li><b>🌈 Сочное</b> яркие насыщенные цвета;</li>
<li><b>🌙 Ночь</b> тёмная атмосфера, сильное свечение;</li>
<li><b>📺 Ретро</b> старый ламповый вид;</li>
<li><b> Мягкое</b> нежная пастельная картинка.</li>
</ul>
<Step n={3}>
Или настрой вручную галочками (свечение, сглаживание,
виньетка, контактные тени) и ползунками (насыщенность,
контраст). Нажми <kbd className="kbd">Сохранить</kbd> эффект
появится сразу.
</Step>
<Try>
Поставь несколько неоновых кубов, включи пресет «Ночь» они
будут красиво светиться в темноте.
</Try>
</>
),
},
{
id: 'gfx-materials',
title: 'GR3. Красивые материалы',
body: (
<>
<p>
У каждого примитива есть <b>материал</b> (в свойствах объекта
или при создании из скрипта). Кроме обычных есть «шейдерные»:
</p>
<ul>
<li><b>neon</b> светится (works с Bloom особенно красиво);</li>
<li><b>metal</b> металлический блик;</li>
<li><b>chrome</b> зеркальный хром;</li>
<li><b>glass</b> прозрачное стекло;</li>
<li><b>water</b> полупрозрачная вода с бликами;</li>
<li><b>iridescent</b> переливы (бензиновая плёнка, кристалл);</li>
<li><b>studs</b> лего-пупырышки;</li>
<li><b>matte</b> матовый (без блеска, по умолчанию).</li>
</ul>
<ScriptKind kind="global" />
<Code>{`// хромированная сфера
game.scene.spawn('sphere', { x: 0, y: 2, z: 0, color: '#dfe6f0', material: 'chrome' });
// светящийся неон-куб
game.scene.spawn('cube', { x: 3, y: 2, z: 0, color: '#00ffd0', material: 'neon' });
// переливающийся кристалл
game.scene.spawn('cone', { x: -3, y: 2, z: 0, color: '#a06bff', material: 'iridescent' });
// поменять материал существующего объекта:
const obj = game.scene.findOne('Вода');
obj.material = 'water';`}</Code>
</>
),
},
{
id: 'gfx-api',
title: 'GR4. Эффекты из скриптов (game.graphics)',
body: (
<>
<p>
Эффекты можно включать и менять <b>на ходу</b> из скрипта
например затемнить мир ночью или включить «кино» в катсцене.
</p>
<ScriptKind kind="global" />
<Code>{`// применить готовый пресет
game.graphics.setPreset('cinematic');
// точечная настройка
game.graphics.setBloom(true, { intensity: 0.7 }); // свечение
game.graphics.setVignette(0.5); // затемнение краёв
game.graphics.setColorGrading({ saturation: 1.4, contrast: 1.1 });
game.graphics.setShadows('soft'); // off|hard|soft|medium|high
game.graphics.setSSAO(true); // контактные тени
game.graphics.setDepthOfField(true); // размытие дальнего плана
// выключить всё
game.graphics.off();`}</Code>
<p>Пример плавно «сделать ночь» при входе в пещеру:</p>
<Code>{`const cave = game.scene.findOne('ВходВПещеру');
cave.onTouch(() => {
game.graphics.setPreset('night');
game.environment.setTimeOfDay(0);
game.ui.showText('Темнеет...', 2);
});`}</Code>
<Note>
Эффекты применяются ко всему экрану. Если игра должна
работать на телефонах не включай разом DoF + SSAO + ультра-
тени: движок их урежет, но лучше выбрать пресет полегче.
</Note>
</>
),
},
],
},
]; ];

View File

@ -83,7 +83,7 @@ export function highlightCode(text, lang) {
// Классифицируем // Классифицируем
if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) { if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) {
tokens.push({ type: 'comment', text: tok }); tokens.push({ type: 'comment', text: tok });
} else if (/^["'`[]/.test(tok)) { } else if (/^["'`\[]/.test(tok)) {
tokens.push({ type: 'string', text: tok }); tokens.push({ type: 'string', text: tok });
} else if (/^\d/.test(tok)) { } else if (/^\d/.test(tok)) {
tokens.push({ type: 'number', text: tok }); tokens.push({ type: 'number', text: tok });

View File

@ -1,440 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import cl from './GameSettingsModal.module.css';
import Icon from './Icon';
/**
* GameDecorModal «оформление» игры: Графика и эффекты, Стартовый экран
* (Ken Burns), Экран загрузки. Вынесено из настроек во вкладку «Игра»
* (TopRibbon) тремя отдельными кнопками. Открывается с нужной секцией через
* проп `section` ('graphics' | 'startscreen' | 'loadingscreen'), вверху
* табы для переключения между ними.
*
* Props:
* open открыто ли
* section какую секцию показать первой
* initial { loading_screen:{...}, graphics:{...} } (из scene/проекта)
* onClose закрыть без сохранения
* onSave(data) data = { loading_screen, graphics } (как в GameSettingsModal)
*/
const MAX_IMG_BYTES = 500 * 1024;
const TABS = [
{ id: 'graphics', title: 'Графика', icon: 'sparkles' },
{ id: 'startscreen', title: 'Стартовый экран', icon: 'loader' },
{ id: 'loadingscreen', title: 'Экран загрузки', icon: 'loader' },
];
const GameDecorModal = ({ open, section = 'graphics', initial, onClose, onSave }) => {
const [tab, setTab] = useState(section);
// Экран загрузки
const [loadingLogo, setLoadingLogo] = useState('');
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
const [loadingSpinner, setLoadingSpinner] = useState(true);
const [loadingSkip, setLoadingSkip] = useState(false);
// Стартовый Ken-Burns экран
const [lsEnabled, setLsEnabled] = useState(true);
const [lsBackground, setLsBackground] = useState('');
const [lsCover, setLsCover] = useState('');
const [lsStyle, setLsStyle] = useState('ken-burns');
const [lsPlaceName, setLsPlaceName] = useState('');
const [lsStudioName, setLsStudioName] = useState('');
const [lsVerified, setLsVerified] = useState(false);
const [lsDuration, setLsDuration] = useState(2.5);
const [lsProgressBar, setLsProgressBar] = useState(true);
// Графика
const [gfxPreset, setGfxPreset] = useState('off');
const [gfxBloom, setGfxBloom] = useState(false);
const [gfxFxaa, setGfxFxaa] = useState(false);
const [gfxVignette, setGfxVignette] = useState(false);
const [gfxSsao, setGfxSsao] = useState(false);
const [gfxSaturation, setGfxSaturation] = useState(1.0);
const [gfxContrast, setGfxContrast] = useState(1.0);
const [error, setError] = useState('');
const logoInputRef = useRef(null);
const lsBgInputRef = useRef(null);
const lsCoverInputRef = useRef(null);
useEffect(() => {
if (!open) return;
setTab(section || 'graphics');
const ls = initial?.loading_screen || {};
setLoadingLogo(ls.logo || '');
setLoadingAccent(ls.accentColor || '#ffc020');
setLoadingSpinner(ls.defaultSpinner !== false);
setLoadingSkip(!!ls.defaultSkipButton);
setLsEnabled(ls.enabled !== false);
setLsBackground(ls.background || '');
setLsCover(ls.cover || '');
setLsStyle(ls.style || 'ken-burns');
setLsPlaceName(ls.placeName || '');
setLsStudioName(ls.studioName || '');
setLsVerified(!!ls.verified);
setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
setLsProgressBar(ls.progressBar !== false);
const gx = initial?.graphics || {};
setGfxPreset(gx.preset || 'off');
setGfxBloom(!!(gx.bloom && gx.bloom.enabled));
setGfxFxaa(!!gx.fxaa);
setGfxVignette(!!(gx.vignette && gx.vignette.enabled));
setGfxSsao(!!gx.ssao);
setGfxSaturation((gx.grading && Number.isFinite(gx.grading.saturation)) ? gx.grading.saturation : 1.0);
setGfxContrast((gx.grading && Number.isFinite(gx.grading.contrast)) ? gx.grading.contrast : 1.0);
setError('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, section]);
if (!open) return null;
const handleLogoSelect = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Логотип: только PNG, JPG или WEBP'); return; }
if (file.size > MAX_IMG_BYTES) { setError('Логотип слишком большой (макс. 500 КБ)'); return; }
const reader = new FileReader();
reader.onload = (ev) => { setLoadingLogo(ev.target.result); setError(''); };
reader.readAsDataURL(file);
};
const handleLsImage = (e, setter) => {
const file = e.target.files?.[0];
if (!file) return;
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; }
if (file.size > MAX_IMG_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; }
const reader = new FileReader();
reader.onload = (ev) => { setter(ev.target.result); setError(''); };
reader.readAsDataURL(file);
};
const applyPresetToToggles = (preset) => {
setGfxPreset(preset);
const P = {
off: { b: false, f: false, v: false, s: false, sat: 1.0, con: 1.0 },
low: { b: true, f: true, v: false, s: false, sat: 1.0, con: 1.0 },
medium: { b: true, f: true, v: true, s: false, sat: 1.1, con: 1.05 },
high: { b: true, f: true, v: true, s: true, sat: 1.2, con: 1.1 },
ultra: { b: true, f: true, v: true, s: true, sat: 1.25, con: 1.12 },
cinematic: { b: true, f: true, v: true, s: true, sat: 1.05, con: 1.18 },
vivid: { b: true, f: true, v: false, s: false, sat: 1.5, con: 1.1 },
night: { b: true, f: true, v: true, s: true, sat: 0.85, con: 1.2 },
retro: { b: false, f: false, v: true, s: false, sat: 0.7, con: 1.3 },
soft: { b: true, f: true, v: true, s: false, sat: 1.05, con: 0.95 },
}[preset];
if (!P) return;
setGfxBloom(P.b); setGfxFxaa(P.f); setGfxVignette(P.v);
setGfxSsao(P.s); setGfxSaturation(P.sat); setGfxContrast(P.con);
};
const handleSubmit = (e) => {
e.preventDefault();
onSave({
loading_screen: {
logo: loadingLogo || null,
accentColor: loadingAccent || '#ffc020',
defaultSpinner: loadingSpinner,
defaultSkipButton: loadingSkip,
enabled: lsEnabled,
background: lsBackground || null,
cover: lsCover || null,
style: lsStyle || 'ken-burns',
placeName: lsPlaceName.trim(),
studioName: lsStudioName.trim(),
verified: lsVerified,
duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
progressBar: lsProgressBar,
},
graphics: {
preset: gfxPreset,
bloom: { enabled: gfxBloom },
fxaa: gfxFxaa,
vignette: { enabled: gfxVignette },
ssao: gfxSsao,
grading: {
enabled: (gfxSaturation !== 1.0 || gfxContrast !== 1.0),
saturation: Number(gfxSaturation) || 1.0,
contrast: Number(gfxContrast) || 1.0,
},
},
});
};
return (
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={cl.modal}>
<div className={cl.header}>
<div className={cl.title}><Icon name="sparkles" size={16} /> Оформление игры</div>
<button className={cl.closeBtn} onClick={onClose}><Icon name="close" size={14} /></button>
</div>
{/* Табы разделов */}
<div style={{ display: 'flex', gap: 6, padding: '0 18px', marginTop: 6, flexWrap: 'wrap' }}>
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 14px', borderRadius: 8, cursor: 'pointer',
border: '1px solid ' + (tab === t.id ? 'rgba(120,150,255,0.6)' : 'rgba(255,255,255,0.10)'),
background: tab === t.id ? 'rgba(90,120,255,0.18)' : 'transparent',
color: tab === t.id ? '#dfe6ff' : '#aab', fontSize: 13, fontWeight: 600,
}}
>
<Icon name={t.icon} size={13} /> {t.title}
</button>
))}
</div>
<form onSubmit={handleSubmit}>
<div className={cl.body}>
{/* === ГРАФИКА === */}
{tab === 'graphics' && (
<div className={cl.field}>
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
Свечение, цветокоррекция, тени и сглаживание (как шейдеры).
По умолчанию выключено. На слабых устройствах тяжёлые эффекты
урезаются автоматически. Также управляется из скриптов (game.graphics).
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 12 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Пресет</span>
<select className={cl.select} value={gfxPreset}
onChange={(e) => applyPresetToToggles(e.target.value)}>
<option value="off">Выключено</option>
<option value="low">Низкое (свечение)</option>
<option value="medium">Среднее</option>
<option value="high">Высокое</option>
<option value="ultra">Ультра (с глубиной резкости)</option>
<option value="cinematic">🎬 Кино</option>
<option value="vivid">🌈 Сочное</option>
<option value="night">🌙 Ночь</option>
<option value="retro">📺 Ретро</option>
<option value="soft"> Мягкое</option>
</select>
</label>
<div className={cl.togglesRow}>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={gfxBloom} onChange={(e) => { setGfxBloom(e.target.checked); setGfxPreset('custom'); }} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Свечение (Bloom)</div>
<div className={cl.toggleHint}><span>Яркие объекты светятся</span></div>
</div>
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={gfxFxaa} onChange={(e) => { setGfxFxaa(e.target.checked); setGfxPreset('custom'); }} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Сглаживание</div>
<div className={cl.toggleHint}><span>Убирает «лесенки» на краях</span></div>
</div>
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={gfxVignette} onChange={(e) => { setGfxVignette(e.target.checked); setGfxPreset('custom'); }} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Виньетка</div>
<div className={cl.toggleHint}><span>Мягкое затемнение по краям</span></div>
</div>
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={gfxSsao} onChange={(e) => { setGfxSsao(e.target.checked); setGfxPreset('custom'); }} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Контактные тени (SSAO)</div>
<div className={cl.toggleHint}><span>Затемнение в углах и стыках</span></div>
</div>
</label>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 160 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Насыщенность: {gfxSaturation.toFixed(2)}</span>
<input type="range" min="0.5" max="2" step="0.05" value={gfxSaturation}
onChange={(e) => { setGfxSaturation(Number(e.target.value)); setGfxPreset('custom'); }} />
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 160 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Контраст: {gfxContrast.toFixed(2)}</span>
<input type="range" min="0.5" max="1.6" step="0.05" value={gfxContrast}
onChange={(e) => { setGfxContrast(Number(e.target.value)); setGfxPreset('custom'); }} />
</label>
</div>
</div>
)}
{/* === СТАРТОВЫЙ ЭКРАН (Ken Burns) === */}
{tab === 'startscreen' && (
<div className={cl.field}>
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
</div>
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
<input type="checkbox" className={cl.toggle}
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
<div className={cl.toggleHint}><span>Если выключено игрок сразу попадает в 3D-сцену</span></div>
</div>
</label>
{lsEnabled && (
<>
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
width: 130, height: 74, borderRadius: 8, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
backgroundSize: 'cover', backgroundPosition: 'center',
}}>
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
</div>
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
<Icon name="folder" size={14} /> Фон
</button>
{lsBackground && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
width: 74, height: 74, borderRadius: 12, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
backgroundSize: 'cover', backgroundPosition: 'center',
}}>
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
</div>
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
<Icon name="folder" size={14} /> Карточка
</button>
{lsCover && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
value={lsPlaceName} maxLength={40}
onChange={(e) => setLsPlaceName(e.target.value)} />
<input type="text" className={cl.input} placeholder="Имя автора"
value={lsStudioName} maxLength={40}
onChange={(e) => setLsStudioName(e.target.value)} />
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Галочка verified</div>
</div>
</label>
</div>
</div>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
<option value="static">Статичный фон</option>
<option value="parallax">Параллакс (по мыши)</option>
<option value="particles">Частицы (искры)</option>
</select>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
onChange={(e) => setLsDuration(Number(e.target.value))}
style={{ width: 160 }} />
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Прогресс-бар</div>
</div>
</label>
</div>
</>
)}
</div>
)}
{/* === ЭКРАН ЗАГРУЗКИ === */}
{tab === 'loadingscreen' && (
<div className={cl.field}>
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
Логотип и цвет акцента для экранов загрузки между мирами (game.loading).
</div>
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
<div style={{
width: 96, height: 54, borderRadius: 8, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', display: 'flex',
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flex: '0 0 auto',
}}>
{loadingLogo
? <img src={loadingLogo} alt="Логотип" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
: <span style={{ color: '#5a6178', fontSize: 11 }}>лого = обложка</span>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<button type="button" className={cl.actionBtn} onClick={() => logoInputRef.current?.click()}>
<Icon name="folder" size={14} /> Логотип игры
</button>
{loadingLogo && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLoadingLogo('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={handleLogoSelect} />
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginLeft: 'auto' }}>
<span style={{ fontSize: 12, color: '#aab' }}>Цвет акцента</span>
<input type="color" value={loadingAccent}
onChange={(e) => setLoadingAccent(e.target.value)}
style={{ width: 48, height: 32, border: 'none', background: 'none', cursor: 'pointer' }} />
</label>
</div>
<div className={cl.togglesRow}>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={loadingSpinner} onChange={(e) => setLoadingSpinner(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Спиннер</div>
<div className={cl.toggleHint}><span>Показывать «ЗАГРУЗКА» по умолчанию</span></div>
</div>
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={loadingSkip} onChange={(e) => setLoadingSkip(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Кнопка «Пропустить»</div>
<div className={cl.toggleHint}><span>Показывать по умолчанию</span></div>
</div>
</label>
</div>
</div>
)}
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
</div>
<div className={cl.footer}>
<button type="button" className={cl.secondaryBtn} onClick={onClose}>Отмена</button>
<button type="submit" className={cl.primaryBtn}>
<Icon name="save" size={13} /> Сохранить
</button>
</div>
</form>
</div>
</div>
);
};
export default GameDecorModal;

View File

@ -45,8 +45,26 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
const [multiplayer, setMultiplayer] = useState(false); const [multiplayer, setMultiplayer] = useState(false);
const [maxPlayers, setMaxPlayers] = useState(10); const [maxPlayers, setMaxPlayers] = useState(10);
const [isTest, setIsTest] = useState(false); const [isTest, setIsTest] = useState(false);
// Задача 12: экран загрузки
const [loadingLogo, setLoadingLogo] = useState('');
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
const [loadingSpinner, setLoadingSpinner] = useState(true);
const [loadingSkip, setLoadingSkip] = useState(false);
// Задача 05: стартовый Ken-Burns экран
const [lsEnabled, setLsEnabled] = useState(true);
const [lsBackground, setLsBackground] = useState('');
const [lsCover, setLsCover] = useState('');
const [lsStyle, setLsStyle] = useState('ken-burns');
const [lsPlaceName, setLsPlaceName] = useState('');
const [lsStudioName, setLsStudioName] = useState('');
const [lsVerified, setLsVerified] = useState(false);
const [lsDuration, setLsDuration] = useState(2.5);
const [lsProgressBar, setLsProgressBar] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const logoInputRef = useRef(null);
const lsBgInputRef = useRef(null);
const lsCoverInputRef = useRef(null);
// Заполняем поля ОДИН РАЗ при открытии модала. // Заполняем поля ОДИН РАЗ при открытии модала.
// Не зависим от `initial` родитель часто передаёт литерал-объект, // Не зависим от `initial` родитель часто передаёт литерал-объект,
@ -60,6 +78,21 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
setIsPublic(!!initial?.is_public); setIsPublic(!!initial?.is_public);
setMultiplayer(!!initial?.multiplayer); setMultiplayer(!!initial?.multiplayer);
setIsTest(!!initial?.is_test); setIsTest(!!initial?.is_test);
const ls = initial?.loading_screen || {};
setLoadingLogo(ls.logo || '');
setLoadingAccent(ls.accentColor || '#ffc020');
setLoadingSpinner(ls.defaultSpinner !== false);
setLoadingSkip(!!ls.defaultSkipButton);
// Задача 05:
setLsEnabled(ls.enabled !== false);
setLsBackground(ls.background || '');
setLsCover(ls.cover || '');
setLsStyle(ls.style || 'ken-burns');
setLsPlaceName(ls.placeName || '');
setLsStudioName(ls.studioName || '');
setLsVerified(!!ls.verified);
setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
setLsProgressBar(ls.progressBar !== false);
setMaxPlayers( setMaxPlayers(
typeof initial?.max_players === 'number' typeof initial?.max_players === 'number'
? Math.max(2, Math.min(50, initial.max_players)) ? Math.max(2, Math.min(50, initial.max_players))
@ -96,6 +129,27 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
const handleLogoSelect = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Логотип: только PNG, JPG или WEBP'); return; }
if (file.size > MAX_THUMBNAIL_BYTES) { setError('Логотип слишком большой (макс. 500 КБ)'); return; }
const reader = new FileReader();
reader.onload = (ev) => { setLoadingLogo(ev.target.result); setError(''); };
reader.readAsDataURL(file);
};
// Задача 05: универсальный загрузчик изображения (фон / cover-карточка).
const handleLsImage = (e, setter) => {
const file = e.target.files?.[0];
if (!file) return;
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; }
if (file.size > MAX_THUMBNAIL_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; }
const reader = new FileReader();
reader.onload = (ev) => { setter(ev.target.result); setError(''); };
reader.readAsDataURL(file);
};
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const trimmedTitle = title.trim(); const trimmedTitle = title.trim();
@ -120,6 +174,22 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
multiplayer, multiplayer,
max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)), max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)),
is_test: isTest, is_test: isTest,
loading_screen: {
logo: loadingLogo || null,
accentColor: loadingAccent || '#ffc020',
defaultSpinner: loadingSpinner,
defaultSkipButton: loadingSkip,
// Задача 05:
enabled: lsEnabled,
background: lsBackground || null,
cover: lsCover || null,
style: lsStyle || 'ken-burns',
placeName: lsPlaceName.trim(),
studioName: lsStudioName.trim(),
verified: lsVerified,
duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
progressBar: lsProgressBar,
},
}); });
}; };
@ -300,6 +370,172 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
</label> </label>
)} )}
{/* Экран загрузки (задача 12) */}
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="loader" size={13} /> Экран загрузки
</div>
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
Логотип и цвет акцента для экранов загрузки между мирами (game.loading).
</div>
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
<div style={{
width: 96, height: 54, borderRadius: 8, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', display: 'flex',
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flex: '0 0 auto',
}}>
{loadingLogo
? <img src={loadingLogo} alt="Логотип" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
: <span style={{ color: '#5a6178', fontSize: 11 }}>лого = обложка</span>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<button type="button" className={cl.actionBtn} onClick={() => logoInputRef.current?.click()}>
<Icon name="folder" size={14} /> Логотип игры
</button>
{loadingLogo && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLoadingLogo('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={handleLogoSelect} />
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginLeft: 'auto' }}>
<span style={{ fontSize: 12, color: '#aab' }}>Цвет акцента</span>
<input type="color" value={loadingAccent}
onChange={(e) => setLoadingAccent(e.target.value)}
style={{ width: 48, height: 32, border: 'none', background: 'none', cursor: 'pointer' }} />
</label>
</div>
<div className={cl.togglesRow}>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={loadingSpinner} onChange={(e) => setLoadingSpinner(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Спиннер</div>
<div className={cl.toggleHint}><span>Показывать «ЗАГРУЗКА» по умолчанию</span></div>
</div>
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={loadingSkip} onChange={(e) => setLoadingSkip(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Кнопка «Пропустить»</div>
<div className={cl.toggleHint}><span>Показывать по умолчанию</span></div>
</div>
</label>
</div>
</div>
{/* Стартовый экран — Ken Burns + название места (задача 05) */}
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="loader" size={13} /> Стартовый экран входа (Ken Burns)
</div>
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
</div>
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
<input type="checkbox" className={cl.toggle}
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
<div className={cl.toggleHint}><span>Если выключено игрок сразу попадает в 3D-сцену</span></div>
</div>
</label>
{lsEnabled && (
<>
{/* Фон + карточка */}
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
width: 130, height: 74, borderRadius: 8, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
backgroundSize: 'cover', backgroundPosition: 'center',
}}>
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
</div>
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
<Icon name="folder" size={14} /> Фон
</button>
{lsBackground && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
width: 74, height: 74, borderRadius: 12, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
backgroundSize: 'cover', backgroundPosition: 'center',
}}>
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
</div>
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
<Icon name="folder" size={14} /> Карточка
</button>
{lsCover && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
value={lsPlaceName} maxLength={40}
onChange={(e) => setLsPlaceName(e.target.value)} />
<input type="text" className={cl.input} placeholder="Имя автора"
value={lsStudioName} maxLength={40}
onChange={(e) => setLsStudioName(e.target.value)} />
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Галочка verified</div>
</div>
</label>
</div>
</div>
{/* Стиль + длительность + прогресс */}
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
<option value="static">Статичный фон</option>
<option value="parallax">Параллакс (по мыши)</option>
<option value="particles">Частицы (искры)</option>
</select>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
onChange={(e) => setLsDuration(Number(e.target.value))}
style={{ width: 160 }} />
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Прогресс-бар</div>
</div>
</label>
</div>
</>
)}
</div>
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>} {error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
</div> </div>

View File

@ -1,10 +1,8 @@
/* === Hierarchy Panel === */ /* === Hierarchy Panel === */
/* Компактные строки (как Roblox Explorer): меньше вертикальных отступов
больше объектов влезает без скролла. */
.hierarchy { .hierarchy {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 0; padding: 6px 0;
font-size: 12px; font-size: 12px;
user-select: none; user-select: none;
position: relative; position: relative;
@ -15,13 +13,13 @@
} }
.rootLine { .rootLine {
padding: 3px 8px; padding: 6px 8px;
color: var(--text); color: var(--text);
font-size: 12px; font-size: 13px;
} }
.systemItem { .systemItem {
padding: 2px 8px 2px 26px; padding: 4px 8px 4px 28px;
color: var(--text-dim); color: var(--text-dim);
font-size: 12px; font-size: 12px;
} }
@ -30,11 +28,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 3px 8px; padding: 6px 8px;
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
margin-top: 3px; margin-top: 6px;
font-weight: 600; font-weight: 600;
} }
@ -74,8 +72,8 @@
.item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 8px;
padding: 2px 8px; padding: 4px 8px;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
color: var(--text); color: var(--text);

View File

@ -73,9 +73,6 @@ const GLYPHS = {
'arrow-down': () => (<><path d="M12 5v14" {...S}/><path d="M6 13l6 6 6-6" {...S}/></>), 'arrow-down': () => (<><path d="M12 5v14" {...S}/><path d="M6 13l6 6 6-6" {...S}/></>),
'arrow-left': () => (<><path d="M19 12H5" {...S}/><path d="M11 6l-6 6 6 6" {...S}/></>), 'arrow-left': () => (<><path d="M19 12H5" {...S}/><path d="M11 6l-6 6 6 6" {...S}/></>),
'arrow-right': () => (<><path d="M5 12h14" {...S}/><path d="M13 6l6 6-6 6" {...S}/></>), 'arrow-right': () => (<><path d="M5 12h14" {...S}/><path d="M13 6l6 6-6 6" {...S}/></>),
// Полноэкранный режим: 4 уголка наружу / внутрь.
'fullscreen': () => (<><path d="M4 9V4h5" {...S}/><path d="M20 9V4h-5" {...S}/><path d="M4 15v5h5" {...S}/><path d="M20 15v5h-5" {...S}/></>),
'fullscreen-exit': () => (<><path d="M9 4v5H4" {...S}/><path d="M15 4v5h5" {...S}/><path d="M9 20v-5H4" {...S}/><path d="M15 20v-5h5" {...S}/></>),
refresh: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>), refresh: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>),
cycle: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>), cycle: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>),
flag: () => (<><path d="M6 21V4" {...S}/><path d="M6 4h11l-2.5 4L17 12H6" {...S}/></>), flag: () => (<><path d="M6 21V4" {...S}/><path d="M6 4h11l-2.5 4L17 12H6" {...S}/></>),
@ -257,8 +254,6 @@ const GLYPHS = {
'prim-trigger': () => (<><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" {...S} strokeDasharray="3 3"/><path d="M4 7.5l8 4.5 8-4.5M12 12v9" {...S} strokeDasharray="3 3"/></>), 'prim-trigger': () => (<><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" {...S} strokeDasharray="3 3"/><path d="M4 7.5l8 4.5 8-4.5M12 12v9" {...S} strokeDasharray="3 3"/></>),
'prim-checkpoint': () => (<><path d="M6 21V4" {...S}/><path d="M6 4h6v3h6v3h-6V7H6" {...S}/><path d="M6 12h12v3h-6v-3" {...S}/></>), 'prim-checkpoint': () => (<><path d="M6 21V4" {...S}/><path d="M6 4h6v3h6v3h-6V7H6" {...S}/><path d="M6 12h12v3h-6v-3" {...S}/></>),
'prim-light': () => (<><path d="M8 14a6 6 0 1 1 8 0c-1 1-1.3 1.7-1.5 3h-5c-0.2-1.3-0.5-2-1.5-3z" {...S}/><path d="M9.5 20h5M10 22h4" {...S}/></>), 'prim-light': () => (<><path d="M8 14a6 6 0 1 1 8 0c-1 1-1.3 1.7-1.5 3h-5c-0.2-1.3-0.5-2-1.5-3z" {...S}/><path d="M9.5 20h5M10 22h4" {...S}/></>),
// Вертикальная лестница: две стойки + перекладины
'prim-ladder': () => (<><path d="M8 3v18M16 3v18" {...S}/><path d="M8 7h8M8 11h8M8 15h8M8 19h8" {...S}/></>),
'prim-emitter': () => (<><circle cx="12" cy="12" r="3" {...S}/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.5 5.5l2 2M16.5 16.5l2 2M18.5 5.5l-2 2M5.5 18.5l2-2" {...S}/></>), 'prim-emitter': () => (<><circle cx="12" cy="12" r="3" {...S}/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.5 5.5l2 2M16.5 16.5l2 2M18.5 5.5l-2 2M5.5 18.5l2-2" {...S}/></>),
// Табличка с GUI: прямоугольник с заголовком, иконкой-кружком и кнопкой // Табличка с GUI: прямоугольник с заголовком, иконкой-кружком и кнопкой
'prim-billboard': () => (<><rect x="3" y="5" width="18" height="14" rx="2" {...S}/><circle cx="7" cy="11" r="2" {...S}/><path d="M10.5 9h6M10.5 12h4" {...S}/><rect x="14" y="14.5" width="5" height="3" rx="1" {...S}/></>), 'prim-billboard': () => (<><rect x="3" y="5" width="18" height="14" rx="2" {...S}/><circle cx="7" cy="11" r="2" {...S}/><path d="M10.5 9h6M10.5 12h4" {...S}/><rect x="14" y="14.5" width="5" height="3" rx="1" {...S}/></>),

View File

@ -328,7 +328,6 @@ const InspectorPanel = ({
const [localTint, setLocalTint] = useState(''); const [localTint, setLocalTint] = useState('');
const [localBrightness, setLocalBrightness] = useState(1.5); const [localBrightness, setLocalBrightness] = useState(1.5);
const [localRange, setLocalRange] = useState(12); const [localRange, setLocalRange] = useState(12);
const [localStepCount, setLocalStepCount] = useState(8);
// Синхронизируем локальное состояние когда меняется selection // Синхронизируем локальное состояние когда меняется selection
useEffect(() => { useEffect(() => {
@ -375,8 +374,6 @@ const InspectorPanel = ({
// Параметры лампы // Параметры лампы
setLocalBrightness(selection.brightness ?? 1.5); setLocalBrightness(selection.brightness ?? 1.5);
setLocalRange(selection.range ?? 12); setLocalRange(selection.range ?? 12);
// Параметр лестницы число ступенек (высота).
setLocalStepCount(selection.stepCount ?? 8);
} }
}, [selection]); }, [selection]);
@ -2018,29 +2015,6 @@ const InspectorPanel = ({
</div> </div>
)} )}
{/* Лестница — число ступенек (высота). При изменении лестница перестраивается. */}
{primitiveType?.kind === 'ladder' && (
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="arrow-up" size={12} /> Лестница</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Высота (ступенек)</span>
<span style={{ opacity: 0.6 }}>{Math.round(localStepCount)}</span>
</div>
<input
type="range" min="2" max="30" step="1"
value={localStepCount}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setLocalStepCount(v);
onSetPrimitiveProps?.({ stepCount: v });
}}
style={{ width: '100%' }}
/>
</div>
</div>
)}
{/* Эмиттер частиц — выбор эффекта + цвет */} {/* Эмиттер частиц — выбор эффекта + цвет */}
{primitiveType?.kind === 'emitter' && ( {primitiveType?.kind === 'emitter' && (
<div className={cl.section}> <div className={cl.section}>

View File

@ -4,7 +4,6 @@ import { jwtDecode } from 'jwt-decode';
import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx'; import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx';
import { useSanctions } from '../auth/SanctionsContext.jsx'; import { useSanctions } from '../auth/SanctionsContext.jsx';
import { BabylonScene } from './engine/BabylonScene'; import { BabylonScene } from './engine/BabylonScene';
import { MIXAMO_SKINS } from './engine/PlayerController';
import { StudioCollab } from './engine/StudioCollab'; import { StudioCollab } from './engine/StudioCollab';
import { CollabOverlay } from './engine/CollabOverlay'; import { CollabOverlay } from './engine/CollabOverlay';
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes'; import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
@ -15,7 +14,6 @@ import { getModelThumbnail } from './engine/ModelThumbnails';
import * as Kubikon3DApi from '../api/Kubikon3DService'; import * as Kubikon3DApi from '../api/Kubikon3DService';
import { REALTIME_HTTP } from '../api/API'; import { REALTIME_HTTP } from '../api/API';
import GameSettingsModal from './GameSettingsModal'; import GameSettingsModal from './GameSettingsModal';
import GameDecorModal from './GameDecorModal';
import SkinManagerModal from './SkinManagerModal'; import SkinManagerModal from './SkinManagerModal';
import PublishModal from './PublishModal'; import PublishModal from './PublishModal';
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice'; import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
@ -47,11 +45,6 @@ import cl from './KubikonEditor.module.css';
import Icon from './Icon'; import Icon from './Icon';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
// В десктоп-приложении (Electron-обёртка, см. rublox-desktop) окно и так на
// весь экран без браузерной панели fullscreen НЕ нужен. preload выставляет
// window.__RUBLOX_DESKTOP__. Глушим авто-fullscreen, чтобы не дёргать окно.
const IS_DESKTOP_APP = typeof window !== 'undefined' && !!window.__RUBLOX_DESKTOP__;
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины авто-сохранение const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины авто-сохранение
// Шаблон глобального скрипта. // Шаблон глобального скрипта.
@ -475,98 +468,6 @@ const KubikonEditor = () => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const sceneRef = useRef(null); const sceneRef = useRef(null);
// === Fullscreen редактора ===
// Верхняя панель браузера съедает ~20% экрана. Браузер НЕ даёт включить
// fullscreen автоматически при загрузке (нужен user gesture), поэтому:
// 1) кнопка в шапке;
// 2) автоматический вход при ПЕРВОМ клике пользователя по редактору.
const [isFullscreen, setIsFullscreen] = useState(false);
const fsAutoTriedRef = useRef(false); // авто-вход пробуем только 1 раз
const requestEditorFullscreen = React.useCallback(() => {
try {
const root = document.documentElement;
const req = root.requestFullscreen
|| root.webkitRequestFullscreen
|| root.mozRequestFullScreen
|| root.msRequestFullscreen;
if (req && !document.fullscreenElement) req.call(root).catch(() => {});
} catch (e) { /* юзер запретил — работаем в окне */ }
}, []);
const exitEditorFullscreen = React.useCallback(() => {
try {
const ex = document.exitFullscreen || document.webkitExitFullscreen
|| document.mozCancelFullScreen || document.msExitFullscreen;
if (ex && document.fullscreenElement) ex.call(document).catch?.(() => {});
} catch (e) { /* ignore */ }
}, []);
const toggleEditorFullscreen = React.useCallback(() => {
if (document.fullscreenElement) exitEditorFullscreen();
else requestEditorFullscreen();
}, [requestEditorFullscreen, exitEditorFullscreen]);
// Следим за состоянием fullscreen (кнопка показывает актуальную иконку).
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', onFsChange);
return () => {
document.removeEventListener('fullscreenchange', onFsChange);
document.removeEventListener('webkitfullscreenchange', onFsChange);
};
}, []);
// Автовход в fullscreen при ПЕРВОМ клике/нажатии по редактору. Один раз.
useEffect(() => {
// В десктоп-приложении окно и так на весь экран авто-FS не нужен.
if (IS_DESKTOP_APP) return;
const tryAuto = () => {
if (fsAutoTriedRef.current) return;
fsAutoTriedRef.current = true;
if (!document.fullscreenElement) requestEditorFullscreen();
};
window.addEventListener('pointerdown', tryAuto, { once: true, capture: true });
window.addEventListener('keydown', tryAuto, { once: true, capture: true });
return () => {
window.removeEventListener('pointerdown', tryAuto, { capture: true });
window.removeEventListener('keydown', tryAuto, { capture: true });
};
}, [requestEditorFullscreen]);
// При уходе со страницы редактора выходим из fullscreen.
useEffect(() => () => { exitEditorFullscreen(); }, [exitEditorFullscreen]);
// === Регулируемая граница между «Объекты сцены» и «Свойства» ===
// Доля высоты под список объектов (0.2..0.85). Сохраняем в localStorage.
const [hierFraction, setHierFraction] = useState(() => {
const v = parseFloat(localStorage.getItem('rbxStudioHierFraction'));
return Number.isFinite(v) && v >= 0.2 && v <= 0.85 ? v : 0.5;
});
const rightPanelRef = useRef(null);
const splitDragRef = useRef(false);
useEffect(() => {
const onMove = (e) => {
if (!splitDragRef.current || !rightPanelRef.current) return;
const rect = rightPanelRef.current.getBoundingClientRect();
// Доля от верха панели до курсора (за вычетом верхнего заголовка ~24px).
let f = (e.clientY - rect.top - 24) / Math.max(1, rect.height - 24);
f = Math.max(0.2, Math.min(0.85, f));
setHierFraction(f);
};
const onUp = () => {
if (!splitDragRef.current) return;
splitDragRef.current = false;
document.body.style.cursor = '';
try { localStorage.setItem('rbxStudioHierFraction', String(hierFraction)); } catch (_) {}
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, [hierFraction]);
const startSplitDrag = React.useCallback((e) => {
e.preventDefault();
splitDragRef.current = true;
document.body.style.cursor = 'row-resize';
}, []);
// Team Create клиент совместного редактирования + presence-overlay. // Team Create клиент совместного редактирования + presence-overlay.
const collabRef = useRef(null); const collabRef = useRef(null);
const collabOverlayRef = useRef(null); const collabOverlayRef = useRef(null);
@ -738,37 +639,6 @@ const KubikonEditor = () => {
return () => clearInterval(t); return () => clearInterval(t);
}, [isPlaying]); }, [isPlaying]);
// 2026-06-14: блокировка системных Ctrl-хоткеев во время Play.
// F-клавиши и Ctrl+W/D/T/R/S/A/P/F/U/J/H/L/O/G + Ctrl+1..9 + Ctrl+Tab.
// В fullscreen Chrome даёт preventDefault'иться. WASD-хоткеи
// (Ctrl+W/A/S/D) НЕ stopPropagation PlayerController должен их видеть
// (одновременный crouch+движение).
useEffect(() => {
if (!isPlaying) return;
const onKey = (e) => {
if (e.code === 'F5' || e.code === 'F3' || e.code === 'F6' || e.code === 'F7') {
e.preventDefault(); e.stopPropagation(); return;
}
if (e.ctrlKey || e.metaKey) {
const wasd = ['KeyW', 'KeyA', 'KeyS', 'KeyD'];
if (wasd.includes(e.code)) {
e.preventDefault();
return;
}
const blocked = ['KeyR','KeyT','KeyN','KeyP','KeyU','KeyJ','KeyH',
'KeyF','KeyG','KeyL','KeyO','Tab',
'Digit1','Digit2','Digit3','Digit4','Digit5',
'Digit6','Digit7','Digit8','Digit9'];
if (blocked.includes(e.code)) {
e.preventDefault();
e.stopPropagation();
}
}
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [isPlaying]);
// При выходе из Play сбросим HP к полному (для следующего захода) // При выходе из Play сбросим HP к полному (для следующего захода)
useEffect(() => { useEffect(() => {
if (!isPlaying) { if (!isPlaying) {
@ -948,15 +818,8 @@ const KubikonEditor = () => {
x: px + (p.x || 0), y: py + (p.y != null ? p.y : 1), z: pz + (p.z || 0), x: px + (p.x || 0), y: py + (p.y != null ? p.y : 1), z: pz + (p.z || 0),
sx: p.sx, sy: p.sy, sz: p.sz, sx: p.sx, sy: p.sy, sz: p.sz,
color: p.color, material: p.material, color: p.color, material: p.material,
// canCollide: явный false уважаем; для лестницы оставляем canCollide: p.canCollide !== false, visible: p.visible !== false, anchored: true,
// undefined addInstance применит свой дефолт (false, чтобы
// в неё можно было войти и лезть). Для прочих true.
canCollide: p.canCollide === false ? false
: (p.type === 'ladder_vertical' ? undefined : true),
visible: p.visible !== false, anchored: true,
name: p.name, name: p.name,
// stepCount высота лестницы (только для ladder_vertical).
...(p.stepCount != null ? { stepCount: p.stepCount } : {}),
}); });
if (newId != null) { if (newId != null) {
createdIds.push(newId); createdIds.push(newId);
@ -1036,7 +899,7 @@ const KubikonEditor = () => {
// === Game settings inline в TopRibbon (вкладка Тест) === // === Game settings inline в TopRibbon (вкладка Тест) ===
// Дефолт R15-скин bacon-hair (классический Roblox-вид). // Дефолт R15-скин bacon-hair (классический Roblox-вид).
const [playerModelType, setPlayerModelTypeUI] = useState('skin_y-bot'); const [playerModelType, setPlayerModelTypeUI] = useState('skin_bacon-hair');
const [envPreset, setEnvPresetUI] = useState('day'); const [envPreset, setEnvPresetUI] = useState('day');
const [dayDurationMin, setDayDurationMinUI] = useState(5); const [dayDurationMin, setDayDurationMinUI] = useState(5);
const [nightDurationMin, setNightDurationMinUI] = useState(3); const [nightDurationMin, setNightDurationMinUI] = useState(3);
@ -1062,7 +925,7 @@ const KubikonEditor = () => {
genre: 'other', genre: 'other',
thumbnail: '', thumbnail: '',
is_public: false, is_public: false,
player_model_type: 'skin_y-bot', player_model_type: 'skin_bacon-hair',
}); });
const projectNameRef = useRef(projectName); const projectNameRef = useRef(projectName);
useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]); useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]);
@ -1071,9 +934,6 @@ const KubikonEditor = () => {
// settingsModalOpen настройки игры (Roblox Game Settings) // settingsModalOpen настройки игры (Roblox Game Settings)
// initialModalOpen инициальный диалог при создании новой игры // initialModalOpen инициальный диалог при создании новой игры
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
// Модал «Оформление» (графика/стартовый экран/экран загрузки) из вкладки «Игра».
const [decorModalOpen, setDecorModalOpen] = useState(false);
const [decorSection, setDecorSection] = useState('graphics');
// Задача 07: модал управления скинами проекта + список всех скинов (манифест). // Задача 07: модал управления скинами проекта + список всех скинов (манифест).
const [skinManagerOpen, setSkinManagerOpen] = useState(false); const [skinManagerOpen, setSkinManagerOpen] = useState(false);
const [allSkinsList, setAllSkinsList] = useState([]); const [allSkinsList, setAllSkinsList] = useState([]);
@ -1825,13 +1685,6 @@ const KubikonEditor = () => {
return pd?.scene?.loadingScreen || null; return pd?.scene?.loadingScreen || null;
} catch { return null; } } catch { return null; }
})()) || null, })()) || null,
// Графика/эффекты из scene-JSON (для модала настроек).
graphics: (data.project_data && (() => {
try {
const pd = typeof data.project_data === 'string' ? JSON.parse(data.project_data) : data.project_data;
return pd?.scene?.graphics || null;
} catch { return null; }
})()) || null,
}; };
// Состояние публикации (этап 3) // Состояние публикации (этап 3)
setProjectStatus({ setProjectStatus({
@ -1880,7 +1733,7 @@ const KubikonEditor = () => {
sceneRef.current.history?.initialize(); sceneRef.current.history?.initialize();
// Синхронизируем UI-state TopRibbon из загруженной сцены // Синхронизируем UI-state TopRibbon из загруженной сцены
try { try {
setPlayerModelTypeUI(sceneRef.current.getPlayerModelType?.() || 'skin_y-bot'); setPlayerModelTypeUI(sceneRef.current.getPlayerModelType?.() || 'skin_bacon-hair');
const env = sceneRef.current.getEnvironmentState?.(); const env = sceneRef.current.getEnvironmentState?.();
if (env?.preset) setEnvPresetUI(env.preset); if (env?.preset) setEnvPresetUI(env.preset);
if (env?.dayDurationMin) setDayDurationMinUI(env.dayDurationMin); if (env?.dayDurationMin) setDayDurationMinUI(env.dayDurationMin);
@ -2127,6 +1980,11 @@ const KubikonEditor = () => {
const handleSettingsSave = (data) => { const handleSettingsSave = (data) => {
metaRef.current = { ...metaRef.current, ...data }; metaRef.current = { ...metaRef.current, ...data };
setProjectName(data.title); setProjectName(data.title);
// Задача 12: конфиг экрана загрузки в сцену (попадёт в project_data.scene
// через toJSON). Логотип-дефолт = обложка проекта.
try {
sceneRef.current?.setLoadingConfig?.(data.loading_screen || null, data.thumbnail);
} catch (e) { /* ignore */ }
setSettingsModalOpen(false); setSettingsModalOpen(false);
setInitialModalOpen(false); setInitialModalOpen(false);
if (autoSaveTimerRef.current) { if (autoSaveTimerRef.current) {
@ -2137,28 +1995,6 @@ const KubikonEditor = () => {
doSave(); doSave();
}; };
// Сохранить «Оформление» (графика / стартовый экран / экран загрузки) из
// модала, открытого во вкладке «Игра». Применяем сразу (превью) + в сцену.
const handleDecorSave = (data) => {
try {
sceneRef.current?.setLoadingConfig?.(
data.loading_screen || null, metaRef.current?.thumbnail);
} catch (e) { /* ignore */ }
try {
if (data.graphics) sceneRef.current?.setGraphics?.(data.graphics);
} catch (e) { /* ignore */ }
// запомним в metaRef, чтобы модал открылся с актуальными значениями
metaRef.current = {
...metaRef.current,
loading_screen: data.loading_screen,
graphics: data.graphics,
};
setDecorModalOpen(false);
dirtyRef.current = true;
doSave();
};
const openDecor = (sec) => { setDecorSection(sec); setDecorModalOpen(true); };
// Закрыть инициальный диалог: если пользователь не сохранил возвращаемся в Studio. // Закрыть инициальный диалог: если пользователь не сохранил возвращаемся в Studio.
const handleInitialClose = () => { const handleInitialClose = () => {
setInitialModalOpen(false); setInitialModalOpen(false);
@ -2168,7 +2004,7 @@ const KubikonEditor = () => {
} }
}; };
const handlePlay = async () => { const handlePlay = () => {
const scene = sceneRef.current; const scene = sceneRef.current;
if (!scene) return; if (!scene) return;
if (scene.isPlaying()) { if (scene.isPlaying()) {
@ -2179,48 +2015,6 @@ const KubikonEditor = () => {
// дёргается только на Esc-выход, кнопка Стоп нет. // дёргается только на Esc-выход, кнопка Стоп нет.
hudRef.current?.reset?.(); hudRef.current?.reset?.();
} else { } else {
// 2026-06-14: Перед входом в Play подтягиваем СКИН ЮЗЕРА из БД
// (если ещё не передан в URL #skin=). Источник:
// 1) URL hash #skin=<id> (если уже есть не трогаем)
// 2) БД (rublox_equipped_skin) через /equipped-skin GET
// BabylonScene.enterPlayMode сам прочитает hash, поэтому
// записываем туда найденный скин.
try {
const hasHashSkin = /[#&]skin=/.test(window.location.hash || '');
if (!hasHashSkin) {
const uid = getCurrentUserId();
if (uid) {
const r = await Kubikon3DApi.getEquippedSkin(uid);
let sf = r?.data?.skin_folder;
// ВАЛИДАЦИЯ: legacy R15-скины (bacon-hair и пр.) больше
// не существуют. Если БД отдала невалидный подменяем
// на skin_y-bot (как в плеере и кабинете).
if (sf && typeof sf === 'string'
&& !MIXAMO_SKINS.has(sf)
&& !sf.startsWith('customskin:')) {
console.log('[KubikonEditor] skin', sf, 'не валиден → skin_y-bot');
sf = 'skin_y-bot';
}
if (sf && typeof sf === 'string') {
// Подмешиваем в hash так чтобы не сломать ticket=...
const cur = window.location.hash || '';
const sep = cur && !cur.endsWith('&') ? '&' : '';
const newHash = cur
? `${cur}${sep}skin=${encodeURIComponent(sf)}`
: `#skin=${encodeURIComponent(sf)}`;
// history.replaceState чтобы не сломать react-router
window.history.replaceState(
null, '',
window.location.pathname + window.location.search + newHash,
);
console.log('[KubikonEditor] play skin from DB:', sf);
}
}
}
} catch (e) {
console.warn('[KubikonEditor] equipped-skin fetch failed:', e?.message || e);
}
// Флаш ScriptEditor иначе при печати сразу Play игра пойдёт // Флаш ScriptEditor иначе при печати сразу Play игра пойдёт
// со старым кодом (debounce 600мс ещё не сработал). // со старым кодом (debounce 600мс ещё не сработал).
try { scriptEditorFlushRef.current?.(); } catch (_) {} try { scriptEditorFlushRef.current?.(); } catch (_) {}
@ -2239,22 +2033,6 @@ const KubikonEditor = () => {
scene.setSpawnPoint(sp.x, spawnY, sp.z); scene.setSpawnPoint(sp.x, spawnY, sp.z);
scene.enterPlayMode(); scene.enterPlayMode();
setIsPlaying(true); setIsPlaying(true);
// 2026-06-14: при входе в Play автоматически запрашиваем
// fullscreen иначе Ctrl+W/Ctrl+D случайно закрывают вкладку
// в режиме игры. Это user gesture (клик по кнопке Play),
// поэтому requestFullscreen() разрешён.
// В десктоп-приложении этого риска нет (нет вкладок браузера)
// окно и так на весь экран, FS не нужен.
if (!IS_DESKTOP_APP) try {
const root = document.documentElement;
const req = root.requestFullscreen
|| root.webkitRequestFullscreen
|| root.mozRequestFullScreen
|| root.msRequestFullscreen;
if (req && !document.fullscreenElement) {
req.call(root).catch(() => {});
}
} catch (e) { /* юзер запретил — играем без FS */ }
// Если активен таб скрипта авто-переключение на «Сцена», // Если активен таб скрипта авто-переключение на «Сцена»,
// чтобы пользователь сразу видел игру. // чтобы пользователь сразу видел игру.
setActiveTabId('scene'); setActiveTabId('scene');
@ -2408,18 +2186,6 @@ const KubikonEditor = () => {
</button> </button>
</> </>
)} )}
{/* Полноэкранный режим съедает верхнюю панель браузера.
В десктоп-приложении не нужен (окно и так на весь экран). */}
{!IS_DESKTOP_APP && (
<button
onClick={toggleEditorFullscreen}
title={isFullscreen ? 'Выйти из полноэкранного режима (F11)' : 'На весь экран (F11)'}
className={cl.toolbarBtn}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 38, height: 38, padding: 0, flexShrink: 0 }}
>
<Icon name={isFullscreen ? 'fullscreen-exit' : 'fullscreen'} size={16} />
</button>
)}
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */} {/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
<button <button
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()} onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
@ -2565,9 +2331,6 @@ const KubikonEditor = () => {
}} }}
onSkins={() => setSkinManagerOpen(true)} onSkins={() => setSkinManagerOpen(true)}
onInvite={handleInvite} onInvite={handleInvite}
onGraphics={() => openDecor('graphics')}
onStartScreen={() => openDecor('startscreen')}
onLoadingScreen={() => openDecor('loadingscreen')}
collabActive={collabActive} collabActive={collabActive}
collabPeers={collabPeers} collabPeers={collabPeers}
hasSelection={!!selection} hasSelection={!!selection}
@ -3631,8 +3394,7 @@ const KubikonEditor = () => {
</div> </div>
{/* Правая панель — Hierarchy + Inspector */} {/* Правая панель — Hierarchy + Inspector */}
<aside className={cl.rightPanel} ref={rightPanelRef}> <aside className={cl.rightPanel}>
<div className={cl.rightSection} style={{ flexGrow: hierFraction, flexBasis: 0 }}>
<div className={cl.panelTitle}>Объекты сцены</div> <div className={cl.panelTitle}>Объекты сцены</div>
<HierarchyPanel <HierarchyPanel
blocks={blocksList} blocks={blocksList}
@ -3857,16 +3619,7 @@ const KubikonEditor = () => {
onAssignToFolder={(kind, ref, folderId) => onAssignToFolder={(kind, ref, folderId) =>
sceneRef.current?.assignToFolder(kind, ref, folderId)} sceneRef.current?.assignToFolder(kind, ref, folderId)}
/> />
</div>
{/* Перетаскиваемая граница между списком объектов и свойствами. */}
<div
className={cl.rightSplitter}
onPointerDown={startSplitDrag}
title="Потяните, чтобы изменить высоту"
/>
<div className={cl.rightSection} style={{ flexGrow: (1 - hierFraction), flexBasis: 0 }}>
<div className={cl.panelTitle}>Свойства</div> <div className={cl.panelTitle}>Свойства</div>
<InspectorPanel <InspectorPanel
selection={selection} selection={selection}
@ -4003,7 +3756,6 @@ const KubikonEditor = () => {
markDirty(); markDirty();
}} }}
/> />
</div>
</aside> </aside>
</div> </div>
@ -4036,14 +3788,6 @@ const KubikonEditor = () => {
onSave={handleSettingsSave} onSave={handleSettingsSave}
onCaptureScreenshot={captureSceneScreenshot} onCaptureScreenshot={captureSceneScreenshot}
/> />
{/* Оформление: графика / стартовый экран / экран загрузки (вкладка «Игра») */}
<GameDecorModal
open={decorModalOpen}
section={decorSection}
initial={metaRef.current}
onClose={() => setDecorModalOpen(false)}
onSave={handleDecorSave}
/>
{/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */} {/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */}
<SkinManagerModal <SkinManagerModal
open={skinManagerOpen} open={skinManagerOpen}

View File

@ -31,8 +31,7 @@
font-family: "Roboto Condensed", system-ui, -apple-system, sans-serif; font-family: "Roboto Condensed", system-ui, -apple-system, sans-serif;
display: grid; display: grid;
/* UI уменьшен на ~10% (ближе к Roblox Studio): topbar 56→50, status 28→26. */ grid-template-rows: 56px auto 1fr 28px;
grid-template-rows: 50px auto 1fr 26px;
height: 100vh; height: 100vh;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@ -40,7 +39,7 @@
color: var(--text); color: var(--text);
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
font-size: 13px; font-size: 14px;
} }
.editor *, .editor *,
@ -53,8 +52,8 @@
.topBar { .topBar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
padding: 0 14px; padding: 0 16px;
background: linear-gradient(180deg, var(--bg-dark) 0%, var(--bg-darkest) 100%); background: linear-gradient(180deg, var(--bg-dark) 0%, var(--bg-darkest) 100%);
border-bottom: 2px solid var(--border); border-bottom: 2px solid var(--border);
z-index: 10; z-index: 10;
@ -146,12 +145,12 @@
} }
.toolbarBtn { .toolbarBtn {
padding: 7px 13px; padding: 8px 16px;
background: var(--bg-mid); background: var(--bg-mid);
border: 2px solid var(--border); border: 2px solid var(--border);
border-radius: 6px; border-radius: 6px;
color: var(--text); color: var(--text);
font-size: 12px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-weight: 600; font-weight: 600;
@ -211,9 +210,7 @@
/* === WORKSPACE === */ /* === WORKSPACE === */
.workspace { .workspace {
display: grid; display: grid;
/* Правая панель шире (280320) длинные имена объектов не обрезаются, grid-template-columns: 240px minmax(0, 1fr) 280px;
больше места под список и свойства. Левая чуть уже (240224). */
grid-template-columns: 224px minmax(0, 1fr) 320px;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
} }
@ -236,61 +233,18 @@
.rightPanel { .rightPanel {
border-left: 2px solid var(--border); border-left: 2px solid var(--border);
overflow: hidden; /* секции скроллятся внутри себя, панель — нет */
}
/* Секция правой панели (список объектов / свойства) занимает свою долю
высоты, скроллится независимо. Доля регулируется сплиттером. */
.rightSection {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* Перетаскиваемая граница между списком объектов и свойствами. */
.rightSplitter {
height: 6px;
flex-shrink: 0;
cursor: row-resize;
background: var(--bg-mid);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
position: relative;
transition: background 0.12s;
}
.rightSplitter::before {
/* визуальная «ручка» — три точки по центру */
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 28px;
height: 2px;
border-radius: 2px;
background: var(--text-dim);
opacity: 0.5;
}
.rightSplitter:hover {
background: var(--accent);
}
.rightSplitter:hover::before {
background: #fff;
opacity: 0.9;
} }
.panelTitle { .panelTitle {
padding: 7px 14px; padding: 12px 16px;
font-size: 10px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1.3px; letter-spacing: 1.5px;
color: var(--text-dim); color: var(--text-dim);
background: var(--bg-mid); background: var(--bg-mid);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
font-weight: 800; font-weight: 800;
flex-shrink: 0;
} }
.panelTitle:first-child { .panelTitle:first-child {

View File

@ -234,7 +234,6 @@ const TopRibbon = (props) => {
activeTool, onToolChange, activeTool, onToolChange,
isPlaying, onPlayToggle, onSetSpawn, isPlaying, onPlayToggle, onSetSpawn,
onSkins, onInvite, collabActive, collabPeers, onSkins, onInvite, collabActive, collabPeers,
onGraphics, onStartScreen, onLoadingScreen,
hasSelection, hasSelection,
onDuplicate, onAlignToFloor, onDelete, onDuplicate, onAlignToFloor, onDelete,
onClearScene, onClearScene,
@ -451,25 +450,6 @@ const TopRibbon = (props) => {
/> />
</Group> </Group>
{/* Оформление — графика/эффекты, стартовый экран, экран загрузки. */}
<Group title="Оформление">
<RibbonBtn
iconName="sparkles" label="Графика"
onClick={onGraphics}
title="Графика и эффекты: свечение, цвет, тени (шейдеры)"
/>
<RibbonBtn
iconName="loader" label="Стартовый экран"
onClick={onStartScreen}
title="Стартовый экран входа (Ken Burns): фон, карточка, название"
/>
<RibbonBtn
iconName="loader" label="Экран загрузки"
onClick={onLoadingScreen}
title="Экран загрузки между мирами: логотип, цвет, спиннер"
/>
</Group>
{/* «Окружение» (время суток / амбиент / музыка) и {/* «Окружение» (время суток / амбиент / музыка) и
«Скин игрока» переехали в иерархию объектов сцены: «Скин игрока» переехали в иерархию объектов сцены:
🌞 Освещение / 🎵 Звук / 👤 Игрок. */} 🌞 Освещение / 🎵 Звук / 👤 Игрок. */}

View File

@ -35,8 +35,6 @@ import {
ParticleSystem, ParticleSystem,
Texture, Texture,
Ray, Ray,
Matrix,
HighlightLayer,
PointerEventTypes, PointerEventTypes,
Tools as BabylonTools, Tools as BabylonTools,
ColorCurves, ColorCurves,
@ -100,7 +98,6 @@ import { GdForest } from './GdForest';
import { GdPlayerCube } from './GdPlayerCube'; import { GdPlayerCube } from './GdPlayerCube';
import { GdPlayerTrail } from './GdPlayerTrail'; import { GdPlayerTrail } from './GdPlayerTrail';
import { GdPostFx } from './GdPostFx'; import { GdPostFx } from './GdPostFx';
import { GraphicsManager } from './GraphicsManager';
import { PhysicsAABB } from './PhysicsAABB'; import { PhysicsAABB } from './PhysicsAABB';
import { PlayerController } from './PlayerController'; import { PlayerController } from './PlayerController';
import { SelectionManager } from './SelectionManager'; import { SelectionManager } from './SelectionManager';
@ -200,18 +197,6 @@ export class BabylonScene {
this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания
this._freeDragActive = false; // идёт ли перетаскивание this._freeDragActive = false; // идёт ли перетаскивание
this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий) this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий)
// Рамка выделения (rubber-band / marquee): ЛКМ-drag по пустому месту.
this._marqueeCandidate = null; // {startX,startY,curX,curY,additive}
this._marqueeActive = false; // появилась ли рамка (после сдвига)
this._marqueeEl = null; // DOM-оверлей прямоугольника
// Групповой пивот multi-выделения (для гизмо).
this._multiPivot = null;
this._multiPivotLast = null;
// Hover-подсветка (белый контур при наведении, как в Roblox Studio).
this._hoverLayer = null; // HighlightLayer
this._hoverMeshes = []; // подсвеченные сейчас меши
this._hoverKey = null; // ключ текущего hover-объекта (для throttle)
this._hoverRaf = 0; // requestAnimationFrame id (throttle pick)
this._isDragPlacing = false; // флаг drag-постановки/удаления блоков this._isDragPlacing = false; // флаг drag-постановки/удаления блоков
this._isTerrainBrushing = false; // флаг drag-кисти террейна this._isTerrainBrushing = false; // флаг drag-кисти террейна
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
@ -228,7 +213,7 @@ export class BabylonScene {
// Дефолт — R15-скин bacon-hair (классический Roblox-вид). // Дефолт — R15-скин bacon-hair (классический Roblox-вид).
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет), // 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
// 'character-*' — старые Kenney-модели. // 'character-*' — старые Kenney-модели.
this._playerModelType = 'skin_y-bot'; this._playerModelType = 'skin_bacon-hair';
// Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z.
// По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize().
this._worldHalf = 40; this._worldHalf = 40;
@ -361,13 +346,7 @@ export class BabylonScene {
this.blockManager = new BlockManager(this.scene); this.blockManager = new BlockManager(this.scene);
// При создании нового proto-меша блока — сразу регистрируем его // При создании нового proto-меша блока — сразу регистрируем его
// как shadow caster (если генератор уже создан). // как shadow caster (если генератор уже создан).
// ОПТИМИЗАЦИЯ: на БОЛЬШИХ block-картах (стриминг включён — лабиринты,
// 200к+ блоков) НЕ кастуем тени от блоков. Shadow-map иначе рендерит
// всю видимую геометрию повторно — это и есть причина «idle 220мс/кадр»
// при крошечном render_ms. Точно как с terrain (см. ниже). Блоки всё
// равно ПРИНИМАЮТ тени (receiveShadows на proto), но сами не кастуют.
this.blockManager.setOnProtoCreated((proto) => { this.blockManager.setOnProtoCreated((proto) => {
if (this._blockStreamingEnabled) return;
this.addShadowCaster(proto); this.addShadowCaster(proto);
}); });
@ -1389,18 +1368,6 @@ export class BabylonScene {
// когда родительская scene control включён (мы убрали detachControl). // когда родительская scene control включён (мы убрали detachControl).
this._gizmoLayer = new UtilityLayerRenderer(this.scene); this._gizmoLayer = new UtilityLayerRenderer(this.scene);
// Hover-подсветка: белый контур по краю объекта при наведении мышью
// (как в Roblox Studio). HighlightLayer рисует мягкий outline.
try {
this._hoverLayer = new HighlightLayer('hoverLayer', this.scene, {
blurHorizontalSize: 1.0,
blurVerticalSize: 1.0,
});
// Тонкая, не «неоновая» обводка — ближе к Roblox.
this._hoverLayer.innerGlow = false;
this._hoverLayer.outerGlow = true;
} catch (e) { console.warn('[hover] HighlightLayer init failed', e); }
this._gizmo = new GizmoController(this._gizmoLayer, this.scene); this._gizmo = new GizmoController(this._gizmoLayer, this.scene);
this._gizmo.setMode('select'); // по умолчанию — без манипулятора this._gizmo.setMode('select'); // по умолчанию — без манипулятора
this._gizmo.setSnap(1.0); // снэп для блоков this._gizmo.setSnap(1.0); // снэп для блоков
@ -1414,8 +1381,6 @@ export class BabylonScene {
// Групповая папка — применяем дельту в реальном времени (видно движение). // Групповая папка — применяем дельту в реальном времени (видно движение).
const sel = this.selection?.getSelection?.(); const sel = this.selection?.getSelection?.();
if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode); if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode);
// Multi-выделение (рамка) — двигаем всю группу по дельте пивота.
if (sel && sel.type === 'multi') this._onMultiGizmoDrag(mode);
}); });
// Привязка гизмо к выделенному // Привязка гизмо к выделенному
@ -1586,38 +1551,6 @@ export class BabylonScene {
const decoRadius = Math.max(18, radius * 0.35); const decoRadius = Math.max(18, radius * 0.35);
this.decoManager.updateStreaming(cx, cz, decoRadius); this.decoManager.updateStreaming(cx, cz, decoRadius);
} }
// Чанковый стриминг БЛОКОВ (большие block-карты:
// лабиринты). Радиус больше террейна — высокие стены
// нужно видеть дальше. Регионы вне радиуса скрыты.
if (this._blockStreamingEnabled && this.blockManager?.updateStreaming) {
const blockRadius = Math.max(90, radius * 1.6);
this.blockManager.updateStreaming(cx, cz, blockRadius);
}
}
}
}
// Block-стриминг работает и когда terrain-стриминг ВЫКЛЮЧЕН
// (block-карта без воксельного террейна — как лабиринт).
else if (this._blockStreamingEnabled && this.blockManager?.updateStreaming) {
const nowMs3 = performance.now();
if (nowMs3 - (this._blockStreamingLastUpdate || 0) > 200) {
this._blockStreamingLastUpdate = nowMs3;
let bx, bz;
if (this._isPlaying && this.player && this.player._pos) {
bx = this.player._pos.x; bz = this.player._pos.z;
} else if (this.camera && this.camera.position) {
bx = this.camera.position.x; bz = this.camera.position.z;
}
if (bx !== undefined) {
const px = this._blockStreamingPrevX, pz = this._blockStreamingPrevZ;
const moved = (px === undefined) ||
((bx - px) * (bx - px) + (bz - pz) * (bz - pz) >= 9);
if (moved) {
this._blockStreamingPrevX = bx; this._blockStreamingPrevZ = bz;
const camY = (this.camera && this.camera.position && this.camera.position.y) || 0;
const blockRadius = 90 + Math.max(0, Math.min(40, camY * 0.4));
this.blockManager.updateStreaming(bx, bz, blockRadius);
}
} }
} }
} }
@ -1867,47 +1800,6 @@ export class BabylonScene {
getShadowQuality() { return this._shadowQuality || 'soft'; } getShadowQuality() { return this._shadowQuality || 'soft'; }
/**
* Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager
* (постобработка: bloom/FXAA/виньетка/цветокор/DoF + управление тенями/SSAO).
* По умолчанию ВЫКЛЮЧЕНО вызывается только когда автор настроил графику.
*/
_ensureGraphics() {
if (this._graphics) {
// камера могла смениться (режимы камеры) — синхронизируем
const cam = this.scene?.activeCamera || this.camera;
if (cam) this._graphics.setCamera(cam);
return this._graphics;
}
const cam = this.scene?.activeCamera || this.camera;
if (!this.scene || !cam) return null;
this._graphics = new GraphicsManager(this.scene, cam, this, {
mobile: !!this._isMobileMode,
});
return this._graphics;
}
/** Применить настройки графики. settings: {preset} и/или секции
* (bloom/vignette/grading/dof/ssao/fxaa/shadows). */
setGraphics(settings) {
const g = this._ensureGraphics();
if (!g) return null;
const cfg = g.apply(settings || {});
this._graphicsConfig = cfg;
return cfg;
}
/** Текущая конфигурация графики (для serialize). */
getGraphicsState() {
return this._graphics ? this._graphics.serialize() : (this._graphicsConfig || null);
}
/** Выключить все эффекты. */
disableGraphics() {
if (this._graphics) this._graphics.disableAll();
this._graphicsConfig = null;
}
/** Включить/выключить SSAO пост-эффект (контактные тени). /** Включить/выключить SSAO пост-эффект (контактные тени).
* *
* Используем SSAORenderingPipeline v1 (не v2). v2 требует * Используем SSAORenderingPipeline v1 (не v2). v2 требует
@ -2132,13 +2024,8 @@ export class BabylonScene {
refreshAllShadows() { refreshAllShadows() {
if (!this._shadowGenerator) return; if (!this._shadowGenerator) return;
if (this.blockManager) { if (this.blockManager) {
// ОПТИМИЗАЦИЯ: на БОЛЬШИХ block-картах (стриминг — лабиринты, 200к+
// блоков) НЕ кастуем тени от блоков. Иначе shadow-map рендерит всю
// видимую геометрию ВТОРОЙ раз → idle ~350мс/кадр при render_ms 1.5.
// Блоки всё равно ПРИНИМАЮТ тени (receiveShadows на proto). Точно
// как terrain (он вообще исключён из shadow casters).
if (!this._blockStreamingEnabled && this.blockManager._protoMeshes) {
// Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы // Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы
if (this.blockManager._protoMeshes) {
for (const proto of this.blockManager._protoMeshes.values()) { for (const proto of this.blockManager._protoMeshes.values()) {
this.addShadowCaster(proto); this.addShadowCaster(proto);
} }
@ -2478,15 +2365,9 @@ export class BabylonScene {
// Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания). // Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания).
// Запоминаем объект как кандидата — реальное перетаскивание начнётся // Запоминаем объект как кандидата — реальное перетаскивание начнётся
// в mousemove, если курсор сдвинется (иначе это просто клик-выбор). // в mousemove, если курсор сдвинется (иначе это просто клик-выбор).
// Если ЛКМ попала в ПУСТОЕ место (не объект) — запускаем рамку
// выделения (rubber-band / marquee).
if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) { if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) {
if (this._beginFreeDragCandidate()) { if (this._beginFreeDragCandidate()) {
e.preventDefault(); e.preventDefault();
} else {
// Пусто под курсором → кандидат на рамку выделения.
// Реальная рамка появится в mousemove после сдвига.
this._beginMarqueeCandidate(e);
} }
} }
@ -2521,7 +2402,6 @@ export class BabylonScene {
if (e.button === 2) { if (e.button === 2) {
this._isRotating = true; this._isRotating = true;
this._clearHover(); // прячем hover пока крутим камеру
this._lastMouseX = e.clientX; this._lastMouseX = e.clientX;
this._lastMouseY = e.clientY; this._lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing'; canvas.style.cursor = 'grabbing';
@ -2556,24 +2436,6 @@ export class BabylonScene {
return; return;
} }
// Рамка выделения (marquee): тянем прямоугольник. Активируем после
// небольшого сдвига, чтобы обычный клик по пустому месту (= снять
// выделение) не превращался в рамку.
if (this._marqueeCandidate) {
if (!this._marqueeActive) {
const ddx = Math.abs(e.clientX - this._marqueeCandidate.startClientX);
const ddy = Math.abs(e.clientY - this._marqueeCandidate.startClientY);
if (ddx > 4 || ddy > 4) {
this._marqueeActive = true;
this._showMarqueeBox();
}
}
if (this._marqueeActive) {
this._updateMarqueeBox(e);
return;
}
}
// Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига, // Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига,
// чтобы обычный клик-выбор не превращался в перетаскивание. // чтобы обычный клик-выбор не превращался в перетаскивание.
if (this._freeDragCandidate) { if (this._freeDragCandidate) {
@ -2611,13 +2473,6 @@ export class BabylonScene {
this._updateTerrainBrushPosition(); this._updateTerrainBrushPosition();
} }
// Hover-подсветка (белый контур при наведении). Только инструмент
// «Выделение», не в play, не во время вращения/панорамы камеры.
if (!this._isPlaying && this._activeTool === 'select'
&& !this._isRotating && !this._isPanning && !this._marqueeActive) {
this._scheduleHoverUpdate();
}
if (!this._isRotating && !this._isPanning) return; if (!this._isRotating && !this._isPanning) return;
const dx = e.clientX - this._lastMouseX; const dx = e.clientX - this._lastMouseX;
const dy = e.clientY - this._lastMouseY; const dy = e.clientY - this._lastMouseY;
@ -2639,18 +2494,6 @@ export class BabylonScene {
}; };
const onMouseUp = (e) => { const onMouseUp = (e) => {
// Рамка выделения: завершаем. Если рамку реально тянули — отбираем
// объекты внутри и НЕ обрабатываем как клик (иначе сбросит выбор).
if (this._marqueeCandidate) {
const wasActive = this._marqueeActive;
this._endMarquee(e);
if (wasActive) {
this._mouseDownButton = -1;
return;
}
// Не тянули (просто клик по пустому) — продолжаем обычную
// обработку клика ниже (она снимет выделение).
}
// Free-drag: завершаем перетаскивание. Если объект реально тащили — // Free-drag: завершаем перетаскивание. Если объект реально тащили —
// фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор). // фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор).
if (this._freeDragCandidate) { if (this._freeDragCandidate) {
@ -2903,15 +2746,12 @@ export class BabylonScene {
canvas.addEventListener('mousedown', onMouseDown, true); canvas.addEventListener('mousedown', onMouseDown, true);
canvas.addEventListener('wheel', onWheel, { passive: false, capture: true }); canvas.addEventListener('wheel', onWheel, { passive: false, capture: true });
canvas.addEventListener('contextmenu', onContextMenu, true); canvas.addEventListener('contextmenu', onContextMenu, true);
// Курсор ушёл с canvas → снять hover-подсветку.
const onCanvasLeave = () => this._clearHover();
// mousemove/mouseup на window — для drag за пределами canvas. // mousemove/mouseup на window — для drag за пределами canvas.
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp); window.addEventListener('mouseup', onMouseUp);
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp); window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur); window.addEventListener('blur', onBlur);
canvas.addEventListener('mouseleave', onCanvasLeave);
this._listeners = [ this._listeners = [
{ target: canvas, type: 'mousedown', fn: onMouseDown, opts: true }, { target: canvas, type: 'mousedown', fn: onMouseDown, opts: true },
@ -2922,7 +2762,6 @@ export class BabylonScene {
{ target: window, type: 'keydown', fn: onKeyDown }, { target: window, type: 'keydown', fn: onKeyDown },
{ target: window, type: 'keyup', fn: onKeyUp }, { target: window, type: 'keyup', fn: onKeyUp },
{ target: window, type: 'blur', fn: onBlur }, { target: window, type: 'blur', fn: onBlur },
{ target: canvas, type: 'mouseleave', fn: onCanvasLeave },
]; ];
} }
@ -4123,58 +3962,6 @@ export class BabylonScene {
if (this._onSceneChange) this._onSceneChange(); if (this._onSceneChange) this._onSceneChange();
} }
// ── Групповой гизмо для multi-выделения (рамка) ─────────────────────────
// По аналогии с папкой: пивот в центре группы, drag двигает/вращает/
// масштабирует пивот, дельта применяется ко всем объектам через
// selection.moveMultiBy. Сейчас поддержан move (перемещение группы) —
// самая нужная операция; rotate/scale для произвольного multi сложнее
// (блоки на сетке) и пока сводятся к move.
/** Создать пивот-узел в центре multi-группы и привязать к нему gizmo. */
_attachMultiGizmo(center) {
try {
if (this._multiPivot) { this._multiPivot.dispose(); this._multiPivot = null; }
const pivot = new TransformNode('multiPivot', this.scene);
pivot.position = new Vector3(center.x, center.y, center.z);
pivot.rotation = new Vector3(0, 0, 0);
pivot.scaling = new Vector3(1, 1, 1);
this._multiPivot = pivot;
this._multiPivotLast = { x: center.x, y: center.y, z: center.z };
if (this._gizmo) {
this._gizmo.attachTo(pivot);
this._gizmo.refreshMode();
}
} catch (e) { console.warn('[multiGizmo] attach failed', e); }
}
/** Инкрементально применить движение пивота к объектам группы во время drag. */
_onMultiGizmoDrag(mode) {
const pivot = this._multiPivot;
const last = this._multiPivotLast;
if (!pivot || !last || !this.selection) return;
if (mode === 'move') {
const dx = pivot.position.x - last.x;
const dy = pivot.position.y - last.y;
const dz = pivot.position.z - last.z;
// Блоки двигаются по сетке (целые клетки) — копим дробный остаток,
// чтобы при медленном drag блоки тоже сдвигались на целые числа.
if (dx || dy || dz) {
this.selection.moveMultiBy(dx, dy, dz);
last.x = pivot.position.x; last.y = pivot.position.y; last.z = pivot.position.z;
}
}
// rotate/scale для произвольного multi не применяем (см. комментарий выше).
}
/** dragEnd: добираем остаток дельты и пересоздаём пивот в новом центре. */
_applyMultiGizmo(mode) {
if (!this.selection) return;
this._onMultiGizmoDrag(mode);
const c = this.selection.getMultiCenter();
if (c) this._attachMultiGizmo(c);
if (this._onSceneChange) this._onSceneChange();
}
/** /**
* Обновить гизмо под текущее выделение. * Обновить гизмо под текущее выделение.
*/ */
@ -4185,11 +3972,6 @@ export class BabylonScene {
try { this._folderPivot.dispose(); } catch (e) {} try { this._folderPivot.dispose(); } catch (e) {}
this._folderPivot = null; this._folderPivotId = null; this._folderPivot = null; this._folderPivotId = null;
} }
// Сменилось выделение и это НЕ multi → убрать пивот multi.
if ((!sel || sel.type !== 'multi') && this._multiPivot) {
try { this._multiPivot.dispose(); } catch (e) {}
this._multiPivot = null; this._multiPivotLast = null;
}
if (!sel) { if (!sel) {
this._gizmo.attachTo(null); this._gizmo.attachTo(null);
return; return;
@ -4204,14 +3986,6 @@ export class BabylonScene {
} else if (sel.type === 'folder') { } else if (sel.type === 'folder') {
// Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo). // Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo).
if (this._folderPivot) this._gizmo.attachTo(this._folderPivot); if (this._folderPivot) this._gizmo.attachTo(this._folderPivot);
} else if (sel.type === 'multi') {
// Multi (рамка) — привязан к пивоту группы. Если пивота ещё нет
// (multi выставлен не из рамки, напр. Ctrl+клик) — создаём его.
if (!this._multiPivot) {
const c = this.selection.getMultiCenter?.();
if (c) { this._attachMultiGizmo(c); return; }
}
if (this._multiPivot) this._gizmo.attachTo(this._multiPivot);
} }
// Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale)
// гарантированно пересоздалась поверх нового attached-mesh. // гарантированно пересоздалась поверх нового attached-mesh.
@ -4253,10 +4027,6 @@ export class BabylonScene {
this._applyFolderGizmo(mode); this._applyFolderGizmo(mode);
return; return;
} }
if (sel.type === 'multi') {
this._applyMultiGizmo(mode);
return;
}
if (sel.type === 'block') { if (sel.type === 'block') {
if (mode === 'move') { if (mode === 'move') {
// Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ)
@ -5688,90 +5458,9 @@ export class BabylonScene {
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
* Модель: создаёт копию со смещением +1 по X. * Модель: создаёт копию со смещением +1 по X.
*/ */
/**
* Дублировать всё multi-выделение (Ctrl+D над рамкой). Модели/примитивы/
* user-модели копируются РОВНО на месте оригиналов (как в Roblox Studio,
* дубль сразу можно тащить). Блоки в свободную клетку рядом (нельзя
* наложить два блока в одну клетку). По завершении дубли становятся новым
* multi-выделением.
*/
async _duplicateMulti() {
const items = this.selection?.getMultiSelection?.() || [];
if (!items.length) return;
const newSel = [];
for (const it of items) {
try {
if (it.kind === 'block') {
const { x, y, z } = it.ref;
const typeId = this.blockManager?.blocks.get(`${x},${y},${z}`)?.metadata?.blockTypeId;
if (typeId == null) continue;
const cands = [[1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0]];
for (const [dx, dy, dz] of cands) {
const nx = x + dx, ny = y + dy, nz = z + dz;
if (ny < 0) continue;
if (!this.blockManager.hasBlock(nx, ny, nz)) {
this.blockManager.addBlock(nx, ny, nz, typeId);
this._copyScriptsToNewObject('block', { x, y, z }, { x: nx, y: ny, z: nz });
newSel.push({ kind: 'block', ref: { x: nx, y: ny, z: nz } });
break;
}
}
} else if (it.kind === 'primitive') {
const d = this.primitiveManager?.instances.get(it.ref);
if (!d) continue;
const newId = this.primitiveManager.addInstance(d.type, {
x: d.x, y: d.y, z: d.z, sx: d.sx, sy: d.sy, sz: d.sz,
rotationX: d.rotationX || 0, rotationY: d.rotationY || 0, rotationZ: d.rotationZ || 0,
color: d.color, material: d.material,
canCollide: d.canCollide, visible: d.visible, anchored: d.anchored,
textureAsset: d.textureAsset || null,
brightness: d.brightness, range: d.range, effect: d.effect,
});
if (newId != null) {
this._copyScriptsToNewObject('primitive', it.ref, newId);
newSel.push({ kind: 'primitive', ref: newId });
}
} else if (it.kind === 'model') {
const d = this.modelManager?.instances.get(it.ref);
if (!d) continue;
const newId = await this.modelManager.addInstance(d.modelTypeId, d.x, d.y, d.z, d.rotationY || 0);
if (newId != null) {
this._copyScriptsToNewObject('model', it.ref, newId);
newSel.push({ kind: 'model', ref: newId });
}
} else if (it.kind === 'userModel') {
const d = this.userModelManager?.instances.get(it.ref);
if (!d) continue;
const newId = await this.userModelManager.addInstance(
d.userModelTypeId, d.x, d.y, d.z, d.rotationY || 0,
{ currentUserId: this._currentUserId || null });
if (newId != null) {
this._copyScriptsToNewObject('userModel', it.ref, newId);
newSel.push({ kind: 'userModel', ref: newId });
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[BabylonScene] duplicate multi item error:', err);
}
}
// Выделяем дубли как новую группу.
if (newSel.length && this.selection) {
this.selection.setMultiSelection(newSel, false);
const c = this.selection.getMultiCenter?.();
if (c) this._attachMultiGizmo(c);
}
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
duplicateSelected() { duplicateSelected() {
const sel = this.selection?.getSelection(); const sel = this.selection?.getSelection();
if (!sel) return; if (!sel) return;
if (sel.type === 'multi') {
this._duplicateMulti();
return;
}
if (sel.type === 'block') { if (sel.type === 'block') {
// Ищем свободную клетку рядом // Ищем свободную клетку рядом
const candidates = [ const candidates = [
@ -6127,18 +5816,6 @@ export class BabylonScene {
const m = pick.mesh; const m = pick.mesh;
if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false; if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false;
if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо
// Если есть multi-выделение и кликнули по объекту ВНУТРИ него —
// тащим всю группу (а не пере-выбираем один объект).
const curSel = this.selection?.getSelection?.();
if (curSel?.type === 'multi' && this._meshInMultiSelection(m)) {
const c = this.selection.getMultiCenter();
this._freeDragCandidate = { multi: true, last: { ...(c || { x: 0, y: 0, z: 0 }) } };
this._freeDragHalf = { x: 0.5, y: 0.5, z: 0.5 };
this._freeDragActive = false;
return true;
}
// Выбираем объект (резолв mesh→тип внутри selection). // Выбираем объект (резолв mesh→тип внутри selection).
this.selection?.selectByMesh(m); this.selection?.selectByMesh(m);
const sel = this.selection?.getSelection(); const sel = this.selection?.getSelection();
@ -6183,23 +5860,6 @@ export class BabylonScene {
return; return;
} }
// Multi (рамка): тащим всю группу по дельте центра в горизонтальной
// плоскости (как папку). Гизмо-пивот пересоздадим в _endFreeDrag.
if (cand.multi) {
const ray = this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.scene.activeCamera);
if (Math.abs(ray.direction.y) < 1e-4) return;
const t = (cand.last.y - ray.origin.y) / ray.direction.y;
if (t < 0) return;
const px = ray.origin.x + ray.direction.x * t;
const pz = ray.origin.z + ray.direction.z * t;
const dx = px - cand.last.x, dz = pz - cand.last.z;
if (dx || dz) {
this.selection?.moveMultiBy(dx, 0, dz);
cand.last.x = px; cand.last.z = pz;
}
return;
}
const root = cand.root; const root = cand.root;
const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 }; const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 };
@ -6254,306 +5914,16 @@ export class BabylonScene {
/** Завершить free-drag, зафиксировать изменение в истории. */ /** Завершить free-drag, зафиксировать изменение в истории. */
_endFreeDrag() { _endFreeDrag() {
const wasActive = this._freeDragActive; const wasActive = this._freeDragActive;
const wasMulti = this._freeDragCandidate?.multi;
this._freeDragCandidate = null; this._freeDragCandidate = null;
this._freeDragActive = false; this._freeDragActive = false;
this._freeDragHalf = null; this._freeDragHalf = null;
if (wasActive) { if (wasActive) {
// После перетаскивания multi-группы — пересоздать пивот гизмо в новом центре.
if (wasMulti && this.selection) {
const c = this.selection.getMultiCenter();
if (c) this._attachMultiGizmo(c);
}
this.history?.markChange(); this.history?.markChange();
if (this._onSceneChange) this._onSceneChange(); if (this._onSceneChange) this._onSceneChange();
} }
return wasActive; return wasActive;
} }
/** Проверить, принадлежит ли mesh одному из объектов в multi-выделении. */
_meshInMultiSelection(mesh) {
if (!this.selection) return false;
const multi = this.selection.getMultiSelection?.() || [];
if (!multi.length) return false;
const md = mesh.metadata || {};
let kind = null, ref = null;
if (md.isBlock) { kind = 'block'; ref = { x: md.gridX, y: md.gridY, z: md.gridZ }; }
else if (md.isModel) { kind = 'model'; ref = md.instanceId; }
else if (md.isPrimitive) { kind = 'primitive'; ref = md.primitiveId; }
else if (md.isUserModel) { kind = 'userModel'; ref = md.instanceId; }
else return false;
return multi.some(it => {
if (it.kind !== kind) return false;
if (kind === 'block') return it.ref.x === ref.x && it.ref.y === ref.y && it.ref.z === ref.z;
return it.ref === ref;
});
}
// ── Рамка выделения (rubber-band / marquee) ─────────────────────────────
// ЛКМ зажата на ПУСТОМ месте (не на объекте) при tool=select → тянем
// прямоугольник. Все объекты, чей ЦЕНТР (экранная проекция позиции)
// попадает в прямоугольник — выделяются (multi-select). Пол не выделяется.
/** Запомнить старт рамки. Реальная рамка появится после сдвига курсора. */
_beginMarqueeCandidate(e) {
this._clearHover(); // не держим белый контур во время рамки
const r = this.canvas.getBoundingClientRect();
this._marqueeCandidate = {
startClientX: e.clientX,
startClientY: e.clientY,
// Координаты относительно canvas (для проекции и оверлея).
startX: e.clientX - r.left,
startY: e.clientY - r.top,
curX: e.clientX - r.left,
curY: e.clientY - r.top,
additive: e.ctrlKey || e.metaKey, // Ctrl — добавить к текущему выделению
};
this._marqueeActive = false;
}
/** Создать DOM-оверлей прямоугольника поверх канваса. */
_showMarqueeBox() {
if (!this._marqueeEl) {
const el = document.createElement('div');
el.style.cssText = [
'position:absolute', 'pointer-events:none', 'z-index:50',
'border:1px solid #38d957',
'background:rgba(56,217,87,0.15)',
'box-shadow:0 0 0 1px rgba(0,0,0,0.25) inset',
'left:0', 'top:0', 'width:0', 'height:0',
].join(';');
// Вставляем в родителя канваса (он position:relative в редакторе).
const parent = this.canvas.parentElement || document.body;
parent.appendChild(el);
this._marqueeEl = el;
}
this._marqueeEl.style.display = 'block';
}
/** Обновить размеры оверлея под текущее положение курсора. */
_updateMarqueeBox(e) {
const cand = this._marqueeCandidate;
if (!cand || !this._marqueeEl) return;
const r = this.canvas.getBoundingClientRect();
cand.curX = e.clientX - r.left;
cand.curY = e.clientY - r.top;
const x0 = Math.min(cand.startX, cand.curX);
const y0 = Math.min(cand.startY, cand.curY);
const w = Math.abs(cand.curX - cand.startX);
const h = Math.abs(cand.curY - cand.startY);
const el = this._marqueeEl;
// Оверлей позиционируется относительно canvas.parentElement, поэтому
// добавляем offset канваса внутри родителя.
el.style.left = (this.canvas.offsetLeft + x0) + 'px';
el.style.top = (this.canvas.offsetTop + y0) + 'px';
el.style.width = w + 'px';
el.style.height = h + 'px';
}
/** Спроецировать мировую точку в экранные координаты канваса (или null если за камерой). */
_projectToScreen(x, y, z) {
const engine = this.engine;
const w = engine.getRenderWidth();
const h = engine.getRenderHeight();
const p = Vector3.Project(
new Vector3(x, y, z),
Matrix.Identity(),
this.scene.getTransformMatrix(),
{ x: 0, y: 0, width: w, height: h }
);
// p.z вне [0,1] → точка за ближней/дальней плоскостью (за спиной камеры).
if (p.z < 0 || p.z > 1) return null;
return { x: p.x, y: p.y };
}
/** Собрать все выделяемые объекты сцены с их центрами. */
_collectSelectableObjects() {
const out = [];
if (this.blockManager) {
for (const mesh of this.blockManager.blocks.values()) {
const md = mesh.metadata;
if (!md?.isBlock) continue;
if (md.locked) continue;
out.push({ kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ },
cx: md.gridX, cy: md.gridY + 0.5, cz: md.gridZ });
}
}
if (this.modelManager) {
for (const [id, d] of this.modelManager.instances) {
if (d.locked) continue;
out.push({ kind: 'model', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
}
}
if (this.primitiveManager) {
for (const [id, d] of this.primitiveManager.instances) {
if (d.locked) continue;
out.push({ kind: 'primitive', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
}
}
if (this.userModelManager) {
for (const [id, d] of this.userModelManager.instances) {
if (d.locked) continue;
out.push({ kind: 'userModel', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
}
}
return out;
}
/** Завершить рамку: отобрать объекты внутри и выставить multi-select. */
_endMarquee(e) {
const cand = this._marqueeCandidate;
const wasActive = this._marqueeActive;
this._marqueeCandidate = null;
this._marqueeActive = false;
if (this._marqueeEl) this._marqueeEl.style.display = 'none';
if (!wasActive || !cand) return;
const minX = Math.min(cand.startX, cand.curX);
const maxX = Math.max(cand.startX, cand.curX);
const minY = Math.min(cand.startY, cand.curY);
const maxY = Math.max(cand.startY, cand.curY);
const objs = this._collectSelectableObjects();
const picked = [];
for (const o of objs) {
const s = this._projectToScreen(o.cx, o.cy, o.cz);
if (!s) continue;
if (s.x >= minX && s.x <= maxX && s.y >= minY && s.y <= maxY) {
picked.push({ kind: o.kind, ref: o.ref });
}
}
if (!this.selection) return;
// Ctrl при старте рамки → добавляем к уже выделенному, иначе заменяем.
this.selection.setMultiSelection(picked, cand.additive);
// Привязать групповой гизмо если выбрано >1.
const sel = this.selection.getSelection();
if (sel?.type === 'multi') {
const c = this.selection.getMultiCenter();
if (c) this._attachMultiGizmo(c);
}
this.history?.markChange();
}
// ── Hover-подсветка (белый контур при наведении, как Roblox Studio) ──────
// Наводим мышь на объект → подсвечиваем его белым контуром. Если объект
// в папке — подсвечиваем ВСЮ папку (все её меши), как в Roblox Studio.
/** Запланировать обновление hover на следующий кадр (throttle дорогого pick). */
_scheduleHoverUpdate() {
if (this._hoverRaf) return;
this._hoverRaf = requestAnimationFrame(() => {
this._hoverRaf = 0;
this._updateHover();
});
}
/** Определить набор мешей под курсором для подсветки + уникальный ключ. */
_resolveHoverTarget() {
const pick = this._pickFromMouse();
if (!pick || !pick.mesh) return null;
const m = pick.mesh;
// Пол / сетка / ghost / террейн — не подсвечиваем.
if (m === this._ghostMesh) return null;
if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return null;
const md = m.metadata || {};
if (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain) return null;
// Определяем тип объекта + его folderId (как в SelectionManager.selectByMesh).
let kind = null, id = null, folderId = null;
if (md.isBlock) {
kind = 'block'; id = `${md.gridX},${md.gridY},${md.gridZ}`;
folderId = md.folderId ?? null;
} else if (md.isModel) {
kind = 'model'; id = md.instanceId;
folderId = this.modelManager?.instances.get(id)?.folderId ?? null;
} else if (md.isUserModel) {
kind = 'userModel'; id = md.instanceId;
folderId = this.userModelManager?.instances.get(id)?.folderId ?? null;
} else if (md.isPrimitive) {
kind = 'primitive'; id = md.primitiveId;
folderId = this.primitiveManager?.instances.get(id)?.folderId ?? null;
} else {
return null;
}
// Объект в папке → подсвечиваем всю папку.
if (folderId != null && this.folderManager) {
const g = this.folderManager.getFolderObjects(folderId);
const meshes = [];
for (const mesh of g.meshes) this._collectMeshTree(mesh, meshes);
// Блоки папки (у getFolderObjects блоки в g.blocks).
for (const bm of (g.blocks || [])) this._collectMeshTree(bm, meshes);
return { key: `folder:${folderId}`, meshes };
}
// Одиночный объект → собираем его меши.
const meshes = [];
if (kind === 'block') {
this._collectMeshTree(m, meshes);
} else if (kind === 'model') {
const d = this.modelManager?.instances.get(id);
for (const cm of (d?.clonedMeshes || [])) this._collectMeshTree(cm, meshes);
} else if (kind === 'userModel') {
const d = this.userModelManager?.instances.get(id);
for (const um of (d?.meshes || [])) this._collectMeshTree(um, meshes);
} else if (kind === 'primitive') {
const d = this.primitiveManager?.instances.get(id);
if (d?.mesh) this._collectMeshTree(d.mesh, meshes);
}
return { key: `${kind}:${id}`, meshes };
}
/** Добавить mesh и его дочерние меши (только реальные Mesh с геометрией). */
_collectMeshTree(node, out) {
if (!node) return;
// HighlightLayer.addMesh работает только с настоящими Mesh (не TransformNode).
if (typeof node.getClassName === 'function'
&& (node.getClassName() === 'Mesh' || node.getClassName() === 'InstancedMesh')) {
if (node.getTotalVertices?.() > 0) out.push(node);
}
const kids = node.getChildMeshes?.(false) || [];
for (const k of kids) {
if (k.getTotalVertices?.() > 0) out.push(k);
}
}
/** Обновить hover-подсветку под текущим положением курсора. */
_updateHover() {
if (!this._hoverLayer || this._isPlaying || this._activeTool !== 'select') {
this._clearHover();
return;
}
const target = this._resolveHoverTarget();
if (!target || !target.meshes.length) {
this._clearHover();
return;
}
// Тот же объект — ничего не меняем (throttle лишних addMesh).
if (target.key === this._hoverKey) return;
this._clearHover();
const WHITE = new Color3(1, 1, 1);
const seen = new Set();
for (const mesh of target.meshes) {
if (seen.has(mesh)) continue;
seen.add(mesh);
try { this._hoverLayer.addMesh(mesh, WHITE); this._hoverMeshes.push(mesh); }
catch (e) { /* некоторые меши нельзя добавить — игнор */ }
}
this._hoverKey = target.key;
}
/** Снять hover-подсветку. */
_clearHover() {
if (this._hoverLayer && this._hoverMeshes.length) {
for (const mesh of this._hoverMeshes) {
try { this._hoverLayer.removeMesh(mesh); } catch (e) { /* ignore */ }
}
}
this._hoverMeshes = [];
this._hoverKey = null;
}
// ── Небо (задача 16) — обёртки для game-API и UI редактора ────────────── // ── Небо (задача 16) — обёртки для game-API и UI редактора ──────────────
setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); } setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); }
setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); } setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); }
@ -6777,30 +6147,9 @@ export class BabylonScene {
// Запускаем фоновую музыку и амбиент // Запускаем фоновую музыку и амбиент
this.audioManager?.start(); this.audioManager?.start();
// Создаём PlayerController и стартуем. // Создаём PlayerController и стартуем
// 2026-06-14: В тест-режиме студии (Play) персонаж = СКИН ЮЗЕРА,
// а не из настроек проекта. Источник:
// 1) hash #skin=<id> (передаёт сайт при openStudio)
// 2) localStorage 'rublox_selected_skin' (если открыли студию напрямую)
// 3) _playerModelType из настроек проекта (фолбэк)
let userSkin = null;
try {
const m = (typeof window !== 'undefined' ? window.location.hash : '')
.match(/[#&]skin=([\w-]+)/);
if (m && m[1]) userSkin = m[1];
else if (typeof localStorage !== 'undefined') {
const ls = localStorage.getItem('rublox_selected_skin');
if (ls && typeof ls === 'string') userSkin = ls;
}
} catch (e) {}
const finalSkin = userSkin || this._playerModelType;
// eslint-disable-next-line no-console
console.log('[BabylonScene] play skin:',
'project=' + this._playerModelType,
'user=' + (userSkin || 'none'),
'→ final=' + finalSkin);
this.player = new PlayerController(this.scene, this.canvas, this.physics, this); this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
this.player.setModelType(finalSkin); this.player.setModelType(this._playerModelType);
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
try { try {
this.modalManager?.attachPlayer?.(this.player); this.modalManager?.attachPlayer?.(this.player);
@ -8340,23 +7689,7 @@ export class BabylonScene {
serialize() { serialize() {
// Принадлежность объектов папкам — серилизуется в их собственных // Принадлежность объектов папкам — серилизуется в их собственных
// данных (folderId), а сами папки в отдельном массиве. // данных (folderId), а сами папки в отдельном массиве.
// БЛОКИ: для БОЛЬШИХ карт (лабиринты, 200к+ блоков) — RLE-формат const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : [];
// (×20-30 меньше, async-загрузка по чанкам без фриза). RLE не хранит
// folderId на блоках (для процедурных карт он не нужен — все null);
// если на блоках есть реальные folderId — остаёмся на плоском массиве.
let blocksWithFolders;
const blockCount = this.blockManager ? this.blockManager.count() : 0;
let blocksHaveFolders = false;
if (this.blockManager && blockCount > 5000 && typeof this.blockManager.serializeRLE === 'function') {
for (const mesh of this.blockManager.blocks.values()) {
if (mesh?.metadata?.folderId != null) { blocksHaveFolders = true; break; }
}
}
if (this.blockManager && blockCount > 5000 && !blocksHaveFolders
&& typeof this.blockManager.serializeRLE === 'function') {
blocksWithFolders = this.blockManager.serializeRLE(); // {format:'blocks-rle-v1',...}
} else {
blocksWithFolders = this.blockManager ? this.blockManager.serialize() : [];
// BlockManager.serialize не знает про folderId — добавляем его поверх. // BlockManager.serialize не знает про folderId — добавляем его поверх.
if (this.blockManager) { if (this.blockManager) {
for (const item of blocksWithFolders) { for (const item of blocksWithFolders) {
@ -8364,7 +7697,6 @@ export class BabylonScene {
item.folderId = mesh?.metadata?.folderId ?? null; item.folderId = mesh?.metadata?.folderId ?? null;
} }
} }
}
const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : []; const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : [];
if (this.modelManager) { if (this.modelManager) {
// Дописываем instanceId + folderId поверх стандартной сериализации // Дописываем instanceId + folderId поверх стандартной сериализации
@ -8473,7 +7805,6 @@ export class BabylonScene {
crosshair: this._crosshair || 'dot', crosshair: this._crosshair || 'dot',
shadowQuality: this._shadowQuality || 'soft', shadowQuality: this._shadowQuality || 'soft',
environment: this.environment ? this.environment.serialize() : null, environment: this.environment ? this.environment.serialize() : null,
graphics: this.getGraphicsState(),
// Кастомные настройки света — слайдеры из «Свет и атмосфера» // Кастомные настройки света — слайдеры из «Свет и атмосфера»
lighting: { lighting: {
sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8, sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8,
@ -8571,29 +7902,9 @@ export class BabylonScene {
this.setShadowQuality(state.scene.shadowQuality); this.setShadowQuality(state.scene.shadowQuality);
} }
// Блоки — синхронно. Для БОЛЬШИХ block-карт (лабиринты и т.п.) включаем // Блоки — синхронно
// чанковый стриминг: блоки бьются на регионы 48×48, дальние скрываются if (this.blockManager && Array.isArray(state.scene.blocks)) {
// по радиусу вокруг камеры/игрока (см. blockManager.updateStreaming в this.blockManager.loadFromArray(state.scene.blocks);
// onBeforeRender). Иначе 200к+ блоков рендерятся все сразу → FPS висит.
// 48 (а не 32) — баланс: меньше proto-мешей/draw-call, скрытие ~75%.
// Поддерживаем 2 формата блоков (как террейн):
// 1. Legacy: blocks = [{x,y,z,type}, ...] — малые карты
// 2. RLE: blocks = {format:'blocks-rle-v1', palette, chunks, props}
// — большие карты (лабиринты), ×20-30 меньше, async без фриза
const bs = state.scene.blocks;
if (this.blockManager && bs && bs.format === 'blocks-rle-v1') {
// RLE-карта всегда большая → стриминг + тени-OFF
if (this.blockManager.enableStreaming) {
this.blockManager.enableStreaming(48);
this._blockStreamingEnabled = true;
}
await this.blockManager.loadFromRLE(bs);
} else if (this.blockManager && Array.isArray(bs)) {
if (bs.length >= 5000 && this.blockManager.enableStreaming) {
this.blockManager.enableStreaming(48);
this._blockStreamingEnabled = true;
}
this.blockManager.loadFromArray(bs);
} }
// Террейн (voxel-ландшафт). Поддерживаем 2 формата: // Террейн (voxel-ландшафт). Поддерживаем 2 формата:
@ -8939,7 +8250,7 @@ export class BabylonScene {
if (state.scene.playerModelType) { if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType; const pmt = state.scene.playerModelType;
if (pmt.startsWith('character-')) { if (pmt.startsWith('character-')) {
this._playerModelType = 'skin_y-bot'; this._playerModelType = 'skin_bacon-hair';
} else { } else {
this._playerModelType = pmt; this._playerModelType = pmt;
} }
@ -8987,12 +8298,6 @@ export class BabylonScene {
if (state.scene.environment && this.environment) { if (state.scene.environment && this.environment) {
this.environment.load(state.scene.environment); this.environment.load(state.scene.environment);
} }
// Графика/эффекты (шейдеры). Применяем только если автор что-то настроил
// и это не 'off' — иначе не трогаем рендер (чистая картинка, дефолт).
if (state.scene.graphics && state.scene.graphics.preset
&& state.scene.graphics.preset !== 'off') {
try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ }
}
// Кастомные настройки света/цветокоррекции — применяем через // Кастомные настройки света/цветокоррекции — применяем через
// setLightingProps (он сам подхватит default-ы если значения нет). // setLightingProps (он сам подхватит default-ы если значения нет).
if (state.scene.lighting) { if (state.scene.lighting) {
@ -9256,23 +8561,6 @@ export class BabylonScene {
try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ } try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ }
this._gizmoLayer = null; this._gizmoLayer = null;
} }
if (this._multiPivot) {
try { this._multiPivot.dispose(); } catch (e) { /* ignore */ }
this._multiPivot = null;
}
if (this._marqueeEl) {
try { this._marqueeEl.remove(); } catch (e) { /* ignore */ }
this._marqueeEl = null;
}
if (this._hoverRaf) {
try { cancelAnimationFrame(this._hoverRaf); } catch (e) { /* ignore */ }
this._hoverRaf = 0;
}
if (this._hoverLayer) {
try { this._hoverLayer.dispose(); } catch (e) { /* ignore */ }
this._hoverLayer = null;
this._hoverMeshes = [];
}
if (this.selection) { if (this.selection) {
this.selection.dispose(); this.selection.dispose();
this.selection = null; this.selection = null;

View File

@ -3252,10 +3252,6 @@ export class GameRuntime {
} catch (e) {} } catch (e) {}
return; return;
} }
if (cmd === 'graphics.set') {
try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {}
return;
}
if (cmd === 'player.setCrouch') { if (cmd === 'player.setCrouch') {
const player = this.scene3d?.player; const player = this.scene3d?.player;
if (player) { if (player) {

View File

@ -35,13 +35,6 @@ export const GAMEPLAY_KITS = [
game.onKey('shift', () => game.player.setSpeed(1.8)); game.onKey('shift', () => game.player.setSpeed(1.8));
game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }], game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }],
}, },
{
id: 'ladder-climb',
name: 'Лестница (лазание)',
desc: 'Вертикальная лестница — подойди и жми W чтобы лезть вверх, S — вниз, Space — спрыгнуть. Высота настраивается параметром в свойствах.',
icon: 'arrow-up', category: 'movement',
prims: [{ type: 'ladder_vertical', x: 0, y: 2, z: 0, stepCount: 8, color: '#a8743a', name: 'Лестница' }],
},
{ {
id: 'double-jump', id: 'double-jump',
name: 'Двойной прыжок', name: 'Двойной прыжок',

View File

@ -1,328 +0,0 @@
/**
* GraphicsManager система визуальных эффектов («шейдеры») для игр Рублокса.
*
* Управляет:
* - постобработкой экрана через Babylon DefaultRenderingPipeline:
* bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция
* (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF);
* - качеством теней (через scene3d.setShadowQuality);
* - контактными тенями SSAO (через scene3d.setSsaoEnabled).
*
* Управляется И из настроек игры (вкладка «Графика»), И из скриптов
* (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') старые игры
* не меняются, FPS не страдает. Автор включает осознанно.
*
* Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени,
* HDR-bloom) автоматически урезаются, даже если в пресете включены.
*
* Один и тот же класс используется в студии и плеере (фича-парность).
*
* Использование:
* const gfx = new GraphicsManager(scene, camera, scene3d, { mobile });
* gfx.apply({ preset: 'cinematic' });
* gfx.apply({ bloom: { enabled: true, intensity: 0.7 } });
* gfx.dispose();
*/
import {
DefaultRenderingPipeline, Color4, ImageProcessingConfiguration,
} from '@babylonjs/core';
/**
* Именованные пресеты. Каждый полный набор настроек. 'off' = чистая картинка
* (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными,
* но не «кислотными».
*/
export const GRAPHICS_PRESETS = {
off: {
bloom: { enabled: false },
fxaa: false,
vignette: { enabled: false },
grading: { enabled: false },
dof: { enabled: false },
ssao: false,
shadows: null, // null = не трогаем текущее качество теней
},
// Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде.
low: {
bloom: { enabled: true, intensity: 0.3, threshold: 0.9 },
fxaa: true,
vignette: { enabled: false },
grading: { enabled: false },
dof: { enabled: false },
ssao: false,
shadows: 'hard',
},
// Средний: свечение + лёгкая виньетка + чуть насыщенности.
medium: {
bloom: { enabled: true, intensity: 0.45, threshold: 0.85 },
fxaa: true,
vignette: { enabled: true, weight: 0.5 },
grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
// Высокий: всё кроме DoF, SSAO включён.
high: {
bloom: { enabled: true, intensity: 0.6, threshold: 0.82 },
fxaa: true,
vignette: { enabled: true, weight: 0.6 },
grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 },
dof: { enabled: false },
ssao: true,
shadows: 'soft',
},
// Ультра: + глубина резкости + мягкие каскадные тени.
ultra: {
bloom: { enabled: true, intensity: 0.7, threshold: 0.8 },
fxaa: true,
vignette: { enabled: true, weight: 0.65 },
grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 },
dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 },
ssao: true,
shadows: 'high',
},
// === Стилевые пресеты (художественные) ===
cinematic: {
bloom: { enabled: true, intensity: 0.55, threshold: 0.8 },
fxaa: true,
vignette: { enabled: true, weight: 0.85 },
grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 },
dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 },
ssao: true,
shadows: 'soft',
},
vivid: {
bloom: { enabled: true, intensity: 0.65, threshold: 0.78 },
fxaa: true,
vignette: { enabled: false },
grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
night: {
bloom: { enabled: true, intensity: 0.8, threshold: 0.7 },
fxaa: true,
vignette: { enabled: true, weight: 1.0 },
grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 },
dof: { enabled: false },
ssao: true,
shadows: 'soft',
},
retro: {
bloom: { enabled: false },
fxaa: false, // намеренно «пиксельно»
vignette: { enabled: true, weight: 1.2 },
grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 },
dof: { enabled: false },
ssao: false,
shadows: 'hard',
},
soft: {
bloom: { enabled: true, intensity: 0.4, threshold: 0.88 },
fxaa: true,
vignette: { enabled: true, weight: 0.4 },
grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
};
// Глубокое слияние пресета и пользовательских оверрайдов.
function _mergeConfig(base, over) {
const out = JSON.parse(JSON.stringify(base || {}));
if (!over) return out;
for (const k of Object.keys(over)) {
const v = over[k];
if (v && typeof v === 'object' && !Array.isArray(v)) {
out[k] = { ...(out[k] || {}), ...v };
} else {
out[k] = v;
}
}
return out;
}
export class GraphicsManager {
/**
* @param scene Babylon Scene
* @param camera активная камера (для pipeline)
* @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света)
* @param opts { mobile:boolean }
*/
constructor(scene, camera, scene3d, opts = {}) {
this.scene = scene;
this.camera = camera;
this.scene3d = scene3d;
this.mobile = !!opts.mobile;
this._pipeline = null;
// Текущая активная конфигурация (после merge + mobile-clamp).
this.config = _mergeConfig(GRAPHICS_PRESETS.off, null);
this.config.preset = 'off';
this.enabled = false;
}
/** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */
setCamera(camera) {
if (camera === this.camera) return;
this.camera = camera;
if (this.enabled) this._rebuildPipeline();
}
/**
* Применить настройки графики. Принимает либо {preset}, либо отдельные
* секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое
* (оверрайды поверх пресета). Сохраняет состояние в this.config.
*/
apply(settings = {}) {
let cfg;
if (settings.preset && GRAPHICS_PRESETS[settings.preset]) {
cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings);
cfg.preset = settings.preset;
} else {
// частичный апдейт поверх текущего
cfg = _mergeConfig(this.config, settings);
cfg.preset = settings.preset || this.config.preset || 'custom';
}
this.config = this._clampForMobile(cfg);
this._applyConfig();
return this.config;
}
/** Полностью выключить эффекты (как preset 'off'). */
disableAll() {
return this.apply({ preset: 'off' });
}
/** Текущая конфигурация (для serialize). */
serialize() {
// Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg.
return JSON.parse(JSON.stringify(this.config));
}
// --- внутреннее ---
/** На слабых устройствах гасим самое дорогое, что бы ни просили. */
_clampForMobile(cfg) {
if (!this.mobile) return cfg;
const c = JSON.parse(JSON.stringify(cfg));
if (c.dof) c.dof.enabled = false; // DoF дорогой
c.ssao = false; // SSAO дорогой
if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard';
// bloom оставляем, но без HDR (решается в _rebuildPipeline)
c._mobileClamped = true;
return c;
}
_applyConfig() {
const c = this.config;
const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa
|| (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled)
|| (c.dof && c.dof.enabled);
// Тени и SSAO — через scene3d (они вне pipeline).
try {
if (c.shadows && this.scene3d?.setShadowQuality) {
this.scene3d.setShadowQuality(c.shadows);
}
} catch (e) { /* ignore */ }
try {
if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao);
} catch (e) { /* ignore */ }
if (!anyPipelineFx) {
this.enabled = false;
this._disposePipeline();
return;
}
this.enabled = true;
this._rebuildPipeline();
}
_rebuildPipeline() {
this._disposePipeline();
if (!this.scene || !this.camera) return;
const c = this.config;
const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile;
const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]);
// Bloom
p.bloomEnabled = !!(c.bloom && c.bloom.enabled);
if (p.bloomEnabled) {
p.bloomThreshold = c.bloom.threshold ?? 0.85;
p.bloomWeight = c.bloom.intensity ?? 0.5;
p.bloomKernel = this.mobile ? 32 : 64;
p.bloomScale = 0.5;
}
// FXAA
p.fxaaEnabled = !!c.fxaa;
p.samples = this.mobile ? 1 : 4;
// Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг
const ip = p.imageProcessing;
if (ip) {
p.imageProcessingEnabled = true;
ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет
// экспозиция/контраст из grading
if (c.grading && c.grading.enabled) {
ip.exposure = c.grading.exposure ?? 1.0;
ip.contrast = c.grading.contrast ?? 1.0;
ip.colorCurvesEnabled = true;
try {
const curves = ip.colorCurves;
if (curves) {
// saturation: 1.0 = норма → curves в диапазоне примерно -100..100
const sat = c.grading.saturation ?? 1.0;
curves.globalSaturation = Math.round((sat - 1.0) * 60);
}
} catch (e) { /* ignore */ }
} else {
ip.exposure = 1.0; ip.contrast = 1.0;
}
// виньетка
if (c.vignette && c.vignette.enabled) {
ip.vignetteEnabled = true;
ip.vignetteWeight = c.vignette.weight ?? 0.6;
ip.vignetteColor = new Color4(0, 0, 0, 0);
ip.vignetteStretch = 0.3;
ip.vignetteCameraFov = 0.5;
ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY;
} else {
ip.vignetteEnabled = false;
}
}
// Depth of Field (глубина резкости) — только desktop
if (c.dof && c.dof.enabled && !this.mobile) {
p.depthOfFieldEnabled = true;
try {
p.depthOfFieldBlurLevel = 1; // 0..2
p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм
p.depthOfField.focalLength = c.dof.focalLength ?? 50;
p.depthOfField.fStop = c.dof.aperture ?? 1.2;
} catch (e) { /* ignore */ }
} else {
p.depthOfFieldEnabled = false;
}
this._pipeline = p;
}
_disposePipeline() {
if (this._pipeline) {
try { this._pipeline.dispose(); } catch (e) { /* ignore */ }
this._pipeline = null;
}
}
dispose() {
this._disposePipeline();
this.scene = null;
this.camera = null;
this.scene3d = null;
}
}

View File

@ -1,593 +0,0 @@
/**
* MixamoAnimator проигрывает Mixamo-анимации на скелете персонажа.
*
* Mixamo-скины (skin_y-bot, skin_x-bot, и ещё 78) приходят БЕЗ
* AnimationGroups в их собственном GLB. Анимации лежат отдельными
* GLB-файлами в /character-assets/animations/:
*
* idle.glb, walk.glb, run.glb, jump.glb, fall.glb
* emote_capoeira.glb, emote_defeated.glb, emote_shoved.glb, emote_taunt.glb
*
* Каждый GLB содержит ровно одну AnimationGroup, нацеленную на bones
* с именами `mixamorig:Hips`, `mixamorig:Spine` и т.д.
*
* Что делает этот класс:
* 1. Загружает 5 базовых GLB параллельно и кэширует AnimationGroup'ы
* (singleton один loader на сессию).
* 2. Для конкретного скина РЕТАРГЕТИТ AnimationGroup на его кости.
* Mixamo-скины разных вышедших времён имеют префикс `mixamorig:`,
* `mixamorig9:` или вообще без префикса детектим автоматически.
* 3. Управление: `setState('idle'|'walk'|'run'|'jump'|'fall')` +
* плавный кросс-фейд (blending) между состояниями.
* 4. `playEmote(name, onDone)` одноразово проиграть эмоцию поверх,
* после конца автоматически вернуться в текущее состояние.
*
* Bone-имена которые ретаргетим (24 обязательных):
* Hips, Spine, Spine1, Spine2, Neck, Head,
* LeftShoulder, LeftArm, LeftForeArm, LeftHand,
* RightShoulder, RightArm, RightForeArm, RightHand,
* LeftUpLeg, LeftLeg, LeftFoot, LeftToeBase,
* RightUpLeg, RightLeg, RightFoot, RightToeBase
*
* Использование:
* const anim = new MixamoAnimator();
* await anim.load(); // один раз на сессию
* anim.attach(scene, skeleton, modelRoot); // на каждую загрузку скина
* anim.setState('idle');
* // каждый кадр в _tick (необязательно — Babylon сам тикает groups):
* anim.update(dt);
* // эмоция:
* anim.playEmote('emote_taunt');
* // при смене скина:
* anim.detach();
*/
import { SceneLoader, AnimationGroup, Animation } from "@babylonjs/core";
import "@babylonjs/loaders/glTF";
// Базовые состояния — соответствуют файлам *.glb в animations/.
// Базовые (всегда грузятся при старте — нужны для движения):
const BASE_STATES = ["idle", "walk", "run", "jump", "fall"];
// Дополнительные движения (грузятся лениво при первом setState):
const EXTRA_STATES = [
"jump_anticipate", "jump_air", "jump_land",
"jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
"jump_run_anticipate", "jump_run_air", "jump_run_land",
"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", "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",
"sword_idle", "sword_slash",
"push_button", "open_door", "throw_action",
];
// Эмоции (вызываются через playEmote()):
const EMOTES = [
"emote_capoeira", "emote_defeated", "emote_shoved", "emote_taunt",
"emote_salute", "emote_pointing", "emote_no",
"dance_hiphop", "dance_rumba", "dance_breakdance",
];
// Все известные анимации (для опциональной полной предзагрузки)
const ALL_ANIMATIONS = [...BASE_STATES, ...EXTRA_STATES, ...EMOTES];
// Кэш сырых данных анимаций между инстансами (singleton-ish):
// один раз загрузили — используем для всех аватаров.
let _cachedRawTargets = null; // { idle: [{boneName, animations:[Anim]}], walk: [...] , ... }
let _loadPromise = null;
/**
* Строит абсолютный URL для статики Mixamo-анимаций.
* Локально localhost:3000 (rublox-site dev-server),
* на проде rublox.pro/character-assets/.
*/
function _assetsBase() {
if (typeof window === "undefined") return "";
const isLocal = window.location.hostname === "localhost"
|| window.location.hostname === "127.0.0.1";
return isLocal ? "http://localhost:3000" : "https://rublox.pro";
}
/**
* Нормализует имя кости: убирает префикс `mixamorig:`, `mixamorig9:`,
* `mixamorig_` и т.п. Возвращает чистое имя типа `Hips`, `Spine`, `LeftArm`.
*/
function _normalizeBone(name) {
if (!name) return "";
// mixamorig:Hips, mixamorig9:Hips, mixamorig_Hips, Armature|mixamorig:Hips, etc
let n = name;
const colon = n.lastIndexOf(":");
if (colon >= 0) n = n.slice(colon + 1);
n = n.replace(/^mixamorig\d*[_:.]?/i, "");
n = n.replace(/^Armature\|/, "");
return n;
}
/**
* Загружает один GLB-файл с анимациями. Возвращает массив
* { boneName, animations: [Babylon.Animation] } сырые треки,
* привязанные к именам костей (без префикса).
*/
async function _loadAnimGlb(scene, url) {
// ImportAnimations не годится — он сразу target-ит конкретный
// скелет. Нам нужны сырые animations[], чтобы потом каждому
// скину пристёгивать отдельно.
const result = await SceneLoader.LoadAssetContainerAsync(
url.substring(0, url.lastIndexOf("/") + 1),
url.substring(url.lastIndexOf("/") + 1),
scene,
);
const out = [];
// В GLB от Mixamo каждая кость — это TransformNode (или Bone),
// содержит свои keyframe animations. После загрузки они на
// result.transformNodes / result.skeletons[].bones.
const allNodes = [
...(result.transformNodes || []),
...((result.skeletons || []).flatMap(sk => sk.bones || [])),
];
for (const node of allNodes) {
if (!node.animations || node.animations.length === 0) continue;
const cleanName = _normalizeBone(node.name);
if (!cleanName) continue;
out.push({ boneName: cleanName, animations: node.animations.slice() });
}
// Освободим геометрию (если случайно приехала — у анимаций мешей нет)
result.dispose();
return out;
}
/**
* Загрузить базовые анимации (idle/walk/run/jump/fall) один раз.
* Дополнительные анимации (extra + эмоции) грузятся лениво в _ensureLoaded
* при первом обращении это экономит трафик: юзер качает только то что
* реально использует в игре.
*/
export async function loadMixamoAnimations(scene) {
if (_loadPromise) return _loadPromise;
_cachedRawTargets = _cachedRawTargets || {};
_loadPromise = (async () => {
const base = _assetsBase();
const entries = await Promise.all(
BASE_STATES.map(async (name) => {
try {
const tracks = await _loadAnimGlb(
scene, `${base}/character-assets/animations/${name}.glb`);
return [name, tracks];
} catch (e) {
console.warn(`[MixamoAnimator] не загрузилась '${name}':`, e?.message || e);
return [name, []];
}
})
);
for (const [k, v] of entries) _cachedRawTargets[k] = v;
// eslint-disable-next-line no-console
console.log("[MixamoAnimator] базовые анимации загружены:",
Object.entries(_cachedRawTargets).map(([k, v]) => `${k}=${v.length}tracks`).join(", "));
return _cachedRawTargets;
})();
return _loadPromise;
}
/**
* Ленивая подгрузка одной анимации по имени (если ещё не в кэше).
* Возвращает массив tracks или null если не удалось.
*/
async function _ensureLoaded(scene, name) {
if (!_cachedRawTargets) _cachedRawTargets = {};
if (_cachedRawTargets[name]) return _cachedRawTargets[name];
const base = _assetsBase();
try {
const tracks = await _loadAnimGlb(
scene, `${base}/character-assets/animations/${name}.glb`);
_cachedRawTargets[name] = tracks;
// eslint-disable-next-line no-console
console.log(`[MixamoAnimator] lazy-load '${name}': ${tracks.length} tracks`);
return tracks;
} catch (e) {
console.warn(`[MixamoAnimator] не удалось загрузить '${name}':`, e?.message || e);
_cachedRawTargets[name] = [];
return null;
}
}
export class MixamoAnimator {
constructor() {
this.scene = null;
this.skeleton = null;
this.modelRoot = null;
/** Map<state, AnimationGroup> — кастомные группы для ЭТОГО скелета */
this._groups = new Map();
this._currentState = null;
this._currentGroup = null;
this._currentEmote = null;
this._emoteOnDone = null;
this._blendInProgress = false;
}
/**
* Пристёгивает аниматор к конкретному скелету (после загрузки модели).
* scene Babylon Scene, skeleton Babylon Skeleton, modelRoot TransformNode.
*/
attach(scene, skeleton, modelRoot) {
this.scene = scene;
this.skeleton = skeleton;
this.modelRoot = modelRoot;
// Резолвим маппинг "clean name" → Bone (из текущего скелета).
this._cleanToBone = new Map();
for (const b of (skeleton.bones || [])) {
const clean = _normalizeBone(b.name);
if (clean && !this._cleanToBone.has(clean)) {
this._cleanToBone.set(clean, b);
}
}
// Также детектим target-property: TransformNode? linkedTransformNode?
// Mixamo-анимации обычно нацелены на linkedTransformNode'ы (если есть),
// потому что в glTF skin'ы делают joints через nodes, не через Bones.
// Для каждой кости берём её _linkedTransformNode (Babylon API).
this._cleanToTarget = new Map();
for (const [name, bone] of this._cleanToBone) {
const tnode = bone.getTransformNode ? bone.getTransformNode() : null;
this._cleanToTarget.set(name, tnode || bone);
}
// Запомним bind-pose позиции (особенно Hips) — нужны для нормализации
// Hips.position в jump_air/jump_land и для сброса после анимаций.
this._restPositions = new Map();
for (const [name, target] of this._cleanToTarget) {
if (target && target.position) {
this._restPositions.set(name, {
x: target.position.x,
y: target.position.y,
z: target.position.z,
});
}
}
}
/** Создать (или достать из кэша) AnimationGroup для конкретного состояния. */
_ensureGroup(state) {
if (this._groups.has(state)) return this._groups.get(state);
if (!_cachedRawTargets || !_cachedRawTargets[state]) return null;
const raw = _cachedRawTargets[state];
const group = new AnimationGroup(`mixamo_${state}`, this.scene);
let attached = 0;
for (const t of raw) {
const target = this._cleanToTarget.get(t.boneName);
if (!target) continue;
for (const anim of t.animations) {
// Клонируем анимацию (одна Babylon.Animation не может
// быть в двух разных AnimationGroup одновременно).
const cloned = anim.clone();
// Mixamo всегда грузит Hips.position — это сдвигает
// персонажа по сцене. В in-place анимациях должно быть
// близко к нулю, но иногда сдвиг есть. Для базовых
// движений (walk/run/jump) фильтруем targetProperty=position
// у кости с именем Hips — её двигает наш PlayerController.
if (t.boneName === "Hips" && cloned.targetProperty === "position") {
// 3-фазная модель прыжка:
// jump_anticipate — присед перед прыжком. baseY = первый кадр
// (стоячая поза → опускается ниже).
// jump_air — физика поднимает _modelRoot, Hips.Y не используем.
// jump_land — приземление с амортизацией. baseY = МИНИМУМ
// (самая низкая точка приседа), так первый кадр будет Y > 0
// (только что приземлились, ноги пружинят к bind),
// середина = 0 (присед на полу), конец = выпрямление.
// Для всех остальных — фильтруем (физика двигает _modelRoot).
const PHASES = new Set([
'jump_anticipate', 'jump_land',
'jump_fwd_anticipate', 'jump_fwd_land',
'jump_run_anticipate', 'jump_run_land',
]);
if (!PHASES.has(state)) {
continue;
}
const rest = this._restPositions?.get('Hips');
try {
const keys = cloned.getKeys();
if (keys && keys.length > 0 && keys[0].value) {
// baseY = МАКСИМУМ Y по клипу. Тогда delta = k.Y - max
// всегда ≤ 0 → Hips только опускается ниже bind.
// jump_land: персонаж приземлился (ноги на полу = bind),
// потом корпус опускается = присед амортизации,
// потом возвращается обратно к bind (выпрямление).
// jump_anticipate: то же — корпус опускается из стоячей.
let maxY = -Infinity;
for (const k of keys) {
const y = k.value.y || 0;
if (y > maxY) maxY = y;
}
const baseY = Number.isFinite(maxY) ? maxY : (keys[0].value.y || 0);
const newKeys = keys.map(k => ({
frame: k.frame,
value: new (k.value.constructor)(
rest ? rest.x : 0,
(rest ? rest.y : 0) + ((k.value.y || 0) - baseY),
rest ? rest.z : 0,
),
inTangent: k.inTangent,
outTangent: k.outTangent,
interpolation: k.interpolation,
}));
cloned.setKeys(newKeys);
}
} catch (e) { continue; }
}
group.addTargetedAnimation(cloned, target);
attached++;
}
}
if (attached === 0) {
group.dispose();
// eslint-disable-next-line no-console
console.warn(`[MixamoAnimator] state='${state}' — 0 целей зарезолвлено, skip`);
return null;
}
// Зацикливаем базовые состояния, кроме jump (он one-shot).
// ВАЖНО: для AnimationGroup нужно ставить loopAnimation=true НА
// САМОМ GROUP до start(). Параметр loop в start() игнорируется в
// некоторых версиях Babylon 7.x.
// One-shot анимации (играются один раз, не зацикливаются):
// jump, crouch_enter, crouch_to_stand, crouch_exit + все эмоции и
// эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.)
const ONE_SHOT = new Set([
"jump", "jump_forward", "jump_backward", "jump_down",
"jump_anticipate", "jump_land",
"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",
"kick_low", "kick_high", "punch_left",
]);
// emote_* — one-shot (один жест), dance_* — лупим (танцы должны крутиться)
const loopable = !ONE_SHOT.has(state) && !state.startsWith("emote_");
group.loopAnimation = loopable;
group.normalize();
// Safety-net: если Babylon всё равно по какой-то причине отыграл
// клип до конца И не зациклил (что бывает с короткими "still pose"
// клипами от Mixamo вроде Crouched Idle ~0.5s) — перезапускаем
// принудительно. Это даёт стабильно зацикленную анимацию.
if (loopable) {
group.onAnimationGroupEndObservable.add(() => {
if (this._currentGroup === group && !this._currentEmote) {
try {
group.reset();
group.start(true, 1.0, group.from, group.to, false);
} catch (_) {}
}
});
}
// eslint-disable-next-line no-console
console.log(`[MixamoAnimator] group '${state}': ${attached} tracks, loop=${loopable}, duration=${((group.to - group.from) / 60).toFixed(2)}s`);
this._groups.set(state, group);
return group;
}
/** Установить состояние с плавным кросс-фейдом 150 мс.
* Если анимация ещё не подгружена стартует lazy-load, при этом
* setState вернётся синхронно (без ожидания) анимация подхватится
* на следующем тике после успешной загрузки.
*
* Anti-flicker: между переключениями требуется минимальная задержка
* 120мс (кроме переходов в воздух/идл из приземления). Это убирает
* «дрожание» crouch_walk crouch_idle когда игрок едет по диагонали
* и одно из направлений физически дёргается между кадрами. */
setState(state) {
if (this._currentEmote) return; // эмоция блокирует смену состояния
if (state === this._currentState) return;
// Сброс Hips.position в bind-pose при выходе из jump-фаз.
// Иначе последний keyframe анимации остаётся на Hips и idle/walk
// подхватывает смещённую позицию → персонаж проседает.
const JUMP_STATES = new Set([
'jump_air', 'jump_land', 'jump_in_place', 'jump_anticipate',
'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
]);
if (JUMP_STATES.has(this._currentState) && !JUMP_STATES.has(state)
&& this._restPositions) {
const rest = this._restPositions.get('Hips');
const hips = this._cleanToTarget?.get('Hips');
if (rest && hips && hips.position) {
try {
hips.position.x = rest.x;
hips.position.y = rest.y;
hips.position.z = rest.z;
} catch (_) {}
}
}
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
// Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс,
// КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость
// и one-shot crouch_enter/crouch_to_stand (они короткие).
const JUMP_VITAL = new Set([
'jump', 'fall', 'jump_air', 'jump_land', 'jump_anticipate',
'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
]);
const isVitalSwitch = JUMP_VITAL.has(state)
|| JUMP_VITAL.has(this._currentState)
|| state === 'crouch_enter' || state === 'crouch_to_stand';
if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) {
// Запомним последний запрошенный state — если он не изменится за
// окно debounce, тогда применим, иначе отбросим вспышку.
this._pendingState = state;
if (!this._debounceTimer) {
const delay = Math.max(0, 120 - (now - this._lastSwitchAt));
this._debounceTimer = setTimeout(() => {
this._debounceTimer = null;
const s = this._pendingState;
this._pendingState = null;
if (s && s !== this._currentState) this.setState(s);
}, delay);
}
return;
}
this._lastSwitchAt = now;
// Если ещё не загружено — стартуем lazy-load, но ТЕКУЩУЮ анимацию
// НЕ останавливаем (иначе в момент Ctrl-on/off персонаж зависает
// в bind-pose пока crouch_idle асинхронно качается).
if (!_cachedRawTargets || !_cachedRawTargets[state]) {
if (!this._pendingLoads) this._pendingLoads = new Set();
if (!this._pendingLoads.has(state)) {
this._pendingLoads.add(state);
_ensureLoaded(this.scene, state).then(() => {
this._pendingLoads.delete(state);
});
}
return; // подхватится при следующем setState когда tracks будут
}
const next = this._ensureGroup(state);
if (!next) return;
const prev = this._currentGroup;
// Loop-флаг берём напрямую с group — _ensureGroup уже разрулил
// (one-shot list + emote_* → не лупим).
const loop = next.loopAnimation;
// Лог переключений (только если изменилось — иначе спам)
// eslint-disable-next-line no-console
console.log(`[MixamoAnimator] setState: ${this._currentState || 'none'}${state} (loop=${loop})`);
// Per-state speedRatio: подгоняем длительность под физику.
// jump_fwd_air: Mixamo Jump полёт = 0.43с, физика = 0.73с
// → speedRatio = 0.59 (замедлить чтобы клип не зациклился).
// jump_fwd_air: Mixamo Jump полёт 0.43с, физика 0.73с → 0.59
// jump_run_air: Mixamo Running Jump полёт 0.52с, физика 0.73с → 0.71
const SPEED_RATIO = {
jump_fwd_air: 0.59,
jump_run_air: 0.71,
};
const speedRatio = SPEED_RATIO[state] || 1.0;
// Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop
// в start() иногда игнорится — дублируем через loopAnimation
// (выставлен в _ensureGroup).
try {
next.reset();
next.start(loop, speedRatio, next.from, next.to, false);
} catch (e) {
try { next.play(loop); } catch (_) {}
}
// Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS.
// 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'ов.
if (this._blendObservers && this._blendObservers.length) {
for (const o of this._blendObservers) {
try { this.scene.onBeforeRenderObservable.remove(o); } catch (_) {}
}
}
this._blendObservers = [];
// КРИТИЧНО: при ЛЮБОМ setState останавливаем ВСЕ остальные группы
// кроме новой. Это убирает кейсы когда rapid-switching между
// prev/next/третий оставляет висящую группу из позапрошлого setState
// (и она «крутится» дальше в фоне с весом 1).
for (const g of this._groups.values()) {
if (g !== next) {
// Не стопим текущую blend-исходную — она нужна для фейда.
if (g !== prev) {
try { g.stop(); g.setWeightForAllAnimatables(0); } catch (_) {}
}
}
}
if (prev && prev !== next) {
const startedAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
const prevGroup = prev;
const nextGroup = next;
const obs = this.scene.onBeforeRenderObservable.add(() => {
const nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now());
const t = Math.min(1, (nowMs - startedAt) / BLEND_MS);
// Если за это время _currentGroup сменилась ещё раз —
// прекращаем blend (новый setState уже разрулил).
if (this._currentGroup !== nextGroup) {
try { this.scene.onBeforeRenderObservable.remove(obs); } catch (_) {}
return;
}
try {
prevGroup.setWeightForAllAnimatables(1 - t);
nextGroup.setWeightForAllAnimatables(t);
} catch (_) {}
if (t >= 1) {
try { prevGroup.stop(); prevGroup.setWeightForAllAnimatables(0); } catch (_) {}
try { nextGroup.setWeightForAllAnimatables(1); } catch (_) {}
this.scene.onBeforeRenderObservable.remove(obs);
}
});
this._blendObservers.push(obs);
} else {
try { next.setWeightForAllAnimatables(1); } catch (_) {}
}
this._currentState = state;
this._currentGroup = next;
}
/** Проиграть эмоцию (one-shot), потом вернуться в idle.
* Если эмоция ещё не подгружена подгружает на лету и стартует. */
async playEmote(name, onDone) {
const tracks = await _ensureLoaded(this.scene, name);
if (!tracks || tracks.length === 0) {
console.warn(`[MixamoAnimator] эмоция '${name}' не загружена`);
if (onDone) onDone();
return;
}
const group = this._ensureGroup(name);
if (!group) { if (onDone) onDone(); return; }
// Стоп текущего состояния
if (this._currentGroup) {
try { this._currentGroup.stop(); } catch (_) {}
}
this._currentEmote = name;
this._emoteOnDone = onDone || null;
const savedState = this._currentState;
try {
group.start(false, 1.0, group.from, group.to, false);
} catch (e) {
try { group.play(false); } catch (_) {}
}
const onEnd = () => {
this._currentEmote = null;
this._currentState = null; // принудим setState заново запустить
this.setState(savedState || "idle");
if (this._emoteOnDone) {
const cb = this._emoteOnDone;
this._emoteOnDone = null;
try { cb(); } catch (_) {}
}
};
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 */ }
/** Остановить и освободить все группы для этого скелета. */
detach() {
if (this._currentGroup) { try { this._currentGroup.stop(); } catch (_) {} }
for (const g of this._groups.values()) {
try { g.dispose(); } catch (_) {}
}
this._groups.clear();
this._currentGroup = null;
this._currentState = null;
this._currentEmote = null;
this.scene = null;
this.skeleton = null;
this.modelRoot = null;
}
}

View File

@ -1192,24 +1192,4 @@ export class PhysicsAABB {
} }
return out; 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

@ -28,28 +28,6 @@ import {
import { getModelType } from './ModelTypes'; import { getModelType } from './ModelTypes';
import { R15Skeleton } from './R15Skeleton'; import { R15Skeleton } from './R15Skeleton';
import { R15Animator } from './R15Animator'; import { R15Animator } from './R15Animator';
import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
// Список всех Mixamo-скинов. Должен совпадать со списком в плеере и
// каталоге сайта (rublox-site/src/data/skinsCatalog.js).
export const MIXAMO_SKINS = new Set([
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
'skin_castle-guard-1', 'skin_castle-guard-2',
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
'skin_ch09', 'skin_ch10', 'skin_ch11', 'skin_ch13', 'skin_ch14', 'skin_ch15',
'skin_ch16', 'skin_ch17', 'skin_ch18', 'skin_ch19', 'skin_ch20', 'skin_ch21',
'skin_ch22', 'skin_ch23', 'skin_ch24', 'skin_ch29', 'skin_ch31', 'skin_ch32',
'skin_ch33', 'skin_ch34', 'skin_ch35', 'skin_ch39', 'skin_ch40', 'skin_ch42',
'skin_ch43', 'skin_ch44', 'skin_ch45', 'skin_ch46', 'skin_ch47', 'skin_ch48',
'skin_claire', 'skin_demon', 'skin_ely', 'skin_erika-archer',
'skin_eve', 'skin_exo-gray', 'skin_exo-red', 'skin_ganfaul', 'skin_heraklios',
'skin_kachujin', 'skin_kaya', 'skin_knight', 'skin_lola', 'skin_maria',
'skin_maw', 'skin_medea', 'skin_mutant', 'skin_nightshade',
'skin_paladin', 'skin_passive-marker-man', 'skin_peasant-girl', 'skin_peasant-man',
'skin_prisoner', 'skin_pumpkinhulk', 'skin_skeleton-zombie', 'skin_sporty-granny',
'skin_survivor', 'skin_swat', 'skin_ty', 'skin_uriel', 'skin_vampire',
'skin_war-zombie', 'skin_warrok', 'skin_white-clown', 'skin_x-bot', 'skin_y-bot',
]);
// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом). // Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа. // 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
@ -86,12 +64,6 @@ export class PlayerController {
this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз) this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump) this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
this._robotBoostLeft = 0; // оставшееся время boost-фазы (с) this._robotBoostLeft = 0; // оставшееся время boost-фазы (с)
// Лестница (ladder_vertical): когда игрок касается лестницы и жмёт W/S —
// входит в ladder-mode: гравитация отключена, W/S = вверх/вниз по лестнице,
// Space = отпрыг. Выход — наверху лестницы, при отходе или по Space.
this._ladderMode = false;
this._ladderData = null; // data текущей лестницы (для верх/низ/центр)
this.CLIMB_SPEED = 2.5; // скорость лазания вверх/вниз (м/с)
// Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с. // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
// Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся. // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
this._autoRunSpeed = 0; this._autoRunSpeed = 0;
@ -205,7 +177,6 @@ export class PlayerController {
this._isR15 = false; // флаг: загружен валидный R15-скелет this._isR15 = false; // флаг: загружен валидный R15-скелет
this._r15Skeleton = null; // R15Skeleton — резолвер костей this._r15Skeleton = null; // R15Skeleton — резолвер костей
this._r15Animator = null; // R15Animator — процедурные анимации this._r15Animator = null; // R15Animator — процедурные анимации
this._mixamoAnimator = null; // MixamoAnimator — Mixamo-скины
this._skinManifest = null; // кеш skins_manifest.json this._skinManifest = null; // кеш skins_manifest.json
this._skinOverrides = {}; // overrides текущего скина this._skinOverrides = {}; // overrides текущего скина
@ -341,8 +312,6 @@ export class PlayerController {
this._r15Skeleton = null; this._r15Skeleton = null;
this._r15Animator = null; this._r15Animator = null;
this._isR15 = false; this._isR15 = false;
try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {}
this._mixamoAnimator = null;
this._modelKind = 'r15'; this._modelKind = 'r15';
this._modelHipHeight = null; this._modelHipHeight = null;
this._nonHumanoidBox = null; this._nonHumanoidBox = null;
@ -685,21 +654,6 @@ export class PlayerController {
async _resolveModelSource() { async _resolveModelSource() {
const typeId = this._modelTypeId || 'character-a'; const typeId = this._modelTypeId || 'character-a';
if (typeId.startsWith('skin_')) { if (typeId.startsWith('skin_')) {
// 2026-06-14: Mixamo-скины (80 шт) — отдельные GLB на rublox-site
// (/character-assets/skins/), без R15-скелета, с Mixamo-rig.
if (MIXAMO_SKINS.has(typeId)) {
const base = (typeof window !== 'undefined'
&& window.location.hostname === 'localhost')
? 'http://localhost:3000'
: 'https://rublox.pro';
return {
file: `${base}/character-assets/skins/${typeId}.glb?v=20260614`,
isR15: false,
kind: 'non-humanoid-rigged',
overrides: {},
isMixamo: true,
};
}
const manifest = await this._loadSkinManifest(); const manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId); const entry = manifest.find((s) => s.id === typeId);
const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets'; const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets';
@ -939,51 +893,11 @@ export class PlayerController {
} }
// Анимации. // Анимации.
// R15-скины — процедурно через R15Animator. // R15-скины не содержат AnimationGroups (анимируются процедурно
// Mixamo-скины (non-humanoid-rigged) — через MixamoAnimator // через R15Animator в _tick). Kenney-модели — наоборот, имеют
// (5 базовых + lazy эмоции грузятся с /character-assets/animations/). // встроенные AnimationGroups (idle/walk/sprint/jump).
// Kenney-модели — встроенные AnimationGroups (idle/walk/sprint/jump).
this._animations = {}; this._animations = {};
this._mixamoAnimator = null; if (!this._isR15) {
if (source.isMixamo || source.kind === 'non-humanoid-rigged') {
let mixSk = (inst.skeletons && inst.skeletons[0]) || null;
if (!mixSk && container.skeletons && container.skeletons.length > 0) {
mixSk = container.skeletons[0];
}
if (!mixSk) {
const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
if (meshWithSkel) mixSk = meshWithSkel.skeleton;
}
if (mixSk) {
try {
const animator = new MixamoAnimator();
loadMixamoAnimations(this.scene)
.then(() => {
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) {}
try { window.__mixamo = animator; } catch (e) {}
// eslint-disable-next-line no-console
console.log('[PlayerController] MixamoAnimator готов, скелет=' + mixSk.bones.length + ' bones');
})
.catch((e) => {
// eslint-disable-next-line no-console
console.warn('[PlayerController] MixamoAnimator не загрузился:', e);
});
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] MixamoAnimator init fail:', e);
}
}
} else if (!this._isR15) {
const groups = inst.animationGroups || []; const groups = inst.animationGroups || [];
for (const g of groups) { for (const g of groups) {
const name = (g.name || '').toLowerCase(); const name = (g.name || '').toLowerCase();
@ -2534,27 +2448,22 @@ export class PlayerController {
&& (this._codes.has('ControlLeft') || this._codes.has('ControlRight')); && (this._codes.has('ControlLeft') || this._codes.has('ControlRight'));
if (wantCrouch && !this._crouching) { if (wantCrouch && !this._crouching) {
this._crouching = true; this._crouching = true;
// сдвигаем центр капсулы вниз — низ ног остаётся на земле
const dH = this.HALF_H_CROUCH - this.HALF_H; const dH = this.HALF_H_CROUCH - this.HALF_H;
this.HALF_H = this.HALF_H_CROUCH; this.HALF_H = this.HALF_H_CROUCH;
if (this._pos) this._pos.y += dH; if (this._pos) this._pos.y += dH;
this._crouchEnterPending = true;
this._crouchTransitionUntil = Date.now() + 600;
} else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) { } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
this._crouching = false; this._crouching = false;
const dH = this.HALF_H_NORMAL - this.HALF_H; const dH = this.HALF_H_NORMAL - this.HALF_H;
this.HALF_H = this.HALF_H_NORMAL; this.HALF_H = this.HALF_H_NORMAL;
if (this._pos) this._pos.y += dH; if (this._pos) this._pos.y += dH;
this._crouchExitPending = true;
this._crouchTransitionUntil = Date.now() + 600;
} }
// === Горизонтальное движение === // === Горизонтальное движение ===
const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw)); const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw)); const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
// Crouch имеет ПРИОРИТЕТ над sprint const isSprinting = this._shift;
const isSprinting = this._shift && !this._crouching; const speedMult = isSprinting ? this.SPRINT_MULT : 1;
const crouchMult = this._crouching ? 0.45 : 1;
const speedMult = (isSprinting ? this.SPRINT_MULT : 1) * crouchMult;
const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt; const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;
let moveX = 0, moveZ = 0; let moveX = 0, moveZ = 0;
@ -2638,154 +2547,8 @@ export class PlayerController {
moveZ *= 0.5; 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 (this._ladderMode) { if (inWater) {
// На лестнице гравитация НЕ применяется — _vy уже выставлен
// (=CLIMB_SPEED вверх / -CLIMB_SPEED вниз / 0 на месте) выше,
// moveAABB применит его с коллизией.
} else if (inWater) {
// Плавание: лёгкая гравитация + плавучесть к поверхности // Плавание: лёгкая гравитация + плавучесть к поверхности
const buoyancy = submerged ? 6 : 0; const buoyancy = submerged ? 6 : 0;
const swimGravity = -3; const swimGravity = -3;
@ -2869,12 +2632,7 @@ export class PlayerController {
// PERF-METRICS: замер физики игрока // PERF-METRICS: замер физики игрока
const _pt0 = performance.now(); const _pt0 = performance.now();
// Во время climb_to_top физику пропускаем — _pos двигается lerp'ом const result = this.physics.moveAABB(
// вручную (вылезание на площадку), коллизия не нужна.
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, this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
moveX, this._vy * dt, moveZ moveX, this._vy * dt, moveZ
); );
@ -3018,42 +2776,17 @@ export class PlayerController {
} else } else
if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) { if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) {
if (!this._jumpHeld) { if (!this._jumpHeld) {
// 3-фазная модель прыжка. // Robot — стартовый импульс полный (как куб) для тапа достаточный,
// _jumpKind определяется по нажатым клавишам в момент Space: // boost-фаза 0.45с удлиняет подъём при удержании Space.
// in_place — нет WASD (анимация Mixamo Jumping) this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
// forward — WASD без Shift (Mixamo Jump) this._playJumpSound();
// run — WASD + Shift (Mixamo Running Jump)
const cc = this._codes;
const wasdHeld = cc && (cc.has('KeyW') || cc.has('KeyS')
|| cc.has('KeyA') || cc.has('KeyD')
|| cc.has('ArrowUp') || cc.has('ArrowDown')
|| cc.has('ArrowLeft') || cc.has('ArrowRight'));
const sprinting = this._shift && !this._crouching;
if (!wasdHeld) this._jumpKind = 'in_place';
else if (sprinting) this._jumpKind = 'run';
else this._jumpKind = 'forward';
// anticipate-фаза разной длительности.
const antDuration = this._jumpKind === 'in_place' ? 375
: this._jumpKind === 'run' ? 125 : 170;
this._jumpHeld = true; this._jumpHeld = true;
this._coyoteLeft = 0; this._coyoteLeft = 0;
this._jumpAnticipateUntil = Date.now() + antDuration;
this._jumpPendingImpulse = true;
// Robot: запускаем boost-фазу на 0.45с // Robot: запускаем boost-фазу на 0.45с
if (this._robotMode) { if (this._robotMode) {
this._robotBoostLeft = 0.45; this._robotBoostLeft = 0.45;
} }
} }
}
// Запускаем физический прыжок ровно в конце anticipate-фазы.
if (this._jumpPendingImpulse
&& this._jumpAnticipateUntil
&& Date.now() >= this._jumpAnticipateUntil
&& !inWater && !this._shipMode && !this._ufoMode) {
this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
this._playJumpSound();
this._jumpPendingImpulse = false;
// _jumpAnticipateUntil оставляем для анимационной ветки
} else if (this._shipMode && c.has('Space')) { } else if (this._shipMode && c.has('Space')) {
this._jumpHeld = true; this._jumpHeld = true;
} else if (this._ufoMode && c.has('Space') && !inWater) { } else if (this._ufoMode && c.has('Space') && !inWater) {
@ -3153,41 +2886,17 @@ export class PlayerController {
const fwdShift = inWater ? bodyLen * tiltFrac : 0; const fwdShift = inWater ? bodyLen * tiltFrac : 0;
const fx = Math.sin(this._modelYaw); const fx = Math.sin(this._modelYaw);
const fz = Math.cos(this._modelYaw); const fz = Math.cos(this._modelYaw);
// Crouch Y-drop для Mixamo (см. rublox-player PlayerController.js).
let crouchYDrop = 0;
if (this._crouching && this._mixamoAnimator) {
const ms = this._mixamoAnimator._currentState;
if (ms === 'crouch_idle') crouchYDrop = 0.45;
else if (ms === 'crouch_walk') crouchYDrop = 0.25;
else if (ms === 'crouch_enter' || ms === 'crouch_to_stand') crouchYDrop = 0.30;
else crouchYDrop = 0.30;
}
this._modelRoot.position.set( this._modelRoot.position.set(
this._pos.x + fx * fwdShift, this._pos.x + fx * fwdShift,
this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset - crouchYDrop, this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset,
this._pos.z + fz * fwdShift this._pos.z + fz * fwdShift
); );
// Поворот модели: // Поворот модели:
// - на лестнице: лицом К лестнице, yaw зафиксирован при входе.
// - на суше: направление РЕАЛЬНОГО движения (как было). // - на суше: направление РЕАЛЬНОГО движения (как было).
// - в воде: направление КАМЕРЫ (yaw игрока). // - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
if (this._climbingTop) { // двигает тело вбок без вращения, как на суше при first-person.
// climb_to_top: модель смотрит В сторону площадки (куда вылазит). if (inWater) {
// Эта анимация имеет другую ориентацию 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; const targetYaw = this._yaw;
let diff = targetYaw - this._modelYaw; let diff = targetYaw - this._modelYaw;
while (diff > Math.PI) diff -= Math.PI * 2; while (diff > Math.PI) diff -= Math.PI * 2;
@ -3303,133 +3012,6 @@ export class PlayerController {
return; return;
} }
// Mixamo-скин: AnimationGroup для каждого состояния (idle/walk/run/jump/fall
// + crouch_idle/crouch_walk). Грузятся отдельными GLB.
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:
// in_place: jump_* (Mixamo Jumping)
// forward: jump_fwd_* (Mixamo Jump, прыжок с шага)
// run: jump_run_* (Mixamo Running Jump, прыжок с бега)
const jk = this._jumpKind;
const isAirborneJump = jk === 'forward' || jk === 'run';
let stAnticipate, stAir, stLand, landDuration;
if (jk === 'run') {
stAnticipate = 'jump_run_anticipate';
stAir = 'jump_run_air';
stLand = 'jump_run_land';
landDuration = 175;
} else if (jk === 'forward') {
stAnticipate = 'jump_fwd_anticipate';
stAir = 'jump_fwd_air';
stLand = 'jump_fwd_land';
landDuration = 142;
} else {
stAnticipate = 'jump_anticipate';
stAir = 'jump_air';
stLand = 'jump_land';
landDuration = 570;
}
const inAnticipate = this._jumpAnticipateUntil
&& now < this._jumpAnticipateUntil
&& this._jumpPendingImpulse;
const inJumpLand = this._jumpLandUntil && now < this._jumpLandUntil;
// Coyote-фильтр для микро-полётов на ступеньках. При спуске по
// лестнице из блоков персонаж 30-700мс физически в воздухе, и
// jump_air мигает между шагами walk. Критерий — ВЫСОТА падения
// от последней наземной позиции (а не время — полёт может быть
// длинным при спуске лицом к камере). Опустился <1.3 блока И не
// прыгал → ступенька, играем walk/run.
if (result.onGround) {
this._lastGroundY = this._pos.y;
}
const dropFromGround = (this._lastGroundY != null)
? (this._lastGroundY - this._pos.y) : Infinity;
const microAir = !result.onGround
&& !this._jumpHeld // не прыжок со Space
&& !this._wasAirborne // не продолжение реального прыжка
&& dropFromGround < 1.3 // опустился меньше 1.3 блока
&& this._vy < 4; // не подлетает вверх (степ-ап импульс)
if (inAnticipate) {
mState = stAnticipate;
} else if (microAir) {
// Микро-полёт между ступеньками — наземная анимация.
mState = this._crouching
? (isMoving ? 'crouch_walk' : 'crouch_idle')
: (isMoving ? (isSprinting ? 'run' : 'walk') : 'idle');
} else if (!result.onGround) {
mState = stAir;
this._wasAirborne = true;
this._crouchEnterPending = false;
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
this._jumpAnticipateUntil = 0;
} else if (this._wasAirborne) {
this._jumpLandUntil = now + landDuration;
this._wasAirborne = false;
mState = stLand;
} else if (inJumpLand) {
// Для forward — доигрываем land даже при движении
// (там короткая фаза 142мс)
if (isAirborneJump || !isMoving) mState = stLand;
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
mState = 'crouch_enter';
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
mState = 'crouch_to_stand';
} else if (this._crouching) {
this._crouchEnterPending = false;
this._crouchExitPending = false;
mState = isMoving ? 'crouch_walk' : 'crouch_idle';
} else if (inWater) {
mState = isMoving ? 'walk' : 'idle';
} else if (isMoving) {
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
this._jumpLandUntil = 0; // прерываем jump_land если пошли
mState = isSprinting ? 'run' : 'walk';
} else {
this._crouchExitPending = false;
mState = 'idle';
}
this._mixamoAnimator.setState(mState);
return;
}
// R15-скин: процедурный аниматор (нет glTF AnimationGroups). // R15-скин: процедурный аниматор (нет glTF AnimationGroups).
// Состояния: idle/walk/run/jump/fall. sprint → run. // Состояния: idle/walk/run/jump/fall. sprint → run.
if (this._isR15 && this._r15Animator) { if (this._isR15 && this._r15Animator) {

View File

@ -6,7 +6,7 @@
* - позиция (x, y, z) * - позиция (x, y, z)
* - размер (sx, sy, sz) * - размер (sx, sy, sz)
* - цвет (#hex) * - цвет (#hex)
* - материал ('matte'|'metal'|'glass'|'neon'|'studs'|'chrome'|'water'|'iridescent') * - материал ('matte' | 'metal' | 'glass' | 'neon')
* - canCollide (bool) участвует ли в физике коллизий * - canCollide (bool) участвует ли в физике коллизий
* - visible (bool) рисуется ли (anchored пока заготовка) * - visible (bool) рисуется ли (anchored пока заготовка)
* *
@ -32,11 +32,6 @@ const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png'; const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
const STUD_UNIT = 1; // 1 круглый stud на 1 юнит размера const STUD_UNIT = 1; // 1 круглый stud на 1 юнит размера
const STUDS_GRID = 4; // текстура содержит сетку 4×4 круглых studs const STUDS_GRID = 4; // текстура содержит сетку 4×4 круглых studs
// Вертикальный шаг между ступеньками лестницы (юниты). Полная высота
// лестницы = stepCount * LADDER_STEP_SPACING. Экспортируется, чтобы
// PlayerController мог считать верх лестницы по data.stepCount.
export const LADDER_STEP_SPACING = 0.45;
// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша. // Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
// Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою // Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою
// материал-копию (свой цвет/тайлинг), но текстуры шарятся. // материал-копию (свой цвет/тайлинг), но текстуры шарятся.
@ -151,16 +146,8 @@ export class PrimitiveManager {
id = this._nextId++; id = this._nextId++;
} }
const sx = opts.sx ?? typeDef.defaultScale.x; const sx = opts.sx ?? typeDef.defaultScale.x;
let sy = opts.sy ?? typeDef.defaultScale.y; const sy = opts.sy ?? typeDef.defaultScale.y;
const sz = opts.sz ?? typeDef.defaultScale.z; const sz = opts.sz ?? typeDef.defaultScale.z;
// Лестница: высота ДЕРИВИРУЕТСЯ из stepCount (а не из sy). Это даёт
// корректный AABB для детекта касания (PlayerController) и совпадает
// с реальной геометрией меша.
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; const color = opts.color ?? typeDef.defaultColor;
// GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики. // GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики.
// Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции. // Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции.
@ -171,10 +158,8 @@ export class PrimitiveManager {
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte'); const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще). // studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1; const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции). // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
// Лестница — тоже проходима (canCollide=false), чтобы игрок мог войти в её const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
// объём и лезть (ladder-mode в PlayerController по детекту касания).
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike && !isLadder;
const visible = opts.visible !== false; const visible = opts.visible !== false;
const anchored = opts.anchored !== false; // по умолчанию заякорен const anchored = opts.anchored !== false; // по умолчанию заякорен
// Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков. // Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков.
@ -190,11 +175,7 @@ export class PrimitiveManager {
const rotationY = opts.rotationY ?? 0; const rotationY = opts.rotationY ?? 0;
const rotationZ = opts.rotationZ ?? 0; const rotationZ = opts.rotationZ ?? 0;
// Передаём stepCount в builder через временное поле (читается в
// _buildLadderMesh внутри _createMeshForType).
this._ladderStepCount = stepCount;
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity); const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
this._ladderStepCount = undefined;
mesh.position = new Vector3(x, y, z); mesh.position = new Vector3(x, y, z);
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ); mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
mesh.isPickable = true; mesh.isPickable = true;
@ -221,8 +202,6 @@ export class PrimitiveManager {
rotationX, rotationY, rotationZ, rotationX, rotationY, rotationZ,
color, material, canCollide, visible, anchored, mass, color, material, canCollide, visible, anchored, mass,
textureAsset, studDensity, textureAsset, studDensity,
// Лестница: число ступенек (высота лестницы). undefined для прочих.
...(isLadder ? { stepCount } : {}),
// Подпись над объектом (задача 10) — восстанавливается из project_data. // Подпись над объектом (задача 10) — восстанавливается из project_data.
label: opts.label || null, label: opts.label || null,
// locked — объект защищён от выделения/перемещения в редакторе // locked — объект защищён от выделения/перемещения в редакторе
@ -376,11 +355,6 @@ export class PrimitiveManager {
return this._buildWedgeMesh(name, sx, sy, sz); return this._buildWedgeMesh(name, sx, sy, sz);
case 'cornerwedge': case 'cornerwedge':
return this._buildCornerWedgeMesh(name, sx, sy, sz); return this._buildCornerWedgeMesh(name, sx, sy, sz);
case 'ladder_vertical':
// Лестница строится из stepCount ступенек — высота зависит от
// количества ступенек, а не от sy. stepCount передаётся через
// замыкание _ladderStepCount (см. _createMeshForType-вызов).
return this._buildLadderMesh(name, sx, sz, this._ladderStepCount || 8);
default: default:
return MeshBuilder.CreateBox(name, return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene); { width: sx, height: sy, depth: sz }, this.scene);
@ -528,52 +502,6 @@ export class PrimitiveManager {
return mesh; return mesh;
} }
/**
* Вертикальная лестница: 2 боковые стойки + N перекладин (ступенек).
* Строится из stepCount ступенек с шагом LADDER_STEP_SPACING по высоте.
* Полная высота = stepCount * LADDER_STEP_SPACING при изменении stepCount
* лестница ПЕРЕСТРАИВАЕТСЯ (добавляются/убираются ступеньки), а не тянется.
* Меш центрирован по (0,0,0) как CreateBox; все части мерджатся в один Mesh.
*
* 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); // толщина стойки по X
const railD = Math.max(0.06, sz); // глубина стойки/перекладины по Z
const rungH = Math.min(0.1, SPACING * 0.3); // высота перекладины по Y
const halfH = height / 2;
const railX = sx / 2 - railW / 2; // стойки у краёв по X
const parts = [];
// Две вертикальные стойки (тонкие высокие box).
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);
// Перекладины (ступеньки) — горизонтальные box между стойками.
// Первая на полшага от низа, далее с шагом SPACING.
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);
}
// Мерджим в один меш (true = удалить исходники, переиспользовать материал).
const merged = Mesh.MergeMeshes(parts, true, true, undefined, false, true);
if (merged) { merged.name = name; return merged; }
// Fallback: если merge не удался — вернуть простой box по габаритам.
return MeshBuilder.CreateBox(name, { width: sx, height, depth: sz }, this.scene);
}
/** Применить цвет и материал. */ /** Применить цвет и материал. */
_applyMaterial(mesh, typeDef, color, material, textureUrl) { _applyMaterial(mesh, typeDef, color, material, textureUrl) {
const matName = `${mesh.name}_mat`; const matName = `${mesh.name}_mat`;
@ -610,9 +538,7 @@ export class PrimitiveManager {
break; break;
case 'glass': case 'glass':
mat.alpha = 0.4; mat.alpha = 0.4;
mat.specularColor = new Color3(0.8, 0.85, 0.9); mat.specularColor = new Color3(0.5, 0.5, 0.5);
mat.specularPower = 96; // более чёткий блик на стекле
mat.backFaceCulling = false; // видно «толщину» — глубже эффект
break; break;
case 'neon': case 'neon':
mat.emissiveColor = Color3.FromHexString(color || '#888888'); mat.emissiveColor = Color3.FromHexString(color || '#888888');
@ -645,39 +571,6 @@ export class PrimitiveManager {
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
break; break;
} }
case 'chrome': {
// Хром/зеркало: яркий узкий блик + лёгкое самосвечение цвета,
// чтобы поверхность «играла» даже без cubemap-отражений (их не
// грузим — экономим ассеты и FPS на детских машинах).
const cc = Color3.FromHexString(color || '#cfd6e0');
mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6);
mat.specularColor = new Color3(1, 1, 1);
mat.specularPower = 128; // узкий резкий блик = «металл»
mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14);
break;
}
case 'water': {
// Вода: полупрозрачный голубой с бликами. Анимацию ряби делает
// GraphicsManager/Environment по флагу mesh._isWater (опционально).
const wc = Color3.FromHexString(color || '#3aa0ff');
mat.diffuseColor = wc;
mat.alpha = 0.55;
mat.specularColor = new Color3(0.9, 0.95, 1.0);
mat.specularPower = 64;
mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2);
mesh._isWater = true;
break;
}
case 'iridescent': {
// Переливы: насыщенное самосвечение + блик. Цвет «бензиновой
// плёнки» — приятно для кристаллов, мыльных пузырей, порталов.
const ic = Color3.FromHexString(color || '#a06bff');
mat.diffuseColor = ic;
mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6);
mat.specularColor = new Color3(1, 1, 1);
mat.specularPower = 96;
break;
}
case 'matte': case 'matte':
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
break; break;
@ -845,14 +738,6 @@ export class PrimitiveManager {
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1; data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
scaleChanged = true; 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) { if (scaleChanged) {
// Поскольку MeshBuilder уже создал mesh с базовыми размерами, // Поскольку MeshBuilder уже создал mesh с базовыми размерами,
// изменения через scaling кажутся правильными. Простой способ — // изменения через scaling кажутся правильными. Простой способ —
@ -1012,10 +897,7 @@ export class PrimitiveManager {
const oldMat = oldMesh.material; const oldMat = oldMesh.material;
const typeDef = getPrimitiveType(data.type); 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); const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
this._ladderStepCount = undefined;
newMesh.position = oldPos; newMesh.position = oldPos;
if (oldRot) newMesh.rotation = oldRot; if (oldRot) newMesh.rotation = oldRot;
// studs — материал пересоздаём заново (свежий faceUV/тайлинг + текстура // studs — материал пересоздаём заново (свежий faceUV/тайлинг + текстура
@ -1101,8 +983,6 @@ export class PrimitiveManager {
...(d.light ? { brightness: d.brightness, range: d.range } : {}), ...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter') // Параметр эмиттера (только для type='emitter')
...(d.effect !== undefined ? { effect: d.effect } : {}), ...(d.effect !== undefined ? { effect: d.effect } : {}),
// Число ступенек лестницы (только для type='ladder_vertical')
...(d.type === 'ladder_vertical' ? { stepCount: d.stepCount } : {}),
// Параметры билборда (только для type='billboard') // Параметры билборда (только для type='billboard')
...(d.billboard ? { ...(d.billboard ? {
template: d.billboard.template, template: d.billboard.template,

View File

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

View File

@ -3890,53 +3890,6 @@ const game = {
_send('environment.setTimeOfDay', { hours: h }); _send('environment.setTimeOfDay', { hours: h });
}, },
}, },
/**
* graphics визуальные эффекты («шейдеры»): постобработка, свечение,
* цветокоррекция, тени. По умолчанию всё выключено.
*/
graphics: {
/** Применить пресет: 'off'|'low'|'medium'|'high'|'ultra'|
* 'cinematic'|'vivid'|'night'|'retro'|'soft'. */
setPreset(preset) {
if (typeof preset !== 'string') return;
_send('graphics.set', { preset });
},
/** Тонкая настройка (поверх текущего): передай любые секции
* {bloom, vignette, grading, dof, ssao, fxaa, shadows}. */
set(settings) {
if (typeof settings !== 'object' || !settings) return;
_send('graphics.set', settings);
},
/** Свечение. on:bool, opts:{intensity:0..1, threshold:0..1}. */
setBloom(on, opts) {
_send('graphics.set', { bloom: { enabled: !!on, ...(opts || {}) } });
},
/** Виньетка (затемнение углов). weight: 0..1.5, 0 = выкл. */
setVignette(weight) {
const w = Number(weight) || 0;
_send('graphics.set', { vignette: { enabled: w > 0, weight: w } });
},
/** Цветокоррекция: {contrast, saturation, exposure} (1.0 = норма). */
setColorGrading(opts) {
if (typeof opts !== 'object' || !opts) return;
_send('graphics.set', { grading: { enabled: true, ...opts } });
},
/** Сглаживание (FXAA). */
setAntialiasing(on) { _send('graphics.set', { fxaa: !!on }); },
/** Глубина резкости: on:bool, opts:{focusDistance, focalLength, aperture}. */
setDepthOfField(on, opts) {
_send('graphics.set', { dof: { enabled: !!on, ...(opts || {}) } });
},
/** Качество теней: 'off'|'hard'|'soft'|'medium'|'high'. */
setShadows(quality) {
if (typeof quality !== 'string') return;
_send('graphics.set', { shadows: quality });
},
/** Контактные тени (SSAO). */
setSSAO(on) { _send('graphics.set', { ssao: !!on }); },
/** Полностью выключить эффекты. */
off() { _send('graphics.set', { preset: 'off' }); },
},
save: { save: {
/** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */
get(namespace, fn) { get(namespace, fn) {

View File

@ -826,11 +826,6 @@ export class SelectionManager {
} else if (it.kind === 'primitive') { } else if (it.kind === 'primitive') {
const data = this.primitiveManager?.instances.get(it.ref); const data = this.primitiveManager?.instances.get(it.ref);
if (data?.mesh) this._highlightMesh(data.mesh); if (data?.mesh) this._highlightMesh(data.mesh);
} else if (it.kind === 'userModel') {
const data = this.userModelManager?.instances.get(it.ref);
if (data?.meshes) {
for (const m of data.meshes) this._highlightMesh(m);
}
} }
} }
} }
@ -838,149 +833,6 @@ export class SelectionManager {
/** Получить массив multi-selection. */ /** Получить массив multi-selection. */
getMultiSelection() { return [...this._multi]; } getMultiSelection() { return [...this._multi]; }
/**
* Установить multi-выделение из списка {kind, ref} (рамка выделения).
* additive=true добавить к текущему (Ctrl+рамка), иначе заменить.
* Если в итоге 0 объектов clear; если 1 обычный single-select.
*/
setMultiSelection(items, additive = false) {
const eq = (a, b) => {
if (a.kind !== b.kind) return false;
if (a.kind === 'block') return a.ref.x === b.ref.x && a.ref.y === b.ref.y && a.ref.z === b.ref.z;
return a.ref === b.ref;
};
let next;
if (additive) {
// Стартуем с текущего multi (или текущего single, развёрнутого в элемент).
next = [...this._multi];
if (next.length === 0 && this._selection) {
const s = this._selection;
if (s.type === 'block') next.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } });
else if (s.type === 'model') next.push({ kind: 'model', ref: s.instanceId });
else if (s.type === 'primitive') next.push({ kind: 'primitive', ref: s.id });
else if (s.type === 'userModel') next.push({ kind: 'userModel', ref: s.instanceId });
}
for (const it of items) {
if (!next.some(x => eq(x, it))) next.push(it);
}
} else {
next = [...items];
}
this._removeHighlight();
if (next.length === 0) {
this._multi = [];
this._selection = null;
this._notifyChange();
return;
}
if (next.length === 1) {
this._multi = [];
const only = next[0];
if (only.kind === 'block') this.selectBlockAt(only.ref.x, only.ref.y, only.ref.z);
else if (only.kind === 'model') this.selectModelByInstanceId(only.ref);
else if (only.kind === 'primitive') this.selectPrimitiveById(only.ref);
else if (only.kind === 'userModel') this.selectUserModelByInstanceId(only.ref);
return;
}
this._multi = next;
this._highlightAllMulti();
this._selection = { type: 'multi', count: this._multi.length, items: [...this._multi] };
this._notifyChange();
}
/**
* Развернуть multi-элемент в его data + mesh + текущую позицию.
* Возвращает { kind, data, mesh, pos:{x,y,z} } или null.
*/
_resolveMultiItem(it) {
if (it.kind === 'block') {
const mesh = this.blockManager?.blocks.get(`${it.ref.x},${it.ref.y},${it.ref.z}`);
return { kind: 'block', mesh, pos: { x: it.ref.x, y: it.ref.y, z: it.ref.z }, ref: it.ref };
}
if (it.kind === 'model') {
const d = this.modelManager?.instances.get(it.ref);
if (!d) return null;
return { kind: 'model', data: d, mesh: d.rootMesh, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
}
if (it.kind === 'userModel') {
const d = this.userModelManager?.instances.get(it.ref);
if (!d) return null;
return { kind: 'userModel', data: d, mesh: d.rootNode, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
}
if (it.kind === 'primitive') {
const d = this.primitiveManager?.instances.get(it.ref);
if (!d) return null;
return { kind: 'primitive', data: d, mesh: d.mesh, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
}
return null;
}
/** Центр multi-выделения (среднее позиций всех объектов). */
getMultiCenter() {
if (!this._multi.length) return null;
let sx = 0, sy = 0, sz = 0, n = 0;
for (const it of this._multi) {
const r = this._resolveMultiItem(it);
if (!r) continue;
sx += r.pos.x; sy += r.pos.y; sz += r.pos.z; n++;
}
if (!n) return null;
return { x: sx / n, y: sy / n, z: sz / n };
}
/**
* Сдвинуть ВСЕ объекты multi-выделения на (dx,dy,dz).
* Блоки переустанавливаются (block-операция через grid-координаты),
* модели/примитивы/user-модели двигают свой root-mesh и data.
* Блоки двигаем с округлением дельты к целым клеткам (сетка).
*/
moveMultiBy(dx, dy, dz) {
if (!this._multi.length) return;
// Блоки: только целочисленный сдвиг по сетке. Накапливаем дробную
// часть снаружи (в BabylonScene), сюда приходит уже округлённая для
// блоков дельта — но на всякий случай округляем здесь.
const bdx = Math.round(dx), bdy = Math.round(dy), bdz = Math.round(dz);
const newBlocks = [];
for (const it of this._multi) {
if (it.kind === 'block') {
const { x, y, z } = it.ref;
const typeId = this.blockManager?.blocks.get(`${x},${y},${z}`)?.metadata?.blockTypeId;
if (typeId == null) continue;
this.blockManager.removeBlock(x, y, z);
newBlocks.push({ x: x + bdx, y: Math.max(0, y + bdy), z: z + bdz, typeId, ref: it.ref });
}
}
for (const nb of newBlocks) {
this.blockManager.addBlock(nb.x, nb.y, nb.z, nb.typeId);
// Обновляем ссылку в _multi на новую клетку.
nb.ref.x = nb.x; nb.ref.y = nb.y; nb.ref.z = nb.z;
}
// Модели / примитивы / user-модели — двигаем плавно.
for (const it of this._multi) {
if (it.kind === 'block') continue;
const r = this._resolveMultiItem(it);
if (!r || !r.data) continue;
r.data.x = (r.data.x || 0) + dx;
r.data.y = (r.data.y || 0) + dy;
r.data.z = (r.data.z || 0) + dz;
if (r.mesh) {
if (r.data._worldMatrixFrozen) {
try { r.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
r.data._worldMatrixFrozen = false;
}
r.mesh.position.set(r.data.x, r.data.y, r.data.z);
}
}
// Перерисовать подсветку (меши блоков пересозданы).
this._removeHighlight();
this._highlightAllMulti();
this.modelManager?._notifyChange?.();
this.primitiveManager?._notifyChange?.();
this.userModelManager && this._scene3d?._syncUserModelColliders?.();
this._notifyChange();
}
/** Выделить ВСЁ в сцене (Ctrl+A). */ /** Выделить ВСЁ в сцене (Ctrl+A). */
selectAll() { selectAll() {
this._removeHighlight(); this._removeHighlight();

View File

@ -76,14 +76,6 @@ export class VehicleManager {
veh.bodyInstanceId = bodyId; veh.bodyInstanceId = bodyId;
const inst = this._models.instances.get(bodyId); const inst = this._models.instances.get(bodyId);
if (inst && inst.rootMesh) { if (inst && inst.rootMesh) {
// Кузов машины — динамический объект: им двигает VehicleManager
// (через парентинг к chassisNode). Исключаем из LOD-freeze, иначе
// freezeWorldMatrix замораживает меш и он перестаёт следовать за
// chassisNode → «едешь на невидимой машине, видимая стоит».
// (LOD меряет дистанцию по локальной root.position запарентенного
// кузова ≈0,0,0 — некорректно, и замораживает машину.)
inst._spawnedAtRuntime = true;
inst._isVehicleBody = true;
// Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга // Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
// (в мировых координатах, кузов ещё в (x,y,z)). // (в мировых координатах, кузов ещё в (x,y,z)).
try { try {

View File

@ -115,7 +115,7 @@ const KubikonPlayer = () => {
const mpSyncRef = useRef(null); const mpSyncRef = useRef(null);
/** Выбранный R15-скин текущего игрока (из rublox_equipped_skin). /** Выбранный R15-скин текущего игрока (из rublox_equipped_skin).
* Грузится при старте, уходит в мультиплеер как modelType. */ * Грузится при старте, уходит в мультиплеер как modelType. */
const skinFolderRef = useRef('skin_y-bot'); const skinFolderRef = useRef('skin_bacon-hair');
const [meta, setMeta] = useState(null); // { title, description, user_id, ... } const [meta, setMeta] = useState(null); // { title, description, user_id, ... }
const [forbidden, setForbidden] = useState(false); const [forbidden, setForbidden] = useState(false);
@ -446,43 +446,22 @@ const KubikonPlayer = () => {
}); });
// === Персональный скин игрока === // === Персональный скин игрока ===
// Источник скина по приоритету: // Грузим выбранный скин из БД (rublox_equipped_skin) и
// 1) hash-параметр #skin=<id> в URL (если сайт передал) // применяем его к локальному игроку ДО enterPlayMode
// 2) БД (rublox_equipped_skin через /equipped-skin) // тогда player.setModelType подхватит правильный скин.
// 3) localStorage студии (fallback для отладки) // Этот же skinFolder уйдёт в мультиплеер как modelType,
// 4) skin_y-bot (дефолт) // чтобы соперники видели наш реальный скин.
let mySkin = 'skin_y-bot'; let mySkin = 'skin_bacon-hair';
try { if (userId) {
const m = window.location.hash.match(/[#&]skin=([\w-]+)/);
if (m && m[1]) {
mySkin = m[1];
console.log('[KubikonPlayer] skin from URL:', mySkin);
}
} catch (e) {}
if (mySkin === 'skin_y-bot' && userId) {
try { try {
const skinRes = await Kubikon3DApi.getEquippedSkin(userId); const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
const sf = skinRes?.data?.skin_folder; const sf = skinRes?.data?.skin_folder;
if (sf && typeof sf === 'string') { if (sf && typeof sf === 'string') mySkin = sf;
mySkin = sf;
console.log('[KubikonPlayer] skin from DB:', mySkin);
}
} catch (e) { } catch (e) {
// Сеть/ошибка играем с дефолтным скином, не блокируем.
console.warn('[KubikonPlayer] equipped-skin load failed', e); console.warn('[KubikonPlayer] equipped-skin load failed', e);
} }
} }
const isLocalDev = (typeof window !== 'undefined'
&& (window.location.hostname === 'localhost'
|| window.location.hostname === '127.0.0.1'));
if (mySkin === 'skin_y-bot' && isLocalDev) {
try {
const localPick = localStorage.getItem('rublox_selected_skin');
if (localPick && typeof localPick === 'string') {
mySkin = localPick;
console.log('[KubikonPlayer] skin from local LS:', mySkin);
}
} catch (e) {}
}
skinFolderRef.current = mySkin; skinFolderRef.current = mySkin;
try { scene.setPlayerModelType?.(mySkin); } catch (e) {} try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
@ -667,7 +646,7 @@ const KubikonPlayer = () => {
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит // загружен при старте в skinFolderRef). Сервер всё равно перепроверит
// скин по userId из JWT и при расхождении возьмёт значение из БД // скин по userId из JWT и при расхождении возьмёт значение из БД
// так каждый игрок виден соперникам в своём реальном скине. // так каждый игрок виден соперникам в своём реальном скине.
const modelType = skinFolderRef.current || 'skin_y-bot'; const modelType = skinFolderRef.current || 'skin_bacon-hair';
const room = await client.joinOrCreate('battle', { const room = await client.joinOrCreate('battle', {
projectId: projectMeta?.id || projectId, projectId: projectMeta?.id || projectId,
token: tokenRaw, token: tokenRaw,
@ -789,8 +768,7 @@ const KubikonPlayer = () => {
|| root.webkitRequestFullscreen || root.webkitRequestFullscreen
|| root.mozRequestFullScreen || root.mozRequestFullScreen
|| root.msRequestFullscreen; || root.msRequestFullscreen;
// В десктоп-приложении (Electron) окно и так на весь экран FS не нужен. if (req) {
if (req && !(typeof window !== 'undefined' && window.__RUBLOX_DESKTOP__)) {
try { await req.call(root); } catch (e) { /* отменено */ } try { await req.call(root); } catch (e) { /* отменено */ }
} }
setMobileStartTapped(true); setMobileStartTapped(true);