studio/src/editor/engine/roblox-tween.js
min 412bb2fad9
All checks were successful
CI / Lint (pull_request) Successful in 2m43s
CI / Build (pull_request) Successful in 1m57s
CI / Secret scan (pull_request) Successful in 1m21s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(rbxl-import): студия исполняет импортированные Roblox-Lua скрипты
Сегодня доведены до играбельного состояния:
- UI модалка импорта подключена в KubikonStudio (кнопка для МИНа в навигации)
- Converter: SCALE 0.35 (карта пропорциональна R15-персонажу),
  playerModelType='skin_bacon-hair', Lua упакован в поле code с маркером
  // @roblox-lua (storys API сохраняет только {id,code,target,name})
- vite.config: api+статика через rublox.pro/minecraftia-school.ru
- GameRuntime: распознаёт маркер, запускает через RobloxLuaSandbox
  + wasmoon Worker. Фильтрация: target!=null + lua<2500б +
  лимит 50 sandbox'ов (WASM OOM при >50 VM)
- roblox-shim: nullStub (Proxy с no-op методами) вместо null
  для FindFirstChild/WaitForChild — цепочки не падают
- require() заменён на nullStub
- RobloxLuaSandbox: совместимость с интерфейсом ScriptSandbox
  (sendGlobalEvent/SceneSnapshot/etc — no-op заглушки)
- RobloxLuaWorker: pcall обёртка над user-кодом
- remoteDevlog.js + /devlog endpoint: автосбор browser-логов
- PlayerController._loadSkinManifest: dev-fallback на studio.rublox.pro

Тест на Easy Obby:
- 8205 instances → 2245 primitives + 742 Lua-scripts
- 50/742 Lua-VM запущены (KillBrick handlers и т.п.),
  151 отфильтровано как admin/chat services, 541 пропущено по памяти
- Скин bacon-hair виден, FPS 20-25
- Сцена играется, можно ходить, прыгать

TODO (следующая итерация):
- Single-VM mode для wasmoon (один Lua-state на 742 скрипта,
  убрать WASM OOM)
- Реализовать select/focus в иерархии для импортированных карт
- Touched events от Babylon impostor → Lua-shim сигналы
- Поддержка GUI (ScreenGui/Frame/TextLabel) в конвертере

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 21:13:16 +03:00

205 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };