Compare commits
10 Commits
bb0726b4ad
...
0b677529e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b677529e1 | |||
| 742bca59ee | |||
| b820ad11bd | |||
| 66aac4826e | |||
| ee0d91235c | |||
| 6bec44d778 | |||
| 265c225772 | |||
| 03a6c357d0 | |||
| d750c94a78 | |||
| 52724ab9c8 |
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc
Normal file
Binary file not shown.
@ -122,12 +122,23 @@ def analyze():
|
|||||||
blob = upload.read()
|
blob = upload.read()
|
||||||
if len(blob) > MAX_RBXL_SIZE:
|
if len(blob) > MAX_RBXL_SIZE:
|
||||||
return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413
|
return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413
|
||||||
if not blob.startswith(b'<roblox!'):
|
# Авто-детект XML vs Binary формата.
|
||||||
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400
|
# Бинарный: <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
|
||||||
|
|
||||||
# Парсим
|
# Парсим
|
||||||
try:
|
try:
|
||||||
model = parse(blob)
|
if is_xml:
|
||||||
|
from rbxl_xml_parser import parse_xml
|
||||||
|
model = parse_xml(blob)
|
||||||
|
else:
|
||||||
|
model = parse(blob)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': f'parse failed: {e}'}), 422
|
return jsonify({'error': f'parse failed: {e}'}), 422
|
||||||
|
|
||||||
|
|||||||
342
rbxl-importer/src/rbxl_xml_parser.py
Normal file
342
rbxl-importer/src/rbxl_xml_parser.py
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
"""
|
||||||
|
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,6 +211,42 @@ export class GameRuntime {
|
|||||||
try { this._registerRbxlTool(payload); } catch (e) {
|
try { this._registerRbxlTool(payload); } catch (e) {
|
||||||
console.warn('[GameRuntime] toolRegistered failed', 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 {
|
} else {
|
||||||
this._handleCommand(null, cmd, payload);
|
this._handleCommand(null, cmd, payload);
|
||||||
}
|
}
|
||||||
@ -545,8 +581,12 @@ export class GameRuntime {
|
|||||||
* Слушает клики ЛКМ → шлёт mouseButton1Down (Tool.Activated fires там). */
|
* Слушает клики ЛКМ → шлёт mouseButton1Down (Tool.Activated fires там). */
|
||||||
_registerRbxlTool(payload) {
|
_registerRbxlTool(payload) {
|
||||||
if (!payload || payload.index == null) return;
|
if (!payload || payload.index == null) return;
|
||||||
const invUI = this.scene3d?.inventory;
|
// invUI — это новая drag-drop система с defineItem, а не inventory (старая)
|
||||||
if (!invUI) return;
|
const invUI = this.scene3d?.invUI;
|
||||||
|
if (!invUI || typeof invUI.defineItem !== 'function') {
|
||||||
|
console.warn('[GameRuntime] invUI not available for tool', payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const itemId = `rbxlTool_${payload.index}`;
|
const itemId = `rbxlTool_${payload.index}`;
|
||||||
const toolName = String(payload.name || `Tool ${payload.index}`);
|
const toolName = String(payload.name || `Tool ${payload.index}`);
|
||||||
invUI.defineItem({
|
invUI.defineItem({
|
||||||
@ -565,6 +605,19 @@ export class GameRuntime {
|
|||||||
if (!this._rbxlToolHooks) {
|
if (!this._rbxlToolHooks) {
|
||||||
this._rbxlToolHooks = true;
|
this._rbxlToolHooks = true;
|
||||||
this._rbxlActiveSlot = -1;
|
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', () => {
|
invUI.on('slot', () => {
|
||||||
const sl = invUI.active;
|
const sl = invUI.active;
|
||||||
const item = invUI.hotbar[sl];
|
const item = invUI.hotbar[sl];
|
||||||
@ -574,9 +627,11 @@ export class GameRuntime {
|
|||||||
const idx = +item.itemId.slice('rbxlTool_'.length);
|
const idx = +item.itemId.slice('rbxlTool_'.length);
|
||||||
sb.sendGlobalEvent?.({ type: 'equipTool', index: idx });
|
sb.sendGlobalEvent?.({ type: 'equipTool', index: idx });
|
||||||
this._rbxlActiveSlot = sl;
|
this._rbxlActiveSlot = sl;
|
||||||
|
this._startRbxlToolParticles();
|
||||||
} else if (this._rbxlActiveSlot >= 0) {
|
} else if (this._rbxlActiveSlot >= 0) {
|
||||||
sb.sendGlobalEvent?.({ type: 'unequipTool' });
|
sb.sendGlobalEvent?.({ type: 'unequipTool' });
|
||||||
this._rbxlActiveSlot = -1;
|
this._rbxlActiveSlot = -1;
|
||||||
|
this._stopRbxlToolParticles();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Клики мыши при экипированном Tool — Activated/mouseButton1Down
|
// Клики мыши при экипированном Tool — Activated/mouseButton1Down
|
||||||
@ -602,6 +657,41 @@ 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. */
|
/** Простой raycast от камеры — для mouse.Hit. */
|
||||||
_raycastFromCamera() {
|
_raycastFromCamera() {
|
||||||
try {
|
try {
|
||||||
@ -624,6 +714,11 @@ export class GameRuntime {
|
|||||||
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
|
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
|
||||||
for (const sb of this.sandboxes) sb.stop();
|
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 они остаются на сцене
|
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
|
||||||
// и накапливаются при повторных запусках.
|
// и накапливаются при повторных запусках.
|
||||||
|
|||||||
@ -183,7 +183,15 @@ export class LuaSharedSandbox {
|
|||||||
Source = nil,
|
Source = nil,
|
||||||
}
|
}
|
||||||
local co = coroutine.create(function()
|
local co = coroutine.create(function()
|
||||||
${entry.code}
|
-- 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)
|
end)
|
||||||
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
|
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
|
||||||
local ok, ret = coroutine.resume(co)
|
local ok, ret = coroutine.resume(co)
|
||||||
|
|||||||
@ -25,6 +25,12 @@ const SCHEDULER = {
|
|||||||
const HEARTBEAT_SIGNAL = makeSignal();
|
const HEARTBEAT_SIGNAL = makeSignal();
|
||||||
const STEPPED_SIGNAL = makeSignal();
|
const STEPPED_SIGNAL = makeSignal();
|
||||||
|
|
||||||
|
// Очередь handler'ов которые надо запустить на следующем tickScheduler.
|
||||||
|
// Этим мы выходим из C-boundary — wait() внутри handler'а становится
|
||||||
|
// безопасным yield в собственной coroutine, потому что handler стартует
|
||||||
|
// уже из main loop, а не из синхронного JS-callback.
|
||||||
|
const _pendingHandlerQueue = [];
|
||||||
|
|
||||||
function makeSignal() {
|
function makeSignal() {
|
||||||
const sig = {
|
const sig = {
|
||||||
__isSignal: true,
|
__isSignal: true,
|
||||||
@ -45,14 +51,14 @@ function makeSignal() {
|
|||||||
sig.connect = sig.Connect;
|
sig.connect = sig.Connect;
|
||||||
sig.Fire = function (...args) {
|
sig.Fire = function (...args) {
|
||||||
for (const fn of [...sig.connections]) {
|
for (const fn of [...sig.connections]) {
|
||||||
try { fn(...args); } catch (e) {
|
// Кладём в очередь, чтобы handler стартовал не в текущем
|
||||||
// eslint-disable-next-line no-console
|
// JS-callback (откуда yield запрещён), а из tickScheduler
|
||||||
console.error('[Signal handler]', e);
|
// в своей coroutine. Безопасно для wait() внутри.
|
||||||
}
|
_pendingHandlerQueue.push({ fn, args });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
sig.fire = sig.Fire;
|
sig.fire = sig.Fire;
|
||||||
sig.Wait = () => null;
|
sig.Wait = () => undefined;
|
||||||
sig.wait = sig.Wait;
|
sig.wait = sig.Wait;
|
||||||
return sig;
|
return sig;
|
||||||
}
|
}
|
||||||
@ -260,7 +266,7 @@ function makeStubCallable() {
|
|||||||
fn.connect = fn.Connect;
|
fn.connect = fn.Connect;
|
||||||
fn.Fire = function () {};
|
fn.Fire = function () {};
|
||||||
fn.fire = fn.Fire;
|
fn.fire = fn.Fire;
|
||||||
fn.Wait = function () { return null; };
|
fn.Wait = function () { return undefined; };
|
||||||
fn.wait = fn.Wait;
|
fn.wait = fn.Wait;
|
||||||
return fn;
|
return fn;
|
||||||
}
|
}
|
||||||
@ -904,9 +910,16 @@ export function registerRobloxShim(lua, opts) {
|
|||||||
lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75);
|
lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75);
|
||||||
lighting._minutes = 14 * 60;
|
lighting._minutes = 14 * 60;
|
||||||
lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; };
|
lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; };
|
||||||
|
let _lastLightSent = 0;
|
||||||
lighting.SetMinutesAfterMidnight = function (m) {
|
lighting.SetMinutesAfterMidnight = function (m) {
|
||||||
lighting._minutes = (Number(m) || 0) % 1440;
|
lighting._minutes = (Number(m) || 0) % 1440;
|
||||||
lighting.ClockTime = lighting._minutes / 60;
|
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.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); };
|
||||||
lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); };
|
lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); };
|
||||||
@ -1283,6 +1296,13 @@ export function registerRobloxShim(lua, opts) {
|
|||||||
inst.Lifetime = { Min: 1, Max: 1 };
|
inst.Lifetime = { Min: 1, Max: 1 };
|
||||||
inst.Brightness = 1;
|
inst.Brightness = 1;
|
||||||
inst.Range = 8;
|
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') {
|
} else if (className === 'Mouse') {
|
||||||
inst = newInstance('Mouse', 'Mouse');
|
inst = newInstance('Mouse', 'Mouse');
|
||||||
inst.Button1Down = makeSignal();
|
inst.Button1Down = makeSignal();
|
||||||
@ -1380,7 +1400,36 @@ export function registerRobloxShim(lua, opts) {
|
|||||||
if type(ret) == 'number' then return ret end
|
if type(ret) == 'number' then return ret end
|
||||||
return 0
|
return 0
|
||||||
end
|
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 для лога
|
// Добавим Lua-side helper для лога
|
||||||
global.set('__log', (level, text) => {
|
global.set('__log', (level, text) => {
|
||||||
send('log', { level: String(level || 'info'), text: String(text || '') });
|
send('log', { level: String(level || 'info'), text: String(text || '') });
|
||||||
@ -1426,7 +1475,26 @@ export function registerRobloxShim(lua, opts) {
|
|||||||
onDataSnapshot() {},
|
onDataSnapshot() {},
|
||||||
|
|
||||||
tickScheduler(_dt) {
|
tickScheduler(_dt) {
|
||||||
// 0. Tweens
|
// 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
|
||||||
_stepTweens(_dt);
|
_stepTweens(_dt);
|
||||||
const now = SCHEDULER.now();
|
const now = SCHEDULER.now();
|
||||||
// 1. task.delay / task.defer
|
// 1. task.delay / task.defer
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user