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