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 отдельной задачей. # Большие png-ассеты вики (73МБ). Будут перенесены на CDN отдельной задачей.
/public/wiki/ /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() blob = upload.read()
if len(blob) > MAX_RBXL_SIZE: if len(blob) > MAX_RBXL_SIZE:
return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413 return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413
if not blob.startswith(b'<roblox!'): # Авто-детект XML vs Binary формата.
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400 # Бинарный: <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: 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: except Exception as e:
return jsonify({'error': f'parse failed: {e}'}), 422 return jsonify({'error': f'parse failed: {e}'}), 422
@ -199,6 +210,16 @@ def create():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
preview_hash = data.get('preview_hash') preview_hash = data.get('preview_hash')
title = (data.get('title') or '').strip() or 'Импортировано из Roblox' 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: if not preview_hash:
return jsonify({'error': 'preview_hash required'}), 400 return jsonify({'error': 'preview_hash required'}), 400
@ -263,6 +284,14 @@ def create():
# Подставляем URLs в project_data # Подставляем URLs в project_data
_resolve_asset_urls(project_data, asset_url_map) _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 # Создаём проект в kubikon3d_projects
# Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db. # Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db.
# Прямой INSERT — проще для MVP. id автогенерируется. # Прямой INSERT — проще для MVP. id автогенерируется.
@ -324,5 +353,66 @@ def _resolve_asset_urls(project_data: dict, asset_map: dict) -> None:
snd['url'] = asset_map[rid] 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__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=8690, debug=False) app.run(host='0.0.0.0', port=8690, debug=False)

View File

@ -103,19 +103,42 @@ SHAPE_TO_PRIMITIVE = {
# ────── BrickColor таблица (упрощённая) ────── # ────── BrickColor таблица (упрощённая) ──────
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые: # Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
BRICKCOLOR_TO_HEX = { BRICKCOLOR_TO_HEX = {
1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea', # Базовые тона
21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e', 1: '#f2f3f3', 2: '#a1a5a2', 3: '#f9e999', 5: '#d9e4f7',
28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', 9: '#9c9e9c', 11: '#e8eaea', 18: '#cc8e69', 21: '#c4281c',
101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', 23: '#0d69ac', 24: '#f5cd30', 26: '#1b2a35', 28: '#293f1a',
105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', 101: '#dab8a3',
111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', 105: '#cf8b3e',
141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', 111: '#a7a6a6',
199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8', 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', 1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000',
1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00', 1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff', 1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0', 1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80', 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': [], 'sounds': [],
'glbModels': [], 'glbModels': [],
'scripts': [], '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 и конвертим # Обходим все instances и конвертим
for inst in self.model.instances: for inst in self.model.instances:
self._convert_one(inst, scene) 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]: for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]:
self.stats.warnings.append(f"skipped {n}× {cls}") self.stats.warnings.append(f"skipped {n}× {cls}")
@ -308,8 +368,12 @@ class Converter:
elif cls == 'Workspace': elif cls == 'Workspace':
# Workspace = root, его свойства мапим на scene.worldSize и т.п. # Workspace = root, его свойства мапим на scene.worldSize и т.п.
pass pass
elif cls == 'Team':
# PvP-команда: имя + цвет в scene.teams[].
self._convert_team(inst, scene)
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui', elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
'StarterPack', 'StarterCharacterScripts', 'Players', 'StarterPack', 'StarterCharacterScripts', 'Players',
'Teams',
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage', 'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
'SoundService', 'TweenService', 'RunService', 'SoundService', 'TweenService', 'RunService',
'UserInputService', 'HttpService', 'DataStoreService', 'UserInputService', 'HttpService', 'DataStoreService',
@ -374,7 +438,9 @@ class Converter:
'canCollide': bool(props.get('CanCollide', True)), 'canCollide': bool(props.get('CanCollide', True)),
'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else 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)), '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, 'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
} }
@ -405,7 +471,9 @@ class Converter:
'material': material_to_string(props.get('Material')), 'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)), 'canCollide': bool(props.get('CanCollide', True)),
'visible': True, 'visible': True,
'anchored': bool(props.get('Anchored', False)), # FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0, 'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
}) })
@ -434,7 +502,9 @@ class Converter:
'material': material_to_string(props.get('Material')), 'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)), 'canCollide': bool(props.get('CanCollide', True)),
'visible': True, 'visible': True,
'anchored': bool(props.get('Anchored', False)), # FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0, 'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
}) })
@ -506,7 +576,9 @@ class Converter:
'material': material_to_string(props.get('Material')), 'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)), 'canCollide': bool(props.get('CanCollide', True)),
'visible': True, 'visible': True,
'anchored': bool(props.get('Anchored', False)), # FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0, 'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'MeshPart (no GLB) rbxid={rbx_id}', 'note': f'MeshPart (no GLB) rbxid={rbx_id}',
@ -527,7 +599,9 @@ class Converter:
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props), 'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)), 'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)), # FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'origin': 'roblox-meshpart', 'origin': 'roblox-meshpart',
'rbxAssetId': rbx_id, 'rbxAssetId': rbx_id,
}) })
@ -567,7 +641,9 @@ class Converter:
'material': material_to_string(props.get('Material')), 'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)), 'canCollide': bool(props.get('CanCollide', True)),
'visible': True, 'visible': True,
'anchored': bool(props.get('Anchored', False)), # FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0, 'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'Union (no CSG GLB) rbxid={rbx_id}', 'note': f'Union (no CSG GLB) rbxid={rbx_id}',
@ -586,7 +662,9 @@ class Converter:
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'], 'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props), 'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)), 'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)), # FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'origin': 'roblox-union', 'origin': 'roblox-union',
'rbxAssetId': rbx_id, 'rbxAssetId': rbx_id,
}) })
@ -594,15 +672,43 @@ class Converter:
# ─── Spawn ─── # ─── 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: def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
props = inst.properties props = inst.properties
cf = props.get('CFrame') cf = props.get('CFrame')
pos, _ = cframe_to_pos_rot(cf, self.scale) 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'] = { scene['spawnPoint'] = {
'x': pos['x'], 'x': pos['x'],
'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите 'y': pos['y'] + 5,
'z': pos['z'], 'z': pos['z'],
} }
@ -719,9 +825,13 @@ class Converter:
if not hasattr(self, '_screen_gui_refs'): if not hasattr(self, '_screen_gui_refs'):
self._screen_gui_refs = set() self._screen_gui_refs = set()
self._screen_gui_enabled = {} self._screen_gui_enabled = {}
self._screen_gui_kind = {} # ref → 'screen' | 'billboard' | 'surface'
self._screen_gui_refs.add(inst.referent) self._screen_gui_refs.add(inst.referent)
enabled = inst.properties.get('Enabled', True) enabled = inst.properties.get('Enabled', True)
self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else 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]: def _gui_parent_id(self, parent_ref) -> Optional[str]:
if parent_ref is None: if parent_ref is None:
@ -815,12 +925,14 @@ class Converter:
# элемент тоже невидим. # элемент тоже невидим.
parent_ref = inst.parent_referent parent_ref = inst.parent_referent
screen_enabled = True screen_enabled = True
container_kind = 'screen' # default
if hasattr(self, '_screen_gui_refs'): if hasattr(self, '_screen_gui_refs'):
cur = parent_ref cur = parent_ref
depth = 0 depth = 0
while cur is not None and depth < 50: while cur is not None and depth < 50:
if cur in self._screen_gui_refs: if cur in self._screen_gui_refs:
screen_enabled = self._screen_gui_enabled.get(cur, True) screen_enabled = self._screen_gui_enabled.get(cur, True)
container_kind = self._screen_gui_kind.get(cur, 'screen')
break break
# Поиск родителя cur в instances (если есть) # Поиск родителя cur в instances (если есть)
cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
@ -873,6 +985,10 @@ class Converter:
'imageAsset': None, 'imageAsset': None,
'zIndex': int(props.get('ZIndex', 1) or 1), 'zIndex': int(props.get('ZIndex', 1) or 1),
'origin': 'roblox-' + cls.lower(), 'origin': 'roblox-' + cls.lower(),
# 'screen' — обычный HUD; 'billboard' — 3D-табличка над частью;
# 'surface' — на грани Part. Last 2 рендерятся в 3D-сцене и
# сильно тормозят если их сотни.
'gui_container_kind': container_kind,
} }
scene['gui'].append(element) scene['gui'].append(element)

