Compare commits
No commits in common. "0b677529e1aa6a6039d6e8dff59903fe53a38e7e" and "bb0726b4ad116bcb6be731aa11c6d283a73d1444" have entirely different histories.
0b677529e1
...
bb0726b4ad
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -122,22 +122,11 @@ def analyze():
|
||||
blob = upload.read()
|
||||
if len(blob) > MAX_RBXL_SIZE:
|
||||
return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413
|
||||
# Авто-детект XML vs Binary формата.
|
||||
# Бинарный: <roblox!\x89\xff\r\n\x1a\n (magic bytes).
|
||||
# XML (старые карты до 2010): <roblox version="4">...
|
||||
stripped = blob.lstrip()
|
||||
is_binary = stripped.startswith(b'<roblox!')
|
||||
is_xml = stripped.startswith(b'<roblox') and not is_binary
|
||||
|
||||
if not is_binary and not is_xml:
|
||||
return jsonify({'error': 'not a .rbxl file (no <roblox magic)'}), 400
|
||||
if not blob.startswith(b'<roblox!'):
|
||||
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400
|
||||
|
||||
# Парсим
|
||||
try:
|
||||
if is_xml:
|
||||
from rbxl_xml_parser import parse_xml
|
||||
model = parse_xml(blob)
|
||||
else:
|
||||
model = parse(blob)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'parse failed: {e}'}), 422
|
||||
|
||||
@ -1,342 +0,0 @@
|
||||
"""
|
||||
rbxl_xml_parser.py — парсер XML-формата .rbxl (старые карты до 2010 года).
|
||||
|
||||
Roblox-XML формат — текстовый предок бинарного .rbxl. Файл начинается с
|
||||
<roblox version="4"> и содержит дерево <Item class="...">...</Item>.
|
||||
|
||||
Возвращает тот же `RobloxModel` что и rbxl_parser.parse — чтобы converter.py
|
||||
работал без изменений.
|
||||
|
||||
Пример входного файла:
|
||||
<roblox version="4">
|
||||
<Item class="Workspace">
|
||||
<Properties>
|
||||
<string name="Name">Workspace</string>
|
||||
</Properties>
|
||||
<Item class="Part">
|
||||
<Properties>
|
||||
<CoordinateFrame name="CFrame">
|
||||
<X>0</X><Y>10</Y><Z>0</Z>
|
||||
<R00>1</R00>...<R22>1</R22>
|
||||
</CoordinateFrame>
|
||||
<Vector3 name="size"><X>4</X><Y>1</Y><Z>2</Z></Vector3>
|
||||
<Color3uint8 name="Color3uint8">4286611584</Color3uint8>
|
||||
<token name="BrickColor">21</token>
|
||||
</Properties>
|
||||
</Item>
|
||||
</Item>
|
||||
</roblox>
|
||||
|
||||
Поддерживает все типичные property-теги: string, bool, int, float, double,
|
||||
token, Vector3, Vector2, CoordinateFrame, Color3, Color3uint8, BrickColor,
|
||||
Content, ProtectedString, Ref, BinaryString, UDim, UDim2, Rect2D.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import base64
|
||||
import struct
|
||||
|
||||
from rbxl_parser import Instance, RobloxModel
|
||||
from rbxl_types import (
|
||||
Vector3, Vector2, Color3, CFrame, BrickColor,
|
||||
EnumValue, PhysicalProperties, OptionalCFrame,
|
||||
)
|
||||
|
||||
|
||||
# Magic для XML-формата
|
||||
XML_MAGIC = b'<roblox'
|
||||
|
||||
|
||||
def is_xml_rbxl(blob: bytes) -> bool:
|
||||
"""Проверяет XML это или нет. Бинарный начинается с <roblox!..."""
|
||||
stripped = blob.lstrip()
|
||||
if stripped.startswith(b'<roblox') and not stripped.startswith(b'<roblox!'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _text(el: ET.Element, default: str = '') -> str:
|
||||
"""Текст элемента (None → default)."""
|
||||
return (el.text if el.text is not None else default).strip()
|
||||
|
||||
|
||||
def _f(el: ET.Element, default: float = 0.0) -> float:
|
||||
"""Float из text."""
|
||||
try:
|
||||
return float(_text(el, '0'))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _i(el: ET.Element, default: int = 0) -> int:
|
||||
"""Int из text."""
|
||||
try:
|
||||
s = _text(el, '0')
|
||||
# Roblox иногда пишет '1.0' где ожидается int
|
||||
return int(float(s)) if '.' in s else int(s)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _parse_vector3(el: ET.Element) -> Vector3:
|
||||
x = _f(el.find('X'))
|
||||
y = _f(el.find('Y'))
|
||||
z = _f(el.find('Z'))
|
||||
return Vector3(x, y, z)
|
||||
|
||||
|
||||
def _parse_vector2(el: ET.Element) -> Vector2:
|
||||
x = _f(el.find('X'))
|
||||
y = _f(el.find('Y'))
|
||||
return Vector2(x, y)
|
||||
|
||||
|
||||
def _parse_cframe(el: ET.Element) -> CFrame:
|
||||
"""CoordinateFrame: 3 позиции + 9 элементов матрицы ротации."""
|
||||
pos = Vector3(_f(el.find('X')), _f(el.find('Y')), _f(el.find('Z')))
|
||||
matrix = tuple(_f(el.find(f'R{i}{j}'), 1.0 if i == j else 0.0)
|
||||
for i in range(3) for j in range(3))
|
||||
return CFrame(position=pos, matrix=matrix)
|
||||
|
||||
|
||||
def _parse_color3(el: ET.Element) -> Color3:
|
||||
"""<Color3 name="..."><R>...</R><G>...</G><B>...</B></Color3>"""
|
||||
r = _f(el.find('R'))
|
||||
g = _f(el.find('G'))
|
||||
b = _f(el.find('B'))
|
||||
return Color3(r, g, b)
|
||||
|
||||
|
||||
def _parse_color3uint8(el: ET.Element) -> Color3:
|
||||
"""<Color3uint8>4286611584</Color3uint8> — packed RGB как uint32."""
|
||||
val = _i(el, 0)
|
||||
# uint32 = 0xFFRRGGBB (alpha=FF). r=byte2, g=byte1, b=byte0
|
||||
b = (val & 0xff) / 255.0
|
||||
g = ((val >> 8) & 0xff) / 255.0
|
||||
r = ((val >> 16) & 0xff) / 255.0
|
||||
return Color3(r, g, b)
|
||||
|
||||
|
||||
def _parse_property(prop_el: ET.Element) -> Tuple[str, Any]:
|
||||
"""Парсит один <тип name="имя">значение</тип>. Возвращает (name, value)."""
|
||||
tag = prop_el.tag
|
||||
name = prop_el.attrib.get('name', '')
|
||||
|
||||
if tag == 'string' or tag == 'ProtectedString' or tag == 'Content':
|
||||
return name, _text(prop_el)
|
||||
|
||||
if tag == 'bool':
|
||||
return name, _text(prop_el).lower() == 'true'
|
||||
|
||||
if tag in ('int', 'int64'):
|
||||
val = _i(prop_el)
|
||||
# В старом XML цвет хранится как <int name="BrickColor">21</int>,
|
||||
# а converter ожидает BrickColor-объект с .code.
|
||||
if name == 'BrickColor':
|
||||
return name, BrickColor(code=val)
|
||||
return name, val
|
||||
|
||||
if tag in ('float', 'double'):
|
||||
return name, _f(prop_el)
|
||||
|
||||
if tag == 'token':
|
||||
# token — int-значение enum
|
||||
return name, EnumValue(value=_i(prop_el))
|
||||
|
||||
if tag == 'Vector3':
|
||||
return name, _parse_vector3(prop_el)
|
||||
|
||||
if tag == 'Vector2':
|
||||
return name, _parse_vector2(prop_el)
|
||||
|
||||
if tag == 'CoordinateFrame':
|
||||
return name, _parse_cframe(prop_el)
|
||||
|
||||
if tag == 'Color3':
|
||||
return name, _parse_color3(prop_el)
|
||||
|
||||
if tag == 'Color3uint8':
|
||||
return name, _parse_color3uint8(prop_el)
|
||||
|
||||
if tag == 'BrickColor':
|
||||
return name, BrickColor(code=_i(prop_el))
|
||||
|
||||
if tag == 'Ref':
|
||||
# Ссылка на другой Item по referent (например "RBX42" или "null")
|
||||
txt = _text(prop_el, 'null')
|
||||
if txt in ('null', 'nil', ''):
|
||||
return name, None
|
||||
return name, txt # храним как строку-referent
|
||||
|
||||
if tag == 'BinaryString':
|
||||
# base64 → bytes
|
||||
try:
|
||||
return name, base64.b64decode(_text(prop_el))
|
||||
except Exception:
|
||||
return name, b''
|
||||
|
||||
if tag == 'UDim':
|
||||
scale = _f(prop_el.find('S'))
|
||||
offset = _i(prop_el.find('O'))
|
||||
return name, {'scale': scale, 'offset': offset}
|
||||
|
||||
if tag == 'UDim2':
|
||||
xs = _f(prop_el.find('XS'))
|
||||
xo = _i(prop_el.find('XO'))
|
||||
ys = _f(prop_el.find('YS'))
|
||||
yo = _i(prop_el.find('YO'))
|
||||
return name, {'x_scale': xs, 'x_offset': xo, 'y_scale': ys, 'y_offset': yo}
|
||||
|
||||
if tag == 'Rect2D':
|
||||
# min/max
|
||||
min_el = prop_el.find('min')
|
||||
max_el = prop_el.find('max')
|
||||
return name, {
|
||||
'min': _parse_vector2(min_el) if min_el is not None else Vector2(0, 0),
|
||||
'max': _parse_vector2(max_el) if max_el is not None else Vector2(0, 0),
|
||||
}
|
||||
|
||||
if tag == 'OptionalCoordinateFrame':
|
||||
cf_el = prop_el.find('CFrame')
|
||||
return name, OptionalCFrame(cframe=_parse_cframe(cf_el)) if cf_el is not None else OptionalCFrame(cframe=None)
|
||||
|
||||
if tag == 'PhysicalProperties':
|
||||
cust = prop_el.find('CustomPhysics')
|
||||
custom = cust is not None and _text(cust).lower() == 'true'
|
||||
return name, PhysicalProperties(
|
||||
custom_physics=custom,
|
||||
density=_f(prop_el.find('Density'), 0.7),
|
||||
friction=_f(prop_el.find('Friction'), 0.3),
|
||||
elasticity=_f(prop_el.find('Elasticity'), 0.5),
|
||||
friction_weight=_f(prop_el.find('FrictionWeight'), 1.0),
|
||||
elasticity_weight=_f(prop_el.find('ElasticityWeight'), 1.0),
|
||||
)
|
||||
|
||||
if tag == 'NumberRange':
|
||||
return name, {'min': _f(prop_el.find('Min')), 'max': _f(prop_el.find('Max'))}
|
||||
|
||||
# SharedString / Uri / другие незнакомые — оставляем как текст
|
||||
return name, _text(prop_el)
|
||||
|
||||
|
||||
# Регекс для извлечения referent из строк типа "RBX42"
|
||||
_REF_RE = re.compile(r'^RBX(\d+)$')
|
||||
|
||||
|
||||
def _ref_to_int(ref: Optional[str]) -> Optional[int]:
|
||||
"""RBX42 → 42, null → None. Если уникальной номер не найден — None."""
|
||||
if ref is None or ref == 'null':
|
||||
return None
|
||||
m = _REF_RE.match(str(ref))
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def parse_xml(blob: bytes) -> RobloxModel:
|
||||
"""Главный entry: bytes → RobloxModel."""
|
||||
try:
|
||||
text = blob.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
text = blob.decode('latin-1', errors='replace')
|
||||
|
||||
# XML может иметь BOM или leading whitespace
|
||||
text = text.lstrip('').lstrip()
|
||||
|
||||
root = ET.fromstring(text)
|
||||
|
||||
instances: List[Instance] = []
|
||||
by_referent: Dict[int, Instance] = {}
|
||||
roots: List[Instance] = []
|
||||
|
||||
# Auto-increment id для Item'ов без referent (старые форматы)
|
||||
next_id_counter = [100000]
|
||||
|
||||
def _walk(item_el: ET.Element, parent_ref: Optional[int]) -> None:
|
||||
"""Рекурсивный обход <Item class="..."> элементов."""
|
||||
cls = item_el.attrib.get('class', 'Unknown')
|
||||
|
||||
# Referent из атрибута (например referent="RBX42")
|
||||
ref_attr = item_el.attrib.get('referent') or item_el.attrib.get('Referent')
|
||||
ref_int = _ref_to_int(ref_attr) if ref_attr else None
|
||||
if ref_int is None:
|
||||
# Назначаем auto-id чтобы converter мог отслеживать parent_referent
|
||||
ref_int = next_id_counter[0]
|
||||
next_id_counter[0] += 1
|
||||
|
||||
# Парсим properties
|
||||
props: Dict[str, Any] = {}
|
||||
props_el = item_el.find('Properties')
|
||||
if props_el is not None:
|
||||
for prop_el in props_el:
|
||||
try:
|
||||
pname, pval = _parse_property(prop_el)
|
||||
if pname:
|
||||
props[pname] = pval
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Roblox в старых картах использовал имена с маленькой первой буквы:
|
||||
# name → Name, size → Size, shape → Shape, и т.д. Converter ожидает
|
||||
# PascalCase. Делаем алиасы (старое имя остаётся, новое добавляется).
|
||||
_ALIAS_TO_PASCAL = {
|
||||
'name': 'Name',
|
||||
'size': 'Size',
|
||||
'shape': 'Shape',
|
||||
'archivable': 'Archivable',
|
||||
'shape3d': 'Shape',
|
||||
}
|
||||
for old, new in _ALIAS_TO_PASCAL.items():
|
||||
if old in props and new not in props:
|
||||
props[new] = props[old]
|
||||
|
||||
# Convert Ref-properties (string "RBX42") в parent_referent если нужно
|
||||
# — пока оставляем как строки.
|
||||
|
||||
inst = Instance(
|
||||
referent=ref_int,
|
||||
class_name=cls,
|
||||
properties=props,
|
||||
parent_referent=parent_ref,
|
||||
children=[],
|
||||
)
|
||||
instances.append(inst)
|
||||
by_referent[ref_int] = inst
|
||||
if parent_ref is None:
|
||||
roots.append(inst)
|
||||
|
||||
# Рекурсивно дочерние Item'ы
|
||||
for child in item_el.findall('Item'):
|
||||
_walk(child, ref_int)
|
||||
|
||||
# Roblox-XML: top-level <Item class="..."> идут под <roblox>
|
||||
for item in root.findall('Item'):
|
||||
_walk(item, None)
|
||||
|
||||
# Заполняем children после полного прохода (для удобства converter'а)
|
||||
for inst in instances:
|
||||
if inst.parent_referent is not None:
|
||||
parent = by_referent.get(inst.parent_referent)
|
||||
if parent is not None:
|
||||
parent.children.append(inst)
|
||||
|
||||
# Версия из атрибута <roblox version="4">
|
||||
version_attr = root.attrib.get('version', '4')
|
||||
try:
|
||||
version = int(version_attr)
|
||||
except ValueError:
|
||||
version = 4
|
||||
|
||||
return RobloxModel(
|
||||
version=version,
|
||||
class_count=len(set(i.class_name for i in instances)),
|
||||
instance_count=len(instances),
|
||||
instances=instances,
|
||||
by_referent=by_referent,
|
||||
roots=roots,
|
||||
shared_strings=[],
|
||||
meta={},
|
||||
warnings=[],
|
||||
)
|
||||
@ -211,42 +211,6 @@ export class GameRuntime {
|
||||
try { this._registerRbxlTool(payload); } catch (e) {
|
||||
console.warn('[GameRuntime] toolRegistered failed', e);
|
||||
}
|
||||
} else if (cmd === 'lightingTimeUpdate') {
|
||||
// Roblox Lighting:SetMinutesAfterMidnight → Babylon небо.
|
||||
// Ускоряем в 8x + меняем пресет skybox (clear/sunset/night).
|
||||
try {
|
||||
const baseHour = Number(payload?.hour);
|
||||
if (baseHour >= 0 && baseHour < 24) {
|
||||
if (this._lightBaseHour == null) {
|
||||
this._lightBaseHour = baseHour;
|
||||
this._lightStartReal = performance.now();
|
||||
}
|
||||
const dGame = baseHour - this._lightBaseHour;
|
||||
const accel = 8;
|
||||
const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24;
|
||||
this.scene3d?.setTimeOfDay?.(hour);
|
||||
// Skybox preset по фазе:
|
||||
// 06-08 sunset, 08-17 clear, 17-19 sunset, 19-06 starry-night
|
||||
let targetPreset;
|
||||
if (hour >= 6 && hour < 8) targetPreset = 'sunset';
|
||||
else if (hour >= 8 && hour < 17) targetPreset = 'lowpoly-roblox';
|
||||
else if (hour >= 17 && hour < 19) targetPreset = 'sunset';
|
||||
else targetPreset = 'starry-night';
|
||||
if (this._lightPreset !== targetPreset) {
|
||||
this._lightPreset = targetPreset;
|
||||
try {
|
||||
const sb = this.scene3d?.skybox;
|
||||
if (sb?.fadeTo) sb.fadeTo({ preset: targetPreset }, 2);
|
||||
else this.scene3d?.setSkybox?.({ preset: targetPreset });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (cmd === 'particleCreated') {
|
||||
// Roblox Instance.new('Sparkles') — запомнили какие
|
||||
// partlcle-эффекты есть у Tool. При equip покажем у руки.
|
||||
this._rbxlPendingParticles = this._rbxlPendingParticles || [];
|
||||
this._rbxlPendingParticles.push(payload);
|
||||
} else {
|
||||
this._handleCommand(null, cmd, payload);
|
||||
}
|
||||
@ -581,12 +545,8 @@ export class GameRuntime {
|
||||
* Слушает клики ЛКМ → шлёт mouseButton1Down (Tool.Activated fires там). */
|
||||
_registerRbxlTool(payload) {
|
||||
if (!payload || payload.index == null) return;
|
||||
// invUI — это новая drag-drop система с defineItem, а не inventory (старая)
|
||||
const invUI = this.scene3d?.invUI;
|
||||
if (!invUI || typeof invUI.defineItem !== 'function') {
|
||||
console.warn('[GameRuntime] invUI not available for tool', payload);
|
||||
return;
|
||||
}
|
||||
const invUI = this.scene3d?.inventory;
|
||||
if (!invUI) return;
|
||||
const itemId = `rbxlTool_${payload.index}`;
|
||||
const toolName = String(payload.name || `Tool ${payload.index}`);
|
||||
invUI.defineItem({
|
||||
@ -605,19 +565,6 @@ export class GameRuntime {
|
||||
if (!this._rbxlToolHooks) {
|
||||
this._rbxlToolHooks = true;
|
||||
this._rbxlActiveSlot = -1;
|
||||
// Авто-эквип первого Tool сразу при регистрации — иначе юзер
|
||||
// не понимает что нажимать. В Roblox StarterPack тоже сразу
|
||||
// в Backpack попадает и юзер жмёт 1 для эквипа.
|
||||
setTimeout(() => {
|
||||
if (this._rbxlActiveSlot < 0) {
|
||||
invUI.setActiveHotbar?.(slot);
|
||||
const sb = this._luaUserSandbox;
|
||||
sb?.sendGlobalEvent?.({ type: 'equipTool', index: payload.index });
|
||||
this._rbxlActiveSlot = slot;
|
||||
// Если у Tool были Sparkles — рисуем непрерывно у руки игрока
|
||||
this._startRbxlToolParticles();
|
||||
}
|
||||
}, 100);
|
||||
invUI.on('slot', () => {
|
||||
const sl = invUI.active;
|
||||
const item = invUI.hotbar[sl];
|
||||
@ -627,11 +574,9 @@ export class GameRuntime {
|
||||
const idx = +item.itemId.slice('rbxlTool_'.length);
|
||||
sb.sendGlobalEvent?.({ type: 'equipTool', index: idx });
|
||||
this._rbxlActiveSlot = sl;
|
||||
this._startRbxlToolParticles();
|
||||
} else if (this._rbxlActiveSlot >= 0) {
|
||||
sb.sendGlobalEvent?.({ type: 'unequipTool' });
|
||||
this._rbxlActiveSlot = -1;
|
||||
this._stopRbxlToolParticles();
|
||||
}
|
||||
});
|
||||
// Клики мыши при экипированном Tool — Activated/mouseButton1Down
|
||||
@ -657,41 +602,6 @@ export class GameRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/** Запускает непрерывный эмиттер Sparkles у руки игрока, пока Tool экипирован. */
|
||||
_startRbxlToolParticles() {
|
||||
if (this._rbxlSparkInterval) return;
|
||||
const particles = this._rbxlPendingParticles || [];
|
||||
if (particles.length === 0) return;
|
||||
// RayGun Color3.new(0,0,1) → #0000ff. Берём цвет первой партиклы.
|
||||
const p0 = particles[0] || {};
|
||||
const col = p0.color || [0, 0, 1];
|
||||
const hexCol = '#' + [col[0], col[1], col[2]].map(c => {
|
||||
const v = Math.max(0, Math.min(255, Math.round((Number(c) || 0) * 255)));
|
||||
return v.toString(16).padStart(2, '0');
|
||||
}).join('');
|
||||
// Каждые 200мс — короткий burst у руки игрока (приблизительно)
|
||||
this._rbxlSparkInterval = setInterval(() => {
|
||||
try {
|
||||
const pl = this.scene3d?.player;
|
||||
if (!pl || !pl._pos) return;
|
||||
this.scene3d?._spawnParticleEffect?.({
|
||||
type: 'sparks',
|
||||
position: { x: pl._pos.x + 0.3, y: pl._pos.y + 0.4, z: pl._pos.z + 0.3 },
|
||||
color: hexCol,
|
||||
duration: 0.4,
|
||||
count: 0.5,
|
||||
});
|
||||
} catch (_) {}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
_stopRbxlToolParticles() {
|
||||
if (this._rbxlSparkInterval) {
|
||||
clearInterval(this._rbxlSparkInterval);
|
||||
this._rbxlSparkInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Простой raycast от камеры — для mouse.Hit. */
|
||||
_raycastFromCamera() {
|
||||
try {
|
||||
@ -714,11 +624,6 @@ export class GameRuntime {
|
||||
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
|
||||
for (const sb of this.sandboxes) sb.stop();
|
||||
}
|
||||
// Останавливаем эффекты импортированных Tools
|
||||
this._stopRbxlToolParticles?.();
|
||||
this._rbxlToolHooks = false;
|
||||
this._rbxlActiveSlot = -1;
|
||||
this._rbxlPendingParticles = null;
|
||||
// Удаляем все объекты, которые скрипты наспавнили через
|
||||
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
|
||||
// и накапливаются при повторных запусках.
|
||||
|
||||
@ -183,16 +183,8 @@ export class LuaSharedSandbox {
|
||||
Source = nil,
|
||||
}
|
||||
local co = coroutine.create(function()
|
||||
-- pcall защищает от runtime-ошибок которые иначе крашат
|
||||
-- coroutine и могут повредить WASM-стейт. Возвраты
|
||||
-- handler'а намеренно поглощаются.
|
||||
local ok_, err_ = pcall(function()
|
||||
${entry.code}
|
||||
end)
|
||||
if not ok_ then
|
||||
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_))
|
||||
end
|
||||
end)
|
||||
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
|
||||
local ok, ret = coroutine.resume(co)
|
||||
if not ok then
|
||||
|
||||
@ -25,12 +25,6 @@ const SCHEDULER = {
|
||||
const HEARTBEAT_SIGNAL = makeSignal();
|
||||
const STEPPED_SIGNAL = makeSignal();
|
||||
|
||||
// Очередь handler'ов которые надо запустить на следующем tickScheduler.
|
||||
// Этим мы выходим из C-boundary — wait() внутри handler'а становится
|
||||
// безопасным yield в собственной coroutine, потому что handler стартует
|
||||
// уже из main loop, а не из синхронного JS-callback.
|
||||
const _pendingHandlerQueue = [];
|
||||
|
||||
function makeSignal() {
|
||||
const sig = {
|
||||
__isSignal: true,
|
||||
@ -51,14 +45,14 @@ function makeSignal() {
|
||||
sig.connect = sig.Connect;
|
||||
sig.Fire = function (...args) {
|
||||
for (const fn of [...sig.connections]) {
|
||||
// Кладём в очередь, чтобы handler стартовал не в текущем
|
||||
// JS-callback (откуда yield запрещён), а из tickScheduler
|
||||
// в своей coroutine. Безопасно для wait() внутри.
|
||||
_pendingHandlerQueue.push({ fn, args });
|
||||
try { fn(...args); } catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Signal handler]', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
sig.fire = sig.Fire;
|
||||
sig.Wait = () => undefined;
|
||||
sig.Wait = () => null;
|
||||
sig.wait = sig.Wait;
|
||||
return sig;
|
||||
}
|
||||
@ -266,7 +260,7 @@ function makeStubCallable() {
|
||||
fn.connect = fn.Connect;
|
||||
fn.Fire = function () {};
|
||||
fn.fire = fn.Fire;
|
||||
fn.Wait = function () { return undefined; };
|
||||
fn.Wait = function () { return null; };
|
||||
fn.wait = fn.Wait;
|
||||
return fn;
|
||||
}
|
||||
@ -910,16 +904,9 @@ export function registerRobloxShim(lua, opts) {
|
||||
lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75);
|
||||
lighting._minutes = 14 * 60;
|
||||
lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; };
|
||||
let _lastLightSent = 0;
|
||||
lighting.SetMinutesAfterMidnight = function (m) {
|
||||
lighting._minutes = (Number(m) || 0) % 1440;
|
||||
lighting.ClockTime = lighting._minutes / 60;
|
||||
// Тротлинг: не чаще раза в 250мс. Скрипты Day/Night обновляют это
|
||||
// каждый кадр (100+ Hz), это убивает WASM.
|
||||
const now = performance.now();
|
||||
if (now - _lastLightSent < 250) return;
|
||||
_lastLightSent = now;
|
||||
send('lightingTimeUpdate', { hour: lighting.ClockTime });
|
||||
};
|
||||
lighting.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); };
|
||||
lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); };
|
||||
@ -1296,13 +1283,6 @@ export function registerRobloxShim(lua, opts) {
|
||||
inst.Lifetime = { Min: 1, Max: 1 };
|
||||
inst.Brightness = 1;
|
||||
inst.Range = 8;
|
||||
inst.__particleKind = className.toLowerCase();
|
||||
// Шлём событие "создан particle-effect" — GameRuntime может его
|
||||
// привязать к мешу на сцене (например, рукам игрока).
|
||||
send('particleCreated', {
|
||||
kind: inst.__particleKind,
|
||||
color: [inst.Color.R, inst.Color.G, inst.Color.B],
|
||||
});
|
||||
} else if (className === 'Mouse') {
|
||||
inst = newInstance('Mouse', 'Mouse');
|
||||
inst.Button1Down = makeSignal();
|
||||
@ -1400,36 +1380,7 @@ export function registerRobloxShim(lua, opts) {
|
||||
if type(ret) == 'number' then return ret end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Запуск Lua-handler'а из очереди в собственной coroutine.
|
||||
-- Вызывается из JS tickScheduler — мы УЖЕ вышли из C-callback,
|
||||
-- так что wait() внутри handler'а — yield в свою coroutine.
|
||||
__rbxl_next_handler_id = 0
|
||||
function __rbxl_drain_handler(fn, a1, a2, a3, a4)
|
||||
__rbxl_next_handler_id = __rbxl_next_handler_id + 1
|
||||
local handlerId = "handler_" .. __rbxl_next_handler_id
|
||||
-- Оборачиваем call в pcall чтобы поглотить return value handler'а
|
||||
-- (RayGun возвращает :connect(...) объект как последнее выражение,
|
||||
-- что приводит к wasmoon promise-detection crash). pcall возвращает
|
||||
-- (ok, ret1, ret2, ...) — мы их не используем.
|
||||
local co = coroutine.create(function()
|
||||
pcall(fn, a1, a2, a3, a4)
|
||||
end)
|
||||
__rbxl_register_coroutine(handlerId, co)
|
||||
local ok, ret = coroutine.resume(co)
|
||||
if not ok then
|
||||
__rbxl_send_error(handlerId, tostring(ret))
|
||||
__rbxl_unregister_coroutine(handlerId)
|
||||
elseif type(ret) == 'number' then
|
||||
__rbxl_schedule_resume(handlerId, ret)
|
||||
elseif coroutine.status(co) == 'dead' then
|
||||
__rbxl_unregister_coroutine(handlerId)
|
||||
end
|
||||
-- Явно ничего не возвращаем чтобы wasmoon не оборачивал nil
|
||||
end
|
||||
`);
|
||||
// Кешируем ссылку на Lua-функцию запуска handler'а
|
||||
const luaDrainHandler = lua.global.get('__rbxl_drain_handler');
|
||||
// Добавим Lua-side helper для лога
|
||||
global.set('__log', (level, text) => {
|
||||
send('log', { level: String(level || 'info'), text: String(text || '') });
|
||||
@ -1475,26 +1426,7 @@ export function registerRobloxShim(lua, opts) {
|
||||
onDataSnapshot() {},
|
||||
|
||||
tickScheduler(_dt) {
|
||||
// 0a. Lua-handlers из очереди (signal.Fire отложил их сюда).
|
||||
// Запускаем каждый в своей coroutine — wait() внутри безопасен.
|
||||
if (_pendingHandlerQueue.length > 0) {
|
||||
const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length);
|
||||
for (const h of queue) {
|
||||
try {
|
||||
// Только реальное число аргументов. wasmoon не любит
|
||||
// undefined/null — может попытаться обернуть как promise.
|
||||
const a = h.args || [];
|
||||
if (a.length === 0) luaDrainHandler(h.fn);
|
||||
else if (a.length === 1) luaDrainHandler(h.fn, a[0]);
|
||||
else if (a.length === 2) luaDrainHandler(h.fn, a[0], a[1]);
|
||||
else if (a.length === 3) luaDrainHandler(h.fn, a[0], a[1], a[2]);
|
||||
else luaDrainHandler(h.fn, a[0], a[1], a[2], a[3]);
|
||||
} catch (e) {
|
||||
console.error('[handler-drain]', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 0b. Tweens
|
||||
// 0. Tweens
|
||||
_stepTweens(_dt);
|
||||
const now = SCHEDULER.now();
|
||||
// 1. task.delay / task.defer
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user