@moood/render 0.0.0 → 0.1.3
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 +29 -29
- package/cli.js +34 -34
- package/engine/actions.js +69 -69
- package/engine/captions.js +20 -20
- package/engine/directions.js +44 -44
- package/engine/engine.js +1479 -1456
- package/engine/engine.json +1 -1
- package/engine/gl.js +1023 -1027
- package/engine/kit.js +142 -142
- package/engine/live.js +23 -23
- package/engine/mic.js +76 -76
- package/engine/music.css +28 -28
- package/engine/music.js +279 -279
- package/engine/nav.css +66 -66
- package/engine/render.html +50 -50
- package/engine/scene.css +124 -124
- package/engine/scenestate.js +225 -220
- package/engine/select.js +301 -301
- package/engine/speech.js +150 -150
- package/engine/volume.js +56 -56
- package/engine/widgets.js +88 -88
- package/index.js +70 -70
- package/package.json +27 -13
- package/scene.js +43 -43
package/engine/select.js
CHANGED
|
@@ -1,301 +1,301 @@
|
|
|
1
|
-
// Selecting any part of the scene. A click (not a drag) picks what is under the cursor —
|
|
2
|
-
// objects and landscape layers nearest first, then the sun / moon / planets, else the sky —
|
|
3
|
-
// pixel-exact: the candidate's own vector shape is re-painted into a tiny canvas around
|
|
4
|
-
// the cursor and we look for ink. The selection glows along its silhouette on an overlay;
|
|
5
|
-
// the page (app.js) tells the chat what is selected.
|
|
6
|
-
// Paths name the selected part inside the scene spec: "objects[3]", "layers[1]",
|
|
7
|
-
// "celestials[0]", "sky" — the same the server understands. Scene elements also carry a
|
|
8
|
-
// stable id (object()), which is what the world's agents know them by.
|
|
9
|
-
const MooodSelect = (() => {
|
|
10
|
-
const I = Moood.internals;
|
|
11
|
-
const NAMES = {
|
|
12
|
-
pine: 'ель', tree: 'дерево', house: 'дом', tower: 'башня', lamp: 'фонарь', lantern: 'фонарик', campfire: 'костёр',
|
|
13
|
-
window: 'окно', boat: 'лодка', rock: 'камень', bench: 'скамейка', figure: 'человек', cat: 'кот', bird: 'птица',
|
|
14
|
-
flower: 'цветок', bridge: 'мостик',
|
|
15
|
-
mountains: 'горы', hills: 'холмы', dunes: 'дюны', forest: 'лес', city: 'город', water: 'вода', ground: 'земля',
|
|
16
|
-
clouds: 'облака', fog: 'туман', sun: 'солнце', moon: 'луна', planet: 'планета',
|
|
17
|
-
};
|
|
18
|
-
const LAND = new Set(['mountains', 'hills', 'dunes', 'forest', 'city', 'water', 'ground']);
|
|
19
|
-
const PROBE = 7, GLOW = 'rgba(255,228,160,0.95)';
|
|
20
|
-
|
|
21
|
-
// the part of the spec a path points to, and a human name for it
|
|
22
|
-
function item(spec, path) {
|
|
23
|
-
if (!spec || !path) return undefined;
|
|
24
|
-
if (path === 'sky') return spec.sky;
|
|
25
|
-
const m = /^(objects|layers|celestials)\[(\d+)\]$/.exec(path);
|
|
26
|
-
return m ? spec[m[1]]?.[Number(m[2])] : undefined;
|
|
27
|
-
}
|
|
28
|
-
function describe(spec, path) {
|
|
29
|
-
if (path === 'sky') return 'небо';
|
|
30
|
-
const it = item(spec, path);
|
|
31
|
-
return it ? it.name || NAMES[it.kind] || it.kind : path; // custom elements carry their own name
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// hint: what the card suggests doing with the selection (the page decides — it depends on the mode);
|
|
35
|
-
// withCard: false — the page shows the selection its own way (the Android app); only(path) — just
|
|
36
|
-
// these parts can be picked (the others let a click through to what is behind them), each on the
|
|
37
|
-
// first click
|
|
38
|
-
function create({ canvas, stage, onChange, hint, withCard = true, only = null }) {
|
|
39
|
-
let spec = null, sel = null, drawn = '';
|
|
40
|
-
const probe = I.layer(PROBE, PROBE), pg = probe.getContext('2d', { willReadFrequently: true });
|
|
41
|
-
const overlay = document.createElement('canvas');
|
|
42
|
-
overlay.id = 'select-overlay';
|
|
43
|
-
canvas.after(overlay);
|
|
44
|
-
const og = overlay.getContext('2d');
|
|
45
|
-
const card = document.createElement('div');
|
|
46
|
-
card.id = 'selection';
|
|
47
|
-
card.hidden = true;
|
|
48
|
-
document.body.append(card);
|
|
49
|
-
|
|
50
|
-
// Scenes are painted in CSS pixels of the frame here (W × H, the stage SW wide — engine.js),
|
|
51
|
-
// so a plane's view() maps painted coordinates straight to where they are in the frame;
|
|
52
|
-
// the frame lies at (x, y) in the canvas.
|
|
53
|
-
const size = () => { const f = stage.frame(); return { W: f.w, H: f.h, SW: I.STAGE * f.h, x: f.x, y: f.y }; };
|
|
54
|
-
// objects stand where the engine puts them (
|
|
55
|
-
const placed = (dz, o) =>
|
|
56
|
-
// a platformer's hero is drawn moved and turned (engine.js heroMatrix): so is its outline
|
|
57
|
-
function heroed(m, o, cam, SW, H) {
|
|
58
|
-
if (!o || !cam.hero || o.id !== cam.hero.id) return m;
|
|
59
|
-
const
|
|
60
|
-
return [m[0] * h[0], 0, 0, m[3], m[0] * h[4] + m[4], m[3] * h[5] + m[5]];
|
|
61
|
-
}
|
|
62
|
-
const layerSeed = i => I.hash(JSON.stringify(spec.layers || []) + (spec.title || '')) + i * 101;
|
|
63
|
-
|
|
64
|
-
// any ink within a few screen pixels of (px, py) when drawn at on-screen scale s?
|
|
65
|
-
function inked(px, py, s, draw) {
|
|
66
|
-
pg.setTransform(1, 0, 0, 1, 0, 0);
|
|
67
|
-
pg.clearRect(0, 0, PROBE, PROBE);
|
|
68
|
-
pg.setTransform(s, 0, 0, s, PROBE / 2 - px * s, PROBE / 2 - py * s);
|
|
69
|
-
draw(pg);
|
|
70
|
-
const d = pg.getImageData(0, 0, PROBE, PROBE).data;
|
|
71
|
-
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) return true;
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function pick(clientX, clientY) {
|
|
76
|
-
const { W, H, SW, x, y } = size(), cam = stage.camera(), dz = I.depths(spec);
|
|
77
|
-
const r = canvas.getBoundingClientRect(), k = r.width / Math.max(1, canvas.clientWidth) || 1;
|
|
78
|
-
const sx = (clientX - r.left) / k - x, sy = (clientY - r.top) / k - y;
|
|
79
|
-
// nearest first, in the renderer's order: it paints far to near, and at equal distance
|
|
80
|
-
// layers in spec order, then objects — so the later one is on top
|
|
81
|
-
// (looking around, the elements standing about — no landscape layers, the sky behind them)
|
|
82
|
-
const round = I.camSetup(spec).round;
|
|
83
|
-
const things = [
|
|
84
|
-
...(round ? [] : spec.layers || []).map((L, i) => ({ path: `layers[${i}]`, L, i, at: { m: I.view(cam, dz.Z[i], W, H, dz.yh), D: dz.Z[i] - cam.z } })).filter(t => LAND.has(t.L.kind)),
|
|
85
|
-
...(spec.objects || []).map((o, i) => ({ path: `objects[${i}]`, o: placed(dz, o), at: round && o.sky ? null : I.placeOf(o, cam, W, H, dz, round) })).filter(t => t.o.kind !== 'bird' && !t.o.plane),
|
|
86
|
-
].map((t, k) => ({ ...t, k })).filter(t => t.at && t.at.D > 0.48 && (!only || only(t.path))).sort((a, b) => a.at.D - b.at.D || b.k - a.k);
|
|
87
|
-
for (const t of things) {
|
|
88
|
-
const m = heroed(t.at.m, t.o, cam, SW, H), s = m[0], px = (sx - m[4]) / s, py = (sy - m[5]) / m[3];
|
|
89
|
-
if (t.o) {
|
|
90
|
-
if (inked(px, py, s, g => I.drawObject(g, t.o, SW, H, 0, []))) return t.path;
|
|
91
|
-
} else if (t.L.kind === 'water') {
|
|
92
|
-
if (py >= t.L.y * H) return t.path;
|
|
93
|
-
} else {
|
|
94
|
-
const k = s, w = PROBE / k;
|
|
95
|
-
I.staticLayer(t.L, SW, H, layerSeed(t.i), { x: px - w / 2, y: py - w / 2, w, h: w, k, canvas: probe });
|
|
96
|
-
const d = pg.getImageData(0, 0, PROBE, PROBE).data;
|
|
97
|
-
for (let j = 3; j < d.length; j += 4) if (d[j] > 40) return t.path;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const m = I.view(cam, Infinity, W, H, dz.yh), px = (sx - m[4]) / m[0], py = (sy - m[5]) / m[3];
|
|
101
|
-
const ci = (spec.celestials || []).findIndex(c => Math.hypot(px - c.x * SW, py - c.y * H) < c.size * H / 2 + 6);
|
|
102
|
-
const far = ci >= 0 ? `celestials[${ci}]` : 'sky';
|
|
103
|
-
return !only || only(far) ? far : null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ── the glow ────────────────────────────────────────────────────────
|
|
107
|
-
// The selected thing's silhouette, painted once: { c, x, y, k } — canvas c covers painted
|
|
108
|
-
// coordinates from (x, y) at scale k — and the plane it stands on (Z).
|
|
109
|
-
let sil = null, box = null; // box: where the outlined thing is on the screen now (CSS pixels)
|
|
110
|
-
// the part of a silhouette's canvas that has ink: { x, y, w, h } in its pixels
|
|
111
|
-
function inkBox(c) {
|
|
112
|
-
const k = Math.max(1, Math.ceil(Math.max(c.width, c.height) / 256)), w = Math.ceil(c.width / k), h = Math.ceil(c.height / k);
|
|
113
|
-
const t = I.layer(w, h), tg = t.getContext('2d', { willReadFrequently: true });
|
|
114
|
-
tg.drawImage(c, 0, 0, w, h);
|
|
115
|
-
const d = tg.getImageData(0, 0, w, h).data;
|
|
116
|
-
let x0 = w, y0 = h, x1 = -1, y1 = -1;
|
|
117
|
-
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (d[(y * w + x) * 4 + 3] > 24) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
|
|
118
|
-
return x1 < 0 ? { x: 0, y: 0, w: c.width, h: c.height } : { x: x0 * k, y: y0 * k, w: (x1 - x0 + 1) * k, h: (y1 - y0 + 1) * k };
|
|
119
|
-
}
|
|
120
|
-
function silhouette() {
|
|
121
|
-
const { SW, H } = size(), dz = I.depths(spec), it = item(spec, sel), i = Number(/\d+/.exec(sel)?.[0]);
|
|
122
|
-
if (sel.startsWith('objects')) {
|
|
123
|
-
const o = placed(dz, it), b = I.objectBox(o, SW, H);
|
|
124
|
-
const k = Math.min(2, 2048 / Math.max(1, b.bw), 2048 / Math.max(1, b.bh));
|
|
125
|
-
const c = I.layer(b.bw * k, b.bh * k), g = c.getContext('2d');
|
|
126
|
-
g.setTransform(k, 0, 0, k, -b.bx * k, -b.by * k);
|
|
127
|
-
I.drawObject(g, o, SW, H, 0, []);
|
|
128
|
-
return { c, x: b.bx, y: b.by, k, Z: dz.
|
|
129
|
-
}
|
|
130
|
-
if (sel.startsWith('layers') && LAND.has(it.kind)) {
|
|
131
|
-
if (it.kind === 'water') {
|
|
132
|
-
const c = I.layer(4, 4); c.getContext('2d').fillRect(0, 0, 4, 4);
|
|
133
|
-
return { c, x: -I.EXT * SW, y: it.y * H, k: 4 / ((1 + 2 * I.EXT) * SW), ky: 4 / ((I.DEEP - it.y) * H), Z: dz.Z[i] };
|
|
134
|
-
}
|
|
135
|
-
const k = 0.5, x = -I.EXT * SW;
|
|
136
|
-
const st = I.staticLayer(it, SW, H, layerSeed(i), { x, y: 0, w: (1 + 2 * I.EXT) * SW, h: I.DEEP * H, k });
|
|
137
|
-
return { c: st.canvas, x, y: 0, k, Z: dz.Z[i] };
|
|
138
|
-
}
|
|
139
|
-
if (sel.startsWith('celestials')) {
|
|
140
|
-
const r = it.size * H / 2, c = I.layer(2 * r + 4, 2 * r + 4), g = c.getContext('2d');
|
|
141
|
-
g.beginPath(); g.arc(r + 2, r + 2, r, 0, 7); g.fill();
|
|
142
|
-
return { c, x: it.x * SW - r - 2, y: it.y * H - r - 2, k: 1, Z: Infinity };
|
|
143
|
-
}
|
|
144
|
-
return null; // the sky: nothing to outline
|
|
145
|
-
}
|
|
146
|
-
function drawGlow() {
|
|
147
|
-
const dpr = window.devicePixelRatio || 1, cam = stage.camera();
|
|
148
|
-
// the scene's state moved or reshaped it (a figure that walks): outline it as it is now
|
|
149
|
-
if (sel && sil && sil.ver !== I.STATE.ver) sil = { ...(silhouette() || sil), ver: I.STATE.ver };
|
|
150
|
-
const key = sel && sil ? `${cam.x.toFixed(4)},${cam.y.toFixed(4)},${cam.z.toFixed(4)},${cam.yaw.toFixed(4)},${cam.h},${overlay.width},${sil.ver}` : '';
|
|
151
|
-
if (key === drawn) return;
|
|
152
|
-
drawn = key;
|
|
153
|
-
og.setTransform(1, 0, 0, 1, 0, 0);
|
|
154
|
-
og.clearRect(0, 0, overlay.width, overlay.height);
|
|
155
|
-
box = null;
|
|
156
|
-
if (!key) return;
|
|
157
|
-
const { W, H, x, y } = size(), dz = I.depths(spec), round = I.camSetup(spec).round;
|
|
158
|
-
const at = sil.obj && !sil.obj.sky ? I.placeOf(sil.obj, cam, W, H, dz, round) : { m: I.view(cam, sil.Z, W, H, dz.yh), D: Infinity };
|
|
159
|
-
if (!at || at.D < 0.3) return;
|
|
160
|
-
const m = heroed(at.m, sil.obj, cam, I.STAGE * H, H);
|
|
161
|
-
const sx = m[0] / sil.k * dpr, sy = m[3] / (sil.ky || sil.k) * dpr;
|
|
162
|
-
const ox = (x + m[0] * sil.x + m[4]) * dpr, oy = (y + m[3] * sil.y + m[5]) * dpr;
|
|
163
|
-
const r = canvas.getBoundingClientRect();
|
|
164
|
-
sil.ink ||= inkBox(sil.c);
|
|
165
|
-
box = { x: r.left + (ox + sil.ink.x * sx) / dpr, y: r.top + (oy + sil.ink.y * sy) / dpr, w: sil.ink.w * sx / dpr, h: sil.ink.h * sy / dpr };
|
|
166
|
-
// the shape itself is drawn far off-canvas; only its blurred shadow lands on screen —
|
|
167
|
-
// then the shape's interior is cut out, leaving a glow around the outline
|
|
168
|
-
const OFF = overlay.width + 4000;
|
|
169
|
-
og.shadowColor = GLOW;
|
|
170
|
-
og.shadowOffsetX = OFF;
|
|
171
|
-
for (const blur of [3, 10, 22]) {
|
|
172
|
-
og.shadowBlur = blur * dpr;
|
|
173
|
-
og.setTransform(sx, 0, 0, sy, ox - OFF, oy);
|
|
174
|
-
og.drawImage(sil.c, 0, 0);
|
|
175
|
-
}
|
|
176
|
-
og.shadowColor = 'transparent';
|
|
177
|
-
og.globalCompositeOperation = 'destination-out';
|
|
178
|
-
og.setTransform(sx, 0, 0, sy, ox, oy);
|
|
179
|
-
og.drawImage(sil.c, 0, 0);
|
|
180
|
-
og.globalCompositeOperation = 'source-over';
|
|
181
|
-
}
|
|
182
|
-
const fit = () => {
|
|
183
|
-
const dpr = window.devicePixelRatio || 1;
|
|
184
|
-
overlay.width = Math.round(overlay.clientWidth * dpr);
|
|
185
|
-
overlay.height = Math.round(overlay.clientHeight * dpr);
|
|
186
|
-
drawn = '';
|
|
187
|
-
};
|
|
188
|
-
addEventListener('resize', () => { fit(); if (sel) sil = silhouette(); });
|
|
189
|
-
fit();
|
|
190
|
-
const loop = () => { requestAnimationFrame(loop); if (spec) drawGlow(); };
|
|
191
|
-
requestAnimationFrame(loop);
|
|
192
|
-
|
|
193
|
-
// ── the card ────────────────────────────────────────────────────────
|
|
194
|
-
const hint_ = () => (hint ? hint() : 'напиши в чат, что с этим сделать');
|
|
195
|
-
function renderCard() {
|
|
196
|
-
card.hidden = !sel || !withCard;
|
|
197
|
-
if (!sel) return;
|
|
198
|
-
const it = item(spec, sel);
|
|
199
|
-
card.innerHTML = '';
|
|
200
|
-
const head = document.createElement('div');
|
|
201
|
-
head.className = 'head';
|
|
202
|
-
const name = document.createElement('b');
|
|
203
|
-
name.textContent = describe(spec, sel);
|
|
204
|
-
const close = document.createElement('button');
|
|
205
|
-
close.type = 'button'; close.textContent = '✕'; close.title = 'снять выделение (Esc)';
|
|
206
|
-
close.onclick = () => select(null);
|
|
207
|
-
head.append(name, close);
|
|
208
|
-
const props = document.createElement('div');
|
|
209
|
-
props.className = 'props';
|
|
210
|
-
const colour = it?.color || it?.top;
|
|
211
|
-
if (colour) { const sw = document.createElement('i'); sw.style.background = colour; props.append(sw); }
|
|
212
|
-
props.append(Object.entries(it || {}).filter(([k, v]) => !['kind', 'code', 'name', 'id'].includes(k) && typeof v !== 'object' && v !== false && String(v).length < 40)
|
|
213
|
-
.map(([k, v]) => `${k} ${typeof v === 'number' ? +v.toFixed(2) : v}`).join(' · '));
|
|
214
|
-
const hint = document.createElement('div');
|
|
215
|
-
hint.className = 'hint';
|
|
216
|
-
hint.textContent = hint_();
|
|
217
|
-
card.append(head, props, hint);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function select(path) {
|
|
221
|
-
sel = path && item(spec, path) !== undefined ? path : null;
|
|
222
|
-
sil = sel ? silhouette() : null;
|
|
223
|
-
drawn = 'dirty';
|
|
224
|
-
renderCard();
|
|
225
|
-
onChange?.(sel);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Things (objects, sun, moon) select on the first click. The continuous background —
|
|
229
|
-
// land layers, water, sky — only on a second click on the same part: the first one
|
|
230
|
-
// clears any selection, so stray clicks on the landscape never select it. Clicking
|
|
231
|
-
// what is selected clears it.
|
|
232
|
-
// Background: the old landscape layers and sky, and any element covering more than a
|
|
233
|
-
// quarter of the view from the start (the sky, a lake, far hills) — measured once per
|
|
234
|
-
// scene by painting it small.
|
|
235
|
-
const coverage = new Map();
|
|
236
|
-
function background(path) {
|
|
237
|
-
if (/^(layers\[|sky$)/.test(path)) return true;
|
|
238
|
-
if (!path.startsWith('objects')) return false;
|
|
239
|
-
if (!coverage.has(path)) {
|
|
240
|
-
const { W, H, SW } = size(), dz = I.depths(spec), it = item(spec, path), n = 64, c = I.layer(n, n * H / W), g = c.getContext('2d');
|
|
241
|
-
// from the start — looking around, turned toward it
|
|
242
|
-
const round = I.camSetup(spec).round, p = round && !it.sky ? dz.
|
|
243
|
-
const at = I.placeOf(it, { x: 0, y: 0, z: 0, h: 1, yaw: p ? Math.atan2(p.X, p.Z) : 0 }, W, H, dz, round && !it.sky), k = n / W;
|
|
244
|
-
const m = at ? at.m : [0, 0, 0, 0, 0, 0];
|
|
245
|
-
g.setTransform(m[0] * k, 0, 0, m[3] * k, m[4] * k, m[5] * k);
|
|
246
|
-
try { I.drawObject(g, placed(dz, it), SW, H, 0, []); } catch {}
|
|
247
|
-
const d = g.getImageData(0, 0, c.width, c.height).data;
|
|
248
|
-
let ink = 0;
|
|
249
|
-
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) ink++;
|
|
250
|
-
coverage.set(path, ink / (d.length / 4));
|
|
251
|
-
}
|
|
252
|
-
return coverage.get(path) > 0.25;
|
|
253
|
-
}
|
|
254
|
-
let armed = null; // a background part clicked once: { path, t }
|
|
255
|
-
function clicked(path, t) {
|
|
256
|
-
const prev = armed;
|
|
257
|
-
armed = null;
|
|
258
|
-
if (path === sel) return select(null);
|
|
259
|
-
if (only) return select(path);
|
|
260
|
-
if (!background(path)) return select(path);
|
|
261
|
-
if (prev && prev.path === path && !sel) return select(path);
|
|
262
|
-
if (sel) select(null);
|
|
263
|
-
armed = { path, t };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// a click is a press and release without dragging, with one finger
|
|
267
|
-
let down = null;
|
|
268
|
-
canvas.addEventListener('pointerdown', e => {
|
|
269
|
-
down = down ? { multi: true } : { x: e.clientX, y: e.clientY, t: performance.now() };
|
|
270
|
-
});
|
|
271
|
-
canvas.addEventListener('pointerup', e => {
|
|
272
|
-
const d = down;
|
|
273
|
-
down = null;
|
|
274
|
-
if (!d || d.multi || !spec) return;
|
|
275
|
-
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 5 || performance.now() - d.t > 500) return;
|
|
276
|
-
const path = pick(e.clientX, e.clientY);
|
|
277
|
-
if (path) clicked(path, performance.now());
|
|
278
|
-
else if (sel) select(null);
|
|
279
|
-
});
|
|
280
|
-
canvas.addEventListener('pointercancel', () => { down = null; });
|
|
281
|
-
addEventListener('keydown', e => {
|
|
282
|
-
if (e.key === 'Escape' && sel && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) select(null);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
return {
|
|
286
|
-
// a new scene (another world or version): indices may mean other things now
|
|
287
|
-
setSpec(next) { if (next !== spec) { spec = next; coverage.clear(); select(null); } },
|
|
288
|
-
path: () => sel,
|
|
289
|
-
// the selected scene element itself (with its stable id), or null
|
|
290
|
-
object: () => (sel && sel.startsWith('objects') ? item(spec, sel) : null),
|
|
291
|
-
// where the selection is on the screen now — {x, y, w, h} in CSS pixels — or null
|
|
292
|
-
box: () => (sel ? box : null),
|
|
293
|
-
// what a click there would pick (a path), or null
|
|
294
|
-
at: (clientX, clientY) => (spec ? pick(clientX, clientY) : null),
|
|
295
|
-
refresh: () => renderCard(),
|
|
296
|
-
clear: () => select(null),
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return { create, describe };
|
|
301
|
-
})();
|
|
1
|
+
// Selecting any part of the scene. A click (not a drag) picks what is under the cursor —
|
|
2
|
+
// objects and landscape layers nearest first, then the sun / moon / planets, else the sky —
|
|
3
|
+
// pixel-exact: the candidate's own vector shape is re-painted into a tiny canvas around
|
|
4
|
+
// the cursor and we look for ink. The selection glows along its silhouette on an overlay;
|
|
5
|
+
// the page (app.js) tells the chat what is selected.
|
|
6
|
+
// Paths name the selected part inside the scene spec: "objects[3]", "layers[1]",
|
|
7
|
+
// "celestials[0]", "sky" — the same the server understands. Scene elements also carry a
|
|
8
|
+
// stable id (object()), which is what the world's agents know them by.
|
|
9
|
+
const MooodSelect = (() => {
|
|
10
|
+
const I = Moood.internals;
|
|
11
|
+
const NAMES = {
|
|
12
|
+
pine: 'ель', tree: 'дерево', house: 'дом', tower: 'башня', lamp: 'фонарь', lantern: 'фонарик', campfire: 'костёр',
|
|
13
|
+
window: 'окно', boat: 'лодка', rock: 'камень', bench: 'скамейка', figure: 'человек', cat: 'кот', bird: 'птица',
|
|
14
|
+
flower: 'цветок', bridge: 'мостик',
|
|
15
|
+
mountains: 'горы', hills: 'холмы', dunes: 'дюны', forest: 'лес', city: 'город', water: 'вода', ground: 'земля',
|
|
16
|
+
clouds: 'облака', fog: 'туман', sun: 'солнце', moon: 'луна', planet: 'планета',
|
|
17
|
+
};
|
|
18
|
+
const LAND = new Set(['mountains', 'hills', 'dunes', 'forest', 'city', 'water', 'ground']);
|
|
19
|
+
const PROBE = 7, GLOW = 'rgba(255,228,160,0.95)';
|
|
20
|
+
|
|
21
|
+
// the part of the spec a path points to, and a human name for it
|
|
22
|
+
function item(spec, path) {
|
|
23
|
+
if (!spec || !path) return undefined;
|
|
24
|
+
if (path === 'sky') return spec.sky;
|
|
25
|
+
const m = /^(objects|layers|celestials)\[(\d+)\]$/.exec(path);
|
|
26
|
+
return m ? spec[m[1]]?.[Number(m[2])] : undefined;
|
|
27
|
+
}
|
|
28
|
+
function describe(spec, path) {
|
|
29
|
+
if (path === 'sky') return 'небо';
|
|
30
|
+
const it = item(spec, path);
|
|
31
|
+
return it ? it.name || NAMES[it.kind] || it.kind : path; // custom elements carry their own name
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// hint: what the card suggests doing with the selection (the page decides — it depends on the mode);
|
|
35
|
+
// withCard: false — the page shows the selection its own way (the Android app); only(path) — just
|
|
36
|
+
// these parts can be picked (the others let a click through to what is behind them), each on the
|
|
37
|
+
// first click
|
|
38
|
+
function create({ canvas, stage, onChange, hint, withCard = true, only = null }) {
|
|
39
|
+
let spec = null, sel = null, drawn = '';
|
|
40
|
+
const probe = I.layer(PROBE, PROBE), pg = probe.getContext('2d', { willReadFrequently: true });
|
|
41
|
+
const overlay = document.createElement('canvas');
|
|
42
|
+
overlay.id = 'select-overlay';
|
|
43
|
+
canvas.after(overlay);
|
|
44
|
+
const og = overlay.getContext('2d');
|
|
45
|
+
const card = document.createElement('div');
|
|
46
|
+
card.id = 'selection';
|
|
47
|
+
card.hidden = true;
|
|
48
|
+
document.body.append(card);
|
|
49
|
+
|
|
50
|
+
// Scenes are painted in CSS pixels of the frame here (W × H, the stage SW wide — engine.js),
|
|
51
|
+
// so a plane's view() maps painted coordinates straight to where they are in the frame;
|
|
52
|
+
// the frame lies at (x, y) in the canvas.
|
|
53
|
+
const size = () => { const f = stage.frame(); return { W: f.w, H: f.h, SW: I.STAGE * f.h, x: f.x, y: f.y }; };
|
|
54
|
+
// objects stand where the engine puts them now (its depths().now: their real distance, their `place`)
|
|
55
|
+
const placed = (dz, o) => dz.now(o).o;
|
|
56
|
+
// a platformer's hero is drawn moved and turned (engine.js heroMatrix): so is its outline
|
|
57
|
+
function heroed(m, o, cam, SW, H) {
|
|
58
|
+
if (!o || !cam.hero || o.id !== cam.hero.id) return m;
|
|
59
|
+
const p = placed(I.depths(spec), o), h = I.heroMatrix(cam.hero, p.x * SW, p.size * H / (o.height || 2));
|
|
60
|
+
return [m[0] * h[0], 0, 0, m[3], m[0] * h[4] + m[4], m[3] * h[5] + m[5]];
|
|
61
|
+
}
|
|
62
|
+
const layerSeed = i => I.hash(JSON.stringify(spec.layers || []) + (spec.title || '')) + i * 101;
|
|
63
|
+
|
|
64
|
+
// any ink within a few screen pixels of (px, py) when drawn at on-screen scale s?
|
|
65
|
+
function inked(px, py, s, draw) {
|
|
66
|
+
pg.setTransform(1, 0, 0, 1, 0, 0);
|
|
67
|
+
pg.clearRect(0, 0, PROBE, PROBE);
|
|
68
|
+
pg.setTransform(s, 0, 0, s, PROBE / 2 - px * s, PROBE / 2 - py * s);
|
|
69
|
+
draw(pg);
|
|
70
|
+
const d = pg.getImageData(0, 0, PROBE, PROBE).data;
|
|
71
|
+
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pick(clientX, clientY) {
|
|
76
|
+
const { W, H, SW, x, y } = size(), cam = stage.camera(), dz = I.depths(spec);
|
|
77
|
+
const r = canvas.getBoundingClientRect(), k = r.width / Math.max(1, canvas.clientWidth) || 1;
|
|
78
|
+
const sx = (clientX - r.left) / k - x, sy = (clientY - r.top) / k - y;
|
|
79
|
+
// nearest first, in the renderer's order: it paints far to near, and at equal distance
|
|
80
|
+
// layers in spec order, then objects — so the later one is on top
|
|
81
|
+
// (looking around, the elements standing about — no landscape layers, the sky behind them)
|
|
82
|
+
const round = I.camSetup(spec).round;
|
|
83
|
+
const things = [
|
|
84
|
+
...(round ? [] : spec.layers || []).map((L, i) => ({ path: `layers[${i}]`, L, i, at: { m: I.view(cam, dz.Z[i], W, H, dz.yh), D: dz.Z[i] - cam.z } })).filter(t => LAND.has(t.L.kind)),
|
|
85
|
+
...(spec.objects || []).map((o, i) => ({ path: `objects[${i}]`, o: placed(dz, o), at: round && o.sky ? null : I.placeOf(o, cam, W, H, dz, round) })).filter(t => t.o.kind !== 'bird' && !t.o.plane),
|
|
86
|
+
].map((t, k) => ({ ...t, k })).filter(t => t.at && t.at.D > 0.48 && (!only || only(t.path))).sort((a, b) => a.at.D - b.at.D || b.k - a.k);
|
|
87
|
+
for (const t of things) {
|
|
88
|
+
const m = heroed(t.at.m, t.o, cam, SW, H), s = m[0], px = (sx - m[4]) / s, py = (sy - m[5]) / m[3];
|
|
89
|
+
if (t.o) {
|
|
90
|
+
if (inked(px, py, s, g => I.drawObject(g, t.o, SW, H, 0, []))) return t.path;
|
|
91
|
+
} else if (t.L.kind === 'water') {
|
|
92
|
+
if (py >= t.L.y * H) return t.path;
|
|
93
|
+
} else {
|
|
94
|
+
const k = s, w = PROBE / k;
|
|
95
|
+
I.staticLayer(t.L, SW, H, layerSeed(t.i), { x: px - w / 2, y: py - w / 2, w, h: w, k, canvas: probe });
|
|
96
|
+
const d = pg.getImageData(0, 0, PROBE, PROBE).data;
|
|
97
|
+
for (let j = 3; j < d.length; j += 4) if (d[j] > 40) return t.path;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const m = I.view(cam, Infinity, W, H, dz.yh), px = (sx - m[4]) / m[0], py = (sy - m[5]) / m[3];
|
|
101
|
+
const ci = (spec.celestials || []).findIndex(c => Math.hypot(px - c.x * SW, py - c.y * H) < c.size * H / 2 + 6);
|
|
102
|
+
const far = ci >= 0 ? `celestials[${ci}]` : 'sky';
|
|
103
|
+
return !only || only(far) ? far : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── the glow ────────────────────────────────────────────────────────
|
|
107
|
+
// The selected thing's silhouette, painted once: { c, x, y, k } — canvas c covers painted
|
|
108
|
+
// coordinates from (x, y) at scale k — and the plane it stands on (Z).
|
|
109
|
+
let sil = null, box = null; // box: where the outlined thing is on the screen now (CSS pixels)
|
|
110
|
+
// the part of a silhouette's canvas that has ink: { x, y, w, h } in its pixels
|
|
111
|
+
function inkBox(c) {
|
|
112
|
+
const k = Math.max(1, Math.ceil(Math.max(c.width, c.height) / 256)), w = Math.ceil(c.width / k), h = Math.ceil(c.height / k);
|
|
113
|
+
const t = I.layer(w, h), tg = t.getContext('2d', { willReadFrequently: true });
|
|
114
|
+
tg.drawImage(c, 0, 0, w, h);
|
|
115
|
+
const d = tg.getImageData(0, 0, w, h).data;
|
|
116
|
+
let x0 = w, y0 = h, x1 = -1, y1 = -1;
|
|
117
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (d[(y * w + x) * 4 + 3] > 24) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
|
|
118
|
+
return x1 < 0 ? { x: 0, y: 0, w: c.width, h: c.height } : { x: x0 * k, y: y0 * k, w: (x1 - x0 + 1) * k, h: (y1 - y0 + 1) * k };
|
|
119
|
+
}
|
|
120
|
+
function silhouette() {
|
|
121
|
+
const { SW, H } = size(), dz = I.depths(spec), it = item(spec, sel), i = Number(/\d+/.exec(sel)?.[0]);
|
|
122
|
+
if (sel.startsWith('objects')) {
|
|
123
|
+
const o = placed(dz, it), b = I.objectBox(o, SW, H);
|
|
124
|
+
const k = Math.min(2, 2048 / Math.max(1, b.bw), 2048 / Math.max(1, b.bh));
|
|
125
|
+
const c = I.layer(b.bw * k, b.bh * k), g = c.getContext('2d');
|
|
126
|
+
g.setTransform(k, 0, 0, k, -b.bx * k, -b.by * k);
|
|
127
|
+
I.drawObject(g, o, SW, H, 0, []);
|
|
128
|
+
return { c, x: b.bx, y: b.by, k, Z: dz.now(it).Z, obj: it };
|
|
129
|
+
}
|
|
130
|
+
if (sel.startsWith('layers') && LAND.has(it.kind)) {
|
|
131
|
+
if (it.kind === 'water') {
|
|
132
|
+
const c = I.layer(4, 4); c.getContext('2d').fillRect(0, 0, 4, 4);
|
|
133
|
+
return { c, x: -I.EXT * SW, y: it.y * H, k: 4 / ((1 + 2 * I.EXT) * SW), ky: 4 / ((I.DEEP - it.y) * H), Z: dz.Z[i] };
|
|
134
|
+
}
|
|
135
|
+
const k = 0.5, x = -I.EXT * SW;
|
|
136
|
+
const st = I.staticLayer(it, SW, H, layerSeed(i), { x, y: 0, w: (1 + 2 * I.EXT) * SW, h: I.DEEP * H, k });
|
|
137
|
+
return { c: st.canvas, x, y: 0, k, Z: dz.Z[i] };
|
|
138
|
+
}
|
|
139
|
+
if (sel.startsWith('celestials')) {
|
|
140
|
+
const r = it.size * H / 2, c = I.layer(2 * r + 4, 2 * r + 4), g = c.getContext('2d');
|
|
141
|
+
g.beginPath(); g.arc(r + 2, r + 2, r, 0, 7); g.fill();
|
|
142
|
+
return { c, x: it.x * SW - r - 2, y: it.y * H - r - 2, k: 1, Z: Infinity };
|
|
143
|
+
}
|
|
144
|
+
return null; // the sky: nothing to outline
|
|
145
|
+
}
|
|
146
|
+
function drawGlow() {
|
|
147
|
+
const dpr = window.devicePixelRatio || 1, cam = stage.camera();
|
|
148
|
+
// the scene's state moved or reshaped it (a figure that walks): outline it as it is now
|
|
149
|
+
if (sel && sil && sil.ver !== I.STATE.ver) sil = { ...(silhouette() || sil), ver: I.STATE.ver };
|
|
150
|
+
const key = sel && sil ? `${cam.x.toFixed(4)},${cam.y.toFixed(4)},${cam.z.toFixed(4)},${cam.yaw.toFixed(4)},${cam.h},${overlay.width},${sil.ver}` : '';
|
|
151
|
+
if (key === drawn) return;
|
|
152
|
+
drawn = key;
|
|
153
|
+
og.setTransform(1, 0, 0, 1, 0, 0);
|
|
154
|
+
og.clearRect(0, 0, overlay.width, overlay.height);
|
|
155
|
+
box = null;
|
|
156
|
+
if (!key) return;
|
|
157
|
+
const { W, H, x, y } = size(), dz = I.depths(spec), round = I.camSetup(spec).round;
|
|
158
|
+
const at = sil.obj && !sil.obj.sky ? I.placeOf(sil.obj, cam, W, H, dz, round) : { m: I.view(cam, sil.Z, W, H, dz.yh), D: Infinity };
|
|
159
|
+
if (!at || at.D < 0.3) return;
|
|
160
|
+
const m = heroed(at.m, sil.obj, cam, I.STAGE * H, H);
|
|
161
|
+
const sx = m[0] / sil.k * dpr, sy = m[3] / (sil.ky || sil.k) * dpr;
|
|
162
|
+
const ox = (x + m[0] * sil.x + m[4]) * dpr, oy = (y + m[3] * sil.y + m[5]) * dpr;
|
|
163
|
+
const r = canvas.getBoundingClientRect();
|
|
164
|
+
sil.ink ||= inkBox(sil.c);
|
|
165
|
+
box = { x: r.left + (ox + sil.ink.x * sx) / dpr, y: r.top + (oy + sil.ink.y * sy) / dpr, w: sil.ink.w * sx / dpr, h: sil.ink.h * sy / dpr };
|
|
166
|
+
// the shape itself is drawn far off-canvas; only its blurred shadow lands on screen —
|
|
167
|
+
// then the shape's interior is cut out, leaving a glow around the outline
|
|
168
|
+
const OFF = overlay.width + 4000;
|
|
169
|
+
og.shadowColor = GLOW;
|
|
170
|
+
og.shadowOffsetX = OFF;
|
|
171
|
+
for (const blur of [3, 10, 22]) {
|
|
172
|
+
og.shadowBlur = blur * dpr;
|
|
173
|
+
og.setTransform(sx, 0, 0, sy, ox - OFF, oy);
|
|
174
|
+
og.drawImage(sil.c, 0, 0);
|
|
175
|
+
}
|
|
176
|
+
og.shadowColor = 'transparent';
|
|
177
|
+
og.globalCompositeOperation = 'destination-out';
|
|
178
|
+
og.setTransform(sx, 0, 0, sy, ox, oy);
|
|
179
|
+
og.drawImage(sil.c, 0, 0);
|
|
180
|
+
og.globalCompositeOperation = 'source-over';
|
|
181
|
+
}
|
|
182
|
+
const fit = () => {
|
|
183
|
+
const dpr = window.devicePixelRatio || 1;
|
|
184
|
+
overlay.width = Math.round(overlay.clientWidth * dpr);
|
|
185
|
+
overlay.height = Math.round(overlay.clientHeight * dpr);
|
|
186
|
+
drawn = '';
|
|
187
|
+
};
|
|
188
|
+
addEventListener('resize', () => { fit(); if (sel) sil = silhouette(); });
|
|
189
|
+
fit();
|
|
190
|
+
const loop = () => { requestAnimationFrame(loop); if (spec) drawGlow(); };
|
|
191
|
+
requestAnimationFrame(loop);
|
|
192
|
+
|
|
193
|
+
// ── the card ────────────────────────────────────────────────────────
|
|
194
|
+
const hint_ = () => (hint ? hint() : 'напиши в чат, что с этим сделать');
|
|
195
|
+
function renderCard() {
|
|
196
|
+
card.hidden = !sel || !withCard;
|
|
197
|
+
if (!sel) return;
|
|
198
|
+
const it = item(spec, sel);
|
|
199
|
+
card.innerHTML = '';
|
|
200
|
+
const head = document.createElement('div');
|
|
201
|
+
head.className = 'head';
|
|
202
|
+
const name = document.createElement('b');
|
|
203
|
+
name.textContent = describe(spec, sel);
|
|
204
|
+
const close = document.createElement('button');
|
|
205
|
+
close.type = 'button'; close.textContent = '✕'; close.title = 'снять выделение (Esc)';
|
|
206
|
+
close.onclick = () => select(null);
|
|
207
|
+
head.append(name, close);
|
|
208
|
+
const props = document.createElement('div');
|
|
209
|
+
props.className = 'props';
|
|
210
|
+
const colour = it?.color || it?.top;
|
|
211
|
+
if (colour) { const sw = document.createElement('i'); sw.style.background = colour; props.append(sw); }
|
|
212
|
+
props.append(Object.entries(it || {}).filter(([k, v]) => !['kind', 'code', 'name', 'id'].includes(k) && typeof v !== 'object' && v !== false && String(v).length < 40)
|
|
213
|
+
.map(([k, v]) => `${k} ${typeof v === 'number' ? +v.toFixed(2) : v}`).join(' · '));
|
|
214
|
+
const hint = document.createElement('div');
|
|
215
|
+
hint.className = 'hint';
|
|
216
|
+
hint.textContent = hint_();
|
|
217
|
+
card.append(head, props, hint);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function select(path) {
|
|
221
|
+
sel = path && item(spec, path) !== undefined ? path : null;
|
|
222
|
+
sil = sel ? silhouette() : null;
|
|
223
|
+
drawn = 'dirty';
|
|
224
|
+
renderCard();
|
|
225
|
+
onChange?.(sel);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Things (objects, sun, moon) select on the first click. The continuous background —
|
|
229
|
+
// land layers, water, sky — only on a second click on the same part: the first one
|
|
230
|
+
// clears any selection, so stray clicks on the landscape never select it. Clicking
|
|
231
|
+
// what is selected clears it.
|
|
232
|
+
// Background: the old landscape layers and sky, and any element covering more than a
|
|
233
|
+
// quarter of the view from the start (the sky, a lake, far hills) — measured once per
|
|
234
|
+
// scene by painting it small.
|
|
235
|
+
const coverage = new Map();
|
|
236
|
+
function background(path) {
|
|
237
|
+
if (/^(layers\[|sky$)/.test(path)) return true;
|
|
238
|
+
if (!path.startsWith('objects')) return false;
|
|
239
|
+
if (!coverage.has(path)) {
|
|
240
|
+
const { W, H, SW } = size(), dz = I.depths(spec), it = item(spec, path), n = 64, c = I.layer(n, n * H / W), g = c.getContext('2d');
|
|
241
|
+
// from the start — looking around, turned toward it
|
|
242
|
+
const round = I.camSetup(spec).round, p = round && !it.sky ? dz.now(it).at : null;
|
|
243
|
+
const at = I.placeOf(it, { x: 0, y: 0, z: 0, h: 1, yaw: p ? Math.atan2(p.X, p.Z) : 0 }, W, H, dz, round && !it.sky), k = n / W;
|
|
244
|
+
const m = at ? at.m : [0, 0, 0, 0, 0, 0];
|
|
245
|
+
g.setTransform(m[0] * k, 0, 0, m[3] * k, m[4] * k, m[5] * k);
|
|
246
|
+
try { I.drawObject(g, placed(dz, it), SW, H, 0, []); } catch {}
|
|
247
|
+
const d = g.getImageData(0, 0, c.width, c.height).data;
|
|
248
|
+
let ink = 0;
|
|
249
|
+
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) ink++;
|
|
250
|
+
coverage.set(path, ink / (d.length / 4));
|
|
251
|
+
}
|
|
252
|
+
return coverage.get(path) > 0.25;
|
|
253
|
+
}
|
|
254
|
+
let armed = null; // a background part clicked once: { path, t }
|
|
255
|
+
function clicked(path, t) {
|
|
256
|
+
const prev = armed;
|
|
257
|
+
armed = null;
|
|
258
|
+
if (path === sel) return select(null);
|
|
259
|
+
if (only) return select(path);
|
|
260
|
+
if (!background(path)) return select(path);
|
|
261
|
+
if (prev && prev.path === path && !sel) return select(path);
|
|
262
|
+
if (sel) select(null);
|
|
263
|
+
armed = { path, t };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// a click is a press and release without dragging, with one finger
|
|
267
|
+
let down = null;
|
|
268
|
+
canvas.addEventListener('pointerdown', e => {
|
|
269
|
+
down = down ? { multi: true } : { x: e.clientX, y: e.clientY, t: performance.now() };
|
|
270
|
+
});
|
|
271
|
+
canvas.addEventListener('pointerup', e => {
|
|
272
|
+
const d = down;
|
|
273
|
+
down = null;
|
|
274
|
+
if (!d || d.multi || !spec) return;
|
|
275
|
+
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 5 || performance.now() - d.t > 500) return;
|
|
276
|
+
const path = pick(e.clientX, e.clientY);
|
|
277
|
+
if (path) clicked(path, performance.now());
|
|
278
|
+
else if (sel) select(null);
|
|
279
|
+
});
|
|
280
|
+
canvas.addEventListener('pointercancel', () => { down = null; });
|
|
281
|
+
addEventListener('keydown', e => {
|
|
282
|
+
if (e.key === 'Escape' && sel && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) select(null);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
// a new scene (another world or version): indices may mean other things now
|
|
287
|
+
setSpec(next) { if (next !== spec) { spec = next; coverage.clear(); select(null); } },
|
|
288
|
+
path: () => sel,
|
|
289
|
+
// the selected scene element itself (with its stable id), or null
|
|
290
|
+
object: () => (sel && sel.startsWith('objects') ? item(spec, sel) : null),
|
|
291
|
+
// where the selection is on the screen now — {x, y, w, h} in CSS pixels — or null
|
|
292
|
+
box: () => (sel ? box : null),
|
|
293
|
+
// what a click there would pick (a path), or null
|
|
294
|
+
at: (clientX, clientY) => (spec ? pick(clientX, clientY) : null),
|
|
295
|
+
refresh: () => renderCard(),
|
|
296
|
+
clear: () => select(null),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { create, describe };
|
|
301
|
+
})();
|