View File

@ -113,18 +113,28 @@ class CFrame:
matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22) matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22)
def to_euler_xyz(self) -> tuple: def to_euler_xyz(self) -> tuple:
"""Конверт 3x3 rotation matrix в Euler XYZ (radians). """Конверт 3x3 rotation matrix в Euler YXZ (Babylon convention).
Использует стандартную intrinsic XYZ rotation extraction: Babylon mesh.rotation = Vector3(rx, ry, rz) применяется в порядке YXZ
Rx = atan2(r21, r22) (rotate Y first, then X, then Z). Чтобы извлечь Euler из матрицы под
Ry = atan2(-r20, sqrt(r21² + r22²)) этот convention, используем формулу YXZ-extraction:
Rz = atan2(r10, r00) Rx = asin(-r12)
Ry = atan2(r02, r22)
Rz = atan2(r10, r11)
(имя метода to_euler_xyz сохраняем для совместимости вызовов.)
""" """
import math import math
r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix
rx = math.atan2(r21, r22) # Edge case: r12 близко к ±1 (gimbal lock на X = ±90°)
ry = math.atan2(-r20, math.sqrt(r21*r21 + r22*r22)) clamped = max(-1.0, min(1.0, -r12))
rz = math.atan2(r10, r00) 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) 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 Источник: https://dom.rojo.space/binary#cframe-orientation-ids
Это полная таблица 24-х валидных orientation id для cube symmetries. Формула из rbx-dom:
Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22). 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) где # Правильный порядок axes (rbx-dom):
# значения в {0,1,2,3,4,5} = +X, -X, +Y, -Y, +Z, -Z # 0=+X, 1=+Y, 2=+Z, 3=-X, 4=-Y, 5=-Z
AXES = [ AXES = [
(1, 0, 0), (-1, 0, 0), (1, 0, 0), # +X
(0, 1, 0), (0, -1, 0), (0, 1, 0), # +Y
(0, 0, 1), (0, 0, -1), (0, 0, 1), # +Z
(-1, 0, 0), # -X
(0, -1, 0), # -Y
(0, 0, -1), # -Z
] ]
# orientation_id = 1..24 (1-based) # orientation_id = 1..36 (некоторые комбинации rx==ry невалидны, в файлах
if not (1 <= orientation_id <= 24): # не встречаются — но id может доходить до 6*6 = 36, не 24).
# Неверный id — возвращаем identity if not (1 <= orientation_id <= 36):
return (1, 0, 0, 0, 1, 0, 0, 0, 1) return (1, 0, 0, 0, 1, 0, 0, 0, 1)
idx = orientation_id - 1 idx = orientation_id - 1
@ -571,16 +592,14 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple:
ry_idx = idx % 6 ry_idx = idx % 6
rx = AXES[rx_idx] rx = AXES[rx_idx]
ry = AXES[ry_idx] ry = AXES[ry_idx]
# rz = rx × ry (cross product) # rz = rx × ry (cross product) — третий столбец
rz = ( rz = (
rx[1] * ry[2] - rx[2] * ry[1], rx[1] * ry[2] - rx[2] * ry[1],
rx[2] * ry[0] - rx[0] * ry[2], rx[2] * ry[0] - rx[0] * ry[2],
rx[0] * ry[1] - rx[1] * ry[0], rx[0] * ry[1] - rx[1] * ry[0],
) )
# Матрица: первые 3 — first row (R_xx, R_yx, R_zx) # rx, ry, rz — это СТОЛБЦЫ матрицы.
# Сложновато; берём из rbx-dom convention: первые три — основа R*XAxis, # row-major: [r00=rx[0], r01=ry[0], r02=rz[0], r10=rx[1], r11=ry[1], r12=rz[1], ...]
# затем R*YAxis, затем R*ZAxis. Расширяем в row-major form.
# На практике: orientation вектора (rx, ry, rz) — это **столбцы** матрицы.
r00, r10, r20 = rx r00, r10, r20 = rx
r01, r11, r21 = ry r01, r11, r21 = ry
r02, r12, r22 = rz 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 ACHIVES_addres = BASE + '/api-achievs';
export const COMMENTS_addres = BASE + '/api-comments'; export const COMMENTS_addres = BASE + '/api-comments';
export const STORYS_addres = BASE + '/api-storys'; 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 RBXL_addres = BASE + '/api-rbxl';
export const NOTICES_addres = BASE + '/api-notices'; export const NOTICES_addres = BASE + '/api-notices';
export const HELP_addres = BASE + '/api-help'; 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 }. * Создаёт проект из 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`, { const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, {
method: 'POST', method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' }, 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) { if (!resp.ok) {
const text = await resp.text(); const text = await resp.text();

View File

@ -13,6 +13,8 @@ import { GAMES, GAME_GROUPS } from './docsGames';
import { LESSONS, hasLesson } from './docsLessons'; import { LESSONS, hasLesson } from './docsLessons';
import { buildGameProject } from './docsGamesBuilders'; import { buildGameProject } from './docsGamesBuilders';
import DocIcon from './docsIcons'; import DocIcon from './docsIcons';
import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang';
import { LUA_OVERRIDES } from './docsGamesBuildersLua';
/** /**
* KubikonDocs вика редактора Рублокс. * KubikonDocs вика редактора Рублокс.
@ -76,6 +78,7 @@ const KubikonDocs = () => {
return ( return (
<div className={cl.studio}> <div className={cl.studio}>
<style>{INLINE_STYLES}</style> <style>{INLINE_STYLES}</style>
<style>{DOCS_LANG_STYLES}</style>
{/* === Левая боковая панель === */} {/* === Левая боковая панель === */}
<aside className={cl.sidebar}> <aside className={cl.sidebar}>
@ -383,12 +386,15 @@ const ChapterPage = ({ chapter, mainRef }) => {
{/* Контент раздела */} {/* Контент раздела */}
<div className="docsContent"> <div className="docsContent">
{chapter.sections.map((s) => ( <DocsLangProvider>
<article key={s.id} id={`sec-${s.id}`} className="docsChapter"> <DocsLangPicker />
<h3 className="docsSectionTitle">{s.title}</h3> {chapter.sections.map((s) => (
<div className="docsSectionBody">{s.body}</div> <article key={s.id} id={`sec-${s.id}`} className="docsChapter">
</article> <h3 className="docsSectionTitle">{s.title}</h3>
))} <div className="docsSectionBody">{s.body}</div>
</article>
))}
</DocsLangProvider>
</div> </div>
</section> </section>
); );
@ -399,17 +405,20 @@ const ChapterPage = ({ chapter, mainRef }) => {
// //
const LessonPage = ({ game, navigate }) => { const LessonPage = ({ game, navigate }) => {
const lesson = LESSONS[game.id]; const lesson = LESSONS[game.id];
// 'idle' | 'creating' | 'error' // 'idle' | 'choosing' | 'creating' | 'error'
const [state, setState] = useState('idle'); const [state, setState] = useState('idle');
// Создаёт НОВУЮ копию игры-урока на текущем пользователе и // Шаг 1: юзер нажал «Открыть копию» показываем модалку выбора языка.
// открывает её в редакторе. Оригинал при этом ВСЕГДА цел. const openInEditor = () => {
const openInEditor = async () => {
const userId = getCurrentUserId(); const userId = getCurrentUserId();
if (!userId) { if (!userId) { setState('error'); return; }
setState('error'); setState('choosing');
return; };
}
// Шаг 2: язык выбран создаём копию с нужными скриптами и открываем.
const createCopyWithLang = async (lang) => {
const userId = getCurrentUserId();
if (!userId) { setState('error'); return; }
setState('creating'); setState('creating');
try { try {
// project_data копии берём двумя способами: // project_data копии берём двумя способами:
@ -422,9 +431,11 @@ const LessonPage = ({ game, navigate }) => {
const pd = orig && orig.data && orig.data.project_data; const pd = orig && orig.data && orig.data.project_data;
if (!pd) { setState('error'); return; } if (!pd) { setState('error'); return; }
// project_data может прийти строкой или объектом нормализуем в строку. // 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 { } else {
const project = buildGameProject(game.id); const project = buildGameProject(game.id, { lang });
if (!project) { setState('error'); return; } if (!project) { setState('error'); return; }
projectDataStr = JSON.stringify(project); projectDataStr = JSON.stringify(project);
} }
@ -477,6 +488,12 @@ const LessonPage = ({ game, navigate }) => {
: <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>} : <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
</button> </button>
</div> </div>
{state === 'choosing' && (
<LangChoiceModal
onPick={(lang) => createCopyWithLang(lang)}
onCancel={() => setState('idle')}
/>
)}
{state === 'error' && ( {state === 'error' && (
<div className="lessonErr"> <div className="lessonErr">
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт, Не получилось открыть игру. Проверь, что ты вошёл в аккаунт,
@ -484,14 +501,134 @@ const LessonPage = ({ game, navigate }) => {
</div> </div>
)} )}
{/* Тело урока */} {/* Тело урока с переключателем JS/Lua */}
<article className="docsChapter lessonBody"> <article className="docsChapter lessonBody">
<div className="docsSectionBody">{lesson.body}</div> <DocsLangProvider>
<DocsLangPicker />
<LuaLessonBanner gameId={game.id} />
<div className="docsSectionBody">{lesson.body}</div>
</DocsLangProvider>
</article> </article>
</section> </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 b { color: #0f172a; font-weight: 800; }
.docsSectionBody h4 { font-family: inherit; } .docsSectionBody h4 { font-family: inherit; }
.docsSectionBody code { .docsSectionBody code {
background: #e0e8ff; background: #fff5e0;
color: #3357ff; color: #b14400;
padding: 2px 7px; padding: 2px 7px;
border-radius: 6px; border-radius: 6px;
font-family: Consolas, Menlo, "Courier New", monospace; font-family: Consolas, Menlo, "Courier New", monospace;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
border: 1px solid #f5d8a8;
} }
/* kbd */ /* kbd */
@ -770,6 +908,7 @@ const INLINE_STYLES = `
.docCode code { .docCode code {
background: none; color: inherit; padding: 0; background: none; color: inherit; padding: 0;
font-weight: 500; font-size: 13px; white-space: pre; 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 className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span> <span>ВИКИ</span>
</button> </button>
{/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */} {/* Импорт Roblox .rbxl — доступно всем */}
{getCurrentUserId() === 1 && ( <button
<button className={cl.navItem}
className={cl.navItem} onClick={() => setRbxlImportOpen(true)}
onClick={() => setRbxlImportOpen(true)} title="Импортировать игру из Roblox (.rbxl файл)"
title="Импортировать игру из Roblox (.rbxl файл) — тест-фича" style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }} >
> <span className={cl.navIcon}>📦</span>
<span className={cl.navIcon}>📦</span> <span>Импорт Roblox</span>
<span>Импорт Roblox</span> </button>
</button>
)}
</nav> </nav>
<RbxlImportModal <RbxlImportModal

