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
49 changed files with 15780 additions and 3934 deletions

1
.gitignore vendored
View File

@ -42,3 +42,4 @@ Thumbs.db
# Большие png-ассеты вики (73МБ). Будут перенесены на CDN отдельной задачей.
/public/wiki/
rbxl-importer/src/__pycache__/

124
RBXL_SOURCES.md Normal file
View File

@ -0,0 +1,124 @@
# Реестр источников .rbxl / .rbxlx для портирования в Рублокс
Цель: легально добыть Roblox Place-файлы (.rbxl бинарный / .rbxlx XML) по жанрам
для портирования и публикации на Рублоксе.
**Форматы:** `.rbxlx` (XML — предпочтителен, читаемый, легко парсить геометрию/CFrame)
· `.rbxl` (бинарный, конвертировать) · `.rbxm`/`.rbxmx` (модели, не целые места).
> ⚠️ **Главное про публикацию:** «uncopylocked» ≠ свободная лицензия. Для ПУБЛИКАЦИИ
> порта на Рублоксе безопасны только: репо с явной **MIT/Apache/MPL/CC0/CC-BY** +
> официальные ассеты Roblox с разрешением. Архивы чужих игр — только для
> обучения/прототипа парсера, НЕ для публикации. Lua-скрипты не портируются
> автоматом — логику переписываешь сам (это и снижает юр.риски).
---
## ИНСТРУМЕНТЫ (распаковка/парсинг)
| Инструмент | URL | Назначение | Лицензия |
|---|---|---|---|
| Rojo | https://github.com/rojo-rbx/rojo | place ↔ файлы | MPL-2.0 |
| rbxlx-to-rojo | https://github.com/rojo-rbx/rbxlx-to-rojo | .rbxl/.rbxlx → проект | MPL/MIT (проверить) |
| rbxfile (Go) | https://github.com/robloxapi/rbxfile | парсинг rbxl/rbxlx/rbxm | MIT (проверить) |
| remodel | https://github.com/rojo-rbx/remodel | скриптовая обработка | MPL-2.0 |
| RobloxAPI/spec | https://github.com/RobloxAPI/spec/blob/master/formats/rbxl.md | спека бинарного формата | docs |
---
## (А) ОФИЦИАЛЬНЫЕ — самые надёжные
### A1. Roblox/Old-Open-Source-Levels — классика от самой Roblox Corp ⭐
- https://github.com/Roblox/Old-Open-Source-Levels
- Каталог: https://github.com/Roblox/Old-Open-Source-Levels/blob/master/catalog.md
- ~30+ мест 2007-2013 (.rbxl). Жанры: Crossroads (арена/PvP), Castle Warfare,
ROBLOX Battle (бой), Sword Fight in the Dark (PvP), Haunted Mansion (хоррор),
Glass Houses, Pinball Wizards, Happy Home in Robloxia (песочница/мини).
- Лицензия: «free to manipulate however you wish» — **проверь файл LICENSE вручную** перед публикацией.
### A2. Встроенные шаблоны Roblox Studio
- Список: https://create.roblox.com/docs/resources/templates
- В Studio: открыть шаблон → File → Save to File → .rbxlx
- Baseplate, Castle, Suburban, Village, Racing, Classic Obby, Team Deathmatch/Combat,
Capture the Flag, Line Runner, Pirate Island, Modern City и др.
- Серая зона для публикации «как есть» — используй как базу/учёбу, геометрию делай своей.
### A3. creator-docs (документация Roblox, open)
- https://github.com/Roblox/creator-docs
### A4. Internet Archive — Crossroads (все версии 2007-2017)
- https://archive.org/details/roblox_crossroads
- https://archive.org/details/classic-crossroads_202408
---
## (Б) РЕПОЗИТОРИИ С КОДОМ/МЕСТАМИ (URL из поиска)
### С подтверждённой свободной лицензией (можно публиковать)
| Репо | URL | Лицензия | Жанр |
|---|---|---|---|
| Vigilant | https://github.com/IsoLogicGames/Vigilant | **MIT** ✅ | co-op horde-survival (шутер) |
| crossroads-rojo | https://github.com/Dekkonot/crossroads-rojo | наследует Crossroads | арена |
### Open-source игры (лицензию проверить у каждого — файл LICENSE)
| Репо | URL | Жанр |
|---|---|---|
| Miner's Haven | https://github.com/berezaa/minershaven | tycoon/симулятор |
| roblox-gym-tycoon | https://github.com/jason-lee88/roblox-gym-tycoon | tycoon |
| Racing-Kit-Roblox | https://github.com/Astrophsica/Racing-Kit-Roblox | гонки |
| RENTED_old_rbx | https://github.com/ReRand/RENTED_old_rbx | хоррор |
| roblox-rpg | https://github.com/mobyrblx/roblox-rpg | RPG/демо |
| RobloxGames (dwmk) | https://github.com/dwmk/RobloxGames | разное |
| recsObby | https://github.com/Nimblz/recsObby | obby |
| WavyRobloxObby | https://github.com/sammy0127/WavyRobloxObby | obby (.rbxlx) |
| Sight-Obby | https://github.com/TeoJJss/Sight-Obby | obby |
| fps (Anninzy) | https://github.com/Anninzy/fps | FPS |
| roblox-game-example | https://github.com/areshaistg/roblox-game-example | демо-каркас |
### Архивы чужих игр (ТОЛЬКО обучение/прототип, НЕ публикация — смешанные права)
| Репо | URL |
|---|---|
| uncopylocked-game-collection | https://github.com/Kitaske/uncopylocked-game-collection |
| robloxplacearchive | https://github.com/tropicalbananas/robloxplacearchive |
| RobloxRBXLArchive | https://github.com/LuaGunsX/RobloxRBXLArchive |
| Biggest Uncopylocked Library | https://github.com/KH0DIN/Biggest_Uncopylocked_Roblox_Games_Library |
| GitHub topics | https://github.com/topics/rbxlx · /rbxl · /rbxm · /rojo · /uncopylocked |
---
## (В) САЙТЫ ДЛЯ САМОСТОЯТЕЛЬНОГО СКАЧИВАНИЯ
### Прямое скачивание .rbxl/.rbxlx
- **GitHub code search** (вход обязателен): `extension:rbxlx`, `extension:rbxl`,
`filename:default.project.json` (корень Rojo-проекта рядом с местом)
https://github.com/search?q=extension%3Arbxlx&type=code
- **GitHub Topics:** https://github.com/topics/rbxlx · https://github.com/topics/rojo
- **Internet Archive:** https://archive.org/ — поиск «roblox place», «rbxl», «crossroads»
### CC0/CC-BY геометрия для воссоздания (юридически чистейший путь, не .rbxl но low-poly близко к Roblox)
- **Kenney** (CC0): https://kenney.nl/assets — Platformer/Nature/Car/Pirate/City/Prototype Kit, Blocky Characters
- **OpenGameArt** (CC0/CC-BY): https://opengameart.org/ — voxel/low-poly паки
- **itch.io** (фильтр assets+CC0): https://itch.io/game-assets/free/tag-low-poly
- **Poly Pizza** (CC0/CC-BY low-poly): https://poly.pizza/
- **Quaternius** (CC0 low-poly паки): https://quaternius.com/
### Сообщества с открытыми играми (часто прямые ссылки + лицензия)
- DevForum «free & open-sourced games»: https://devforum.roblox.com/t/lots-of-free-open-sourced-games/525670
- DevForum «Open Source Arena FPS»: https://devforum.roblox.com/t/open-source-arena-fps/1034576
- Uplift Games open source: https://www.uplift.games/open-source
---
## ЮРИДИЧЕСКИЕ ПРАВИЛА (коротко)
- ✅ Публиковать можно: **MIT / Apache-2.0 / MPL-2.0 / CC0 / CC-BY** (CC-BY — с атрибуцией).
- ❌ Нельзя: **GPL/AGPL** (заразные), **CC-BY-NC** (некоммерч.), **без лицензии** (= all rights reserved),
чужие игры через game-savers/декомпиляторы (нарушение DMCA/ToS).
- ⚠️ «Uncopylocked» = только разрешение копировать в Studio, НЕ передача прав.
- ⚠️ Официальные шаблоны Studio — учиться ОК, публиковать «как есть» — серая зона.
**Рекомендация для наполнения Рублокса легально:**
1. Геометрия под чистую публикацию → Kenney/OpenGameArt CC0.
2. Классика Roblox-стиля → Roblox/Old-Open-Source-Levels (проверить LICENSE) + Crossroads.
3. Полная игра с кодом → Vigilant (MIT).
4. Масса .rbxl для теста парсера → архивы из (Б) + GitHub topics.

504
RUBLOX_LUA_API.md Normal file
View File

@ -0,0 +1,504 @@
# Lua API Рублокса (справочник для скриптеров)
Этот документ — полный список того, что работает в Lua-скриптах Рублокса.
API максимально приближен к Roblox, чтобы можно было переносить чужие
скрипты с минимальными правками.
> **Как переключить скрипт на Lua:** в шапке вкладки редактора кода кликни
> по переключателю **JS / Lua**. Подсветка синтаксиса и автодополнение
> автоматически переключатся.
---
## Содержание
1. [Базовые типы](#базовые-типы)
2. [DataModel: game, workspace, Players](#datamodel)
3. [Part — куб на сцене](#part)
4. [Создание и удаление](#создание-и-удаление)
5. [События: Touched, Heartbeat, RemoteEvent](#события)
6. [Таймеры: task.wait, task.delay](#таймеры)
7. [GUI: TextLabel, TextButton, Frame](#gui)
8. [Звук: Sound](#звук)
9. [Анимации: TweenService](#tweenservice)
10. [Игрок: Humanoid, LocalPlayer](#игрок)
11. [Чего пока нет](#чего-пока-нет)
---
## Базовые типы
### `Vector3`
```lua
local v = Vector3.new(1, 2, 3)
print(v.X, v.Y, v.Z) -- 1 2 3
print(v.Magnitude) -- 3.7416... (длина)
print(v.Unit) -- нормализованный
print(v:Dot(otherVec)) -- скалярное произведение
print(v:Cross(otherVec)) -- векторное произведение
local mid = v:Lerp(otherVec, 0.5) -- линейная интерполяция
-- Константы:
Vector3.zero -- (0,0,0)
Vector3.one -- (1,1,1)
Vector3.xAxis -- (1,0,0)
Vector3.yAxis, Vector3.zAxis
```
Поддержаны операторы: `+`, `-`, `*` (на число), `/`, унарный `-`.
### `Color3`
```lua
local c = Color3.new(0.5, 0.2, 0.8) -- 0..1 каждый
local c2 = Color3.fromRGB(255, 128, 0) -- 0..255
local c3 = Color3.fromHSV(0.1, 0.8, 1)
local c4 = Color3.fromHex("#FF8000")
local mid = c:Lerp(c2, 0.5)
print(c:ToHex()) -- "#7F33CC"
```
### `UDim2` / `UDim` / `Vector2`
Для GUI-координат:
```lua
local pos = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана (scale/offset)
local pos2 = UDim2.fromScale(0.2, 0.1)
local pos3 = UDim2.fromOffset(100, 50) -- в пикселях
```
### `CFrame`
```lua
local cf = CFrame.new(0, 10, 0) -- позиция
local cf2 = CFrame.lookAt(eye, target) -- упрощённый
print(cf.Position) -- Vector3
```
### `Enum`
```lua
Enum.KeyCode.W
Enum.KeyCode.Space
Enum.Material.Plastic, Enum.Material.Neon, Enum.Material.Wood
Enum.UserInputType.MouseButton1
Enum.HumanoidStateType.Running
```
---
## DataModel
Виртуальное дерево, как в Roblox:
```lua
game -- корневой DataModel
game.Workspace -- = workspace (короче)
game.Players -- сервис игроков
game.Players.LocalPlayer -- локальный игрок
game.ReplicatedStorage -- хранилище общих ресурсов
game.StarterGui -- стартовое GUI
game.Lighting -- свет
```
Методы:
```lua
local svc = game:GetService("RunService")
local part = workspace:FindFirstChild("Coin")
local part2 = workspace:FindFirstChildOfClass("Part")
local all = workspace:GetChildren() -- массив всех детей
local descendants = workspace:GetDescendants()
local sib = workspace.Coin:FindFirstAncestorOfClass("Workspace")
print(workspace:IsA("Workspace")) -- true
```
---
## Part
`Part` — куб/сфера/цилиндр на сцене. **Это обёртка над примитивом Рублокса.**
Скрипт привязанный к кубу получает его через `script.Parent`:
```lua
-- script.Parent — Part к которому прицеплен скрипт
print(script.Parent.Name) -- "Part_1"
-- Чтение свойств
print(script.Parent.Position) -- Vector3
print(script.Parent.Size) -- Vector3
print(script.Parent.Color) -- Color3
print(script.Parent.Anchored) -- bool
print(script.Parent.CanCollide) -- bool
print(script.Parent.Transparency) -- 0..1
-- Запись (двигает куб в реальном времени!)
script.Parent.Position = Vector3.new(0, 10, 0)
script.Parent.Size = Vector3.new(5, 1, 5)
script.Parent.Color = Color3.fromRGB(255, 0, 0)
script.Parent.Anchored = false -- куб начнёт падать (физика)
script.Parent.Transparency = 0.5 -- полупрозрачный
script.Parent.CFrame = CFrame.new(0, 20, 0)
```
---
## Создание и удаление
### `Instance.new`
```lua
-- Создать Part на сцене
local p = Instance.new("Part")
p.Position = Vector3.new(0, 5, 0)
p.Size = Vector3.new(2, 2, 2)
p.Color = Color3.fromRGB(255, 100, 0)
p.Anchored = true
p.Parent = workspace
-- Удалить через 3 секунды
task.delay(3, function()
p:Destroy()
end)
```
Поддержанные классы:
- **Сцена:** `Part`, `WedgePart`, `MeshPart`
- **События:** `RemoteEvent`, `BindableEvent`
- **GUI:** `ScreenGui`, `Frame`, `TextLabel`, `TextButton`, `ImageLabel`,
`ImageButton`, `TextBox`, `ScrollingFrame`
- **Звук:** `Sound`
- **Прочее:** `Folder`, `Humanoid`, `Configuration`, любой `ClassName`
---
## События
### `script.Parent.Touched` — касание игрока
```lua
script.Parent.Touched:Connect(function(hit)
print("Игрок коснулся!", hit.Name)
local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid")
if h then
h:TakeDamage(100) -- KillBrick
end
end)
```
### `RunService.Heartbeat` — каждый кадр
```lua
local RunService = game:GetService("RunService")
RunService.Heartbeat:Connect(function(dt)
-- dt — время с прошлого кадра (~0.016)
script.Parent.Position = script.Parent.Position + Vector3.new(0, 0.1, 0)
end)
```
### `BindableEvent` / `RemoteEvent` — общение между скриптами
```lua
-- Скрипт A создаёт событие в общем месте
local event = Instance.new("BindableEvent")
event.Name = "MyEvent"
event.Parent = game.ReplicatedStorage
-- Скрипт B подписывается
local event = game.ReplicatedStorage:WaitForChild("MyEvent")
event.Event:Connect(function(msg, num)
print("Получено:", msg, num)
end)
-- Скрипт A триггерит
event:Fire("привет", 42)
```
### `Humanoid.Died`
```lua
local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid")
h.Died:Connect(function()
print("игрок умер")
end)
h.HealthChanged:Connect(function(newHp)
print("здоровье:", newHp)
end)
```
---
## Таймеры
### `task.wait(сек)` — приостановить скрипт
```lua
print("сейчас")
task.wait(1)
print("через секунду")
```
`task.wait` **не блокирует** другие скрипты — это yield через coroutines.
Можно использовать в `while true do ... task.wait(0.1) end` без проблем.
### `task.delay(сек, fn)` — выполнить через
```lua
task.delay(2, function()
print("через 2 секунды")
end)
```
### `task.spawn(fn)` — асинхронно
```lua
task.spawn(function()
print("параллельно с основным потоком")
end)
```
---
## GUI
### Базовая иерархия
```lua
-- ScreenGui — корень всех GUI
local sg = Instance.new("ScreenGui")
sg.Parent = game.Players.LocalPlayer.PlayerGui
-- TextLabel — статичный текст
local label = Instance.new("TextLabel")
label.Parent = sg
label.Text = "Привет!"
label.TextColor3 = Color3.fromRGB(255, 255, 0)
label.BackgroundColor3 = Color3.fromRGB(50, 30, 20)
label.Position = UDim2.new(0.4, 0, 0.1, 0) -- 40% от ширины, 10% от высоты
label.Size = UDim2.new(0.2, 0, 0.05, 0)
label.TextSize = 24
-- TextButton — кликабельная кнопка
local btn = Instance.new("TextButton")
btn.Parent = sg
btn.Text = "Нажми"
btn.Position = UDim2.new(0.4, 0, 0.5, 0)
btn.Size = UDim2.new(0.2, 0, 0.08, 0)
btn.MouseButton1Click:Connect(function()
print("Клик!")
label.Text = "Нажата!"
end)
```
### Свойства
| Свойство | Тип | Описание |
|----------------------|-----------|-----------------------------------|
| `Text` | string | Видимый текст |
| `TextColor3` | Color3 | Цвет текста |
| `TextSize` | number | Размер шрифта |
| `BackgroundColor3` | Color3 | Цвет фона |
| `BackgroundTransparency` | 0..1 | 0=сплошной, 1=прозрачный |
| `Position` | UDim2 | Позиция (scale=%, offset=px/10) |
| `Size` | UDim2 | Размер |
| `Visible` | bool | Виден или нет |
### События кнопок
```lua
btn.MouseButton1Click:Connect(fn) -- ЛКМ клик
btn.MouseEnter:Connect(fn) -- наведение
btn.MouseLeave:Connect(fn) -- увод
btn.Activated:Connect(fn) -- = MouseButton1Click
```
---
## Звук
```lua
local sound = Instance.new("Sound")
sound.SoundId = "coin" -- или "jump", "win", "lose", "hit", "click", "pickup"
sound.Volume = 1 -- 0..2
sound.PlaybackSpeed = 1 -- pitch
sound:Play()
```
Также Roblox-AssetID работает с эвристикой:
```lua
sound.SoundId = "rbxassetid://1234567890" -- автоподбор по имени переменной
```
Поддержанные звуки (процедурные, не из файлов):
- `jump` — прыжок
- `pickup` — подбор
- `coin` — звон монеты
- `win` — победа
- `lose` — поражение
- `click` — клик
- `hit` — удар
Зацикливание:
```lua
sound.Looped = true
sound:Play() -- играет до sound:Stop()
```
---
## TweenService
Плавная анимация свойств:
```lua
local TweenService = game:GetService("TweenService")
local part = script.Parent
local tween = TweenService:Create(
part,
{ Time = 2 }, -- длительность 2 сек
{ Position = Vector3.new(0, 20, 0),
Color = Color3.fromRGB(255, 0, 0) } -- цели
)
tween:Play()
tween.Completed:Connect(function()
print("Анимация завершилась!")
end)
```
Работает с `Position`, `Size`, `Color` (Vector3/Color3) и числовыми
свойствами (`Transparency`, `TextSize`, и т.д.).
---
## Игрок
### `game.Players.LocalPlayer`
```lua
local plr = game.Players.LocalPlayer
print(plr.Name, plr.UserId, plr.DisplayName)
print(plr.Character) -- Model
```
### `Humanoid`
```lua
local char = game.Players.LocalPlayer.Character
local h = char:FindFirstChildOfClass("Humanoid")
print(h.Health, h.MaxHealth)
print(h.WalkSpeed) -- скорость ходьбы
print(h.JumpPower) -- сила прыжка
h.Health = 0 -- мгновенная смерть → респавн
h:TakeDamage(50) -- урон с учётом invulnerability
h.Died:Connect(function()
print("Помер")
end)
h.HealthChanged:Connect(function(newHp)
if newHp < 30 then
print("Здоровье низкое!")
end
end)
```
### `HumanoidRootPart`
```lua
local hrp = char:FindFirstChild("HumanoidRootPart")
print(hrp.Position)
```
---
## Чего пока нет
Не работает (пока):
- **Скрипты не делятся на Server/LocalScript** — все скрипты client-side.
- **DataStoreService** — методы есть, но возвращают nil/no-op.
- **`workspace:Raycast`** / **`game.Lighting.ClockTime`** — заглушки.
- **`Players.PlayerAdded`** — никогда не фейерится (только один игрок).
- **3D-анимации (`Animation` instance + `AnimationController`)**
`LoadAnimation` возвращает заглушку.
- **`Sound` из файлов** — только встроенные процедурные.
- **`SurfaceGui` / `BillboardGui`** — нет, только `ScreenGui`.
- **`Model:MoveTo` / `:SetPrimaryPartCFrame`** — нет.
- **Networking (`RemoteFunction:InvokeServer`)** — RemoteEvent работает
только в пределах одного клиента.
Если что-то из этого критично — открой issue в репо.
---
## Пример: KillBrick + монета + GUI-счётчик
Положи 1 куб и 1 шарик на сцене. К каждому привяжи скрипт:
**На кубе (KillBrick):**
```lua
script.Parent.Color = Color3.fromRGB(200, 30, 30)
script.Parent.Touched:Connect(function()
local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid")
if h then h:TakeDamage(100) end
end)
```
**На шарике (Coin):**
```lua
script.Parent.Color = Color3.fromRGB(255, 215, 0)
script.Parent.Touched:Connect(function()
-- Запускаем событие на ReplicatedStorage
local re = game.ReplicatedStorage:FindFirstChild("CoinPicked")
if not re then
re = Instance.new("BindableEvent")
re.Name = "CoinPicked"
re.Parent = game.ReplicatedStorage
end
re:Fire()
script.Parent:Destroy()
end)
```
**Глобальный скрипт (GUI):**
```lua
local sg = Instance.new("ScreenGui")
sg.Parent = game.Players.LocalPlayer.PlayerGui
local label = Instance.new("TextLabel")
label.Parent = sg
label.Text = "Монет: 0"
label.Position = UDim2.new(0.05, 0, 0.05, 0)
label.Size = UDim2.new(0.1, 0, 0.05, 0)
label.TextSize = 20
label.TextColor3 = Color3.fromRGB(255, 215, 0)
local count = 0
task.spawn(function()
while not game.ReplicatedStorage:FindFirstChild("CoinPicked") do
task.wait(0.1)
end
game.ReplicatedStorage.CoinPicked.Event:Connect(function()
count = count + 1
label.Text = "Монет: " .. count
local sound = Instance.new("Sound")
sound.SoundId = "coin"
sound:Play()
end)
end)
```
Получится: красный куб убивает, золотая монета даёт +1 к счётчику со
звуком.
---
**Версия документации:** Этап 7 (готово после реализации Этапов 1-6).
Если что-то описанное здесь не работает — это баг, репортуй.

431
RUBLOX_LUA_API_CHANGELOG.md Normal file
View File

@ -0,0 +1,431 @@
# Lua API — журнал изменений
Файл фиксирует **что было добавлено в Lua-runtime** при работе с реальными
Roblox-играми. Цель — потом продублировать тот же API для **JS-движка**
(на будущее, сейчас работаем только с Lua).
Формат: дата + что и почему + куда добавлено + надо ли портировать в JS.
---
## 2026-06-08 — Итерация 4: Spawn-fix + философия импорта
**Контекст:** МИН подтвердил после ROBLOX Battle: 100% покрытие Lua-скриптов
из Roblox не получится (наш wasmoon не yield'ит из JS C-call boundary,
старый Roblox-pattern WaitForChild через ChildAdded:wait тривиально вешает
страницу). **Цель импорта сменилась**: показать геометрию и базовые
интеракции, а не полную скриптовую логику.
### Spawn fix (карта проекта 2853)
После переимпорта одной из карт игрок появлялся **внутри Anchored
геометрии** (стена/пол), не мог двигаться. Причина: SpawnLocation в старых
.rbxl ставится впритык к плите (Y+0.5), наш отступ +1.5 не спасал от
толстых Floor'ов 2-3 units high. Anchored=True (наш force-fix) не давал
выпрыгнуть.
Фиксы в `converter.py`:
1. **SpawnLocation +5** вместо +1.5. Если spawn внутри толстого пола —
гравитация уронит обратно за 1 кадр, не страшно. Если выше — отлично.
2. **Auto-fallback** если SpawnLocation в карте НЕ был (или дефолт остался
`(0, 2, 0)`):
```python
max_top = max(p['y'] + p['sy']/2 for p in primitives)
scene['spawnPoint'] = {x: 0, y: max_top + 5, z: 0}
```
Игрок появляется над самой высокой Part'ой → падает на крышу.
### Философия импорта (зафиксировано как принцип)
**Цель импорта .rbxl** = показать геометрию и сцену, а не воспроизвести
скриптовое поведение. Что работает (важно):
- ✅ Все примитивы (Part/Wedge/CornerWedge/Truss/Union/MeshPart)
- ✅ Цвета через BrickColor (расширенная палитра 120 цветов)
- ✅ Anchored=True для всех (карта не рассыпается)
- ✅ SpawnLocation с правильным Y (игрок не в стене)
- ✅ Корректный CFrame YXZ (мостики/wedge'и стоят правильно)
- ✅ Скайбокс, освещение, экспозиция/контраст через слайдеры
- ✅ Простые Touched-скрипты (Bouncer, BattleArmor, KillBrick)
- ✅ Tools.Equipped/Activated handlers (часть оружия)
Что НЕ воспроизводится (принимаем):
- ❌ Сложные RoundScript / GameClock / Spawner / KillFeed-логика
- ❌ WaitForChild через while+:wait() паттерны (regex-фильтр пропускает)
- ❌ Регенерация построек (Regenerate*) — не нужна т.к. Anchored
- ❌ LeaderboardV3 с DataStore (пропускается)
- ❌ Сетевые RemoteEvent/RemoteFunction (single-player только)
### Когда снова работать со скриптами
Если попадётся **новая карта (2015+)**`WaitForChild` встроен в API,
наш regex-фильтр не сработает. Скрипты пройдут больше и будут работать
лучше. Старые карты (2007-2010) принципиально ограничены.
### Что НЕ делать
- Не пытаться "ещё раз" решить yield-across-C-boundary через debug.sethook
или pcall-трюки. Проверено — не работает с wasmoon.
- Не переписывать wasmoon — это месяцы работы.
- Не сужать regex-фильтр в надежде запустить ещё пару скриптов — лучше
пусть пропустится лишний, чем висит страница.
### Что делать дальше
- Идти по .rbxl из Desktop/RBLX/ как пользователь.
- На каждой карте проверять: геометрия загрузилась? игрок ходит? видна?
- Если виснет — добавлять regex-паттерн в фильтр.
- Если игрок застрял — улучшать spawn-fallback.
- Если падают конкретные API — реализовывать в shim (как Mouse.Icon,
BodyVelocity-bouncer, leaderstats).
### В JS
Все фиксы spawn + философия общая для студии и плеера.
---
## 2026-06-08 — Итерация 3: ROBLOX Battle (arch1_ROBLOX_Battle_v2.rbxl, проект 2851)
**Контекст:** PvP-арена 2009 в XML, 1677 примитивов, 66 скриптов, 4 команды
(TeamBeacon), 5 оружий, 12 батутов, KillFeed, раунды.
### Реализовано 11 механик из 14
1. **Teams** — game.Teams сервис + Team-инстансы, эвристика TeamBeacon-Model
в converter.py → автоматически создаёт 4 команды по имени.
2. **Leaderstats UI** — IntValue.Value реактивно через Object.defineProperty,
при Parent=leaderstats шлёт leaderstatSet → существующий LeaderstatsManager.
3. **BindableFunction/RemoteFunction** + Message/Hint классы с реактивным Text.
4. **KillFeed UI** + creator-tag tracking в Humanoid.TakeDamage. DOM-overlay.
5. **SpawnLocation.TeamColor** → scene.team_spawns[].
6. **Tool/Model:Clone()** + :MakeJoints/:BreakJoints/:Remove no-op.
7. **Creator-tag**: ObjectValue.Name='creator' проверяется на Health=0.
8. **RegenerationScript** — no-op skip по имени (Anchored=True держит).
9. **BattleArmor** — реактивный Humanoid.MaxHealth/Health/WalkSpeed/JumpPower.
10. **WinGui/FireButton** через GuiManager.
11. **AdminConsole** — no-op.
12. **Bouncer** — BodyVelocity.Y > 10 + Parent=Torso → playerSet jumpVelocity.
14. **Mouse.Icon** → CSS cursor через canvas.style.cursor.
Также добавлены: **tick/time/delay/spawn/LoadLibrary** legacy globals,
**SpecialMesh/BlockMesh/CylinderMesh/FileMesh** Instance.new стабы.
### Новый модуль RbxlHudOverlay.js
DOM-оверлей поверх canvas с KillFeed (правый верх, fade 5с) + Message
(центр верх) + WinGui (центр). Lazy-создаётся.
### Tight-loop защита (КРИТИЧНО)
Roblox 2009 паттерн:
```lua
while not parent:FindFirstChild(name) do parent.ChildAdded:wait() end
```
Наш Signal:wait() возвращает синхронно — цикл бесконечный, страница виснет.
**Не можем yield** из JS-функции через wasmoon C-call boundary.
Перепробовали:
- debug.sethook(yield, 'i', N) — внутри C-call падает с `yield across C-call`.
- pcall(coroutine.yield) — ошибка ловится, счётчик не сбрасывается, вис.
**Финал**: regex-фильтр в GameRuntime.js пропускает скрипты с этими паттернами.
Из 66 скриптов 37 пропущены, 29 работают. Жертвы: RoundScript, GameClock,
Spawner, KillFeed, LeaderboardV3, оружие Launcher/Sword/Slingshot/Cannon.
### CFrame YXZ Euler
Переписал `to_euler_xyz` в `rbxl_types.py` под Babylon YXZ convention:
rx=asin(-r12), ry=atan2(r02,r22), rz=atan2(r10,r11) + gimbal-lock guard.
Раньше извлекал XYZ-Euler, Babylon применял как YXZ — мостики
поворачивались криво.
### Persistence настроек света
BabylonScene.serialize/loadFromState сохраняют scene.lighting:
sunIntensity, hemiIntensity, sceneAmbient, exposure, contrast, saturation.
### Известные баги
- `memory access out of bounds` (1 раз) — WASM-crash одного скрипта.
- `Cannot read properties of null ('then')` — wasmoon promise-detection,
скрипт init крашится но не блокирует.
- 0 teams при загрузке старого проекта — нужен переимпорт.
### В JS
✅ Всё: Teams формат общий, KillFeed/Message HUD общий для студии+плеера.
---
## 2026-06-08 — Итерация 2: Crossroads (arch1_Original_Crossroads.rbxl, проект 2827)
**Контекст:** Классическая Roblox-карта 2009 года для PvP, **XML-формат** .rbxl
(старее бинарного). 877 instances, 777 Part, 83 Model. Состоит из 4 зон:
крепость (Castle), дом (House Platform), деревья, дорожки крест-накрест.
2 скрипта: «Regenerate Playground» и «Regenerate Castle» — периодически
удаляют и восстанавливают постройки (для PvP).
### Главное: XML-парсер для .rbxl
`rbxl-importer/src/rbxl_xml_parser.py` (новый файл, ~330 строк):
- `is_xml_rbxl(blob)` — детект по `<roblox` без `!` (binary имеет magic `<roblox!`).
- `parse_xml(blob) → RobloxModel` — то же что `parse()` из binary parser'а,
совместимый формат, чтобы converter работал без изменений.
- Поддержанные property-теги: `string`, `bool`, `int`, `int64`, `float`,
`double`, `token`, `Vector3`, `Vector2`, `CoordinateFrame`, `Color3`,
`Color3uint8`, `BrickColor`, `Ref`, `BinaryString`, `UDim`, `UDim2`,
`Rect2D`, `OptionalCoordinateFrame`, `PhysicalProperties`, `NumberRange`,
`ProtectedString`, `Content`.
- Алиасы PascalCase: старые карты использовали `name/size/shape`
с маленькой буквы — добавлены как PascalCase для converter'а.
- `<int name="BrickColor">N</int>` — особый случай: в старом XML цвет
лежит как int с именем `BrickColor`, заворачиваем в `BrickColor(code=N)`.
В `app.py` добавлен автодетект формата:
```python
is_binary = blob.lstrip().startswith(b'<roblox!')
is_xml = blob.lstrip().startswith(b'<roblox') and not is_binary
if is_xml:
model = parse_xml(blob)
else:
model = parse(blob)
```
### Расширенная BrickColor палитра (converter.py)
Старая палитра: ~50 цветов. Новая: ~120 цветов. Главные добавления:
- **151 (Earth green)** — основная трава Crossroads (`#7c9b53`). Без неё
пол получал дефолтный `#cccccc` и выглядел белым на 344 примитива.
- 18, 26, 115-148, 168-301, 1021-1032 — заполнили дыры.
После: `#cccccc` дефолт упал с 344 → 68 на Crossroads (большинство цветов
теперь правильные).
### Anchored = True для всех импортированных Part
В `_convert_part`/`_convert_wedge`/`_convert_cornerwedge`/`_convert_truss`/
`_convert_meshpart`/`_convert_union` принудительно `'anchored': True`.
Причина: Roblox-карты держатся на **Welds** (склейки) или **BasePart-default
Anchored=true** (Crossroads). У нас Welds — заглушки, физика 700+ unanchored
Part'ов = карта рассыпается за 1 секунду (`Unanchored bodies: 767`).
После фикса: `Unanchored bodies: 0`, всё стоит на месте.
### Уважение поля `enabled` из метадаты
Уже было в Итерации 1, но напомню: скрипты с `Disabled=True` в Roblox
не запускаются. Парсер метадаты `parseRobloxLuaMeta()` смотрит вторую
строку packed-кода (JSON с `enabled`), если false — пропускаем.
### Визуальная настройка света
Главные находки:
1. **mat.ambientColor=(1,1,1)** обязательно — иначе `scene.ambientColor`
(«Заливка теней» слайдер) не влияет на материалы.
2. **mat.ambientColor=(цвет_от_diffuse)** даёт пересвет — не делать.
3. **scene.imageProcessingConfiguration** есть готовый в Babylon — даёт
exposure/contrast/saturation бесплатно.
В `BabylonScene.setLightingProps(patch)` добавлено:
- `patch.sceneAmbient` (0..1) → `scene.ambientColor` (заливка теней)
- `patch.exposure` (0.3..2) → `ipc.exposure`
- `patch.contrast` (0.5..2) → `ipc.contrast`
- `patch.saturation` (0..2) → `ipc.colorCurves.globalSaturation`
В `SelectionManager.selectLighting()` добавлены поля для чтения текущих значений.
В `InspectorPanel.jsx` добавлены 4 новых слайдера в «Свет и атмосфера»:
- Заливка теней (в блоке «Окружающий свет»)
- Экспозиция / Контраст / Насыщенность (новый блок «Цветокоррекция»)
### Persistence настроек света
`BabylonScene.serialize()` теперь включает `scene.lighting`:
```js
lighting: {
sunIntensity, hemiIntensity, sceneAmbient,
exposure, contrast, saturation,
}
```
`BabylonScene.loadFromState()` применяет эти параметры через `setLightingProps()`.
### Деплой rbxl-importer
**ВАЖНО:** rbxl-importer на VM 130 деплоится **напрямую через SSH**, не через CI/CD:
```bash
KEY="/c/Users/min/.ssh/id_ed25519"
scp -P 2280 -i "$KEY" rbxl-importer/src/FILE.py root@85.175.7.40:/tmp/
ssh -p 2280 -i "$KEY" root@85.175.7.40 \
"scp -P 22 -i /root/.ssh/id_ed25519 /tmp/FILE.py min@192.168.1.130:/tmp/ && \
ssh -p 22 -i /root/.ssh/id_ed25519 min@192.168.1.130 'sudo mv /tmp/FILE.py /opt/rbxl-importer/src/ && sudo systemctl restart rbxl-importer'"
```
S1 PVE root доступен по SSH-ключу `~/.ssh/id_ed25519` (без пароля).
См. [[vm130-direct-deploy]] в memory.
### Что НЕ доделано (известные баги Crossroads)
1. **2 скрипта падают `self2 is not a function`**:
- `Regenerate Playground` и `Regenerate Castle`.
- Используют `model:clone()` где `model = game.Workspace.Playground`
наш stub-Folder для Playground не имеет `:clone()` метода.
- Также `Instance.new("Message")` — класс не реализован.
- **Не критично**: Anchored=True держит постройки, регенерация не нужна.
2. **Цвета всё ещё чуть-чуть не такие как в Roblox**:
- Юзер крутит слайдеры sun/hemi/ambient/saturation и подбирает
baseline. Параметры сохраняются в проект через persistence.
### Надо ли в JS?
**Все правки** — да:
- BrickColor расширенная палитра — общая для обоих движков.
- Anchored=True для импорта — это про converter, не движок.
- Слайдеры света — UI студии, общий для обоих.
- persistence — общий формат `projectData.scene.lighting`.
---
## 2026-06-08 — Итерация 1: RayGun (проект 2792, 9 скриптов)
**Контекст:** Roblox-Tool пушка-стрелялка, использует Tool-API, Lighting,
Mouse, Welds, BodyForce, BrickColor, IntValue для leaderboard.
### Добавлено в `RobloxShim.js`
**Глобалы:**
- `BrickColor.new("Bright red")` + ~25 цветов (White, Black, Bright red/blue/green,
Pink, Brown, Reddish brown, Cyan, Magenta и др.). Возвращает `{Color, Name, R, G, B}`.
- `Ray.new(origin, direction)` — для raycast (заглушка структуры).
- `Region3.new(min, max)` — куб (заглушка).
- `TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime)`
- `NumberSequence`, `ColorSequence`, `NumberRange`, `Rect` — конструкторы-стабы.
**Enum расширения:** InfoType, SortOrder, FillDirection, Font,
TextXAlignment/TextYAlignment, ScaleType, AspectType, PartType, SurfaceType,
ContextActionResult, UserInputState, BorderMode, FormFactor.
**`game` методы:**
- `game:service(name)` (lowercase alias на GetService) — старый Roblox API.
- `game.GetServiceFromName` = alias.
- `game.JobId/PlaceId/GameId/CreatorId/CreatorType` — stub fields.
**Lighting:**
- `Brightness`, `ClockTime`, `TimeOfDay`, `OutdoorAmbient`, `FogStart/End/Color`.
- `GetMinutesAfterMidnight()`, `SetMinutesAfterMidnight(m)`.
- `GetMoonDirection()`, `GetSunDirection()`.
**Players:**
- `GetPlayers()`, `GetPlayerFromCharacter(char)`, `playerFromCharacter` alias.
- `PlayerAdded`, `PlayerRemoving`, `ChildAdded` signals.
**Instance.new новые типы:**
- `Tool` / `HopperBin` — Equipped/Unequipped/Activated/Deactivated signals,
GripForward/Right/Up/Pos, CanBeDropped, RequiresHandle, ToolTip.
- `IntValue` / `NumberValue` / `BoolValue` / `StringValue` / `ObjectValue` /
`CFrameValue` / `Vector3Value` / `Color3Value` / `BrickColorValue` / `RayValue`
`.Value` + `.Changed` сигнал.
- `BodyForce` / `BodyVelocity` / `BodyPosition` / `BodyGyro` / `BodyAngularVelocity`
/ `BodyThrust``.force`, `.Velocity`, `.MaxForce`, `.P/.D`.
- `Weld` / `WeldConstraint` / `Motor6D` / `Snap` / `HingeConstraint` /
`BallSocketConstraint` / `RopeConstraint` / `SpringConstraint` — Part0/Part1/C0/C1/Enabled.
- `Sparkles` / `ParticleEmitter` / `Smoke` / `Fire` / `Trail` / `Beam` /
`PointLight` / `SurfaceLight` / `SpotLight` — Enabled/Color/Rate/Lifetime/Brightness/Range.
- `Mouse` — Button1Down/Up, Button2Down/Up, Move, KeyDown/Up, WheelForward/Backward,
Icon, Hit (Position), Target, TargetSurface, X/Y, ViewSizeX/Y.
### Исправлено
- `rbx_wait(sec)`: минимум 0.016с (1 кадр). `while true do wait() end` без
аргумента раньше делал tight loop без yield → WASM stack overflow
("memory access out of bounds").
- **Уважаем `enabled: false`** в Roblox-метадате. Roblox-скрипты с
`Disabled = true` — это шаблоны для клонирования (`script.Clean:Clone()`),
не должны запускаться при старте. `parseRobloxLuaMeta()` парсит JSON
из второй строки packed-кода, при `enabled=false` скрипт идёт в `rbxlSkipped`.
### Tool/Backpack/Mouse flow (Шаг 1)
Контекст: Roblox-Tool это объект который попадает в Backpack игрока,
при экипировке (клавиша 1-9) фейерит Tool.Equipped с настоящим Mouse,
скрипты внутри Tool слушают MouseButton1Down/KeyDown.
**В `RobloxShim.js`:**
- `localPlayer.Backpack` — инвентарь.
- `localPlayer:GetMouse()` → playerMouse с Button1Down/KeyDown/Hit.Position.
- Внутренний `allTools[]` registry + `equippedTool` слот.
- `Instance.new('Tool')` теперь:
- создаёт виртуальный `Handle` (Part внутри Tool);
- регистрирует в `allTools[]`;
- шлёт `toolRegistered {index, name}` в GameRuntime.
- `fireGlobalEvent` обрабатывает: `equipTool`, `unequipTool`,
`toolActivated`, `toolDeactivated`, `mouseButton1Down`/`Up`, `keyDown`/`Up`.
- `__rbxl_get_tool_by_name(name)` — для script.Parent резолва.
**В `LuaSharedSandbox.js`:**
- `addScript(id, code, target, name, {toolName})` — расширенная сигнатура.
- В `_startSingleScript` если есть `toolName``script.Parent` = виртуальный Tool.
**В `GameRuntime.js`:**
- Эвристика: скрипты с `target=null` и содержащие
`(script.Parent|Tool).(Equipped|Unequipped|Activated|Deactivated)`
получают `toolName='Tool'`, группируются в один общий Tool.
- `_registerRbxlTool(payload)` — кладёт item в InventoryUI.hotbar,
слушает `slot` event → шлёт `equipTool`/`unequipTool`.
- `canvas.mousedown``mouseButton1Down` + `toolActivated` с raycast Hit.
- `_raycastFromCamera()` — простой ray из камеры на 50 unit вперёд.
**Надо ли в JS?** ✅ Да — Tool/Backpack/Mouse это базовый Roblox-game-loop.
### Импорт изменений в converter.py (не задеплоено)
Файл изменён локально, но importer на VM 130 — не обновлён. Когда придёт
время деплоя, ключевые правки:
- `_collect_tool(inst)` — собирает `scene['tools'][]` из Tool/HopperBin;
- `_find_ancestor_tool(inst)` — определяет в каком Tool лежит Script;
- В `_convert_script` добавлено поле `tool_id` в метадату.
Это уберёт необходимость эвристики на стороне studio.
### Надо ли портировать в JS-движок?
**Да, всё** — это базовый Roblox-совместимый API, который должен работать
независимо от языка скриптов.
**JS-эквивалент будет такой же структурой:**
- `BrickColor.new("Bright red")``new BrickColor("Bright red")`
- `Tool` Equipped/Unequipped → JS-EventEmitter методы
- BodyForce/Weld/Sparkles → JS-классы с теми же полями
- Mouse — глобальный объект `game.mouse` или через `player:GetMouse()`.
---
## Куда добавляется API
| Источник | Файл | Что туда идёт |
|----------|------|---------------|
| Глобалы (Vector3, Color3, BrickColor, Enum) | `RobloxShim.js` через `global.set` | Конструкторы, Enum-таблицы |
| Instance.new типы | `RobloxShim.js` в ветке `global.set('Instance', {new: ...})` | Tool, BodyForce, Weld, Sparkles и т.д. |
| Сервисы | `RobloxShim.js` через `makeService(name)` | Lighting, Players, RunService и т.д. |
| Wait/Task | `RobloxShim.js` в Lua-prelude (`lua.doStringSync`) | rbx_wait, task.wait |
| Setter Part-свойств | `newPart()` через `Object.defineProperty` | Position, Color, Anchored шлют partSet |
| Команды от Lua к Babylon | `rbxl-lua-integration.js` `handleLuaCommand` | partSet, sceneCreate, sceneDelete |
---
## Принципы расширения API
1. **No-op > Падение.** Лучше пустой stub-метод чем `nil error`.
2. **Сигналы (`Connect`/`Fire`) всегда есть на любом объекте.**
3. **Coloncall совместимость.** Если есть `Foo.Bar`, обычно делаем и `Foo:Bar`
(lowercase) как alias.
4. **При добавлении нового Instance-типа** — давай ему **все типичные поля**
сразу, не только те что нужны прямо сейчас (Equipped + Unequipped + Activated
вместе, даже если скрипт юзает только Equipped).
5. **Логировать сюда после каждой итерации** — что было добавлено и из какой игры.

434
RUBLOX_LUA_SUPPORT_PLAN.md Normal file
View File

@ -0,0 +1,434 @@
# План: Полная поддержка Lua-скриптов в Рублоксе
**Цель:** Пользователь Рублокс-студии создаёт скрипт → выбирает язык **JS** или **Lua** → пишет код → в плеере оба языка работают параллельно. Lua-скрипты совместимы с Roblox API настолько, чтобы код из Roblox-игр работал без модификаций.
**Зачем:** В Roblox экосистеме сотни тысяч разработчиков, привыкших к Lua + Roblox API (Vector3, CFrame, Instance, RemoteEvent, etc.). Сейчас они не могут перенести свои игры. С этой фичей — могут.
**Срок:** ~6 недель полного времени. Можно делать поэтапно (после каждого этапа есть полезный результат).
**Юр.риск:** "юр риски беру на себя" (МИН, 2026-06-08).
---
## Архитектурное решение
### Текущая ситуация
- Скрипты хранятся как `{id, code, target, name}` в БД
- `GameRuntime` запускает каждый JS-скрипт в Web Worker `ScriptSandboxWorker.js`
- API игре доступен через `game.*` объект (события, scene, player, gui, save, etc.)
- Скриптов в игре могут быть **сотни**, каждый — отдельный Worker
### Целевая
- Скрипты получают поле `language: 'js' | 'lua'` (default `'js'`)
- В редакторе — переключатель в `ScriptEditor.jsx` в шапке: **JS / Lua**
- Monaco-editor подсвечивает Lua (есть встроенный язык в `monaco-editor`)
- В runtime: JS-скрипты идут через старый sandbox, Lua — через новый `LuaSandbox.js`
- Lua-runtime построен поверх **wasmoon** (Lua 5.4 в WebAssembly)
- Lua-скрипты делятся на **один shared VM** на игру (а не Web Worker на скрипт) — иначе OOM при 100+ скриптах
- Lua-API проксирует Roblox API → нашу game.* плюс полную **DataModel** иерархию
### Ключевая идея
**Lua-скрипты не вызывают `game.move(...)`. Они вызывают `script.Parent.Position = Vector3.new(1,2,3)`** — как в Roblox. Lua-shim под капотом переводит это в `partSet` команды для движка Рублокса. Юзер пишет идиоматичный Roblox-Lua, движок Рублокса исполняет.
---
## Этап 1: UI и хранение языка (3 дня)
### 1.1 Миграция БД
- Добавить поле `language VARCHAR(8) DEFAULT 'js' NOT NULL` в таблицу `scripts` (есть на storys API)
- API endpoints (`POST/PATCH /scripts/...`) принимают и сохраняют `language`
- Старые скрипты без поля → `'js'` по умолчанию
### 1.2 ScriptEditor.jsx
- Переключатель в шапке редактора: `[ JS | Lua ]` (segmented control)
- При смене языка — confirm("Сменить язык? Текущий код будет очищен"), затем код сбрасывается на шаблон-заглушку нового языка
- Monaco language switch: `javascript``lua`
- Подсветка/автодополнение Lua встроены в Monaco (`monaco-editor/esm/vs/basic-languages/lua`)
- Линтер ошибок Lua через **luaparse** (npm пакет, ~50KB, parse-only) — показываем красные подчёркивания
- Шаблон Lua для нового скрипта на target=Part:
```lua
-- Скрипт на части. script.Parent = эта часть.
local part = script.Parent
part.Touched:Connect(function(hit)
print("Касание:", hit.Name)
end)
```
- Шаблон Lua для глобального скрипта (target=nil):
```lua
local Players = game:GetService("Players")
Players.PlayerAdded:Connect(function(player)
print("Игрок зашёл:", player.Name)
end)
```
### 1.3 Иконка языка в HierarchyPanel
- Рядом с именем скрипта — маленький бейдж `JS` или `Lua` (синий / голубой)
- Помогает не путаться при сотне скриптов
### Что готово в конце Этапа 1
- Юзер может **создать Lua-скрипт**, написать код, сохранить
- Код **не исполняется** — пока только хранится и редактируется
- В плеере Lua-скрипты молча игнорируются
---
## Этап 2: Базовый Lua-runtime (5 дней)
### 2.1 LuaSharedSandbox.js — новый sandbox-класс
Полная архитектура шаринг-VM (один wasmoon-state на все Lua-скрипты игры):
```
GameRuntime
├── ScriptSandbox (JS-скрипт, Web Worker, как сейчас)
├── ScriptSandbox (JS-скрипт)
├── LuaSharedSandbox ← НОВЫЙ
│ ├── LuaSharedWorker.js
│ │ └── wasmoon VM (один на всю игру)
│ │ ├── Roblox API shim
│ │ ├── DataModel tree (game.Workspace, Players, ...)
│ │ └── все Lua-скрипты как сопрограммы (coroutines)
│ └── проксирует partSet/sceneCreate/event обратно в main thread
```
Файлы:
- `src/editor/engine/LuaSharedSandbox.js` (main thread): API совместимый с ScriptSandbox (`sendSceneSnapshot`, `sendGlobalEvent`, etc.)
- `src/editor/engine/LuaSharedWorker.js` (Web Worker): держит wasmoon, исполняет скрипты, шлёт командные сообщения
- `src/editor/engine/RobloxLuaShim.js` (worker side): объявление всех Roblox-классов и сервисов
### 2.2 Минимальный Roblox shim в первой итерации
- `Vector3.new(x,y,z)`, `+`, `-`, `*`, `:Magnitude()`, `:Dot()`, `:Cross()`, `:Lerp()`, `:Normalize()`
- `Color3.new(r,g,b)`, `Color3.fromRGB(r,g,b)`, `:Lerp()`
- `CFrame.new(x,y,z)`, `CFrame.lookAt()`, `CFrame.fromEulerAnglesXYZ()`, операторы `*` и `:Inverse()`, `:ToWorldSpace()`
- `UDim2.new(sx,ox,sy,oy)`, `UDim.new(s,o)`
- `Enum.KeyCode.W`, `Enum.UserInputType.MouseButton1`, etc. (через generated table)
- `print()` → console + onLog event в студии
- `wait(secs)` / `task.wait(secs)` — через coroutine + scheduler в main loop
- `tick()`, `os.time()`, `os.clock()`, `math.*`, `string.*`, `table.*` (стандартные Lua)
- `pcall`, `xpcall`, `error`, `assert` (стандартные)
### 2.3 GameRuntime интеграция
- При старте игры — пробежать по `scripts[]`, разделить на `jsScripts` и `luaScripts`
- Для JS — старый путь (по сэндбоксу на скрипт)
- Для Lua — один `LuaSharedSandbox`, в него `addScript(luaSource)` каждым
- LuaSharedSandbox шлёт назад те же команды что JS sandbox: `partSet`, `sceneCreate`, `chatSay`, `guiSet`, etc.
### Что готово в конце Этапа 2
- Lua-скрипты **исполняются**
- Можно использовать `Vector3`, `Color3`, `print()`, `wait()`, `math/string/table`
- Скрипт **ещё не видит** Workspace, Player, GUI
---
## Этап 3: DataModel — game.Workspace и иерархия (5 дней)
### 3.1 Что такое DataModel
В Roblox любая игра — **дерево объектов**. Корень = `game`. У него детки = сервисы: `Workspace`, `Players`, `ReplicatedStorage`, `Lighting`, `StarterGui`, `RunService`, etc. У каждого деток — свои детки.
У нас сейчас сцена плоская: `primitives`, `blocks`, `models`. Нужно **виртуальное дерево DataModel** поверх плоской сцены.
### 3.2 Виртуальное дерево
Файл: `src/editor/engine/datamodel/DataModelTree.js`
При старте Lua-runtime, для текущей сцены строится виртуальное дерево:
```
game (RbxGame)
├── Workspace (RbxWorkspace)
│ ├── Part_0 ← обёртка над primitive id=0
│ ├── Part_1 ← обёртка над primitive id=1
│ ├── Model_5 ← обёртка над model id=5 (с Children)
│ │ └── Part_inner
│ ├── Camera
│ └── Terrain
├── Players (RbxPlayers)
│ └── LocalPlayer (RbxPlayer)
│ ├── Character (RbxCharacter)
│ │ ├── Humanoid (RbxHumanoid)
│ │ └── HumanoidRootPart (RbxPart)
│ └── PlayerGui (RbxScreenGui-контейнер)
│ └── (Lua-скрипты могут спавнить GUI через Instance.new)
├── ReplicatedStorage (RbxFolder)
├── ServerStorage (RbxFolder)
├── Lighting (RbxLighting)
├── StarterGui (RbxFolder)
├── StarterPlayer (RbxFolder)
│ └── StarterCharacter (RbxFolder)
├── RunService (RbxRunService с :Heartbeat, :Stepped, :RenderStepped)
├── UserInputService (события InputBegan/Ended/Changed)
├── TweenService (:Create, :GetService для tweens)
├── HttpService (заглушка либо проксируем через нашего бэка)
├── DataStoreService (проксируем через game.save)
└── MarketplaceService (заглушка)
```
### 3.3 Instance-классы
`src/editor/engine/datamodel/Instance.js`:
```js
class RbxInstance {
Name = "Instance";
ClassName = "Instance";
Parent = null;
Children = [];
// Свойства которые юзер может ставить через __newindex (metatable)
// отслеживаются — при изменении посылается команда в main thread
// для синхронизации с Babylon-сценой
GetChildren() { return [...this.Children]; }
FindFirstChild(name, recursive) { ... }
WaitForChild(name, timeout) { ... } // через coroutine + yield
FindFirstAncestor(name) { ... }
FindFirstChildOfClass(class) { ... }
Destroy() { ... }
Clone() { ... }
IsA(class) { ... }
GetFullName() { ... }
GetAttribute(name) / SetAttribute(name, value)
GetPropertyChangedSignal(name) → RbxSignal
}
class RbxPart extends RbxInstance {
Position // setter: партиклс в Babylon (primitiveManager.setPosition)
Size // setter
Color // setter
Material // setter (mapping Roblox materials → наши)
Anchored // setter
CanCollide // setter
Touched // RbxSignal — Fire когда BabylonScene детектит overlap
TouchEnded // RbxSignal
CFrame // computed property — Position + rotation
}
class RbxModel extends RbxInstance {
PrimaryPart
GetPrimaryPartCFrame() / SetPrimaryPartCFrame()
PivotTo(cframe) // MoveTo + Rotate
GetBoundingBox()
}
class RbxHumanoid extends RbxInstance {
Health = 100
MaxHealth = 100
WalkSpeed = 16
JumpPower = 50
Died, HealthChanged, Touched, StateChanged — signals
TakeDamage(amount)
MoveTo(pos) // simulates Roblox NPC pathing
LoadAnimation(anim) → RbxAnimationTrack
}
class RbxScript extends RbxInstance {
Source // источник Lua (read-only обычно)
Disabled // bool
RunContext // Server / Client / Legacy
}
class RbxRemoteEvent extends RbxInstance {
OnServerEvent : RbxSignal
OnClientEvent : RbxSignal
FireServer(...args)
FireClient(player, ...args)
FireAllClients(...args)
}
```
### 3.4 Метатаблицы Lua
Каждая JS-обёртка `RbxPart` экспортируется в Lua как **table с metatable**:
- `__index` — чтение свойства, либо метод
- `__newindex` — запись свойства, триггерит side-effects (синхронизация сцены)
- `__tostring` — для `print(part)` показывает `"Part_0"`
Это **критично** для совместимости с Roblox-скриптами.
### 3.5 script.Parent для каждого Lua-скрипта
- Если скрипт привязан к `target=42` (primitive id 42) — `script.Parent = workspace:FindFirstChild по primId(42)`
- Если глобальный — `script.Parent = nil`
- В скриптовом контексте: `script` это таблица `{Name=..., Parent=..., ClassName="Script"}`
### Что готово в конце Этапа 3
- Lua-скрипт может пройтись по `game.Workspace:GetChildren()`
- `script.Parent.Touched:Connect(...)` работает (KillBrick = реально)
- `local player = game.Players.LocalPlayer; player.Character.Humanoid.Health = 0` убивает игрока
- `Instance.new("Part", workspace)` создаёт примитив
---
## Этап 4: Полный Roblox API (10 дней)
Закрываем "длинный хвост" API. Каждый день — 1-2 сервиса.
### 4.1 Services
- **RunService**: `.Heartbeat`, `.Stepped`, `.RenderStepped` — RbxSignals, фаер в main loop tick
- **UserInputService**: `.InputBegan`, `.InputChanged`, `.InputEnded` — события KeyCode/MouseButton/Touch
- **TweenService**: `:Create(instance, TweenInfo, propertyTable)` → возвращает RbxTween; `Tween:Play()/Pause()/Cancel()`; интерполируется в main loop
- **DataStoreService**: проксируем через наш `game.save` (sync version)
- `:GetDataStore(name)` → объект с `:GetAsync(key)`, `:SetAsync(key, value)`, `:UpdateAsync(key, fn)`
- Async-методы через coroutine.yield + наш save API
- **MarketplaceService**: заглушки `PromptPurchase`, `GetProductInfo` (бизнес-логика через наш интерфейс)
- **HttpService**: `:JSONEncode`, `:JSONDecode`, `:GenerateGUID` — простая встроенная реализация; `:GetAsync/PostAsync` проксируем через ограниченный список разрешённых доменов
- **Players**: `LocalPlayer`, `:GetPlayers()`, `PlayerAdded`/`PlayerRemoving` сигналы
- **Lighting**: read-only сейчас (через `Lighting.Ambient`, `Lighting.OutdoorAmbient` ставить значения нашему envManager)
- **Workspace**: `:Raycast(origin, direction, params)` → реальный raycast через PhysicsAABB; `:GetServerTimeNow()`; `CurrentCamera`
### 4.2 GUI (важно!)
- `Instance.new("ScreenGui")` → если `Parent = playerGui`, регистрируется в нашем GuiManager
- `TextLabel`, `TextButton`, `ImageLabel`, `ImageButton`, `Frame` — все мапятся на наш GuiOverlay
- `MouseButton1Click`, `MouseEnter`, `MouseLeave`, `Activated` — сигналы
- `UDim2`, `Vector2` для позиций/размеров
- При установке `gui.Position = UDim2.new(0.5, 0, 0.5, 0)` — пересылается в GuiManager и обновляется DOM
### 4.3 Sound
- `Instance.new("Sound", part)` с `SoundId = "rbxassetid://12345"` или с нашим URL
- `:Play()`, `:Stop()`, `:Pause()`, `Volume`, `Pitch`, `Looped`
- Под капотом — наш SoundManager
### 4.4 Animation
- `Instance.new("Animation")` с `AnimationId` (наши собственные ID анимаций R15)
- `humanoid:LoadAnimation(anim) → AnimationTrack`
- `:Play()`, `:Stop()`, `:AdjustSpeed()`, `:GetMarkerReachedSignal()`
- Связь с нашим R15Animator
### 4.5 Tools / Backpack
- `Tool` Instance: `Activated`, `Equipped`, `Unequipped` сигналы
- `player.Backpack:GetChildren()` — Lua видит инвентарь
- Реализация через наш HotbarManager + InventoryService
### 4.6 ProximityPrompt, ClickDetector
- ProximityPrompt: `Triggered` сигнал, `:Show/:Hide`, ActionText, ObjectText
- ClickDetector: `MouseClick`, `MouseHoverEnter/Leave` сигналы
### 4.7 Networking-эмуляция (single-player)
- RemoteEvent / RemoteFunction работают **локально** (поскольку у нас singleplayer на client-only)
- `FireServer/InvokeServer` запускают handlers в том же VM, но в other-side контексте
- Это позволяет копировать многопользовательские скрипты Roblox без изменений (хоть мультиплеера и нет)
- Когда у Рублокса появится мультиплеер — `RemoteEvent` уже будет работать «по-настоящему» без изменений в скриптах юзера
### Что готово в конце Этапа 4
- **~90% типовых Roblox-скриптов работают** без модификаций
- DataStore сохраняет прогресс
- TweenService плавно двигает объекты
- GUI создаётся скриптом
- Анимации играются
---
## Этап 5: Импорт .rbxl + конвертер юзер-кода (5 дней)
### 5.1 Изменение импортера
Сейчас импортер сохраняет Lua-исходник в JS-комментарии и пытается завернуть в JS-обёртку. **Это устаревает.**
Новый путь:
- Импортер сохраняет Lua-source **как есть** (без обёрток)
- В записи скрипта `language = 'lua'`
- target = primitiveId или null
- В GameRuntime Lua-скрипт идёт сразу в LuaSharedSandbox
### 5.2 Конвертация ассетов
- Roblox MeshId/TextureId через наш ImageProxy → ассеты сохраняются в minio + на CDN
- `rbxassetid://12345` → resolve в наш asset_id
- Сохранение в БД ссылок на ассеты
### 5.3 Поведение при импорте
- При импорте .rbxl карты — все Lua-скрипты сохраняются как Lua-скрипты Рублокса (не пытаемся конвертить в JS)
- Юзер открывает игру → редактирует → видит в Hierarchy `Script (Lua)` рядом с `Script (JS)` — может писать на любом
### Что готово в конце Этапа 5
- Импорт Roblox-карты работает **бесшовно**
- Юзер может править Lua-код в редакторе и видеть результат
---
## Этап 6: Производительность и стабильность (5 дней)
### 6.1 Profiling
- Замерить FPS на картах с 100/500/1000 Lua-скриптов
- Если падает — переезд на **fengari** (pure-JS Lua interp, в 5-10× медленнее wasmoon но без WASM overhead на старте) либо на **собственный Lua-bytecode runtime** для горячих скриптов
### 6.2 Memory
- Каждый wasmoon VM ~10-15MB. Один на игру = ОК. Если придётся разделять на части (server/client), нужно бенчить.
### 6.3 Песочница (security)
- Lua не должен дёргать `io.*`, `os.execute`, `loadstring(внешний код)`, etc.
- Whitelist стандартной библиотеки. Запрещаем всё что может выйти из браузера.
### 6.4 Ошибки
- Lua-ошибки в скрипте — показываются в Output-панели студии (как JS-ошибки)
- Stack trace с правильными номерами строк (не из обёртки)
- Если скрипт зациклился — kill через `debug.sethook` после N инструкций без yield
### 6.5 Тесты
- 50 unit-тестов на Roblox API (Vector3 операции, CFrame, Instance.new, Touched, RunService, TweenService)
- 10 интеграционных: импортировать тест-rbxl, запустить, проверить что нужное случилось
- CI: тесты прогоняются в Gitea Actions при PR
### Что готово в конце Этапа 6
- Lua-runtime production-ready
- Можно объявить публично «теперь в Рублоксе пишут на Lua с Roblox-совместимостью»
---
## Этап 7: Документация (3 дня)
### 7.1 Раздел вики
- `wiki/lua-intro` — введение в Lua для Рублокса (для пользователей, которые приходят с Roblox — короткое)
- `wiki/lua-vs-js` — таблица: «то же самое на JS и на Lua» для типичных задач
- `wiki/roblox-api-supported` — список того что работает / не работает / отличается
- `wiki/lua-examples` — 20 готовых сниппетов (KillBrick, TeleportPad, Checkpoint, Coin, NPCFollower, etc.)
### 7.2 Migration guide
- «Как перенести свою Roblox-игру в Рублокс» — пошаговое
- Список известных несовместимостей и их обходов
### 7.3 PR-материал
- Пост в /developer на team.rublox.pro: «Lua-поддержка теперь GA»
- Если есть бюджет — короткий ролик на YouTube/TikTok
---
## Этапы целиком
| Этап | Длительность | Содержание |
|------|--------------|------------|
| 1 | 3 дня | UI и хранение языка |
| 2 | 5 дней | Базовый Lua-runtime + минимальный shim |
| 3 | 5 дней | DataModel (game.Workspace и иерархия) |
| 4 | 10 дней | Полный Roblox API (services, GUI, Sound, Animation) |
| 5 | 5 дней | Импорт .rbxl и асетов |
| 6 | 5 дней | Производительность, безопасность, тесты |
| 7 | 3 дня | Документация и публикация |
| **Итого** | **~36 рабочих дней** | **~6 недель полного времени** |
---
## Что-то можно делать раньше, чтобы получить пользу
- **MVP (Этапы 1+2):** через **8 дней** — юзер может писать Lua-скрипты с минимальным API (Vector3, Color3, print, wait). Уже видит что фича есть.
- **Beta (Этапы 1-3):** через **13 дней** — KillBrick'и работают, можно делать простые игры на Lua.
- **GA (все этапы):** через **6 недель** — продакшен.
После каждого этапа можно делать релиз и собирать фидбек.
---
## Решения которые нужны от тебя перед стартом
1. **wasmoon vs fengari** — wasmoon быстрее но WASM-heavy, fengari проще но медленнее. Предлагаю wasmoon (уже используем для импорта).
2. **Один shared VM на игру** — согласен или разделять server/client? Предлагаю один в singleplayer-фазе, разделение — позже когда будет мультиплеер.
3. **Бэкенд изменения** — нужна миграция БД (поле `language`). У нас сейчас S2 + S1 + auto-backup, ничего страшного, но согласовать момент апдейта.
4. **Roblox API trademark/copyright** — мы делаем API-compatible runtime. Названия классов `Workspace`, `Humanoid`, etc. это API names. Юр.риск есть. Ты сказал берёшь — фиксируем.
5. **Приоритет** — этот план делать **вместо** других задач (тогда параллельные фичи стопаются) или **после** текущего бэклога?
---
## Связанные документы
- `RUBLOX_PROJECT.md` — общий план Рублокса
- `RUBLOX_EDITOR_ROADMAP.md` — куда движется редактор
- `INFO_PROCESS.md` — лог реализации (будет апдейтиться по ходу)
---
**Создано:** 2026-06-08, Claude (Opus 4.7) совместно с МИНом.
**Статус:** план готов, ждём решения по 5 вопросам перед стартом.

View File

@ -122,12 +122,23 @@ def analyze():
blob = upload.read()
if len(blob) > MAX_RBXL_SIZE:
return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413
if not blob.startswith(b'<roblox!'):
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400
# Авто-детект XML vs Binary формата.
# Бинарный: <roblox!\x89\xff\r\n\x1a\n (magic bytes).
# XML (старые карты до 2010): <roblox version="4">...
stripped = blob.lstrip()
is_binary = stripped.startswith(b'<roblox!')
is_xml = stripped.startswith(b'<roblox') and not is_binary
if not is_binary and not is_xml:
return jsonify({'error': 'not a .rbxl file (no <roblox magic)'}), 400
# Парсим
try:
model = parse(blob)
if is_xml:
from rbxl_xml_parser import parse_xml
model = parse_xml(blob)
else:
model = parse(blob)
except Exception as e:
return jsonify({'error': f'parse failed: {e}'}), 422
@ -199,6 +210,16 @@ def create():
data = request.get_json(silent=True) or {}
preview_hash = data.get('preview_hash')
title = (data.get('title') or '').strip() or 'Импортировано из Roblox'
# scripts_mode: 'disabled' (default) — оставить в проекте, но enabled=False
# 'enabled' — попытаться запустить, может вешать
# 'skip' — не импортировать совсем
scripts_mode = data.get('scripts_mode', 'disabled')
if scripts_mode not in ('disabled', 'enabled', 'skip'):
scripts_mode = 'disabled'
# gui_mode: 'all' / 'screen-only' (только ScreenGui-HUD) / 'skip' (без GUI)
gui_mode = data.get('gui_mode', 'all')
if gui_mode not in ('all', 'screen-only', 'skip'):
gui_mode = 'all'
if not preview_hash:
return jsonify({'error': 'preview_hash required'}), 400
@ -263,6 +284,14 @@ def create():
# Подставляем URLs в project_data
_resolve_asset_urls(project_data, asset_url_map)
# Применяем scripts_mode: меняем поле enabled в метадате каждого скрипта
# либо удаляем все скрипты полностью.
_apply_scripts_mode(project_data, scripts_mode)
# Применяем gui_mode: удаляем 3D-GUI (BillboardGui/SurfaceGui) или вообще
# всё, если выбрано 'skip'.
_apply_gui_mode(project_data, gui_mode)
# Создаём проект в kubikon3d_projects
# Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db.
# Прямой INSERT — проще для MVP. id автогенерируется.
@ -324,5 +353,66 @@ def _resolve_asset_urls(project_data: dict, asset_map: dict) -> None:
snd['url'] = asset_map[rid]
def _apply_gui_mode(project_data: dict, mode: str) -> None:
"""Фильтрует scene.gui[] по режиму.
'all' оставить всё (default).
'screen-only' оставить только ScreenGui-HUD, удалить billboard/surface.
Карты с 200+ BillboardGui (Robloxity) перестают тормозить.
'skip' удалить gui[] совсем.
"""
scene = project_data.get('scene', {})
if mode == 'skip':
scene['gui'] = []
return
if mode == 'screen-only':
gui = scene.get('gui', [])
scene['gui'] = [g for g in gui
if g.get('gui_container_kind', 'screen') == 'screen']
return
# 'all' — без изменений
def _apply_scripts_mode(project_data: dict, mode: str) -> None:
"""Применяет режим scripts_mode к проекту.
mode='disabled' (default): для каждого скрипта меняем JSON-метадату
на 2-й строке packed-кода выставляем enabled=False. GameRuntime
уже умеет уважать этот флаг и не запускает.
mode='enabled': оставляем как было (как пришло из конвертера).
mode='skip': удаляем все scripts из scene.scripts полностью.
"""
scene = project_data.get('scene', {})
scripts = scene.get('scripts', [])
if not scripts:
return
if mode == 'skip':
scene['scripts'] = []
return
if mode == 'enabled':
return # ничего не делаем
# mode == 'disabled' — патчим метадату каждого скрипта.
# Формат packed-кода (см. converter._convert_script):
# "// @roblox-lua\n// {JSON}\n/* lua_source:\n...source...\n*/\n"
for s in scripts:
code = s.get('code', '')
lines = code.split('\n', 2)
if len(lines) < 2 or not lines[0].startswith('// @roblox-lua'):
continue
meta_line = lines[1]
if not meta_line.startswith('// '):
continue
try:
meta = json.loads(meta_line[3:])
meta['enabled'] = False
new_meta_line = '// ' + json.dumps(meta, ensure_ascii=False)
s['code'] = lines[0] + '\n' + new_meta_line + '\n' + (lines[2] if len(lines) > 2 else '')
except (json.JSONDecodeError, ValueError):
continue
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8690, debug=False)

View File

@ -103,19 +103,42 @@ SHAPE_TO_PRIMITIVE = {
# ────── BrickColor таблица (упрощённая) ──────
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
BRICKCOLOR_TO_HEX = {
1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea',
21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e',
28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32',
101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a',
105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50',
111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76',
141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91',
199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8',
# Базовые тона
1: '#f2f3f3', 2: '#a1a5a2', 3: '#f9e999', 5: '#d9e4f7',
9: '#9c9e9c', 11: '#e8eaea', 18: '#cc8e69', 21: '#c4281c',
23: '#0d69ac', 24: '#f5cd30', 26: '#1b2a35', 28: '#293f1a',
29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', 101: '#dab8a3',
102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', 105: '#cf8b3e',
106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', 111: '#a7a6a6',
115: '#c7d23c', 116: '#56fff0', 118: '#b4d2e4', 119: '#aac84a',
120: '#d4f0a6', 123: '#cf6b6f', 124: '#9c54a6', 125: '#e8b486',
126: '#a6c2e3', 127: '#deb87b', 128: '#a37e5b', 131: '#9ba19d',
133: '#cc7c39', 134: '#de8b5f', 135: '#74859c', 136: '#876a7a',
137: '#e6a262', 138: '#8a8a76', 140: '#234770', 141: '#26462b',
143: '#bdc3e3', 145: '#5c8aa1', 146: '#75718b', 147: '#9a8a64',
148: '#5a605a', 149: '#1b2a47', 150: '#9ea1a3',
# ВАЖНО: 151 — Earth green (тёмная трава Crossroads)
151: '#7c9b53',
153: '#9b605a', 154: '#7a2d2d', 157: '#f5e09c', 158: '#b58c9c',
168: '#3c3a37', 176: '#a39989', 178: '#aa724c', 180: '#cc9555',
190: '#f7b830', 191: '#e69138',
192: '#5a3019', 193: '#f59d24', 194: '#9c9b91', 195: '#447ba6',
196: '#283970', 198: '#7b4b85', 199: '#3c3e3f', 200: '#7a854b',
208: '#dbdcdc', 209: '#a4733f', 210: '#7d8a8e', 211: '#9da3b3',
212: '#a5cce0', 213: '#6584b5', 215: '#7c8aa4', 216: '#8a5040',
217: '#7a5443', 218: '#94748a', 219: '#5c5a8a', 220: '#a3a8c4',
221: '#cc4488', 222: '#e8a8e0', 223: '#dd7790', 224: '#f3e3a5',
225: '#e8b685', 226: '#fff8a8', 232: '#bce0f0', 268: '#3c2e74',
301: '#73584b',
# Бипалитра 1001-1032 — стандартные яркие цвета
1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000',
1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80',
1021: '#80c0ff', 1022: '#80ffff', 1023: '#80ff00', 1024: '#00ff80',
1025: '#ff4040', 1026: '#8a0028', 1027: '#001f80', 1028: '#4d4d4d',
1029: '#9d9d9d', 1030: '#5e3923', 1031: '#7a4f30', 1032: '#cca5a5',
}
@ -241,12 +264,49 @@ class Converter:
'sounds': [],
'glbModels': [],
'scripts': [],
# Команды PvP (Roblox Battle): {id, name, color_hex, auto_assignable}
'teams': [],
# Spawn-точки команд (для SpawnLocation.TeamColor)
'team_spawns': [], # {team_color_hex, x, y, z}
}
# Эвристика для Roblox Battle: Model с именем "TeamBeacon X" →
# команда X. PvP-карты часто используют этот паттерн вместо Team-инстансов.
TEAM_BEACON_COLORS = {
'Black': '#1f1f1f', 'Blue': '#0d69ac', 'Red': '#c4281c',
'Green': '#4b9740', 'White': '#f2f3f3', 'Yellow': '#f5cd30',
'Orange': '#d97e29', 'Purple': '#6b327a',
}
for inst in self.model.instances:
name = inst.properties.get('Name', '')
if (inst.class_name == 'Model' and isinstance(name, str)
and name.startswith('TeamBeacon ')):
team_name = name.replace('TeamBeacon ', '').strip()
color = TEAM_BEACON_COLORS.get(team_name, '#cccccc')
scene['teams'].append({
'id': f'team_{len(scene["teams"]) + 1}',
'name': team_name,
'color_hex': color,
'auto_assignable': True,
})
# Обходим все instances и конвертим
for inst in self.model.instances:
self._convert_one(inst, scene)
# Spawn fallback: если SpawnLocation в карте НЕ был (или дефолт 0,2,0
# остался) — поднимаем выше самой высокой Part'ы. Иначе игрок
# появляется внутри Anchored=True геометрии и не может двигаться.
sp = scene.get('spawnPoint', {'x': 0, 'y': 2, 'z': 0})
if sp.get('x') == 0 and sp.get('y') == 2 and sp.get('z') == 0:
prims = scene.get('primitives', [])
if prims:
max_top = max(
(p['y'] + p.get('sy', 1) / 2) for p in prims
if isinstance(p.get('y'), (int, float))
)
scene['spawnPoint'] = {'x': 0, 'y': max_top + 5, 'z': 0}
# Финальный отчёт о скипнутых классах
for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]:
self.stats.warnings.append(f"skipped {n}× {cls}")
@ -308,8 +368,12 @@ class Converter:
elif cls == 'Workspace':
# Workspace = root, его свойства мапим на scene.worldSize и т.п.
pass
elif cls == 'Team':
# PvP-команда: имя + цвет в scene.teams[].
self._convert_team(inst, scene)
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
'StarterPack', 'StarterCharacterScripts', 'Players',
'Teams',
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
'SoundService', 'TweenService', 'RunService',
'UserInputService', 'HttpService', 'DataStoreService',
@ -374,7 +438,9 @@ class Converter:
'canCollide': bool(props.get('CanCollide', True)),
'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True,
'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)),
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
}
@ -405,7 +471,9 @@ class Converter:
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
@ -434,7 +502,9 @@ class Converter:
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
@ -506,7 +576,9 @@ class Converter:
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'MeshPart (no GLB) rbxid={rbx_id}',
@ -527,7 +599,9 @@ class Converter:
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'origin': 'roblox-meshpart',
'rbxAssetId': rbx_id,
})
@ -567,7 +641,9 @@ class Converter:
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'Union (no CSG GLB) rbxid={rbx_id}',
@ -586,7 +662,9 @@ class Converter:
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'origin': 'roblox-union',
'rbxAssetId': rbx_id,
})
@ -594,15 +672,43 @@ class Converter:
# ─── Spawn ───
def _convert_team(self, inst: Instance, scene: Dict) -> None:
"""Roblox Team → scene.teams[]."""
props = inst.properties
name = str(props.get('Name', 'Team'))
# TeamColor — BrickColor код, мапим в hex через существующую таблицу
team_color = props.get('TeamColor')
color_hex = '#ffffff'
if isinstance(team_color, BrickColor):
color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
scene['teams'].append({
'id': f'team_{len(scene["teams"]) + 1}',
'name': name,
'color_hex': color_hex,
'auto_assignable': bool(props.get('AutoAssignable', True)),
})
def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
pos, _ = cframe_to_pos_rot(cf, self.scale)
# Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита,
# юзер появляется на её верхней грани.
# TeamColor (если есть) → spawn для команды.
team_color = props.get('TeamColor')
if isinstance(team_color, BrickColor):
color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
scene['team_spawns'].append({
'team_color_hex': color_hex,
'x': pos['x'], 'y': pos['y'] + 1.5, 'z': pos['z'],
'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0,
})
# Spawn должен быть значительно выше — старые Roblox-карты часто имеют
# толстый Floor выше плиты, юзер появляется внутри стены/пола если
# не дать запас. +5 единиц достаточно — гравитация уронит на пол.
scene['spawnPoint'] = {
'x': pos['x'],
'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите
'y': pos['y'] + 5,
'z': pos['z'],
}
@ -719,9 +825,13 @@ class Converter:
if not hasattr(self, '_screen_gui_refs'):
self._screen_gui_refs = set()
self._screen_gui_enabled = {}
self._screen_gui_kind = {} # ref → 'screen' | 'billboard' | 'surface'
self._screen_gui_refs.add(inst.referent)
enabled = inst.properties.get('Enabled', True)
self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else True
# Сохраняем тип контейнера — потом отфильтруем 3D-GUI если выбрано screen-only
kind = {'ScreenGui': 'screen', 'BillboardGui': 'billboard', 'SurfaceGui': 'surface'}.get(inst.class_name, 'screen')
self._screen_gui_kind[inst.referent] = kind
def _gui_parent_id(self, parent_ref) -> Optional[str]:
if parent_ref is None:
@ -815,12 +925,14 @@ class Converter:
# элемент тоже невидим.
parent_ref = inst.parent_referent
screen_enabled = True
container_kind = 'screen' # default
if hasattr(self, '_screen_gui_refs'):
cur = parent_ref
depth = 0
while cur is not None and depth < 50:
if cur in self._screen_gui_refs:
screen_enabled = self._screen_gui_enabled.get(cur, True)
container_kind = self._screen_gui_kind.get(cur, 'screen')
break
# Поиск родителя cur в instances (если есть)
cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
@ -873,6 +985,10 @@ class Converter:
'imageAsset': None,
'zIndex': int(props.get('ZIndex', 1) or 1),
'origin': 'roblox-' + cls.lower(),
# 'screen' — обычный HUD; 'billboard' — 3D-табличка над частью;
# 'surface' — на грани Part. Last 2 рендерятся в 3D-сцене и
# сильно тормозят если их сотни.
'gui_container_kind': container_kind,
}
scene['gui'].append(element)

View File

@ -113,18 +113,28 @@ class CFrame:
matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22)
def to_euler_xyz(self) -> tuple:
"""Конверт 3x3 rotation matrix в Euler XYZ (radians).
"""Конверт 3x3 rotation matrix в Euler YXZ (Babylon convention).
Использует стандартную intrinsic XYZ rotation extraction:
Rx = atan2(r21, r22)
Ry = atan2(-r20, sqrt(r21² + r22²))
Rz = atan2(r10, r00)
Babylon mesh.rotation = Vector3(rx, ry, rz) применяется в порядке YXZ
(rotate Y first, then X, then Z). Чтобы извлечь Euler из матрицы под
этот convention, используем формулу YXZ-extraction:
Rx = asin(-r12)
Ry = atan2(r02, r22)
Rz = atan2(r10, r11)
(имя метода to_euler_xyz сохраняем для совместимости вызовов.)
"""
import math
r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix
rx = math.atan2(r21, r22)
ry = math.atan2(-r20, math.sqrt(r21*r21 + r22*r22))
rz = math.atan2(r10, r00)
# Edge case: r12 близко к ±1 (gimbal lock на X = ±90°)
clamped = max(-1.0, min(1.0, -r12))
rx = math.asin(clamped)
if abs(clamped) > 0.99999:
# Gimbal lock — z = 0, y = atan2(-r20, r00)
ry = math.atan2(-r20, r00)
rz = 0.0
else:
ry = math.atan2(r02, r22)
rz = math.atan2(r10, r11)
return (rx, ry, rz)
@ -551,19 +561,30 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple:
Источник: https://dom.rojo.space/binary#cframe-orientation-ids
Это полная таблица 24-х валидных orientation id для cube symmetries.
Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22).
Формула из rbx-dom:
orientation_id = (rx_axis * 6) + ry_axis + 1
где rx_axis, ry_axis {0..5} = (R0, R1, R2, R3, R4, R5):
R0 = +X, R1 = +Y, R2 = +Z, R3 = -X, R4 = -Y, R5 = -Z
rx это направление куда смотрит локальная +X ось куба (правая грань),
ry направление куда смотрит локальная +Y ось (верхняя грань).
Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22) row-major.
Матрица собирается так: rx, ry, rz это столбцы.
"""
# Таблица из rbx-dom. Каждое значение — пара (rx_axis, ry_axis) где
# значения в {0,1,2,3,4,5} = +X, -X, +Y, -Y, +Z, -Z
# Правильный порядок axes (rbx-dom):
# 0=+X, 1=+Y, 2=+Z, 3=-X, 4=-Y, 5=-Z
AXES = [
(1, 0, 0), (-1, 0, 0),
(0, 1, 0), (0, -1, 0),
(0, 0, 1), (0, 0, -1),
(1, 0, 0), # +X
(0, 1, 0), # +Y
(0, 0, 1), # +Z
(-1, 0, 0), # -X
(0, -1, 0), # -Y
(0, 0, -1), # -Z
]
# orientation_id = 1..24 (1-based)
if not (1 <= orientation_id <= 24):
# Неверный id — возвращаем identity
# orientation_id = 1..36 (некоторые комбинации rx==ry невалидны, в файлах
# не встречаются — но id может доходить до 6*6 = 36, не 24).
if not (1 <= orientation_id <= 36):
return (1, 0, 0, 0, 1, 0, 0, 0, 1)
idx = orientation_id - 1
@ -571,16 +592,14 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple:
ry_idx = idx % 6
rx = AXES[rx_idx]
ry = AXES[ry_idx]
# rz = rx × ry (cross product)
# rz = rx × ry (cross product) — третий столбец
rz = (
rx[1] * ry[2] - rx[2] * ry[1],
rx[2] * ry[0] - rx[0] * ry[2],
rx[0] * ry[1] - rx[1] * ry[0],
)
# Матрица: первые 3 — first row (R_xx, R_yx, R_zx)
# Сложновато; берём из rbx-dom convention: первые три — основа R*XAxis,
# затем R*YAxis, затем R*ZAxis. Расширяем в row-major form.
# На практике: orientation вектора (rx, ry, rz) — это **столбцы** матрицы.
# rx, ry, rz — это СТОЛБЦЫ матрицы.
# row-major: [r00=rx[0], r01=ry[0], r02=rz[0], r10=rx[1], r11=ry[1], r12=rz[1], ...]
r00, r10, r20 = rx
r01, r11, r21 = ry
r02, r12, r22 = rz

View File

@ -0,0 +1,342 @@
"""
rbxl_xml_parser.py парсер XML-формата .rbxl (старые карты до 2010 года).
Roblox-XML формат текстовый предок бинарного .rbxl. Файл начинается с
<roblox version="4"> и содержит дерево <Item class="...">...</Item>.
Возвращает тот же `RobloxModel` что и rbxl_parser.parse чтобы converter.py
работал без изменений.
Пример входного файла:
<roblox version="4">
<Item class="Workspace">
<Properties>
<string name="Name">Workspace</string>
</Properties>
<Item class="Part">
<Properties>
<CoordinateFrame name="CFrame">
<X>0</X><Y>10</Y><Z>0</Z>
<R00>1</R00>...<R22>1</R22>
</CoordinateFrame>
<Vector3 name="size"><X>4</X><Y>1</Y><Z>2</Z></Vector3>
<Color3uint8 name="Color3uint8">4286611584</Color3uint8>
<token name="BrickColor">21</token>
</Properties>
</Item>
</Item>
</roblox>
Поддерживает все типичные property-теги: string, bool, int, float, double,
token, Vector3, Vector2, CoordinateFrame, Color3, Color3uint8, BrickColor,
Content, ProtectedString, Ref, BinaryString, UDim, UDim2, Rect2D.
"""
from __future__ import annotations
from typing import Dict, List, Any, Optional, Tuple
import xml.etree.ElementTree as ET
import re
import base64
import struct
from rbxl_parser import Instance, RobloxModel
from rbxl_types import (
Vector3, Vector2, Color3, CFrame, BrickColor,
EnumValue, PhysicalProperties, OptionalCFrame,
)
# Magic для XML-формата
XML_MAGIC = b'<roblox'
def is_xml_rbxl(blob: bytes) -> bool:
"""Проверяет XML это или нет. Бинарный начинается с <roblox!..."""
stripped = blob.lstrip()
if stripped.startswith(b'<roblox') and not stripped.startswith(b'<roblox!'):
return True
return False
def _text(el: ET.Element, default: str = '') -> str:
"""Текст элемента (None → default)."""
return (el.text if el.text is not None else default).strip()
def _f(el: ET.Element, default: float = 0.0) -> float:
"""Float из text."""
try:
return float(_text(el, '0'))
except (ValueError, TypeError):
return default
def _i(el: ET.Element, default: int = 0) -> int:
"""Int из text."""
try:
s = _text(el, '0')
# Roblox иногда пишет '1.0' где ожидается int
return int(float(s)) if '.' in s else int(s)
except (ValueError, TypeError):
return default
def _parse_vector3(el: ET.Element) -> Vector3:
x = _f(el.find('X'))
y = _f(el.find('Y'))
z = _f(el.find('Z'))
return Vector3(x, y, z)
def _parse_vector2(el: ET.Element) -> Vector2:
x = _f(el.find('X'))
y = _f(el.find('Y'))
return Vector2(x, y)
def _parse_cframe(el: ET.Element) -> CFrame:
"""CoordinateFrame: 3 позиции + 9 элементов матрицы ротации."""
pos = Vector3(_f(el.find('X')), _f(el.find('Y')), _f(el.find('Z')))
matrix = tuple(_f(el.find(f'R{i}{j}'), 1.0 if i == j else 0.0)
for i in range(3) for j in range(3))
return CFrame(position=pos, matrix=matrix)
def _parse_color3(el: ET.Element) -> Color3:
"""<Color3 name="..."><R>...</R><G>...</G><B>...</B></Color3>"""
r = _f(el.find('R'))
g = _f(el.find('G'))
b = _f(el.find('B'))
return Color3(r, g, b)
def _parse_color3uint8(el: ET.Element) -> Color3:
"""<Color3uint8>4286611584</Color3uint8> — packed RGB как uint32."""
val = _i(el, 0)
# uint32 = 0xFFRRGGBB (alpha=FF). r=byte2, g=byte1, b=byte0
b = (val & 0xff) / 255.0
g = ((val >> 8) & 0xff) / 255.0
r = ((val >> 16) & 0xff) / 255.0
return Color3(r, g, b)
def _parse_property(prop_el: ET.Element) -> Tuple[str, Any]:
"""Парсит один <тип name="имя">значение</тип>. Возвращает (name, value)."""
tag = prop_el.tag
name = prop_el.attrib.get('name', '')
if tag == 'string' or tag == 'ProtectedString' or tag == 'Content':
return name, _text(prop_el)
if tag == 'bool':
return name, _text(prop_el).lower() == 'true'
if tag in ('int', 'int64'):
val = _i(prop_el)
# В старом XML цвет хранится как <int name="BrickColor">21</int>,
# а converter ожидает BrickColor-объект с .code.
if name == 'BrickColor':
return name, BrickColor(code=val)
return name, val
if tag in ('float', 'double'):
return name, _f(prop_el)
if tag == 'token':
# token — int-значение enum
return name, EnumValue(value=_i(prop_el))
if tag == 'Vector3':
return name, _parse_vector3(prop_el)
if tag == 'Vector2':
return name, _parse_vector2(prop_el)
if tag == 'CoordinateFrame':
return name, _parse_cframe(prop_el)
if tag == 'Color3':
return name, _parse_color3(prop_el)
if tag == 'Color3uint8':
return name, _parse_color3uint8(prop_el)
if tag == 'BrickColor':
return name, BrickColor(code=_i(prop_el))
if tag == 'Ref':
# Ссылка на другой Item по referent (например "RBX42" или "null")
txt = _text(prop_el, 'null')
if txt in ('null', 'nil', ''):
return name, None
return name, txt # храним как строку-referent
if tag == 'BinaryString':
# base64 → bytes
try:
return name, base64.b64decode(_text(prop_el))
except Exception:
return name, b''
if tag == 'UDim':
scale = _f(prop_el.find('S'))
offset = _i(prop_el.find('O'))
return name, {'scale': scale, 'offset': offset}
if tag == 'UDim2':
xs = _f(prop_el.find('XS'))
xo = _i(prop_el.find('XO'))
ys = _f(prop_el.find('YS'))
yo = _i(prop_el.find('YO'))
return name, {'x_scale': xs, 'x_offset': xo, 'y_scale': ys, 'y_offset': yo}
if tag == 'Rect2D':
# min/max
min_el = prop_el.find('min')
max_el = prop_el.find('max')
return name, {
'min': _parse_vector2(min_el) if min_el is not None else Vector2(0, 0),
'max': _parse_vector2(max_el) if max_el is not None else Vector2(0, 0),
}
if tag == 'OptionalCoordinateFrame':
cf_el = prop_el.find('CFrame')
return name, OptionalCFrame(cframe=_parse_cframe(cf_el)) if cf_el is not None else OptionalCFrame(cframe=None)
if tag == 'PhysicalProperties':
cust = prop_el.find('CustomPhysics')
custom = cust is not None and _text(cust).lower() == 'true'
return name, PhysicalProperties(
custom_physics=custom,
density=_f(prop_el.find('Density'), 0.7),
friction=_f(prop_el.find('Friction'), 0.3),
elasticity=_f(prop_el.find('Elasticity'), 0.5),
friction_weight=_f(prop_el.find('FrictionWeight'), 1.0),
elasticity_weight=_f(prop_el.find('ElasticityWeight'), 1.0),
)
if tag == 'NumberRange':
return name, {'min': _f(prop_el.find('Min')), 'max': _f(prop_el.find('Max'))}
# SharedString / Uri / другие незнакомые — оставляем как текст
return name, _text(prop_el)
# Регекс для извлечения referent из строк типа "RBX42"
_REF_RE = re.compile(r'^RBX(\d+)$')
def _ref_to_int(ref: Optional[str]) -> Optional[int]:
"""RBX42 → 42, null → None. Если уникальной номер не найден — None."""
if ref is None or ref == 'null':
return None
m = _REF_RE.match(str(ref))
if m:
return int(m.group(1))
return None
def parse_xml(blob: bytes) -> RobloxModel:
"""Главный entry: bytes → RobloxModel."""
try:
text = blob.decode('utf-8', errors='replace')
except Exception:
text = blob.decode('latin-1', errors='replace')
# XML может иметь BOM или leading whitespace
text = text.lstrip('').lstrip()
root = ET.fromstring(text)
instances: List[Instance] = []
by_referent: Dict[int, Instance] = {}
roots: List[Instance] = []
# Auto-increment id для Item'ов без referent (старые форматы)
next_id_counter = [100000]
def _walk(item_el: ET.Element, parent_ref: Optional[int]) -> None:
"""Рекурсивный обход <Item class="..."> элементов."""
cls = item_el.attrib.get('class', 'Unknown')
# Referent из атрибута (например referent="RBX42")
ref_attr = item_el.attrib.get('referent') or item_el.attrib.get('Referent')
ref_int = _ref_to_int(ref_attr) if ref_attr else None
if ref_int is None:
# Назначаем auto-id чтобы converter мог отслеживать parent_referent
ref_int = next_id_counter[0]
next_id_counter[0] += 1
# Парсим properties
props: Dict[str, Any] = {}
props_el = item_el.find('Properties')
if props_el is not None:
for prop_el in props_el:
try:
pname, pval = _parse_property(prop_el)
if pname:
props[pname] = pval
except Exception:
continue
# Roblox в старых картах использовал имена с маленькой первой буквы:
# name → Name, size → Size, shape → Shape, и т.д. Converter ожидает
# PascalCase. Делаем алиасы (старое имя остаётся, новое добавляется).
_ALIAS_TO_PASCAL = {
'name': 'Name',
'size': 'Size',
'shape': 'Shape',
'archivable': 'Archivable',
'shape3d': 'Shape',
}
for old, new in _ALIAS_TO_PASCAL.items():
if old in props and new not in props:
props[new] = props[old]
# Convert Ref-properties (string "RBX42") в parent_referent если нужно
# — пока оставляем как строки.
inst = Instance(
referent=ref_int,
class_name=cls,
properties=props,
parent_referent=parent_ref,
children=[],
)
instances.append(inst)
by_referent[ref_int] = inst
if parent_ref is None:
roots.append(inst)
# Рекурсивно дочерние Item'ы
for child in item_el.findall('Item'):
_walk(child, ref_int)
# Roblox-XML: top-level <Item class="..."> идут под <roblox>
for item in root.findall('Item'):
_walk(item, None)
# Заполняем children после полного прохода (для удобства converter'а)
for inst in instances:
if inst.parent_referent is not None:
parent = by_referent.get(inst.parent_referent)
if parent is not None:
parent.children.append(inst)
# Версия из атрибута <roblox version="4">
version_attr = root.attrib.get('version', '4')
try:
version = int(version_attr)
except ValueError:
version = 4
return RobloxModel(
version=version,
class_count=len(set(i.class_name for i in instances)),
instance_count=len(instances),
instances=instances,
by_referent=by_referent,
roots=roots,
shared_strings=[],
meta={},
warnings=[],
)

View File

@ -10,7 +10,7 @@ export const USER_addres = BASE + '/api-user';
export const ACHIVES_addres = BASE + '/api-achievs';
export const COMMENTS_addres = BASE + '/api-comments';
export const STORYS_addres = BASE + '/api-storys';
// rbxl-importer: только для МИНа (тест-фича импорта .rbxl карт Roblox)
// rbxl-importer: импорт .rbxl карт Roblox (см. вики «Импорт из Roblox»)
export const RBXL_addres = BASE + '/api-rbxl';
export const NOTICES_addres = BASE + '/api-notices';
export const HELP_addres = BASE + '/api-help';

View File

@ -52,11 +52,20 @@ export async function analyzeRbxl(file) {
/**
* Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }.
*/
export async function createRbxlProject(previewHash, title) {
export async function createRbxlProject(previewHash, title, opts = {}) {
const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ preview_hash: previewHash, title: title || '' }),
body: JSON.stringify({
preview_hash: previewHash,
title: title || '',
// 'disabled' (default) — импортнуть выключенными, читать можно
// 'enabled' — попытаться запустить (может вешать карту)
// 'skip' — не импортировать совсем
scripts_mode: opts.scriptsMode || 'disabled',
// 'all' (default) / 'screen-only' (только HUD) / 'skip' (без GUI)
gui_mode: opts.guiMode || 'all',
}),
});
if (!resp.ok) {
const text = await resp.text();

View File

@ -13,6 +13,8 @@ import { GAMES, GAME_GROUPS } from './docsGames';
import { LESSONS, hasLesson } from './docsLessons';
import { buildGameProject } from './docsGamesBuilders';
import DocIcon from './docsIcons';
import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang';
import { LUA_OVERRIDES } from './docsGamesBuildersLua';
/**
* KubikonDocs вика редактора Рублокс.
@ -76,6 +78,7 @@ const KubikonDocs = () => {
return (
<div className={cl.studio}>
<style>{INLINE_STYLES}</style>
<style>{DOCS_LANG_STYLES}</style>
{/* === Левая боковая панель === */}
<aside className={cl.sidebar}>
@ -383,12 +386,15 @@ const ChapterPage = ({ chapter, mainRef }) => {
{/* Контент раздела */}
<div className="docsContent">
{chapter.sections.map((s) => (
<article key={s.id} id={`sec-${s.id}`} className="docsChapter">
<h3 className="docsSectionTitle">{s.title}</h3>
<div className="docsSectionBody">{s.body}</div>
</article>
))}
<DocsLangProvider>
<DocsLangPicker />
{chapter.sections.map((s) => (
<article key={s.id} id={`sec-${s.id}`} className="docsChapter">
<h3 className="docsSectionTitle">{s.title}</h3>
<div className="docsSectionBody">{s.body}</div>
</article>
))}
</DocsLangProvider>
</div>
</section>
);
@ -399,17 +405,20 @@ const ChapterPage = ({ chapter, mainRef }) => {
//
const LessonPage = ({ game, navigate }) => {
const lesson = LESSONS[game.id];
// 'idle' | 'creating' | 'error'
// 'idle' | 'choosing' | 'creating' | 'error'
const [state, setState] = useState('idle');
// Создаёт НОВУЮ копию игры-урока на текущем пользователе и
// открывает её в редакторе. Оригинал при этом ВСЕГДА цел.
const openInEditor = async () => {
// Шаг 1: юзер нажал «Открыть копию» показываем модалку выбора языка.
const openInEditor = () => {
const userId = getCurrentUserId();
if (!userId) {
setState('error');
return;
}
if (!userId) { setState('error'); return; }
setState('choosing');
};
// Шаг 2: язык выбран создаём копию с нужными скриптами и открываем.
const createCopyWithLang = async (lang) => {
const userId = getCurrentUserId();
if (!userId) { setState('error'); return; }
setState('creating');
try {
// project_data копии берём двумя способами:
@ -422,9 +431,11 @@ const LessonPage = ({ game, navigate }) => {
const pd = orig && orig.data && orig.data.project_data;
if (!pd) { setState('error'); return; }
// project_data может прийти строкой или объектом нормализуем в строку.
projectDataStr = typeof pd === 'string' ? pd : JSON.stringify(pd);
let pdObj = typeof pd === 'string' ? JSON.parse(pd) : pd;
if (lang === 'lua') pdObj = convertProjectScriptsToLua(pdObj);
projectDataStr = JSON.stringify(pdObj);
} else {
const project = buildGameProject(game.id);
const project = buildGameProject(game.id, { lang });
if (!project) { setState('error'); return; }
projectDataStr = JSON.stringify(project);
}
@ -477,6 +488,12 @@ const LessonPage = ({ game, navigate }) => {
: <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
</button>
</div>
{state === 'choosing' && (
<LangChoiceModal
onPick={(lang) => createCopyWithLang(lang)}
onCancel={() => setState('idle')}
/>
)}
{state === 'error' && (
<div className="lessonErr">
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт,
@ -484,14 +501,134 @@ const LessonPage = ({ game, navigate }) => {
</div>
)}
{/* Тело урока */}
{/* Тело урока с переключателем JS/Lua */}
<article className="docsChapter lessonBody">
<div className="docsSectionBody">{lesson.body}</div>
<DocsLangProvider>
<DocsLangPicker />
<LuaLessonBanner gameId={game.id} />
<div className="docsSectionBody">{lesson.body}</div>
</DocsLangProvider>
</article>
</section>
);
};
// При выбранном Lua показывает плашку с готовыми Lua-скриптами для урока
// (если они есть в LUA_OVERRIDES). Скрипты ниже в основном теле остаются
// на JS как референс Lua-версия здесь сверху для копирования.
const LuaLessonBanner = ({ gameId }) => {
const { lang } = useDocsLang();
if (lang !== 'lua') return null;
const overrides = LUA_OVERRIDES[gameId];
if (!overrides) {
return (
<div className="luaLessonBanner luaLessonBanner--missing">
<b>Lua-версия в работе.</b>
<p>
Для этого урока пока готова только JS-версия (показана ниже).
Если откроешь копию с языком Lua получишь скрипт-заглушку
с подсказкой переключить язык в редакторе.
</p>
</div>
);
}
const entries = Object.entries(overrides);
return (
<div className="luaLessonBanner">
<div className="luaLessonBanner__head">
<b>Готовые Lua-скрипты для этой игры</b>
<span className="luaLessonBanner__hint">
Эти скрипты автоматически попадут в твою копию, если откроешь её на Lua.
</span>
</div>
{entries.map(([id, codeOrFn]) => {
const code = typeof codeOrFn === 'function' ? codeOrFn({ id }) : codeOrFn;
return (
<details key={id} className="luaLessonBanner__script">
<summary>{id}</summary>
<pre className="docCode" data-lang="lua"><code>{code}</code></pre>
</details>
);
})}
</div>
);
};
//
// Модалка выбора языка скриптов при «Открыть копию»
//
const LangChoiceModal = ({ onPick, onCancel }) => {
return (
<div className="langChoiceOverlay" onClick={onCancel}>
<div className="langChoiceDialog" onClick={(e) => e.stopPropagation()}>
<h3 className="langChoiceTitle">На каком языке открыть копию?</h3>
<p className="langChoiceSub">
Скрипты в твоей копии будут написаны на выбранном языке.
Логика игры одинаковая отличается только запись кода.
</p>
<div className="langChoiceBtns">
<button className="langChoiceBtn langChoiceBtn--js"
onClick={() => onPick('js')}>
<div className="langChoiceBtn__name">JavaScript</div>
<div className="langChoiceBtn__hint">
Если ты новичок этот выбор проще.
</div>
</button>
<button className="langChoiceBtn langChoiceBtn--lua"
onClick={() => onPick('lua')}>
<div className="langChoiceBtn__name">Lua</div>
<div className="langChoiceBtn__hint">
Если играл в Roblox узнаешь команды.
</div>
</button>
</div>
<button className="langChoiceCancel" onClick={onCancel}>Отмена</button>
</div>
</div>
);
};
/**
* Конвертирует все JS-скрипты в project_data в Lua-эквивалент.
* Сейчас простая стратегия: если в скрипте есть code_lua слот, делает его
* активным. Иначе ставит флаг language='lua' и пустой Lua-шаблон с TODO.
* Полноценная транспиляция JSLua невозможна без AST-анализа.
*/
function convertProjectScriptsToLua(projectData) {
const scene = projectData?.scene;
if (!scene || !Array.isArray(scene.scripts)) return projectData;
scene.scripts = scene.scripts.map(s => {
if (s.language === 'lua') return s;
// Если уже есть готовый Lua-слот используем его
if (s.code_lua && s.code_lua.trim()) {
return {
...s,
language: 'lua',
code: s.code_lua,
code_js: s.code_js || s.code,
code_lua: s.code_lua,
};
}
// Иначе ставим заглушку с подсказкой
const luaStub = `-- TODO: версия этого скрипта на Lua пока не готова.
-- Оригинальный JS-код сохранён ниже (переключи язык назад на JS в редакторе).
-- Доступные API: game:GetService("Players"), game.Workspace, script.Parent
--
-- Например, простой пример:
local Players = game:GetService("Players")
print("Привет от Lua-скрипта")
`;
return {
...s,
language: 'lua',
code: luaStub,
code_js: s.code_js || s.code,
code_lua: luaStub,
};
});
return projectData;
}
//
// Инлайн-стили
//
@ -732,13 +869,14 @@ const INLINE_STYLES = `
.docsSectionBody b { color: #0f172a; font-weight: 800; }
.docsSectionBody h4 { font-family: inherit; }
.docsSectionBody code {
background: #e0e8ff;
color: #3357ff;
background: #fff5e0;
color: #b14400;
padding: 2px 7px;
border-radius: 6px;
font-family: Consolas, Menlo, "Courier New", monospace;
font-size: 13px;
font-weight: 700;
border: 1px solid #f5d8a8;
}
/* kbd */
@ -770,6 +908,7 @@ const INLINE_STYLES = `
.docCode code {
background: none; color: inherit; padding: 0;
font-weight: 500; font-size: 13px; white-space: pre;
border: none;
}
/* Скриншот интерфейса с подписью.

View File

@ -390,18 +390,16 @@ const KubikonStudio = () => {
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span>
</button>
{/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */}
{getCurrentUserId() === 1 && (
<button
className={cl.navItem}
onClick={() => setRbxlImportOpen(true)}
title="Импортировать игру из Roblox (.rbxl файл) — тест-фича"
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
>
<span className={cl.navIcon}>📦</span>
<span>Импорт Roblox</span>
</button>
)}
{/* Импорт Roblox .rbxl — доступно всем */}
<button
className={cl.navItem}
onClick={() => setRbxlImportOpen(true)}
title="Импортировать игру из Roblox (.rbxl файл)"
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
>
<span className={cl.navIcon}>📦</span>
<span>Импорт Roblox</span>
</button>
</nav>
<RbxlImportModal

File diff suppressed because it is too large Load Diff

View File

@ -748,7 +748,9 @@ function game6ColorTiles() {
id,
type: 'cube',
name: 'Плитка_' + id,
x: -4 + c * 2, y: 1.15, z: -4 + r * 2,
// Платформа grass: x от -6 до 5 (blocks=1unit, центры [-5.5..5.5]).
// Сетка 6×6 плиток (центры через 2) центрируем на [-5..5].
x: -5 + c * 2, y: 1.15, z: -5 + r * 2,
sx: 1.8, sy: 0.3, sz: 1.8,
color: '#9aa0aa', // серый — не раскрашена
material: 'matte',
@ -6158,8 +6160,143 @@ export function hasGameBuilder(id) {
return typeof GAME_BUILDERS[id] === 'function';
}
/** Построить project_data для игры-урока. Возвращает объект или null. */
export function buildGameProject(id) {
const fn = GAME_BUILDERS[id];
return fn ? fn() : null;
// ══════════════════════════════════════════════════════════════════
// LUA_OVERRIDES — реестр Lua-версий скриптов для уроков.
// Структура: { gameId: { scriptId: 'lua code' | (script) => 'lua code' } }
// Если скрипт описан здесь — при buildGameProject(id, {lang:'lua'}) его
// code будет заменён на Lua-версию.
// См. docsGamesBuildersLua.js для содержимого.
// ══════════════════════════════════════════════════════════════════
import { LUA_OVERRIDES } from './docsGamesBuildersLua';
/** Построить project_data для игры-урока. Возвращает объект или null.
* opts.lang: 'js' (default) | 'lua' на каком языке скрипты в копии.
*/
/**
* Генерирует минимальный рабочий Lua-каркас для скрипта когда явной
* Lua-реализации в LUA_OVERRIDES нет. Анализирует target и name чтобы
* сделать что-то осмысленное:
* - target=null (главный скрипт): показывает подсказку, слушает событие
* FinishReached и при срабатывании конфетти + Победа
* - target=primitive с именем содержащим "Финиш"/"Final": Touched
* шлёт FinishReached
* - target=primitive с любым другим именем: Touched красит примитив
* в случайный цвет (визуальный feedback что скрипт работает)
*/
function generateFallbackLua(s, gameTitle) {
const target = s.target;
const name = s.name || s.id || '';
const title = gameTitle || 'игра';
// Главный скрипт (target=null)
if (!target || target === null) {
return `-- === ${name} (Lua, авто-каркас) ===
-- Полная Lua-версия этой игры пока в разработке.
-- Этот каркас обеспечивает базовое поведение: подсказка + победа на финише.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local function getEvent(eventName)
local ev = ReplicatedStorage:FindFirstChild(eventName)
if not ev then
ev = Instance.new("BindableEvent")
ev.Name = eventName
ev.Parent = ReplicatedStorage
end
return ev
end
__rbxl_show_text("${title.replace(/"/g, '\\"')}", 3)
local winSound = Instance.new("Sound", workspace)
winSound.SoundId = "win"; winSound.Volume = 1
local won = false
local winEvent = getEvent("FinishReached")
winEvent.Event:Connect(function()
if won then return end
won = true
winSound:Play()
__rbxl_show_text("Победа!", 4)
local px = __rbxl_player_x()
local py = __rbxl_player_y()
local pz = __rbxl_player_z()
__rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3)
end)`;
}
// Скрипт на примитиве с именем "Финиш" / "ФинишЗона" / "Final"
const isFinish = /финиш|финал|final/i.test(name);
if (isFinish) {
return `-- === ${name} (Lua, авто-каркас) ===
-- При касании игроком шлём событие победы.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local part = script.Parent
local fired = false
part.Touched:Connect(function(hit)
if fired then return end
local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if not h then return end
fired = true
local ev = ReplicatedStorage:FindFirstChild("FinishReached")
if not ev then
ev = Instance.new("BindableEvent")
ev.Name = "FinishReached"
ev.Parent = ReplicatedStorage
end
ev:Fire()
end)`;
}
// Общий каркас для любого target-примитива — Touched красит в случайный цвет
return `-- === ${name} (Lua, авто-каркас) ===
-- Полная Lua-версия этого скрипта пока в разработке.
-- Базовое поведение: при касании предмет реагирует визуально.
local part = script.Parent
local touched = false
part.Touched:Connect(function(hit)
if touched then return end
local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if not h then return end
touched = true
-- Меняем цвет на яркий зелёный простой feedback
part.Color = Color3.fromRGB(60, 230, 80)
end)`;
}
export function buildGameProject(id, opts = {}) {
const fn = GAME_BUILDERS[id];
if (!fn) return null;
const project = fn();
if (opts.lang === 'lua' && project) {
const scene = project.scene || {};
if (Array.isArray(scene.scripts)) {
const overrides = LUA_OVERRIDES[id] || {};
// Извлекаем название игры из любого скрипта (для подсказки в fallback)
let gameTitle = '';
const mainScript = scene.scripts.find(s => !s.target);
if (mainScript) {
const m = /===\s*ИГРА\s*[«"](.+?)[»"]/i.exec(mainScript.code || '');
if (m) gameTitle = m[1];
}
scene.scripts = scene.scripts.map(s => {
if (s.language === 'lua') return s;
// Приоритет: явный code_lua → override из реестра → авто-fallback.
let luaCode = s.code_lua;
if (!luaCode) {
const ov = overrides[s.id];
if (typeof ov === 'function') luaCode = ov(s);
else if (typeof ov === 'string') luaCode = ov;
}
if (!luaCode || !luaCode.trim()) {
luaCode = generateFallbackLua(s, gameTitle);
}
return {
...s,
language: 'lua',
code: luaCode,
code_js: s.code_js || s.code,
code_lua: luaCode,
};
});
}
}
return project;
}

File diff suppressed because it is too large Load Diff

463
src/community/docsLang.jsx Normal file
View File

@ -0,0 +1,463 @@
/**
* docsLang.jsx поддержка вкладок JS/Lua в статьях вики.
*
* Компоненты:
* <DocsLangProvider> оборачивает страницу статьи, хранит выбранный язык
* в localStorage 'rublox.docs.lang' ('js' | 'lua').
* <DocsLangPicker /> большой переключатель JS/Lua над статьёй.
* <LangTabs js lua /> вкладка-переключатель внутри статьи. Показывает
* либо js, либо lua, согласно текущему языку.
* useDocsLang() хук: возвращает {lang, setLang}.
*
* Если в статье нет ни одного <LangTabs> она одинаково выглядит на обоих
* языках (общая теория, не зависящая от языка скриптов).
*/
import React, { createContext, useContext, useEffect, useState } from 'react';
//
// Простая подсветка синтаксиса для JS и Lua
//
const JS_KEYWORDS = new Set([
'let', 'const', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
'do', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class',
'extends', 'super', 'true', 'false', 'null', 'undefined', 'try', 'catch',
'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'async', 'await',
'import', 'export', 'from', 'default', 'delete', 'void',
]);
const JS_BUILTINS = new Set([
'game', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean', 'JSON',
'console', 'setTimeout', 'setInterval', 'Promise', 'document', 'window',
]);
const LUA_KEYWORDS = new Set([
'local', 'function', 'end', 'if', 'then', 'else', 'elseif', 'for', 'while',
'do', 'repeat', 'until', 'return', 'break', 'and', 'or', 'not', 'true',
'false', 'nil', 'in', 'goto',
]);
const LUA_BUILTINS = new Set([
'game', 'workspace', 'script', 'Instance', 'Vector3', 'Vector2', 'Color3',
'CFrame', 'UDim2', 'UDim', 'BrickColor', 'Enum', 'math', 'string', 'table',
'task', 'print', 'warn', 'pairs', 'ipairs', 'pcall', 'tostring', 'tonumber',
'TweenInfo', 'wait', 'tick', 'type', 'require', 'next', 'setmetatable',
'getmetatable', 'rawget', 'rawset',
]);
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Возвращает HTML-строку с раскрашенным кодом. lang: 'js' | 'lua'. */
export function highlightCode(text, lang) {
if (typeof text !== 'string') return escapeHtml(String(text || ''));
const isLua = lang === 'lua';
const keywords = isLua ? LUA_KEYWORDS : JS_KEYWORDS;
const builtins = isLua ? LUA_BUILTINS : JS_BUILTINS;
// Регулярки для токенов порядок важен: сначала комменты и строки,
// потом числа, потом identifier'ы.
// JS: //... и /*...*/. Lua: --... и --[[...]].
const commentRe = isLua
? /--\[\[[\s\S]*?\]\]|--[^\n]*/g
: /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
// Строки: одинарные, двойные, в JS ещё бэктики.
const stringRe = isLua
? /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\[\[[\s\S]*?\]\]/g
: /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`/g;
const numRe = /\b\d+(?:\.\d+)?\b/g;
const idRe = /[A-Za-zА-Яа-я_$][A-Za-zА-Яа-я0-9_$]*/g;
// Берём весь текст, делим на токены через одну общую регулярку.
const tokens = [];
const combined = new RegExp(
commentRe.source + '|' + stringRe.source + '|' + numRe.source + '|' + idRe.source,
'g'
);
let lastIndex = 0;
let match;
while ((match = combined.exec(text)) !== null) {
const start = match.index;
const tok = match[0];
if (start > lastIndex) {
tokens.push({ type: 'raw', text: text.slice(lastIndex, start) });
}
// Классифицируем
if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) {
tokens.push({ type: 'comment', text: tok });
} else if (/^["'`\[]/.test(tok)) {
tokens.push({ type: 'string', text: tok });
} else if (/^\d/.test(tok)) {
tokens.push({ type: 'number', text: tok });
} else if (keywords.has(tok)) {
tokens.push({ type: 'keyword', text: tok });
} else if (builtins.has(tok)) {
tokens.push({ type: 'builtin', text: tok });
} else {
// Идентификатор проверим, идёт ли за ним ( функция
const rest = text.slice(start + tok.length);
if (/^\s*\(/.test(rest)) {
tokens.push({ type: 'fn', text: tok });
} else {
tokens.push({ type: 'ident', text: tok });
}
}
lastIndex = start + tok.length;
}
if (lastIndex < text.length) {
tokens.push({ type: 'raw', text: text.slice(lastIndex) });
}
return tokens.map(t => {
const safe = escapeHtml(t.text);
if (t.type === 'raw' || t.type === 'ident') return safe;
return `<span class="hl-${t.type}">${safe}</span>`;
}).join('');
}
// v2 раньше при первом включении lua-режима сохранялся в LS и юзер
// потом всегда видел Lua-таб по умолчанию. Бамп ключа = сброс на JS
// у всех уже-открытых вкладок.
const LS_KEY = 'rublox.docs.lang.v2';
const LS_KEY_OLD = 'rublox.docs.lang';
const DEFAULT_LANG = 'js';
const DocsLangContext = createContext({
lang: DEFAULT_LANG,
setLang: () => {},
});
export function DocsLangProvider({ children }) {
const [lang, setLangState] = useState(() => {
try {
// Очищаем старый ключ у части юзеров там залип 'lua'
localStorage.removeItem(LS_KEY_OLD);
const v = localStorage.getItem(LS_KEY);
return v === 'lua' ? 'lua' : 'js';
} catch (_) {
return DEFAULT_LANG;
}
});
const setLang = (next) => {
const v = next === 'lua' ? 'lua' : 'js';
setLangState(v);
try { localStorage.setItem(LS_KEY, v); } catch (_) {}
};
useEffect(() => {
// Слушаем смену из других вкладок
const onStorage = (e) => {
if (e.key === LS_KEY && (e.newValue === 'js' || e.newValue === 'lua')) {
setLangState(e.newValue);
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
return (
<DocsLangContext.Provider value={{ lang, setLang }}>
{children}
</DocsLangContext.Provider>
);
}
export function useDocsLang() {
return useContext(DocsLangContext);
}
/** Большой переключатель над статьёй: «На каком языке смотреть код?» */
export function DocsLangPicker() {
const { lang, setLang } = useDocsLang();
return (
<div className="docsLangPicker">
<div className="docsLangPicker__label">
Язык скриптов в этой статье:
</div>
<div className="docsLangPicker__tabs">
<button
type="button"
className={
'docsLangPicker__tab docsLangPicker__tab--js' +
(lang === 'js' ? ' is-active' : '')
}
onClick={() => setLang('js')}
>
JavaScript
</button>
<button
type="button"
className={
'docsLangPicker__tab docsLangPicker__tab--lua' +
(lang === 'lua' ? ' is-active' : '')
}
onClick={() => setLang('lua')}
>
Lua
</button>
</div>
<div className="docsLangPicker__hint">
Не знаешь что выбрать? Смотри статью <b>D0. Скриптинг: JS или Lua?</b>
</div>
</div>
);
}
/**
* Локальный переключатель вкладок внутри статьи. Если js/lua
* прямой контент (children), если на странице нет <DocsLangProvider>
* показываем оба заголовками.
*
* Использование:
* <LangTabs
* js={<Code>game.log('Привет')</Code>}
* lua={<Code>print('Привет')</Code>}
* />
*/
export function LangTabs({ js, lua }) {
const { lang, setLang } = useDocsLang();
const hasJs = js !== undefined && js !== null;
const hasLua = lua !== undefined && lua !== null;
if (!hasJs && !hasLua) return null;
// Если есть только один язык показываем без переключателя
if (hasJs && !hasLua) return <>{js}</>;
if (!hasJs && hasLua) return <>{lua}</>;
return (
<div className="docsLangTabs">
<div className="docsLangTabs__head">
<button
type="button"
className={'docsLangTabs__tab' + (lang === 'js' ? ' is-active' : '')}
onClick={() => setLang('js')}
>
JS
</button>
<button
type="button"
className={'docsLangTabs__tab' + (lang === 'lua' ? ' is-active' : '')}
onClick={() => setLang('lua')}
>
Lua
</button>
</div>
<div className="docsLangTabs__body">
{lang === 'lua' ? lua : js}
</div>
</div>
);
}
export const DOCS_LANG_STYLES = `
.docsLangPicker {
background: linear-gradient(135deg, #1a1d2e 0%, #14172b 100%);
border: 1px solid #2a3050;
border-radius: 10px;
padding: 14px 18px;
margin: 16px 0 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.docsLangPicker__label {
font-size: 13px;
font-weight: 600;
color: #c8cce0;
}
.docsLangPicker__tabs {
display: flex;
gap: 8px;
}
.docsLangPicker__tab {
flex: 1;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid transparent;
background: #232842;
color: #aab0c8;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
}
.docsLangPicker__tab:hover { background: #2a304f; color: #fff; }
.docsLangPicker__tab--js.is-active {
background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%);
color: #1a1a1c;
border-color: #d4b500;
}
.docsLangPicker__tab--lua.is-active {
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
color: #fff;
border-color: #1565c0;
}
.docsLangPicker__hint {
font-size: 12px;
color: #8a90a8;
font-style: italic;
}
.docsLangTabs {
margin: 12px 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid #e0e6f0;
background: #fff;
}
.docsLangTabs__head {
display: flex;
background: #f4f6fb;
border-bottom: 1px solid #e0e6f0;
}
.docsLangTabs__tab {
padding: 9px 18px;
border: none;
background: transparent;
color: #64748b;
font-size: 12px;
font-weight: 700;
cursor: pointer;
letter-spacing: 0.5px;
border-bottom: 2px solid transparent;
}
.docsLangTabs__tab:hover { color: #1e293b; }
.docsLangTabs__tab.is-active {
color: #1e3a8a;
border-bottom-color: #3357ff;
background: #fff;
}
.docsLangTabs__body {
padding: 0;
background: #fff;
}
.docsLangTabs__body > pre,
.docsLangTabs__body > .docCode { margin: 0; border-radius: 0; }
/* Заголовки колонок таблицы (th) в основных стилях вики не определены.
Делаем светлыми чтобы не сливались с фоном таблицы. */
.docTable th {
padding: 9px 14px;
background: #eef2ff;
color: #1e3a8a;
font-size: 13px;
font-weight: 700;
text-align: left;
border-bottom: 1px solid #d4dcef;
border-right: 1px solid #eef2f7;
}
.docTable th:last-child { border-right: none; }
.docTable thead tr:first-child th:first-child { border-top-left-radius: 12px; }
.docTable thead tr:first-child th:last-child { border-top-right-radius: 12px; }
.langChoiceOverlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.75);
display: flex; align-items: center; justify-content: center;
z-index: 10000;
}
.langChoiceDialog {
background: #1a1d2e;
border: 1px solid #2a3050;
border-radius: 14px;
padding: 28px;
width: 100%;
max-width: 520px;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
}
.langChoiceTitle {
font-size: 20px;
margin: 0 0 8px;
color: #fff;
}
.langChoiceSub {
margin: 0 0 20px;
font-size: 13px;
color: #aab0c8;
line-height: 1.5;
}
.langChoiceBtns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 14px;
}
.langChoiceBtn {
padding: 18px 16px;
border-radius: 10px;
border: 2px solid transparent;
text-align: left;
cursor: pointer;
transition: all 0.15s;
color: #fff;
}
.langChoiceBtn--js {
background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%);
color: #1a1a1c;
}
.langChoiceBtn--js:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(247,223,30,0.3); }
.langChoiceBtn--lua {
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
}
.langChoiceBtn--lua:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(33,150,243,0.4); }
.langChoiceBtn__name {
font-size: 18px;
font-weight: 800;
margin-bottom: 4px;
}
.langChoiceBtn__hint {
font-size: 12px;
font-weight: 400;
opacity: 0.85;
}
.langChoiceCancel {
width: 100%;
padding: 10px;
background: transparent;
border: 1px solid #2a3050;
color: #aab0c8;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
}
.langChoiceCancel:hover { background: #232842; color: #fff; }
/*
Подсветка синтаксиса в код-блоках
*/
.docCode .hl-keyword { color: #ff79c6; font-weight: 600; } /* let/const/local/function */
.docCode .hl-builtin { color: #8be9fd; } /* game / workspace / Math */
.docCode .hl-string { color: #f1fa8c; } /* 'строки' "строки" */
.docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */
.docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */
.docCode .hl-fn { color: #50fa7b; } /* myFunc() */
/*
Баннер «Lua-скрипты для урока»
*/
.luaLessonBanner {
background: #eef4ff;
border: 1px solid #c7d8f5;
border-radius: 10px;
padding: 14px 18px;
margin: 14px 0 22px;
}
.luaLessonBanner--missing {
background: #fff7e0;
border-color: #f0d599;
color: #5a4500;
}
.luaLessonBanner--missing p { margin: 4px 0 0; font-size: 13px; }
.luaLessonBanner__head { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.luaLessonBanner__head b { font-size: 14px; color: #1e3a8a; }
.luaLessonBanner__hint { font-size: 12px; color: #475569; font-style: italic; }
.luaLessonBanner__script { margin: 6px 0; }
.luaLessonBanner__script summary {
cursor: pointer;
padding: 8px 12px;
background: #fff;
border-radius: 6px;
border: 1px solid #d0dcf0;
font-family: Consolas, monospace;
font-size: 13px;
color: #1e3a8a;
font-weight: 600;
}
.luaLessonBanner__script summary:hover { background: #f4f8ff; }
.luaLessonBanner__script[open] summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.luaLessonBanner__script pre { margin: 0; border-top-left-radius: 0; border-top-right-radius: 0; }
`;

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
/**
* RbxlImportModal модалка импорта .rbxl Roblox-карт в Rublox.
*
* Доступна ТОЛЬКО МИНу (user_id === 1) это тест-фича.
* Доступна всем пользователям (см. вики «Импорт из Roblox» о нюансах).
*
* Поток:
* 1. Юзер дропает или выбирает .rbxl файл.
@ -13,8 +13,6 @@
import React, { useState, useRef } from 'react';
import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js';
const ALLOWED_USER_ID = 1; // МИН
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) {
@ -26,25 +24,22 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
const [previewHash, setPreviewHash] = useState(null);
const [title, setTitle] = useState('');
const [error, setError] = useState(null);
// Режим скриптов: 'disabled' (импортнуть выключенными для чтения),
// 'enabled' (попытаться запустить может вешать карту), 'skip' (удалить).
const [scriptsMode, setScriptsMode] = useState('disabled');
// Режим GUI: 'all' все, 'screen-only' только ScreenGui (HUD),
// 'skip' не импортировать. Старые карты часто имеют 200+ BillboardGui
// (вывески города), что вешает рендер.
const [guiMode, setGuiMode] = useState('all');
const fileInputRef = useRef(null);
if (!open) return null;
if (currentUserId !== ALLOWED_USER_ID) {
return (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<h2 style={{ marginTop: 0 }}>Импорт из Roblox</h2>
<p>Эта тест-функция доступна только администратору.</p>
<button style={btnStyle} onClick={onClose}>Закрыть</button>
</div>
</div>
);
}
const reset = () => {
setFile(null); setReport(null); setPreviewHash(null);
setTitle(''); setError(null); setAnalyzing(false); setCreating(false);
setScriptsMode('disabled');
setGuiMode('all');
};
const handleClose = () => { reset(); onClose?.(); };
@ -88,7 +83,7 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
setCreating(true);
setError(null);
try {
const result = await createRbxlProject(previewHash, title);
const result = await createRbxlProject(previewHash, title, { scriptsMode, guiMode });
onCreated?.(result);
handleClose();
// редирект на редактор
@ -175,6 +170,29 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
</tbody>
</table>
{report.primitives_created > 5000 && (
<div style={{
marginTop: 12, padding: 12,
background: report.primitives_created > 15000 ? '#5a1a1a' : '#4a3a1a',
borderRadius: 6,
border: '1px solid ' + (report.primitives_created > 15000 ? '#a55' : '#a85'),
}}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
{report.primitives_created > 15000
? '🛑 Очень большая карта'
: '⚠️ Большая карта'}
</div>
<div style={{ fontSize: 12, color: '#ddd' }}>
{report.primitives_created} Part'ов это много. Студия может
{report.primitives_created > 15000
? ' зависнуть или работать с FPS &lt; 1.'
: ' тормозить (FPS 10-30).'}
{' '}Рекомендуем выбрать ниже «Не импортировать скрипты»
чтобы хоть посмотреть геометрию.
</div>
</div>
)}
{report.top_classes?.length > 0 && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary>
@ -206,6 +224,98 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
</div>
</div>
{report.scripts_total > 0 && (
<div style={{ marginTop: 16, padding: 12, background: '#1f1f1f', borderRadius: 6, border: '1px solid #333' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
Что делать со скриптами ({report.scripts_total} шт.)?
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
<input
type="radio" name="scriptsMode" value="disabled"
checked={scriptsMode === 'disabled'}
onChange={() => setScriptsMode('disabled')}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13 }}>Импортировать <b>выключенными</b> (рекомендуется)</div>
<div style={{ fontSize: 11, color: '#888' }}>
Скрипты видны в иерархии и редакторе, можно читать как референс,
но не исполняются. Карта не подвиснет.
</div>
</div>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
<input
type="radio" name="scriptsMode" value="enabled"
checked={scriptsMode === 'enabled'}
onChange={() => setScriptsMode('enabled')}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13 }}>Импортировать <b>активными</b></div>
<div style={{ fontSize: 11, color: '#888' }}>
Попытаться запустить. Старые Roblox-скрипты могут подвешивать игру
тогда вернись и переимпортируй с другим режимом.
</div>
</div>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
<input
type="radio" name="scriptsMode" value="skip"
checked={scriptsMode === 'skip'}
onChange={() => setScriptsMode('skip')}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13 }}>Не импортировать совсем</div>
<div style={{ fontSize: 11, color: '#888' }}>
Только геометрия. Скрипты не попадут в проект чистое начало.
</div>
</div>
</label>
</div>
)}
{(() => {
const guiCount = (report.top_classes || [])
.filter(c => /Gui|Frame|Label|Button|Image|Text/.test(c.class))
.reduce((s, c) => s + c.count, 0);
if (guiCount < 50) return null;
return (
<div style={{ marginTop: 16, padding: 12, background: '#1f1f1f', borderRadius: 6, border: '1px solid #333' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
Что делать с GUI ({guiCount}+ элементов)?
</div>
<div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
В этой карте много GUI-элементов (BillboardGui вывески, табло).
Они сильно тормозят рендер если их сотни.
</div>
{['all', 'screen-only', 'skip'].map((m) => (
<label key={m} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
<input
type="radio" name="guiMode" value={m}
checked={guiMode === m}
onChange={() => setGuiMode(m)}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13 }}>
{m === 'all' && 'Все GUI'}
{m === 'screen-only' && (<>Только <b>ScreenGui</b> (рекомендуется)</>)}
{m === 'skip' && 'Без GUI вообще'}
</div>
<div style={{ fontSize: 11, color: '#888' }}>
{m === 'all' && 'Может тормозить.'}
{m === 'screen-only' && 'HUD остаётся, BillboardGui/SurfaceGui (3D-вывески) удаляются.'}
{m === 'skip' && 'Самый быстрый рендер. Только геометрия мира.'}
</div>
</div>
</label>
))}
</div>
);
})()}
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label>
<input

197
src/editor/ConfirmModal.jsx Normal file
View File

@ -0,0 +1,197 @@
/**
* ConfirmModal кастомная модалка подтверждения вместо window.confirm.
*
* Использование:
* const [confirmState, setConfirmState] = useState(null);
* ...
* setConfirmState({
* title: 'Сменить язык?',
* message: '...',
* confirmLabel: 'Сменить',
* cancelLabel: 'Отмена',
* onConfirm: () => doSomething(),
* });
* ...
* {confirmState && <ConfirmModal {...confirmState} onClose={() => setConfirmState(null)} />}
*
* Стиль тёмная тема Рублокс-студии, кнопка confirm заметная.
*/
import React, { useEffect, useRef } from 'react';
export default function ConfirmModal({
title,
message,
confirmLabel = 'OK',
cancelLabel = 'Отмена',
confirmTone = 'primary', // 'primary' | 'danger'
onConfirm,
onCancel, // если задан вызывается при клике на «cancel» вместо тихого закрытия
onClose,
}) {
const handleCancel = () => {
try { onCancel?.(); } finally { onClose?.(); }
};
const confirmBtnRef = useRef(null);
useEffect(() => {
// Автофокус на кнопке подтверждения
const t = setTimeout(() => confirmBtnRef.current?.focus(), 50);
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); onClose?.(); }
else if (e.key === 'Enter') {
// Enter confirm только если кнопка в фокусе или ничего не в фокусе
if (document.activeElement === confirmBtnRef.current || document.activeElement?.tagName === 'BODY') {
e.preventDefault();
handleConfirm();
}
}
};
window.addEventListener('keydown', onKey);
return () => { clearTimeout(t); window.removeEventListener('keydown', onKey); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleConfirm = () => {
try { onConfirm?.(); } finally { onClose?.(); }
};
return (
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
animation: 'rbxConfirmFadeIn 140ms ease-out',
}}
>
<style>{`
@keyframes rbxConfirmFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes rbxConfirmPopIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
`}</style>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: 'linear-gradient(180deg, #2a2a2e 0%, #1f1f22 100%)',
border: '1px solid #3a3a40',
borderRadius: 14,
padding: '22px 26px 18px',
minWidth: 380,
maxWidth: 480,
color: '#e8e8ea',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04)',
animation: 'rbxConfirmPopIn 160ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
{title && (
<div style={{
fontSize: 16,
fontWeight: 800,
letterSpacing: -0.2,
marginBottom: 10,
color: '#fff',
}}>
{title}
</div>
)}
{message && (
<div style={{
fontSize: 13.5,
lineHeight: 1.55,
color: '#c8c8cc',
marginBottom: 20,
whiteSpace: 'pre-wrap',
}}>
{message}
</div>
)}
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
}}>
<button
onClick={handleCancel}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #3a3a40',
background: 'transparent',
color: '#c8c8cc',
fontSize: 13,
fontWeight: 700,
fontFamily: 'inherit',
cursor: 'pointer',
transition: 'all 120ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2e2e34';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#c8c8cc';
}}
>
{cancelLabel}
</button>
<button
ref={confirmBtnRef}
onClick={handleConfirm}
style={{
padding: '8px 18px',
borderRadius: 8,
border: 'none',
background: confirmTone === 'danger'
? 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)'
: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
color: '#fff',
fontSize: 13,
fontWeight: 800,
fontFamily: 'inherit',
cursor: 'pointer',
letterSpacing: 0.2,
boxShadow: confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.4)'
: '0 6px 16px rgba(79, 116, 255, 0.4)',
transition: 'all 120ms',
outline: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.filter = 'brightness(1.12)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.filter = 'brightness(1)';
e.currentTarget.style.transform = 'translateY(0)';
}}
onFocus={(e) => {
e.currentTarget.style.boxShadow = confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.5), 0 0 0 3px rgba(192, 48, 63, 0.35)'
: '0 6px 16px rgba(79, 116, 255, 0.5), 0 0 0 3px rgba(79, 116, 255, 0.35)';
}}
onBlur={(e) => {
e.currentTarget.style.boxShadow = confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.4)'
: '0 6px 16px rgba(79, 116, 255, 0.4)';
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@ -37,7 +37,7 @@ const renderRowIcon = (val) => {
const ItemRow = ({
icon, label, title, depth = 0, selected, plusItems,
onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
extraStyle, selId,
extraStyle, selId, badge,
}) => {
const [hovered, setHovered] = useState(false);
const rowRef = React.useRef(null);
@ -84,6 +84,9 @@ const ItemRow = ({
>
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{renderRowIcon(icon)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{label}</span>
{badge && (
<span style={{ flexShrink: 0, display: 'inline-flex' }}>{badge}</span>
)}
{plusItems && plusItems.length > 0 && (
<HoverPlusMenu visible={hovered} items={plusItems} />
)}
@ -129,15 +132,39 @@ const GroupRow = ({ icon, label, open, onToggle, plusItems }) => {
/** Строка скрипта внутри иерархии. */
const ScriptRow = ({ script, depth, selected, onSelect, onDelete, onRename, onContextMenu, onStartRename }) => {
const displayName = script.name || (script.id === 'demo' ? 'Демо-скрипт' : script.id);
// Lua либо явно language='lua', либо импортированный .rbxl-скрипт
// (хранится с language='js' в БД но фактически Lua-код внутри обёртки).
const isRbxlImported = typeof script.code === 'string'
&& script.code.startsWith('// @roblox-lua');
const isLua = script.language === 'lua' || isRbxlImported;
const badge = (
<span
style={{
fontSize: 9,
fontWeight: 800,
padding: '1px 5px',
borderRadius: 4,
lineHeight: 1.4,
letterSpacing: 0.4,
marginRight: 4,
background: isLua ? '#2196f3' : '#f7df1e',
color: isLua ? '#fff' : '#1a1a1c',
}}
title={isLua ? 'Lua (Roblox API)' : 'JavaScript (game.* API)'}
>
{isLua ? 'LUA' : 'JS'}
</span>
);
return (
<ItemRow
icon="📜"
label={displayName}
title={`${displayName} (id: ${script.id})`}
title={`${displayName} (id: ${script.id}, язык: ${isLua ? 'Lua' : 'JavaScript'})`}
depth={depth}
selected={selected}
onClick={onSelect}
onContextMenu={onContextMenu}
badge={badge}
plusItems={[
{
id: 'rename', label: 'Переименовать', icon: '✏️',

View File

@ -526,11 +526,73 @@ const InspectorPanel = ({
style={{ width: '100%' }}
/>
</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Заливка теней</span>
<span style={{ opacity: 0.6 }}>{(selection.sceneAmbient ?? 0.3).toFixed(2)}</span>
</div>
<input
type="range" min="0" max="1" step="0.05"
value={selection.sceneAmbient ?? 0.3}
onChange={(e) => props.onSetLightingProps?.({ sceneAmbient: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
Подсветка теней цвет в затенённых гранях. 0 = чёрные тени, 1 = плоско.
</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}>
<Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток.
</div>
</div>
{/* Цветокоррекция */}
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="sparkle" 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 }}>{(selection.exposure ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0.3" max="2" step="0.05"
value={selection.exposure ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ exposure: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
Общая яркость. &lt;1 = темнее, &gt;1 = светлее.
</div>
</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Контраст</span>
<span style={{ opacity: 0.6 }}>{(selection.contrast ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0.5" max="2" step="0.05"
value={selection.contrast ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ contrast: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Насыщенность</span>
<span style={{ opacity: 0.6 }}>{(selection.saturation ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0" max="2" step="0.05"
value={selection.saturation ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ saturation: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
0 = чёрно-белое, 1 = норма, 2 = очень сочно.
</div>
</div>
</div>
{/* Туман */}
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="fog" size={12} /> Туман</div>

View File

@ -30,7 +30,7 @@ import BillboardEditorModal from './BillboardEditorModal';
import TerrainGenPanel from './TerrainGenPanel';
import ScriptConsole from './ScriptConsole';
import SceneTabs from './SceneTabs';
import ScriptEditor from './ScriptEditor';
import ScriptEditor, { LUA_TEMPLATE_PART, LUA_TEMPLATE_GLOBAL, JS_TEMPLATE_GLOBAL } from './ScriptEditor';
import GameHud from './GameHud';
import MinimapOverlay from './MinimapOverlay';
import GuiOverlay from './GuiOverlay';
@ -43,6 +43,7 @@ import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
import cl from './KubikonEditor.module.css';
import Icon from './Icon';
import ConfirmModal from './ConfirmModal';
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины авто-сохранение
@ -512,6 +513,8 @@ const KubikonEditor = () => {
// BillboardEditorModal открывается из инспектора при клике
// «Редактировать табличку». Содержит primitiveData выделенного билборда.
const [billboardEditorData, setBillboardEditorData] = useState(null);
// ConfirmModal кастомная модалка вместо window.confirm.
const [confirmState, setConfirmState] = useState(null);
// Bumper для обновления списков в Toolbox после edit/settings/delete.
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
// Bump-счётчик: инкрементируется при создании/очистке гладкого
@ -2043,13 +2046,19 @@ const KubikonEditor = () => {
// Флаш ScriptEditor без этого 600мс свежих правок не успеют
// попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
try { scriptEditorFlushRef.current?.(); } catch (_) {}
// Несохранённые изменения спрашиваем
// Несохранённые изменения кастомная модалка с 3 кнопками:
// Сохранить (по умолчанию), Не сохранять, Отмена.
if (dirtyRef.current) {
const ok = window.confirm('Есть несохранённые изменения. Сохранить перед выходом?');
if (ok) {
doSave().finally(() => navigate('/'));
return;
}
setConfirmState({
title: 'Несохранённые изменения',
message: 'Сохранить проект перед выходом? Если выйти без сохранения — последние правки пропадут.',
confirmLabel: 'Сохранить и выйти',
cancelLabel: 'Выйти без сохранения',
confirmTone: 'primary',
onConfirm: () => doSave().finally(() => navigate('/')),
onCancel: () => navigate('/'), // выйти без сохранения
});
return;
}
navigate('/');
};
@ -3324,10 +3333,43 @@ const KubikonEditor = () => {
scriptId={sc.id}
value={sc.code}
target={sc.target}
language={sc.language || 'js'}
flushRef={scriptEditorFlushRef}
isSoloRunning={soloScriptId === sc.id}
onLanguageChange={(lang, currentEditorCode) => {
// Два слота: code_js и code_lua живут в самом скрипте.
// При переключении: сохраняем текущий код в слот ТЕКУЩЕГО
// языка, достаём слот ЦЕЛЕВОГО языка (или шаблон если пусто).
const fromLang = sc.language === 'lua' ? 'lua' : 'js';
if (fromLang === lang) return;
const fromSlotKey = fromLang === 'lua' ? 'code_lua' : 'code_js';
const toSlotKey = lang === 'lua' ? 'code_lua' : 'code_js';
// Сохраняем текущий редактируемый код в слот текущего языка
const savedSlots = {
...(sc.code_js !== undefined ? { code_js: sc.code_js } : {}),
...(sc.code_lua !== undefined ? { code_lua: sc.code_lua } : {}),
[fromSlotKey]: currentEditorCode || '',
};
// Достаём слот целевого языка или подставляем шаблон
let nextCode = savedSlots[toSlotKey];
if (nextCode === undefined || nextCode === '') {
nextCode = lang === 'lua'
? (sc.target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL)
: JS_TEMPLATE_GLOBAL;
}
sceneRef.current?.upsertScript(
sc.id, nextCode, undefined, undefined, lang, savedSlots
);
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
}}
onSave={(code) => {
sceneRef.current?.upsertScript(sc.id, code, sc.target);
// Зеркалим в слот активного языка чтобы при swap не потерять.
const slotKey = (sc.language === 'lua') ? 'code_lua' : 'code_js';
sceneRef.current?.upsertScript(
sc.id, code, sc.target, undefined, undefined,
{ [slotKey]: code }
);
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
}}
@ -4187,6 +4229,13 @@ const KubikonEditor = () => {
setBillboardEditorData(null);
}}
/>
{/* Кастомная модалка подтверждения вместо window.confirm. */}
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</div>
);
};

View File

@ -7,6 +7,8 @@ import Icon from './Icon';
// при правке одного файла не перетряхивать все остальные.
import { GAME_TYPE_LIBS } from './engine/types/bundle';
import { registerSnippets } from './engine/snippets';
import { registerLuaInMonaco } from './lua-monaco-setup';
import ConfirmModal from './ConfirmModal';
/**
* ScriptEditor Monaco-редактор кода скрипта в табе.
@ -34,7 +36,50 @@ import { registerSnippets } from './engine/snippets';
// Если нужен какой-то метод, которого нет в автокомплите добавляйте его
// в соответствующий .d.ts (player.d.ts / scene.d.ts / ...) и пересобирайте
// командой `python _build_bundle.py` в той же папке.
function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, onClose, flushRef }) {
// Дефолтный шаблон Lua-скрипта для нового скрипта (на Part или глобальный).
// Используется при смене языка JSLua когда текущий код выглядит «пустым».
export const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть.
local part = script.Parent
print("Скрипт детали", part.Name, "запущен")
part.Touched:Connect(function(hit)
print("Касание:", hit.Name)
end)
`;
export const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Доступ к game.* API через Roblox-обёртку.
local Players = game:GetService("Players")
print("Привет, Рублокс! Lua-скрипты работают.")
-- Здороваемся со всеми кто уже в игре + кто заходит позже
for _, player in ipairs(Players:GetPlayers()) do
print("Игрок в игре:", player.Name)
end
Players.PlayerAdded:Connect(function(player)
print("Зашёл игрок:", player.Name)
end)
`;
export const JS_TEMPLATE_GLOBAL = `// Глобальный JS-скрипт. Подробнее: см. game.* API в /справочник.
game.onPlayerJoined((player) => {
game.chat.say('Привет, ' + player.name + '!');
});
`;
function isCodeLikelyEmptyTemplate(code) {
if (!code) return true;
const trimmed = code.trim();
if (trimmed.length === 0) return true;
// Содержит ТОЛЬКО комментарии и пустые строки
const lines = trimmed.split('\n').map(l => l.trim()).filter(Boolean);
return lines.every(l =>
l.startsWith('//') || l.startsWith('--') ||
l.startsWith('/*') || l.startsWith('*/') || l.startsWith('*')
);
}
function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, language, onLanguageChange, onClose, flushRef }) {
const currentLanguage = language === 'lua' ? 'lua' : 'js';
// Кастомная модалка подтверждения смены языка (вместо window.confirm)
const [confirmState, setConfirmState] = useState(null);
// Локальный буфер кода то что в редакторе сейчас.
// Синхронизируется с external value только при смене scriptId.
const [localCode, setLocalCode] = useState(value || '');
@ -76,6 +121,15 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
// При смене языка принудительно синхронизируем код со слотом нового языка.
// (родитель swap'нул code_js code_lua и прислал свежий value.)
useEffect(() => {
if (value !== undefined && value !== localCodeRef.current) {
setLocalCode(value || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [language]);
// Дебаунс-сохранение
const scheduleSave = useCallback((code) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@ -162,6 +216,9 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
// Сниппеты для быстрого старта (door/coin/portal/npc/quest/save и т.д.).
// Регистрируются один раз для всего Monaco, флаг хранится в monaco.__kubikonSnippetsRegistered.
registerSnippets(monaco);
// Lua: completionProvider (Vector3.new/Color3.fromRGB/script.Parent/...)
// + hoverProvider (документация при наведении)
registerLuaInMonaco(monaco);
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[ScriptEditor] Monaco setup error', e);
@ -282,6 +339,54 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
border: '1px solid rgba(79, 116, 255, 0.35)',
}}>{targetLabel}</span>
)}
{/* Переключатель языка JS / Lua */}
<span style={{
display: 'inline-flex',
background: '#1a1a1c',
border: '1px solid #3a3a3a',
borderRadius: 8,
padding: 2,
}}>
{['js', 'lua'].map((lang) => {
const active = currentLanguage === lang;
return (
<button
key={lang}
onClick={() => {
if (active) return;
if (!onLanguageChange) return;
// Логика двух слотов (code_js / code_lua) живёт в родителе.
// Здесь только сигналим: «переключи на lang».
// Текущий код отдаём чтобы родитель сохранил в слот.
onLanguageChange(lang, localCodeRef.current);
}}
style={{
padding: '4px 12px',
fontSize: 11,
fontWeight: 800,
fontFamily: 'inherit',
border: 'none',
borderRadius: 6,
cursor: active ? 'default' : 'pointer',
background: active
? (lang === 'lua'
? 'linear-gradient(135deg, #2196f3 0%, #1565c0 100%)'
: 'linear-gradient(135deg, #f7df1e 0%, #d4b500 100%)')
: 'transparent',
color: active
? (lang === 'lua' ? '#fff' : '#1a1a1c')
: '#9a9a9e',
letterSpacing: 0.3,
}}
title={lang === 'lua'
? 'Lua с Roblox-совместимым API (Vector3, CFrame, Instance)'
: 'JavaScript с game.* API Рублокса'}
>
{lang === 'lua' ? 'Lua' : 'JS'}
</button>
);
})}
</span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
{/* Фаза 6.1.4: кнопка «Проверить» включает семантический анализ TS
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
@ -394,10 +499,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
defaultLanguage="javascript"
defaultLanguage={currentLanguage === 'lua' ? 'lua' : 'javascript'}
language={currentLanguage === 'lua' ? 'lua' : 'javascript'}
theme="vs-dark"
value={localCode}
path={`script_${scriptId}.js`}
path={`script_${scriptId}.${currentLanguage === 'lua' ? 'lua' : 'js'}`}
onChange={handleChange}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
@ -434,6 +540,12 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
}}
/>
</div>
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</div>
);
}

View File

@ -37,6 +37,7 @@ import {
Ray,
PointerEventTypes,
Tools as BabylonTools,
ColorCurves,
} from '@babylonjs/core';
import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi';
@ -1885,9 +1886,41 @@ export class BabylonScene {
}
if (typeof patch.sunIntensity === 'number' && this._sunLight) {
this._sunLight.intensity = Math.max(0, patch.sunIntensity);
this._sunIntensity = patch.sunIntensity;
}
if (typeof patch.hemiIntensity === 'number' && this._hemiLight) {
this._hemiLight.intensity = Math.max(0, patch.hemiIntensity);
this._hemiIntensity = patch.hemiIntensity;
}
// Окружающий свет (scene.ambientColor) — отдельный множитель.
// Применяется ко всем материалам через ambient*ambient.
if (typeof patch.sceneAmbient === 'number') {
const v = Math.max(0, Math.min(1, patch.sceneAmbient));
this.scene.ambientColor = new Color3(v, v, v);
this._sceneAmbient = v;
}
// Цветокоррекция — экспозиция, контраст, насыщенность через
// imageProcessingConfiguration (включает HDR pipeline).
if (typeof patch.exposure === 'number' || typeof patch.contrast === 'number'
|| typeof patch.saturation === 'number') {
const ipc = this.scene.imageProcessingConfiguration;
ipc.isEnabled = true;
if (typeof patch.exposure === 'number') {
ipc.exposure = Math.max(0.1, Math.min(3, patch.exposure));
this._exposure = ipc.exposure;
}
if (typeof patch.contrast === 'number') {
ipc.contrast = Math.max(0.5, Math.min(2.5, patch.contrast));
this._contrast = ipc.contrast;
}
if (typeof patch.saturation === 'number') {
// colorCurves для saturation (стандартный Babylon приём)
if (!ipc.colorCurves) ipc.colorCurves = new ColorCurves();
const s = Math.max(-100, Math.min(100, (patch.saturation - 1) * 100));
ipc.colorCurves.globalSaturation = s;
ipc.colorCurvesEnabled = true;
this._saturation = patch.saturation;
}
}
if (this.environment && typeof this.environment.setFog === 'function') {
// Текущие значения берём из Environment, поверх накладываем patch
@ -3002,6 +3035,7 @@ export class BabylonScene {
if (md.isBlock) {
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
}
if (md.npcId != null) return { kind: 'npc', id: md.npcId };
if (md.isModel) return { kind: 'model', id: md.instanceId };
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
return null;
@ -3036,24 +3070,36 @@ export class BabylonScene {
const EPS = 0.25;
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
let _firedThisFrame = 0;
for (const s of scripts) {
if (!s.target) continue;
const key = 's:' + s.id;
seen.add(key);
const aabb = this._targetAABB(s.target);
if (!aabb) continue;
const overlap =
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
const wasTouching = this._touchState.get(key);
if (overlap && !wasTouching) {
this._touchState.set(key, true);
rt.routeEvent(s.target, 'touch', {});
rt.routeGlobalEvent('playerTouch', { target: s.target });
} else if (!overlap && wasTouching) {
this._touchState.set(key, false);
rt.routeEvent(s.target, 'untouch', {});
try {
const key = 's:' + s.id;
seen.add(key);
const aabb = this._targetAABB(s.target);
if (!aabb) continue;
const overlap =
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
const wasTouching = this._touchState.get(key);
if (overlap && !wasTouching) {
this._touchState.set(key, true);
rt.routeEvent(s.target, 'touch', {});
rt.routeGlobalEvent('playerTouch', { target: s.target });
_firedThisFrame++;
if (_firedThisFrame === 1) {
console.warn(`[Touch FIRE] scriptId=${s.id} target=${s.target} pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)})`);
}
} else if (!overlap && wasTouching) {
this._touchState.set(key, false);
rt.routeEvent(s.target, 'untouch', {});
}
} catch (e) {
if (!this._touchDetectErrored) {
this._touchDetectErrored = true;
console.error('[TouchDetect] error', e, 'on script', s);
}
}
}
@ -3161,6 +3207,17 @@ export class BabylonScene {
_targetAABB(target) {
if (!target) return null;
try {
// Импортированные Roblox-скрипты имеют target = число (primitiveId).
if (typeof target === 'number') {
const data = this.primitiveManager?.instances?.get(target);
if (!data || data.sx == null || data.x == null) return null;
const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2;
return {
minX: data.x - hx, maxX: data.x + hx,
minY: data.y - hy, maxY: data.y + hy,
minZ: data.z - hz, maxZ: data.z + hz,
};
}
if (target.kind === 'block') {
const r = target.ref || target;
return {
@ -3215,7 +3272,30 @@ export class BabylonScene {
}
if (!this.gameRuntime) return;
const pick = this._pickFromCenter();
// В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром.
// В 3-м лице (свободный курсор) — пикаем по координатам клика.
const locked = (document.pointerLockElement === this.canvas);
let pick;
if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
const pi = this.scene.pick(clickX, clickY, (mesh) => {
if (!mesh.isPickable) return false;
if (mesh === this._ghostMesh) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
return true;
});
if (pi?.hit) {
let m = pi.pickedMesh;
if (m?.metadata?._isBlockProto && this.blockManager) {
const proxy = this.blockManager.findProxyByPickInfo(pi);
if (proxy) m = proxy;
}
pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi };
} else {
pick = null;
}
} else {
pick = this._pickFromCenter();
}
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
// 1) Self-onClick — только если target есть
@ -5364,6 +5444,7 @@ export class BabylonScene {
code: s.code,
name: s.name || null,
target: newTarget,
language: s.language || 'js',
});
}
if (srcScripts.length > 0) {
@ -5506,7 +5587,7 @@ export class BabylonScene {
};
clip.scripts = (this._scripts || [])
.filter(s => matchTarget(s.target))
.map(s => ({ code: s.code, name: s.name || null }));
.map(s => ({ code: s.code, name: s.name || null, language: s.language || 'js' }));
} catch (e) { clip.scripts = []; }
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
catch (e) { /* ignore — приватный режим / переполнение */ }
@ -5521,7 +5602,7 @@ export class BabylonScene {
const target = kind === 'block'
? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
: { kind, id: dstRef };
this._scripts.push({ id: newId, code: s.code, name: s.name || null, target });
this._scripts.push({ id: newId, code: s.code, name: s.name || null, target, language: s.language || 'js' });
}
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
@ -6677,7 +6758,7 @@ export class BabylonScene {
}
/** Установить код одного скрипта по id. Если id нет — создать новый. */
upsertScript(id, code, target = undefined, name = undefined) {
upsertScript(id, code, target = undefined, name = undefined, language = undefined, slots = undefined) {
const i = this._scripts.findIndex(s => s.id === id);
if (i >= 0) {
this._scripts[i] = {
@ -6685,6 +6766,11 @@ export class BabylonScene {
code,
...(target !== undefined ? { target } : {}),
...(name !== undefined ? { name } : {}),
...(language !== undefined ? { language } : {}),
// Слоты code_js и code_lua — сохраняемый код для каждого языка.
// Передаются при переключении языка, чтобы код другого языка
// не пропадал.
...(slots && typeof slots === 'object' ? slots : {}),
};
} else {
this._scripts.push({
@ -6692,6 +6778,8 @@ export class BabylonScene {
code,
target: target !== undefined ? target : null,
name: name || null,
language: language || 'js',
...(slots && typeof slots === 'object' ? slots : {}),
});
}
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
@ -7717,6 +7805,15 @@ export class BabylonScene {
crosshair: this._crosshair || 'dot',
shadowQuality: this._shadowQuality || 'soft',
environment: this.environment ? this.environment.serialize() : null,
// Кастомные настройки света — слайдеры из «Свет и атмосфера»
lighting: {
sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8,
hemiIntensity: this._hemiIntensity ?? this._hemiLight?.intensity ?? 0.65,
sceneAmbient: this._sceneAmbient ?? 0.3,
exposure: this._exposure ?? 1.0,
contrast: this._contrast ?? 1.0,
saturation: this._saturation ?? 1.0,
},
skybox: this.skybox ? this.skybox.serialize() : null,
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
achievements: this.achievements ? this.achievements.serialize() : null,
@ -7736,6 +7833,7 @@ export class BabylonScene {
code: s.code,
target: s.target || null,
name: s.name || null,
language: s.language === 'lua' ? 'lua' : 'js',
})),
},
editorCamera: this.camera ? {
@ -8193,12 +8291,19 @@ export class BabylonScene {
code: s.code,
target: s.target || null,
name: s.name || null,
language: s.language === 'lua' ? 'lua' : 'js',
}));
}
// Окружение (время суток, скайбокс, туман)
if (state.scene.environment && this.environment) {
this.environment.load(state.scene.environment);
}
// Кастомные настройки света/цветокоррекции — применяем через
// setLightingProps (он сам подхватит default-ы если значения нет).
if (state.scene.lighting) {
try { this.setLightingProps(state.scene.lighting); }
catch (e) { console.warn('[BabylonScene] lighting load failed:', e); }
}
// Кастомное небо (задача 16)
if (state.scene.skybox && this.skybox) {
this.skybox.load(state.scene.skybox);

View File

@ -19,7 +19,9 @@ import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../../api/API';
import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager';
import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js';
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
import { RbxlHudOverlay } from './RbxlHudOverlay.js';
export class GameRuntime {
constructor(scene3d) {
@ -115,11 +117,70 @@ export class GameRuntime {
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
// Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
const rbxlBatch = [];
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
// Единый Lua-batch: и user-Lua (language='lua'), и импортированные .rbxl
// скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox.
// .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua.
const luaUserBatch = [];
// Импортированные .rbxl-скрипты ВКЛЮЧЕНЫ — итеративно настраиваем API
// под реальные скрипты. Выключить временно: window.__RBXL_SKIP_IMPORTED=true.
const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true);
let rbxlSkipped = 0;
for (const s of scripts) {
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
rbxlBatch.push(s);
if (!runImportedRbxl) { rbxlSkipped++; continue; }
// Уважаем поле enabled=false из Roblox-метадаты: такие скрипты
// были disabled-шаблоны (для клонирования через :Clone()), их
// запуск немедленно крашит coroutine (WASM access out of bounds).
const meta = parseRobloxLuaMeta(s.code);
if (meta && meta.enabled === false) { rbxlSkipped++; continue; }
// Пропускаем Regeneration-скрипты: у нас Anchored=True для
// импорта, постройки не разрушаются, регенерация не нужна.
// Их работа (model:remove + Clone) даст визуальные глитчи.
const sname = String(s.name || '').toLowerCase();
if (sname.startsWith('regenerate') || sname === 'regenerationscript') {
rbxlSkipped++; continue;
}
const luaSource = unpackRobloxLuaCode(s.code);
// SAFETY: пропускаем скрипты с tight-loop'ами через ChildAdded:wait()
// или WaitForChild через пользовательский while-not-FindFirstChild.
// Они подвешивают страницу (wait() возвращает синхронно, скрипт
// никогда не yield'ит из C-call). Распространённый Roblox 2009
// паттерн который мы не можем безопасно эмулировать.
if (luaSource && (
/while\s+not\s+\w+[:.]FindFirstChild/.test(luaSource) ||
/ChildAdded:[Ww]ait\(\)/.test(luaSource) ||
/:[Gg]etChildren\(\)\s*\[\d/.test(luaSource)
)) {
rbxlSkipped++;
console.warn(`[GameRuntime] skipped ${s.name}: содержит небезопасный tight-loop (WaitForChild/ChildAdded:wait)`);
continue;
}
if (luaSource && luaSource.trim()) {
// Эвристика Tool: если скрипт ссылается на Equipped/Activated
// или Tool = script.Parent — он лежит в Tool. Все Tool-скрипты
// с target=null склеиваем в ОДИН виртуальный Tool, имя берём
// из самого "явного" скрипта (содержит RayGun/Sword/Gun/Weapon).
let toolName = null;
if (s.target == null && /(script\.Parent|Tool)\.(Equipped|Unequipped|Activated|Deactivated)/.test(luaSource)) {
// Все Tool-скрипты группируем в ОДИН виртуальный Tool с именем "Tool".
// Для Zapper-демки этого хватит. В будущем — парсинг StarterPack из converter.
toolName = 'Tool';
}
luaUserBatch.push({
id: s.id,
name: s.name,
target: s.target,
toolName,
language: 'lua',
code: luaSource,
_rbxlImported: true,
});
}
continue;
}
if (s && s.language === 'lua') {
if (typeof s.code === 'string' && s.code.trim()) luaUserBatch.push(s);
continue;
}
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
@ -151,25 +212,157 @@ export class GameRuntime {
// eslint-disable-next-line no-console
console.log('[GameRuntime] sandbox started for script id=', s.id);
}
// Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом.
let rbxlCount = 0;
if (rbxlBatch.length > 0) {
// GUI-дерево из projectData для pre-population
const guiElements = this.projectData?.scene?.gui || [];
const result = startRobloxLuaShared(rbxlBatch, {
primitives,
guiElements,
onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this),
});
if (result && result.sandbox) {
this.sandboxes.push(result.sandbox);
this._rbxlSharedSandbox = result.sandbox;
rbxlCount = result.count;
// Импортированные .rbxl-скрипты теперь идут через тот же LuaSharedSandbox
// вместе с user-Lua (см. luaUserBatch выше). Отдельный Worker больше не нужен.
let luaUserCount = 0;
if (luaUserBatch.length > 0) {
try {
const sb = new LuaSharedSandbox();
// partSet/sceneCreate — переиспользуем обработчик rbxl
sb.setOnCommand(({ cmd, payload }) => {
if (cmd === 'partSet' || cmd === 'partVel' ||
cmd === 'sceneCreate' || cmd === 'sceneDelete') {
try {
handleLuaCommand(null, cmd, payload, this);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e);
}
} else if (cmd === 'toolRegistered') {
// Lua-shim создал Tool — кладём в hotbar инвентаря.
try { this._registerRbxlTool(payload); } catch (e) {
console.warn('[GameRuntime] toolRegistered failed', e);
}
} else if (cmd === 'lightingTimeUpdate') {
// Roblox Lighting:SetMinutesAfterMidnight → Babylon небо.
// Ускоряем в 8x + меняем пресет skybox (clear/sunset/night).
try {
const baseHour = Number(payload?.hour);
if (baseHour >= 0 && baseHour < 24) {
if (this._lightBaseHour == null) {
this._lightBaseHour = baseHour;
this._lightStartReal = performance.now();
}
const dGame = baseHour - this._lightBaseHour;
const accel = 8;
const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24;
this.scene3d?.setTimeOfDay?.(hour);
// Skybox preset по фазе:
// 06-08 sunset, 08-17 clear, 17-19 sunset, 19-06 starry-night
let targetPreset;
if (hour >= 6 && hour < 8) targetPreset = 'sunset';
else if (hour >= 8 && hour < 17) targetPreset = 'lowpoly-roblox';
else if (hour >= 17 && hour < 19) targetPreset = 'sunset';
else targetPreset = 'starry-night';
if (this._lightPreset !== targetPreset) {
this._lightPreset = targetPreset;
try {
const sb = this.scene3d?.skybox;
if (sb?.fadeTo) sb.fadeTo({ preset: targetPreset }, 2);
else this.scene3d?.setSkybox?.({ preset: targetPreset });
} catch (_) {}
}
}
} catch (_) {}
} else if (cmd === 'particleCreated') {
// Roblox Instance.new('Sparkles') — запомнили какие
// partlcle-эффекты есть у Tool. При equip покажем у руки.
this._rbxlPendingParticles = this._rbxlPendingParticles || [];
this._rbxlPendingParticles.push(payload);
} else if (cmd === 'mouseIconChanged') {
// Roblox Mouse.Icon → CSS cursor на canvas
try {
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
if (canvas) canvas.style.cursor = payload.cssCursor || 'default';
} catch (_) {}
} else if (cmd === 'hudMessage') {
// Roblox Message/Hint в верхней трети экрана
try {
this._ensureRbxlHud();
if (payload.visible && payload.text) {
this._rbxlHud.showMessage(payload.text);
} else {
this._rbxlHud.hideMessage();
}
} catch (_) {}
} else if (cmd === 'killFeed') {
// Кастомное событие от нашего creator-tag tracker'а
try {
this._ensureRbxlHud();
this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon);
} catch (_) {}
} else if (cmd === 'winShow') {
try {
this._ensureRbxlHud();
this._rbxlHud.showWin(payload.text || 'WIN!');
} catch (_) {}
} else if (cmd === 'ui.showText') {
// Lua-helper __rbxl_show_text: красивый центрированный
// текст без рамки (паритет с JS game.ui.showText).
try {
this._ensureRbxlHud();
this._rbxlHud.showMessage(payload.text || '');
const dur = Number(payload.duration) || 2;
const t = payload.text || '';
setTimeout(() => {
try {
if (this._rbxlHud._lastMessage === t) {
this._rbxlHud.hideMessage();
}
} catch (_) {}
}, dur * 1000);
try { this._rbxlHud._lastMessage = t; } catch (_) {}
} catch (_) {}
} else if (cmd === 'leaderstatSet') {
// Roblox leaderstats: IntValue.Value меняется → HUD.
try {
const lm = this.scene3d?.leaderstats;
if (lm) {
const statName = String(payload.statName || 'Stat');
if (!lm._defs.some(d => d.name === statName)) {
lm.define(statName, { initial: 0 });
}
lm.set(lm._meId || 'me', statName, Number(payload.value) || 0);
}
} catch (_) {}
} else {
this._handleCommand(null, cmd, payload);
}
});
// Передаём snapshot ДО start чтобы Workspace.Children заполнились
try {
const snap = this._buildSceneSnapshot();
sb.sendSceneSnapshot(snap);
} catch (_) {}
for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target, s.name, { toolName: s.toolName });
sb.start();
this.sandboxes.push(sb);
this._luaUserSandbox = sb;
luaUserCount = luaUserBatch.length;
} catch (e) {
// eslint-disable-next-line no-console
console.error('[GameRuntime] Lua user runtime failed to init', e);
this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
}
}
this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`);
if (rbxlCount > 0) {
this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`);
const rbxlImported = luaUserBatch.filter(s => s._rbxlImported).length;
const luaWritten = luaUserCount - rbxlImported;
const jsOnly = this.sandboxes.length - (this._luaUserSandbox ? 1 : 0);
// Чёткий маркер языка в логах — чтобы было видно что запущено
const lang = (luaWritten > 0 || rbxlImported > 0)
? (jsOnly > 0 ? 'СМЕШАННЫЙ (JS+Lua)' : 'LUA')
: 'JS';
// eslint-disable-next-line no-console
console.warn(`[GameRuntime] === ЯЗЫК СКРИПТОВ: ${lang} === (JS=${jsOnly}, Lua=${luaWritten}, rbxl=${rbxlImported})`);
this._log('info', `Запущено JS-скриптов: ${jsOnly}`);
if (rbxlImported > 0) {
this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`);
}
if (rbxlSkipped > 0) {
this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped} (Roblox-скрипты не поддерживаются — пиши свои Lua-скрипты под Этап 1-7 API)`);
}
if (luaWritten > 0) {
this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`);
}
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик —
@ -467,6 +660,146 @@ export class GameRuntime {
return null;
}
/** Создаёт DOM-overlay для импортированных Roblox-карт (KillFeed,
* Message, WinGui). Лениво только при первом hudMessage/killFeed. */
_ensureRbxlHud() {
if (this._rbxlHud) return;
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
const parent = canvas?.parentElement || document.body;
this._rbxlHud = new RbxlHudOverlay(parent);
}
/** Регистрирует Roblox-Tool в InventoryUI как item в hotbar.
* Слушает смену активного слота шлёт equipTool/unequipTool в Lua-shim.
* Слушает клики ЛКМ шлёт mouseButton1Down (Tool.Activated fires там). */
_registerRbxlTool(payload) {
if (!payload || payload.index == null) return;
// invUI — это новая drag-drop система с defineItem, а не inventory (старая)
const invUI = this.scene3d?.invUI;
if (!invUI || typeof invUI.defineItem !== 'function') {
console.warn('[GameRuntime] invUI not available for tool', payload);
return;
}
const itemId = `rbxlTool_${payload.index}`;
const toolName = String(payload.name || `Tool ${payload.index}`);
invUI.defineItem({
id: itemId,
name: toolName,
emoji: '🔫',
rarity: 'uncommon',
maxStack: 1,
description: `Импортированный Roblox-Tool: ${toolName}`,
});
// Кладём в конкретный hotbar-слот (index 1..9 → slot 0..8)
const slot = Math.max(0, Math.min(8, payload.index - 1));
invUI.hotbar[slot] = { itemId, count: 1 };
invUI._renderHotbar?.();
// На первом Tool — навешиваем слушатели слотов и кликов мыши.
if (!this._rbxlToolHooks) {
this._rbxlToolHooks = true;
this._rbxlActiveSlot = -1;
// Авто-эквип первого Tool сразу при регистрации — иначе юзер
// не понимает что нажимать. В Roblox StarterPack тоже сразу
// в Backpack попадает и юзер жмёт 1 для эквипа.
setTimeout(() => {
if (this._rbxlActiveSlot < 0) {
invUI.setActiveHotbar?.(slot);
const sb = this._luaUserSandbox;
sb?.sendGlobalEvent?.({ type: 'equipTool', index: payload.index });
this._rbxlActiveSlot = slot;
// Если у Tool были Sparkles — рисуем непрерывно у руки игрока
this._startRbxlToolParticles();
}
}, 100);
invUI.on('slot', () => {
const sl = invUI.active;
const item = invUI.hotbar[sl];
const sb = this._luaUserSandbox;
if (!sb) return;
if (item && item.itemId.startsWith('rbxlTool_')) {
const idx = +item.itemId.slice('rbxlTool_'.length);
sb.sendGlobalEvent?.({ type: 'equipTool', index: idx });
this._rbxlActiveSlot = sl;
this._startRbxlToolParticles();
} else if (this._rbxlActiveSlot >= 0) {
sb.sendGlobalEvent?.({ type: 'unequipTool' });
this._rbxlActiveSlot = -1;
this._stopRbxlToolParticles();
}
});
// Клики мыши при экипированном Tool — Activated/mouseButton1Down
try {
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
if (canvas) {
const sb = this._luaUserSandbox;
canvas.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (this._rbxlActiveSlot < 0) return;
// Hit-position: raycast от камеры в сцену
const hit = this._raycastFromCamera?.() || { x: 0, y: 5, z: 0 };
sb?.sendGlobalEvent?.({ type: 'mouseButton1Down', hit });
sb?.sendGlobalEvent?.({ type: 'toolActivated' });
});
canvas.addEventListener('mouseup', (e) => {
if (e.button !== 0) return;
if (this._rbxlActiveSlot < 0) return;
sb?.sendGlobalEvent?.({ type: 'mouseButton1Up' });
});
}
} catch (_) {}
}
}
/** Запускает непрерывный эмиттер Sparkles у руки игрока, пока Tool экипирован. */
_startRbxlToolParticles() {
if (this._rbxlSparkInterval) return;
const particles = this._rbxlPendingParticles || [];
if (particles.length === 0) return;
// RayGun Color3.new(0,0,1) → #0000ff. Берём цвет первой партиклы.
const p0 = particles[0] || {};
const col = p0.color || [0, 0, 1];
const hexCol = '#' + [col[0], col[1], col[2]].map(c => {
const v = Math.max(0, Math.min(255, Math.round((Number(c) || 0) * 255)));
return v.toString(16).padStart(2, '0');
}).join('');
// Каждые 200мс — короткий burst у руки игрока (приблизительно)
this._rbxlSparkInterval = setInterval(() => {
try {
const pl = this.scene3d?.player;
if (!pl || !pl._pos) return;
this.scene3d?._spawnParticleEffect?.({
type: 'sparks',
position: { x: pl._pos.x + 0.3, y: pl._pos.y + 0.4, z: pl._pos.z + 0.3 },
color: hexCol,
duration: 0.4,
count: 0.5,
});
} catch (_) {}
}, 200);
}
_stopRbxlToolParticles() {
if (this._rbxlSparkInterval) {
clearInterval(this._rbxlSparkInterval);
this._rbxlSparkInterval = null;
}
}
/** Простой raycast от камеры — для mouse.Hit. */
_raycastFromCamera() {
try {
const cam = this.scene3d?.scene?.activeCamera;
if (!cam) return { x: 0, y: 5, z: 0 };
const forward = cam.getForwardRay?.()?.direction;
const pos = cam.position;
if (!pos || !forward) return { x: 0, y: 5, z: 0 };
const t = 50;
return { x: pos.x + forward.x * t, y: pos.y + forward.y * t, z: pos.z + forward.z * t };
} catch (_) {
return { x: 0, y: 5, z: 0 };
}
}
stop() {
if (this.sandboxes.length > 0) {
this._log('info', 'Остановка скриптов');
@ -474,6 +807,14 @@ export class GameRuntime {
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
for (const sb of this.sandboxes) sb.stop();
}
// Останавливаем эффекты импортированных Tools
this._stopRbxlToolParticles?.();
this._rbxlToolHooks = false;
this._rbxlActiveSlot = -1;
this._rbxlPendingParticles = null;
// Очищаем Roblox HUD overlay (KillFeed/Message/WinGui)
try { this._rbxlHud?.dispose(); } catch (_) {}
this._rbxlHud = null;
// Удаляем все объекты, которые скрипты наспавнили через
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках.
@ -621,7 +962,61 @@ export class GameRuntime {
this._syncPhysicsToScene();
}
const state = this._collectState();
// Реальная позиция игрока для Lua __rbxl_player_pos()
// PlayerController хранит позицию в player._pos (Vector3).
const player = this.scene3d?.player;
let realPos = null;
if (player?._pos) {
const halfH = player.HALF_H ?? 0.9;
realPos = { x: player._pos.x, y: player._pos.y - halfH, z: player._pos.z };
} else if (state?.player) {
realPos = { x: state.player.x, y: state.player.y, z: state.player.z };
}
// Собираем актуальные позиции спавненных динамических примитивов
// (id >= 800000) — нужно для AABB-touched-check в Lua-shim, чтобы
// ловить попадание игрока в падающий куб.
let spawnedPositions = null;
try {
const pm = this.scene3d?.primitiveManager;
if (pm && pm.instances) {
for (const [id, data] of pm.instances.entries()) {
if (id < 800000 || data.anchored !== false) continue;
if (!spawnedPositions) spawnedPositions = [];
spawnedPositions.push([id, data.x, data.y, data.z]);
}
}
} catch (_) {}
// Собираем позиции NPC для Lua-shim
const npcPositions = [];
try {
const nm = this.scene3d?.npcManager;
if (nm && nm.npcs && this._localToReal) {
// localRef ('npc_lua_N') → реальный 'npc:<id>' → npc
for (const [localRef, realRef] of this._localToReal.entries()) {
if (typeof realRef !== 'string' || !realRef.startsWith('npc:')) continue;
const npcId = Number(realRef.slice(4));
const npc = nm.npcs.get(npcId);
if (npc) npcPositions.push([localRef, npc.x, npc.y, npc.z]);
}
}
} catch (_) {}
for (const sb of this.sandboxes) {
// Обновляем реальную позицию игрока для Lua-shim
if (realPos && sb.api?.updatePlayerPos) {
try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {}
}
// Синк спавненных динамических примитивов
if (spawnedPositions && sb.api?.updateSpawnedPos) {
for (const [id, x, y, z] of spawnedPositions) {
try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {}
}
}
// Синк позиций NPC
if (npcPositions.length > 0 && sb.api?.updateNpcPos) {
for (const [ref, x, y, z] of npcPositions) {
try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {}
}
}
// Для скриптов с target — добавляем актуальную позицию self
const stateForSb = sb.target
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
@ -1118,7 +1513,8 @@ export class GameRuntime {
const nid = this._resolveNpcId(ref);
if (nid != null) { fn(nid); return; }
// ещё не резолвится — откладываем (только для локальных ref NPC)
if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) {
if (typeof ref === 'string'
&& (ref.indexOf('npc:_local_') === 0 || ref.startsWith('npc_lua_'))) {
if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map();
if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []);
this._pendingNpcCmds.get(ref).push(fn);
@ -1183,6 +1579,32 @@ export class GameRuntime {
const d = tryGet(this.scene3d?.modelManager);
if (d) return { kind: 'model', data: d };
}
// NPC — для setLabel/clearLabel над NPC.
if (kind === 'npc' || kind == null) {
const nm = this.scene3d?.npcManager;
if (nm && nm.npcs) {
let npc = nm.npcs.get(rawId);
if (!npc) {
const n = Number(rawId);
if (Number.isFinite(n)) npc = nm.npcs.get(n);
}
if (npc) {
// У NPC реальный mesh лежит в npc.data.rootMesh (модель).
const mesh = npc.data?.rootMesh || npc.data?.rootNode
|| npc.rootMesh || npc.rootNode || null;
return {
kind: 'npc',
data: {
mesh,
rootMesh: mesh,
x: npc.x ?? 0,
y: npc.y ?? 0,
z: npc.z ?? 0,
},
};
}
}
}
const um = tryGet(this.scene3d?.userModelManager);
if (um) return { kind: 'userModel', data: um };
return null;
@ -1288,6 +1710,17 @@ export class GameRuntime {
routeEvent(target, eventType, extra = {}) {
if (!target || !eventType) return;
for (const sb of this.sandboxes) {
// LuaSharedSandbox = один sandbox на все Lua-скрипты, target=null.
// Шлём ему ВСЕ события — shim сам найдёт соответствующий Part
// через partById и сфейерит Touched на нужной части.
if (sb.constructor?.name === 'LuaSharedSandbox' || sb._luaShared) {
const kind = eventType === 'touch' ? 'touched'
: eventType === 'untouch' ? 'untouched'
: eventType;
const primId = target.id ?? target.ref ?? null;
sb.sendEvent({ kind, primId, target, ...extra });
continue;
}
if (!sb.target) continue;
if (!this._targetMatches(sb.target, target)) continue;
sb.sendEvent({ type: eventType, ...extra });
@ -1739,6 +2172,13 @@ export class GameRuntime {
// после spawnNpc (follow/moveTo/say) — они ждали
// резолва ref в очереди.
this._flushPendingNpcCmds(payload.ref, npcId);
// Также сообщаем Lua-sandbox-ам маппинг, чтобы
// npc.onDeath по локальному ref находил npcId.
for (const sb of this.sandboxes) {
if (sb.api?.setNpcLocalRef) {
try { sb.api.setNpcLocalRef(payload.ref, 'npc:' + npcId); } catch (_) {}
}
}
}
// Сообщаем воркеру маппинг localRef → npcId, чтобы
// npc.onDeath по локальному ref находил правильного NPC.
@ -3198,16 +3638,28 @@ export class GameRuntime {
const ref = payload?.ref;
const text = payload?.text;
if (typeof ref !== 'string') return;
// ленивое создание менеджера меток
if (!this.scene3d._labelManager) {
this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
}
const lm = this.scene3d._labelManager;
// резолвим меш объекта (примитив или модель)
const applyLabel = () => {
const tgt = this._resolveTweenTarget(ref);
const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode);
if (mesh) {
lm.setLabel(ref, mesh, text, payload?.opts || {});
}
};
// Если NPC ещё не зарезолвлен — откладываем через _npcCmd
// (или просто несколько попыток с retry).
const tgt = this._resolveTweenTarget(ref);
const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode);
if (mesh) {
lm.setLabel(ref, mesh, text, payload?.opts || {});
if (tgt) {
applyLabel();
} else if (typeof ref === 'string' && ref.startsWith('npc_lua_')) {
// NPC ещё спавнится — откладываем
this._npcCmd(ref, () => applyLabel());
} else {
// Retry через 0.3с (для primitive после sceneCreate)
setTimeout(applyLabel, 300);
}
} catch (e) {
console.warn('[GameRuntime] scene.setLabel failed', e);
@ -3935,6 +4387,73 @@ export class GameRuntime {
}
return;
}
if (cmd === 'playerSet' && payload) {
// Из Lua-runtime: humanoid.Health = N → {prop:'health', value:N}.
// Используем PlayerController.takeDamage, который запускает полный
// death-flow: distance debris, _onDeath callback (respawn), звук.
// Сбрасываем _lastDamageTime чтобы invulnerability не блокировал.
const player = this.scene3d?.player;
if (!player) return;
if (payload.prop === 'health') {
const target = Math.max(0, Number(payload.value) || 0);
const damage = Math.max(0, (player.hp || 0) - target);
if (damage > 0 && typeof player.takeDamage === 'function') {
player._lastDamageTime = 0;
player.takeDamage(damage, 'lua');
} else {
player.hp = target;
}
} else if (payload.prop === 'jumpVelocity') {
// Bouncer (батут): Lua-скрипт даёт игроку Y-velocity = N
try {
if (player._vy !== undefined) player._vy = Number(payload.value) || 0;
else if (player.velocity) player.velocity.y = Number(payload.value) || 0;
} catch (_) {}
} else if (payload.prop === 'walkSpeed') {
try { player.walkSpeed = Number(payload.value) || player.walkSpeed; } catch (_) {}
} else if (payload.prop === 'jumpPower') {
try { player.jumpPower = Number(payload.value) || player.jumpPower; } catch (_) {}
} else if (payload.prop === 'maxHealth') {
try {
const max = Math.max(1, Number(payload.value) || 100);
player.maxHp = max;
if (player.hp > max) player.hp = max;
} catch (_) {}
} else if (payload.prop === 'position') {
// Lua-вызов hrp.Position = ... — телепорт игрока
try {
const v = payload.value || {};
const halfH = player.HALF_H ?? 0.9;
if (player._pos) {
player._pos.set(v.x || 0, (v.y || 0) + halfH, v.z || 0);
if (player._vy != null) player._vy = 0;
} else if (player.body?.position?.set) {
player.body.position.set(v.x || 0, v.y || 0, v.z || 0);
}
} catch (_) {}
} else if (payload.prop === 'respawn') {
// Lua-вызов player:LoadCharacter() — телепорт к spawn и сброс HP
try {
if (typeof player.respawn === 'function') {
player.respawn();
} else {
const sp = this.scene3d?.projectData?.scene?.spawnPoint
|| this.projectData?.scene?.spawnPoint
|| { x: 0, y: 5, z: 0 };
// PlayerController хранит позицию в player._pos.
const halfH = player.HALF_H ?? 0.9;
if (player._pos) {
player._pos.set(sp.x, sp.y + halfH, sp.z);
if (player._vy != null) player._vy = 0;
} else if (player.body?.position?.set) {
player.body.position.set(sp.x, sp.y, sp.z);
}
player.hp = player.maxHp || 100;
}
} catch (_) {}
}
return;
}
// eslint-disable-next-line no-console
console.warn('[GameRuntime] unknown cmd', cmd);
}
@ -4213,6 +4732,7 @@ export class GameRuntime {
if (s?.primitiveManager) {
for (const data of s.primitiveManager.instances.values()) {
primitives.push({
id: data.id,
ref: 'primitive:' + data.id,
type: data.type,
x: data.x, y: data.y, z: data.z,
@ -4222,11 +4742,18 @@ export class GameRuntime {
sz: data.sz != null ? data.sz : 1,
rotationY: data.rotationY || 0,
visible: data.visible !== false,
name: data.name || null,
name: data.name || undefined,
color: data.color || undefined,
anchored: data.anchored !== false,
canCollide: data.canCollide !== false,
opacity: data.opacity != null ? data.opacity : 1,
});
}
}
return { blocks, models, primitives };
// Teams и team_spawns из projectData (импортированные из .rbxl)
const teams = this.projectData?.scene?.teams || [];
const teamSpawns = this.projectData?.scene?.team_spawns || [];
return { blocks, models, primitives, teams, teamSpawns };
}
// Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target
@ -4359,6 +4886,13 @@ export class GameRuntime {
}
_log(level, text, scriptId = null, scriptName = null) {
// Дублируем в DevTools Console — удобно для отладки скриптов
try {
const fn = level === 'error' ? console.error
: level === 'warn' ? console.warn
: console.log;
fn(`[script${scriptName ? ' ' + scriptName : ''}] ${text}`);
} catch (_) {}
if (this._onLog) {
try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ }
}

View File

@ -470,7 +470,7 @@ export class NpcManager {
const show = npc.hp < npc.maxHp;
hb.anchor.setEnabled(show);
if (show) {
hb.anchor.position.set(npc.x, npc.y + 2.4, npc.z);
hb.anchor.position.set(npc.x, npc.y + 1.9, npc.z);
const pct = Math.max(0, Math.min(1, npc.hp / npc.maxHp));
hb.fill.scaling.x = pct;
hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;

View File

@ -507,6 +507,11 @@ export class PrimitiveManager {
const matName = `${mesh.name}_mat`;
const mat = new StandardMaterial(matName, this.scene);
mat.diffuseColor = Color3.FromHexString(color || '#888888');
// ambient = (1,1,1) — пассивный, реагирует на scene.ambientColor.
// Юзер крутит «Заливку теней» (sceneAmbient) → тени светлеют.
// На прямом свете diffuse доминирует — пересвета нет если
// sceneAmbient в разумных пределах (0..0.5).
mat.ambientColor = new Color3(1, 1, 1);
// Если задан textureUrl — подгружаем PNG как diffuseTexture. Это
// используется для GD-скинов куба (например /gd/skins/cube_smile.png).
@ -567,9 +572,18 @@ export class PrimitiveManager {
break;
}
case 'matte':
default:
mat.specularColor = new Color3(0, 0, 0);
break;
case 'glossy':
default: {
// Roblox Plastic — слабый specular, без emissive.
// diffuse=#cccccc должно выглядеть СЕРЫМ (как в Roblox).
// ambient (от scene 0.3 × mat.ambient 0.4) даёт цвет в тенях,
// но не убивает контраст.
mat.specularColor = new Color3(0.05, 0.05, 0.05);
mat.specularPower = 64;
break;
}
}
// Триггеры — всегда полупрозрачные жёлтые в редакторе
@ -689,7 +703,16 @@ export class PrimitiveManager {
const data = this.instances.get(id);
if (!data) return;
// Позиция
// Позиция / поворот / размер — нужно расфризить world matrix,
// иначе freezeStaticPrimitives() сделает mesh.position.set бессмысленным.
const positionChanged = patch.x !== undefined || patch.y !== undefined || patch.z !== undefined;
const transformChanged = positionChanged
|| patch.rotationX !== undefined || patch.rotationY !== undefined || patch.rotationZ !== undefined
|| patch.sx !== undefined || patch.sy !== undefined || patch.sz !== undefined;
if (transformChanged && data._worldMatrixFrozen) {
try { data.mesh.unfreezeWorldMatrix?.(); } catch (_) {}
data._worldMatrixFrozen = false;
}
if (patch.x !== undefined) data.x = patch.x;
if (patch.y !== undefined) data.y = patch.y;
if (patch.z !== undefined) data.z = patch.z;

View File

@ -0,0 +1,177 @@
/**
* RbxlHudOverlay DOM-оверлей с HUD-элементами для импортированных
* Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui.
*
* Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние
* блоки по типу. Стили inline, ничего не зависит от CSS приложения.
*
* API:
* const hud = new RbxlHudOverlay(canvasParent);
* hud.addKillFeed(killer, victim, weapon)
* hud.showMessage(text, opts)
* hud.hideMessage()
* hud.showWin(text)
* hud.dispose()
*/
export class RbxlHudOverlay {
constructor(parent) {
this._parent = parent || document.body;
this._root = null;
this._killFeed = null;
this._message = null;
this._winBox = null;
this._killEntries = []; // [{el, expireAt}]
this._mount();
}
_mount() {
if (this._root) return;
const root = document.createElement('div');
root.className = 'rbxl-hud-overlay';
Object.assign(root.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '999',
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
});
this._parent.appendChild(root);
this._root = root;
// KillFeed — правый верхний угол
const kf = document.createElement('div');
Object.assign(kf.style, {
position: 'absolute',
top: '60px',
right: '12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
maxWidth: '320px',
pointerEvents: 'none',
});
root.appendChild(kf);
this._killFeed = kf;
// Message — центр сверху (Roblox Message по центру экрана,
// но в верхней трети чтобы не мешать игре)
const msg = document.createElement('div');
Object.assign(msg.style, {
position: 'absolute',
top: '15%',
left: '50%',
transform: 'translateX(-50%)',
padding: '10px 24px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
fontSize: '22px',
fontWeight: '600',
borderRadius: '6px',
textShadow: '0 2px 4px rgba(0,0,0,0.8)',
display: 'none',
pointerEvents: 'none',
});
root.appendChild(msg);
this._message = msg;
// WinGui — большая надпись по центру
const win = document.createElement('div');
Object.assign(win.style, {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '24px 48px',
background: 'rgba(0,0,0,0.75)',
color: '#ffd86b',
fontSize: '48px',
fontWeight: '800',
borderRadius: '12px',
textShadow: '0 4px 8px rgba(0,0,0,0.8)',
display: 'none',
pointerEvents: 'none',
});
root.appendChild(win);
this._winBox = win;
// Тик для авто-исчезновения KillFeed entries (через 5с)
this._tickInterval = setInterval(() => this._cleanupKills(), 500);
}
addKillFeed(killer, victim, weapon) {
if (!this._killFeed) return;
const entry = document.createElement('div');
Object.assign(entry.style, {
background: 'rgba(0,0,0,0.55)',
color: '#fff',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '13px',
display: 'flex',
gap: '6px',
alignItems: 'center',
animation: 'rbxlHudFadeIn 0.3s',
});
const killerEl = document.createElement('span');
killerEl.textContent = String(killer || '?');
killerEl.style.color = '#5bd1e8';
const arrow = document.createElement('span');
arrow.textContent = weapon ? `→ [${weapon}] →` : '→';
arrow.style.color = '#ff9a52';
const victimEl = document.createElement('span');
victimEl.textContent = String(victim || '?');
victimEl.style.color = '#f87a7a';
entry.appendChild(killerEl);
entry.appendChild(arrow);
entry.appendChild(victimEl);
this._killFeed.appendChild(entry);
this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 });
// Keep only last 8
while (this._killEntries.length > 8) {
const old = this._killEntries.shift();
try { old.el.remove(); } catch (_) {}
}
}
_cleanupKills() {
const now = performance.now();
const keep = [];
for (const e of this._killEntries) {
if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} }
else keep.push(e);
}
this._killEntries = keep;
}
showMessage(text, opts = {}) {
if (!this._message) return;
this._message.textContent = String(text || '');
this._message.style.display = text ? 'block' : 'none';
if (opts.duration) {
clearTimeout(this._msgTimer);
this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration);
}
}
hideMessage() {
if (this._message) this._message.style.display = 'none';
}
showWin(text) {
if (!this._winBox) return;
this._winBox.textContent = String(text || '');
this._winBox.style.display = 'block';
// Auto-hide через 6с
clearTimeout(this._winTimer);
this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000);
}
dispose() {
try { this._root?.remove(); } catch (_) {}
clearInterval(this._tickInterval);
clearTimeout(this._msgTimer);
clearTimeout(this._winTimer);
this._root = null;
}
}

View File

@ -1,164 +0,0 @@
/**
* RobloxLuaSandbox main-side обёртка над одним RobloxLuaWorker.
*
* Использование (по аналогии с ScriptSandbox):
* const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId);
* sb.setOnCommand((cmd, payload) => ...);
* sb.setInitialScene({primitives: {...}});
* sb.start();
* sb.tick(dt, sceneSnap);
* sb.fireEvent('touched', {primId, otherPrimId});
* sb.stop();
*
* Команды от Worker:
* { cmd: 'boot' } Lua-VM запущена
* { cmd: 'ready' } top-level код выполнен
* { cmd: 'log', payload: { level, text } }
* { cmd: 'partSet', payload: { primId, prop, value } }
* { cmd: 'partVel', payload: { primId, vx, vy, vz } }
* { cmd: 'playerCmd', payload: { method, args } }
* { cmd: 'tweenStart', payload: { ... } }
* { cmd: 'broadcast', payload: { msg, data } }
* { cmd: 'spawn', payload: { template, props, parentId } }
*/
let _workerUrl = null;
function getWorkerUrl() {
if (_workerUrl) return _workerUrl;
// Vite worker syntax — лучше через ?worker импорт; но мы можем
// динамически генерировать URL для ScriptSandboxWorker-style.
// Здесь упрощённо: загружаем worker как module через Vite ?worker&inline.
// Это будет настроено при интеграции в GameRuntime.
return null;
}
export class RobloxLuaSandbox {
constructor(luaSource, targetPrimitiveId = null) {
this.luaSource = luaSource || '';
this.targetPrimitiveId = targetPrimitiveId;
this.worker = null;
this._onCommand = null;
this._booted = false;
this._ready = false;
this._stopped = false;
this._pendingTicks = [];
this._pendingEvents = [];
this._initialScene = null;
}
setOnCommand(cb) { this._onCommand = cb; }
setInitialScene(snap) { this._initialScene = snap; }
/**
* @param {Worker} worker экземпляр Worker'а (предоставляется снаружи,
* так как Vite требует new Worker(new URL(...)) syntax который надо
* прописать в месте импорта)
*/
start(worker) {
if (this.worker) return;
if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required');
this.worker = worker;
this.worker.onmessage = (e) => this._handle(e);
this.worker.onerror = (err) => {
this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` });
};
this.worker.postMessage({
cmd: 'init',
payload: {
code: this.luaSource,
target: this.targetPrimitiveId,
sceneSnap: this._initialScene || { primitives: {} },
},
});
}
/** Передать кадр (snap сцены + dt). */
tick(dt, sceneSnap) {
if (!this.worker) return;
if (!this._ready) {
this._pendingTicks.push({ dt, sceneSnap });
return;
}
try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {}
}
/** Передать событие. */
fireEvent(kind, args, signalId) {
if (!this.worker) return;
if (!this._ready) {
this._pendingEvents.push({ kind, args, signalId });
return;
}
try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {}
}
stop() {
this._stopped = true;
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
try { this.worker?.terminate(); } catch (e) {}
this.worker = null;
}
// ── Совместимость с интерфейсом ScriptSandbox (GameRuntime ожидает эти методы) ──
// Все no-op либо мапятся на fireEvent — наш Worker сам держит state сцены.
sendSceneSnapshot(_snap) { /* no-op: ничего не делает, наш Worker не использует snapshot напрямую */ }
sendGuiSnapshot(_snap) { /* no-op */ }
sendSkinsSnapshot(_snap) { /* no-op */ }
sendInventorySnapshot(_snap) { /* no-op */ }
sendTerrainHeightmap(_payload) { /* no-op */ }
sendGlobalEvent(kind, payload) {
// Глобальные события (input, hpChange, broadcast) маршрутизируем в наш fireEvent.
try { this.fireEvent(kind, [payload]); } catch (e) {}
}
sendBroadcast(msg, data) {
try { this.fireEvent('broadcast', [msg, data]); } catch (e) {}
}
sendOnTouchEvent(payload) {
try { this.fireEvent('touched', [payload]); } catch (e) {}
}
sendOnTickEvent(dt) {
try { this.tick(dt, null); } catch (e) {}
}
sendTweenDone(payload) {
try { this.fireEvent('tweenDone', [payload]); } catch (e) {}
}
sendSpawnResolved(payload) {
try { this.fireEvent('spawnResolved', [payload]); } catch (e) {}
}
setInitialSelfPosition(_p) { /* no-op */ }
setModules(_modules) { /* no-op: rbxl Lua не использует наш game.require */ }
get scriptId() { return this._scriptId; }
set scriptId(v) { this._scriptId = v; }
_handle(ev) {
if (this._stopped) return;
const { cmd, payload } = ev.data || {};
if (cmd === 'boot') {
this._booted = true;
return;
}
if (cmd === 'ready') {
this._ready = true;
// флушим накопленное
for (const t of this._pendingTicks) {
try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {}
}
this._pendingTicks = [];
for (const e of this._pendingEvents) {
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {}
}
this._pendingEvents = [];
this._emit('ready', null);
return;
}
this._emit(cmd, payload);
}
_emit(cmd, payload) {
if (this._onCommand) {
try { this._onCommand(cmd, payload); } catch (e) {}
}
}
}

View File

@ -1,150 +0,0 @@
/**
* RobloxLuaSharedSandbox main-side обёртка над одним shared Lua-worker'ом.
*
* v2 (после rewrite):
* - start(sceneSnap, guiTree, worker) init с GUI-деревом
* - addScriptsBatch(scripts) одной пачкой все скрипты регистрируются в VM
* - kickoff() запускает event loop, fire'ит PlayerAdded
* - tick(dt) каждый кадр
* - fireEvent(kind, payload) маршрутизирует в Worker.handleEvent
*
* GameRuntime пушит ОДИН экземпляр в this.sandboxes.
*/
export class RobloxLuaSharedSandbox {
constructor() {
this.worker = null;
this._onCommand = null;
this._booted = false;
this._scriptsLoaded = false;
this._stopped = false;
this._pendingTicks = [];
this._pendingEvents = [];
this._pendingScripts = null;
this._pendingKickoff = false;
this.scriptId = 'rbxl-shared';
}
setOnCommand(cb) { this._onCommand = cb; }
start(sceneSnap, guiTree, worker) {
if (this.worker) return;
this.worker = worker;
this.worker.onmessage = (e) => this._handle(e);
this.worker.onerror = (err) => {
this._emit('log', { level: 'error', text: `SharedWorker error: ${err.message || err}` });
};
this.worker.postMessage({ cmd: 'init', payload: { sceneSnap, guiTree } });
}
addScriptsBatch(scripts) {
if (!this._booted) { this._pendingScripts = scripts; return; }
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts } }); } catch (e) {}
}
kickoff() {
if (!this._scriptsLoaded) { this._pendingKickoff = true; return; }
try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
}
tick(dt) {
if (!this.worker) return;
if (!this._booted) { this._pendingTicks.push(dt); return; }
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
}
fireEvent(kind, payload) {
if (!this.worker) return;
const ev = { kind, ...(payload || {}) };
if (!this._booted) { this._pendingEvents.push(ev); return; }
try { this.worker.postMessage({ cmd: 'event', payload: ev }); } catch (e) {}
}
stop() {
this._stopped = true;
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
try { this.worker?.terminate(); } catch (e) {}
this.worker = null;
}
_handle(ev) {
if (this._stopped) return;
const { cmd, payload } = ev.data || {};
if (cmd === 'boot') {
this._booted = true;
// флушим pending scripts
if (this._pendingScripts) {
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts: this._pendingScripts } }); } catch (e) {}
this._pendingScripts = null;
}
// ticks накопленные до boot
for (const dt of this._pendingTicks) {
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
}
this._pendingTicks = [];
return;
}
if (cmd === 'ready') {
this._scriptsLoaded = true;
this._emit('ready', payload);
if (this._pendingKickoff) {
try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
this._pendingKickoff = false;
}
// флушим pending events
for (const e of this._pendingEvents) {
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (er) {}
}
this._pendingEvents = [];
return;
}
this._emit(cmd, payload);
}
_emit(cmd, payload) {
if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} }
}
// ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ──
sendSceneSnapshot(_snap) {}
sendGuiSnapshot(_snap) {}
sendSkinsSnapshot(_snap) {}
sendInventorySnapshot(_snap) {}
sendTerrainHeightmap(_payload) {}
sendGlobalEvent(payload) {
if (!payload || typeof payload !== 'object') return;
const type = payload.type;
// playerTouch: BabylonScene уже детектит касания → Touched на Part
if (type === 'playerTouch' && payload.target) {
const m = /^primitive:(\d+)$/.exec(String(payload.target));
if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; }
}
// GUI click — Rublox GuiOverlay шлёт guiClick с id
if (type === 'guiClick' && (payload.id || payload.localId)) {
this.fireEvent('guiClick', { guiId: payload.id || payload.localId });
return;
}
// keyboard
if (type === 'keydown' || type === 'keyup') {
this.fireEvent(type, { key: payload.key });
return;
}
// hp/death
if (type === 'hpChange' || type === 'humanoidHealth') {
this.fireEvent('humanoidHealth', { health: payload.hp || payload.health || 0 });
return;
}
if (type === 'died' || type === 'humanoidDied') {
this.fireEvent('humanoidDied', {});
return;
}
// default: пробрасываем как kind=type
this.fireEvent(type || 'unknown', payload);
}
sendBroadcast(msg, data) { this.fireEvent('broadcast', { msg, data }); }
sendOnTouchEvent(payload) { this.fireEvent('touched', { primId: payload?.primId, isPlayer: true }); }
sendOnTickEvent(dt) { this.tick(dt); }
sendTweenDone(payload) { this.fireEvent('tweenDone', payload); }
sendSpawnResolved(payload) { this.fireEvent('spawnResolved', payload); }
setInitialSelfPosition(_p) {}
setModules(_modules) {}
}

View File

@ -1,380 +0,0 @@
/* eslint-disable no-restricted-globals */
/**
* RobloxLuaSharedWorker.js single-VM Lua-runtime для импортированных Roblox-скриптов.
*
* Архитектура v2 (после ITERATION 5-step rewrite):
*
* ФАЗА 1 (boot): создаём wasmoon-VM, регистрируем Roblox API без скриптов.
*
* ФАЗА 2 (populate): main шлёт snapshot сцены (primitives + GUI tree).
* Создаём workspace со всеми Part'ами и PlayerGui со всем GUI-деревом.
* На каждом TextButton MouseButton1Click сигнал, на каждом Part Touched.
*
* ФАЗА 3 (addScripts): main шлёт ВСЕ скрипты ОДНИМ батчем. Worker загружает
* их в Lua-VM как отдельные функции в pcall. Скрипты регистрируют свои
* Connect'ы (Touched, MouseButton1Click, Heartbeat, ...). top-level wait()
* yield'ится через coroutine управление возвращается в worker.
*
* ФАЗА 4 (run): main шлёт 'startEvents'. Worker запускает scheduler-tick
* и начинает обрабатывать события (touched/guiClick/heartbeat).
*
* IPC:
* <- init { sceneSnap, guiTree }
* <- addScripts { scripts: [{id, target, luaSource}] }
* <- start
* <- tick { dt }
* <- event { kind, payload }
* <- stop
* -> boot
* -> ready
* -> log/partSet/partVel/playerCmd/broadcast/guiUpdate
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi, RbxSignal } from './roblox-shim.js';
const state = {
factory: null,
lua: null,
sceneSnap: { primitives: {} },
guiTree: [],
isStopped: false,
initPromise: null,
eventsStarted: false,
pendingEvents: [],
scriptCount: 0,
coroutines: [], // активные ждущие корутины: { co, resumeAt }
nowSec: 0,
api: null, // результат registerRobloxApi: { workspace, game, part_by_id, gui_by_id, localPlayer, humanoid }
};
function send(cmd, payload) {
self.postMessage({ cmd, payload });
}
function log(level, text) {
send('log', { level, text });
}
const scheduler = {
now: () => state.nowSec,
schedule: (sec, fn) => {
state.coroutines.push({ resumeAt: state.nowSec + (sec || 0), fn });
},
spawn: (fn) => {
// spawn — fn запускается асинхронно (на следующем tick'е)
state.coroutines.push({ resumeAt: state.nowSec, fn });
},
};
self.addEventListener('message', async (ev) => {
const { cmd, payload } = ev.data || {};
try {
if (cmd === 'init') await handleInit(payload);
else if (cmd === 'addScripts') await handleAddScripts(payload);
else if (cmd === 'start') handleStart();
else if (cmd === 'tick') handleTick(payload);
else if (cmd === 'event') {
if (!state.eventsStarted) state.pendingEvents.push(payload);
else handleEvent(payload);
}
else if (cmd === 'stop') {
state.isStopped = true;
try { state.lua?.global?.close?.(); } catch (e) {}
}
} catch (err) {
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
}
});
async function handleInit({ sceneSnap, guiTree }) {
if (state.initPromise) { await state.initPromise; return; }
state.initPromise = (async () => {
state.sceneSnap = sceneSnap || { primitives: {} };
state.guiTree = guiTree || [];
state.factory = new LuaFactory();
state.lua = await state.factory.createEngine({
injectObjects: true,
enableProxy: true,
traceAllocations: false,
});
state.api = registerRobloxApi(state.lua, {
getSceneSnap: () => state.sceneSnap,
getGuiTree: () => state.guiTree,
targetPrimitiveId: null,
send,
scheduler,
});
// Передаём part_by_id в Lua как table {id → Instance}
// ВНИМАНИЕ: lua_table принимает string keys, ключи кладём как строки.
try {
const m = state.api?.part_by_id;
if (m) {
const obj = {};
for (const [id, part] of m) obj[String(id)] = part;
state.lua.global.set('__rbxl_parts_by_id', obj);
}
} catch (e) {}
// null-stub builder: возвращает Instance-like объект который безопасно
// отвечает на .Parent, .Name, .WaitForChild и т.п. чтобы цепочки
// script.Parent.Parent.X не валили.
const makeNullStub = () => {
const stub = {
Name: 'NullStub',
ClassName: 'Nil',
Children: [],
__isNullStub: true,
};
// Parent — самоссылающийся nullStub
stub.Parent = stub;
stub.FindFirstChild = () => stub;
stub.FindFirstChildOfClass = () => stub;
stub.FindFirstAncestor = () => stub;
stub.FindFirstAncestorOfClass = () => stub;
stub.WaitForChild = () => stub;
stub.GetChildren = () => [];
stub.GetDescendants = () => [];
stub.IsA = () => false;
stub.Clone = () => makeNullStub();
stub.Destroy = () => {};
stub.GetService = () => stub;
// Сигналы — пустой connector
const nullSig = {
Connect: () => ({ Disconnect: () => {}, Connected: false }),
Wait: () => null,
Fire: () => {},
};
// Любой каpitalized property — сигнал-stub
return new Proxy(stub, {
get(t, k) {
if (k in t) return t[k];
if (typeof k === 'string' && /^[A-Z]/.test(k)) return nullSig;
return undefined;
},
set(t, k, v) { t[k] = v; return true; },
});
};
state.lua.global.set('__rbxl_make_null_stub', makeNullStub);
// ВАЖНО: создаём nullStub НА СТОРОНЕ LUA как настоящую table с
// metatable __index возвращающей сам stub. Это позволит цепочкам
// .Parent.X.Y:WaitForChild():Connect() корректно работать и обе
// нотации (. и :) сработают.
await state.lua.doString(`
__null_stub_mt = {}
function __make_null_stub()
local t = setmetatable({
Name = "Nil",
ClassName = "Nil",
__isNullStub = true,
Visible = false,
Enabled = false,
Value = 0,
Text = "",
}, __null_stub_mt)
return t
end
__null_stub_singleton = __make_null_stub()
-- nullSignal с обоими Connect/connect:
local function null_sig_method() return { Disconnect = function() end, disconnect = function() end, Connected = false } end
__null_signal = setmetatable({
Connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
Wait = function() return nil end,
wait = function() return nil end,
Fire = function() end,
fire = function() end,
}, { __index = function() return function() return __null_stub_singleton end end })
-- Любой index nullStub'а возвращает либо null_signal (для уже известных
-- сигнальных имён) либо noop-функцию которая возвращает сам stub.
__null_stub_mt.__index = function(t, k)
-- известные сигнал-имена
local sig_names = {Touched=true,TouchEnded=true,Changed=true,Activated=true,
MouseButton1Click=true,MouseButton1Down=true,MouseButton1Up=true,
MouseEnter=true,MouseLeave=true,InputBegan=true,InputEnded=true,
PlayerAdded=true,PlayerRemoving=true,CharacterAdded=true,CharacterRemoving=true,
Heartbeat=true,Stepped=true,RenderStepped=true,Died=true,HealthChanged=true,
FocusLost=true,Focused=true,ChildAdded=true,ChildRemoved=true,
AncestryChanged=true,DescendantAdded=true,DescendantRemoving=true}
if sig_names[k] then return __null_signal end
-- любой метод функция которая возвращает stub (поддерживает оба синтаксиса)
return function(...) return __null_stub_singleton end
end
__null_stub_mt.__newindex = function(t, k, v) rawset(t, k, v) end
__null_stub_mt.__call = function(t, ...) return __null_stub_singleton end
-- Сделаем __null_stub_singleton.Parent = сам себя (lazy)
rawset(__null_stub_singleton, "Parent", __null_stub_singleton)
`);
// Заменяем __rbxl_make_null_stub на Lua-side функцию
await state.lua.doString(`
function __rbxl_make_null_stub() return __null_stub_singleton end
`);
// КРИТИЧНО: расширенные metatable для nil + function + number чтобы
// любые цепочки nil.x.y:method() и func.x не валили скрипты.
await state.lua.doString(`
if debug and debug.setmetatable then
local _stub_mt = {
__index = function(t, k) return __null_stub_singleton end,
__newindex = function(t, k, v) end,
__call = function(t, ...) return __null_stub_singleton end,
__add = function(a, b) return 0 end,
__sub = function(a, b) return 0 end,
__mul = function(a, b) return 0 end,
__div = function(a, b) return 0 end,
__mod = function(a, b) return 0 end,
__pow = function(a, b) return 0 end,
__unm = function() return 0 end,
__concat = function(a, b) return "" end,
__len = function() return 0 end,
__eq = function(a, b) return false end,
__lt = function(a, b) return false end,
__le = function(a, b) return false end,
__tostring = function() return "nil" end,
}
debug.setmetatable(nil, _stub_mt)
debug.setmetatable(function() end, _stub_mt)
-- НЕ ставим на number/string/boolean они должны работать нормально
end
`);
// helper: безопасный pcall с warn'ом при ошибке
await state.lua.doString(`
__rbxl_scripts = {}
function __rbxl_safe_run(id, fn)
local ok, err = pcall(fn)
if not ok then warn("[rbxl-lua " .. tostring(id) .. " err] " .. tostring(err)) end
end
-- Lookup Part по primitiveId. Используем __rbxl_parts_by_id из JS,
-- т.к. ipairs() на JS array не работает (0-indexed vs Lua 1-indexed).
function __rbxl_lookup_part(id)
if __rbxl_parts_by_id then
return __rbxl_parts_by_id[tostring(id)] or __rbxl_parts_by_id[id]
end
return nil
end
`);
send('boot', null);
})();
await state.initPromise;
}
async function handleAddScripts({ scripts }) {
if (!state.lua) { log('error', 'addScripts before init'); return; }
let ok = 0, fail = 0;
for (const s of scripts) {
const safeId = String(s.id).replace(/[^a-zA-Z0-9_]/g, '_');
const targetExpr = s.target != null
? `__rbxl_lookup_part(${JSON.stringify(s.target)}) or __rbxl_make_null_stub()`
: '__rbxl_make_null_stub()';
// Оборачиваем в pcall. script — локальный, не конфликтует между скриптами.
// script.Parent НИКОГДА не nil — даём nullStub чтобы цепочки
// script.Parent.Parent.X не валили.
const wrapped = `
do
local script = setmetatable({
Name = "Script_${safeId}",
Parent = ${targetExpr},
ClassName = "LocalScript",
}, { __index = function(t, k) return rawget(t, k) end })
__rbxl_safe_run("${safeId}", function()
${s.luaSource}
end)
end
`;
try {
await state.lua.doString(wrapped);
ok++;
} catch (e) {
fail++;
// ошибки парсинга/runtime, считаем но не валим всё
}
}
state.scriptCount = ok;
send('ready', { ok, fail });
}
function handleStart() {
state.eventsStarted = true;
// Эмитим Players.PlayerAdded + CharacterAdded чтобы скрипты которые
// делают game.Players.PlayerAdded:Connect(...) получили событие.
try {
const lp = state.api?.localPlayer;
const players = state.api?.services?.get('Players');
if (lp && players?.PlayerAdded?.Fire) players.PlayerAdded.Fire(lp);
if (lp?.CharacterAdded?.Fire && lp.Character) lp.CharacterAdded.Fire(lp.Character);
} catch (e) {}
// Флушим накопленные события
for (const e of state.pendingEvents) handleEvent(e);
state.pendingEvents = [];
}
function handleTick({ dt }) {
if (state.isStopped || !state.lua) return;
state.nowSec += dt || 0;
// Резолвим планированные корутины
if (state.coroutines.length > 0) {
const due = [];
const left = [];
for (const c of state.coroutines) {
if (c.resumeAt <= state.nowSec) due.push(c); else left.push(c);
}
state.coroutines = left;
for (const c of due) {
try { c.fn(); } catch (e) { log('warn', `coroutine err: ${e?.message || e}`); }
}
}
// RunService сигналы
try {
const rs = state.api?.services?.get('RunService');
if (rs?.Heartbeat?.Fire) rs.Heartbeat.Fire(dt);
if (rs?.Stepped?.Fire) rs.Stepped.Fire(state.nowSec, dt);
if (rs?.RenderStepped?.Fire) rs.RenderStepped.Fire(dt);
} catch (e) {}
}
function handleEvent(payload) {
if (state.isStopped || !state.lua || !state.api) return;
const { kind } = payload || {};
try {
if (kind === 'guiClick' || kind === 'guiActivated') {
const guiId = payload.guiId;
const inst = state.api.gui_by_id?.get(guiId);
if (inst) {
if (kind === 'guiActivated') inst.Activated?.Fire?.(1);
else inst.MouseButton1Click?.Fire?.();
}
} else if (kind === 'touched') {
const primId = payload.primId;
const part = state.api.part_by_id?.get(primId);
if (part?.Touched?.Fire) {
// hit = HumanoidRootPart
part.Touched.Fire(state.api.character?.HumanoidRootPart || part);
}
// также Humanoid.Touched на самом игроке
if (payload.isPlayer) {
state.api.humanoid?.Touched?.Fire?.(part);
}
} else if (kind === 'keydown' || kind === 'keyup') {
// UserInputService.InputBegan/Ended
const uis = state.api.services?.get('UserInputService') ||
(() => {
const s = new (state.lua.global.get('Instance')?.new ? Object : Object)();
return null;
})();
if (uis) {
if (kind === 'keydown') uis.InputBegan?.Fire?.({ KeyCode: { Name: payload.key } });
else uis.InputEnded?.Fire?.({ KeyCode: { Name: payload.key } });
}
} else if (kind === 'humanoidDied') {
state.api.humanoid?.Died?.Fire?.();
} else if (kind === 'humanoidHealth') {
const h = state.api.humanoid;
if (h) {
h.Health = payload.health;
h.HealthChanged?.Fire?.(payload.health);
}
}
} catch (e) {
log('warn', `event ${kind} err: ${e?.message || e}`);
}
}
self.__rbxlSharedState = state;

View File

@ -1,180 +0,0 @@
/* eslint-disable no-restricted-globals */
/**
* RobloxLuaWorker.js Web Worker, хостящий Lua 5.4 VM (wasmoon) для исполнения
* Roblox-Lua скриптов импортированных через rbxl-importer.
*
* Запускается из RobloxLuaSandbox.js (main thread).
*
* IPC (с main):
* <- init { code: string, target?: id, shim: 'full'|'mini', sceneSnap: object }
* <- tick { dt, sceneSnap } каждый кадр
* <- event { kind: 'touched'|'changed'|..., args } события сцены
* -> boot нет payload Worker запустился, Lua-VM ready
* -> ready нет payload top-level lua код исполнен
* -> log { level, text }
* -> partSet { primId, prop, value } изменение свойства Part'а
* -> partVel { primId, vx, vy, vz }
* -> playerCmd { method, args } методы game.player (teleport, damage, walkSpeed)
* -> tweenStart{ targetId, prop, from, to, durationSec, easing }
* -> broadcast { msg, data } RemoteEvent аналог
* -> spawn { template, props, parentId } Instance.new()
*
* Lua-runtime архитектура:
* - wasmoon = Lua 5.4 в WebAssembly, ~500KB, ~5x быстрее fengari.
* - Lua-VM глобалы: game, workspace, script, task, wait, print, warn, error.
* - Все Roblox-классы JS-объекты-прокси (см. roblox-shim.js, регистрируемые
* через factory.setProxy).
*
* Безопасность:
* - Worker изолирован от DOM.
* - Memory limit ~50 MB на VM (через wasmoon options).
* - На каждые N=10000 инструкций Lua hook возможность отменить (TODO).
*
* Воркер ДЕРЖИТ И ОБНОВЛЯЕТ snapshot сцены (зеркало того что в Babylon-сцене),
* чтобы Lua-код мог читать Position/Color без round-trip к main thread.
* Обновление от main: cmd='tick' с дельтой сцены.
*
* Это первый MVP-вариант. Полный shim API регистрируется в фазе 4.3-4.13.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from './roblox-shim.js';
/**
* Worker-side state. Один Worker = один скрипт.
*/
const state = {
factory: null,
lua: null,
target: null, // id примитива к которому привязан script.Parent
sceneSnap: { primitives: {} },// зеркало
isStopped: false,
pendingEvents: [], // события до init
signals: new Map(), // signalId → [callbacks]
nextSignalId: 1,
};
/* ──────── IPC helpers ──────── */
function send(cmd, payload) {
self.postMessage({ cmd, payload });
}
function log(level, text) {
send('log', { level, text });
}
/* ──────── Worker entrypoint ──────── */
self.addEventListener('message', async (ev) => {
const { cmd, payload } = ev.data || {};
try {
if (cmd === 'init') {
await handleInit(payload);
} else if (cmd === 'tick') {
handleTick(payload);
} else if (cmd === 'event') {
handleEvent(payload);
} else if (cmd === 'stop') {
state.isStopped = true;
try { state.lua?.global?.close?.(); } catch (e) {}
}
} catch (err) {
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
}
});
async function handleInit({ code, target, sceneSnap }) {
state.target = target;
state.sceneSnap = sceneSnap || { primitives: {} };
state.factory = new LuaFactory();
state.lua = await state.factory.createEngine({
injectObjects: true,
enableProxy: true,
traceAllocations: false,
});
// Регистрируем Roblox API.
registerRobloxApi(state.lua, {
getSceneSnap: () => state.sceneSnap,
targetPrimitiveId: state.target,
send,
registerSignal: (callback) => {
const id = state.nextSignalId++;
const list = state.signals.get(id) || [];
list.push(callback);
state.signals.set(id, list);
return id;
},
});
send('boot', null);
try {
// Оборачиваем в pcall + ловим errors. Roblox-карты часто делают
// game.Players.LocalPlayer:WaitForChild("PlayerGui") который у нас
// даёт null — top-level код падает на первой такой строке.
// pcall ловит и даёт сигналам/Touched зарегистрироваться там где смогли.
const wrapped = `
local _ok, _err = pcall(function()
${code}
end)
if not _ok then
warn("[rbxl-lua partial fail] " .. tostring(_err))
end
`;
await state.lua.doString(wrapped);
send('ready', null);
} catch (e) {
log('error', `Lua error: ${e && e.message ? e.message : e}`);
send('ready', null);
}
// После ready доставляем events которые накопились
for (const ev of state.pendingEvents) handleEvent(ev);
state.pendingEvents = [];
}
function handleTick({ dt, sceneSnap }) {
if (state.isStopped || !state.lua) return;
if (sceneSnap) state.sceneSnap = sceneSnap;
// Heartbeat — для всех подписанных
fireSignalByName('Heartbeat', [dt]);
// Stepped (старая API) — тоже даём
fireSignalByName('Stepped', [dt]);
// RenderStepped — отдельно (на клиенте между physics и render)
fireSignalByName('RenderStepped', [dt]);
}
function handleEvent({ kind, args, signalId }) {
if (!state.lua) {
state.pendingEvents.push({ kind, args, signalId });
return;
}
if (signalId != null) {
const list = state.signals.get(signalId) || [];
for (const cb of list) {
try { cb(...(args || [])); } catch (e) { log('error', `signal callback error: ${e}`); }
}
} else {
fireSignalByName(kind, args || []);
}
}
function fireSignalByName(name, args) {
// namedSignals регистрируются в roblox-shim как сильные строки
// (например 'Heartbeat'). Все callback'и под этим именем в signals.
// Без отдельной мапы — ищем линейно.
for (const [id, list] of state.signals.entries()) {
if (list.__name === name) {
for (const cb of list) {
try { cb(...args); } catch (e) { log('error', `${name} cb error: ${e}`); }
}
}
}
}
/* ──────── Helper export для тестов ──────── */
self.__rbxlState = state;

View File

@ -282,6 +282,11 @@ export class SelectionManager {
fogColor: env ? `#${[env.fogColor?.[0] ?? 0.7, env.fogColor?.[1] ?? 0.8, env.fogColor?.[2] ?? 0.9].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, '0')).join('')}` : '#b0c8e6',
shadowQuality: this._scene3d.getShadowQuality?.() || 'soft',
ssaoEnabled: this._scene3d.getSsaoEnabled?.() || false,
// Новые: глобальный ambient + image processing
sceneAmbient: this._scene3d._sceneAmbient ?? 0.3,
exposure: this._scene3d._exposure ?? 1.0,
contrast: this._scene3d._contrast ?? 1.0,
saturation: this._scene3d._saturation ?? 1.0,
};
this._notifyChange();
}

View File

@ -170,8 +170,8 @@ export class StudioCollab {
sc.__collabScriptsPatched = true;
if (typeof sc.upsertScript === 'function') {
const origUpsert = sc.upsertScript.bind(sc);
sc.upsertScript = function (id, code, target, name) {
const r = origUpsert(id, code, target, name);
sc.upsertScript = function (id, code, target, name, language) {
const r = origUpsert(id, code, target, name, language);
if (!self._applyingRemote) {
// id может быть сгенерён внутри upsertScript, если был null —
// достаём фактический из _scripts (последний с этим code).
@ -188,6 +188,7 @@ export class StudioCollab {
code: rec.code,
target: rec.target ?? null,
name: rec.name ?? null,
language: rec.language ?? 'js',
});
}
}
@ -523,7 +524,7 @@ export function applyRemoteOp(scene, op) {
// Создание/редактирование скрипта у соавтора. _applyingRemote уже
// выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт
// эхо обратно. _onSceneChange внутри обновит React-панели.
scene.upsertScript?.(op.id, op.code, op.target ?? null, op.name ?? null);
scene.upsertScript?.(op.id, op.code, op.target ?? null, op.name ?? null, op.language ?? undefined);
scene._onCollabScriptsChange?.();
return;
case 'scriptRemove':

View File

@ -0,0 +1,337 @@
/**
* LuaSharedSandbox (v3, main-thread) wasmoon-VM работает в MAIN потоке,
* без Web Worker. Это позволяет:
* - Видеть точные Lua-ошибки в DevTools (через console.error)
* - Использовать debugger / breakpoints прямо в RobloxShim.js
* - Не возиться с молчаливыми Worker-падениями
*
* Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style
* скриптов это нестрашно они быстрые.
*
* API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent /
* sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot /
* sendTerrainHeightmap / stop / tick / target.
*
* Что добавлено сверх ScriptSandbox:
* - addScript(id, code, target) добавить скрипт в общий VM. Можно
* до или после start().
* - start() асинхронен (createEngine), но возвращает сразу. После init
* стартует main loop (Heartbeat + scheduler).
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxShim } from './RobloxShim.js';
export class LuaSharedSandbox {
constructor() {
this.vm = null;
this.api = null;
this._onCommand = null;
this._isReady = false;
this._isStopped = false;
this._isKickedOff = false;
this._pendingScripts = []; // [{id, code, target, name}]
this._scriptsById = new Map();
this._scenes = null;
this._guiTree = null;
this._loopHandle = null;
this._lastTickAt = 0;
// Маркер для GameRuntime.routeEvent — этот sandbox принимает все
// события и сам маршрутизирует через shim.fireTargetEvent.
this._luaShared = true;
}
setOnCommand(cb) { this._onCommand = cb; }
get target() { return null; }
tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
addScript(id, code, target, name, extra) {
const entry = {
id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
code: String(code || ''),
target: target == null ? null : target,
name: name || null,
toolName: extra?.toolName || null,
};
this._scriptsById.set(entry.id, entry);
if (!this._isKickedOff) {
this._pendingScripts.push(entry);
} else {
this._startSingleScript(entry);
}
}
removeScript(id) {
this._scriptsById.delete(String(id));
}
/** Стартует VM, регистрирует shim, запускает main-loop. */
start() {
if (this.vm || this._isStopped) return;
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...');
this._initAsync().catch((err) => {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox] FATAL init error:', err);
this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` });
});
}
async _initAsync() {
const factory = new LuaFactory();
this.vm = await factory.createEngine({ openStandardLibs: true });
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...');
// Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait.
const send = (cmd, payload) => this._emit(cmd, payload);
this.api = registerRobloxShim(this.vm, {
send,
getSceneSnapshot: () => this._scenes,
getGuiTree: () => this._guiTree,
scheduleWait: () => null,
});
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {}));
// Применим snapshot если он есть
if (this._scenes && this.api?.onSceneSnapshot) {
try { this.api.onSceneSnapshot(this._scenes); } catch (e) {
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
}
}
this._isReady = true;
this._kickoff();
}
_kickoff() {
if (this._isKickedOff || this._isStopped) return;
this._isKickedOff = true;
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`);
const pending = this._pendingScripts;
this._pendingScripts = [];
// Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines.
this._lastTickAt = performance.now();
this._startMainLoop();
// Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался.
const BATCH_SIZE = 5;
let idx = 0;
const initBatch = () => {
if (this._isStopped) return;
const end = Math.min(idx + BATCH_SIZE, pending.length);
for (let i = idx; i < end; i++) {
try { this._startSingleScript(pending[i]); }
catch (e) {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox] init batch err:', e);
}
}
idx = end;
if (idx < pending.length) {
setTimeout(initBatch, 20);
} else {
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`);
// После того как все скрипты подключили хендлеры — фейрим
// events для уже существующих сущностей. Roblox-конвенция:
// если игрок уже на сервере когда скрипт подключается,
// Players.PlayerAdded не сработает повторно. Юзеру нужно
// делать ручной обход GetPlayers() — но это редко кто помнит.
// Мы дублируем событие через короткую задержку.
setTimeout(() => {
try {
if (this.api?.fireExistingPlayers) {
this.api.fireExistingPlayers();
}
} catch (e) {
console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e);
}
}, 100);
}
};
setTimeout(initBatch, 0);
}
_startSingleScript(entry) {
if (!this.vm || !entry || typeof entry.code !== 'string') return;
let primId = null;
if (typeof entry.target === 'number') primId = entry.target;
else if (entry.target && typeof entry.target === 'object') {
if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref;
}
const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
const scriptName = entry.name || `Script_${safeId}`;
// Скрипт оборачиваем в coroutine — это позволяет task.wait через yield.
// Резюмим coroutine из main-loop когда наступило время.
// Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
// Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
// delay из resume → планируем следующий resume через scheduleResume.
// Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) —
// подсовываем виртуальный Tool как script.Parent. Иначе primitive по id,
// иначе workspace.
let parentExpr;
if (entry.toolName) {
// Tool создаётся в shim как Instance.new('Tool'). По имени достаём.
// Если не нашли — fallback на новый Tool того же имени.
const safeName = JSON.stringify(entry.toolName);
parentExpr = `(function()
local existing = __rbxl_get_tool_by_name(${safeName})
if existing then return existing end
local t = Instance.new("Tool")
t.Name = ${safeName}
return t
end)()`;
} else if (primId != null) {
parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`;
} else {
parentExpr = 'workspace';
}
const wrapped = `
do
-- Если parentExpr вернул primitive у него уже есть :FindFirstChild и пр.
-- Если ничего не вернёт workspace (всегда валидный).
-- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace).
local _scriptParent = ${parentExpr}
if _scriptParent == nil then _scriptParent = workspace end
if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end
local script = setmetatable({
Name = ${JSON.stringify(scriptName)},
Parent = _scriptParent,
ClassName = "Script",
Disabled = false,
Source = nil,
}, {
-- Любой доступ к несуществующему полю workspace
-- (на случай script.Foo:Bar() в старом коде)
__index = function(t, k)
if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then
return function() return nil end
end
return workspace[k]
end,
})
local co = coroutine.create(function()
-- WATCHDOG: каждые 100000 инструкций yield 1 кадр.
-- НЕ оборачиваем в pcall внутри C-call boundary yield
-- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть.
debug.sethook(function()
coroutine.yield(0.016)
end, "", 20000)
-- pcall защищает от runtime-ошибок которые иначе крашат
-- coroutine и могут повредить WASM-стейт. Возвраты
-- handler'а намеренно поглощаются.
local ok_, err_ = pcall(function()
${entry.code}
end)
if not ok_ then
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_))
end
end)
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
local ok, ret = coroutine.resume(co)
if not ok then
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret))
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
elseif type(ret) == 'number' then
-- скрипт yield'нул с delay (через task.wait) планируем resume
__rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret)
elseif coroutine.status(co) == 'dead' then
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
end
end
`;
try {
this.vm.doStringSync(wrapped);
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err);
this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` });
}
}
_startMainLoop() {
const tick = () => {
if (this._isStopped) return;
try {
const now = performance.now();
const dt = Math.min(0.1, (now - this._lastTickAt) / 1000);
this._lastTickAt = now;
if (this.api?.tickScheduler) this.api.tickScheduler(dt);
if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox tick]', e);
}
this._loopHandle = setTimeout(tick, 16);
};
this._loopHandle = setTimeout(tick, 16);
}
_emit(cmd, payload) {
if (typeof this._onCommand === 'function') {
try { this._onCommand({ cmd, payload }); } catch (_) {}
}
}
// ----- API совместимый с ScriptSandbox -----
sendEvent(payload) {
if (!this.api?.fireTargetEvent || !this._isReady) return;
try { this.api.fireTargetEvent(payload); } catch (e) {
console.error('[LuaSharedSandbox] sendEvent:', e);
}
}
sendGlobalEvent(payload) {
if (!this.api?.fireGlobalEvent || !this._isReady) return;
try { this.api.fireGlobalEvent(payload); } catch (e) {
console.error('[LuaSharedSandbox] sendGlobalEvent:', e);
}
}
sendSceneSnapshot(snapshot) {
this._scenes = snapshot;
if (this.api?.onSceneSnapshot && this._isReady) {
try { this.api.onSceneSnapshot(snapshot); } catch (e) {
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
}
}
}
sendGuiSnapshot(snapshot) {
this._guiTree = snapshot;
if (this.api?.onGuiSnapshot && this._isReady) {
try { this.api.onGuiSnapshot(snapshot); } catch (_) {}
}
}
sendDataSnapshot(snapshot) {
if (this.api?.onDataSnapshot && this._isReady) {
try { this.api.onDataSnapshot(snapshot); } catch (_) {}
}
}
sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ }
sendTerrainHeightmap(_) { /* no-op */ }
stop() {
this._isStopped = true;
if (this._loopHandle) {
clearTimeout(this._loopHandle);
this._loopHandle = null;
}
if (this.vm) {
try { this.vm.global.close(); } catch (_) {}
this.vm = null;
}
this.api = null;
}
}
export default LuaSharedSandbox;

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
/**
* rbxl-lua-integration.js single-VM интеграция (v2).
* rbxl-lua-integration.js вспомогательные функции для импорта .rbxl-карт.
*
* Двухфазная инициализация:
* 1) init worker pre-populate workspace + GUI tree (включая сигналы)
* 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением
* 3) ready kickoff emit PlayerAdded, начать tick
* Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
* Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
* (см. GameRuntime.start()). Этот файл оставлен только для:
* - unpackRobloxLuaCode() распаковка Lua из JS-комментария-обёртки;
* - handleLuaCommand() обработка partSet/sceneCreate/sceneDelete/playerCmd
* команд от Lua-VM в BabylonScene.
*/
import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker';
import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js';
/** Распаковка lua_source из packed-кода. */
export function unpackRobloxLuaCode(code) {
@ -20,6 +20,20 @@ export function unpackRobloxLuaCode(code) {
return code.slice(start, closeIdx);
}
/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */
export function parseRobloxLuaMeta(code) {
if (typeof code !== 'string') return null;
const lines = code.split('\n');
if (lines.length < 2) return null;
const metaLine = lines[1];
if (!metaLine.startsWith('// ')) return null;
try {
return JSON.parse(metaLine.slice(3));
} catch (_) {
return null;
}
}
/** Сцена → snap для shim'а (workspace:GetChildren). */
export function buildLuaSceneSnap(primitives) {
const out = { primitives: {} };
@ -80,37 +94,6 @@ export function buildLuaGuiTree(guiElements) {
return out;
}
/**
* Старт shared-sandbox: init addScripts kickoff.
*/
export function startRobloxLuaShared(scripts, ctx) {
try {
const luaScripts = [];
for (const s of scripts) {
if (!s || typeof s.code !== 'string') continue;
if (!s.code.startsWith('// @roblox-lua')) continue;
const luaSource = unpackRobloxLuaCode(s.code);
if (!luaSource) continue;
luaScripts.push({ id: s.id, target: s.target, luaSource });
}
if (luaScripts.length === 0) return { sandbox: null, count: 0 };
const worker = new RobloxLuaSharedWorker();
const sceneSnap = buildLuaSceneSnap(ctx.primitives);
const guiTree = buildLuaGuiTree(ctx.guiElements || []);
const mgr = new RobloxLuaSharedSandbox();
mgr.setOnCommand(ctx.onCommand);
mgr.start(sceneSnap, guiTree, worker);
mgr.addScriptsBatch(luaScripts);
mgr.kickoff();
return { sandbox: mgr, count: luaScripts.length };
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[rbxl-lua-shared v2] start failed:', e?.message || e);
return null;
}
}
/**
* Обработка IPC команд от worker'а мапим на действия в Babylon-сцене.
*/
@ -122,28 +105,78 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
return;
}
if (cmd === 'partSet') {
const pm = runtime.scene3d?.primitiveManager;
if (!pm) {
console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d);
return;
}
const primId = payload?.primId;
const prop = payload?.prop;
const value = payload?.value;
const patch = {};
if (prop === 'position' && value) {
patch.x = value.x; patch.y = value.y; patch.z = value.z;
} else if (prop === 'cframe' && value) {
patch.x = value.x; patch.y = value.y; patch.z = value.z;
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
} else if (prop === 'size' && value) {
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
} else if (prop === 'color') patch.color = value;
else if (prop === 'material') patch.material = value;
else if (prop === 'anchored') patch.anchored = value;
else if (prop === 'canCollide') patch.canCollide = value;
else if (prop === 'opacity') patch.opacity = value;
try {
if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch);
else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
else if (typeof pm.update === 'function') pm.update(primId, patch);
} catch (e) {
console.error('[partSet] updateInstance failed:', e);
}
return;
}
if (cmd === 'sceneCreate') {
// Lua: Instance.new("Part") + part.Parent = workspace → создание примитива.
// payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored }
try {
const pm = runtime.scene3d?.primitiveManager;
if (!pm) return;
const primId = payload?.primId;
const prop = payload?.prop;
const value = payload?.value;
const patch = {};
if (prop === 'position' && value) {
patch.x = value.x; patch.y = value.y; patch.z = value.z;
} else if (prop === 'cframe' && value) {
patch.x = value.x; patch.y = value.y; patch.z = value.z;
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
} else if (prop === 'size' && value) {
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
} else if (prop === 'color') patch.color = value;
else if (prop === 'material') patch.material = value;
else if (prop === 'anchored') patch.anchored = value;
else if (prop === 'canCollide') patch.canCollide = value;
else if (prop === 'opacity') patch.opacity = value;
if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
else if (typeof pm.update === 'function') pm.update(primId, patch);
} catch (e) {}
if (!pm || typeof pm.addInstance !== 'function') return;
const opts = {
id: payload?.primId,
x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0,
sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1,
color: payload?.color,
anchored: payload?.anchored !== false,
canCollide: payload?.canCollide !== false,
};
pm.addInstance(payload?.type || 'cube', opts);
// Если unanchored — регистрируем в физике на лету, иначе он не падает.
if (opts.anchored === false) {
try {
const dm = runtime.scene3d?.dynamics;
const data = pm.instances?.get?.(opts.id);
if (dm && data && typeof dm.registerPrimitive === 'function') {
dm.registerPrimitive(data);
}
} catch (e) {
console.warn('[sceneCreate] registerPrimitive failed', e);
}
}
} catch (e) {
console.error('[sceneCreate]', e);
}
return;
}
if (cmd === 'sceneDelete') {
// Lua: part:Destroy() → удаление примитива.
try {
const pm = runtime.scene3d?.primitiveManager;
if (!pm || typeof pm.removeInstance !== 'function') return;
const id = payload?.primId;
if (id != null) pm.removeInstance(Number(id));
} catch (e) {
console.error('[sceneDelete]', e);
}
return;
}
if (cmd === 'partVel') {

View File

@ -1,216 +0,0 @@
/**
* roblox-physics.js эмуляция BodyMover / Constraint объектов Roblox.
*
* Roblox BodyMover'ы (старые, deprecated но массово используются):
* BodyVelocity поддерживает заданную линейную velocity
* BodyAngularVelocity поддерживает заданную угловую velocity
* BodyGyro пытается удержать ориентацию (Lookat)
* BodyForce постоянная сила
* BodyPosition пытается удержать позицию
* BodyThrust направленный импульс
*
* Constraint (новые):
* AlignPosition, AlignOrientation, LinearVelocity, AngularVelocity, Torque,
* VectorForce, Spring, RodConstraint, RopeConstraint, ...
*
* MVP: реализуем самые частые (BodyVelocity, BodyGyro, AlignPosition, VectorForce).
* Остальные заглушки + warning.
*
* Архитектура:
* - Когда Lua делает `Instance.new("BodyVelocity", part)`, мы создаём RbxBodyVelocity,
* прикрепляем к Part через .Parent.
* - На каждом tick шедулера обходим активные movers и отсылаем physForce в main.
* - Main применяет к Babylon physics impostor.
*/
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
class RbxBodyMoverBase extends RbxInstance {
constructor(className) {
super(className, { Name: className });
this._ctx = null; // { send, registerMover }
this.__parentPart = null;
}
/** Установить родителя и зарегистрироваться в physics-manager. */
setMoverParent(part) {
this.Parent = part;
if (part && part.__primId != null) {
this.__parentPart = part;
this._ctx?.registerMover?.(this);
}
}
}
export class RbxBodyVelocity extends RbxBodyMoverBase {
constructor() {
super('BodyVelocity');
this.Velocity = new RbxVector3(0, 0, 0);
this.MaxForce = new RbxVector3(4000, 4000, 4000);
this.P = 1250;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
// posVel — желаемая velocity. Применяем как setVelocity.
this._ctx.send('partVel', {
primId: this.__parentPart.__primId,
vx: this.Velocity.X,
vy: this.Velocity.Y,
vz: this.Velocity.Z,
});
}
}
export class RbxBodyGyro extends RbxBodyMoverBase {
constructor() {
super('BodyGyro');
this.CFrame = null; // целевое вращение
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
this.D = 500;
this.P = 3000;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx || !this.CFrame) return;
const [rx, ry, rz] = this.CFrame.toEulerXYZ();
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'rotation',
value: { rx, ry, rz },
});
}
}
export class RbxBodyPosition extends RbxBodyMoverBase {
constructor() {
super('BodyPosition');
this.Position = new RbxVector3(0, 0, 0);
this.MaxForce = new RbxVector3(4000, 4000, 4000);
this.D = 1250;
this.P = 10000;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'position',
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
});
}
}
export class RbxBodyForce extends RbxBodyMoverBase {
constructor() {
super('BodyForce');
this.Force = new RbxVector3(0, 0, 0);
}
_step(dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partForce', {
primId: this.__parentPart.__primId,
fx: this.Force.X * dt, fy: this.Force.Y * dt, fz: this.Force.Z * dt,
});
}
}
export class RbxBodyAngularVelocity extends RbxBodyMoverBase {
constructor() {
super('BodyAngularVelocity');
this.AngularVelocity = new RbxVector3(0, 0, 0);
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partAngVel', {
primId: this.__parentPart.__primId,
wx: this.AngularVelocity.X, wy: this.AngularVelocity.Y, wz: this.AngularVelocity.Z,
});
}
}
/* ──────── New Constraints ──────── */
export class RbxAlignPosition extends RbxBodyMoverBase {
constructor() {
super('AlignPosition');
this.Position = new RbxVector3(0, 0, 0);
this.Attachment0 = null;
this.Attachment1 = null;
this.MaxForce = 1e6;
this.Enabled = true;
}
_step(_dt) {
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'position',
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
});
}
}
export class RbxLinearVelocity extends RbxBodyMoverBase {
constructor() {
super('LinearVelocity');
this.VectorVelocity = new RbxVector3(0, 0, 0);
this.MaxForce = 1e6;
this.Enabled = true;
}
_step(_dt) {
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
this._ctx.send('partVel', {
primId: this.__parentPart.__primId,
vx: this.VectorVelocity.X,
vy: this.VectorVelocity.Y,
vz: this.VectorVelocity.Z,
});
}
}
/* ──────── Manager ──────── */
export class RobloxPhysicsManager {
constructor(send) {
this._send = send;
this._movers = new Set();
}
install(lua) {
const self = this;
const ctx = {
send: this._send,
registerMover: (m) => self._movers.add(m),
};
// Подменяем Instance.new для физических классов
const origInstance = lua.global.get('Instance');
lua.global.set('Instance', {
new: (className, parent) => {
let inst = null;
switch (className) {
case 'BodyVelocity': inst = new RbxBodyVelocity(); break;
case 'BodyGyro': inst = new RbxBodyGyro(); break;
case 'BodyPosition': inst = new RbxBodyPosition(); break;
case 'BodyForce': inst = new RbxBodyForce(); break;
case 'BodyAngularVelocity':inst = new RbxBodyAngularVelocity(); break;
case 'AlignPosition': inst = new RbxAlignPosition(); break;
case 'LinearVelocity': inst = new RbxLinearVelocity(); break;
}
if (inst) {
inst._ctx = ctx;
if (parent) {
inst.setMoverParent(parent);
if (parent.Children) parent.Children.push(inst);
}
return inst;
}
return origInstance.new(className, parent);
},
});
}
tick(dt) {
for (const m of [...this._movers]) {
if (m.__destroyed || !m.__parentPart) { this._movers.delete(m); continue; }
try { m._step(dt); } catch (e) {}
}
}
}

View File

@ -1,209 +0,0 @@
/**
* roblox-scheduler.js шедулер корутин для Roblox-Lua wait/task.
*
* Архитектура:
* - Каждый верхне-уровневый Lua-код оборачивается в coroutine.
* - wait(sec) / task.wait(sec) делают coroutine.yield(sec)
* - Шедулер запоминает: { coro, resumeAt: tick + sec }
* - На каждом handleTick из main thread шедулер ресюмит готовые корутины
*
* RBXScriptSignal.Wait() = аналогично, но wait не на время, а на event'е:
* - { coro, waitingForSignal: signalName }
* - При Fire() сигнала шедулер ресюмит все ждущие
*
* Использование:
* const sched = new RobloxScheduler(luaEngine);
* sched.spawnMain(luaSource);
* // Каждый кадр:
* sched.tick(dtSec);
* // При событии:
* sched.fireSignal('Heartbeat', dt);
*/
export class RobloxScheduler {
constructor(lua) {
this.lua = lua;
this.time = 0;
this.tasks = []; // [{ coro, resumeAt, waitForSignal?, signalArgsBuf? }]
this.signalWaiters = new Map(); // name → [task]
this._coroBox = null;
}
/**
* Регистрирует глобалы wait/task.wait/task.spawn/task.delay в Lua-VM.
* Должно вызываться ПОСЛЕ registerRobloxApi (т.к. перебивает заглушки).
*/
install() {
const self = this;
// wait(sec) — yield в корутине на sec секунд
this.lua.global.set('wait', (sec) => {
// Этот wait вызовется из Lua. Если мы внутри корутины (а мы внутри
// т.к. spawnMain обернул всё) — yield. Иначе вернём дельту времени
// как обычное wait в Roblox.
const s = +sec || 0;
self._currentYield = { kind: 'sleep', sec: s };
// Возврат тут — это значение которое получит await в Lua;
// wasmoon обработает yield извне.
return s;
});
this.lua.global.set('task', {
wait: (sec) => {
self._currentYield = { kind: 'sleep', sec: +sec || 0 };
return +sec || 0;
},
spawn: (fn, ...args) => {
self.spawnCoroutine(fn, args);
},
delay: (sec, fn, ...args) => {
self.tasks.push({
resumeAt: self.time + (+sec || 0),
runFn: () => { try { fn(...args); } catch (e) {} },
});
},
defer: (fn, ...args) => {
self.tasks.push({
resumeAt: self.time,
runFn: () => { try { fn(...args); } catch (e) {} },
});
},
});
this.lua.global.set('spawn', (fn) => { self.spawnCoroutine(fn, []); });
this.lua.global.set('delay', (sec, fn) => {
self.tasks.push({
resumeAt: self.time + (+sec || 0),
runFn: () => { try { fn(); } catch (e) {} },
});
});
}
/**
* Запустить верхне-уровневый Lua-код как корутину.
* Возвращает Promise который резолвится когда код достиг ready (либо ушёл в первый yield).
*/
async spawnMain(luaSource) {
// Оборачиваем источник в coroutine.wrap(function() ... end)
// и сразу зовём — это даёт нам ручку на корутине через специальный
// приём: храним её в global _userCoro.
const wrapped = `
_userCoro = coroutine.create(function()
${luaSource}
end)
local ok, yieldVal = coroutine.resume(_userCoro)
if not ok then
error("user script error: " .. tostring(yieldVal))
end
return yieldVal
`;
try {
await this.lua.doString(wrapped);
const coroStatus = await this.lua.doString('return coroutine.status(_userCoro)');
if (coroStatus === 'suspended') {
// Ушла в yield — добавляем в шедулер
const yieldInfo = this._currentYield || { kind: 'sleep', sec: 0 };
this._currentYield = null;
this.tasks.push({
resumeAt: this.time + (yieldInfo.kind === 'sleep' ? yieldInfo.sec : 0),
waitForSignal: yieldInfo.kind === 'signal' ? yieldInfo.name : null,
coro: '_userCoro',
});
}
} catch (e) {
console.warn('spawnMain error:', e);
}
}
/**
* Запустить произвольную функцию как корутину (для task.spawn).
*/
spawnCoroutine(fn, args) {
// Создаём корутину на JS-стороне: просто вызываем fn() сразу,
// а если внутри неё дёрнут wait — yield не сработает (JS не делает
// sync yield в обычной функции). Поэтому task.spawn для JS-функций
// равен прямому вызову.
// В будущем (4.7.1) можно через Lua coroutine реализовать.
try { fn(...(args || [])); } catch (e) { /* swallow */ }
}
/**
* Продвинуть время на dt и резюмить готовые корутины.
* Также автоматически fire'ит RunService.Heartbeat / Stepped / RenderStepped.
*/
async tick(dtSec) {
const dt = +dtSec || 0;
this.time += dt;
// Heartbeat / Stepped / RenderStepped для RunService
const game = this.lua.global.get('game');
if (game && typeof game.GetService === 'function') {
const rs = game.GetService('RunService');
if (rs) {
if (rs.Heartbeat && rs.Heartbeat.Fire) rs.Heartbeat.Fire(dt);
if (rs.Stepped && rs.Stepped.Fire) rs.Stepped.Fire(this.time, dt);
if (rs.RenderStepped && rs.RenderStepped.Fire) rs.RenderStepped.Fire(dt);
}
}
// Резюмим всё что готово
const ready = this.tasks.filter(t => !t.waitForSignal && t.resumeAt <= this.time);
this.tasks = this.tasks.filter(t => !(ready.includes(t)));
for (const t of ready) {
await this._resumeTask(t);
}
}
/**
* Fire signal разбудить все task'и ждущие этого сигнала.
*/
async fireSignal(name, ...args) {
const waiters = this.signalWaiters.get(name) || [];
this.signalWaiters.set(name, []);
for (const t of waiters) {
// Resume корутины передавая args как возврат :Wait()
await this._resumeTask(t, args);
}
}
async _resumeTask(task, resumeArgs = []) {
if (task.runFn) {
try {
const ret = task.runFn();
if (ret && typeof ret.then === 'function') await ret;
} catch (e) {}
return;
}
if (task.coro) {
try {
// resumeArgs идут как аргументы в coroutine.resume
const argsCode = resumeArgs.map((a, i) => {
if (typeof a === 'number') return String(a);
if (typeof a === 'string') return JSON.stringify(a);
return 'nil';
}).join(', ');
const code = `
local ok, val = coroutine.resume(${task.coro}${argsCode ? ', ' + argsCode : ''})
if not ok then
error("coro error: " .. tostring(val))
end
return val
`;
await this.lua.doString(code);
const status = await this.lua.doString(`return coroutine.status(${task.coro})`);
if (status === 'suspended') {
// Опять ушла в yield
const yi = this._currentYield || { kind: 'sleep', sec: 0 };
this._currentYield = null;
if (yi.kind === 'sleep') {
this.tasks.push({
resumeAt: this.time + yi.sec,
coro: task.coro,
});
} else if (yi.kind === 'signal') {
const list = this.signalWaiters.get(yi.name) || [];
list.push({ coro: task.coro });
this.signalWaiters.set(yi.name, list);
}
}
} catch (e) {
// Корутина завершилась с ошибкой — просто дропаем
}
}
}
}

View File

@ -1,384 +0,0 @@
/**
* roblox-services.js расширения Roblox-API для сервисов:
* Players / Humanoid / UserInputService / RemoteEvent / RemoteFunction
* / DataStoreService / HttpService.
*
* Регистрируется ПОСЛЕ registerRobloxApi (см. roblox-shim.js).
*
* Поведение:
* - Players.LocalPlayer.Character.Humanoid.Health, WalkSpeed, JumpPower
* мапятся на game.player.* в Rublox через `playerCmd` IPC.
* - UserInputService.InputBegan/InputEnded пробрасываются из main
* по событию через fireEvent.
* - RemoteEvent:FireServer/FireClient broadcast.
* - DataStoreService:GetDataStore game.save.
*/
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
/* ──────── Humanoid ──────── */
class RbxHumanoid extends RbxInstance {
constructor(ctx) {
super('Humanoid', { Name: 'Humanoid' });
this._ctx = ctx; // { send, getPlayerState }
this._snap = {
Health: 100,
MaxHealth: 100,
WalkSpeed: 16,
JumpPower: 50,
JumpHeight: 7.2,
HipHeight: 0,
HumanoidStateType: 'GettingUp',
PlatformStand: false,
};
this.Died = new RbxSignal('Died');
this.HealthChanged = new RbxSignal('HealthChanged');
this.Touched = new RbxSignal('Touched');
this.Running = new RbxSignal('Running');
this.Jumping = new RbxSignal('Jumping');
this.StateChanged = new RbxSignal('StateChanged');
}
get Health() { return this._snap.Health; }
set Health(v) {
const old = this._snap.Health;
const nv = Math.max(0, +v || 0);
this._snap.Health = nv;
if (nv !== old) this.HealthChanged.Fire(nv);
if (nv <= 0 && old > 0) {
this.Died.Fire();
this._ctx.send?.('playerCmd', { method: 'die', args: [] });
} else {
this._ctx.send?.('playerCmd', { method: 'setHealth', args: [nv] });
}
}
get MaxHealth() { return this._snap.MaxHealth; }
set MaxHealth(v) {
this._snap.MaxHealth = +v || 100;
this._ctx.send?.('playerCmd', { method: 'setMaxHealth', args: [this._snap.MaxHealth] });
}
get WalkSpeed() { return this._snap.WalkSpeed; }
set WalkSpeed(v) {
this._snap.WalkSpeed = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setWalkSpeed', args: [this._snap.WalkSpeed] });
}
get JumpPower() { return this._snap.JumpPower; }
set JumpPower(v) {
this._snap.JumpPower = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setJumpPower', args: [this._snap.JumpPower] });
}
get JumpHeight() { return this._snap.JumpHeight; }
set JumpHeight(v) {
this._snap.JumpHeight = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setJumpHeight', args: [this._snap.JumpHeight] });
}
get PlatformStand() { return !!this._snap.PlatformStand; }
set PlatformStand(v) {
this._snap.PlatformStand = !!v;
this._ctx.send?.('playerCmd', { method: 'setPlatformStand', args: [!!v] });
}
TakeDamage(amount) {
this.Health = Math.max(0, this.Health - (+amount || 0));
}
Move(direction, relative) {
if (direction instanceof RbxVector3) {
this._ctx.send?.('playerCmd', {
method: 'move',
args: [{ x: direction.X, y: direction.Y, z: direction.Z }, !!relative],
});
}
}
Jump() {
this._ctx.send?.('playerCmd', { method: 'jump', args: [] });
}
LoadAnimation(animation) {
// Animation объект — content rbxassetid. Возвращаем animation-track stub.
const aid = animation?.AnimationId || '';
return {
AnimationId: aid,
Length: 0,
IsPlaying: false,
Looped: false,
Play: () => this._ctx.send?.('playerCmd', { method: 'playAnim', args: [aid] }),
Stop: () => this._ctx.send?.('playerCmd', { method: 'stopAnim', args: [aid] }),
AdjustSpeed: (s) => this._ctx.send?.('playerCmd', { method: 'animSpeed', args: [aid, s] }),
GetTimeOfKeyframe: () => 0,
KeyframeReached: new RbxSignal('KeyframeReached'),
};
}
ChangeState(state) {
this._snap.HumanoidStateType = state;
this.StateChanged.Fire(state);
}
SetStateEnabled(_state, _enabled) { /* noop */ }
GetState() { return this._snap.HumanoidStateType; }
}
/* ──────── Character / Player ──────── */
class RbxCharacter extends RbxInstance {
constructor(ctx) {
super('Model', { Name: 'Character' });
// HumanoidRootPart — это «Position персонажа»
this.HumanoidRootPart = new RbxInstance('Part', { Name: 'HumanoidRootPart', Parent: this });
// mock Position через getter — берём текущую позицию из ctx
Object.defineProperty(this.HumanoidRootPart, 'Position', {
get: () => {
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
return new RbxVector3(p.x, p.y, p.z);
},
set: (v) => {
if (v instanceof RbxVector3) {
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X, v.Y, v.Z] });
}
},
});
Object.defineProperty(this.HumanoidRootPart, 'CFrame', {
get: () => {
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
return { X: p.x, Y: p.y, Z: p.z, p: { X: p.x, Y: p.y, Z: p.z } };
},
set: (v) => {
if (v && typeof v === 'object') {
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X || 0, v.Y || 0, v.Z || 0] });
}
},
});
this.Children.push(this.HumanoidRootPart);
this.Humanoid = new RbxHumanoid(ctx);
this.Humanoid.Parent = this;
this.Children.push(this.Humanoid);
}
}
class RbxPlayer extends RbxInstance {
constructor(ctx) {
super('Player', { Name: 'Player' });
this.UserId = 1;
this.DisplayName = 'Player';
this.Character = new RbxCharacter(ctx);
this.CharacterAdded = new RbxSignal('CharacterAdded');
this.CharacterRemoving = new RbxSignal('CharacterRemoving');
// На MVP — характер уже создан.
setTimeout(() => this.CharacterAdded.Fire(this.Character), 0);
this.leaderstats = new RbxInstance('Folder', { Name: 'leaderstats', Parent: this });
this.Children.push(this.leaderstats);
}
GetMouse() {
return { Hit: { Position: new RbxVector3(0, 0, 0) }, Target: null,
Button1Down: new RbxSignal('Button1Down'), Move: new RbxSignal('Move') };
}
Kick(reason) {
// в нашем плеере — просто log
return reason;
}
}
/* ──────── UserInputService ──────── */
class RbxUserInputService extends RbxInstance {
constructor() {
super('UserInputService', { Name: 'UserInputService' });
this.InputBegan = new RbxSignal('InputBegan');
this.InputEnded = new RbxSignal('InputEnded');
this.InputChanged = new RbxSignal('InputChanged');
this.JumpRequest = new RbxSignal('JumpRequest');
this.KeyboardEnabled = true;
this.MouseEnabled = true;
this.TouchEnabled = false;
}
GetMouseLocation() { return { X: 0, Y: 0 }; }
IsKeyDown(_keyCode) { return false; } // в MVP всегда false
}
/* ──────── RemoteEvent / RemoteFunction ──────── */
class RbxRemoteEvent extends RbxInstance {
constructor(ctx) {
super('RemoteEvent', { Name: 'RemoteEvent' });
this._ctx = ctx;
this.OnServerEvent = new RbxSignal('OnServerEvent');
this.OnClientEvent = new RbxSignal('OnClientEvent');
}
FireServer(...args) {
// singleplayer: server == client, просто отдаём в OnServerEvent
this.OnServerEvent.Fire(this._ctx.localPlayer, ...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
FireClient(_player, ...args) {
this.OnClientEvent.Fire(...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
FireAllClients(...args) {
this.OnClientEvent.Fire(...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
}
class RbxRemoteFunction extends RbxInstance {
constructor(ctx) {
super('RemoteFunction', { Name: 'RemoteFunction' });
this._ctx = ctx;
this.OnServerInvoke = null; // function(player, ...args) → result
}
InvokeServer(...args) {
if (typeof this.OnServerInvoke === 'function') {
try { return this.OnServerInvoke(this._ctx.localPlayer, ...args); } catch (e) {}
}
return null;
}
InvokeClient(_player, ...args) {
if (typeof this.OnClientInvoke === 'function') {
try { return this.OnClientInvoke(...args); } catch (e) {}
}
return null;
}
}
/* ──────── DataStoreService ──────── */
class RbxDataStore {
constructor(name, ctx) {
this.name = name;
this._ctx = ctx;
}
GetAsync(key) {
try {
const data = this._ctx.loadSave?.(this.name + ':' + key);
return data ?? null;
} catch (e) { return null; }
}
SetAsync(key, value) {
this._ctx.saveSave?.(this.name + ':' + key, value);
return value;
}
UpdateAsync(key, updaterFn) {
const cur = this.GetAsync(key);
const next = updaterFn(cur);
if (next !== undefined) this.SetAsync(key, next);
return next;
}
IncrementAsync(key, delta) {
const cur = +this.GetAsync(key) || 0;
const next = cur + (+delta || 1);
this.SetAsync(key, next);
return next;
}
RemoveAsync(key) {
this._ctx.removeSave?.(this.name + ':' + key);
}
}
class RbxDataStoreService extends RbxInstance {
constructor(ctx) {
super('DataStoreService', { Name: 'DataStoreService' });
this._ctx = ctx;
this._stores = new Map();
}
GetDataStore(name) {
if (!this._stores.has(name)) this._stores.set(name, new RbxDataStore(name, this._ctx));
return this._stores.get(name);
}
GetGlobalDataStore() { return this.GetDataStore('__global__'); }
GetOrderedDataStore(name) { return this.GetDataStore('ordered:' + name); }
}
/* ──────── HttpService ──────── */
class RbxHttpService extends RbxInstance {
constructor(ctx) {
super('HttpService', { Name: 'HttpService' });
this._ctx = ctx;
this.HttpEnabled = false; // в нашем плеере по дефолту выкл, безопаснее
}
GenerateGUID(wrap) {
const c = () => Math.random().toString(16).slice(2, 6);
const guid = `${c()}${c()}-${c()}-${c()}-${c()}-${c()}${c()}${c()}`.toUpperCase();
return wrap === false ? guid : `{${guid}}`;
}
JSONEncode(value) { try { return JSON.stringify(value); } catch (e) { return ''; } }
JSONDecode(s) { try { return JSON.parse(s); } catch (e) { return null; } }
GetAsync(url) {
// CORS / sandbox: блокируем в MVP, возвращаем заглушку
this._ctx.send?.('log', { level: 'warn', text: `HttpService:GetAsync(${url}) blocked in MVP` });
return '';
}
PostAsync(url) {
this._ctx.send?.('log', { level: 'warn', text: `HttpService:PostAsync(${url}) blocked in MVP` });
return '';
}
}
/* ──────── install ──────── */
export function installRobloxServices(lua, ctx) {
// ctx: { send, getPlayerState, getSnapPlayer, loadSave, saveSave, removeSave }
const game = lua.global.get('game');
if (!game) return;
// Создаём LocalPlayer
const player = new RbxPlayer({
send: ctx.send,
getPlayerState: ctx.getPlayerState,
});
// Players service апгрейдим
const players = game.GetService('Players');
if (players) {
players.LocalPlayer = player;
// GetPlayers / GetPlayerFromCharacter
players.GetPlayers = () => [player];
players.GetPlayerFromCharacter = (c) => (c === player.Character ? player : null);
}
// UserInputService
const uis = new RbxUserInputService();
// RemoteEvent / DataStoreService / HttpService — выдаются через GetService
const dss = new RbxDataStoreService({
loadSave: ctx.loadSave,
saveSave: ctx.saveSave,
removeSave: ctx.removeSave,
});
const httpSvc = new RbxHttpService({ send: ctx.send });
// Подмена GetService — добавляем наши новые сервисы
const origGetService = game.GetService;
game.GetService = function(svc) {
if (svc === 'UserInputService') return uis;
if (svc === 'DataStoreService') return dss;
if (svc === 'HttpService') return httpSvc;
// ContextActionService — стаб
if (svc === 'ContextActionService') {
return {
ClassName: 'ContextActionService', Name: 'ContextActionService',
BindAction: (_name, fn, _gui, ...keys) => { /* в MVP — игнор */ },
UnbindAction: () => {},
};
}
return origGetService.call(this, svc);
};
// Instance.new('RemoteEvent') / 'RemoteFunction' — переопределяем фабрику
const origInstance = lua.global.get('Instance');
lua.global.set('Instance', {
new: (className, parent) => {
if (className === 'RemoteEvent') {
const r = new RbxRemoteEvent({ send: ctx.send, localPlayer: player });
if (parent) { r.Parent = parent; parent.Children.push(r); }
return r;
}
if (className === 'RemoteFunction') {
const r = new RbxRemoteFunction({ send: ctx.send, localPlayer: player });
if (parent) { r.Parent = parent; parent.Children.push(r); }
return r;
}
return origInstance.new(className, parent);
},
});
return { player, uis, dss, httpSvc };
}
export { RbxHumanoid, RbxCharacter, RbxPlayer, RbxUserInputService,
RbxRemoteEvent, RbxRemoteFunction, RbxDataStoreService, RbxHttpService };

View File

@ -1,715 +0,0 @@
/**
* roblox-shim.js регистрация Roblox API внутри Lua-VM (wasmoon).
*
* Используется из RobloxLuaWorker.js. Регистрирует глобалы:
* - game, workspace, script Instance-прокси
* - Vector3, Color3, CFrame, UDim, UDim2 конструкторы математических классов
* - Instance.new(class) фабрика
* - wait, task, tick, os, print, warn стандартные глобалы
* - Enum enum-таблица
*
* Архитектура:
* - JS-классы (RbxVector3, RbxCFrame, ...) обычные дата-объекты с
* перегруженными методами.
* - Instance прокси-объект который хранит { className, properties, children, parent }.
* Геттеры/сеттеры эмулируются через __index/__newindex (mt в wasmoon).
* - RBXScriptSignal JS-объект с Connect/Wait/Disconnect.
*
* Sandbox-side: при изменении Part.Position и т.п. отсылаем в main thread
* `partSet` main применит к Babylon-сцене.
*/
/* ──────── Math classes ──────── */
class RbxVector3 {
constructor(x, y, z) {
this.X = +x || 0;
this.Y = +y || 0;
this.Z = +z || 0;
}
get Magnitude() {
return Math.sqrt(this.X*this.X + this.Y*this.Y + this.Z*this.Z);
}
get Unit() {
const m = this.Magnitude || 1;
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
}
Dot(o) { return this.X*o.X + this.Y*o.Y + this.Z*o.Z; }
Cross(o) {
return new RbxVector3(
this.Y*o.Z - this.Z*o.Y,
this.Z*o.X - this.X*o.Z,
this.X*o.Y - this.Y*o.X,
);
}
Lerp(o, alpha) {
return new RbxVector3(
this.X + (o.X - this.X) * alpha,
this.Y + (o.Y - this.Y) * alpha,
this.Z + (o.Z - this.Z) * alpha,
);
}
add(o) { return new RbxVector3(this.X + o.X, this.Y + o.Y, this.Z + o.Z); }
sub(o) { return new RbxVector3(this.X - o.X, this.Y - o.Y, this.Z - o.Z); }
mul(scalar) {
if (typeof scalar === 'number') {
return new RbxVector3(this.X * scalar, this.Y * scalar, this.Z * scalar);
}
return new RbxVector3(this.X * scalar.X, this.Y * scalar.Y, this.Z * scalar.Z);
}
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
}
class RbxColor3 {
constructor(r, g, b) {
this.R = +r || 0;
this.G = +g || 0;
this.B = +b || 0;
}
static fromRGB(r, g, b) {
return new RbxColor3((r||0)/255, (g||0)/255, (b||0)/255);
}
static fromHex(hex) {
const h = String(hex || '#000000').replace('#','');
return new RbxColor3(
parseInt(h.slice(0,2), 16)/255,
parseInt(h.slice(2,4), 16)/255,
parseInt(h.slice(4,6), 16)/255,
);
}
Lerp(o, alpha) {
return new RbxColor3(
this.R + (o.R - this.R) * alpha,
this.G + (o.G - this.G) * alpha,
this.B + (o.B - this.B) * alpha,
);
}
toHex() {
const h = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0');
return `#${h(this.R)}${h(this.G)}${h(this.B)}`;
}
toString() { return `${this.R}, ${this.G}, ${this.B}`; }
}
class RbxCFrame {
constructor(x, y, z, r00=1, r01=0, r02=0, r10=0, r11=1, r12=0, r20=0, r21=0, r22=1) {
this.X = +x || 0; this.Y = +y || 0; this.Z = +z || 0;
// Row-major 3x3
this.r00 = r00; this.r01 = r01; this.r02 = r02;
this.r10 = r10; this.r11 = r11; this.r12 = r12;
this.r20 = r20; this.r21 = r21; this.r22 = r22;
}
static new(x, y, z) {
if (x instanceof RbxVector3) return new RbxCFrame(x.X, x.Y, x.Z);
return new RbxCFrame(x || 0, y || 0, z || 0);
}
static Angles(rx, ry, rz) {
// Euler XYZ → 3x3 (intrinsic)
const cx = Math.cos(rx), sx = Math.sin(rx);
const cy = Math.cos(ry), sy = Math.sin(ry);
const cz = Math.cos(rz), sz = Math.sin(rz);
// R = Rx * Ry * Rz
const r00 = cy*cz, r01 = -cy*sz, r02 = sy;
const r10 = sx*sy*cz + cx*sz, r11 = -sx*sy*sz + cx*cz, r12 = -sx*cy;
const r20 = -cx*sy*cz + sx*sz, r21 = cx*sy*sz + sx*cz, r22 = cx*cy;
return new RbxCFrame(0, 0, 0, r00, r01, r02, r10, r11, r12, r20, r21, r22);
}
static fromEulerAnglesXYZ(rx, ry, rz) { return RbxCFrame.Angles(rx, ry, rz); }
get Position() { return new RbxVector3(this.X, this.Y, this.Z); }
get LookVector() { return new RbxVector3(-this.r02, -this.r12, -this.r22); }
get RightVector() { return new RbxVector3(this.r00, this.r10, this.r20); }
get UpVector() { return new RbxVector3(this.r01, this.r11, this.r21); }
Lerp(o, a) {
// Линейная интерполяция (без правильного slerp на матрицах — для MVP сойдёт)
return new RbxCFrame(
this.X + (o.X - this.X) * a,
this.Y + (o.Y - this.Y) * a,
this.Z + (o.Z - this.Z) * a,
this.r00, this.r01, this.r02,
this.r10, this.r11, this.r12,
this.r20, this.r21, this.r22,
);
}
Inverse() {
// Транспонируем 3x3 (для rotation matrix Inverse == Transpose)
return new RbxCFrame(
-this.X, -this.Y, -this.Z,
this.r00, this.r10, this.r20,
this.r01, this.r11, this.r21,
this.r02, this.r12, this.r22,
);
}
toEulerXYZ() {
const rx = Math.atan2(this.r21, this.r22);
const ry = Math.atan2(-this.r20, Math.sqrt(this.r21*this.r21 + this.r22*this.r22));
const rz = Math.atan2(this.r10, this.r00);
return [rx, ry, rz];
}
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
}
class RbxUDim {
constructor(scale, offset) { this.Scale = +scale || 0; this.Offset = +offset | 0; }
toString() { return `${this.Scale}, ${this.Offset}`; }
}
class RbxUDim2 {
constructor(xs, xo, ys, yo) {
this.X = new RbxUDim(xs, xo);
this.Y = new RbxUDim(ys, yo);
}
static new(xs, xo, ys, yo) { return new RbxUDim2(xs, xo, ys, yo); }
static fromScale(xs, ys) { return new RbxUDim2(xs, 0, ys, 0); }
static fromOffset(xo, yo) { return new RbxUDim2(0, xo, 0, yo); }
toString() { return `${this.X.Scale}, ${this.X.Offset}, ${this.Y.Scale}, ${this.Y.Offset}`; }
}
/* ──────── RBXScriptSignal ──────── */
let _signalIdCounter = 1000;
class RbxSignal {
constructor(name) {
this.name = name;
this.id = _signalIdCounter++;
this.connections = [];
}
Connect(callback) {
const conn = { callback, connected: true };
this.connections.push(conn);
return {
Disconnect: () => { conn.connected = false; },
disconnect: () => { conn.connected = false; },
Connected: () => conn.connected,
};
}
// Legacy Roblox API — lowercase alias
connect(callback) { return this.Connect(callback); }
Wait() { return null; }
wait() { return null; }
Fire(...args) {
for (const c of this.connections) {
if (!c.connected) continue;
try { c.callback(...args); } catch (e) { /* swallow */ }
}
}
fire(...args) { return this.Fire(...args); }
}
/* ──────── Instance прокси ──────── */
let _instanceCounter = 1;
// Null-stub: возвращается из FindFirstChild/WaitForChild когда объект не найден.
// Имеет все методы Instance как no-op, чтобы Lua-цепочки вроде
// game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn)
// не падали с "attempt to call js_null", когда промежуточный объект не существует.
// Все методы возвращают сам _nullStub чтобы цепочка не разорвалась.
// nullSignal: callable proxy. Lua делает x:Connect(fn) = x.Connect(x, fn),
// но также pattern signal:Connect(fn) сначала достаёт signal.Connect (это функция),
// потом вызывает её. Мы возвращаем функцию которая безусловно возвращает {Disconnect}.
const _nullConn = { Disconnect: () => {}, disconnect: () => {}, Connected: false };
const _nullSignalFn = () => _nullConn;
const _nullSignal = new Proxy(_nullSignalFn, {
get(_, k) {
if (k === 'Connect' || k === 'connect') return _nullSignalFn;
if (k === 'Wait' || k === 'wait') return () => null;
if (k === 'Fire' || k === 'fire') return () => {};
return undefined;
},
});
// Известные имена сигналов (Touched, Changed, MouseButton1Click, ...)
const _SIGNAL_NAMES = new Set([
'Touched','TouchEnded','Changed','Activated',
'MouseButton1Click','MouseButton1Down','MouseButton1Up',
'MouseButton2Click','MouseButton2Down','MouseButton2Up',
'MouseEnter','MouseLeave','InputBegan','InputEnded','InputChanged',
'PlayerAdded','PlayerRemoving','CharacterAdded','CharacterRemoving',
'Heartbeat','Stepped','RenderStepped','Died','HealthChanged',
'FocusLost','Focused','ChildAdded','ChildRemoved',
'AncestryChanged','DescendantAdded','DescendantRemoving',
// Tool сигналы
'Equipped','Unequipped','Selected','Deselected',
// прочие популярные
'OnInvoke','OnServerInvoke','OnClientInvoke',
'OnServerEvent','OnClientEvent','Fired','Triggered',
'ChatMakeSystemMessage','ChatMade',
]);
// _makeDeepStub — рекурсивный proxy которому всё равно сколько раз его
// индексируют. На любом уровне:
// - caps-имя из _SIGNAL_NAMES → возвращает _nullSignal
// - 'Parent' → возвращает _nullStub
// - любое другое имя → callable proxy + рекурсивная глубина
// Это позволяет цепочкам типа `tool.Selected:Connect(fn)` или
// `script.Parent.Parent.Frame.Visible` молча no-op'аться.
// Вместо JS Proxy (который wasmoon оборачивает в js_promise) — используем
// специальный маркер. Реальный stub живёт на Lua-стороне.
const NULL_STUB_MARKER = { __isNullStubMarker: true };
function _makeDeepStub() { return NULL_STUB_MARKER; }
const _nullStubBase = { __isNullStub: true, ClassName: 'Nil', Name: 'Nil', Children: [], Value: 0, Text: '', Visible: false };
// _nullStub оставлен как маркер, но не используется как реальный stub —
// debug.setmetatable(nil) в Lua перехватывает всё это.
const _nullStub = _nullStubBase;
class RbxInstance {
constructor(className, init = {}) {
this.__id = _instanceCounter++;
this.ClassName = className;
this.Name = init.Name || className;
this.Parent = init.Parent || null;
this.Children = [];
this.__props = {}; // raw properties (для Position и т.п.)
// Signals доступны как прямые свойства, плюс дублируются в __signals для serv-кода
this.Touched = new RbxSignal('Touched');
this.TouchEnded = new RbxSignal('TouchEnded');
this.Changed = new RbxSignal('Changed');
this.AncestryChanged = new RbxSignal('AncestryChanged');
this.ChildAdded = new RbxSignal('ChildAdded');
this.ChildRemoved = new RbxSignal('ChildRemoved');
this.__signals = {
Touched: this.Touched,
TouchEnded: this.TouchEnded,
Changed: this.Changed,
AncestryChanged: this.AncestryChanged,
ChildAdded: this.ChildAdded,
ChildRemoved: this.ChildRemoved,
};
this.__sceneState = null;
}
GetChildren() { return [...this.Children]; }
GetDescendants() {
const out = [];
const walk = (n) => {
for (const c of n.Children) { out.push(c); walk(c); }
};
walk(this);
return out;
}
FindFirstChild(name, recursive) {
for (const c of this.Children) {
if (c.Name === name) return c;
if (recursive) {
const found = c.FindFirstChild(name, true);
if (found) return found;
}
}
// Возвращаем undefined — wasmoon отдаст это как nil.
// Lua-side debug.setmetatable(nil) перехватит дальнейшую индексацию.
return undefined;
}
FindFirstChildOfClass(className) {
for (const c of this.Children) {
if (c.ClassName === className) return c;
}
return undefined;
}
FindFirstAncestor(name) {
let p = this.Parent;
while (p) {
if (p.Name === name) return p;
p = p.Parent;
}
return undefined;
}
WaitForChild(name, _timeout) {
// В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать.
return this.FindFirstChild(name);
}
IsA(className) {
if (this.ClassName === className) return true;
// Roblox class hierarchy: Part isA BasePart isA PVInstance isA Instance.
const hierarchy = {
'Part': ['BasePart', 'PVInstance', 'Instance'],
'WedgePart': ['BasePart', 'PVInstance', 'Instance'],
'CornerWedgePart': ['BasePart', 'PVInstance', 'Instance'],
'MeshPart': ['BasePart', 'PVInstance', 'Instance'],
'UnionOperation': ['PartOperation', 'BasePart', 'PVInstance', 'Instance'],
'TrussPart': ['BasePart', 'PVInstance', 'Instance'],
'SpawnLocation': ['Part', 'BasePart', 'PVInstance', 'Instance'],
'Script': ['BaseScript', 'LuaSourceContainer', 'Instance'],
'LocalScript': ['BaseScript', 'LuaSourceContainer', 'Instance'],
'ModuleScript': ['LuaSourceContainer', 'Instance'],
'Folder': ['Instance'],
'Model': ['PVInstance', 'Instance'],
'Sound': ['Instance'],
'PointLight': ['Light', 'Instance'],
'SpotLight': ['Light', 'Instance'],
'Humanoid': ['Instance'],
};
const ancestors = hierarchy[this.ClassName] || [];
return ancestors.includes(className);
}
Destroy() {
if (this.Parent && this.Parent.Children) {
const idx = this.Parent.Children.indexOf(this);
if (idx >= 0) this.Parent.Children.splice(idx, 1);
}
this.Parent = null;
this.__destroyed = true;
}
Clone() {
const cl = new RbxInstance(this.ClassName);
cl.Name = this.Name;
cl.__props = JSON.parse(JSON.stringify(this.__props));
for (const c of this.Children) {
const cc = c.Clone();
cc.Parent = cl;
cl.Children.push(cc);
}
return cl;
}
GetPropertyChangedSignal(propName) {
const sigName = `Changed:${propName}`;
if (!this.__signals[sigName]) this.__signals[sigName] = new RbxSignal(sigName);
return this.__signals[sigName];
}
}
/* ──────── Part — наследник Instance с реальными свойствами сцены ──────── */
class RbxPart extends RbxInstance {
constructor(primId, init = {}) {
super(init.ClassName || 'Part', init);
this.__primId = primId; // id примитива в Rublox-сцене
this.__sendFn = null; // setter из shim init
// Кешированные свойства (mirror'ятся через handleTick)
this._snap = init.snap || {};
}
get Position() {
return new RbxVector3(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
}
set Position(v) {
if (v instanceof RbxVector3) {
this._snap.x = v.X; this._snap.y = v.Y; this._snap.z = v.Z;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'position', value: { x: v.X, y: v.Y, z: v.Z } });
}
}
get CFrame() {
return new RbxCFrame(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
}
set CFrame(cf) {
if (cf instanceof RbxCFrame) {
this._snap.x = cf.X; this._snap.y = cf.Y; this._snap.z = cf.Z;
const [rx, ry, rz] = cf.toEulerXYZ();
this._snap.rx = rx; this._snap.ry = ry; this._snap.rz = rz;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'cframe', value: { x: cf.X, y: cf.Y, z: cf.Z, rx, ry, rz } });
}
}
get Size() {
return new RbxVector3(this._snap.sx || 1, this._snap.sy || 1, this._snap.sz || 1);
}
set Size(v) {
if (v instanceof RbxVector3) {
this._snap.sx = v.X; this._snap.sy = v.Y; this._snap.sz = v.Z;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'size', value: { sx: v.X, sy: v.Y, sz: v.Z } });
}
}
get Color() { return RbxColor3.fromHex(this._snap.color || '#cccccc'); }
set Color(c) {
if (c instanceof RbxColor3) {
const hex = c.toHex();
this._snap.color = hex;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'color', value: hex });
}
}
get BrickColor() { return { Color: this.Color, Name: 'Medium stone grey' }; }
set BrickColor(b) { if (b && b.Color) this.Color = b.Color; }
get Material() { return this._snap.material || 'glossy'; }
set Material(m) {
this._snap.material = m;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'material', value: m });
}
get Anchored() { return !!this._snap.anchored; }
set Anchored(v) {
this._snap.anchored = !!v;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'anchored', value: !!v });
}
get CanCollide() { return this._snap.canCollide !== false; }
set CanCollide(v) {
this._snap.canCollide = !!v;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'canCollide', value: !!v });
}
get Transparency() { return 1.0 - (this._snap.opacity ?? 1.0); }
set Transparency(v) {
this._snap.opacity = 1.0 - (+v || 0);
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'opacity', value: this._snap.opacity });
}
get Velocity() { return new RbxVector3(0, 0, 0); }
set Velocity(v) {
if (v instanceof RbxVector3) {
this.__sendFn?.('partVel', { primId: this.__primId, vx: v.X, vy: v.Y, vz: v.Z });
}
}
}
/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */
export function registerRobloxApi(lua, ctx) {
const { getSceneSnap, targetPrimitiveId, send, getGuiTree, scheduler } = ctx;
// 1. Math classes — как глобалы с .new factory
const wrap = (cls) => ({
new: (...args) => new cls(...args),
});
lua.global.set('Vector3', {
new: (x, y, z) => new RbxVector3(x, y, z),
zero: new RbxVector3(0, 0, 0),
one: new RbxVector3(1, 1, 1),
xAxis: new RbxVector3(1, 0, 0),
yAxis: new RbxVector3(0, 1, 0),
zAxis: new RbxVector3(0, 0, 1),
});
lua.global.set('Color3', {
new: (r, g, b) => new RbxColor3(r, g, b),
fromRGB: RbxColor3.fromRGB,
fromHex: RbxColor3.fromHex,
});
lua.global.set('CFrame', {
new: RbxCFrame.new,
Angles: RbxCFrame.Angles,
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
});
lua.global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
lua.global.set('UDim2', {
new: RbxUDim2.new,
fromScale: RbxUDim2.fromScale,
fromOffset: RbxUDim2.fromOffset,
});
// 2. Сцена — собираем JS-структуру из snap'а
// Workspace — корень.
const workspace = new RbxInstance('Workspace', { Name: 'Workspace' });
const part_by_id = new Map();
const snap = getSceneSnap();
if (snap && snap.primitives) {
for (const [id, p] of Object.entries(snap.primitives)) {
const part = new RbxPart(+id, {
ClassName: p.type === 'wedge' ? 'WedgePart' :
p.type === 'cornerwedge' ? 'CornerWedgePart' : 'Part',
Name: p.name || 'Part',
snap: { ...p },
});
part.__sendFn = send;
// Сигналы Part: Touched/TouchEnded существуют на каждом по умолчанию
part.Touched = new RbxSignal('Touched');
part.TouchEnded = new RbxSignal('TouchEnded');
part.Parent = workspace;
workspace.Children.push(part);
part_by_id.set(+id, part);
}
}
// 2b. GUI-tree: предсоздаём ScreenGui + Frame/Button/Label/etc по дереву
// конвертера. Каждый button получает MouseButton1Click/MouseButton1Down/Up
// сигналы которые fire'аются из main через sendGlobalEvent('guiClick').
const gui_by_id = new Map();
// PlayerGui контейнер внутри Players.LocalPlayer
const playerGui = new RbxInstance('PlayerGui', { Name: 'PlayerGui' });
if (getGuiTree) {
const tree = getGuiTree() || [];
// первый проход — создаём instances
for (const el of tree) {
const cls = el.__roblox_class || 'Frame';
const inst = new RbxInstance(cls, { Name: el.name || cls });
inst.__guiId = el.id;
inst.Visible = el.visible !== false;
inst.Text = el.text || '';
// Стандартные сигналы кнопок
if (cls === 'TextButton' || cls === 'ImageButton') {
inst.MouseButton1Click = new RbxSignal('MouseButton1Click');
inst.MouseButton1Down = new RbxSignal('MouseButton1Down');
inst.MouseButton1Up = new RbxSignal('MouseButton1Up');
inst.Activated = new RbxSignal('Activated');
inst.MouseEnter = new RbxSignal('MouseEnter');
inst.MouseLeave = new RbxSignal('MouseLeave');
}
// FocusLost для textboxes
if (cls === 'TextBox') {
inst.FocusLost = new RbxSignal('FocusLost');
inst.Focused = new RbxSignal('Focused');
}
// Changed-сигнал у каждого
inst.Changed = new RbxSignal('Changed');
gui_by_id.set(el.id, inst);
}
// второй проход — parent-связи (parentId → Instance)
for (const el of tree) {
const inst = gui_by_id.get(el.id);
if (!inst) continue;
const parentInst = el.parentId ? gui_by_id.get(el.parentId) : playerGui;
if (parentInst) {
inst.Parent = parentInst;
parentInst.Children.push(inst);
}
}
}
// 3. script — в shared-режиме не глобал, а локально создаётся при addScript.
// Здесь только заглушка чтобы простые non-shared скрипты не падали.
if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) {
const parentPart = part_by_id.get(targetPrimitiveId);
const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' });
scriptInst.Parent = parentPart;
parentPart.Children.push(scriptInst);
lua.global.set('script', scriptInst);
}
// 4. game / game:GetService
const services = new Map();
const game = new RbxInstance('DataModel', { Name: 'Game' });
game.Children.push(workspace);
workspace.Parent = game;
// Builtin services:
const lighting = new RbxInstance('Lighting', { Name: 'Lighting' });
lighting.Parent = game;
game.Children.push(lighting);
services.set('Lighting', lighting);
const replicatedStorage = new RbxInstance('ReplicatedStorage', { Name: 'ReplicatedStorage' });
replicatedStorage.Parent = game;
game.Children.push(replicatedStorage);
services.set('ReplicatedStorage', replicatedStorage);
const runService = new RbxInstance('RunService', { Name: 'RunService' });
runService.Heartbeat = new RbxSignal('Heartbeat');
runService.Stepped = new RbxSignal('Stepped');
runService.RenderStepped = new RbxSignal('RenderStepped');
services.set('RunService', runService);
const playersService = new RbxInstance('Players', { Name: 'Players' });
playersService.PlayerAdded = new RbxSignal('PlayerAdded');
playersService.PlayerRemoving = new RbxSignal('PlayerRemoving');
// LocalPlayer с PlayerGui + Character
const localPlayer = new RbxInstance('Player', { Name: 'Player1' });
localPlayer.UserId = 1;
localPlayer.PlayerGui = playerGui;
playerGui.Parent = localPlayer;
localPlayer.Children.push(playerGui);
// Character заглушка с Humanoid и HumanoidRootPart
const character = new RbxInstance('Model', { Name: 'Character' });
const humanoid = new RbxInstance('Humanoid', { Name: 'Humanoid' });
humanoid.WalkSpeed = 16;
humanoid.JumpPower = 50;
humanoid.Health = 100;
humanoid.MaxHealth = 100;
humanoid.Died = new RbxSignal('Died');
humanoid.HealthChanged = new RbxSignal('HealthChanged');
humanoid.Touched = new RbxSignal('Touched');
humanoid.Parent = character;
character.Children.push(humanoid);
character.Humanoid = humanoid;
const hrp = new RbxPart(-1, { ClassName: 'Part', Name: 'HumanoidRootPart' });
hrp.Touched = new RbxSignal('Touched');
hrp.Parent = character;
character.Children.push(hrp);
character.HumanoidRootPart = hrp;
localPlayer.Character = character;
localPlayer.CharacterAdded = new RbxSignal('CharacterAdded');
localPlayer.CharacterRemoving = new RbxSignal('CharacterRemoving');
playersService.LocalPlayer = localPlayer;
playersService.Children.push(localPlayer);
services.set('Players', playersService);
game.GetService = function(svc) {
if (services.has(svc)) return services.get(svc);
if (svc === 'Workspace') return workspace;
if (svc === 'Workspace') return workspace;
// Неизвестный сервис — создаём заглушку, чтобы не падало
const stub = new RbxInstance(svc, { Name: svc });
services.set(svc, stub);
return stub;
};
game.Workspace = workspace;
game.Lighting = lighting;
game.Players = playersService;
game.ReplicatedStorage = replicatedStorage;
lua.global.set('game', game);
lua.global.set('workspace', workspace);
lua.global.set('Workspace', workspace);
// 5. Instance.new
lua.global.set('Instance', {
new: (className, parent) => {
const inst = new RbxInstance(className);
if (parent && parent instanceof RbxInstance) {
inst.Parent = parent;
parent.Children.push(inst);
}
return inst;
},
});
// 6. wait/task.wait через scheduler. scheduler — main-side, поддерживает
// schedule(sec, fn) что fire'ит fn после задержки в следующих tick'ах.
// spawn/delay/defer запускают функцию через scheduler.spawn (отдельная корутина).
const sched = scheduler || {
schedule: (sec, fn) => { try { fn(); } catch (e) {} },
spawn: (fn) => { try { fn(); } catch (e) {} },
now: () => Date.now() / 1000,
};
lua.global.set('wait', (sec) => {
// В корутине: yield на (sec || 0). Scheduler сам resume'ит.
// Тут мы синхронны (вызов из Lua) — реальный yield делается в lua-wrapper
// через coroutine.yield, который мы оборачиваем в addScript.
// Здесь просто возвращаем длительность для совместимости.
return [sec || 0, 0];
});
lua.global.set('task', {
wait: (sec) => sec || 0,
spawn: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
delay: (sec, fn, ...args) => { sched.schedule(sec || 0, () => { try { fn(...args); } catch (e) {} }); return null; },
defer: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
});
lua.global.set('spawn', (fn) => { sched.spawn(() => { try { fn(); } catch (e) {} }); });
lua.global.set('delay', (sec, fn) => { sched.schedule(sec || 0, () => { try { fn(); } catch (e) {} }); });
// require(ModuleScript) — возвращаем nil, debug.setmetatable перехватит.
lua.global.set('require', (_arg) => undefined);
lua.global.set('tick', () => Date.now() / 1000);
lua.global.set('time', () => Date.now() / 1000);
lua.global.set('elapsedTime', () => Date.now() / 1000);
// 7. print / warn / error — пробрасываем в main как log
lua.global.set('print', (...args) => {
const text = args.map(a => luaToString(a)).join('\t');
send('log', { level: 'info', text });
});
lua.global.set('warn', (...args) => {
const text = args.map(a => luaToString(a)).join('\t');
send('log', { level: 'warn', text });
});
// 8. Enum — упрощённая заглушка для самых популярных enums
const enumTable = {
Material: { Plastic: { Value: 256, Name: 'Plastic' }, Neon: { Value: 784, Name: 'Neon' },
Metal: { Value: 512, Name: 'Metal' }, Glass: { Value: 1024, Name: 'Glass' },
Wood: { Value: 272, Name: 'Wood' }, SmoothPlastic: { Value: 496, Name: 'SmoothPlastic' } },
PartType: { Ball: { Value: 0, Name: 'Ball' }, Block: { Value: 1, Name: 'Block' },
Cylinder: { Value: 2, Name: 'Cylinder' } },
KeyCode: { Space: { Value: 32, Name: 'Space' }, W: { Value: 87, Name: 'W' },
A: { Value: 65, Name: 'A' }, S: { Value: 83, Name: 'S' }, D: { Value: 68, Name: 'D' } },
EasingStyle: { Linear: { Value: 0, Name: 'Linear' }, Quad: { Value: 1, Name: 'Quad' },
Sine: { Value: 5, Name: 'Sine' } },
EasingDirection: { In: { Value: 0, Name: 'In' }, Out: { Value: 1, Name: 'Out' },
InOut: { Value: 2, Name: 'InOut' } },
};
lua.global.set('Enum', enumTable);
return { workspace, game, part_by_id, services, gui_by_id, localPlayer, character, humanoid };
}
function luaToString(v) {
if (v == null) return 'nil';
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
if (typeof v === 'boolean') return String(v);
if (v.toString) return v.toString();
return '<object>';
}
export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal };

View File

@ -1,204 +0,0 @@
/**
* roblox-tween.js TweenService для Roblox Lua-shim.
*
* Использование в Lua:
* local TS = game:GetService("TweenService")
* local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
* local tween = TS:Create(part, info, {Position = Vector3.new(0, 10, 0)})
* tween:Play()
* tween.Completed:Connect(function() print("done") end)
*
* Реализация:
* - Все активные tween'ы держатся в этом модуле.
* - На каждом tick() прогрессируется alpha = (now - startTime) / duration.
* - Применяется easing-кривая, и обновляется свойство объекта через __sendFn.
* - При alpha >= 1 fire Completed signal и удаляем tween.
*/
import { RbxSignal, RbxVector3, RbxColor3, RbxCFrame, RbxUDim2 } from './roblox-shim.js';
/* ──────── EasingStyle / Direction ──────── */
const EASING_FNS = {
'Linear': (t) => t,
'Quad': (t) => t * t,
'Cubic': (t) => t * t * t,
'Quart': (t) => t * t * t * t,
'Quint': (t) => t * t * t * t * t,
'Sine': (t) => 1 - Math.cos((t * Math.PI) / 2),
'Bounce': (t) => {
const n1 = 7.5625, d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; }
if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; }
t -= 2.625 / d1; return n1 * t * t + 0.984375;
},
'Elastic': (t) => {
if (t === 0) return 0;
if (t === 1) return 1;
return -(2 ** (10 * (t - 1))) * Math.sin((t - 1.1) * 5 * Math.PI);
},
'Back': (t) => t * t * (2.70158 * t - 1.70158),
'Exponential': (t) => t === 0 ? 0 : 2 ** (10 * (t - 1)),
};
function applyDirection(t, direction) {
if (direction === 'In') return t;
if (direction === 'Out') return 1 - (1 - t);
if (direction === 'InOut') {
return t < 0.5 ? t * 2 : (1 - (1 - t) * 2);
}
return t;
}
function easeValue(alpha, style, direction) {
const styleFn = EASING_FNS[style] || EASING_FNS.Linear;
if (direction === 'In') return styleFn(alpha);
if (direction === 'Out') return 1 - styleFn(1 - alpha);
// InOut
if (alpha < 0.5) return styleFn(alpha * 2) / 2;
return 1 - styleFn((1 - alpha) * 2) / 2;
}
/* ──────── TweenInfo ──────── */
class RbxTweenInfo {
constructor(time = 1, easingStyle = 'Quad', easingDirection = 'Out',
repeatCount = 0, reverses = false, delayTime = 0) {
this.Time = +time || 0;
this.EasingStyle = typeof easingStyle === 'object' ? easingStyle.Name : easingStyle;
this.EasingDirection = typeof easingDirection === 'object' ? easingDirection.Name : easingDirection;
this.RepeatCount = repeatCount | 0;
this.Reverses = !!reverses;
this.DelayTime = +delayTime || 0;
}
}
/* ──────── Tween ──────── */
class RbxTween {
constructor(instance, info, goalProps, manager) {
this.Instance = instance;
this.TweenInfo = info;
this.GoalProps = goalProps;
this._manager = manager;
this._startTime = null;
this._fromProps = null;
this._playing = false;
this._completed = false;
this.Completed = new RbxSignal('Completed');
this.PlaybackState = 'Begin';
}
Play() {
if (this._playing) return;
// Снимок старых значений
this._fromProps = {};
for (const k of Object.keys(this.GoalProps)) {
this._fromProps[k] = this.Instance[k]; // через getter Part'а
}
this._startTime = this._manager.time;
this._playing = true;
this.PlaybackState = 'Playing';
this._manager._add(this);
}
Pause() { this._playing = false; this.PlaybackState = 'Paused'; }
Cancel() {
this._playing = false;
this.PlaybackState = 'Cancelled';
this._manager._remove(this);
}
/** internal — вызывается из manager.tick */
_step(now) {
if (!this._playing) return false;
const elapsed = now - this._startTime;
const dur = this.TweenInfo.Time || 0.001;
let alpha = Math.min(1, Math.max(0, elapsed / dur));
const ea = easeValue(alpha, this.TweenInfo.EasingStyle, this.TweenInfo.EasingDirection);
for (const k of Object.keys(this.GoalProps)) {
const from = this._fromProps[k];
const to = this.GoalProps[k];
const interp = interpolate(from, to, ea);
// Set через setter в Part — он отправит partSet в main
try { this.Instance[k] = interp; } catch (e) {}
}
if (alpha >= 1) {
this._playing = false;
this._completed = true;
this.PlaybackState = 'Completed';
this.Completed.Fire('Completed');
return true; // удалить из активных
}
return false;
}
}
function interpolate(from, to, a) {
if (from instanceof RbxVector3 && to instanceof RbxVector3) {
return from.Lerp(to, a);
}
if (from instanceof RbxColor3 && to instanceof RbxColor3) {
return from.Lerp(to, a);
}
if (from instanceof RbxCFrame && to instanceof RbxCFrame) {
return from.Lerp(to, a);
}
if (typeof from === 'number' && typeof to === 'number') {
return from + (to - from) * a;
}
// Иначе ничего не интерполируем
return a >= 1 ? to : from;
}
/* ──────── Manager ──────── */
export class RobloxTweenManager {
constructor() {
this.active = new Set();
this.time = 0;
}
install(lua) {
const self = this;
// TweenInfo конструктор
lua.global.set('TweenInfo', {
new: (time, style, direction, repeat_, reverses, delay_) =>
new RbxTweenInfo(time, style, direction, repeat_, reverses, delay_),
});
// Сервис: добавляем в services через game:GetService('TweenService')
// (services map передаётся в shim — но мы не имеем к нему доступа здесь;
// делаем по-другому: регистрируем сразу глобал TweenService который
// совместим с GetService('TweenService'))
const tweenService = {
ClassName: 'TweenService',
Name: 'TweenService',
Create(instance, info, goalProps) {
return new RbxTween(instance, info, goalProps, self);
},
};
lua.global.set('__tweenService', tweenService);
// и в game.GetService — мы делаем монки-патч если игра уже есть:
const game = lua.global.get('game');
if (game && typeof game.GetService === 'function') {
const origGetService = game.GetService;
game.GetService = function(svc) {
if (svc === 'TweenService') return tweenService;
return origGetService.call(this, svc);
};
}
}
_add(tween) { this.active.add(tween); }
_remove(tween) { this.active.delete(tween); }
tick(dtSec) {
this.time += +dtSec || 0;
for (const t of [...this.active]) {
const done = t._step(this.time);
if (done) this.active.delete(t);
}
}
}
export { RbxTweenInfo, RbxTween };

