All checks were successful
Сегодня доведены до играбельного состояния:
- 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>
205 lines
7.5 KiB
JavaScript
205 lines
7.5 KiB
JavaScript
/**
|
||
* 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 };
|