breakpoint-mcp 1.4.1 → 1.12.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 +1 -1
- package/addon/breakpoint_mcp/LICENSE +21 -0
- package/addon/breakpoint_mcp/bridge_server.gd +16 -0
- package/addon/breakpoint_mcp/operations.gd +1 -1
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/dist/index.js +13 -1
- package/dist/schemas.js +215 -0
- package/dist/tools/tabletop.js +1775 -0
- package/dist/tools/vcs.js +458 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1775 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gate } from "../confirm.js";
|
|
3
|
+
import { toFsPath, readFileText } from "../paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* Group N — Card / board / piece authoring composites (`card_*`, and later
|
|
6
|
+
* `board_*` / `piece_*` / `interact_*`).
|
|
7
|
+
*
|
|
8
|
+
* Increment 1 is the Card slice: four composites that turn "build a card scene
|
|
9
|
+
* from a spec", "stamp a card bound to data", "lay out a row/fan/stack/grid of
|
|
10
|
+
* cards", and "stamp one card per row of a table" into single calls instead of
|
|
11
|
+
* dozens of `scene_*` / `control_*` / `node_*` primitives each.
|
|
12
|
+
*
|
|
13
|
+
* Principle: **decompose onto audited primitives.** No tool here talks to the
|
|
14
|
+
* engine directly — each emits an ordered list of existing bridge ops
|
|
15
|
+
* (`scene.new`, `control.create`, `node.set_property`, `resource.create`, …)
|
|
16
|
+
* through an injectable emit-sink, so the whole op-sequence is unit-tested
|
|
17
|
+
* offline (given a spec, assert the exact primitive calls emitted) exactly like
|
|
18
|
+
* the CLI's `runInit({fetchFn})` seam. Nothing new reaches the addon, so the
|
|
19
|
+
* host↔addon contract is unchanged.
|
|
20
|
+
*
|
|
21
|
+
* These tools build *structure* only. They bind data a caller passes in; they
|
|
22
|
+
* never invent card values, names, or rules. What a card looks like is Group N;
|
|
23
|
+
* what a card does is not.
|
|
24
|
+
*/
|
|
25
|
+
// ---- result envelopes (mirror tools/netcode.ts + tools/assetgen.ts) ----
|
|
26
|
+
function ok(obj) {
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
29
|
+
structuredContent: obj,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function fail(err) {
|
|
33
|
+
const be = err;
|
|
34
|
+
const code = be?.code ?? "error";
|
|
35
|
+
const message = be?.message ?? String(err);
|
|
36
|
+
return {
|
|
37
|
+
isError: true,
|
|
38
|
+
content: [{ type: "text", text: `Tabletop compose error [${code}]: ${message}` }],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** A bad-input failure that reads like a bridge error but never reaches the bridge. */
|
|
42
|
+
class ComposeError extends Error {
|
|
43
|
+
code;
|
|
44
|
+
constructor(code, message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.code = code;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// ------------------------------------------------------------ pure helpers ----
|
|
50
|
+
/** Join a scene-relative parent path and a child node name (`.`/`` = root). */
|
|
51
|
+
export function joinPath(parent, child) {
|
|
52
|
+
return parent === "" || parent === "." ? child : `${parent}/${child}`;
|
|
53
|
+
}
|
|
54
|
+
/** Derive a scene root node name from a `res://…/Foo.tscn` path (→ `Foo`). */
|
|
55
|
+
export function sceneRootName(scenePath) {
|
|
56
|
+
const base = scenePath.split("/").pop() ?? scenePath;
|
|
57
|
+
const stem = base.replace(/\.[^.]+$/, "");
|
|
58
|
+
const cleaned = stem.replace(/[^A-Za-z0-9_]/g, "");
|
|
59
|
+
return cleaned.length > 0 ? cleaned : "Card";
|
|
60
|
+
}
|
|
61
|
+
/** Default `res://…/Foo.gd` script path derived from a `res://…/Foo.tscn`. */
|
|
62
|
+
function defaultScriptPath(scenePath) {
|
|
63
|
+
return scenePath.replace(/\.tscn$/, ".gd");
|
|
64
|
+
}
|
|
65
|
+
/** Reject node names that would break a node path. */
|
|
66
|
+
function assertNodeName(name) {
|
|
67
|
+
if (name === "" || /[/\s]/.test(name)) {
|
|
68
|
+
throw new ComposeError("bad_params", `Invalid slot/node name: ${JSON.stringify(name)} (no spaces or slashes)`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Parse `#RGB[A]` hex into 0..1 [r,g,b,a]; throws on a malformed string. */
|
|
72
|
+
export function parseHexColor(hex) {
|
|
73
|
+
const m = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(hex);
|
|
74
|
+
if (!m)
|
|
75
|
+
throw new ComposeError("bad_params", `Malformed colour ${JSON.stringify(hex)} (expected #RRGGBB or #RRGGBBAA)`);
|
|
76
|
+
const h = m[1];
|
|
77
|
+
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
78
|
+
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
79
|
+
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
80
|
+
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
|
81
|
+
return [r, g, b, a];
|
|
82
|
+
}
|
|
83
|
+
/** Tagged-Variant Color for `node.set_property` / `resource.create`. */
|
|
84
|
+
function colorVariant(hex) {
|
|
85
|
+
const [r, g, b, a] = parseHexColor(hex);
|
|
86
|
+
return { __type__: "Color", r, g, b, a };
|
|
87
|
+
}
|
|
88
|
+
/** Tagged-Variant Vector2. */
|
|
89
|
+
function vec2(x, y) {
|
|
90
|
+
return { __type__: "Vector2", x, y };
|
|
91
|
+
}
|
|
92
|
+
/** Tagged-Variant Resource reference (a `res://` load by class). */
|
|
93
|
+
function resourceVariant(cls, path) {
|
|
94
|
+
return { __type__: "Resource", class: cls, path };
|
|
95
|
+
}
|
|
96
|
+
const ALIGN_TO_ENUM = { left: 0, center: 1, right: 2 };
|
|
97
|
+
// ------------------------------------------------------ column expressions ----
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a `card_deck_from_table` column expression against one row. A value is
|
|
100
|
+
* either a bare `{column}` or a composed template like `{name} · {role}`; every
|
|
101
|
+
* `{placeholder}` is replaced by that column's cell. A reference to a column the
|
|
102
|
+
* row does not have is a hard error (surfaced, never silently blank).
|
|
103
|
+
*
|
|
104
|
+
* Returns both the resolved string and the set of columns it referenced (so the
|
|
105
|
+
* caller can compute which table columns went unused). Pure — unit-tested.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveColumnExpr(expr, row) {
|
|
108
|
+
const columns = [];
|
|
109
|
+
const value = expr.replace(/\{([^}]*)\}/g, (_full, rawName) => {
|
|
110
|
+
const name = rawName.trim();
|
|
111
|
+
if (name === "")
|
|
112
|
+
throw new ComposeError("bad_params", `Empty {} placeholder in column expression ${JSON.stringify(expr)}`);
|
|
113
|
+
if (!Object.prototype.hasOwnProperty.call(row, name)) {
|
|
114
|
+
throw new ComposeError("bad_column", `Column ${JSON.stringify(name)} referenced by ${JSON.stringify(expr)} is not in the table`);
|
|
115
|
+
}
|
|
116
|
+
columns.push(name);
|
|
117
|
+
return row[name] ?? "";
|
|
118
|
+
});
|
|
119
|
+
return { value, columns };
|
|
120
|
+
}
|
|
121
|
+
// --------------------------------------------------------------- CSV / JSON ----
|
|
122
|
+
/** Minimal RFC-4180-ish CSV parser: quoted fields, escaped quotes, CRLF. */
|
|
123
|
+
export function parseCsv(text) {
|
|
124
|
+
const rows = [];
|
|
125
|
+
let field = "";
|
|
126
|
+
let record = [];
|
|
127
|
+
let inQuotes = false;
|
|
128
|
+
for (let i = 0; i < text.length; i++) {
|
|
129
|
+
const c = text[i];
|
|
130
|
+
if (inQuotes) {
|
|
131
|
+
if (c === '"') {
|
|
132
|
+
if (text[i + 1] === '"') {
|
|
133
|
+
field += '"';
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
inQuotes = false;
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
field += c;
|
|
141
|
+
}
|
|
142
|
+
else if (c === '"') {
|
|
143
|
+
inQuotes = true;
|
|
144
|
+
}
|
|
145
|
+
else if (c === ",") {
|
|
146
|
+
record.push(field);
|
|
147
|
+
field = "";
|
|
148
|
+
}
|
|
149
|
+
else if (c === "\n" || c === "\r") {
|
|
150
|
+
if (c === "\r" && text[i + 1] === "\n")
|
|
151
|
+
i++;
|
|
152
|
+
record.push(field);
|
|
153
|
+
field = "";
|
|
154
|
+
if (record.length > 1 || record[0] !== "")
|
|
155
|
+
rows.push(record);
|
|
156
|
+
record = [];
|
|
157
|
+
}
|
|
158
|
+
else
|
|
159
|
+
field += c;
|
|
160
|
+
}
|
|
161
|
+
if (field !== "" || record.length > 0) {
|
|
162
|
+
record.push(field);
|
|
163
|
+
if (record.length > 1 || record[0] !== "")
|
|
164
|
+
rows.push(record);
|
|
165
|
+
}
|
|
166
|
+
if (rows.length === 0)
|
|
167
|
+
return [];
|
|
168
|
+
const header = rows[0].map((h) => h.trim());
|
|
169
|
+
return rows.slice(1).map((r) => {
|
|
170
|
+
const obj = {};
|
|
171
|
+
header.forEach((h, idx) => { obj[h] = r[idx] ?? ""; });
|
|
172
|
+
return obj;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Coerce a parsed JSON document into a list of string-valued rows. */
|
|
176
|
+
export function jsonRows(text) {
|
|
177
|
+
let doc;
|
|
178
|
+
try {
|
|
179
|
+
doc = JSON.parse(text);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
throw new ComposeError("bad_table", `Table is not valid JSON: ${e.message}`);
|
|
183
|
+
}
|
|
184
|
+
let arr;
|
|
185
|
+
if (Array.isArray(doc))
|
|
186
|
+
arr = doc;
|
|
187
|
+
else if (doc && typeof doc === "object") {
|
|
188
|
+
const vals = Object.values(doc);
|
|
189
|
+
arr = doc.rows ?? vals.find((v) => Array.isArray(v));
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(arr))
|
|
192
|
+
throw new ComposeError("bad_table", "JSON table must be an array of row objects (or an object holding one)");
|
|
193
|
+
return arr.map((row) => {
|
|
194
|
+
const obj = {};
|
|
195
|
+
if (row && typeof row === "object")
|
|
196
|
+
for (const [k, v] of Object.entries(row))
|
|
197
|
+
obj[k] = v == null ? "" : String(v);
|
|
198
|
+
return obj;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function readTableRows(text, format) {
|
|
202
|
+
return format === "json" ? jsonRows(text) : parseCsv(text);
|
|
203
|
+
}
|
|
204
|
+
const DEFAULT_STEP = 110;
|
|
205
|
+
const DEFAULT_GRID_CELL = 120;
|
|
206
|
+
/**
|
|
207
|
+
* Compute one placement per card for a layout mode. Pure and deterministic (no
|
|
208
|
+
* engine, no card-size probing) so it is unit-tested directly. `rotation` is in
|
|
209
|
+
* radians and only set for `fan`. Positions are top-left offsets in px.
|
|
210
|
+
*/
|
|
211
|
+
export function computeLayout(mode, count, opts = {}) {
|
|
212
|
+
const origin = opts.origin ?? { x: 0, y: 0 };
|
|
213
|
+
const align = opts.align ?? "center";
|
|
214
|
+
const out = [];
|
|
215
|
+
if (count <= 0)
|
|
216
|
+
return out;
|
|
217
|
+
if (mode === "row") {
|
|
218
|
+
const step = (opts.spacing ?? DEFAULT_STEP) - (opts.overlap ?? 0);
|
|
219
|
+
const span = step * (count - 1);
|
|
220
|
+
const shift = align === "center" ? -span / 2 : align === "end" ? -span : 0;
|
|
221
|
+
for (let i = 0; i < count; i++)
|
|
222
|
+
out.push({ x: origin.x + shift + i * step, y: origin.y });
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
if (mode === "fan") {
|
|
226
|
+
const step = (opts.spacing ?? DEFAULT_STEP) - (opts.overlap ?? 0);
|
|
227
|
+
const span = step * (count - 1);
|
|
228
|
+
const shift = align === "end" ? -span : align === "start" ? 0 : -span / 2;
|
|
229
|
+
const total = ((opts.fan_angle ?? 0) * Math.PI) / 180;
|
|
230
|
+
for (let i = 0; i < count; i++) {
|
|
231
|
+
const t = count === 1 ? 0.5 : i / (count - 1);
|
|
232
|
+
out.push({ x: origin.x + shift + i * step, y: origin.y, rotation: count === 1 ? 0 : -total / 2 + t * total });
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
if (mode === "stack") {
|
|
237
|
+
const off = opts.overlap ?? 0;
|
|
238
|
+
for (let i = 0; i < count; i++)
|
|
239
|
+
out.push({ x: origin.x + i * off, y: origin.y + i * off });
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
// grid
|
|
243
|
+
const cols = opts.columns ?? Math.max(1, Math.ceil(Math.sqrt(count)));
|
|
244
|
+
const cell = opts.spacing ?? DEFAULT_GRID_CELL;
|
|
245
|
+
const rowSpan = (cols - 1) * cell;
|
|
246
|
+
const shift = align === "center" ? -rowSpan / 2 : align === "end" ? -rowSpan : 0;
|
|
247
|
+
for (let i = 0; i < count; i++) {
|
|
248
|
+
const r = Math.floor(i / cols);
|
|
249
|
+
const c = i % cols;
|
|
250
|
+
out.push({ x: origin.x + shift + c * cell, y: origin.y + r * cell });
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
/** Node path of a slot's bindable target (badge binds its inner Label). */
|
|
255
|
+
function slotTargetPath(slot) {
|
|
256
|
+
return slot.kind === "badge" ? `Face/${slot.name}/Label` : `Face/${slot.name}`;
|
|
257
|
+
}
|
|
258
|
+
const KIND_TO_CLASS = {
|
|
259
|
+
label: "Label",
|
|
260
|
+
rich_text: "RichTextLabel",
|
|
261
|
+
texture: "TextureRect",
|
|
262
|
+
panel: "Panel",
|
|
263
|
+
badge: "Panel",
|
|
264
|
+
};
|
|
265
|
+
// ------------------------------------------------------ GDScript generator ----
|
|
266
|
+
/**
|
|
267
|
+
* Generate the card's `set_data(data)` / `set_face(face_up)` GDScript. Pure and
|
|
268
|
+
* exported for unit testing. Tab-indented to match the addon's GDScript. The
|
|
269
|
+
* template carries this script so a bound instance updates through one method
|
|
270
|
+
* call and can flip its face at runtime.
|
|
271
|
+
*/
|
|
272
|
+
export function buildCardScript(rootType, slots, hasBack) {
|
|
273
|
+
const L = [];
|
|
274
|
+
L.push("@tool");
|
|
275
|
+
L.push(`extends ${rootType}`);
|
|
276
|
+
L.push("## Card template generated by Breakpoint MCP (Group N). @tool so set_data /");
|
|
277
|
+
L.push("## set_face run in the editor: without it, the edited-scene instance is a");
|
|
278
|
+
L.push("## PlaceholderScriptInstance whose methods aren't callable, so card_instance");
|
|
279
|
+
L.push("## can't bind data at author time (Finding A). Do not edit by hand —");
|
|
280
|
+
L.push("## re-run card_template_create to regenerate.");
|
|
281
|
+
L.push("");
|
|
282
|
+
L.push("func set_data(data: Dictionary) -> Dictionary:");
|
|
283
|
+
L.push("\tvar bound: Array = []");
|
|
284
|
+
L.push("\tfor key in data.keys():");
|
|
285
|
+
L.push("\t\tvar v = data[key]");
|
|
286
|
+
const branches = [];
|
|
287
|
+
for (const slot of slots) {
|
|
288
|
+
const t = slotTargetPath(slot);
|
|
289
|
+
if (slot.kind === "texture") {
|
|
290
|
+
branches.push([
|
|
291
|
+
`key == ${JSON.stringify(slot.name)} and has_node(${JSON.stringify(t)})`,
|
|
292
|
+
`\t\t\tvar _tex = load(str(v))`,
|
|
293
|
+
`\t\t\tif _tex: get_node(${JSON.stringify(t)}).texture = _tex`,
|
|
294
|
+
`\t\t\tbound.append(key)`,
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
else if (slot.kind === "panel") {
|
|
298
|
+
branches.push([
|
|
299
|
+
`key == ${JSON.stringify(slot.name)} and has_node(${JSON.stringify(t)})`,
|
|
300
|
+
`\t\t\tget_node(${JSON.stringify(t)}).self_modulate = _to_color(str(v))`,
|
|
301
|
+
`\t\t\tbound.append(key)`,
|
|
302
|
+
]);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
branches.push([
|
|
306
|
+
`key == ${JSON.stringify(slot.name)} and has_node(${JSON.stringify(t)})`,
|
|
307
|
+
`\t\t\tget_node(${JSON.stringify(t)}).text = str(v)`,
|
|
308
|
+
`\t\t\tbound.append(key)`,
|
|
309
|
+
]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// colour-by bindings: a slot tinted by another data key.
|
|
313
|
+
for (const slot of slots) {
|
|
314
|
+
if (!slot.color_by)
|
|
315
|
+
continue;
|
|
316
|
+
const t = `Face/${slot.name}`;
|
|
317
|
+
branches.push([
|
|
318
|
+
`key == ${JSON.stringify(slot.color_by)} and has_node(${JSON.stringify(t)})`,
|
|
319
|
+
`\t\t\tget_node(${JSON.stringify(t)}).self_modulate = _to_color(str(v))`,
|
|
320
|
+
`\t\t\tbound.append(key)`,
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
branches.forEach((b, i) => {
|
|
324
|
+
L.push(`\t\t${i === 0 ? "if" : "elif"} ${b[0]}:`);
|
|
325
|
+
for (let j = 1; j < b.length; j++)
|
|
326
|
+
L.push(b[j]);
|
|
327
|
+
});
|
|
328
|
+
L.push("\tvar unbound: Array = []");
|
|
329
|
+
L.push("\tfor key in data.keys():");
|
|
330
|
+
L.push("\t\tif not bound.has(key):");
|
|
331
|
+
L.push("\t\t\tunbound.append(key)");
|
|
332
|
+
L.push("\treturn {\"bound\": bound, \"unbound\": unbound}");
|
|
333
|
+
L.push("");
|
|
334
|
+
L.push("func set_face(face_up: bool) -> void:");
|
|
335
|
+
L.push("\tif has_node(\"Face\"):");
|
|
336
|
+
L.push("\t\tget_node(\"Face\").visible = face_up");
|
|
337
|
+
if (hasBack) {
|
|
338
|
+
L.push("\tif has_node(\"Back\"):");
|
|
339
|
+
L.push("\t\tget_node(\"Back\").visible = not face_up");
|
|
340
|
+
}
|
|
341
|
+
L.push("");
|
|
342
|
+
L.push("func _to_color(s: String) -> Color:");
|
|
343
|
+
L.push("\treturn Color.html(s) if s.begins_with(\"#\") else Color(1, 1, 1, 1)");
|
|
344
|
+
L.push("");
|
|
345
|
+
return L.join("\n");
|
|
346
|
+
}
|
|
347
|
+
/** Emit the full op-sequence that builds + saves a card template scene. */
|
|
348
|
+
export async function emitCardTemplate(emit, spec) {
|
|
349
|
+
const rootType = spec.root_type ?? "PanelContainer";
|
|
350
|
+
const scriptPath = spec.script_path ?? defaultScriptPath(spec.path);
|
|
351
|
+
const rootName = sceneRootName(spec.path);
|
|
352
|
+
const hasBack = spec.back !== undefined;
|
|
353
|
+
for (const slot of spec.slots)
|
|
354
|
+
assertNodeName(slot.name);
|
|
355
|
+
// 1. fresh scene rooted at the card node.
|
|
356
|
+
await emit("scene.new", { root_type: rootType, path: spec.path, name: rootName });
|
|
357
|
+
// 2. the face container that holds every slot.
|
|
358
|
+
await emit("control.create", { parent_path: ".", type: "Control", name: "Face" });
|
|
359
|
+
// 3. optional inline theme (built on disk, then assigned at the end).
|
|
360
|
+
let themePath = spec.theme_path;
|
|
361
|
+
if (!themePath && spec.theme) {
|
|
362
|
+
themePath = spec.path.replace(/\.tscn$/, ".theme.tres");
|
|
363
|
+
const sb = spec.theme.panel_stylebox;
|
|
364
|
+
if (sb) {
|
|
365
|
+
const stylePath = spec.path.replace(/\.tscn$/, ".stylebox.tres");
|
|
366
|
+
const props = {};
|
|
367
|
+
if (sb.bg_color)
|
|
368
|
+
props.bg_color = colorVariant(sb.bg_color);
|
|
369
|
+
if (sb.corner_radius !== undefined) {
|
|
370
|
+
for (const c of ["top_left", "top_right", "bottom_left", "bottom_right"])
|
|
371
|
+
props[`corner_radius_${c}`] = sb.corner_radius;
|
|
372
|
+
}
|
|
373
|
+
if (sb.border_width !== undefined) {
|
|
374
|
+
for (const s of ["left", "top", "right", "bottom"])
|
|
375
|
+
props[`border_width_${s}`] = sb.border_width;
|
|
376
|
+
}
|
|
377
|
+
if (sb.border_color)
|
|
378
|
+
props.border_color = colorVariant(sb.border_color);
|
|
379
|
+
await emit("resource.create", { class_name: "StyleBoxFlat", to_path: stylePath, properties: props });
|
|
380
|
+
await emit("theme.create", { to_path: themePath });
|
|
381
|
+
await emit("theme.set_stylebox", { path: themePath, name: "panel", theme_type: rootType, stylebox_path: stylePath });
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
await emit("theme.create", { to_path: themePath });
|
|
385
|
+
}
|
|
386
|
+
if (spec.theme.base_color) {
|
|
387
|
+
await emit("theme.set_color", { path: themePath, name: "font_color", theme_type: "Label", color: parseHexColor(spec.theme.base_color) });
|
|
388
|
+
}
|
|
389
|
+
if (spec.theme.font_path) {
|
|
390
|
+
await emit("theme.set_font", { path: themePath, name: "font", theme_type: "Label", font_path: spec.theme.font_path });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// 4. one node per slot, plus geometry + static styling.
|
|
394
|
+
const slotMap = [];
|
|
395
|
+
for (const slot of spec.slots) {
|
|
396
|
+
const path = `Face/${slot.name}`;
|
|
397
|
+
await emit("control.create", { parent_path: "Face", type: KIND_TO_CLASS[slot.kind], name: slot.name });
|
|
398
|
+
if (slot.kind === "badge") {
|
|
399
|
+
await emit("control.create", { parent_path: path, type: "Label", name: "Label" });
|
|
400
|
+
}
|
|
401
|
+
if (slot.rect) {
|
|
402
|
+
await emit("node.set_property", { path, property: "position", value: vec2(slot.rect.x ?? 0, slot.rect.y ?? 0) });
|
|
403
|
+
if (slot.rect.w !== undefined || slot.rect.h !== undefined) {
|
|
404
|
+
await emit("node.set_property", { path, property: "size", value: vec2(slot.rect.w ?? 0, slot.rect.h ?? 0) });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
else if (slot.anchor_preset !== undefined) {
|
|
408
|
+
await emit("control.set_layout_preset", { path, preset: slot.anchor_preset });
|
|
409
|
+
}
|
|
410
|
+
const textTarget = slotTargetPath(slot);
|
|
411
|
+
const textual = slot.kind === "label" || slot.kind === "rich_text" || slot.kind === "badge";
|
|
412
|
+
if (slot.default_text !== undefined && textual) {
|
|
413
|
+
await emit("node.set_property", { path: textTarget, property: "text", value: slot.default_text });
|
|
414
|
+
}
|
|
415
|
+
if (slot.align && textual) {
|
|
416
|
+
await emit("node.set_property", { path: textTarget, property: "horizontal_alignment", value: ALIGN_TO_ENUM[slot.align] });
|
|
417
|
+
}
|
|
418
|
+
if (slot.wrap && textual) {
|
|
419
|
+
await emit("node.set_property", { path: textTarget, property: "autowrap_mode", value: 2 });
|
|
420
|
+
}
|
|
421
|
+
if (slot.font_size !== undefined && textual) {
|
|
422
|
+
await emit("node.set_property", { path: textTarget, property: "theme_override_font_sizes/font_size", value: slot.font_size });
|
|
423
|
+
}
|
|
424
|
+
slotMap.push({ name: slot.name, node_path: textTarget, kind: slot.kind });
|
|
425
|
+
}
|
|
426
|
+
// 5. optional card back (makes the template two-sided).
|
|
427
|
+
let backNodes = 0;
|
|
428
|
+
if (spec.back) {
|
|
429
|
+
await emit("control.create", { parent_path: ".", type: "Control", name: "Back" });
|
|
430
|
+
backNodes++;
|
|
431
|
+
if (spec.back.art) {
|
|
432
|
+
await emit("control.create", { parent_path: "Back", type: "TextureRect", name: "Art" });
|
|
433
|
+
backNodes++;
|
|
434
|
+
if (spec.back.art.startsWith("res://")) {
|
|
435
|
+
await emit("node.set_property", { path: "Back/Art", property: "texture", value: resourceVariant("Texture2D", spec.back.art) });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (spec.back.color) {
|
|
439
|
+
await emit("control.create", { parent_path: "Back", type: "Panel", name: "Panel" });
|
|
440
|
+
backNodes++;
|
|
441
|
+
await emit("node.set_property", { path: "Back/Panel", property: "self_modulate", value: colorVariant(spec.back.color) });
|
|
442
|
+
}
|
|
443
|
+
await emit("node.set_property", { path: "Back", property: "visible", value: false });
|
|
444
|
+
}
|
|
445
|
+
// 6. generate + attach the card script.
|
|
446
|
+
const source = buildCardScript(rootType, spec.slots, hasBack);
|
|
447
|
+
await emit("resource.create", { class_name: "GDScript", to_path: scriptPath, properties: { source_code: source } });
|
|
448
|
+
await emit("node.set_property", { path: ".", property: "script", value: resourceVariant("GDScript", scriptPath) });
|
|
449
|
+
// 7. assign the theme (if any), then persist.
|
|
450
|
+
if (themePath)
|
|
451
|
+
await emit("control.set_theme", { path: ".", theme_path: themePath });
|
|
452
|
+
await emit("scene.save", {});
|
|
453
|
+
const nodeCount = 1 /* root */ + 1 /* Face */ + spec.slots.length +
|
|
454
|
+
spec.slots.filter((s) => s.kind === "badge").length + backNodes;
|
|
455
|
+
return {
|
|
456
|
+
scene_path: spec.path, script_path: scriptPath, root_type: rootType, has_back: hasBack,
|
|
457
|
+
node_count: nodeCount, saved: true, slots: slotMap,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// ----------------------------------------------------- composite: instance ----
|
|
461
|
+
/** Extract a `{bound, unbound}` split from a `set_data` call result. */
|
|
462
|
+
function splitFromCall(res, data) {
|
|
463
|
+
const result = res?.result;
|
|
464
|
+
const bound = Array.isArray(result?.bound) ? result.bound.map(String) : [];
|
|
465
|
+
const unbound = Array.isArray(result?.unbound)
|
|
466
|
+
? result.unbound.map(String)
|
|
467
|
+
: Object.keys(data).filter((k) => !bound.includes(k));
|
|
468
|
+
return { bound, unbound };
|
|
469
|
+
}
|
|
470
|
+
/** Instance one card + bind + set face. Returns the instance path + bind split. */
|
|
471
|
+
async function emitOneCard(emit, args) {
|
|
472
|
+
const instPath = joinPath(args.parent, args.name);
|
|
473
|
+
await emit("node.instantiate_scene", { parent_path: args.parent, scene_path: args.template_path, name: args.name });
|
|
474
|
+
if (args.placement) {
|
|
475
|
+
await emit("node.set_property", { path: instPath, property: "position", value: vec2(args.placement.x, args.placement.y) });
|
|
476
|
+
if (args.placement.rotation !== undefined && args.placement.rotation !== 0) {
|
|
477
|
+
await emit("node.set_property", { path: instPath, property: "rotation", value: args.placement.rotation });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const res = await emit("node.call_method", { path: instPath, method: "set_data", args: [args.data] });
|
|
481
|
+
await emit("node.call_method", { path: instPath, method: "set_face", args: [args.face_up] });
|
|
482
|
+
return { instance_path: instPath, ...splitFromCall(res, args.data) };
|
|
483
|
+
}
|
|
484
|
+
export async function emitCardInstance(emit, args) {
|
|
485
|
+
const face_up = args.face_up ?? true;
|
|
486
|
+
const name = args.name ?? sceneRootName(args.template_path);
|
|
487
|
+
const { instance_path, bound, unbound } = await emitOneCard(emit, {
|
|
488
|
+
template_path: args.template_path, parent: args.parent, data: args.data, name, face_up,
|
|
489
|
+
placement: args.position ? { x: args.position.x, y: args.position.y } : undefined,
|
|
490
|
+
});
|
|
491
|
+
return { instance_path, face_up, bound, unbound };
|
|
492
|
+
}
|
|
493
|
+
export async function emitCardHand(emit, args) {
|
|
494
|
+
const container = args.parent === "" ? "." : args.parent;
|
|
495
|
+
const base = sceneRootName(args.template_path);
|
|
496
|
+
const places = computeLayout(args.mode, args.cards.length, args);
|
|
497
|
+
const instances = [];
|
|
498
|
+
for (let i = 0; i < args.cards.length; i++) {
|
|
499
|
+
const { instance_path } = await emitOneCard(emit, {
|
|
500
|
+
template_path: args.template_path, parent: container, data: args.cards[i].data,
|
|
501
|
+
name: `${base}_${i}`, face_up: args.cards[i].face_up ?? true, placement: places[i],
|
|
502
|
+
});
|
|
503
|
+
instances.push({ index: i, instance_path });
|
|
504
|
+
}
|
|
505
|
+
return { container_path: container, mode: args.mode, count: instances.length, instances };
|
|
506
|
+
}
|
|
507
|
+
export async function emitDeckFromTable(emit, readFile, args) {
|
|
508
|
+
const container = args.parent === "" ? "." : args.parent;
|
|
509
|
+
const base = sceneRootName(args.template_path);
|
|
510
|
+
const face_up = args.face_up ?? true;
|
|
511
|
+
const format = args.format ?? (args.table_path.toLowerCase().endsWith(".json") ? "json" : "csv");
|
|
512
|
+
const text = readFile(args.table_path);
|
|
513
|
+
if (text === "")
|
|
514
|
+
throw new ComposeError("not_found", `Cannot read table ${args.table_path} (does it exist?)`);
|
|
515
|
+
const allRows = readTableRows(text, format);
|
|
516
|
+
const rows_read = allRows.length;
|
|
517
|
+
const header = new Set();
|
|
518
|
+
for (const r of allRows)
|
|
519
|
+
for (const k of Object.keys(r))
|
|
520
|
+
header.add(k);
|
|
521
|
+
// which columns are actually referenced (by a placeholder, art, or filter)?
|
|
522
|
+
const referenced = new Set();
|
|
523
|
+
if (args.art_column)
|
|
524
|
+
referenced.add(args.art_column);
|
|
525
|
+
if (args.filter)
|
|
526
|
+
referenced.add(args.filter.column);
|
|
527
|
+
// select rows: filter → limit.
|
|
528
|
+
let selected = args.filter
|
|
529
|
+
? allRows.filter((r) => String(r[args.filter.column] ?? "") === String(args.filter.equals))
|
|
530
|
+
: allRows.slice();
|
|
531
|
+
if (args.limit !== undefined)
|
|
532
|
+
selected = selected.slice(0, args.limit);
|
|
533
|
+
const places = args.layout ? computeLayout(args.layout.mode, selected.length, args.layout) : [];
|
|
534
|
+
const instances = [];
|
|
535
|
+
for (let i = 0; i < selected.length; i++) {
|
|
536
|
+
const row = selected[i];
|
|
537
|
+
const data = {};
|
|
538
|
+
for (const [slot, expr] of Object.entries(args.column_map)) {
|
|
539
|
+
const { value, columns } = resolveColumnExpr(expr, row);
|
|
540
|
+
for (const c of columns)
|
|
541
|
+
referenced.add(c);
|
|
542
|
+
data[slot] = value;
|
|
543
|
+
}
|
|
544
|
+
if (args.art_column && row[args.art_column])
|
|
545
|
+
data.art = row[args.art_column];
|
|
546
|
+
const name = `${base}_${i}`;
|
|
547
|
+
const { instance_path } = await emitOneCard(emit, {
|
|
548
|
+
template_path: args.template_path, parent: container, data, name, face_up,
|
|
549
|
+
placement: args.layout ? places[i] : undefined,
|
|
550
|
+
});
|
|
551
|
+
instances.push({ row_index: allRows.indexOf(row), instance_path });
|
|
552
|
+
}
|
|
553
|
+
const unmapped_columns = [...header].filter((c) => !referenced.has(c)).sort();
|
|
554
|
+
return {
|
|
555
|
+
deck_container: container, count: instances.length, rows_read,
|
|
556
|
+
rows_skipped: rows_read - instances.length, unmapped_columns, instances,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Flip an instanced card (or any node exposing a `set_face(bool)` setter — the
|
|
561
|
+
* generated card AND piece scripts both do) between its face and back.
|
|
562
|
+
*
|
|
563
|
+
* Instant (default): calls the setter now, so the visible side changes at once.
|
|
564
|
+
*
|
|
565
|
+
* Animated: instead authors a reusable flip *clip* under the node from existing
|
|
566
|
+
* Group C anim primitives — a horizontal "pinch" on the node's own `scale`
|
|
567
|
+
* (1 → edge-on `(0, 1)` → 1) plus a `method` key that calls the setter at the
|
|
568
|
+
* edge-on midpoint, so playing the clip performs a believable flip and swaps the
|
|
569
|
+
* side exactly when the card is thinnest. Purely additive: it emits only existing
|
|
570
|
+
* `node.*` / `anim.*` ops — no new bridge method — so it stays offline-testable
|
|
571
|
+
* and out of the host↔addon parity scan, exactly like `piece_move`. The clip is
|
|
572
|
+
* played on demand (like `piece_move`'s pop); the current face is unchanged until
|
|
573
|
+
* it plays. The method track path is `.` (the node itself, via the player's
|
|
574
|
+
* default `root_node` of `..`), matching `piece_move`'s `.:scale` convention.
|
|
575
|
+
*/
|
|
576
|
+
export async function emitCardSetFace(emit, args) {
|
|
577
|
+
if (args.node === "")
|
|
578
|
+
throw new ComposeError("bad_params", "Missing 'node' (the card to flip)");
|
|
579
|
+
const method = args.method ?? "set_face";
|
|
580
|
+
assertNodeName(method);
|
|
581
|
+
if (!args.animate) {
|
|
582
|
+
await emit("node.call_method", { path: args.node, method, args: [args.face_up] });
|
|
583
|
+
return { node_path: args.node, face_up: args.face_up, method, animated: false, player_path: null, anim: null };
|
|
584
|
+
}
|
|
585
|
+
const player = args.animate.player ?? "FlipFX";
|
|
586
|
+
const animName = args.animate.anim ?? "flip";
|
|
587
|
+
const duration = args.animate.duration ?? 0.3;
|
|
588
|
+
const transition = args.animate.transition ?? 1.0;
|
|
589
|
+
const mid = duration / 2;
|
|
590
|
+
const playerPath = joinPath(args.node, player);
|
|
591
|
+
// Track 0 — a horizontal pinch on the node's own scale: 1 → edge-on → 1. Keyed
|
|
592
|
+
// relative to the node (`.:scale`), so it needs no world-transform knowledge.
|
|
593
|
+
await emit("anim.player_create", { parent_path: args.node, name: player });
|
|
594
|
+
await emit("anim.create", { player_path: playerPath, name: animName, library: "" });
|
|
595
|
+
await emit("anim.add_track", { player_path: playerPath, name: animName, path: ".:scale", type: "value", library: "" });
|
|
596
|
+
await emit("anim.insert_key", { player_path: playerPath, name: animName, track: 0, time: 0, value: vec2(1, 1), transition, library: "" });
|
|
597
|
+
await emit("anim.insert_key", { player_path: playerPath, name: animName, track: 0, time: mid, value: vec2(0, 1), transition, library: "" });
|
|
598
|
+
await emit("anim.insert_key", { player_path: playerPath, name: animName, track: 0, time: duration, value: vec2(1, 1), transition, library: "" });
|
|
599
|
+
// Track 1 — a method key that swaps the visible side at the edge-on midpoint.
|
|
600
|
+
await emit("anim.add_track", { player_path: playerPath, name: animName, path: ".", type: "method", library: "" });
|
|
601
|
+
await emit("anim.insert_key", { player_path: playerPath, name: animName, track: 1, time: mid, value: { method, args: [args.face_up] }, transition, library: "" });
|
|
602
|
+
await emit("anim.set_length", { player_path: playerPath, name: animName, length: duration, library: "" });
|
|
603
|
+
return { node_path: args.node, face_up: args.face_up, method, animated: true, player_path: playerPath, anim: animName };
|
|
604
|
+
}
|
|
605
|
+
const DEFAULT_CELL_SIZE = 96;
|
|
606
|
+
const BOARD_CELLS_GROUP = "board_cells";
|
|
607
|
+
const CELL_KIND_TO_CLASS = { marker: "Marker2D", control: "Control" };
|
|
608
|
+
/** Degrees → radians. */
|
|
609
|
+
function deg2rad(d) { return (d * Math.PI) / 180; }
|
|
610
|
+
/**
|
|
611
|
+
* Place `ids` evenly around a ring. Pure + deterministic (no engine, no cell
|
|
612
|
+
* probing) so it is unit-tested directly. Angle 0° points +x (right); +y is down
|
|
613
|
+
* (Godot 2D), so with the default -90° start the first cell sits at the top and,
|
|
614
|
+
* clockwise, the rest sweep right → bottom → left. Positions are local offsets in
|
|
615
|
+
* px relative to `center` (default the board root's origin).
|
|
616
|
+
*/
|
|
617
|
+
export function computeRingCells(ids, opts = {}) {
|
|
618
|
+
const n = ids.length;
|
|
619
|
+
const cell = opts.cell_size ?? DEFAULT_CELL_SIZE;
|
|
620
|
+
// Default radius keeps neighbouring cells about `cell_size` apart along the ring.
|
|
621
|
+
const radius = opts.radius ?? (n <= 1 ? cell : Math.max(cell, (cell * n) / (2 * Math.PI)));
|
|
622
|
+
const start = deg2rad(opts.start_deg ?? -90);
|
|
623
|
+
const dir = (opts.clockwise ?? true) ? 1 : -1;
|
|
624
|
+
const cx = opts.center?.x ?? 0;
|
|
625
|
+
const cy = opts.center?.y ?? 0;
|
|
626
|
+
const out = [];
|
|
627
|
+
for (let i = 0; i < n; i++) {
|
|
628
|
+
const a = start + dir * ((2 * Math.PI * i) / n);
|
|
629
|
+
out.push({ id: ids[i], x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) });
|
|
630
|
+
}
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Fill a rows×cols grid left-to-right, top-to-bottom. Cell id is `"<row>_<col>"`.
|
|
635
|
+
* Pure + deterministic; positions are top-left offsets in px from `origin`.
|
|
636
|
+
*/
|
|
637
|
+
export function computeGridCells(rows, cols, cell_size, opts = {}) {
|
|
638
|
+
const cell = cell_size ?? DEFAULT_CELL_SIZE;
|
|
639
|
+
const ox = opts.origin?.x ?? 0;
|
|
640
|
+
const oy = opts.origin?.y ?? 0;
|
|
641
|
+
const out = [];
|
|
642
|
+
for (let r = 0; r < rows; r++) {
|
|
643
|
+
for (let c = 0; c < cols; c++)
|
|
644
|
+
out.push({ id: `${r}_${c}`, x: ox + c * cell, y: oy + r * cell });
|
|
645
|
+
}
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
/** Resolve a layout spec to the ordered list of cells (pure — no emit). */
|
|
649
|
+
export function resolveBoardCells(layout, cell_size) {
|
|
650
|
+
if (layout.mode === "ring") {
|
|
651
|
+
if (layout.cells.length === 0)
|
|
652
|
+
throw new ComposeError("bad_params", "A ring layout needs at least one cell id");
|
|
653
|
+
return computeRingCells(layout.cells, {
|
|
654
|
+
radius: layout.radius, cell_size, start_deg: layout.start_deg,
|
|
655
|
+
clockwise: layout.clockwise, center: layout.center,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
if (layout.mode === "grid") {
|
|
659
|
+
if (layout.rows < 1 || layout.cols < 1)
|
|
660
|
+
throw new ComposeError("bad_params", "A grid layout needs rows >= 1 and cols >= 1");
|
|
661
|
+
return computeGridCells(layout.rows, layout.cols, cell_size);
|
|
662
|
+
}
|
|
663
|
+
if (layout.cells.length === 0)
|
|
664
|
+
throw new ComposeError("bad_params", "An explicit cells layout needs at least one cell");
|
|
665
|
+
return layout.cells.map((c) => ({ id: c.id, x: c.x, y: c.y }));
|
|
666
|
+
}
|
|
667
|
+
/** Node name for a cell id, validated so it can never break a node path. */
|
|
668
|
+
function cellNodeName(id) {
|
|
669
|
+
const name = `cell_${id}`;
|
|
670
|
+
assertNodeName(name);
|
|
671
|
+
return name;
|
|
672
|
+
}
|
|
673
|
+
/** Emit the full op-sequence that builds + saves a board scene with addressable cells. */
|
|
674
|
+
export async function emitBoardCreate(emit, spec) {
|
|
675
|
+
const rootType = spec.root_type ?? "Node2D";
|
|
676
|
+
const cellKind = spec.cell_kind ?? "marker";
|
|
677
|
+
const cellClass = CELL_KIND_TO_CLASS[cellKind];
|
|
678
|
+
const rootName = sceneRootName(spec.path);
|
|
679
|
+
const cells = resolveBoardCells(spec.layout, spec.cell_size);
|
|
680
|
+
// Ids must be unique and must form legal node names.
|
|
681
|
+
const seen = new Set();
|
|
682
|
+
for (const c of cells) {
|
|
683
|
+
cellNodeName(c.id);
|
|
684
|
+
if (seen.has(c.id))
|
|
685
|
+
throw new ComposeError("bad_params", `Duplicate cell id ${JSON.stringify(c.id)}`);
|
|
686
|
+
seen.add(c.id);
|
|
687
|
+
}
|
|
688
|
+
// 1. fresh scene rooted at the board node.
|
|
689
|
+
await emit("scene.new", { root_type: rootType, path: spec.path, name: rootName });
|
|
690
|
+
// 2. optional background (emitted first so it draws behind the cells).
|
|
691
|
+
let backgroundNodes = 0;
|
|
692
|
+
if (spec.background) {
|
|
693
|
+
const bg = spec.background;
|
|
694
|
+
const bgType = bg.art ? (rootType === "Control" ? "TextureRect" : "Sprite2D") : "ColorRect";
|
|
695
|
+
await emit("node.add", { parent_path: ".", type: bgType, name: "Background" });
|
|
696
|
+
backgroundNodes = 1;
|
|
697
|
+
if (bg.art) {
|
|
698
|
+
if (bg.art.startsWith("res://")) {
|
|
699
|
+
await emit("node.set_property", { path: "Background", property: "texture", value: resourceVariant("Texture2D", bg.art) });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
else if (bg.color) {
|
|
703
|
+
await emit("node.set_property", { path: "Background", property: "color", value: colorVariant(bg.color) });
|
|
704
|
+
}
|
|
705
|
+
if (bg.size && bgType !== "Sprite2D") {
|
|
706
|
+
await emit("node.set_property", { path: "Background", property: "size", value: vec2(bg.size.w ?? 0, bg.size.h ?? 0) });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// 3. one anchor node per cell: add → position → join the board_cells group.
|
|
710
|
+
const outCells = [];
|
|
711
|
+
for (const c of cells) {
|
|
712
|
+
const name = cellNodeName(c.id);
|
|
713
|
+
await emit("node.add", { parent_path: ".", type: cellClass, name });
|
|
714
|
+
await emit("node.set_property", { path: name, property: "position", value: vec2(c.x, c.y) });
|
|
715
|
+
await emit("node.add_to_group", { path: name, group: BOARD_CELLS_GROUP });
|
|
716
|
+
outCells.push({ id: c.id, node_path: name, x: c.x, y: c.y });
|
|
717
|
+
}
|
|
718
|
+
// 4. persist.
|
|
719
|
+
await emit("scene.save", {});
|
|
720
|
+
return {
|
|
721
|
+
scene_path: spec.path, root_type: rootType, cell_kind: cellKind, layout_mode: spec.layout.mode,
|
|
722
|
+
cell_count: outCells.length, node_count: 1 + backgroundNodes + outCells.length, saved: true,
|
|
723
|
+
cells: outCells,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Reparent an existing node onto a board cell and snap it to the cell anchor.
|
|
728
|
+
* The composite computes the destination path itself (cell node name + the moved
|
|
729
|
+
* node's own name), so the sequence is fully offline-testable. `align` is an
|
|
730
|
+
* offset from the cell origin (default {0,0} — centred on the anchor).
|
|
731
|
+
*/
|
|
732
|
+
export async function emitBoardPlace(emit, args) {
|
|
733
|
+
if (args.board === "")
|
|
734
|
+
throw new ComposeError("bad_params", "Missing 'board' (the board root node path)");
|
|
735
|
+
if (args.node === "")
|
|
736
|
+
throw new ComposeError("bad_params", "Missing 'node' (the node to place)");
|
|
737
|
+
const cellPath = joinPath(args.board, cellNodeName(args.cell));
|
|
738
|
+
const nodeName = args.node.split("/").pop() ?? args.node;
|
|
739
|
+
const dest = joinPath(cellPath, nodeName);
|
|
740
|
+
const ax = args.align?.x ?? 0;
|
|
741
|
+
const ay = args.align?.y ?? 0;
|
|
742
|
+
await emit("node.reparent", { path: args.node, new_parent_path: cellPath, keep_global_transform: false });
|
|
743
|
+
await emit("node.set_property", { path: dest, property: "position", value: vec2(ax, ay) });
|
|
744
|
+
return { placed: true, cell: args.cell, cell_path: cellPath, node_path: dest, align: { x: ax, y: ay } };
|
|
745
|
+
}
|
|
746
|
+
// =============================================================================
|
|
747
|
+
// Group N — Board-slice fast-follow: tile-backed board cells
|
|
748
|
+
// (`board_tile_create`, `board_tile_place`)
|
|
749
|
+
//
|
|
750
|
+
// A marker board addresses cells through per-cell `cell_<id>` anchor nodes and
|
|
751
|
+
// `board_place` reparents onto one. A *tile* board is the other Group D idiom:
|
|
752
|
+
// a `TileMapLayer` grid whose cells are addressed by integer `[x, y]` tile
|
|
753
|
+
// coordinates — no per-cell anchor node exists, so `board_tile_place` snaps a
|
|
754
|
+
// node onto a coordinate by computing the cell's local position from the layer's
|
|
755
|
+
// `tile_size` (the same value `TileMapLayer.map_to_local` uses: a cell's centre
|
|
756
|
+
// is `(coord + 0.5) * tile_size`; its corner is `coord * tile_size`). Both tools
|
|
757
|
+
// stay host-side scripted sequences of already-audited primitives — `scene.new`,
|
|
758
|
+
// `tileset.create`, `tilemaplayer.create`, `tilemap.set_cells_rect`,
|
|
759
|
+
// `node.reparent`, `node.set_property`, `scene.save` — emitted through the same
|
|
760
|
+
// injectable sink, so the whole op-sequence is unit-tested offline. Nothing here
|
|
761
|
+
// is domain-specific: cells carry only integer coordinates.
|
|
762
|
+
// =============================================================================
|
|
763
|
+
const DEFAULT_TILE_SIZE = 64;
|
|
764
|
+
/** Normalise an optional `[w, h]` tile size to two positive integers. */
|
|
765
|
+
function normTileSize(ts) {
|
|
766
|
+
if (ts === undefined)
|
|
767
|
+
return [DEFAULT_TILE_SIZE, DEFAULT_TILE_SIZE];
|
|
768
|
+
if (ts.length !== 2 || !ts.every((v) => Number.isInteger(v) && v > 0)) {
|
|
769
|
+
throw new ComposeError("bad_params", "tile_size must be [width, height] positive integers");
|
|
770
|
+
}
|
|
771
|
+
return [ts[0], ts[1]];
|
|
772
|
+
}
|
|
773
|
+
/** Normalise a `[x, y]` tile coordinate to two integers. */
|
|
774
|
+
function normCoord(c) {
|
|
775
|
+
if (!Array.isArray(c) || c.length !== 2 || !c.every((v) => Number.isInteger(v))) {
|
|
776
|
+
throw new ComposeError("bad_params", "coord must be [x, y] integers");
|
|
777
|
+
}
|
|
778
|
+
return [c[0], c[1]];
|
|
779
|
+
}
|
|
780
|
+
/** Derive a sibling TileSet path from a `res://…/Foo.tscn` scene path. */
|
|
781
|
+
function deriveTilesetPath(scenePath) {
|
|
782
|
+
return scenePath.replace(/\.tscn$/, "_tiles.tres");
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Emit the op-sequence that builds + saves a tile-backed board: a `TileMapLayer`
|
|
786
|
+
* bound to a TileSet (created here unless `tileset` is supplied) that establishes
|
|
787
|
+
* a rows×cols coordinate frame at `tile_size`, optionally filled with one tile.
|
|
788
|
+
*/
|
|
789
|
+
export async function emitBoardTileCreate(emit, spec) {
|
|
790
|
+
if (!Number.isInteger(spec.rows) || spec.rows < 1)
|
|
791
|
+
throw new ComposeError("bad_params", "rows must be an integer >= 1");
|
|
792
|
+
if (!Number.isInteger(spec.cols) || spec.cols < 1)
|
|
793
|
+
throw new ComposeError("bad_params", "cols must be an integer >= 1");
|
|
794
|
+
const tile = normTileSize(spec.tile_size);
|
|
795
|
+
const layerName = spec.layer_name ?? "Cells";
|
|
796
|
+
assertNodeName(layerName);
|
|
797
|
+
if (spec.paint && spec.tileset === undefined) {
|
|
798
|
+
throw new ComposeError("bad_params", "`paint` needs an existing `tileset` (with a painted source) to fill the grid from");
|
|
799
|
+
}
|
|
800
|
+
const rootName = sceneRootName(spec.path);
|
|
801
|
+
const tilesetCreated = spec.tileset === undefined;
|
|
802
|
+
const tilesetPath = spec.tileset ?? deriveTilesetPath(spec.path);
|
|
803
|
+
// 1. fresh Node2D scene rooted at the board node.
|
|
804
|
+
await emit("scene.new", { root_type: "Node2D", path: spec.path, name: rootName });
|
|
805
|
+
// 2. the coordinate frame: bind a supplied TileSet, or create a fresh one so
|
|
806
|
+
// the layer has a real tile_size (map_to_local reads it) even when unpainted.
|
|
807
|
+
if (tilesetCreated) {
|
|
808
|
+
await emit("tileset.create", { to_path: tilesetPath, tile_size: tile });
|
|
809
|
+
}
|
|
810
|
+
// 3. the TileMapLayer that holds the cells.
|
|
811
|
+
await emit("tilemaplayer.create", { parent_path: ".", name: layerName, tileset_path: tilesetPath });
|
|
812
|
+
// 4. optional: fill the whole grid with one tile in a single undoable action.
|
|
813
|
+
const painted = spec.paint !== undefined;
|
|
814
|
+
if (spec.paint) {
|
|
815
|
+
const atlas = spec.paint.atlas_coords ?? [0, 0];
|
|
816
|
+
await emit("tilemap.set_cells_rect", {
|
|
817
|
+
path: layerName, rect: [0, 0, spec.cols, spec.rows], source_id: spec.paint.source_id, atlas_coords: atlas,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
// 5. persist.
|
|
821
|
+
await emit("scene.save", {});
|
|
822
|
+
return {
|
|
823
|
+
scene_path: spec.path, layer_path: layerName, layer_name: layerName,
|
|
824
|
+
rows: spec.rows, cols: spec.cols, tile_size: tile,
|
|
825
|
+
tileset_path: tilesetPath, tileset_created: tilesetCreated,
|
|
826
|
+
cell_count: spec.rows * spec.cols, painted, node_count: 2, saved: true,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Snap an existing node onto a tile coordinate of a `TileMapLayer`. The cell's
|
|
831
|
+
* local position is computed host-side from `tile_size` — cell centre is
|
|
832
|
+
* `(coord + 0.5) * tile_size`, corner is `coord * tile_size` — matching Godot's
|
|
833
|
+
* `TileMapLayer.map_to_local`, plus an optional `align` offset. With `reparent`
|
|
834
|
+
* (default) the node is moved under the layer so the coordinate is layer-local.
|
|
835
|
+
*/
|
|
836
|
+
export async function emitBoardTilePlace(emit, args) {
|
|
837
|
+
if (args.layer === "")
|
|
838
|
+
throw new ComposeError("bad_params", "Missing 'layer' (the TileMapLayer node path)");
|
|
839
|
+
if (args.node === "")
|
|
840
|
+
throw new ComposeError("bad_params", "Missing 'node' (the node to place)");
|
|
841
|
+
const coord = normCoord(args.coord);
|
|
842
|
+
const [tw, th] = normTileSize(args.tile_size);
|
|
843
|
+
const anchor = args.anchor ?? "center";
|
|
844
|
+
const reparent = args.reparent ?? true;
|
|
845
|
+
const ax = args.align?.x ?? 0;
|
|
846
|
+
const ay = args.align?.y ?? 0;
|
|
847
|
+
const frac = anchor === "center" ? 0.5 : 0;
|
|
848
|
+
const px = (coord[0] + frac) * tw + ax;
|
|
849
|
+
const py = (coord[1] + frac) * th + ay;
|
|
850
|
+
const nodeName = args.node.split("/").pop() ?? args.node;
|
|
851
|
+
const dest = reparent ? joinPath(args.layer, nodeName) : args.node;
|
|
852
|
+
if (reparent) {
|
|
853
|
+
await emit("node.reparent", { path: args.node, new_parent_path: args.layer, keep_global_transform: false });
|
|
854
|
+
}
|
|
855
|
+
await emit("node.set_property", { path: dest, property: "position", value: vec2(px, py) });
|
|
856
|
+
return {
|
|
857
|
+
placed: true, coord: [coord[0], coord[1]], layer_path: args.layer, node_path: dest,
|
|
858
|
+
local_pos: { x: px, y: py }, tile_size: [tw, th], anchor, align: { x: ax, y: ay }, reparented: reparent,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Generate the piece's `set_data(data)` / `set_face(face_up)` GDScript. Pure and
|
|
863
|
+
* exported for unit testing — the same script-backed binding pattern as the card,
|
|
864
|
+
* so a bound instance updates through one method call. `set_data` binds the
|
|
865
|
+
* neutral keys `art` (texture), `color` (Art tint) and, when present, `label`
|
|
866
|
+
* (text); `set_face` flips Art/Label vs Back visibility.
|
|
867
|
+
*/
|
|
868
|
+
export function buildPieceScript(rootType, opts) {
|
|
869
|
+
const L = [];
|
|
870
|
+
L.push("@tool");
|
|
871
|
+
L.push(`extends ${rootType}`);
|
|
872
|
+
L.push("## Piece template generated by Breakpoint MCP (Group N). @tool so set_data /");
|
|
873
|
+
L.push("## set_face run in the editor: piece_instance binds at author time, and without");
|
|
874
|
+
L.push("## @tool the edited-scene instance is a PlaceholderScriptInstance whose methods");
|
|
875
|
+
L.push("## aren't callable (Finding A). Do not edit by hand —");
|
|
876
|
+
L.push("## re-run piece_template_create to regenerate.");
|
|
877
|
+
L.push("");
|
|
878
|
+
L.push("func set_data(data: Dictionary) -> Dictionary:");
|
|
879
|
+
L.push("\tvar bound: Array = []");
|
|
880
|
+
L.push("\tfor key in data.keys():");
|
|
881
|
+
L.push("\t\tvar v = data[key]");
|
|
882
|
+
const branches = [
|
|
883
|
+
[
|
|
884
|
+
`key == "art" and has_node("Art")`,
|
|
885
|
+
`\t\t\tvar _tex = load(str(v))`,
|
|
886
|
+
`\t\t\tif _tex: get_node("Art").texture = _tex`,
|
|
887
|
+
`\t\t\tbound.append(key)`,
|
|
888
|
+
],
|
|
889
|
+
[
|
|
890
|
+
`key == "color" and has_node("Art")`,
|
|
891
|
+
`\t\t\tget_node("Art").self_modulate = _to_color(str(v))`,
|
|
892
|
+
`\t\t\tbound.append(key)`,
|
|
893
|
+
],
|
|
894
|
+
];
|
|
895
|
+
if (opts.hasLabel) {
|
|
896
|
+
branches.push([
|
|
897
|
+
`key == "label" and has_node("Label")`,
|
|
898
|
+
`\t\t\tget_node("Label").text = str(v)`,
|
|
899
|
+
`\t\t\tbound.append(key)`,
|
|
900
|
+
]);
|
|
901
|
+
}
|
|
902
|
+
branches.forEach((b, i) => {
|
|
903
|
+
L.push(`\t\t${i === 0 ? "if" : "elif"} ${b[0]}:`);
|
|
904
|
+
for (let j = 1; j < b.length; j++)
|
|
905
|
+
L.push(b[j]);
|
|
906
|
+
});
|
|
907
|
+
L.push("\tvar unbound: Array = []");
|
|
908
|
+
L.push("\tfor key in data.keys():");
|
|
909
|
+
L.push("\t\tif not bound.has(key):");
|
|
910
|
+
L.push("\t\t\tunbound.append(key)");
|
|
911
|
+
L.push("\treturn {\"bound\": bound, \"unbound\": unbound}");
|
|
912
|
+
L.push("");
|
|
913
|
+
L.push("func set_face(face_up: bool) -> void:");
|
|
914
|
+
L.push("\tif has_node(\"Art\"):");
|
|
915
|
+
L.push("\t\tget_node(\"Art\").visible = face_up");
|
|
916
|
+
if (opts.hasLabel) {
|
|
917
|
+
L.push("\tif has_node(\"Label\"):");
|
|
918
|
+
L.push("\t\tget_node(\"Label\").visible = face_up");
|
|
919
|
+
}
|
|
920
|
+
if (opts.hasBack) {
|
|
921
|
+
L.push("\tif has_node(\"Back\"):");
|
|
922
|
+
L.push("\t\tget_node(\"Back\").visible = not face_up");
|
|
923
|
+
}
|
|
924
|
+
L.push("");
|
|
925
|
+
L.push("func _to_color(s: String) -> Color:");
|
|
926
|
+
L.push("\treturn Color.html(s) if s.begins_with(\"#\") else Color(1, 1, 1, 1)");
|
|
927
|
+
L.push("");
|
|
928
|
+
return L.join("\n");
|
|
929
|
+
}
|
|
930
|
+
/** Emit the full op-sequence that builds + saves a piece template scene. */
|
|
931
|
+
export async function emitPieceTemplate(emit, spec) {
|
|
932
|
+
const rootType = spec.root_type ?? "Node2D";
|
|
933
|
+
const scriptPath = spec.script_path ?? defaultScriptPath(spec.path);
|
|
934
|
+
const rootName = sceneRootName(spec.path);
|
|
935
|
+
const hasLabel = spec.label ?? true;
|
|
936
|
+
const hasHitArea = spec.hit_area !== undefined;
|
|
937
|
+
const hasBack = spec.back !== undefined;
|
|
938
|
+
const isControl = rootType === "Control";
|
|
939
|
+
const artType = isControl ? "TextureRect" : "Sprite2D";
|
|
940
|
+
const nodes = [];
|
|
941
|
+
// 1. fresh scene rooted at the piece node.
|
|
942
|
+
await emit("scene.new", { root_type: rootType, path: spec.path, name: rootName });
|
|
943
|
+
// 2. the Art node (Sprite2D / TextureRect) + optional texture / tint / size.
|
|
944
|
+
await emit("node.add", { parent_path: ".", type: artType, name: "Art" });
|
|
945
|
+
nodes.push({ name: "Art", node_path: "Art", type: artType });
|
|
946
|
+
if (spec.art && spec.art.startsWith("res://")) {
|
|
947
|
+
await emit("node.set_property", { path: "Art", property: "texture", value: resourceVariant("Texture2D", spec.art) });
|
|
948
|
+
}
|
|
949
|
+
if (spec.color) {
|
|
950
|
+
await emit("node.set_property", { path: "Art", property: "self_modulate", value: colorVariant(spec.color) });
|
|
951
|
+
}
|
|
952
|
+
if (isControl) {
|
|
953
|
+
await emit("node.set_property", { path: "Art", property: "size", value: vec2(spec.size.width, spec.size.height) });
|
|
954
|
+
}
|
|
955
|
+
// 3. optional Label (the piece's name).
|
|
956
|
+
if (hasLabel) {
|
|
957
|
+
await emit("node.add", { parent_path: ".", type: "Label", name: "Label" });
|
|
958
|
+
nodes.push({ name: "Label", node_path: "Label", type: "Label" });
|
|
959
|
+
if (spec.label_text !== undefined) {
|
|
960
|
+
await emit("node.set_property", { path: "Label", property: "text", value: spec.label_text });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// 4. optional hit area: Area2D + CollisionShape2D with a sized shape resource.
|
|
964
|
+
if (spec.hit_area) {
|
|
965
|
+
const shapeKind = spec.hit_area.shape ?? "rectangle";
|
|
966
|
+
await emit("node.add", { parent_path: ".", type: "Area2D", name: "HitArea" });
|
|
967
|
+
nodes.push({ name: "HitArea", node_path: "HitArea", type: "Area2D" });
|
|
968
|
+
const shapeClass = shapeKind === "circle" ? "CircleShape2D" : "RectangleShape2D";
|
|
969
|
+
const shapePath = spec.path.replace(/\.tscn$/, ".shape.tres");
|
|
970
|
+
const shapeProps = shapeKind === "circle"
|
|
971
|
+
? { radius: Math.min(spec.size.width, spec.size.height) / 2 }
|
|
972
|
+
: { size: vec2(spec.size.width, spec.size.height) };
|
|
973
|
+
await emit("resource.create", { class_name: shapeClass, to_path: shapePath, properties: shapeProps });
|
|
974
|
+
await emit("node.add", { parent_path: "HitArea", type: "CollisionShape2D", name: "Shape" });
|
|
975
|
+
nodes.push({ name: "Shape", node_path: "HitArea/Shape", type: "CollisionShape2D" });
|
|
976
|
+
await emit("node.set_property", { path: "HitArea/Shape", property: "shape", value: resourceVariant(shapeClass, shapePath) });
|
|
977
|
+
}
|
|
978
|
+
// 5. optional two-sided Back (makes the piece flippable).
|
|
979
|
+
if (spec.back) {
|
|
980
|
+
const backType = spec.back.art ? artType : "ColorRect";
|
|
981
|
+
await emit("node.add", { parent_path: ".", type: backType, name: "Back" });
|
|
982
|
+
nodes.push({ name: "Back", node_path: "Back", type: backType });
|
|
983
|
+
if (spec.back.art && spec.back.art.startsWith("res://")) {
|
|
984
|
+
await emit("node.set_property", { path: "Back", property: "texture", value: resourceVariant("Texture2D", spec.back.art) });
|
|
985
|
+
}
|
|
986
|
+
else if (spec.back.color) {
|
|
987
|
+
await emit("node.set_property", { path: "Back", property: "color", value: colorVariant(spec.back.color) });
|
|
988
|
+
}
|
|
989
|
+
await emit("node.set_property", { path: "Back", property: "visible", value: false });
|
|
990
|
+
}
|
|
991
|
+
// 6. generate + attach the piece script.
|
|
992
|
+
const source = buildPieceScript(rootType, { hasLabel, hasBack });
|
|
993
|
+
await emit("resource.create", { class_name: "GDScript", to_path: scriptPath, properties: { source_code: source } });
|
|
994
|
+
await emit("node.set_property", { path: ".", property: "script", value: resourceVariant("GDScript", scriptPath) });
|
|
995
|
+
// 7. persist.
|
|
996
|
+
await emit("scene.save", {});
|
|
997
|
+
return {
|
|
998
|
+
scene_path: spec.path, script_path: scriptPath, root_type: rootType,
|
|
999
|
+
has_label: hasLabel, has_hit_area: hasHitArea, has_back: hasBack,
|
|
1000
|
+
node_count: 1 + nodes.length, saved: true, nodes,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
/** Instance one piece + bind + set face, optionally placing it on a board cell. */
|
|
1004
|
+
export async function emitPieceInstance(emit, args) {
|
|
1005
|
+
const face_up = args.face_up ?? true;
|
|
1006
|
+
const name = args.name ?? sceneRootName(args.template_path);
|
|
1007
|
+
const instPath = joinPath(args.parent, name);
|
|
1008
|
+
await emit("node.instantiate_scene", { parent_path: args.parent, scene_path: args.template_path, name });
|
|
1009
|
+
if (args.position) {
|
|
1010
|
+
await emit("node.set_property", { path: instPath, property: "position", value: vec2(args.position.x, args.position.y) });
|
|
1011
|
+
}
|
|
1012
|
+
const res = await emit("node.call_method", { path: instPath, method: "set_data", args: [args.data] });
|
|
1013
|
+
await emit("node.call_method", { path: instPath, method: "set_face", args: [face_up] });
|
|
1014
|
+
const { bound, unbound } = splitFromCall(res, args.data);
|
|
1015
|
+
if (args.place_on) {
|
|
1016
|
+
const placed = await emitBoardPlace(emit, {
|
|
1017
|
+
board: args.place_on.board, cell: args.place_on.cell, node: instPath, align: args.place_on.align,
|
|
1018
|
+
});
|
|
1019
|
+
return { instance_path: placed.node_path, face_up, bound, unbound, placed: true, cell: args.place_on.cell };
|
|
1020
|
+
}
|
|
1021
|
+
return { instance_path: instPath, face_up, bound, unbound, placed: false, cell: null };
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Move a piece onto a destination cell (reusing `board_place` for the reparent +
|
|
1025
|
+
* snap), optionally authoring a short scale "pop" via existing Group C anim
|
|
1026
|
+
* primitives. Purely additive: it emits only `node.*` + `anim.*` ops that already
|
|
1027
|
+
* exist — no new bridge method, so it stays offline-testable and out of the
|
|
1028
|
+
* host↔addon parity scan, exactly like the rest of Group N.
|
|
1029
|
+
*/
|
|
1030
|
+
export async function emitPieceMove(emit, args) {
|
|
1031
|
+
// Core move: reparent onto the destination cell and snap (the board_place seq).
|
|
1032
|
+
const placed = await emitBoardPlace(emit, { board: args.board, cell: args.to, node: args.node, align: args.align });
|
|
1033
|
+
const dest = placed.node_path;
|
|
1034
|
+
let animated = false;
|
|
1035
|
+
if (args.animate) {
|
|
1036
|
+
animated = true;
|
|
1037
|
+
const player = args.animate.player ?? "MoveFX";
|
|
1038
|
+
const animName = args.animate.anim ?? "move";
|
|
1039
|
+
const duration = args.animate.duration ?? 0.25;
|
|
1040
|
+
const pop = args.animate.pop_scale ?? 1.15;
|
|
1041
|
+
const transition = args.animate.transition ?? 1.0;
|
|
1042
|
+
const playerPath = joinPath(dest, player);
|
|
1043
|
+
// A scale pop (1 → pop → 1) on one value track of an AnimationPlayer added
|
|
1044
|
+
// under the moved piece. Keyed relative to the piece's own scale (`.:scale`),
|
|
1045
|
+
// so it is deterministic and needs no world-transform knowledge.
|
|
1046
|
+
await emit("anim.player_create", { parent_path: dest, name: player });
|
|
1047
|
+
await emit("anim.create", { player_path: playerPath, name: animName, library: "" });
|
|
1048
|
+
await emit("anim.add_track", { player_path: playerPath, name: animName, path: ".:scale", type: "value", library: "" });
|
|
1049
|
+
await emit("anim.insert_key", { player_path: playerPath, name: animName, track: 0, time: 0, value: vec2(1, 1), transition, library: "" });
|
|
1050
|
+
await emit("anim.insert_key", { player_path: playerPath, name: animName, track: 0, time: duration / 2, value: vec2(pop, pop), transition, library: "" });
|
|
1051
|
+
await emit("anim.insert_key", { player_path: playerPath, name: animName, track: 0, time: duration, value: vec2(1, 1), transition, library: "" });
|
|
1052
|
+
await emit("anim.set_length", { player_path: playerPath, name: animName, length: duration, library: "" });
|
|
1053
|
+
}
|
|
1054
|
+
return { moved: true, from: args.from ?? null, to: args.to, node_path: dest, animated };
|
|
1055
|
+
}
|
|
1056
|
+
/** Godot 4 `Object.ConnectFlags.CONNECT_PERSIST` — write the connection into the
|
|
1057
|
+
* scene so it survives a reload (Finding E), instead of a runtime-only connect. */
|
|
1058
|
+
const CONNECT_PERSIST = 2;
|
|
1059
|
+
/** A GDScript double-quoted string literal with the minimal escaping. Exported for tests. */
|
|
1060
|
+
export function gdQuote(s) {
|
|
1061
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
1062
|
+
}
|
|
1063
|
+
/** Render a JS scalar as a GDScript literal (string quoted, bool lower-case). */
|
|
1064
|
+
function gdScalar(v) {
|
|
1065
|
+
if (typeof v === "string")
|
|
1066
|
+
return gdQuote(v);
|
|
1067
|
+
if (typeof v === "boolean")
|
|
1068
|
+
return v ? "true" : "false";
|
|
1069
|
+
return String(v);
|
|
1070
|
+
}
|
|
1071
|
+
/** Render a flat `{k: scalar}` map as a GDScript Dictionary literal. Exported for tests. */
|
|
1072
|
+
export function gdDictLiteral(obj) {
|
|
1073
|
+
const entries = Object.entries(obj).map(([k, v]) => `${gdQuote(k)}: ${gdScalar(v)}`);
|
|
1074
|
+
return entries.length ? `{${entries.join(", ")}}` : "{}";
|
|
1075
|
+
}
|
|
1076
|
+
/** Render a `string[]` as a GDScript Array literal. */
|
|
1077
|
+
function gdStrArray(items) {
|
|
1078
|
+
return items.length ? `[${items.map(gdQuote).join(", ")}]` : "[]";
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Generate a draggable node's behaviour script. Pure + exported for unit tests.
|
|
1082
|
+
* `control` overrides Godot's `_get_drag_data` to hand off `{payload, source}`
|
|
1083
|
+
* (and, when `preview`, a translucent drag preview); `node2d` carries the payload
|
|
1084
|
+
* and flips a `_dragging` flag from a pointer handler (`_on_drag_input`), following
|
|
1085
|
+
* the mouse in `_process`. The base class is derived from the mode so the script
|
|
1086
|
+
* attaches to any Control- / Node2D-derived node.
|
|
1087
|
+
*/
|
|
1088
|
+
export function buildDraggableScript(mode, payload, opts = {}) {
|
|
1089
|
+
const dict = gdDictLiteral(payload);
|
|
1090
|
+
// Finding B: when composing onto an existing script (e.g. an authored card),
|
|
1091
|
+
// extend THAT script so the node keeps its behaviour (set_data / set_face) and
|
|
1092
|
+
// gains drag, instead of `extends Control` / `extends Node2D` replacing it.
|
|
1093
|
+
const controlExtends = opts.baseScript ? `extends ${gdQuote(opts.baseScript)}` : "extends Control";
|
|
1094
|
+
const node2dExtends = opts.baseScript ? `extends ${gdQuote(opts.baseScript)}` : "extends Node2D";
|
|
1095
|
+
const L = [];
|
|
1096
|
+
if (mode === "control") {
|
|
1097
|
+
L.push(controlExtends);
|
|
1098
|
+
L.push("## Draggable (Control drag-and-drop) generated by Breakpoint MCP (Group N).");
|
|
1099
|
+
L.push("## Do not edit by hand — re-run interact_make_draggable to regenerate.");
|
|
1100
|
+
L.push("");
|
|
1101
|
+
L.push("## Finding C: the drag payload is a per-node @export, so ONE script serves");
|
|
1102
|
+
L.push("## many draggables — interact_make_draggable sets it on each node.");
|
|
1103
|
+
L.push(`@export var payload: Dictionary = ${dict}`);
|
|
1104
|
+
L.push("");
|
|
1105
|
+
L.push("func get_drag_payload() -> Dictionary:");
|
|
1106
|
+
L.push("\treturn payload.duplicate(true)");
|
|
1107
|
+
L.push("");
|
|
1108
|
+
L.push("func _get_drag_data(_at_position: Vector2) -> Variant:");
|
|
1109
|
+
L.push('\tvar data := {"payload": get_drag_payload(), "source": self}');
|
|
1110
|
+
if (opts.preview)
|
|
1111
|
+
L.push("\tset_drag_preview(_make_drag_preview())");
|
|
1112
|
+
L.push("\treturn data");
|
|
1113
|
+
if (opts.preview) {
|
|
1114
|
+
L.push("");
|
|
1115
|
+
L.push("func _make_drag_preview() -> Control:");
|
|
1116
|
+
L.push("\tvar ghost := duplicate()");
|
|
1117
|
+
L.push("\tghost.modulate = Color(1, 1, 1, 0.7)");
|
|
1118
|
+
L.push("\treturn ghost");
|
|
1119
|
+
}
|
|
1120
|
+
L.push("");
|
|
1121
|
+
return L.join("\n");
|
|
1122
|
+
}
|
|
1123
|
+
const button = opts.button ?? 1;
|
|
1124
|
+
L.push(node2dExtends);
|
|
1125
|
+
L.push("## Draggable (Node2D pointer drag) generated by Breakpoint MCP (Group N).");
|
|
1126
|
+
L.push("## Do not edit by hand — re-run interact_make_draggable to regenerate.");
|
|
1127
|
+
L.push("");
|
|
1128
|
+
L.push("signal drag_started(payload)");
|
|
1129
|
+
L.push("signal drag_ended(payload)");
|
|
1130
|
+
L.push("");
|
|
1131
|
+
L.push(`const DRAG_BUTTON := ${button}`);
|
|
1132
|
+
L.push("");
|
|
1133
|
+
L.push("## Finding C: per-node drag payload (see the control branch).");
|
|
1134
|
+
L.push(`@export var payload: Dictionary = ${dict}`);
|
|
1135
|
+
L.push("var _dragging := false");
|
|
1136
|
+
L.push("");
|
|
1137
|
+
L.push("func get_drag_payload() -> Dictionary:");
|
|
1138
|
+
L.push("\treturn payload.duplicate(true)");
|
|
1139
|
+
L.push("");
|
|
1140
|
+
L.push("func _on_drag_input(_viewport: Node, event: InputEvent, _shape_idx: int) -> void:");
|
|
1141
|
+
L.push("\tif event is InputEventMouseButton and event.button_index == DRAG_BUTTON and event.pressed:");
|
|
1142
|
+
L.push("\t\t_dragging = true");
|
|
1143
|
+
L.push("\t\tdrag_started.emit(get_drag_payload())");
|
|
1144
|
+
L.push("");
|
|
1145
|
+
L.push("func _process(_delta: float) -> void:");
|
|
1146
|
+
L.push("\tif not _dragging:");
|
|
1147
|
+
L.push("\t\treturn");
|
|
1148
|
+
L.push("\tglobal_position = get_global_mouse_position()");
|
|
1149
|
+
L.push("\tif not Input.is_mouse_button_pressed(DRAG_BUTTON):");
|
|
1150
|
+
L.push("\t\t_dragging = false");
|
|
1151
|
+
L.push("\t\tdrag_ended.emit(get_drag_payload())");
|
|
1152
|
+
L.push("");
|
|
1153
|
+
return L.join("\n");
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Generate a drop zone's validator/acceptor script. Pure + exported for unit
|
|
1157
|
+
* tests. `_accepts` is the neutral predicate: accept any payload when no key is
|
|
1158
|
+
* set, else accept when `payload[key]` is one of `values`. `control` overrides
|
|
1159
|
+
* `_can_drop_data` / `_drop_data`; `node2d` exposes a `try_drop(payload)` seam a
|
|
1160
|
+
* pointer-release handler calls. Both emit the caller's `on_drop` signal (added to
|
|
1161
|
+
* the node via `signal.add_user_signal`) with the accepted payload.
|
|
1162
|
+
*/
|
|
1163
|
+
export function buildDropZoneScript(mode, opts) {
|
|
1164
|
+
const L = [];
|
|
1165
|
+
L.push(`extends ${mode === "control" ? "Control" : "Node2D"}`);
|
|
1166
|
+
L.push("## Drop zone (drag-and-drop target) generated by Breakpoint MCP (Group N).");
|
|
1167
|
+
L.push("## Do not edit by hand — re-run interact_add_drop_zone to regenerate.");
|
|
1168
|
+
L.push("");
|
|
1169
|
+
L.push("## Finding E: declare the drop signal IN the script (not via a runtime-only");
|
|
1170
|
+
L.push("## add_user_signal) so a reloaded drop zone can still emit AND be connected.");
|
|
1171
|
+
L.push(`signal ${opts.onDrop}(payload)`);
|
|
1172
|
+
L.push("");
|
|
1173
|
+
L.push(`const ACCEPT_KEY := ${gdQuote(opts.acceptKey)}`);
|
|
1174
|
+
L.push(`const ACCEPT_VALUES := ${gdStrArray(opts.acceptValues)}`);
|
|
1175
|
+
L.push(`const ON_DROP := ${gdQuote(opts.onDrop)}`);
|
|
1176
|
+
L.push("");
|
|
1177
|
+
L.push("func _accepts(payload: Dictionary) -> bool:");
|
|
1178
|
+
L.push('\tif ACCEPT_KEY == "":');
|
|
1179
|
+
L.push("\t\treturn true");
|
|
1180
|
+
L.push('\treturn ACCEPT_VALUES.has(str(payload.get(ACCEPT_KEY, "")))');
|
|
1181
|
+
L.push("");
|
|
1182
|
+
L.push("func _payload_of(data: Variant) -> Dictionary:");
|
|
1183
|
+
L.push('\tif data is Dictionary and data.has("payload") and data["payload"] is Dictionary:');
|
|
1184
|
+
L.push('\t\treturn data["payload"]');
|
|
1185
|
+
L.push("\treturn {}");
|
|
1186
|
+
L.push("");
|
|
1187
|
+
if (mode === "control") {
|
|
1188
|
+
L.push("func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:");
|
|
1189
|
+
L.push("\treturn _accepts(_payload_of(data))");
|
|
1190
|
+
L.push("");
|
|
1191
|
+
L.push("func _drop_data(_at_position: Vector2, data: Variant) -> void:");
|
|
1192
|
+
L.push("\tvar payload := _payload_of(data)");
|
|
1193
|
+
L.push("\tif _accepts(payload):");
|
|
1194
|
+
L.push("\t\temit_signal(ON_DROP, payload)");
|
|
1195
|
+
L.push("");
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
L.push("## Call when a dragged node is released over this zone. Emits ON_DROP");
|
|
1199
|
+
L.push("## with the payload when accepted; returns whether it was accepted.");
|
|
1200
|
+
L.push("func try_drop(payload: Dictionary) -> bool:");
|
|
1201
|
+
L.push("\tif _accepts(payload):");
|
|
1202
|
+
L.push("\t\temit_signal(ON_DROP, payload)");
|
|
1203
|
+
L.push("\t\treturn true");
|
|
1204
|
+
L.push("\treturn false");
|
|
1205
|
+
L.push("");
|
|
1206
|
+
}
|
|
1207
|
+
return L.join("\n");
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Wire a node for drag-and-drop. `control` just attaches the drag script (Godot's
|
|
1211
|
+
* built-in Control DnD picks it up); `node2d` also registers a drag input action
|
|
1212
|
+
* and connects the hit area's `input_event` to the generated pointer handler.
|
|
1213
|
+
*/
|
|
1214
|
+
export async function emitMakeDraggable(emit, args) {
|
|
1215
|
+
if (args.node === "")
|
|
1216
|
+
throw new ComposeError("bad_params", "Missing 'node' (the node to make draggable)");
|
|
1217
|
+
const payload = args.payload ?? {};
|
|
1218
|
+
const mode = args.mode;
|
|
1219
|
+
let action = null;
|
|
1220
|
+
if (mode === "node2d") {
|
|
1221
|
+
action = args.action ?? "drag";
|
|
1222
|
+
const button = args.button ?? 1;
|
|
1223
|
+
await emit("inputmap.add_action", { name: action, save: true });
|
|
1224
|
+
await emit("inputmap.add_event", { name: action, event: { type: "mouse_button", button_index: button }, save: true });
|
|
1225
|
+
}
|
|
1226
|
+
// Finding B: compose instead of clobber. If the node already carries a
|
|
1227
|
+
// different script (e.g. an authored card's set_data/set_face), generate the
|
|
1228
|
+
// drag script as `extends "<that script>"` so the node KEEPS its behaviour and
|
|
1229
|
+
// GAINS drag. Skip when the node's script is already the target path (a plain
|
|
1230
|
+
// re-generation) — there is no prior base to preserve.
|
|
1231
|
+
const scriptProp = await emit("node.get_property", { path: args.node, property: "script" });
|
|
1232
|
+
const existing = scriptProp?.value && typeof scriptProp.value === "object" && typeof scriptProp.value.path === "string" ? scriptProp.value.path : "";
|
|
1233
|
+
const baseScript = existing && existing !== args.script_path ? existing : null;
|
|
1234
|
+
const source = buildDraggableScript(mode, payload, { preview: args.preview, button: args.button, baseScript: baseScript ?? undefined });
|
|
1235
|
+
await emit("resource.create", { class_name: "GDScript", to_path: args.script_path, properties: { source_code: source } });
|
|
1236
|
+
await emit("node.set_property", { path: args.node, property: "script", value: resourceVariant("GDScript", args.script_path) });
|
|
1237
|
+
// Finding C: write the payload as a per-node property override, so the shared
|
|
1238
|
+
// script above can serve many draggables — each carrying its own payload.
|
|
1239
|
+
await emit("node.set_property", { path: args.node, property: "payload", value: payload });
|
|
1240
|
+
let connected = false;
|
|
1241
|
+
if (mode === "node2d") {
|
|
1242
|
+
const hitSource = args.hit_area ? joinPath(args.node, args.hit_area) : args.node;
|
|
1243
|
+
await emit("signal.connect", { path: hitSource, signal: "input_event", target_path: args.node, method: "_on_drag_input", flags: 0 });
|
|
1244
|
+
connected = true;
|
|
1245
|
+
}
|
|
1246
|
+
return {
|
|
1247
|
+
node_path: args.node, mode, script_path: args.script_path,
|
|
1248
|
+
payload_keys: Object.keys(payload), action, connected,
|
|
1249
|
+
composed: baseScript !== null, base_script: baseScript,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Mark a node as a drop target: build an Area2D hit region for the node2d path,
|
|
1254
|
+
* attach the validator/acceptor script, add the `on_drop` user signal, and
|
|
1255
|
+
* optionally connect it to a handler. `accepts` is the neutral key∈values
|
|
1256
|
+
* predicate (accept-any when omitted).
|
|
1257
|
+
*/
|
|
1258
|
+
export async function emitAddDropZone(emit, args) {
|
|
1259
|
+
if (args.node === "")
|
|
1260
|
+
throw new ComposeError("bad_params", "Missing 'node' (the node to mark as a drop zone)");
|
|
1261
|
+
const mode = args.mode;
|
|
1262
|
+
const onDrop = args.on_drop ?? "dropped";
|
|
1263
|
+
assertNodeName(onDrop);
|
|
1264
|
+
const acceptKey = args.accepts?.key ?? "";
|
|
1265
|
+
const acceptValues = args.accepts?.values ?? [];
|
|
1266
|
+
let areaPath = null;
|
|
1267
|
+
if (mode === "node2d") {
|
|
1268
|
+
const size = args.size ?? { width: DEFAULT_CELL_SIZE, height: DEFAULT_CELL_SIZE };
|
|
1269
|
+
const shapeKind = args.shape ?? "rectangle";
|
|
1270
|
+
await emit("node.add", { parent_path: args.node, type: "Area2D", name: "DropArea" });
|
|
1271
|
+
areaPath = joinPath(args.node, "DropArea");
|
|
1272
|
+
const shapeClass = shapeKind === "circle" ? "CircleShape2D" : "RectangleShape2D";
|
|
1273
|
+
const shapePath = args.script_path.replace(/\.gd$/, ".shape.tres");
|
|
1274
|
+
const shapeProps = shapeKind === "circle"
|
|
1275
|
+
? { radius: Math.min(size.width, size.height) / 2 }
|
|
1276
|
+
: { size: vec2(size.width, size.height) };
|
|
1277
|
+
await emit("resource.create", { class_name: shapeClass, to_path: shapePath, properties: shapeProps });
|
|
1278
|
+
await emit("node.add", { parent_path: areaPath, type: "CollisionShape2D", name: "Shape" });
|
|
1279
|
+
await emit("node.set_property", { path: joinPath(areaPath, "Shape"), property: "shape", value: resourceVariant(shapeClass, shapePath) });
|
|
1280
|
+
}
|
|
1281
|
+
const source = buildDropZoneScript(mode, { acceptKey, acceptValues, onDrop });
|
|
1282
|
+
await emit("resource.create", { class_name: "GDScript", to_path: args.script_path, properties: { source_code: source } });
|
|
1283
|
+
await emit("node.set_property", { path: args.node, property: "script", value: resourceVariant("GDScript", args.script_path) });
|
|
1284
|
+
// Finding E: the on_drop signal is now declared IN the generated script (see
|
|
1285
|
+
// buildDropZoneScript), so no runtime-only signal.add_user_signal is emitted —
|
|
1286
|
+
// the signal (and any connection to it) survives a scene reload.
|
|
1287
|
+
let notified = false;
|
|
1288
|
+
if (args.notify) {
|
|
1289
|
+
// Finding E: persist the connection (CONNECT_PERSIST) so it is written into
|
|
1290
|
+
// the .tscn and re-established on reload, rather than being runtime-only.
|
|
1291
|
+
await emit("signal.connect", { path: args.node, signal: onDrop, target_path: args.notify.target, method: args.notify.method, flags: CONNECT_PERSIST });
|
|
1292
|
+
notified = true;
|
|
1293
|
+
}
|
|
1294
|
+
return {
|
|
1295
|
+
node_path: args.node, mode, script_path: args.script_path, on_drop: onDrop,
|
|
1296
|
+
accepts_key: acceptKey, accepts_values: acceptValues, notified, area_path: areaPath,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
// ------------------------------------------------------------- registration ----
|
|
1300
|
+
export function registerTabletopTools(server, bridge, config) {
|
|
1301
|
+
const emit = (method, params) => bridge.request(method, params);
|
|
1302
|
+
const readFile = (p) => readFileText(toFsPath(p, config.projectPath));
|
|
1303
|
+
const slotSchema = z.object({
|
|
1304
|
+
name: z.string().describe("Slot key used by card_instance / card_deck_from_table data (e.g. title, cost, body, art)"),
|
|
1305
|
+
kind: z.enum(["label", "rich_text", "texture", "panel", "badge"]).describe("label→Label, rich_text→RichTextLabel, texture→TextureRect, panel→Panel, badge→Label-in-Panel"),
|
|
1306
|
+
rect: z.object({ x: z.number().optional(), y: z.number().optional(), w: z.number().optional(), h: z.number().optional() }).optional().describe("Explicit local rect; mutually exclusive with anchor_preset"),
|
|
1307
|
+
anchor_preset: z.number().int().min(0).max(15).optional().describe("Control anchor preset (0–15) instead of an explicit rect"),
|
|
1308
|
+
font_size: z.number().int().positive().optional().describe("Font size override (label / rich_text / badge)"),
|
|
1309
|
+
align: z.enum(["left", "center", "right"]).optional().describe("Horizontal text alignment (default left)"),
|
|
1310
|
+
wrap: z.boolean().optional().describe("Autowrap the text (label / rich_text)"),
|
|
1311
|
+
color_by: z.string().optional().describe("Tint this slot's node from another data key (a #RRGGBB value)"),
|
|
1312
|
+
default_text: z.string().optional().describe("Static text shown before any data is bound"),
|
|
1313
|
+
});
|
|
1314
|
+
const layoutKnobs = {
|
|
1315
|
+
spacing: z.number().optional().describe("px between cards (row / grid)"),
|
|
1316
|
+
overlap: z.number().optional().describe("px overlap (row / fan / stack)"),
|
|
1317
|
+
fan_angle: z.number().optional().describe("Total fan spread in degrees (fan mode)"),
|
|
1318
|
+
columns: z.number().int().positive().optional().describe("Grid mode column count"),
|
|
1319
|
+
align: z.enum(["start", "center", "end"]).optional().describe("Alignment along the layout (default center)"),
|
|
1320
|
+
origin: z.object({ x: z.number(), y: z.number() }).optional().describe("Top-left origin offset in px"),
|
|
1321
|
+
};
|
|
1322
|
+
// ----------------------------------------------------- card_template_create ----
|
|
1323
|
+
server.registerTool("card_template_create", {
|
|
1324
|
+
title: "Create card template",
|
|
1325
|
+
description: "Build a reusable card scene (a PackedScene) from a slot spec, with a generated script-backed set_data() / set_face(). " +
|
|
1326
|
+
"Named slots (label / rich_text / texture / panel / badge) become the card's regions; card_instance and card_deck_from_table " +
|
|
1327
|
+
"bind data to them by slot name. Optional inline theme and a two-sided card back. DESTRUCTIVE (writes a scene + script) — gated by confirmation.",
|
|
1328
|
+
inputSchema: {
|
|
1329
|
+
path: z.string().describe("Where to save the template scene, e.g. res://ui/cards/Card.tscn"),
|
|
1330
|
+
size: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).describe("Card dimensions in px"),
|
|
1331
|
+
root_type: z.enum(["PanelContainer", "Panel", "Control"]).optional().describe("Root node type (default PanelContainer)"),
|
|
1332
|
+
slots: z.array(slotSchema).min(1).describe("Named regions the card exposes"),
|
|
1333
|
+
face: z.array(z.string()).optional().describe("Slot names shown on the face; omitted → all slots"),
|
|
1334
|
+
back: z.object({
|
|
1335
|
+
art: z.string().optional().describe("res:// texture for the card back"),
|
|
1336
|
+
color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional().describe("Card-back panel colour"),
|
|
1337
|
+
}).optional().describe("Optional card-back state; its presence makes the template two-sided"),
|
|
1338
|
+
theme_path: z.string().optional().describe("Use an existing Theme resource (res://…tres); mutually exclusive with inline theme"),
|
|
1339
|
+
theme: z.object({
|
|
1340
|
+
base_color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional(),
|
|
1341
|
+
accent_color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional(),
|
|
1342
|
+
font_path: z.string().optional(),
|
|
1343
|
+
font_size: z.number().int().positive().optional(),
|
|
1344
|
+
panel_stylebox: z.object({
|
|
1345
|
+
bg_color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional(),
|
|
1346
|
+
corner_radius: z.number().int().nonnegative().optional(),
|
|
1347
|
+
border_width: z.number().int().nonnegative().optional(),
|
|
1348
|
+
border_color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional(),
|
|
1349
|
+
}).optional(),
|
|
1350
|
+
}).optional().describe("Inline theme built via theme_create + theme_set_*"),
|
|
1351
|
+
script_path: z.string().optional().describe("Generated card script path (default derives from `path`)"),
|
|
1352
|
+
overwrite: z.boolean().optional().describe("Overwrite an existing template at `path` (default false)"),
|
|
1353
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1354
|
+
},
|
|
1355
|
+
}, async (raw) => {
|
|
1356
|
+
const a = raw;
|
|
1357
|
+
if (!a.path.startsWith("res://") || !a.path.endsWith(".tscn"))
|
|
1358
|
+
return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
|
|
1359
|
+
if (a.theme_path && a.theme)
|
|
1360
|
+
return fail({ code: "bad_params", message: "Pass either theme_path or an inline theme, not both" });
|
|
1361
|
+
const blocked = await gate(server, a.confirm, `Create card template scene + script at ${a.path}`);
|
|
1362
|
+
if (blocked)
|
|
1363
|
+
return blocked;
|
|
1364
|
+
try {
|
|
1365
|
+
return ok(await emitCardTemplate(emit, a));
|
|
1366
|
+
}
|
|
1367
|
+
catch (err) {
|
|
1368
|
+
return fail(err);
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
// ------------------------------------------------------------ card_instance ----
|
|
1372
|
+
server.registerTool("card_instance", {
|
|
1373
|
+
title: "Instance a card",
|
|
1374
|
+
description: "Instance a card template into the open scene and bind data to its slots via the template's set_data(). Undoable node authoring. " +
|
|
1375
|
+
"Slot values are strings/numbers/booleans; any texture slot (e.g. art) takes a res:// texture path. Reports which data keys bound and which had no matching slot.",
|
|
1376
|
+
inputSchema: {
|
|
1377
|
+
template_path: z.string().describe("Card template scene, e.g. res://ui/cards/Card.tscn"),
|
|
1378
|
+
parent: z.string().describe("Node path to parent the instance under (in the open scene); \".\" for the root"),
|
|
1379
|
+
data: z.record(z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value; a texture slot takes a res:// path"),
|
|
1380
|
+
position: z.object({ x: z.number(), y: z.number() }).optional().describe("Local position of the instance"),
|
|
1381
|
+
face_up: z.boolean().optional().describe("Show the face (default true); false shows the back on two-sided cards"),
|
|
1382
|
+
name: z.string().optional().describe("Optional node name for the instance"),
|
|
1383
|
+
},
|
|
1384
|
+
}, async (raw) => {
|
|
1385
|
+
const a = raw;
|
|
1386
|
+
try {
|
|
1387
|
+
return ok(await emitCardInstance(emit, a));
|
|
1388
|
+
}
|
|
1389
|
+
catch (err) {
|
|
1390
|
+
return fail(err);
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
// --------------------------------------------------------- card_hand_layout ----
|
|
1394
|
+
server.registerTool("card_hand_layout", {
|
|
1395
|
+
title: "Lay out a hand of cards",
|
|
1396
|
+
description: "Instance N cards under a container and arrange them as a row, fan, stack, or grid. Undoable node authoring. Each card carries its own " +
|
|
1397
|
+
"data (bound via the template's set_data) and face state; spacing / overlap / fan_angle / columns / align / origin tune the arrangement.",
|
|
1398
|
+
inputSchema: {
|
|
1399
|
+
template_path: z.string().describe("Card template scene, e.g. res://ui/cards/Card.tscn"),
|
|
1400
|
+
parent: z.string().describe("Container node path the cards are instanced under; \".\" for the root"),
|
|
1401
|
+
cards: z.array(z.object({
|
|
1402
|
+
data: z.record(z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value for this card"),
|
|
1403
|
+
face_up: z.boolean().optional().describe("Show the face (default true)"),
|
|
1404
|
+
})).min(1).describe("One entry per card to instance"),
|
|
1405
|
+
mode: z.enum(["row", "fan", "stack", "grid"]).describe("Arrangement mode"),
|
|
1406
|
+
...layoutKnobs,
|
|
1407
|
+
},
|
|
1408
|
+
}, async (raw) => {
|
|
1409
|
+
const a = raw;
|
|
1410
|
+
try {
|
|
1411
|
+
return ok(await emitCardHand(emit, a));
|
|
1412
|
+
}
|
|
1413
|
+
catch (err) {
|
|
1414
|
+
return fail(err);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
// ------------------------------------------------------ card_deck_from_table ----
|
|
1418
|
+
server.registerTool("card_deck_from_table", {
|
|
1419
|
+
title: "Stamp a deck from a table",
|
|
1420
|
+
description: "Read a CSV or JSON table and stamp one card per row, binding columns to slots via a column map. Undoable node authoring. " +
|
|
1421
|
+
"column_map values are bare {column} references or composed templates like \"{name} · {role}\"; a filter selects rows and an optional layout arranges them. " +
|
|
1422
|
+
"Table columns no slot referenced are surfaced (not silently dropped).",
|
|
1423
|
+
inputSchema: {
|
|
1424
|
+
template_path: z.string().describe("Card template scene, e.g. res://ui/cards/Card.tscn"),
|
|
1425
|
+
parent: z.string().describe("Container node path the cards are instanced under; \".\" for the root"),
|
|
1426
|
+
table_path: z.string().describe("CSV or JSON table on disk (res:// or absolute); format auto-detected by extension unless `format` set"),
|
|
1427
|
+
format: z.enum(["csv", "json"]).optional().describe("Override the table format"),
|
|
1428
|
+
column_map: z.record(z.string()).describe("Slot name → column expression (a bare {column} or a composed \"{a} · {b}\")"),
|
|
1429
|
+
filter: z.object({
|
|
1430
|
+
column: z.string(),
|
|
1431
|
+
equals: z.union([z.string(), z.number(), z.boolean()]),
|
|
1432
|
+
}).optional().describe("Optional row selector, e.g. {column:'set', equals:'base'}"),
|
|
1433
|
+
art_column: z.string().optional().describe("Column holding a res:// texture path bound to the `art` slot"),
|
|
1434
|
+
limit: z.number().int().positive().optional().describe("Cap the number of rows stamped"),
|
|
1435
|
+
face_up: z.boolean().optional().describe("Show the face (default true)"),
|
|
1436
|
+
layout: z.object({
|
|
1437
|
+
mode: z.enum(["row", "fan", "stack", "grid"]),
|
|
1438
|
+
spacing: z.number().optional(), overlap: z.number().optional(), fan_angle: z.number().optional(),
|
|
1439
|
+
columns: z.number().int().positive().optional(),
|
|
1440
|
+
align: z.enum(["start", "center", "end"]).optional(),
|
|
1441
|
+
origin: z.object({ x: z.number(), y: z.number() }).optional(),
|
|
1442
|
+
}).optional().describe("Optional arrangement (same knobs as card_hand_layout); omitted → stacked at origin"),
|
|
1443
|
+
},
|
|
1444
|
+
}, async (raw) => {
|
|
1445
|
+
const a = raw;
|
|
1446
|
+
try {
|
|
1447
|
+
return ok(await emitDeckFromTable(emit, readFile, a));
|
|
1448
|
+
}
|
|
1449
|
+
catch (err) {
|
|
1450
|
+
return fail(err);
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
// -------------------------------------------------------------- card_set_face ----
|
|
1454
|
+
server.registerTool("card_set_face", {
|
|
1455
|
+
title: "Flip a card's face",
|
|
1456
|
+
description: "Flip an instanced card (or any node exposing set_face(bool) — the generated card and piece scripts both do) between its face and back. " +
|
|
1457
|
+
"Instant by default: calls the setter now, so the visible side changes immediately. With `animate`, instead authors a reusable flip clip under the node from Group C anim primitives — a scale pinch (1 → edge-on → 1) plus a method key that calls the setter at the edge-on midpoint — played on demand; purely additive (only existing node.* / anim.* ops, never a new engine call). Undoable node authoring. Returns the target state and any authored player / anim.",
|
|
1458
|
+
inputSchema: {
|
|
1459
|
+
node: z.string().describe("Node path of the card instance to flip (in the open scene); \".\" for the root"),
|
|
1460
|
+
face_up: z.boolean().describe("Target face state: true shows the face, false the back"),
|
|
1461
|
+
method: z.string().optional().describe("Setter method invoked with face_up (default \"set_face\"; the generated card/piece scripts expose it)"),
|
|
1462
|
+
animate: z.object({
|
|
1463
|
+
duration: z.number().positive().optional().describe("Flip duration in seconds (default 0.3)"),
|
|
1464
|
+
player: z.string().optional().describe("AnimationPlayer node name added under the card (default FlipFX)"),
|
|
1465
|
+
anim: z.string().optional().describe("Animation name (default flip)"),
|
|
1466
|
+
transition: z.number().optional().describe("Key transition curve exponent (default 1.0)"),
|
|
1467
|
+
}).optional().describe("Optional flip animation; omitted → an instant set_face"),
|
|
1468
|
+
},
|
|
1469
|
+
}, async (raw) => {
|
|
1470
|
+
const a = raw;
|
|
1471
|
+
try {
|
|
1472
|
+
return ok(await emitCardSetFace(emit, a));
|
|
1473
|
+
}
|
|
1474
|
+
catch (err) {
|
|
1475
|
+
return fail(err);
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
// -------------------------------------------------------------- board_create ----
|
|
1479
|
+
const boardLayout = z.discriminatedUnion("mode", [
|
|
1480
|
+
z.object({
|
|
1481
|
+
mode: z.literal("ring"),
|
|
1482
|
+
cells: z.array(z.string()).min(1).describe("Cell ids placed evenly around the ring, in order"),
|
|
1483
|
+
radius: z.number().positive().optional().describe("Ring radius in px (default scales with cell_size × cell count)"),
|
|
1484
|
+
start_deg: z.number().optional().describe("Angle of the first cell in degrees (default -90 = top)"),
|
|
1485
|
+
clockwise: z.boolean().optional().describe("Sweep direction (default true)"),
|
|
1486
|
+
center: z.object({ x: z.number(), y: z.number() }).optional().describe("Ring centre offset from the root (default 0,0)"),
|
|
1487
|
+
}),
|
|
1488
|
+
z.object({
|
|
1489
|
+
mode: z.literal("grid"),
|
|
1490
|
+
rows: z.number().int().positive().describe("Grid row count"),
|
|
1491
|
+
cols: z.number().int().positive().describe("Grid column count; cell ids are \"<row>_<col>\""),
|
|
1492
|
+
}),
|
|
1493
|
+
z.object({
|
|
1494
|
+
mode: z.literal("cells"),
|
|
1495
|
+
cells: z.array(z.object({
|
|
1496
|
+
id: z.string().describe("Cell id (becomes node cell_<id>)"),
|
|
1497
|
+
x: z.number(), y: z.number(),
|
|
1498
|
+
})).min(1).describe("Explicit cell ids and local positions"),
|
|
1499
|
+
}),
|
|
1500
|
+
]);
|
|
1501
|
+
server.registerTool("board_create", {
|
|
1502
|
+
title: "Create board scene",
|
|
1503
|
+
description: "Build a board scene whose children are addressable cells (each a cell_<id> node in the board_cells group) from a ring, grid, or explicit-cells layout. " +
|
|
1504
|
+
"Cells are Marker2D (or Control) anchors positioned by pure layout math; an optional background (color or res:// art) sits behind them. " +
|
|
1505
|
+
"General-purpose — cells carry only caller-supplied ids. DESTRUCTIVE (writes a scene) — gated by confirmation. Returns the cell_id → node_path + position map.",
|
|
1506
|
+
inputSchema: {
|
|
1507
|
+
path: z.string().describe("Where to save the board scene, e.g. res://ui/board/Board.tscn"),
|
|
1508
|
+
layout: boardLayout.describe("ring{cells[]} | grid{rows,cols} | cells{cells[{id,x,y}]}"),
|
|
1509
|
+
cell_size: z.number().positive().optional().describe("Cell pitch in px (drives ring radius / grid spacing; default 96)"),
|
|
1510
|
+
cell_kind: z.enum(["marker", "control"]).optional().describe("marker→Marker2D anchor (default), control→Control anchor"),
|
|
1511
|
+
root_type: z.enum(["Node2D", "Control"]).optional().describe("Board root node type (default Node2D)"),
|
|
1512
|
+
background: z.object({
|
|
1513
|
+
color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional().describe("Solid ColorRect background"),
|
|
1514
|
+
art: z.string().optional().describe("res:// texture background (Sprite2D under Node2D, TextureRect under Control)"),
|
|
1515
|
+
size: z.object({ w: z.number().optional(), h: z.number().optional() }).optional().describe("Background size in px (ColorRect / TextureRect)"),
|
|
1516
|
+
}).optional().describe("Optional background drawn behind the cells"),
|
|
1517
|
+
overwrite: z.boolean().optional().describe("Overwrite an existing board at `path` (default false)"),
|
|
1518
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1519
|
+
},
|
|
1520
|
+
}, async (raw) => {
|
|
1521
|
+
const a = raw;
|
|
1522
|
+
if (!a.path.startsWith("res://") || !a.path.endsWith(".tscn"))
|
|
1523
|
+
return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
|
|
1524
|
+
const blocked = await gate(server, a.confirm, `Create board scene at ${a.path}`);
|
|
1525
|
+
if (blocked)
|
|
1526
|
+
return blocked;
|
|
1527
|
+
try {
|
|
1528
|
+
return ok(await emitBoardCreate(emit, a));
|
|
1529
|
+
}
|
|
1530
|
+
catch (err) {
|
|
1531
|
+
return fail(err);
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
// --------------------------------------------------------------- board_place ----
|
|
1535
|
+
server.registerTool("board_place", {
|
|
1536
|
+
title: "Place a node on a board cell",
|
|
1537
|
+
description: "Reparent an existing node (a card or piece instance) onto a board cell by id and snap it to the cell anchor. Undoable node authoring. " +
|
|
1538
|
+
"The target cell is <board>/cell_<cell>; `align` offsets the node from the cell origin (default centred). Returns the node's new path.",
|
|
1539
|
+
inputSchema: {
|
|
1540
|
+
board: z.string().describe("Board root node path in the open scene (\".\" if the board is the scene root)"),
|
|
1541
|
+
cell: z.string().describe("Cell id to place onto (resolves to <board>/cell_<cell>)"),
|
|
1542
|
+
node: z.string().describe("Node path of the node to place (a card / piece already in the scene)"),
|
|
1543
|
+
align: z.object({ x: z.number(), y: z.number() }).optional().describe("Offset from the cell origin in px (default 0,0 — centred on the anchor)"),
|
|
1544
|
+
},
|
|
1545
|
+
}, async (raw) => {
|
|
1546
|
+
const a = raw;
|
|
1547
|
+
try {
|
|
1548
|
+
return ok(await emitBoardPlace(emit, a));
|
|
1549
|
+
}
|
|
1550
|
+
catch (err) {
|
|
1551
|
+
return fail(err);
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
// ----------------------------------------------------------- board_tile_create ----
|
|
1555
|
+
server.registerTool("board_tile_create", {
|
|
1556
|
+
title: "Create tile-backed board",
|
|
1557
|
+
description: "Build a tile-backed board scene: a TileMapLayer grid whose cells are addressable by integer [x, y] tile coordinates (cols wide × rows tall). " +
|
|
1558
|
+
"The layer binds a TileSet — a supplied `tileset` .tres, or a fresh empty one created at <scene>_tiles.tres — so it has a real tile_size (the coordinate frame placement uses); `paint` optionally fills the whole grid with one tile from the bound tileset. " +
|
|
1559
|
+
"General-purpose — cells carry only coordinates. Adds no addon method — decomposes onto scene.new → tileset.create → tilemaplayer.create → tilemap.set_cells_rect → scene.save. DESTRUCTIVE (writes a scene, and a TileSet .tres unless `tileset` is supplied) — gated by confirmation. Returns the layer path + grid dimensions + tile size.",
|
|
1560
|
+
inputSchema: {
|
|
1561
|
+
path: z.string().describe("Where to save the board scene, e.g. res://ui/board/TileBoard.tscn"),
|
|
1562
|
+
rows: z.number().int().positive().describe("Grid row count (cell y ranges 0..rows-1)"),
|
|
1563
|
+
cols: z.number().int().positive().describe("Grid column count (cell x ranges 0..cols-1)"),
|
|
1564
|
+
tile_size: z.array(z.number().int().positive()).length(2).optional().describe("Tile cell size [w, h] in px (default [64, 64]); the coordinate frame board_tile_place snaps to"),
|
|
1565
|
+
tileset: z.string().optional().describe("Existing TileSet res:// .tres to bind; omitted → a fresh empty TileSet is created at <scene>_tiles.tres to establish the tile size"),
|
|
1566
|
+
paint: z.object({
|
|
1567
|
+
source_id: z.number().int().nonnegative().describe("Atlas source id in the bound tileset to fill the grid with"),
|
|
1568
|
+
atlas_coords: z.array(z.number().int()).length(2).optional().describe("Tile atlas coordinates [x, y] within the source (default [0, 0])"),
|
|
1569
|
+
}).optional().describe("Fill the whole grid with one tile (needs an existing `tileset` that has the source); omitted → cells stay empty (a coordinate frame only)"),
|
|
1570
|
+
layer_name: z.string().optional().describe("TileMapLayer node name (default \"Cells\")"),
|
|
1571
|
+
overwrite: z.boolean().optional().describe("Overwrite an existing board at `path` (default false)"),
|
|
1572
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1573
|
+
},
|
|
1574
|
+
}, async (raw) => {
|
|
1575
|
+
const a = raw;
|
|
1576
|
+
if (!a.path.startsWith("res://") || !a.path.endsWith(".tscn"))
|
|
1577
|
+
return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
|
|
1578
|
+
if (a.tileset !== undefined && !(a.tileset.startsWith("res://") && a.tileset.endsWith(".tres")))
|
|
1579
|
+
return fail({ code: "bad_params", message: "'tileset' must be a res:// .tres path" });
|
|
1580
|
+
const blocked = await gate(server, a.confirm, `Create tile-backed board scene at ${a.path}`);
|
|
1581
|
+
if (blocked)
|
|
1582
|
+
return blocked;
|
|
1583
|
+
try {
|
|
1584
|
+
return ok(await emitBoardTileCreate(emit, a));
|
|
1585
|
+
}
|
|
1586
|
+
catch (err) {
|
|
1587
|
+
return fail(err);
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
// ------------------------------------------------------------ board_tile_place ----
|
|
1591
|
+
server.registerTool("board_tile_place", {
|
|
1592
|
+
title: "Place a node on a tile coordinate",
|
|
1593
|
+
description: "Snap an existing node (a card or piece instance) onto a TileMapLayer cell by integer [x, y] tile coordinate. Undoable node authoring. " +
|
|
1594
|
+
"The cell's local position is computed from `tile_size` — centre `(coord + 0.5) × tile_size` (default) or corner `coord × tile_size` — matching Godot's TileMapLayer.map_to_local, plus an optional `align` offset. With `reparent` (default true) the node is moved under the layer so the coordinate is layer-local. Decomposes onto node.reparent + node.set_property. Returns the node's new path and local position.",
|
|
1595
|
+
inputSchema: {
|
|
1596
|
+
layer: z.string().describe("TileMapLayer node path in the open scene"),
|
|
1597
|
+
node: z.string().describe("Node path of the node to place (a card / piece already in the scene)"),
|
|
1598
|
+
coord: z.array(z.number().int()).length(2).describe("Tile coordinate [x, y] (in cells)"),
|
|
1599
|
+
tile_size: z.array(z.number().int().positive()).length(2).optional().describe("The layer's tile cell size [w, h] in px (default [64, 64]); must match the board's tile_size"),
|
|
1600
|
+
anchor: z.enum(["center", "corner"]).optional().describe("Snap to the cell centre (default) or its top-left corner"),
|
|
1601
|
+
align: z.object({ x: z.number(), y: z.number() }).optional().describe("Offset from the anchor in px (default 0,0)"),
|
|
1602
|
+
reparent: z.boolean().optional().describe("Reparent the node under the layer so the coordinate is layer-local (default true)"),
|
|
1603
|
+
},
|
|
1604
|
+
}, async (raw) => {
|
|
1605
|
+
const a = raw;
|
|
1606
|
+
try {
|
|
1607
|
+
return ok(await emitBoardTilePlace(emit, a));
|
|
1608
|
+
}
|
|
1609
|
+
catch (err) {
|
|
1610
|
+
return fail(err);
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
// ---------------------------------------------------------- piece_template_create ----
|
|
1614
|
+
server.registerTool("piece_template_create", {
|
|
1615
|
+
title: "Create piece template",
|
|
1616
|
+
description: "Build a reusable piece (token) scene from a spec: an Art node (Sprite2D under a Node2D root, TextureRect under a Control root), an optional Label, an optional hit area (Area2D + CollisionShape2D), and an optional two-sided Back, plus a generated script-backed set_data() / set_face(). set_data binds art / color / label; set_face flips face/back visibility. " +
|
|
1617
|
+
"Decomposes onto scene.new → node.add → node.set_property → resource.create → scene.save. DESTRUCTIVE (writes a scene + script) — gated by confirmation. Returns the scene path + the created-node map.",
|
|
1618
|
+
inputSchema: {
|
|
1619
|
+
path: z.string().describe("Where to save the template scene, e.g. res://ui/pieces/Piece.tscn"),
|
|
1620
|
+
size: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).describe("Token size in px (drives the hit-area extents and, for a Control root, the Art size)"),
|
|
1621
|
+
root_type: z.enum(["Node2D", "Control"]).optional().describe("Root node type (default Node2D → Sprite2D art; Control → TextureRect art)"),
|
|
1622
|
+
art: z.string().optional().describe("res:// texture bound to the Art node at build time"),
|
|
1623
|
+
color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional().describe("Default Art tint (self_modulate), #RRGGBB or #RRGGBBAA"),
|
|
1624
|
+
label: z.boolean().optional().describe("Include a Label child for the piece name (default true)"),
|
|
1625
|
+
label_text: z.string().optional().describe("Static Label text shown before any data is bound"),
|
|
1626
|
+
hit_area: z.object({
|
|
1627
|
+
shape: z.enum(["rectangle", "circle"]).optional().describe("Collision shape (default rectangle sized to `size`; circle radius = min(w,h)/2)"),
|
|
1628
|
+
}).optional().describe("Optional Area2D + CollisionShape2D for hit-testing"),
|
|
1629
|
+
back: z.object({
|
|
1630
|
+
art: z.string().optional().describe("res:// texture for the piece back"),
|
|
1631
|
+
color: z.string().regex(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/).optional().describe("Solid ColorRect back colour"),
|
|
1632
|
+
}).optional().describe("Optional back state; its presence makes the piece two-sided"),
|
|
1633
|
+
script_path: z.string().optional().describe("Generated piece script path (default derives from `path`)"),
|
|
1634
|
+
overwrite: z.boolean().optional().describe("Overwrite an existing template at `path` (default false)"),
|
|
1635
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1636
|
+
},
|
|
1637
|
+
}, async (raw) => {
|
|
1638
|
+
const a = raw;
|
|
1639
|
+
if (!a.path.startsWith("res://") || !a.path.endsWith(".tscn"))
|
|
1640
|
+
return fail({ code: "bad_params", message: "'path' must be a res:// .tscn path" });
|
|
1641
|
+
const blocked = await gate(server, a.confirm, `Create piece template scene + script at ${a.path}`);
|
|
1642
|
+
if (blocked)
|
|
1643
|
+
return blocked;
|
|
1644
|
+
try {
|
|
1645
|
+
return ok(await emitPieceTemplate(emit, a));
|
|
1646
|
+
}
|
|
1647
|
+
catch (err) {
|
|
1648
|
+
return fail(err);
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
// ------------------------------------------------------------------ piece_instance ----
|
|
1652
|
+
server.registerTool("piece_instance", {
|
|
1653
|
+
title: "Instance a piece",
|
|
1654
|
+
description: "Instance a piece template into the open scene and bind data (art / color / label) via the template's set_data(). Undoable node authoring. " +
|
|
1655
|
+
"Optionally place_on a board cell in the same call (reparent + snap via board_place). Reports which data keys bound and which had no matching slot.",
|
|
1656
|
+
inputSchema: {
|
|
1657
|
+
template_path: z.string().describe("Piece template scene, e.g. res://ui/pieces/Piece.tscn"),
|
|
1658
|
+
parent: z.string().describe("Node path to parent the instance under (in the open scene); \".\" for the root"),
|
|
1659
|
+
data: z.record(z.union([z.string(), z.number(), z.boolean()])).describe("Slot name → value (art takes a res:// path; color a #RRGGBB; label a string)"),
|
|
1660
|
+
position: z.object({ x: z.number(), y: z.number() }).optional().describe("Local position of the instance (ignored when place_on snaps it to a cell)"),
|
|
1661
|
+
face_up: z.boolean().optional().describe("Show the face (default true); false shows the back on two-sided pieces"),
|
|
1662
|
+
name: z.string().optional().describe("Optional node name for the instance"),
|
|
1663
|
+
place_on: z.object({
|
|
1664
|
+
board: z.string().describe("Board root node path (\".\" if the board is the scene root)"),
|
|
1665
|
+
cell: z.string().describe("Cell id to place onto (resolves to <board>/cell_<cell>)"),
|
|
1666
|
+
align: z.object({ x: z.number(), y: z.number() }).optional().describe("Offset from the cell origin in px (default centred)"),
|
|
1667
|
+
}).optional().describe("Optionally place the new piece on a board cell in the same call"),
|
|
1668
|
+
},
|
|
1669
|
+
}, async (raw) => {
|
|
1670
|
+
const a = raw;
|
|
1671
|
+
try {
|
|
1672
|
+
return ok(await emitPieceInstance(emit, a));
|
|
1673
|
+
}
|
|
1674
|
+
catch (err) {
|
|
1675
|
+
return fail(err);
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
// --------------------------------------------------------------------- piece_move ----
|
|
1679
|
+
server.registerTool("piece_move", {
|
|
1680
|
+
title: "Move a piece to a cell",
|
|
1681
|
+
description: "Move a piece onto a board cell by id (reparent + snap via board_place), optionally with a short scale \"pop\" animation authored from Group C anim primitives. Undoable node authoring; purely additive — it emits only existing node / anim ops, never a new engine call. Returns the piece's new path.",
|
|
1682
|
+
inputSchema: {
|
|
1683
|
+
board: z.string().describe("Board root node path in the open scene (\".\" if the board is the scene root)"),
|
|
1684
|
+
node: z.string().describe("Node path of the piece to move"),
|
|
1685
|
+
to: z.string().describe("Destination cell id (resolves to <board>/cell_<to>)"),
|
|
1686
|
+
from: z.string().optional().describe("Source cell id, echoed in the result for the caller's convenience"),
|
|
1687
|
+
align: z.object({ x: z.number(), y: z.number() }).optional().describe("Offset from the cell origin in px (default 0,0 — centred on the anchor)"),
|
|
1688
|
+
animate: z.object({
|
|
1689
|
+
duration: z.number().positive().optional().describe("Pop duration in seconds (default 0.25)"),
|
|
1690
|
+
pop_scale: z.number().positive().optional().describe("Peak scale of the pop (default 1.15)"),
|
|
1691
|
+
player: z.string().optional().describe("AnimationPlayer node name added under the piece (default MoveFX)"),
|
|
1692
|
+
anim: z.string().optional().describe("Animation name (default move)"),
|
|
1693
|
+
transition: z.number().optional().describe("Key transition curve exponent (default 1.0)"),
|
|
1694
|
+
}).optional().describe("Optional pop animation; omitted → an instant snap"),
|
|
1695
|
+
},
|
|
1696
|
+
}, async (raw) => {
|
|
1697
|
+
const a = raw;
|
|
1698
|
+
try {
|
|
1699
|
+
return ok(await emitPieceMove(emit, a));
|
|
1700
|
+
}
|
|
1701
|
+
catch (err) {
|
|
1702
|
+
return fail(err);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
// ------------------------------------------------------ interact_make_draggable ----
|
|
1706
|
+
server.registerTool("interact_make_draggable", {
|
|
1707
|
+
title: "Make a node draggable",
|
|
1708
|
+
description: "Wire an existing node for drag-and-drop by attaching a generated reusable drag script (and, for node2d, a drag input action + a hit-area input_event connection). " +
|
|
1709
|
+
"COMPOSES with the node's existing script: if it already has one, the drag script `extends` it, so an authored card keeps its set_data/set_face and merely gains drag (never overwritten). " +
|
|
1710
|
+
"control mode uses Godot's built-in Control drag-and-drop (_get_drag_data hands off {payload, source}, with an optional translucent preview); node2d mode carries the payload and follows the pointer from a button-driven handler. " +
|
|
1711
|
+
"General-purpose — the drag carries a caller-supplied neutral payload Dictionary, written as a per-node @export so one script serves many draggables. Decomposes onto node.get_property → resource.create → node.set_property (script + payload) (+ inputmap.add_action / add_event / signal.connect for node2d). DESTRUCTIVE (writes a script) — gated by confirmation.",
|
|
1712
|
+
inputSchema: {
|
|
1713
|
+
node: z.string().describe("Node path (in the open scene) to make draggable; \".\" for the root"),
|
|
1714
|
+
script_path: z.string().describe("Where to save the generated drag script, e.g. res://ui/interact/Draggable.gd"),
|
|
1715
|
+
mode: z.enum(["control", "node2d"]).describe("control→Control _get_drag_data DnD; node2d→Area2D hit region + pointer handler"),
|
|
1716
|
+
payload: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().describe("Neutral data the drag carries (bound into the script as a Dictionary); default empty"),
|
|
1717
|
+
preview: z.boolean().optional().describe("control mode: show a translucent drag preview (default false)"),
|
|
1718
|
+
button: z.number().int().nonnegative().optional().describe("node2d mode: mouse button index that begins the drag (default 1 = left)"),
|
|
1719
|
+
action: z.string().optional().describe("node2d mode: input action name registered for the drag button (default \"drag\")"),
|
|
1720
|
+
hit_area: z.string().optional().describe("node2d mode: sub-path to the Area2D whose input_event drives the drag (default the node itself)"),
|
|
1721
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1722
|
+
},
|
|
1723
|
+
}, async (raw) => {
|
|
1724
|
+
const a = raw;
|
|
1725
|
+
if (!a.script_path.startsWith("res://") || !a.script_path.endsWith(".gd"))
|
|
1726
|
+
return fail({ code: "bad_params", message: "'script_path' must be a res:// .gd path" });
|
|
1727
|
+
const blocked = await gate(server, a.confirm, `Make ${a.node} draggable (writes script ${a.script_path})`);
|
|
1728
|
+
if (blocked)
|
|
1729
|
+
return blocked;
|
|
1730
|
+
try {
|
|
1731
|
+
return ok(await emitMakeDraggable(emit, a));
|
|
1732
|
+
}
|
|
1733
|
+
catch (err) {
|
|
1734
|
+
return fail(err);
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
// ------------------------------------------------------- interact_add_drop_zone ----
|
|
1738
|
+
server.registerTool("interact_add_drop_zone", {
|
|
1739
|
+
title: "Add a drop zone",
|
|
1740
|
+
description: "Mark a node as a drop target that validates an incoming payload and emits a signal on a valid drop. Attaches a generated validator/acceptor script that DECLARES the on_drop signal (so it survives a scene reload), and (for node2d) builds an Area2D + CollisionShape2D hit region; optionally connects on_drop to a handler with a persisted connection. " +
|
|
1741
|
+
"accepts is the neutral predicate {key, values} — accept any payload when omitted, else accept when payload[key] is one of values. control mode overrides _can_drop_data / _drop_data; node2d exposes a try_drop(payload) seam. " +
|
|
1742
|
+
"General-purpose — no domain vocabulary. Decomposes onto (node.add + resource.create + node.set_property for node2d) → resource.create → node.set_property (+ a persisted signal.connect). DESTRUCTIVE (writes a script) — gated by confirmation.",
|
|
1743
|
+
inputSchema: {
|
|
1744
|
+
node: z.string().describe("Node path (in the open scene) to mark as a drop zone; \".\" for the root"),
|
|
1745
|
+
script_path: z.string().describe("Where to save the generated drop-zone script, e.g. res://ui/interact/DropZone.gd"),
|
|
1746
|
+
mode: z.enum(["control", "node2d"]).describe("control→_can_drop_data / _drop_data overrides; node2d→Area2D hit region + try_drop() seam"),
|
|
1747
|
+
accepts: z.object({
|
|
1748
|
+
key: z.string().optional().describe("Payload key to test; omitted → accept any payload"),
|
|
1749
|
+
values: z.array(z.string()).optional().describe("Accepted values for payload[key]"),
|
|
1750
|
+
}).optional().describe("Neutral accept predicate (accept-any when omitted)"),
|
|
1751
|
+
on_drop: z.string().optional().describe("User signal emitted with the payload on a valid drop (default \"dropped\")"),
|
|
1752
|
+
notify: z.object({
|
|
1753
|
+
target: z.string().describe("Node path of the handler to connect on_drop to"),
|
|
1754
|
+
method: z.string().describe("Method on the target invoked with the payload"),
|
|
1755
|
+
}).optional().describe("Optionally connect on_drop to a handler in the same call"),
|
|
1756
|
+
size: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional().describe("node2d mode: DropArea hit size in px (default 96×96)"),
|
|
1757
|
+
shape: z.enum(["rectangle", "circle"]).optional().describe("node2d mode: DropArea collision shape (default rectangle)"),
|
|
1758
|
+
confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
|
|
1759
|
+
},
|
|
1760
|
+
}, async (raw) => {
|
|
1761
|
+
const a = raw;
|
|
1762
|
+
if (!a.script_path.startsWith("res://") || !a.script_path.endsWith(".gd"))
|
|
1763
|
+
return fail({ code: "bad_params", message: "'script_path' must be a res:// .gd path" });
|
|
1764
|
+
const blocked = await gate(server, a.confirm, `Add a drop zone on ${a.node} (writes script ${a.script_path})`);
|
|
1765
|
+
if (blocked)
|
|
1766
|
+
return blocked;
|
|
1767
|
+
try {
|
|
1768
|
+
return ok(await emitAddDropZone(emit, a));
|
|
1769
|
+
}
|
|
1770
|
+
catch (err) {
|
|
1771
|
+
return fail(err);
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
//# sourceMappingURL=tabletop.js.map
|