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