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>
385 lines
14 KiB
JavaScript
385 lines
14 KiB
JavaScript
/**
|
||
* 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 };
|