@clovis500c/figma-bridge 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/server.js +443 -112
- package/package.json +1 -1
- package/plugin/code.js +327 -53
package/dist/server.js
CHANGED
|
@@ -214,7 +214,7 @@ var init_setup = __esm(() => {
|
|
|
214
214
|
});
|
|
215
215
|
|
|
216
216
|
// package.json
|
|
217
|
-
var version = "1.
|
|
217
|
+
var version = "1.14.0";
|
|
218
218
|
var init_package = () => {};
|
|
219
219
|
|
|
220
220
|
// node_modules/zod/v3/helpers/util.js
|
|
@@ -27388,13 +27388,13 @@ Turn ${opt(a.target, "the selected frame")} into code for the project at ${opt(a
|
|
|
27388
27388
|
name: "figma-to-roblox",
|
|
27389
27389
|
title: "Figma to Roblox",
|
|
27390
27390
|
description: "Turn a frame into Roblox UI and place it in Studio with a Roblox Studio MCP.",
|
|
27391
|
-
args: { target: "Layer name or id (default: the selection)", mode: "scale, offset or hybrid
|
|
27391
|
+
args: { target: "Layer name or id (default: the selection)", mode: "scale (default), offset or hybrid" },
|
|
27392
27392
|
render: (a) => `
|
|
27393
27393
|
Turn ${opt(a.target, "the selected frame")} into Roblox UI that looks the same, and put it in the open Roblox Studio place.
|
|
27394
|
-
1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "
|
|
27394
|
+
1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "scale")}"}. Read the warnings (approximations, font substitutions).
|
|
27395
27395
|
2. If assets are not uploaded (placeholders rbxassetid://PENDING_n): upload each file in assets[] with the Roblox Studio MCP (e.g. upload_image), and replace each placeholder in the Luau with the returned id.
|
|
27396
27396
|
3. Run the Luau with execute_luau: it creates the ScreenGui under StarterGui (replacing an earlier copy) and returns it.
|
|
27397
|
-
4. Take a Studio screenshot and compare it with screenshot {returnImage:true} of the Figma frame. Fix differences in the Luau (or in Figma, then export again).
|
|
27397
|
+
4. Take a Studio screenshot and compare it with screenshot {returnImage:true} of the Figma frame. Fix differences in the Luau (or in Figma, then export again), keeping every size, position, padding and gap in scale (no offsets) and the PascalCase names.
|
|
27398
27398
|
5. Report the result and anything Roblox could not reproduce.`
|
|
27399
27399
|
},
|
|
27400
27400
|
{
|
|
@@ -27413,6 +27413,218 @@ Audit and improve this file${a.target ? ` (fixes limited to ${a.target})` : ""}.
|
|
|
27413
27413
|
];
|
|
27414
27414
|
});
|
|
27415
27415
|
|
|
27416
|
+
// plugin/lib/naming.ts
|
|
27417
|
+
function isGenericName(n) {
|
|
27418
|
+
const name = n.name.trim();
|
|
27419
|
+
if (!name || GENERIC_NAME.test(name))
|
|
27420
|
+
return true;
|
|
27421
|
+
if (n.kind === "text" && n.text !== undefined) {
|
|
27422
|
+
const t = n.text.trim();
|
|
27423
|
+
const a = name.toLowerCase();
|
|
27424
|
+
const b = t.toLowerCase();
|
|
27425
|
+
return a === b || a.length >= 12 && b.indexOf(a) === 0;
|
|
27426
|
+
}
|
|
27427
|
+
return false;
|
|
27428
|
+
}
|
|
27429
|
+
function visibleKids(n) {
|
|
27430
|
+
return n.children.filter(function(c) {
|
|
27431
|
+
return !c.hidden;
|
|
27432
|
+
});
|
|
27433
|
+
}
|
|
27434
|
+
function maxFont(n) {
|
|
27435
|
+
let m = n.kind === "text" && n.fontSize ? n.fontSize : 0;
|
|
27436
|
+
for (let i = 0;i < n.children.length; i++) {
|
|
27437
|
+
const c = n.children[i];
|
|
27438
|
+
if (c && !c.hidden)
|
|
27439
|
+
m = Math.max(m, maxFont(c));
|
|
27440
|
+
}
|
|
27441
|
+
return m;
|
|
27442
|
+
}
|
|
27443
|
+
function onlyKinds(list, kinds) {
|
|
27444
|
+
for (let i = 0;i < list.length; i++) {
|
|
27445
|
+
const c = list[i];
|
|
27446
|
+
if (c && kinds.indexOf(c.kind) === -1)
|
|
27447
|
+
return false;
|
|
27448
|
+
}
|
|
27449
|
+
return list.length > 0;
|
|
27450
|
+
}
|
|
27451
|
+
function buttonLike(n) {
|
|
27452
|
+
const kids = visibleKids(n);
|
|
27453
|
+
if (!n.background || n.h > 72 || kids.length === 0 || kids.length > 3)
|
|
27454
|
+
return false;
|
|
27455
|
+
let texts = 0;
|
|
27456
|
+
for (let i = 0;i < kids.length; i++) {
|
|
27457
|
+
const c = kids[i];
|
|
27458
|
+
if (!c)
|
|
27459
|
+
continue;
|
|
27460
|
+
if (c.kind === "text") {
|
|
27461
|
+
texts++;
|
|
27462
|
+
if ((c.text || "").length > 32)
|
|
27463
|
+
return false;
|
|
27464
|
+
} else if (c.kind !== "vector" && c.kind !== "image")
|
|
27465
|
+
return false;
|
|
27466
|
+
}
|
|
27467
|
+
return texts === 1;
|
|
27468
|
+
}
|
|
27469
|
+
function isScreen(n) {
|
|
27470
|
+
return SCREEN_W.indexOf(Math.round(n.w)) !== -1 && n.h >= 640 || n.w >= 1024 && n.h >= 600;
|
|
27471
|
+
}
|
|
27472
|
+
function largestText(n, parent) {
|
|
27473
|
+
const others = parent ? visibleKids(parent).filter(function(c) {
|
|
27474
|
+
return c !== n && c.kind === "text";
|
|
27475
|
+
}) : [];
|
|
27476
|
+
const size = n.fontSize || 14;
|
|
27477
|
+
return others.length > 0 && others.every(function(c) {
|
|
27478
|
+
return (c.fontSize || 14) < size;
|
|
27479
|
+
});
|
|
27480
|
+
}
|
|
27481
|
+
function covers(n, p) {
|
|
27482
|
+
return Math.abs(n.x) <= 1 && Math.abs(n.y) <= 1 && n.w >= p.w - 1 && n.h >= p.h - 1;
|
|
27483
|
+
}
|
|
27484
|
+
function roleOf(n, parent, root, top, inButton) {
|
|
27485
|
+
const kids = visibleKids(n);
|
|
27486
|
+
if (n.kind === "text") {
|
|
27487
|
+
const t = (n.text || "").trim();
|
|
27488
|
+
const size = n.fontSize || 14;
|
|
27489
|
+
if (inButton)
|
|
27490
|
+
return "Label";
|
|
27491
|
+
if (NUMERIC.test(t))
|
|
27492
|
+
return "Value";
|
|
27493
|
+
if (size >= top - 0.5 && size >= 18)
|
|
27494
|
+
return "Title";
|
|
27495
|
+
if (size >= 16 && largestText(n, parent))
|
|
27496
|
+
return "Title";
|
|
27497
|
+
if (size >= 20)
|
|
27498
|
+
return "Heading";
|
|
27499
|
+
if (t.length > 60)
|
|
27500
|
+
return "Description";
|
|
27501
|
+
if (size <= 12)
|
|
27502
|
+
return "Caption";
|
|
27503
|
+
if (t.length > 30)
|
|
27504
|
+
return "Description";
|
|
27505
|
+
return "Label";
|
|
27506
|
+
}
|
|
27507
|
+
if (n.kind === "vector")
|
|
27508
|
+
return "Icon";
|
|
27509
|
+
if (n.kind === "image" && kids.length === 0) {
|
|
27510
|
+
const round = n.ellipse || n.radius !== undefined && Math.abs(n.w - n.h) <= 1 && n.radius >= n.w / 2 - 1;
|
|
27511
|
+
return round ? "Avatar" : "Image";
|
|
27512
|
+
}
|
|
27513
|
+
if (kids.length === 0) {
|
|
27514
|
+
if (Math.min(n.w, n.h) <= 2 && Math.max(n.w, n.h) > 8)
|
|
27515
|
+
return "Divider";
|
|
27516
|
+
if (parent && covers(n, parent))
|
|
27517
|
+
return "Background";
|
|
27518
|
+
if (n.clickable)
|
|
27519
|
+
return "Button";
|
|
27520
|
+
if (n.ellipse)
|
|
27521
|
+
return n.w <= 16 ? "Dot" : "Circle";
|
|
27522
|
+
return "Shape";
|
|
27523
|
+
}
|
|
27524
|
+
if (n.clickable)
|
|
27525
|
+
return "Button";
|
|
27526
|
+
if (buttonLike(n)) {
|
|
27527
|
+
const label = kids.filter(function(c) {
|
|
27528
|
+
return c.kind === "text";
|
|
27529
|
+
})[0];
|
|
27530
|
+
return n.h <= 24 || label && (label.fontSize || 14) <= 12 ? "Badge" : "Button";
|
|
27531
|
+
}
|
|
27532
|
+
if (!parent) {
|
|
27533
|
+
if (isScreen(n))
|
|
27534
|
+
return "Screen";
|
|
27535
|
+
} else if (parent === root && isScreen(root)) {
|
|
27536
|
+
if (n.w >= root.w * 0.9 && n.y <= root.h * 0.1 && n.h <= root.h * 0.25)
|
|
27537
|
+
return "Header";
|
|
27538
|
+
if (n.w >= root.w * 0.9 && n.y + n.h >= root.h * 0.9 && n.h <= root.h * 0.25)
|
|
27539
|
+
return "Footer";
|
|
27540
|
+
if (n.h >= root.h * 0.9 && n.w <= root.w * 0.35 && n.x <= 1)
|
|
27541
|
+
return "Sidebar";
|
|
27542
|
+
}
|
|
27543
|
+
const flow = kids.filter(function(c) {
|
|
27544
|
+
return !c.absolute;
|
|
27545
|
+
});
|
|
27546
|
+
if (flow.length >= 2) {
|
|
27547
|
+
const r = baseName(flow[0]);
|
|
27548
|
+
const same = flow.every(function(c) {
|
|
27549
|
+
return baseName(c) === r;
|
|
27550
|
+
});
|
|
27551
|
+
if (same && r && r !== "Label" && r !== "Value" && r !== "Divider") {
|
|
27552
|
+
if (r === "Button")
|
|
27553
|
+
return "Actions";
|
|
27554
|
+
return r + (n.layout === "GRID" || n.wrap ? "Grid" : "List");
|
|
27555
|
+
}
|
|
27556
|
+
}
|
|
27557
|
+
if (n.background)
|
|
27558
|
+
return n.radius ? "Card" : "Panel";
|
|
27559
|
+
if (onlyKinds(kids, ["text"]))
|
|
27560
|
+
return "TextGroup";
|
|
27561
|
+
if (n.layout === "HORIZONTAL")
|
|
27562
|
+
return "Row";
|
|
27563
|
+
if (n.layout === "GRID")
|
|
27564
|
+
return "Grid";
|
|
27565
|
+
return "Container";
|
|
27566
|
+
}
|
|
27567
|
+
function baseName(n) {
|
|
27568
|
+
return pascalName(n.role || n.name).replace(/\d+$/, "");
|
|
27569
|
+
}
|
|
27570
|
+
function inferRoles(root) {
|
|
27571
|
+
const top = maxFont(root);
|
|
27572
|
+
const walk = function(n, parent, inButton) {
|
|
27573
|
+
const button = n.kind !== "text" && (n.clickable || buttonLike(n) || /button|btn/i.test(n.name));
|
|
27574
|
+
for (let i = 0;i < n.children.length; i++) {
|
|
27575
|
+
const c = n.children[i];
|
|
27576
|
+
if (c)
|
|
27577
|
+
walk(c, n, inButton || button);
|
|
27578
|
+
}
|
|
27579
|
+
if (isGenericName(n))
|
|
27580
|
+
n.role = roleOf(n, parent, root, top, inButton);
|
|
27581
|
+
};
|
|
27582
|
+
walk(root, null, false);
|
|
27583
|
+
}
|
|
27584
|
+
function pascalName(name) {
|
|
27585
|
+
let segs = name.split("/").map(function(s) {
|
|
27586
|
+
return s.replace(/^[\w-]+:/, "").trim();
|
|
27587
|
+
}).filter(function(s) {
|
|
27588
|
+
return s && !/^default$/i.test(s);
|
|
27589
|
+
});
|
|
27590
|
+
if (segs.length > 1)
|
|
27591
|
+
segs = segs.slice(1).concat([segs[0]]);
|
|
27592
|
+
const words = segs.join(" ").normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
27593
|
+
const out = words.map(function(w) {
|
|
27594
|
+
return w === w.toUpperCase() && w.length <= 4 ? w : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
|
27595
|
+
}).join("");
|
|
27596
|
+
return out.slice(0, 48);
|
|
27597
|
+
}
|
|
27598
|
+
function endsWithWord(name, words) {
|
|
27599
|
+
for (let i = 0;i < words.length; i++) {
|
|
27600
|
+
const w = words[i];
|
|
27601
|
+
if (name.length >= w.length && name.slice(-w.length) === w)
|
|
27602
|
+
return true;
|
|
27603
|
+
}
|
|
27604
|
+
return false;
|
|
27605
|
+
}
|
|
27606
|
+
function uniqueSiblingNames(names) {
|
|
27607
|
+
const counts = {};
|
|
27608
|
+
for (let i = 0;i < names.length; i++) {
|
|
27609
|
+
const k = names[i];
|
|
27610
|
+
counts[k] = (counts[k] || 0) + 1;
|
|
27611
|
+
}
|
|
27612
|
+
const seen = {};
|
|
27613
|
+
return names.map(function(k) {
|
|
27614
|
+
if ((counts[k] || 0) < 2)
|
|
27615
|
+
return k;
|
|
27616
|
+
seen[k] = (seen[k] || 0) + 1;
|
|
27617
|
+
return k + seen[k];
|
|
27618
|
+
});
|
|
27619
|
+
}
|
|
27620
|
+
var GENERIC_NAME, NUMERIC, SCREEN_W, ROLE_WORDS;
|
|
27621
|
+
var init_naming = __esm(() => {
|
|
27622
|
+
GENERIC_NAME = /^(frame|rectangle|ellipse|group|vector|text|line|polygon|star|component|instance|section|image|auto ?layout|union|subtract|intersect|exclude|boolean|layer|shape|slice|mask|container|wrapper|div|span|box)(\s*\d+)?(\s+copy(\s*\d+)?)?$/i;
|
|
27623
|
+
NUMERIC = /^[\s$€£¥+\-−]*[\d][\d\s.,:%/×x+\-−]*[\s$€£¥%kKmMbB]*$/;
|
|
27624
|
+
SCREEN_W = [360, 375, 390, 393, 402, 412, 414, 428, 430, 440];
|
|
27625
|
+
ROLE_WORDS = ("Actions Avatar Background Badge Banner Bar Body Button Card Column Container Content Controls Details Dialog Divider Footer Frame Grid Group Gui " + "Header Hero Hud HUD Info Item List Menu Modal Nav Navbar Navigation Overlay Panel Popup Row Screen Section Sidebar Slot Stack Stats Tab Tabs Tile " + "Toolbar Tooltip Window Wrapper").split(" ");
|
|
27626
|
+
});
|
|
27627
|
+
|
|
27416
27628
|
// src/roblox/fonts.ts
|
|
27417
27629
|
function robloxFamily(family, overrides = {}) {
|
|
27418
27630
|
const key = family.trim().toLowerCase();
|
|
@@ -27517,26 +27729,24 @@ function visible(list) {
|
|
|
27517
27729
|
function decide(n, opts) {
|
|
27518
27730
|
const approximations = [];
|
|
27519
27731
|
if (n.text)
|
|
27520
|
-
return { kind: "text",
|
|
27732
|
+
return { kind: "text", approximations };
|
|
27521
27733
|
const raster = opts.rasterize;
|
|
27522
27734
|
const fills = visible(n.fills);
|
|
27523
27735
|
const strokes = visible(n.strokes);
|
|
27524
|
-
const
|
|
27525
|
-
const drop = effects.filter((e) => e.type === "DROP_SHADOW");
|
|
27526
|
-
const other = effects.filter((e) => e.type !== "DROP_SHADOW");
|
|
27736
|
+
const other = (n.effects ?? []).filter((e) => e.type !== "DROP_SHADOW");
|
|
27527
27737
|
const hasChildren = !!n.children?.some((c) => !c.hidden);
|
|
27528
|
-
if (n.vector ||
|
|
27529
|
-
return raster === "none" ? { kind: "frame",
|
|
27738
|
+
if (n.vector || VECTOR_TYPES.includes(n.type)) {
|
|
27739
|
+
return raster === "none" ? { kind: "frame", approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", approximations };
|
|
27530
27740
|
}
|
|
27531
27741
|
const image = fills.length === 1 && fills[0].type === "IMAGE";
|
|
27532
|
-
if (image && !hasChildren && !strokes.length && !
|
|
27533
|
-
return { kind: "image",
|
|
27742
|
+
if (image && !hasChildren && !strokes.length && !other.length && raster !== "all")
|
|
27743
|
+
return { kind: "image", approximations };
|
|
27534
27744
|
const reasons = [];
|
|
27535
27745
|
if (fills.length > 1)
|
|
27536
27746
|
reasons.push("several fills");
|
|
27537
27747
|
if (fills.length === 1 && !["SOLID", "GRADIENT_LINEAR", "IMAGE"].includes(fills[0].type))
|
|
27538
27748
|
reasons.push(`${fills[0].type.toLowerCase().replace("_", " ")} fill`);
|
|
27539
|
-
if (image && (strokes.length ||
|
|
27749
|
+
if (image && (strokes.length || other.length))
|
|
27540
27750
|
reasons.push("image with stroke or effects");
|
|
27541
27751
|
if (strokes.length > 1 || strokes[0] && strokes[0].type !== "SOLID")
|
|
27542
27752
|
reasons.push("complex stroke");
|
|
@@ -27546,18 +27756,16 @@ function decide(n, opts) {
|
|
|
27546
27756
|
reasons.push(other.map((e) => e.type.toLowerCase().replace(/_/g, " ")).join(", "));
|
|
27547
27757
|
if (n.type === "ELLIPSE" && Math.abs(n.w - n.h) > 0.5)
|
|
27548
27758
|
reasons.push("oval");
|
|
27549
|
-
const styled = fills.length + strokes.length + effects.length > 0;
|
|
27759
|
+
const styled = fills.length + strokes.length + (n.effects ?? []).length > 0;
|
|
27550
27760
|
if ((reasons.length || raster === "all" && styled) && raster !== "none") {
|
|
27551
|
-
return { kind: hasChildren ? "panel" : "picture",
|
|
27761
|
+
return { kind: hasChildren ? "panel" : "picture", approximations };
|
|
27552
27762
|
}
|
|
27553
27763
|
if (reasons.length)
|
|
27554
27764
|
approximations.push(`${reasons.join(", ")}: approximated (rasterize is none)`);
|
|
27555
27765
|
const r = n.radius ?? [];
|
|
27556
27766
|
if (r.length && r.some((v) => Math.abs(v - r[0]) > 0.5))
|
|
27557
27767
|
approximations.push("different corner radii: used the largest");
|
|
27558
|
-
|
|
27559
|
-
approximations.push("drop shadow skipped (rasterize is none)");
|
|
27560
|
-
return { kind: "frame", shadow: drop.length > 0 && raster !== "none", approximations };
|
|
27768
|
+
return { kind: "frame", approximations };
|
|
27561
27769
|
}
|
|
27562
27770
|
function pictureRequests(root, opts) {
|
|
27563
27771
|
const out = [];
|
|
@@ -27569,8 +27777,6 @@ function pictureRequests(root, opts) {
|
|
|
27569
27777
|
out.push({ id: n.id, mode: "full" });
|
|
27570
27778
|
if (d.kind === "panel")
|
|
27571
27779
|
out.push({ id: n.id, mode: "panel" });
|
|
27572
|
-
if (d.shadow)
|
|
27573
|
-
out.push({ id: n.id, mode: "shadow" });
|
|
27574
27780
|
if (d.kind !== "picture")
|
|
27575
27781
|
for (const c of n.children ?? [])
|
|
27576
27782
|
walk(c);
|
|
@@ -27590,6 +27796,56 @@ function imageHashes(root) {
|
|
|
27590
27796
|
walk(root);
|
|
27591
27797
|
return [...out];
|
|
27592
27798
|
}
|
|
27799
|
+
function robloxName(base, flavor) {
|
|
27800
|
+
switch (flavor) {
|
|
27801
|
+
case "text":
|
|
27802
|
+
return /Label$/.test(base) ? base : `${base}Label`;
|
|
27803
|
+
case "button": {
|
|
27804
|
+
const b = base.replace(/Btn$/, "Button");
|
|
27805
|
+
return /Button$/.test(b) ? b : `${b}Button`;
|
|
27806
|
+
}
|
|
27807
|
+
case "icon":
|
|
27808
|
+
return endsWithWord(base, ICON_WORDS) ? base : `${base}Icon`;
|
|
27809
|
+
case "image":
|
|
27810
|
+
return endsWithWord(base, IMAGE_WORDS) ? base : `${base}Image`;
|
|
27811
|
+
default:
|
|
27812
|
+
return endsWithWord(base, ROLE_WORDS) ? base : `${base}${flavor === "frame" ? "Frame" : "Container"}`;
|
|
27813
|
+
}
|
|
27814
|
+
}
|
|
27815
|
+
function roleTree(n) {
|
|
27816
|
+
const fills = visible(n.fills);
|
|
27817
|
+
const kind = n.text ? "text" : n.vector || VECTOR_TYPES.includes(n.type) ? "vector" : fills.length === 1 && fills[0].type === "IMAGE" && !n.children?.length ? "image" : n.children?.length || n.layout ? "frame" : "shape";
|
|
27818
|
+
return {
|
|
27819
|
+
id: n.id,
|
|
27820
|
+
kind,
|
|
27821
|
+
name: n.name,
|
|
27822
|
+
x: n.x,
|
|
27823
|
+
y: n.y,
|
|
27824
|
+
w: n.w,
|
|
27825
|
+
h: n.h,
|
|
27826
|
+
hidden: n.hidden,
|
|
27827
|
+
text: n.text?.characters,
|
|
27828
|
+
fontSize: n.text ? Math.max(0, ...n.text.segments.map((s) => s.size)) : undefined,
|
|
27829
|
+
layout: n.layout?.mode,
|
|
27830
|
+
wrap: n.layout?.wrap,
|
|
27831
|
+
background: fills.length > 0 || visible(n.strokes).length > 0,
|
|
27832
|
+
radius: Math.max(0, ...n.radius ?? []),
|
|
27833
|
+
ellipse: n.type === "ELLIPSE",
|
|
27834
|
+
clickable: n.clickable,
|
|
27835
|
+
absolute: n.absolute,
|
|
27836
|
+
children: (n.children ?? []).map(roleTree)
|
|
27837
|
+
};
|
|
27838
|
+
}
|
|
27839
|
+
function dedupe(i) {
|
|
27840
|
+
const layers = i.children.filter((c) => !/^UI/.test(c.className));
|
|
27841
|
+
const names = uniqueSiblingNames(layers.map((c) => c.name));
|
|
27842
|
+
layers.forEach((c, k) => {
|
|
27843
|
+
c.name = names[k];
|
|
27844
|
+
c.props[0] = ["Name", S(c.name)];
|
|
27845
|
+
});
|
|
27846
|
+
for (const c of i.children)
|
|
27847
|
+
dedupe(c);
|
|
27848
|
+
}
|
|
27593
27849
|
|
|
27594
27850
|
class Mapper {
|
|
27595
27851
|
opts;
|
|
@@ -27598,48 +27854,82 @@ class Mapper {
|
|
|
27598
27854
|
fontSubstitutions = new Map;
|
|
27599
27855
|
counts = { instances: 0, pictures: 0, skippedHidden: 0 };
|
|
27600
27856
|
textFactor = 1;
|
|
27857
|
+
names = new Map;
|
|
27858
|
+
roles = new Map;
|
|
27601
27859
|
constructor(opts, assets) {
|
|
27602
27860
|
this.opts = opts;
|
|
27603
27861
|
this.assets = assets;
|
|
27604
27862
|
}
|
|
27863
|
+
get scaleOnly() {
|
|
27864
|
+
return this.opts.mode === "scale";
|
|
27865
|
+
}
|
|
27605
27866
|
warn(n, msg) {
|
|
27606
27867
|
if (this.warnings.size < 60)
|
|
27607
27868
|
this.warnings.add(n ? `${n.name}: ${msg}` : msg);
|
|
27608
27869
|
}
|
|
27870
|
+
nameLayers(root) {
|
|
27871
|
+
const tree = roleTree(root);
|
|
27872
|
+
inferRoles(tree);
|
|
27873
|
+
const walk = (r) => {
|
|
27874
|
+
const base = pascalName(r.role ?? r.name) || pascalName(r.role ?? "") || "Layer";
|
|
27875
|
+
this.names.set(r.id, base);
|
|
27876
|
+
if (r.role)
|
|
27877
|
+
this.roles.set(r.id, r.role);
|
|
27878
|
+
for (const c of r.children)
|
|
27879
|
+
walk(c);
|
|
27880
|
+
};
|
|
27881
|
+
walk(tree);
|
|
27882
|
+
}
|
|
27883
|
+
label(n, flavor) {
|
|
27884
|
+
return robloxName(this.names.get(n.id) ?? (pascalName(n.name) || "Layer"), flavor);
|
|
27885
|
+
}
|
|
27886
|
+
isButton(n) {
|
|
27887
|
+
return !!n.clickable || BUTTON_NAME.test(n.name) || this.roles.get(n.id) === "Button";
|
|
27888
|
+
}
|
|
27609
27889
|
map(root) {
|
|
27890
|
+
this.nameLayers(root);
|
|
27610
27891
|
const [tw, th] = this.opts.targetResolution;
|
|
27611
27892
|
const fullScreen = Math.abs(root.w / root.h - tw / th) < 0.1 * (tw / th) && root.w >= tw * 0.4;
|
|
27612
27893
|
if (fullScreen && this.opts.mode !== "offset")
|
|
27613
27894
|
this.textFactor = tw / root.w;
|
|
27614
27895
|
const top = this.node(root, null, 0);
|
|
27615
27896
|
const geo = this.opts.mode === "offset" ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(0, root.w, 0, root.h)]] : fullScreen ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(1, 0, 1, 0)]] : [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(root.w / tw, 0, root.h / th, 0)]];
|
|
27616
|
-
top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex"].includes(k));
|
|
27897
|
+
top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex", "AutomaticSize"].includes(k));
|
|
27617
27898
|
top.props.splice(1, 0, ...geo);
|
|
27618
|
-
top.children.push(this.inst("UIAspectRatioConstraint", "
|
|
27619
|
-
|
|
27620
|
-
|
|
27621
|
-
return
|
|
27899
|
+
top.children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(root.w / root.h)]]));
|
|
27900
|
+
const out = this.opts.asRootFrame ? top : this.inst("ScreenGui", `${(this.names.get(root.id) ?? "Ui").replace(/Gui$/, "")}Gui`, [["ResetOnSpawn", B(false)], ["ZIndexBehavior", E("ZIndexBehavior", "Sibling")], ["IgnoreGuiInset", B(true)]], [top]);
|
|
27901
|
+
dedupe(out);
|
|
27902
|
+
return out;
|
|
27622
27903
|
}
|
|
27623
27904
|
inst(className, name, props = [], children = [], source) {
|
|
27624
27905
|
this.counts.instances++;
|
|
27625
27906
|
return { className, name, props: [["Name", S(name)], ...props], children, ...source ? { source } : {} };
|
|
27626
27907
|
}
|
|
27908
|
+
frameOf(n, parent) {
|
|
27909
|
+
const inFlow = !!parent.layout && !n.absolute;
|
|
27910
|
+
const scale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
|
|
27911
|
+
if (!inFlow)
|
|
27912
|
+
return { scale, w: Math.max(1, parent.w), h: Math.max(1, parent.h), inFlow };
|
|
27913
|
+
const p = parent.layout.padding;
|
|
27914
|
+
return { scale, w: Math.max(1, parent.w - (p[1] ?? 0) - (p[3] ?? 0)), h: Math.max(1, parent.h - (p[0] ?? 0) - (p[2] ?? 0)), inFlow };
|
|
27915
|
+
}
|
|
27916
|
+
hugs(n, d) {
|
|
27917
|
+
if (this.scaleOnly)
|
|
27918
|
+
return { w: false, h: false };
|
|
27919
|
+
const content = d.kind === "text" || !!n.layout;
|
|
27920
|
+
return { w: n.sizing?.h === "HUG" && content, h: n.sizing?.v === "HUG" && content };
|
|
27921
|
+
}
|
|
27627
27922
|
geometry(n, parent, index, d) {
|
|
27628
27923
|
const out = [];
|
|
27629
27924
|
if (!parent)
|
|
27630
27925
|
return out;
|
|
27631
|
-
const
|
|
27632
|
-
const
|
|
27633
|
-
|
|
27634
|
-
|
|
27635
|
-
if (inFlow) {
|
|
27636
|
-
const l = parent.layout;
|
|
27637
|
-
const horizontal = l.mode === "HORIZONTAL";
|
|
27638
|
-
const pw = parent.w - (l.padding[1] ?? 0) - (l.padding[3] ?? 0);
|
|
27639
|
-
const ph = parent.h - (l.padding[0] ?? 0) - (l.padding[2] ?? 0);
|
|
27926
|
+
const f = this.frameOf(n, parent);
|
|
27927
|
+
const hug = this.hugs(n, d);
|
|
27928
|
+
if (f.inFlow) {
|
|
27929
|
+
const horizontal = parent.layout.mode === "HORIZONTAL";
|
|
27640
27930
|
const crossFill = horizontal ? n.sizing?.v === "FILL" || n.stretch : n.sizing?.h === "FILL" || n.stretch;
|
|
27641
|
-
const w =
|
|
27642
|
-
const h =
|
|
27931
|
+
const w = !horizontal && crossFill ? U(1, 0) : hug.w ? U(0, 0) : f.scale ? U(n.w / f.w, 0) : U(0, n.w);
|
|
27932
|
+
const h = horizontal && crossFill ? U(1, 0) : hug.h ? U(0, 0) : f.scale ? U(n.h / f.h, 0) : U(0, n.h);
|
|
27643
27933
|
out.push(["Size", { t: "UDim2", xs: w.s, xo: w.o, ys: h.s, yo: h.o }]);
|
|
27644
27934
|
out.push(["LayoutOrder", I(index + 1)]);
|
|
27645
27935
|
return out;
|
|
@@ -27647,7 +27937,7 @@ class Mapper {
|
|
|
27647
27937
|
const c = n.constraints ?? { h: "MIN", v: "MIN" };
|
|
27648
27938
|
const axis = (pos, size, total, k) => {
|
|
27649
27939
|
const end = total - pos - size;
|
|
27650
|
-
if (k === "SCALE" ||
|
|
27940
|
+
if (k === "SCALE" || f.scale) {
|
|
27651
27941
|
if (k === "MAX")
|
|
27652
27942
|
return { anchor: 1, p: [(pos + size) / total, 0], s: [size / total, 0] };
|
|
27653
27943
|
if (k === "CENTER")
|
|
@@ -27662,39 +27952,63 @@ class Mapper {
|
|
|
27662
27952
|
return { anchor: 0, p: [0, pos], s: [1, -(pos + end)] };
|
|
27663
27953
|
return { anchor: 0, p: [0, pos], s: [0, size] };
|
|
27664
27954
|
};
|
|
27665
|
-
const x = axis(n.x, n.w,
|
|
27666
|
-
const y = axis(n.y, n.h,
|
|
27955
|
+
const x = axis(n.x, n.w, f.w, c.h);
|
|
27956
|
+
const y = axis(n.y, n.h, f.h, c.v);
|
|
27667
27957
|
if (x.anchor || y.anchor)
|
|
27668
27958
|
out.push(["AnchorPoint", V2(x.anchor, y.anchor)]);
|
|
27669
27959
|
out.push(["Position", U2(x.p[0], x.p[1], y.p[0], y.p[1])]);
|
|
27670
|
-
out.push(["Size", U2(
|
|
27960
|
+
out.push(["Size", U2(hug.w ? 0 : x.s[0], hug.w ? 0 : x.s[1], hug.h ? 0 : y.s[0], hug.h ? 0 : y.s[1])]);
|
|
27671
27961
|
out.push(["ZIndex", I(index + 1)]);
|
|
27672
27962
|
return out;
|
|
27673
27963
|
}
|
|
27674
27964
|
automaticSize(n, d) {
|
|
27675
|
-
const
|
|
27676
|
-
|
|
27677
|
-
if (!hugW && !hugH)
|
|
27965
|
+
const hug = this.hugs(n, d);
|
|
27966
|
+
if (!hug.w && !hug.h)
|
|
27678
27967
|
return [];
|
|
27679
|
-
return [["AutomaticSize", E("AutomaticSize",
|
|
27968
|
+
return [["AutomaticSize", E("AutomaticSize", hug.w && hug.h ? "XY" : hug.w ? "X" : "Y")]];
|
|
27680
27969
|
}
|
|
27681
27970
|
corner(n) {
|
|
27682
27971
|
const r = Math.max(0, ...n.radius ?? []);
|
|
27683
27972
|
if (!r && !(n.type === "ELLIPSE"))
|
|
27684
27973
|
return null;
|
|
27685
|
-
const
|
|
27686
|
-
|
|
27974
|
+
const short = Math.max(1, Math.min(n.w, n.h));
|
|
27975
|
+
const pill = n.type === "ELLIPSE" || r >= short / 2 - 0.5;
|
|
27976
|
+
return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : this.scaleOnly ? U(r / short, 0) : U(0, r)]]);
|
|
27687
27977
|
}
|
|
27688
27978
|
stroke(n) {
|
|
27689
27979
|
const s = visible(n.strokes)[0];
|
|
27690
27980
|
if (!s || s.type !== "SOLID" || !n.strokeWeight)
|
|
27691
27981
|
return null;
|
|
27692
|
-
|
|
27982
|
+
const short = Math.max(1, Math.min(n.w, n.h));
|
|
27983
|
+
const props = [
|
|
27693
27984
|
["ApplyStrokeMode", E("ApplyStrokeMode", "Border")],
|
|
27694
27985
|
["Color", C3(s.color)],
|
|
27695
|
-
["Thickness", F(n.strokeWeight)],
|
|
27986
|
+
["Thickness", F(this.scaleOnly ? n.strokeWeight / short : n.strokeWeight)],
|
|
27696
27987
|
["Transparency", F(1 - s.opacity)],
|
|
27697
|
-
["LineJoinMode", E("LineJoinMode", "Round")]
|
|
27988
|
+
["LineJoinMode", E("LineJoinMode", "Round")],
|
|
27989
|
+
["BorderStrokePosition", E("BorderStrokePosition", n.strokeAlign === "OUTSIDE" ? "Outer" : n.strokeAlign === "CENTER" ? "Center" : "Inner")]
|
|
27990
|
+
];
|
|
27991
|
+
if (this.scaleOnly)
|
|
27992
|
+
props.push(["StrokeSizingMode", E("StrokeSizingMode", "ScaledSize")]);
|
|
27993
|
+
return this.inst("UIStroke", "UIStroke", props);
|
|
27994
|
+
}
|
|
27995
|
+
shadow(n) {
|
|
27996
|
+
const list = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
|
|
27997
|
+
if (!list.length)
|
|
27998
|
+
return null;
|
|
27999
|
+
if (list.length > 1)
|
|
28000
|
+
this.warn(n, "several drop shadows: kept the largest");
|
|
28001
|
+
const e = list.reduce((a, b) => b.blur + (b.spread ?? 0) > a.blur + (a.spread ?? 0) ? b : a);
|
|
28002
|
+
const w = Math.max(1, n.w);
|
|
28003
|
+
const h = Math.max(1, n.h);
|
|
28004
|
+
const spread = (e.spread ?? 0) * 2;
|
|
28005
|
+
const s = this.scaleOnly;
|
|
28006
|
+
return this.inst("UIShadow", "UIShadow", [
|
|
28007
|
+
["Color", C3(e.color ?? "#000000")],
|
|
28008
|
+
["Transparency", F(1 - (e.alpha ?? 0.25))],
|
|
28009
|
+
["BlurRadius", s ? U(e.blur / Math.min(w, h), 0) : U(0, e.blur)],
|
|
28010
|
+
["Offset", s ? U2((e.x ?? 0) / w, 0, (e.y ?? 0) / h, 0) : U2(0, e.x ?? 0, 0, e.y ?? 0)],
|
|
28011
|
+
["Spread", s ? U2(spread / w, 0, spread / h, 0) : U2(0, spread, 0, spread)]
|
|
27698
28012
|
]);
|
|
27699
28013
|
}
|
|
27700
28014
|
gradient(p) {
|
|
@@ -27717,18 +28031,26 @@ class Mapper {
|
|
|
27717
28031
|
}
|
|
27718
28032
|
layoutChildren(n) {
|
|
27719
28033
|
const l = n.layout;
|
|
28034
|
+
const s = this.scaleOnly;
|
|
27720
28035
|
const out = [];
|
|
27721
|
-
const [t, r, b, lft] = l.padding;
|
|
28036
|
+
const [t = 0, r = 0, b = 0, lft = 0] = l.padding;
|
|
28037
|
+
const w = Math.max(1, n.w);
|
|
28038
|
+
const h = Math.max(1, n.h);
|
|
28039
|
+
const cw = Math.max(1, w - r - lft);
|
|
28040
|
+
const ch = Math.max(1, h - t - b);
|
|
28041
|
+
const pad = (v, total) => s ? U(v / total, 0) : U(0, v);
|
|
27722
28042
|
if (t || r || b || lft)
|
|
27723
|
-
out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop",
|
|
28043
|
+
out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", pad(t, h)], ["PaddingRight", pad(r, w)], ["PaddingBottom", pad(b, h)], ["PaddingLeft", pad(lft, w)]]));
|
|
27724
28044
|
if (l.mode === "GRID") {
|
|
27725
28045
|
const kids = (n.children ?? []).filter((c) => !c.hidden && !c.absolute);
|
|
27726
28046
|
const first = kids[0];
|
|
27727
28047
|
if (kids.some((k) => Math.abs(k.w - (first?.w ?? 0)) > 1 || Math.abs(k.h - (first?.h ?? 0)) > 1))
|
|
27728
28048
|
this.warn(n, "grid cells of different sizes: UIGridLayout uses the first cell's size");
|
|
28049
|
+
const fw = first?.w ?? 100;
|
|
28050
|
+
const fh = first?.h ?? 100;
|
|
27729
28051
|
out.push(this.inst("UIGridLayout", "UIGridLayout", [
|
|
27730
|
-
["CellSize", U2(0,
|
|
27731
|
-
["CellPadding", U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
|
|
28052
|
+
["CellSize", s ? U2(fw / cw, 0, fh / ch, 0) : U2(0, fw, 0, fh)],
|
|
28053
|
+
["CellPadding", s ? U2((l.columnGap ?? 0) / cw, 0, (l.rowGap ?? 0) / ch, 0) : U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
|
|
27732
28054
|
["FillDirectionMaxCells", I(l.columns ?? 0)],
|
|
27733
28055
|
["SortOrder", E("SortOrder", "LayoutOrder")]
|
|
27734
28056
|
]));
|
|
@@ -27737,10 +28059,11 @@ class Mapper {
|
|
|
27737
28059
|
const horizontal = l.mode === "HORIZONTAL";
|
|
27738
28060
|
const main = l.primary === "CENTER" ? "Center" : l.primary === "MAX" ? horizontal ? "Right" : "Bottom" : horizontal ? "Left" : "Top";
|
|
27739
28061
|
const cross = l.counter === "CENTER" ? "Center" : l.counter === "MAX" ? horizontal ? "Bottom" : "Right" : horizontal ? "Top" : "Left";
|
|
28062
|
+
const gap = l.primary === "SPACE_BETWEEN" ? 0 : l.gap;
|
|
27740
28063
|
const props = [
|
|
27741
28064
|
["FillDirection", E("FillDirection", horizontal ? "Horizontal" : "Vertical")],
|
|
27742
28065
|
["SortOrder", E("SortOrder", "LayoutOrder")],
|
|
27743
|
-
["Padding",
|
|
28066
|
+
["Padding", pad(gap, horizontal ? cw : ch)],
|
|
27744
28067
|
["HorizontalAlignment", E("HorizontalAlignment", horizontal ? main === "Right" ? "Right" : main === "Center" ? "Center" : "Left" : cross === "Right" ? "Right" : cross === "Center" ? "Center" : "Left")],
|
|
27745
28068
|
["VerticalAlignment", E("VerticalAlignment", horizontal ? cross === "Bottom" ? "Bottom" : cross === "Center" ? "Center" : "Top" : main === "Bottom" ? "Bottom" : main === "Center" ? "Center" : "Top")]
|
|
27746
28069
|
];
|
|
@@ -27803,10 +28126,12 @@ class Mapper {
|
|
|
27803
28126
|
props.push(["LineHeight", F(Math.max(1, Math.min(3, base.lineHeight / (base.size * 1.2))))]);
|
|
27804
28127
|
if (t.maxLines)
|
|
27805
28128
|
props.push(["TextTruncate", E("TextTruncate", "AtEnd")]);
|
|
27806
|
-
if (this.opts.textScaled)
|
|
28129
|
+
if (this.opts.textScaled || this.scaleOnly)
|
|
27807
28130
|
props.push(["TextScaled", B(true)]);
|
|
27808
28131
|
if (segs.some((s) => s.letterSpacing))
|
|
27809
28132
|
this.warn(n, "letter spacing has no Roblox equivalent: ignored");
|
|
28133
|
+
if (rich && this.scaleOnly && segs.some((s) => s.size !== base.size))
|
|
28134
|
+
this.warn(n, "several text sizes with TextScaled: the size tags may not scale with the label");
|
|
27810
28135
|
return props;
|
|
27811
28136
|
}
|
|
27812
28137
|
richRun(s, base) {
|
|
@@ -27844,7 +28169,7 @@ class Mapper {
|
|
|
27844
28169
|
const d = decide(n, this.opts);
|
|
27845
28170
|
for (const a of d.approximations)
|
|
27846
28171
|
this.warn(n, a);
|
|
27847
|
-
const button = !!parent &&
|
|
28172
|
+
const button = !!parent && this.isButton(n) && d.kind !== "text";
|
|
27848
28173
|
const geo = this.geometry(n, parent, index, d);
|
|
27849
28174
|
const common = [...geo, ...this.automaticSize(n, d)];
|
|
27850
28175
|
if (n.rotation)
|
|
@@ -27853,26 +28178,32 @@ class Mapper {
|
|
|
27853
28178
|
let className = "Frame";
|
|
27854
28179
|
let props = [];
|
|
27855
28180
|
if (d.kind === "text") {
|
|
27856
|
-
|
|
28181
|
+
const isButton = !!n.clickable || BUTTON_NAME.test(n.name);
|
|
28182
|
+
className = isButton ? "TextButton" : "TextLabel";
|
|
27857
28183
|
props = this.textProps(n);
|
|
27858
|
-
if (
|
|
28184
|
+
if (isButton)
|
|
27859
28185
|
props.push(["AutoButtonColor", B(false)]);
|
|
28186
|
+
if ((n.effects ?? []).some((e) => e.type === "DROP_SHADOW"))
|
|
28187
|
+
this.warn(n, "text shadow has no Roblox equivalent: ignored");
|
|
27860
28188
|
const size = props.find(([k]) => k === "TextSize")[1].v;
|
|
27861
|
-
|
|
27862
|
-
|
|
28189
|
+
const tight = n.text.autoResize === "WIDTH_AND_HEIGHT" || n.text.autoResize === "HEIGHT";
|
|
28190
|
+
const max = this.scaleOnly && tight ? size * 2 : size;
|
|
28191
|
+
children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(max)], ["MinTextSize", I(Math.max(1, Math.round(size * 0.5)))]]));
|
|
28192
|
+
return this.inst(className, this.label(n, isButton ? "button" : "text"), [...common, ...props], children, n.id);
|
|
27863
28193
|
}
|
|
27864
28194
|
if (d.kind === "picture") {
|
|
27865
28195
|
this.counts.pictures++;
|
|
27866
28196
|
const a = this.assetUrl(`${n.id}:full`, n);
|
|
27867
28197
|
className = button ? "ImageButton" : "ImageLabel";
|
|
27868
|
-
const pic = this.pictureGeometry(n, a, geo);
|
|
28198
|
+
const pic = parent ? this.pictureGeometry(n, a, geo, parent) : geo;
|
|
27869
28199
|
props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", "Stretch")]];
|
|
27870
28200
|
if (n.opacity !== undefined && n.opacity < 1)
|
|
27871
28201
|
props.push(["ImageTransparency", F(1 - n.opacity)]);
|
|
27872
28202
|
if (button)
|
|
27873
28203
|
props.push(["AutoButtonColor", B(false)]);
|
|
27874
28204
|
children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F((a.size?.w ?? n.w) / Math.max(1, a.size?.h ?? n.h))]]));
|
|
27875
|
-
|
|
28205
|
+
const flavor = button ? "button" : n.vector || VECTOR_TYPES.includes(n.type) ? "icon" : "image";
|
|
28206
|
+
return this.inst(className, this.label(n, flavor), [...pic, ...props], children, n.id);
|
|
27876
28207
|
}
|
|
27877
28208
|
const fills = visible(n.fills);
|
|
27878
28209
|
const opacity = n.opacity ?? 1;
|
|
@@ -27890,8 +28221,11 @@ class Mapper {
|
|
|
27890
28221
|
const corner = this.corner(n);
|
|
27891
28222
|
if (corner)
|
|
27892
28223
|
children.push(corner);
|
|
28224
|
+
const shadow = this.shadow(n);
|
|
28225
|
+
if (shadow)
|
|
28226
|
+
children.push(shadow);
|
|
27893
28227
|
children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(n.w / Math.max(1, n.h))]]));
|
|
27894
|
-
return this.inst(className, n
|
|
28228
|
+
return this.inst(className, this.label(n, button ? "button" : "image"), [...common, ...props], children, n.id);
|
|
27895
28229
|
}
|
|
27896
28230
|
if (d.kind === "panel") {
|
|
27897
28231
|
this.counts.pictures++;
|
|
@@ -27914,6 +28248,13 @@ class Mapper {
|
|
|
27914
28248
|
props.push(["ImageTransparency", F(1 - opacity)]);
|
|
27915
28249
|
if (button)
|
|
27916
28250
|
props.push(["AutoButtonColor", B(false)]);
|
|
28251
|
+
const shadow = this.shadow(n);
|
|
28252
|
+
if (shadow) {
|
|
28253
|
+
const corner = this.corner(n);
|
|
28254
|
+
if (corner)
|
|
28255
|
+
children.push(corner);
|
|
28256
|
+
children.push(shadow);
|
|
28257
|
+
}
|
|
27917
28258
|
} else {
|
|
27918
28259
|
className = button ? "TextButton" : hasKids && opacity < 1 ? "CanvasGroup" : "Frame";
|
|
27919
28260
|
const f = fills[0];
|
|
@@ -27930,7 +28271,7 @@ class Mapper {
|
|
|
27930
28271
|
} else if (f.type === "IMAGE") {
|
|
27931
28272
|
props.push(["BackgroundTransparency", F(1)]);
|
|
27932
28273
|
const a = this.assetUrl(`image:${f.imageHash}`, n);
|
|
27933
|
-
const bg = this.inst("ImageLabel", "
|
|
28274
|
+
const bg = this.inst("ImageLabel", "BackgroundImage", [["Size", U2(1, 0, 1, 0)], ["ZIndex", I(0)], ["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", f.scaleMode === "FIT" ? "Fit" : "Crop")]]);
|
|
27934
28275
|
const corner = this.corner(n);
|
|
27935
28276
|
if (corner)
|
|
27936
28277
|
bg.children.push(corner);
|
|
@@ -27946,6 +28287,9 @@ class Mapper {
|
|
|
27946
28287
|
const stroke = this.stroke(n);
|
|
27947
28288
|
if (stroke)
|
|
27948
28289
|
children.push(stroke);
|
|
28290
|
+
const shadow = this.shadow(n);
|
|
28291
|
+
if (shadow)
|
|
28292
|
+
children.push(shadow);
|
|
27949
28293
|
}
|
|
27950
28294
|
props.push(["BorderSizePixel", I(0)]);
|
|
27951
28295
|
if (n.clip)
|
|
@@ -27954,8 +28298,7 @@ class Mapper {
|
|
|
27954
28298
|
const flow = n.layout ? kids.filter((k) => !k.absolute) : kids;
|
|
27955
28299
|
const loose = n.layout ? kids.filter((k) => k.absolute) : [];
|
|
27956
28300
|
const me = { w: n.w, h: n.h, layout: n.layout };
|
|
27957
|
-
const
|
|
27958
|
-
const flowMapped = mapped(flow, me);
|
|
28301
|
+
const flowMapped = flow.map((k, i) => this.node(k, me, i)).filter((x) => !!x);
|
|
27959
28302
|
if (n.layout && n.layout.mode !== "GRID") {
|
|
27960
28303
|
for (const k of flow) {
|
|
27961
28304
|
const target = k.grow && !k.hidden ? flowMapped.find((c) => c.source === k.id) : undefined;
|
|
@@ -27973,44 +28316,24 @@ class Mapper {
|
|
|
27973
28316
|
children.push(...this.layoutChildren(n));
|
|
27974
28317
|
children.push(...flowMapped);
|
|
27975
28318
|
}
|
|
27976
|
-
const
|
|
27977
|
-
|
|
27978
|
-
return self;
|
|
27979
|
-
this.counts.pictures++;
|
|
27980
|
-
const a = this.assetUrl(`${n.id}:shadow`, n);
|
|
27981
|
-
const scale = this.opts.scale ?? 2;
|
|
27982
|
-
const sh = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
|
|
27983
|
-
const reach = Math.max(...sh.map((e) => e.blur + (e.spread ?? 0) + Math.max(Math.abs(e.x ?? 0), Math.abs(e.y ?? 0))));
|
|
27984
|
-
const off = a.offset ?? { x: 0, y: 0 };
|
|
27985
|
-
const size = a.size ?? { w: n.w, h: n.h };
|
|
27986
|
-
const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + reach) * scale);
|
|
27987
|
-
const pw = Math.round(size.w * scale);
|
|
27988
|
-
const ph = Math.round(size.h * scale);
|
|
27989
|
-
const ix = Math.min(inset, Math.floor(pw / 2) - 1);
|
|
27990
|
-
const iy = Math.min(inset, Math.floor(ph / 2) - 1);
|
|
27991
|
-
const shadow = this.inst("ImageLabel", "Shadow", [
|
|
27992
|
-
["Position", U2(0, off.x, 0, off.y)],
|
|
27993
|
-
["Size", U2(1, size.w - n.w, 1, size.h - n.h)],
|
|
27994
|
-
["ZIndex", I(1)],
|
|
27995
|
-
["BackgroundTransparency", F(1)],
|
|
27996
|
-
["Image", { t: "Content", url: a.url }],
|
|
27997
|
-
["ScaleType", E("ScaleType", "Slice")],
|
|
27998
|
-
["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
|
|
27999
|
-
["SliceScale", F(1 / scale)]
|
|
28000
|
-
]);
|
|
28001
|
-
self.props = self.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "AutomaticSize"].includes(k));
|
|
28002
|
-
self.props.splice(1, 0, ["Size", U2(1, 0, 1, 0)], ["ZIndex", I(2)]);
|
|
28003
|
-
return this.inst("Frame", n.name, [...common.filter(([k]) => k !== "AutomaticSize"), ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [shadow, self], n.id);
|
|
28319
|
+
const flavor = button ? "button" : fills.length || visible(n.strokes).length || d.kind === "panel" ? "frame" : "container";
|
|
28320
|
+
return this.inst(className, this.label(n, flavor), [...common, ...props], children, n.id);
|
|
28004
28321
|
}
|
|
28005
|
-
pictureGeometry(n, a, geo) {
|
|
28322
|
+
pictureGeometry(n, a, geo, parent) {
|
|
28006
28323
|
if (!a.offset || !a.size || Math.abs(a.offset.x) < 0.5 && Math.abs(a.offset.y) < 0.5 && Math.abs(a.size.w - n.w) < 0.5 && Math.abs(a.size.h - n.h) < 0.5)
|
|
28007
28324
|
return geo;
|
|
28325
|
+
const f = this.frameOf(n, parent);
|
|
28326
|
+
const dw = a.size.w - n.w;
|
|
28327
|
+
const dh = a.size.h - n.h;
|
|
28328
|
+
const ox = a.offset.x;
|
|
28329
|
+
const oy = a.offset.y;
|
|
28008
28330
|
return geo.map(([k, v]) => {
|
|
28009
|
-
if (k
|
|
28010
|
-
return [k,
|
|
28011
|
-
if (
|
|
28012
|
-
return [k, { ...v,
|
|
28013
|
-
|
|
28331
|
+
if (v.t !== "UDim2" || k !== "Size" && k !== "Position")
|
|
28332
|
+
return [k, v];
|
|
28333
|
+
if (f.scale) {
|
|
28334
|
+
return k === "Size" ? [k, { ...v, xs: num(v.xs + dw / f.w), ys: num(v.ys + dh / f.h) }] : [k, { ...v, xs: num(v.xs + ox / f.w), ys: num(v.ys + oy / f.h) }];
|
|
28335
|
+
}
|
|
28336
|
+
return k === "Size" ? [k, { ...v, xo: Math.round(v.xo + dw), yo: Math.round(v.yo + dh) }] : [k, { ...v, xo: Math.round(v.xo + ox), yo: Math.round(v.yo + oy) }];
|
|
28014
28337
|
});
|
|
28015
28338
|
}
|
|
28016
28339
|
}
|
|
@@ -28021,10 +28344,14 @@ function mapToRoblox(root, opts, assets) {
|
|
|
28021
28344
|
m.warn(null, `${m.counts.skippedHidden} hidden layer(s) skipped`);
|
|
28022
28345
|
return { root: out, warnings: [...m.warnings], fontSubstitutions: Object.fromEntries(m.fontSubstitutions), counts: m.counts };
|
|
28023
28346
|
}
|
|
28024
|
-
var num = (v) => Math.round(v *
|
|
28347
|
+
var num = (v) => Math.round(v * 1e4) / 1e4, S = (v) => ({ t: "string", v }), B = (v) => ({ t: "bool", v }), I = (v) => ({ t: "int", v: Math.round(v) }), F = (v) => ({ t: "float", v: num(v) }), E = (e, v) => ({ t: "enum", e, v }), U = (s, o) => ({ t: "UDim", s: num(s), o: Math.round(o) }), U2 = (xs, xo, ys, yo) => ({ t: "UDim2", xs: num(xs), xo: Math.round(xo), ys: num(ys), yo: Math.round(yo) }), V2 = (x, y) => ({ t: "Vector2", x: num(x), y: num(y) }), C3 = (hex) => ({ t: "Color3", ...color3(hex) }), BUTTON_NAME, VECTOR_TYPES, ICON_WORDS, IMAGE_WORDS;
|
|
28025
28348
|
var init_map2 = __esm(() => {
|
|
28349
|
+
init_naming();
|
|
28026
28350
|
init_fonts();
|
|
28027
|
-
BUTTON_NAME =
|
|
28351
|
+
BUTTON_NAME = /button|btn|\bcta\b/i;
|
|
28352
|
+
VECTOR_TYPES = ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"];
|
|
28353
|
+
ICON_WORDS = ["Icon", "Logo", "Image", "Illustration", "Avatar", "Emoji"];
|
|
28354
|
+
IMAGE_WORDS = ["Image", "Icon", "Avatar", "Logo", "Thumbnail", "Picture", "Photo", "Banner", "Background", "Illustration", "Art", "Cover"];
|
|
28028
28355
|
});
|
|
28029
28356
|
|
|
28030
28357
|
// src/roblox/output.ts
|
|
@@ -28186,7 +28513,7 @@ return ${rootVar}
|
|
|
28186
28513
|
function substituteAssets(text, ids) {
|
|
28187
28514
|
return text.replace(/rbxassetid:\/\/PENDING_\d+/g, (m) => ids[m] ? `rbxassetid://${ids[m]}` : m);
|
|
28188
28515
|
}
|
|
28189
|
-
var ENUMS, n = (v) => String(Math.round(v *
|
|
28516
|
+
var ENUMS, n = (v) => String(Math.round(v * 1e4) / 1e4), xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """), KEYWORDS, rgb = (v) => Math.round(v * 255);
|
|
28190
28517
|
var init_output = __esm(() => {
|
|
28191
28518
|
ENUMS = {
|
|
28192
28519
|
ZIndexBehavior: { Global: 0, Sibling: 1 },
|
|
@@ -28202,7 +28529,9 @@ var init_output = __esm(() => {
|
|
|
28202
28529
|
ScaleType: { Stretch: 0, Slice: 1, Tile: 2, Fit: 3, Crop: 4 },
|
|
28203
28530
|
ApplyStrokeMode: { Contextual: 0, Border: 1 },
|
|
28204
28531
|
LineJoinMode: { Round: 0, Bevel: 1, Miter: 2 },
|
|
28205
|
-
TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 }
|
|
28532
|
+
TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 },
|
|
28533
|
+
StrokeSizingMode: { FixedSize: 0, ScaledSize: 1 },
|
|
28534
|
+
BorderStrokePosition: { Outer: 0, Center: 1, Inner: 2 }
|
|
28206
28535
|
};
|
|
28207
28536
|
KEYWORDS = new Set(["and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while", "continue", "export", "type", "game", "workspace", "script", "PARENT", "existing"]);
|
|
28208
28537
|
});
|
|
@@ -28308,7 +28637,7 @@ import { join as join2 } from "node:path";
|
|
|
28308
28637
|
async function exportRoblox(args, deps) {
|
|
28309
28638
|
const env = deps.env ?? process.env;
|
|
28310
28639
|
const opts = {
|
|
28311
|
-
mode: args.mode ?? "
|
|
28640
|
+
mode: args.mode ?? "scale",
|
|
28312
28641
|
targetResolution: args.targetResolution ?? [1920, 1080],
|
|
28313
28642
|
rasterize: args.rasterize ?? "auto",
|
|
28314
28643
|
asRootFrame: args.asRootFrame,
|
|
@@ -32078,7 +32407,7 @@ async function layerNames(session, ids) {
|
|
|
32078
32407
|
return bridge.request("node_info", { ids: [...new Set(ids)] }, 20000).catch(() => ({}));
|
|
32079
32408
|
}
|
|
32080
32409
|
var PORT, CHANNEL, OUT_DIR, LOG_FILE, MAX_OUTPUT_CHARS = 60000, MAX_IMAGE_BYTES, FIGMA_MAX_IMAGE_DIM = 4096, VIEW_MAX_DIM = 1568, bridge, ok = (value) => ({ content: [{ type: "text", text: json(value) }] }), plain = (text) => ({ content: [{ type: "text", text }] }), oneLine = (s, max = 120) => s.replace(/\s+/g, " ").trim().slice(0, max), safeName = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "node", INSTRUCTIONS, BUILD_DESCRIPTION = `Create a whole layout in ONE call from a declarative spec. Fonts load automatically, icons are fetched, images loaded. Much faster and safer than run_script for new UI.
|
|
32081
|
-
Node: {type?, name?, ...props, children?: Node[]}. type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
|
|
32410
|
+
Node: {type?, name?, ...props, children?: Node[]}. Name layers by role (Header, Card, CardList, Title, Description, PrimaryButton, Icon); unnamed layers get a role name (Container, Row, Card, Title, Label, Button, Icon, Divider…). type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
|
|
32082
32411
|
FRAME: layout "row"|"column"|"grid" (auto-layout; omit for free positioning), gap (number|"auto"), padding (n | [v,h] | [t,r,b,l]), align (cross axis: start|center|end|baseline), justify (main axis: start|center|end|between), wrap, rowGap, clip. Frames have no fill unless set.
|
|
32083
32412
|
GRID: layout:"grid", columns (count | tracks like [200,"1fr","2fr","hug"]), rows (same; default: enough rows, hugging), columnGap, rowGap (or gap). Children: span:[rows,cols] or colSpan/rowSpan, cellAlign/cellValign. Give the grid a w for "fr" columns.
|
|
32084
32413
|
SIZE: w / h: number (fixed) | "fill" (stretch inside an auto-layout parent) | "hug". Auto-layout frames hug by default. grow:true. absolute:true with x/y inside auto-layout; x/y for children of free frames.
|
|
@@ -32157,7 +32486,8 @@ var init_server3 = __esm(async () => {
|
|
|
32157
32486
|
"Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
|
|
32158
32487
|
"The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
|
|
32159
32488
|
"With FIGMA_TOKEN set: comments reads and posts file comments pinned to layers, versions lists the history and diffs a version against now.",
|
|
32160
|
-
"
|
|
32489
|
+
"Names: give every layer you create a professional name for its role, as a design system or a developer would: Screen, Header, Navbar, Sidebar, Section, Container, Card, CardList, Row, Grid, Title, Subtitle, Description, Label, Price, PrimaryButton, Icon, Avatar, Badge, Input, Divider, Background. Never leave Frame 12, Rectangle 3 or a text named after its content; build names unnamed layers by role and audit {fix:true} renames default names.",
|
|
32490
|
+
"Roblox: export_roblox turns a frame into a ScreenGui (.rbxmx + Luau for execute_luau + PNG assets), in scale only (no offsets) with PascalCase names (ShopGui, ItemCard, TitleLabel, BuyButton); with a Roblox Studio MCP, upload the assets, run the Luau and check a Studio screenshot. Any Roblox UI you write by hand follows the same rules: UDim2.fromScale, scale padding and gaps, UIStroke ScaledSize, TextScaled + UITextSizeConstraint, UIAspectRatioConstraint on the root.",
|
|
32161
32491
|
"Prompts new-screen, apply-design-system, reproduce-screenshot, import-website, figma-to-code, audit-and-fix and figma-to-roblox are ready-made workflows; the resources figma-bridge://docs/build-spec and figma-bridge://docs/workflow are the full reference.",
|
|
32162
32492
|
"If no file is connected, tell the user to run Plugins → Development → Figma Bridge in Figma (Ctrl+Alt+P re-runs it). If list_sessions reports versionMismatch, ask them to reopen or update the plugin."
|
|
32163
32493
|
];
|
|
@@ -32692,21 +33022,22 @@ ${f.content}`).join(`
|
|
|
32692
33022
|
server.registerTool("export_roblox", {
|
|
32693
33023
|
title: "Export a frame to Roblox UI",
|
|
32694
33024
|
description: "Turn a Figma frame (default: selection) into Roblox UI that looks the same: a .rbxmx model (a ScreenGui, or a Frame with asRootFrame), an equivalent Luau builder " + `script for execute_luau (creates the UI under PARENT, default StarterGui, replaces a previous copy with the same name, returns the root), and the PNG assets.
|
|
32695
|
-
` + "
|
|
32696
|
-
` + "
|
|
33025
|
+
` + "Everything is in SCALE by default: sizes, positions, UIPadding, list gaps, grid cells, UICorner, UIStroke (StrokeSizingMode ScaledSize) and UIShadow are relative, " + "text uses TextScaled with a UITextSizeConstraint, and nothing is in offset. Instances get professional PascalCase names (ShopGui, ItemCard, TitleLabel, BuyButton, CoinIcon, " + `CardList, Header): the layer's own name, or its role when Figma named it (Frame 12 → Card, Container, Row…).
|
|
33026
|
+
` + "Frames become Frames with UICorner, UIStroke, UIGradient, UIShadow, UIPadding; auto-layout becomes UIListLayout (UIFlexItem for fill) or UIGridLayout; " + "constraints become AnchorPoint + UDim2; text becomes TextLabel with FontFace (Inter → Builder Sans…), RichText for mixed styles; clickable or " + "*Button layers become TextButton/ImageButton. What Roblox can't draw (vectors, icons, blurs, several fills, radial gradients) is rasterized at 2×; panels whose background " + `can't be native become 9-slice images with their content on top.
|
|
33027
|
+
` + "mode: scale (default, recommended); offset (pixels) or hybrid (pixels inside auto-layout) only when the user asks for them. " + "Assets: with upload:true and ROBLOX_API_KEY + ROBLOX_CREATOR_ID set, pictures are uploaded through Open Cloud and their ids written in; otherwise each asset has a " + "placeholder (rbxassetid://PENDING_n): upload the files with your Roblox Studio tools (e.g. upload_image), replace the placeholders in the Luau, run it with execute_luau and check a Studio screenshot. " + 'When writing Roblox UI yourself, follow the same rules: scale only, PascalCase role names. Example: {"nodeId":"12:34"}',
|
|
32697
33028
|
inputSchema: {
|
|
32698
33029
|
nodeId: string2().optional(),
|
|
32699
|
-
mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default hybrid"),
|
|
33030
|
+
mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default scale (no offsets); offset or hybrid only if the user asks"),
|
|
32700
33031
|
targetResolution: array(number2().int().min(100).max(8000)).length(2).optional().describe("Screen size the UI is designed for, default [1920, 1080]"),
|
|
32701
33032
|
rasterize: _enum(["auto", "none", "all"]).optional().describe("auto (default): pictures only where Roblox can't draw it; none: approximate instead; all: every styled layer"),
|
|
32702
33033
|
outDir: string2().optional().describe("Folder for the files (default: a new temp folder)"),
|
|
32703
33034
|
upload: boolean2().optional().describe("Upload the pictures through Open Cloud (needs ROBLOX_API_KEY and ROBLOX_CREATOR_ID)"),
|
|
32704
33035
|
asRootFrame: boolean2().optional().describe("Root is a Frame instead of a ScreenGui"),
|
|
32705
|
-
textScaled: boolean2().optional().describe("TextScaled on labels
|
|
33036
|
+
textScaled: boolean2().optional().describe("TextScaled on labels in offset or hybrid mode (always on in scale mode)"),
|
|
32706
33037
|
fonts: object2({}).passthrough().optional().describe('Figma family → Roblox family or rbxasset URL, e.g. {"Inter":"GothamSSm"}'),
|
|
32707
33038
|
parent: string2().optional().describe('Luau expression for the parent, default game:GetService("StarterGui")')
|
|
32708
33039
|
}
|
|
32709
|
-
}, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "
|
|
33040
|
+
}, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "scale"}`, async () => {
|
|
32710
33041
|
const r = await exportRoblox({ ...args, targetResolution: args.targetResolution, fonts: args.fonts }, { request: (m, p, t) => bridge.request(m, p, t), outDir: OUT_DIR });
|
|
32711
33042
|
let luau = r.luau;
|
|
32712
33043
|
if (luau.length > MAX_OUTPUT_CHARS)
|