File diff suppressed because it is too large Load Diff

View File

@ -748,7 +748,9 @@ function game6ColorTiles() {
id, id,
type: 'cube', type: 'cube',
name: 'Плитка_' + id, 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, sx: 1.8, sy: 0.3, sz: 1.8,
color: '#9aa0aa', // серый — не раскрашена color: '#9aa0aa', // серый — не раскрашена
material: 'matte', material: 'matte',
@ -6158,8 +6160,143 @@ export function hasGameBuilder(id) {
return typeof GAME_BUILDERS[id] === 'function'; return typeof GAME_BUILDERS[id] === 'function';
} }
/** Построить project_data для игры-урока. Возвращает объект или null. */ // ══════════════════════════════════════════════════════════════════
export function buildGameProject(id) { // LUA_OVERRIDES — реестр Lua-версий скриптов для уроков.
const fn = GAME_BUILDERS[id]; // Структура: { gameId: { scriptId: 'lua code' | (script) => 'lua code' } }
return fn ? fn() : null; // Если скрипт описан здесь — при 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. * RbxlImportModal модалка импорта .rbxl Roblox-карт в Rublox.
* *
* Доступна ТОЛЬКО МИНу (user_id === 1) это тест-фича. * Доступна всем пользователям (см. вики «Импорт из Roblox» о нюансах).
* *
* Поток: * Поток:
* 1. Юзер дропает или выбирает .rbxl файл. * 1. Юзер дропает или выбирает .rbxl файл.
@ -13,8 +13,6 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js'; import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js';
const ALLOWED_USER_ID = 1; // МИН
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) { 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 [previewHash, setPreviewHash] = useState(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [error, setError] = useState(null); 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); const fileInputRef = useRef(null);
if (!open) return 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 = () => { const reset = () => {
setFile(null); setReport(null); setPreviewHash(null); setFile(null); setReport(null); setPreviewHash(null);
setTitle(''); setError(null); setAnalyzing(false); setCreating(false); setTitle(''); setError(null); setAnalyzing(false); setCreating(false);
setScriptsMode('disabled');
setGuiMode('all');
}; };
const handleClose = () => { reset(); onClose?.(); }; const handleClose = () => { reset(); onClose?.(); };
@ -88,7 +83,7 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
setCreating(true); setCreating(true);
setError(null); setError(null);
try { try {
const result = await createRbxlProject(previewHash, title); const result = await createRbxlProject(previewHash, title, { scriptsMode, guiMode });
onCreated?.(result); onCreated?.(result);
handleClose(); handleClose();
// редирект на редактор // редирект на редактор
@ -175,6 +170,29 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
</tbody> </tbody>
</table> </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 && ( {report.top_classes?.length > 0 && (
<details style={{ marginTop: 12 }}> <details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary> <summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary>
@ -206,6 +224,98 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
</div> </div>
</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 }}> <div style={{ marginTop: 16 }}>
<label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label> <label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label>
<input <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 = ({ const ItemRow = ({
icon, label, title, depth = 0, selected, plusItems, icon, label, title, depth = 0, selected, plusItems,
onClick, onDoubleClick, onContextMenu, onDragStart, draggable, onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
extraStyle, selId, extraStyle, selId, badge,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const rowRef = React.useRef(null); 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={{ 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> <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 && ( {plusItems && plusItems.length > 0 && (
<HoverPlusMenu visible={hovered} items={plusItems} /> <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 ScriptRow = ({ script, depth, selected, onSelect, onDelete, onRename, onContextMenu, onStartRename }) => {
const displayName = script.name || (script.id === 'demo' ? 'Демо-скрипт' : script.id); 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 ( return (
<ItemRow <ItemRow
icon="📜" icon="📜"
label={displayName} label={displayName}
title={`${displayName} (id: ${script.id})`} title={`${displayName} (id: ${script.id}, язык: ${isLua ? 'Lua' : 'JavaScript'})`}
depth={depth} depth={depth}
selected={selected} selected={selected}
onClick={onSelect} onClick={onSelect}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
badge={badge}
plusItems={[ plusItems={[
{ {
id: 'rename', label: 'Переименовать', icon: '✏️', id: 'rename', label: 'Переименовать', icon: '✏️',

View File

@ -526,11 +526,73 @@ const InspectorPanel = ({
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</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.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 }}> <div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}>
<Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток. <Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток.
</div> </div>
</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.section}>
<div className={cl.sectionTitle}><Icon name="fog" size={12} /> Туман</div> <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 TerrainGenPanel from './TerrainGenPanel';
import ScriptConsole from './ScriptConsole'; import ScriptConsole from './ScriptConsole';
import SceneTabs from './SceneTabs'; 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 GameHud from './GameHud';
import MinimapOverlay from './MinimapOverlay'; import MinimapOverlay from './MinimapOverlay';
import GuiOverlay from './GuiOverlay'; import GuiOverlay from './GuiOverlay';
@ -43,6 +43,7 @@ import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton'; import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
import cl from './KubikonEditor.module.css'; import cl from './KubikonEditor.module.css';
import Icon from './Icon'; import Icon from './Icon';
import ConfirmModal from './ConfirmModal';
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины авто-сохранение const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины авто-сохранение
@ -512,6 +513,8 @@ const KubikonEditor = () => {
// BillboardEditorModal открывается из инспектора при клике // BillboardEditorModal открывается из инспектора при клике
// «Редактировать табличку». Содержит primitiveData выделенного билборда. // «Редактировать табличку». Содержит primitiveData выделенного билборда.
const [billboardEditorData, setBillboardEditorData] = useState(null); const [billboardEditorData, setBillboardEditorData] = useState(null);
// ConfirmModal кастомная модалка вместо window.confirm.
const [confirmState, setConfirmState] = useState(null);
// Bumper для обновления списков в Toolbox после edit/settings/delete. // Bumper для обновления списков в Toolbox после edit/settings/delete.
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0); const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
// Bump-счётчик: инкрементируется при создании/очистке гладкого // Bump-счётчик: инкрементируется при создании/очистке гладкого
@ -2043,13 +2046,19 @@ const KubikonEditor = () => {
// Флаш ScriptEditor без этого 600мс свежих правок не успеют // Флаш ScriptEditor без этого 600мс свежих правок не успеют
// попасть в _scripts[]/dirtyRef и confirm-диалог не покажется. // попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
try { scriptEditorFlushRef.current?.(); } catch (_) {} try { scriptEditorFlushRef.current?.(); } catch (_) {}
// Несохранённые изменения спрашиваем // Несохранённые изменения кастомная модалка с 3 кнопками:
// Сохранить (по умолчанию), Не сохранять, Отмена.
if (dirtyRef.current) { if (dirtyRef.current) {
const ok = window.confirm('Есть несохранённые изменения. Сохранить перед выходом?'); setConfirmState({
if (ok) { title: 'Несохранённые изменения',
doSave().finally(() => navigate('/')); message: 'Сохранить проект перед выходом? Если выйти без сохранения — последние правки пропадут.',
return; confirmLabel: 'Сохранить и выйти',
} cancelLabel: 'Выйти без сохранения',
confirmTone: 'primary',
onConfirm: () => doSave().finally(() => navigate('/')),
onCancel: () => navigate('/'), // выйти без сохранения
});
return;
} }
navigate('/'); navigate('/');
}; };
@ -3324,10 +3333,43 @@ const KubikonEditor = () => {
scriptId={sc.id} scriptId={sc.id}
value={sc.code} value={sc.code}
target={sc.target} target={sc.target}
language={sc.language || 'js'}
flushRef={scriptEditorFlushRef} flushRef={scriptEditorFlushRef}
isSoloRunning={soloScriptId === sc.id} 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) => { 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?.() || []); setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty(); markDirty();
}} }}
@ -4187,6 +4229,13 @@ const KubikonEditor = () => {
setBillboardEditorData(null); setBillboardEditorData(null);
}} }}
/> />
{/* Кастомная модалка подтверждения вместо window.confirm. */}
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</div> </div>
); );
}; };

View File

@ -7,6 +7,8 @@ import Icon from './Icon';
// при правке одного файла не перетряхивать все остальные. // при правке одного файла не перетряхивать все остальные.
import { GAME_TYPE_LIBS } from './engine/types/bundle'; import { GAME_TYPE_LIBS } from './engine/types/bundle';
import { registerSnippets } from './engine/snippets'; import { registerSnippets } from './engine/snippets';
import { registerLuaInMonaco } from './lua-monaco-setup';
import ConfirmModal from './ConfirmModal';
/** /**
* ScriptEditor Monaco-редактор кода скрипта в табе. * ScriptEditor Monaco-редактор кода скрипта в табе.
@ -34,7 +36,50 @@ import { registerSnippets } from './engine/snippets';
// Если нужен какой-то метод, которого нет в автокомплите добавляйте его // Если нужен какой-то метод, которого нет в автокомплите добавляйте его
// в соответствующий .d.ts (player.d.ts / scene.d.ts / ...) и пересобирайте // в соответствующий .d.ts (player.d.ts / scene.d.ts / ...) и пересобирайте
// командой `python _build_bundle.py` в той же папке. // командой `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. // Синхронизируется с external value только при смене scriptId.
const [localCode, setLocalCode] = useState(value || ''); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]); }, [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) => { const scheduleSave = useCallback((code) => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
@ -162,6 +216,9 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
// Сниппеты для быстрого старта (door/coin/portal/npc/quest/save и т.д.). // Сниппеты для быстрого старта (door/coin/portal/npc/quest/save и т.д.).
// Регистрируются один раз для всего Monaco, флаг хранится в monaco.__kubikonSnippetsRegistered. // Регистрируются один раз для всего Monaco, флаг хранится в monaco.__kubikonSnippetsRegistered.
registerSnippets(monaco); registerSnippets(monaco);
// Lua: completionProvider (Vector3.new/Color3.fromRGB/script.Parent/...)
// + hoverProvider (документация при наведении)
registerLuaInMonaco(monaco);
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[ScriptEditor] Monaco setup error', e); 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)', border: '1px solid rgba(79, 116, 255, 0.35)',
}}>{targetLabel}</span> }}>{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' }}> <span style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
{/* Фаза 6.1.4: кнопка «Проверить» включает семантический анализ TS {/* Фаза 6.1.4: кнопка «Проверить» включает семантический анализ TS
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.). на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
@ -394,10 +499,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
<Editor <Editor
height="100%" height="100%"
defaultLanguage="javascript" defaultLanguage={currentLanguage === 'lua' ? 'lua' : 'javascript'}
language={currentLanguage === 'lua' ? 'lua' : 'javascript'}
theme="vs-dark" theme="vs-dark"
value={localCode} value={localCode}
path={`script_${scriptId}.js`} path={`script_${scriptId}.${currentLanguage === 'lua' ? 'lua' : 'js'}`}
onChange={handleChange} onChange={handleChange}
beforeMount={handleEditorWillMount} beforeMount={handleEditorWillMount}
onMount={handleEditorMount} onMount={handleEditorMount}
@ -434,6 +540,12 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
}} }}
/> />
</div> </div>
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</div> </div>
); );
} }

View File

@ -37,6 +37,7 @@ import {
Ray, Ray,
PointerEventTypes, PointerEventTypes,
Tools as BabylonTools, Tools as BabylonTools,
ColorCurves,
} from '@babylonjs/core'; } from '@babylonjs/core';
import { PlacementManager } from './PlacementManager'; import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi'; import { ShopInventoryUi } from './ShopInventoryUi';
@ -1885,9 +1886,41 @@ export class BabylonScene {
} }
if (typeof patch.sunIntensity === 'number' && this._sunLight) { if (typeof patch.sunIntensity === 'number' && this._sunLight) {
this._sunLight.intensity = Math.max(0, patch.sunIntensity); this._sunLight.intensity = Math.max(0, patch.sunIntensity);
this._sunIntensity = patch.sunIntensity;
} }
if (typeof patch.hemiIntensity === 'number' && this._hemiLight) { if (typeof patch.hemiIntensity === 'number' && this._hemiLight) {
this._hemiLight.intensity = Math.max(0, patch.hemiIntensity); 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') { if (this.environment && typeof this.environment.setFog === 'function') {
// Текущие значения берём из Environment, поверх накладываем patch // Текущие значения берём из Environment, поверх накладываем patch
@ -3002,6 +3035,7 @@ export class BabylonScene {
if (md.isBlock) { if (md.isBlock) {
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; 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.isModel) return { kind: 'model', id: md.instanceId };
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
return null; return null;
@ -3036,24 +3070,36 @@ export class BabylonScene {
const EPS = 0.25; const EPS = 0.25;
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId) // 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
let _firedThisFrame = 0;
for (const s of scripts) { for (const s of scripts) {
if (!s.target) continue; if (!s.target) continue;
const key = 's:' + s.id; try {
seen.add(key); const key = 's:' + s.id;
const aabb = this._targetAABB(s.target); seen.add(key);
if (!aabb) continue; const aabb = this._targetAABB(s.target);
const overlap = if (!aabb) continue;
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && const overlap =
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
const wasTouching = this._touchState.get(key); pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
if (overlap && !wasTouching) { const wasTouching = this._touchState.get(key);
this._touchState.set(key, true); if (overlap && !wasTouching) {
rt.routeEvent(s.target, 'touch', {}); this._touchState.set(key, true);
rt.routeGlobalEvent('playerTouch', { target: s.target }); rt.routeEvent(s.target, 'touch', {});
} else if (!overlap && wasTouching) { rt.routeGlobalEvent('playerTouch', { target: s.target });
this._touchState.set(key, false); _firedThisFrame++;
rt.routeEvent(s.target, 'untouch', {}); 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) { _targetAABB(target) {
if (!target) return null; if (!target) return null;
try { 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') { if (target.kind === 'block') {
const r = target.ref || target; const r = target.ref || target;
return { return {
@ -3215,7 +3272,30 @@ export class BabylonScene {
} }
if (!this.gameRuntime) return; 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 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; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
// 1) Self-onClick — только если target есть // 1) Self-onClick — только если target есть
@ -5364,6 +5444,7 @@ export class BabylonScene {
code: s.code, code: s.code,
name: s.name || null, name: s.name || null,
target: newTarget, target: newTarget,
language: s.language || 'js',
}); });
} }
if (srcScripts.length > 0) { if (srcScripts.length > 0) {
@ -5506,7 +5587,7 @@ export class BabylonScene {
}; };
clip.scripts = (this._scripts || []) clip.scripts = (this._scripts || [])
.filter(s => matchTarget(s.target)) .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 = []; } } catch (e) { clip.scripts = []; }
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
catch (e) { /* ignore — приватный режим / переполнение */ } catch (e) { /* ignore — приватный режим / переполнение */ }
@ -5521,7 +5602,7 @@ export class BabylonScene {
const target = kind === 'block' const target = kind === 'block'
? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } } ? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
: { kind, id: dstRef }; : { 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(); this.history?.markChange();
if (this._onSceneChange) this._onSceneChange(); if (this._onSceneChange) this._onSceneChange();
@ -6677,7 +6758,7 @@ export class BabylonScene {
} }
/** Установить код одного скрипта по id. Если id нет — создать новый. */ /** Установить код одного скрипта по 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); const i = this._scripts.findIndex(s => s.id === id);
if (i >= 0) { if (i >= 0) {
this._scripts[i] = { this._scripts[i] = {
@ -6685,6 +6766,11 @@ export class BabylonScene {
code, code,
...(target !== undefined ? { target } : {}), ...(target !== undefined ? { target } : {}),
...(name !== undefined ? { name } : {}), ...(name !== undefined ? { name } : {}),
...(language !== undefined ? { language } : {}),
// Слоты code_js и code_lua — сохраняемый код для каждого языка.
// Передаются при переключении языка, чтобы код другого языка
// не пропадал.
...(slots && typeof slots === 'object' ? slots : {}),
}; };
} else { } else {
this._scripts.push({ this._scripts.push({
@ -6692,6 +6778,8 @@ export class BabylonScene {
code, code,
target: target !== undefined ? target : null, target: target !== undefined ? target : null,
name: name || null, name: name || null,
language: language || 'js',
...(slots && typeof slots === 'object' ? slots : {}),
}); });
} }
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
@ -7717,6 +7805,15 @@ export class BabylonScene {
crosshair: this._crosshair || 'dot', crosshair: this._crosshair || 'dot',
shadowQuality: this._shadowQuality || 'soft', shadowQuality: this._shadowQuality || 'soft',
environment: this.environment ? this.environment.serialize() : null, environment: this.environment ? this.environment.serialize() : null,
// Кастомные настройки света — слайдеры из «Свет и атмосфера»
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, skybox: this.skybox ? this.skybox.serialize() : null,
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null, leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
achievements: this.achievements ? this.achievements.serialize() : null, achievements: this.achievements ? this.achievements.serialize() : null,
@ -7736,6 +7833,7 @@ export class BabylonScene {
code: s.code, code: s.code,
target: s.target || null, target: s.target || null,
name: s.name || null, name: s.name || null,
language: s.language === 'lua' ? 'lua' : 'js',
})), })),
}, },
editorCamera: this.camera ? { editorCamera: this.camera ? {
@ -8193,12 +8291,19 @@ export class BabylonScene {
code: s.code, code: s.code,
target: s.target || null, target: s.target || null,
name: s.name || null, name: s.name || null,
language: s.language === 'lua' ? 'lua' : 'js',
})); }));
} }
// Окружение (время суток, скайбокс, туман) // Окружение (время суток, скайбокс, туман)
if (state.scene.environment && this.environment) { if (state.scene.environment && this.environment) {
this.environment.load(state.scene.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) // Кастомное небо (задача 16)
if (state.scene.skybox && this.skybox) { if (state.scene.skybox && this.skybox) {
this.skybox.load(state.scene.skybox); this.skybox.load(state.scene.skybox);

View File

@ -19,7 +19,9 @@ import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../../api/API'; import { STORYS_addres } from '../../api/API';
import { PhysicsWorld } from './PhysicsWorld'; import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager'; 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 { export class GameRuntime {
constructor(scene3d) { constructor(scene3d) {
@ -115,11 +117,70 @@ export class GameRuntime {
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
// Roblox-Lua скрипты собираем для single-VM режима: один shared Worker // Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит. // на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
const rbxlBatch = [];
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || []; 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) { for (const s of scripts) {
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { 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; continue;
} }
if (!s || typeof s.code !== 'string' || !s.code.trim()) { if (!s || typeof s.code !== 'string' || !s.code.trim()) {
@ -151,25 +212,157 @@ export class GameRuntime {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[GameRuntime] sandbox started for script id=', s.id); console.log('[GameRuntime] sandbox started for script id=', s.id);
} }
// Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом. // Импортированные .rbxl-скрипты теперь идут через тот же LuaSharedSandbox
let rbxlCount = 0; // вместе с user-Lua (см. luaUserBatch выше). Отдельный Worker больше не нужен.
if (rbxlBatch.length > 0) { let luaUserCount = 0;
// GUI-дерево из projectData для pre-population if (luaUserBatch.length > 0) {
const guiElements = this.projectData?.scene?.gui || []; try {
const result = startRobloxLuaShared(rbxlBatch, { const sb = new LuaSharedSandbox();
primitives, // partSet/sceneCreate — переиспользуем обработчик rbxl
guiElements, sb.setOnCommand(({ cmd, payload }) => {
onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this), if (cmd === 'partSet' || cmd === 'partVel' ||
}); cmd === 'sceneCreate' || cmd === 'sceneDelete') {
if (result && result.sandbox) { try {
this.sandboxes.push(result.sandbox); handleLuaCommand(null, cmd, payload, this);
this._rbxlSharedSandbox = result.sandbox; } catch (e) {
rbxlCount = result.count; // 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)}`); const rbxlImported = luaUserBatch.filter(s => s._rbxlImported).length;
if (rbxlCount > 0) { const luaWritten = luaUserCount - rbxlImported;
this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`); 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' // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик — // во все sandbox'ы. Не перезаписываем существующий обработчик —
@ -467,6 +660,146 @@ export class GameRuntime {
return null; 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() { stop() {
if (this.sandboxes.length > 0) { if (this.sandboxes.length > 0) {
this._log('info', 'Остановка скриптов'); this._log('info', 'Остановка скриптов');
@ -474,6 +807,14 @@ export class GameRuntime {
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes'); console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
for (const sb of this.sandboxes) sb.stop(); 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 они остаются на сцене // game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках. // и накапливаются при повторных запусках.
@ -621,7 +962,61 @@ export class GameRuntime {
this._syncPhysicsToScene(); this._syncPhysicsToScene();
} }
const state = this._collectState(); 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) { 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 // Для скриптов с target — добавляем актуальную позицию self
const stateForSb = sb.target const stateForSb = sb.target
? { ...state, selfPosition: this._collectSelfPosition(sb.target) } ? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
@ -1118,7 +1513,8 @@ export class GameRuntime {
const nid = this._resolveNpcId(ref); const nid = this._resolveNpcId(ref);
if (nid != null) { fn(nid); return; } if (nid != null) { fn(nid); return; }
// ещё не резолвится — откладываем (только для локальных ref NPC) // ещё не резолвится — откладываем (только для локальных 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) this._pendingNpcCmds = new Map();
if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []); if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []);
this._pendingNpcCmds.get(ref).push(fn); this._pendingNpcCmds.get(ref).push(fn);
@ -1183,6 +1579,32 @@ export class GameRuntime {
const d = tryGet(this.scene3d?.modelManager); const d = tryGet(this.scene3d?.modelManager);
if (d) return { kind: 'model', data: d }; 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); const um = tryGet(this.scene3d?.userModelManager);
if (um) return { kind: 'userModel', data: um }; if (um) return { kind: 'userModel', data: um };
return null; return null;
@ -1288,6 +1710,17 @@ export class GameRuntime {
routeEvent(target, eventType, extra = {}) { routeEvent(target, eventType, extra = {}) {
if (!target || !eventType) return; if (!target || !eventType) return;
for (const sb of this.sandboxes) { 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 (!sb.target) continue;
if (!this._targetMatches(sb.target, target)) continue; if (!this._targetMatches(sb.target, target)) continue;
sb.sendEvent({ type: eventType, ...extra }); sb.sendEvent({ type: eventType, ...extra });
@ -1739,6 +2172,13 @@ export class GameRuntime {
// после spawnNpc (follow/moveTo/say) — они ждали // после spawnNpc (follow/moveTo/say) — они ждали
// резолва ref в очереди. // резолва ref в очереди.
this._flushPendingNpcCmds(payload.ref, npcId); 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, чтобы // Сообщаем воркеру маппинг localRef → npcId, чтобы
// npc.onDeath по локальному ref находил правильного NPC. // npc.onDeath по локальному ref находил правильного NPC.
@ -3198,16 +3638,28 @@ export class GameRuntime {
const ref = payload?.ref; const ref = payload?.ref;
const text = payload?.text; const text = payload?.text;
if (typeof ref !== 'string') return; if (typeof ref !== 'string') return;
// ленивое создание менеджера меток
if (!this.scene3d._labelManager) { if (!this.scene3d._labelManager) {
this.scene3d._labelManager = new LabelManager(this.scene3d.scene); this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
} }
const lm = this.scene3d._labelManager; 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 tgt = this._resolveTweenTarget(ref);
const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode); if (tgt) {
if (mesh) { applyLabel();
lm.setLabel(ref, mesh, text, payload?.opts || {}); } 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) { } catch (e) {
console.warn('[GameRuntime] scene.setLabel failed', e); console.warn('[GameRuntime] scene.setLabel failed', e);
@ -3935,6 +4387,73 @@ export class GameRuntime {
} }
return; 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 // eslint-disable-next-line no-console
console.warn('[GameRuntime] unknown cmd', cmd); console.warn('[GameRuntime] unknown cmd', cmd);
} }
@ -4213,6 +4732,7 @@ export class GameRuntime {
if (s?.primitiveManager) { if (s?.primitiveManager) {
for (const data of s.primitiveManager.instances.values()) { for (const data of s.primitiveManager.instances.values()) {
primitives.push({ primitives.push({
id: data.id,
ref: 'primitive:' + data.id, ref: 'primitive:' + data.id,
type: data.type, type: data.type,
x: data.x, y: data.y, z: data.z, x: data.x, y: data.y, z: data.z,
@ -4222,11 +4742,18 @@ export class GameRuntime {
sz: data.sz != null ? data.sz : 1, sz: data.sz != null ? data.sz : 1,
rotationY: data.rotationY || 0, rotationY: data.rotationY || 0,
visible: data.visible !== false, 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 // Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target
@ -4359,6 +4886,13 @@ export class GameRuntime {
} }
_log(level, text, scriptId = null, scriptName = null) { _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) { if (this._onLog) {
try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ } 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; const show = npc.hp < npc.maxHp;
hb.anchor.setEnabled(show); hb.anchor.setEnabled(show);
if (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)); const pct = Math.max(0, Math.min(1, npc.hp / npc.maxHp));
hb.fill.scaling.x = pct; hb.fill.scaling.x = pct;
hb.fill.position.x = -(1 - pct) * hb.barWidth / 2; hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;

View File

@ -507,6 +507,11 @@ export class PrimitiveManager {
const matName = `${mesh.name}_mat`; const matName = `${mesh.name}_mat`;
const mat = new StandardMaterial(matName, this.scene); const mat = new StandardMaterial(matName, this.scene);
mat.diffuseColor = Color3.FromHexString(color || '#888888'); 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. Это // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это
// используется для GD-скинов куба (например /gd/skins/cube_smile.png). // используется для GD-скинов куба (например /gd/skins/cube_smile.png).
@ -567,9 +572,18 @@ export class PrimitiveManager {
break; break;
} }
case 'matte': case 'matte':
default:
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
break; 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); const data = this.instances.get(id);
if (!data) return; 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.x !== undefined) data.x = patch.x;
if (patch.y !== undefined) data.y = patch.y; if (patch.y !== undefined) data.y = patch.y;
if (patch.z !== undefined) data.z = patch.z; 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', 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', shadowQuality: this._scene3d.getShadowQuality?.() || 'soft',
ssaoEnabled: this._scene3d.getSsaoEnabled?.() || false, 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(); this._notifyChange();
} }

View File

@ -170,8 +170,8 @@ export class StudioCollab {
sc.__collabScriptsPatched = true; sc.__collabScriptsPatched = true;
if (typeof sc.upsertScript === 'function') { if (typeof sc.upsertScript === 'function') {
const origUpsert = sc.upsertScript.bind(sc); const origUpsert = sc.upsertScript.bind(sc);
sc.upsertScript = function (id, code, target, name) { sc.upsertScript = function (id, code, target, name, language) {
const r = origUpsert(id, code, target, name); const r = origUpsert(id, code, target, name, language);
if (!self._applyingRemote) { if (!self._applyingRemote) {
// id может быть сгенерён внутри upsertScript, если был null — // id может быть сгенерён внутри upsertScript, если был null —
// достаём фактический из _scripts (последний с этим code). // достаём фактический из _scripts (последний с этим code).
@ -188,6 +188,7 @@ export class StudioCollab {
code: rec.code, code: rec.code,
target: rec.target ?? null, target: rec.target ?? null,
name: rec.name ?? null, name: rec.name ?? null,
language: rec.language ?? 'js',
}); });
} }
} }
@ -523,7 +524,7 @@ export function applyRemoteOp(scene, op) {
// Создание/редактирование скрипта у соавтора. _applyingRemote уже // Создание/редактирование скрипта у соавтора. _applyingRemote уже
// выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт // выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт
// эхо обратно. _onSceneChange внутри обновит React-панели. // эхо обратно. _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?.(); scene._onCollabScriptsChange?.();
return; return;
case 'scriptRemove': 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-карт.
* *
* Двухфазная инициализация: * Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
* 1) init worker pre-populate workspace + GUI tree (включая сигналы) * Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
* 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением * (см. GameRuntime.start()). Этот файл оставлен только для:
* 3) ready kickoff emit PlayerAdded, начать tick * - 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-кода. */ /** Распаковка lua_source из packed-кода. */
export function unpackRobloxLuaCode(code) { export function unpackRobloxLuaCode(code) {
@ -20,6 +20,20 @@ export function unpackRobloxLuaCode(code) {
return code.slice(start, closeIdx); 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). */ /** Сцена → snap для shim'а (workspace:GetChildren). */
export function buildLuaSceneSnap(primitives) { export function buildLuaSceneSnap(primitives) {
const out = { primitives: {} }; const out = { primitives: {} };
@ -80,37 +94,6 @@ export function buildLuaGuiTree(guiElements) {
return out; 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-сцене. * Обработка IPC команд от worker'а мапим на действия в Babylon-сцене.
*/ */
@ -122,28 +105,78 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
return; return;
} }
if (cmd === 'partSet') { 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 { try {
const pm = runtime.scene3d?.primitiveManager; const pm = runtime.scene3d?.primitiveManager;
if (!pm) return; if (!pm || typeof pm.addInstance !== 'function') return;
const primId = payload?.primId; const opts = {
const prop = payload?.prop; id: payload?.primId,
const value = payload?.value; x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0,
const patch = {}; sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1,
if (prop === 'position' && value) { color: payload?.color,
patch.x = value.x; patch.y = value.y; patch.z = value.z; anchored: payload?.anchored !== false,
} else if (prop === 'cframe' && value) { canCollide: payload?.canCollide !== false,
patch.x = value.x; patch.y = value.y; patch.z = value.z; };
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz; pm.addInstance(payload?.type || 'cube', opts);
} else if (prop === 'size' && value) { // Если unanchored — регистрируем в физике на лету, иначе он не падает.
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz; if (opts.anchored === false) {
} else if (prop === 'color') patch.color = value; try {
else if (prop === 'material') patch.material = value; const dm = runtime.scene3d?.dynamics;
else if (prop === 'anchored') patch.anchored = value; const data = pm.instances?.get?.(opts.id);
else if (prop === 'canCollide') patch.canCollide = value; if (dm && data && typeof dm.registerPrimitive === 'function') {
else if (prop === 'opacity') patch.opacity = value; dm.registerPrimitive(data);
if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch); }
else if (typeof pm.update === 'function') pm.update(primId, patch); } catch (e) {
} 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; return;
} }
if (cmd === 'partVel') { 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 },
],
};
},
});
}