View File

@ -0,0 +1,249 @@
/**
* lua-monaco-setup регистрация Lua-фич в Monaco:
* 1) Подсветка через встроенный 'lua' language (Monaco поставляется с basic-languages/lua)
* 2) Автодополнение Roblox-API (Vector3.new, Color3.fromRGB, script.Parent, game.Players, ...)
* 3) Hover-документация (наведя на Vector3 описание + пример)
* 4) Подсветка ошибок через luaparse (на этапе 7, опционально)
*
* Регистрируется ОДИН раз глобально через флаг monaco.__rbxLuaRegistered.
*/
const ROBLOX_LUA_API = [
// === Глобальные функции ===
{ kind: 'function', name: 'print', insertText: 'print($0)', doc: 'Выводит сообщения в Output-панель.\n```lua\nprint("Привет", x, y)\n```' },
{ kind: 'function', name: 'warn', insertText: 'warn($0)', doc: 'Выводит предупреждение (жёлтым).\n```lua\nwarn("Что-то не так")\n```' },
{ kind: 'function', name: 'error', insertText: 'error(${1:"сообщение"})', doc: 'Бросает ошибку, останавливая текущий скрипт.\n```lua\nerror("Здоровье < 0")\n```' },
{ kind: 'function', name: 'wait', insertText: 'wait(${1:1})', doc: 'Приостанавливает скрипт на N секунд (заменяется на `task.wait` в новом коде).' },
{ kind: 'function', name: 'tick', insertText: 'tick()', doc: 'Возвращает количество секунд с эпохи (как `os.time()`, но дробное).' },
{ kind: 'function', name: 'pcall', insertText: 'pcall(${1:fn}, $0)', doc: 'Защищённый вызов. Возвращает `success, result|error`.\n```lua\nlocal ok, err = pcall(function() risky() end)\nif not ok then warn(err) end\n```' },
{ kind: 'function', name: 'xpcall', insertText: 'xpcall(${1:fn}, ${2:handler})', doc: 'Защищённый вызов с кастомным обработчиком ошибки.' },
{ kind: 'function', name: 'tostring', insertText: 'tostring($0)', doc: 'Преобразует значение в строку.' },
{ kind: 'function', name: 'tonumber', insertText: 'tonumber($0)', doc: 'Преобразует строку в число. Возвращает nil если не число.' },
{ kind: 'function', name: 'type', insertText: 'type($0)', doc: 'Возвращает строку с типом: "nil", "number", "string", "boolean", "table", "function", "userdata".' },
{ kind: 'function', name: 'typeof', insertText: 'typeof($0)', doc: 'Расширенная версия type — для Roblox-типов вернёт "Vector3", "CFrame", "Color3", "Instance".' },
{ kind: 'function', name: 'ipairs', insertText: 'ipairs(${1:t})', doc: 'Итератор по числовым ключам массива.\n```lua\nfor i, v in ipairs(arr) do ... end\n```' },
{ kind: 'function', name: 'pairs', insertText: 'pairs(${1:t})', doc: 'Итератор по всем ключам таблицы.\n```lua\nfor k, v in pairs(t) do ... end\n```' },
{ kind: 'function', name: 'next', insertText: 'next(${1:t}, $0)', doc: 'Возвращает следующую пару ключ-значение в таблице.' },
{ kind: 'function', name: 'select', insertText: 'select(${1:1}, $0)', doc: 'select("#", ...) — количество аргументов. select(n, ...) — n-й и далее аргументы.' },
{ kind: 'function', name: 'unpack', insertText: 'unpack(${1:t})', doc: 'Распаковывает массив в значения. (В Lua 5.4 — `table.unpack`)' },
{ kind: 'function', name: 'setmetatable', insertText: 'setmetatable(${1:t}, ${2:mt})', doc: 'Устанавливает metatable для таблицы.' },
{ kind: 'function', name: 'getmetatable', insertText: 'getmetatable($0)', doc: 'Возвращает metatable или nil.' },
{ kind: 'function', name: 'rawget', insertText: 'rawget(${1:t}, ${2:key})', doc: 'Чтение без вызова __index metatable.' },
{ kind: 'function', name: 'rawset', insertText: 'rawset(${1:t}, ${2:key}, ${3:value})', doc: 'Запись без вызова __newindex metatable.' },
// === task.* ===
{ kind: 'module', name: 'task', insertText: 'task', doc: 'Современный API планировщика Roblox-Lua.\nЗаменяет `wait`, `spawn`, `delay`, `defer` из старого API.' },
{ kind: 'function', name: 'task.wait', insertText: 'task.wait(${1:1})', doc: 'Приостанавливает на N секунд.\nВозвращает фактическое время ожидания.\n```lua\nlocal dt = task.wait(0.5)\n```' },
{ kind: 'function', name: 'task.spawn', insertText: 'task.spawn(${1:function() end})', doc: 'Немедленно запускает функцию как coroutine.\n```lua\ntask.spawn(function() heavy() end)\n```' },
{ kind: 'function', name: 'task.delay', insertText: 'task.delay(${1:1}, ${2:function() end})', doc: 'Отложенный запуск функции через N секунд.\n```lua\ntask.delay(3, function() print("через 3 сек") end)\n```' },
{ kind: 'function', name: 'task.defer', insertText: 'task.defer(${1:function() end})', doc: 'Запуск в следующем кадре (после Heartbeat).' },
// === Vector3 ===
{ kind: 'class', name: 'Vector3', insertText: 'Vector3', doc: '3D-вектор в Roblox.\nКонструктор: `Vector3.new(x, y, z)`.\nКонстанты: `Vector3.zero`, `Vector3.one`, `Vector3.xAxis`, `Vector3.yAxis`, `Vector3.zAxis`.' },
{ kind: 'function', name: 'Vector3.new', insertText: 'Vector3.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт `Vector3(x, y, z)`.\n```lua\nlocal v = Vector3.new(10, 5, 0)\nprint(v.X, v.Y, v.Z, v.Magnitude)\n```' },
{ kind: 'function', name: 'Vector3.zero', insertText: 'Vector3.zero', doc: '`Vector3(0, 0, 0)`.' },
{ kind: 'function', name: 'Vector3.one', insertText: 'Vector3.one', doc: '`Vector3(1, 1, 1)`.' },
{ kind: 'function', name: 'Vector3.xAxis', insertText: 'Vector3.xAxis', doc: '`Vector3(1, 0, 0)`.' },
{ kind: 'function', name: 'Vector3.yAxis', insertText: 'Vector3.yAxis', doc: '`Vector3(0, 1, 0)`.' },
{ kind: 'function', name: 'Vector3.zAxis', insertText: 'Vector3.zAxis', doc: '`Vector3(0, 0, 1)`.' },
// === Color3 ===
{ kind: 'class', name: 'Color3', insertText: 'Color3', doc: 'Цвет RGB в Roblox.\nКомпоненты `R`, `G`, `B` в диапазоне [0, 1].' },
{ kind: 'function', name: 'Color3.new', insertText: 'Color3.new(${1:1}, ${2:1}, ${3:1})', doc: 'Создаёт `Color3(r, g, b)`, где компоненты в [0, 1].' },
{ kind: 'function', name: 'Color3.fromRGB', insertText: 'Color3.fromRGB(${1:255}, ${2:255}, ${3:255})', doc: 'Создаёт `Color3` из 0-255 RGB.\n```lua\nlocal red = Color3.fromRGB(255, 0, 0)\n```' },
{ kind: 'function', name: 'Color3.fromHSV', insertText: 'Color3.fromHSV(${1:0}, ${2:1}, ${3:1})', doc: 'Создаёт цвет из HSV-компонентов в [0, 1].' },
{ kind: 'function', name: 'Color3.fromHex', insertText: 'Color3.fromHex(${1:"#FF0000"})', doc: 'Создаёт цвет из hex-строки.' },
// === CFrame ===
{ kind: 'class', name: 'CFrame', insertText: 'CFrame', doc: 'Coordinate Frame — позиция + поворот в 3D.\nИспользуется для трансформаций Part.CFrame.' },
{ kind: 'function', name: 'CFrame.new', insertText: 'CFrame.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт CFrame в указанной позиции.' },
{ kind: 'function', name: 'CFrame.lookAt', insertText: 'CFrame.lookAt(${1:eye}, ${2:target})', doc: 'CFrame, направленный из eye на target.' },
{ kind: 'function', name: 'CFrame.Angles', insertText: 'CFrame.Angles(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame только с поворотом (в радианах).' },
{ kind: 'function', name: 'CFrame.fromEulerAnglesXYZ', insertText: 'CFrame.fromEulerAnglesXYZ(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame с поворотом по эйлеровым углам.' },
// === UDim2 / Vector2 ===
{ kind: 'class', name: 'UDim2', insertText: 'UDim2', doc: 'Размер/позиция GUI: процент + пиксели по обеим осям.' },
{ kind: 'function', name: 'UDim2.new', insertText: 'UDim2.new(${1:0}, ${2:0}, ${3:0}, ${4:0})', doc: '`UDim2.new(scaleX, offsetX, scaleY, offsetY)`.\n```lua\nframe.Position = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана\n```' },
{ kind: 'function', name: 'UDim2.fromScale', insertText: 'UDim2.fromScale(${1:0.5}, ${2:0.5})', doc: 'Только процентные размеры.' },
{ kind: 'function', name: 'UDim2.fromOffset', insertText: 'UDim2.fromOffset(${1:100}, ${2:100})', doc: 'Только пиксельные размеры.' },
{ kind: 'class', name: 'Vector2', insertText: 'Vector2', doc: '2D-вектор.' },
{ kind: 'function', name: 'Vector2.new', insertText: 'Vector2.new(${1:0}, ${2:0})', doc: '`Vector2(x, y)`.' },
{ kind: 'class', name: 'UDim', insertText: 'UDim', doc: 'Одномерная UDim (scale + offset).' },
{ kind: 'function', name: 'UDim.new', insertText: 'UDim.new(${1:0}, ${2:0})', doc: '`UDim.new(scale, offset)`.' },
// === Instance ===
{ kind: 'class', name: 'Instance', insertText: 'Instance', doc: 'Базовый класс всех объектов Roblox.' },
{ kind: 'function', name: 'Instance.new', insertText: 'Instance.new("${1:Part}", ${2:workspace})', doc: 'Создаёт новый объект указанного класса.\n```lua\nlocal part = Instance.new("Part", workspace)\npart.Size = Vector3.new(4, 1, 4)\npart.Position = Vector3.new(0, 10, 0)\n```' },
// === game / services ===
{ kind: 'variable', name: 'game', insertText: 'game', doc: 'Корень DataModel. `game:GetService("Players")` — доступ к сервисам.' },
{ kind: 'variable', name: 'workspace', insertText: 'workspace', doc: 'Сокращение для `game.Workspace`. Содержит все Part-объекты сцены.' },
{ kind: 'variable', name: 'script', insertText: 'script', doc: 'Текущий скрипт. `script.Parent` — объект-носитель.\n```lua\nlocal part = script.Parent\npart.Touched:Connect(function(hit) ... end)\n```' },
// === Enum ===
{ kind: 'enum', name: 'Enum', insertText: 'Enum', doc: 'Перечисления Roblox: KeyCode, Material, UserInputType, EasingStyle, EasingDirection, HumanoidStateType.' },
{ kind: 'enum', name: 'Enum.KeyCode', insertText: 'Enum.KeyCode.${1:W}', doc: 'Клавиши клавиатуры: W, A, S, D, Space, LeftShift, Q, E, F, R, T, ..., One, Two, ..., Up, Down.' },
{ kind: 'enum', name: 'Enum.UserInputType', insertText: 'Enum.UserInputType.${1:MouseButton1}', doc: 'Типы ввода: MouseButton1/2/3, Keyboard, Touch, MouseMovement, MouseWheel.' },
{ kind: 'enum', name: 'Enum.Material', insertText: 'Enum.Material.${1:Plastic}', doc: 'Материалы: Plastic, Wood, Metal, Neon, Glass, Sand, Ice, Grass, Concrete.' },
{ kind: 'enum', name: 'Enum.HumanoidStateType', insertText: 'Enum.HumanoidStateType.${1:Running}', doc: 'Состояния Humanoid: Running, Jumping, Freefall, Landed, Dead, Climbing, Swimming, Seated.' },
];
// === Сниппеты быстрого старта (готовые шаблоны) ===
const ROBLOX_LUA_SNIPPETS = [
{
label: 'killbrick',
documentation: 'KillBrick — убивает игрока при касании.',
insertText: [
'local part = script.Parent',
'part.Touched:Connect(function(hit)',
'\tlocal humanoid = hit.Parent:FindFirstChildOfClass("Humanoid")',
'\tif humanoid then',
'\t\thumanoid.Health = 0',
'\tend',
'end)',
].join('\n'),
},
{
label: 'teleportpad',
documentation: 'TeleportPad — телепортирует игрока в указанную точку.',
insertText: [
'local destination = Vector3.new(${1:0}, ${2:50}, ${3:0})',
'local pad = script.Parent',
'pad.Touched:Connect(function(hit)',
'\tlocal root = hit.Parent:FindFirstChild("HumanoidRootPart")',
'\tif root then',
'\t\troot.CFrame = CFrame.new(destination)',
'\tend',
'end)',
].join('\n'),
},
{
label: 'coin',
documentation: 'Coin — даёт игроку монету при касании, потом исчезает.',
insertText: [
'local coin = script.Parent',
'local collected = false',
'coin.Touched:Connect(function(hit)',
'\tif collected then return end',
'\tif hit.Parent:FindFirstChildOfClass("Humanoid") then',
'\t\tcollected = true',
'\t\tprint("Монета собрана!")',
'\t\tcoin:Destroy()',
'\tend',
'end)',
].join('\n'),
},
{
label: 'heartbeat',
documentation: 'RunService.Heartbeat — кадровый callback.',
insertText: [
'local RunService = game:GetService("RunService")',
'RunService.Heartbeat:Connect(function(dt)',
'\t${0:-- код, выполняется каждый кадр}',
'end)',
].join('\n'),
},
{
label: 'playeradded',
documentation: 'PlayerAdded — реакция на захождение игрока.',
insertText: [
'local Players = game:GetService("Players")',
'Players.PlayerAdded:Connect(function(player)',
'\tprint("Игрок зашёл:", player.Name)',
'\t${0:}',
'end)',
].join('\n'),
},
{
label: 'spinpart',
documentation: 'SpinPart — вращающаяся платформа.',
insertText: [
'local RunService = game:GetService("RunService")',
'local part = script.Parent',
'local speed = ${1:2} -- радиан/сек',
'RunService.Heartbeat:Connect(function(dt)',
'\tpart.CFrame = part.CFrame * CFrame.Angles(0, speed * dt, 0)',
'end)',
].join('\n'),
},
];
export function registerLuaInMonaco(monaco) {
if (monaco.__rbxLuaRegistered) return;
monaco.__rbxLuaRegistered = true;
// 1. CompletionProvider — автодополнение
monaco.languages.registerCompletionItemProvider('lua', {
triggerCharacters: ['.', ':', '"', "'"],
provideCompletionItems: (model, position) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = [];
const kindMap = {
'function': monaco.languages.CompletionItemKind.Function,
'class': monaco.languages.CompletionItemKind.Class,
'module': monaco.languages.CompletionItemKind.Module,
'enum': monaco.languages.CompletionItemKind.Enum,
'variable': monaco.languages.CompletionItemKind.Variable,
};
for (const item of ROBLOX_LUA_API) {
suggestions.push({
label: item.name,
kind: kindMap[item.kind] || monaco.languages.CompletionItemKind.Text,
insertText: item.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: item.doc, isTrusted: true },
range,
});
}
for (const snip of ROBLOX_LUA_SNIPPETS) {
suggestions.push({
label: snip.label,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: snip.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: snip.documentation, isTrusted: true },
detail: 'Сниппет Roblox-Lua',
range,
});
}
return { suggestions };
},
});
// 2. HoverProvider — подсказки при наведении
const lookupTable = new Map();
for (const item of ROBLOX_LUA_API) lookupTable.set(item.name, item);
monaco.languages.registerHoverProvider('lua', {
provideHover: (model, position) => {
const word = model.getWordAtPosition(position);
if (!word) return null;
// Пробуем найти точное совпадение или с префиксом (Vector3.new)
let found = lookupTable.get(word.word);
if (!found) {
// Возможно курсор на середине A.B — попробуем собрать всю цепочку
const line = model.getLineContent(position.lineNumber);
// Ищем имя.имя.имя на позиции
const left = line.slice(0, word.endColumn - 1);
const m = left.match(/[A-Za-z_][\w.]*$/);
if (m) found = lookupTable.get(m[0]);
}
if (!found) return null;
return {
range: new monaco.Range(
position.lineNumber, word.startColumn,
position.lineNumber, word.endColumn,
),
contents: [
{ value: `**${found.name}**` },
{ value: found.doc },
],
};
},
});
}