@silkweaver/build 1.0.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/LICENSE +674 -0
- package/README.md +20 -0
- package/assets/icon.ico +0 -0
- package/dist/build.d.ts +42 -0
- package/dist/build.js +736 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +29 -0
- package/dist/object_format.d.ts +46 -0
- package/dist/object_format.js +235 -0
- package/package.json +46 -0
package/dist/build.js
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Game build / export logic for the Silkweaver desktop app.
|
|
4
|
+
*
|
|
5
|
+
* Pure Node code (fs/path/esbuild only — no electron imports) so it can be
|
|
6
|
+
* unit-tested and reused outside the IPC layer. main.ts wires these functions
|
|
7
|
+
* to the renderer over IPC.
|
|
8
|
+
*
|
|
9
|
+
* The engine, user scripts, and object code are all bundled into game.js by
|
|
10
|
+
* esbuild, so the ONLY difference between a preview build and an export build
|
|
11
|
+
* is how runtime asset URLs (sprite/background images) are resolved:
|
|
12
|
+
* - 'preview': absolute file:// URLs into the live project folder (runs in the IDE)
|
|
13
|
+
* - 'export': relative 'assets/...' URLs (assets are copied next to game.js)
|
|
14
|
+
*/
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
27
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
28
|
+
}) : function(o, v) {
|
|
29
|
+
o["default"] = v;
|
|
30
|
+
});
|
|
31
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
32
|
+
var ownKeys = function(o) {
|
|
33
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34
|
+
var ar = [];
|
|
35
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
36
|
+
return ar;
|
|
37
|
+
};
|
|
38
|
+
return ownKeys(o);
|
|
39
|
+
};
|
|
40
|
+
return function (mod) {
|
|
41
|
+
if (mod && mod.__esModule) return mod;
|
|
42
|
+
var result = {};
|
|
43
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
44
|
+
__setModuleDefault(result, mod);
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.read_project = read_project;
|
|
50
|
+
exports.build_preview = build_preview;
|
|
51
|
+
exports.export_html5 = export_html5;
|
|
52
|
+
exports.export_executable = export_executable;
|
|
53
|
+
const fs = __importStar(require("node:fs"));
|
|
54
|
+
const path = __importStar(require("node:path"));
|
|
55
|
+
const os = __importStar(require("node:os"));
|
|
56
|
+
const node_url_1 = require("node:url");
|
|
57
|
+
/** Reads and parses a project's project.json. */
|
|
58
|
+
async function read_project(project_folder) {
|
|
59
|
+
const proj_text = await fs.promises.readFile(path.join(project_folder, 'project.json'), 'utf8');
|
|
60
|
+
return JSON.parse(proj_text);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Returns every runtime export name of the built engine bundle, so the generated
|
|
64
|
+
* entry can import the whole API (keeping it in sync with the IDE's autocomplete).
|
|
65
|
+
* esbuild tree-shakes whatever the game doesn't actually use.
|
|
66
|
+
*/
|
|
67
|
+
let _engine_names = null;
|
|
68
|
+
async function engine_export_names(engine_path) {
|
|
69
|
+
if (_engine_names)
|
|
70
|
+
return _engine_names;
|
|
71
|
+
const mod = await import((0, node_url_1.pathToFileURL)(engine_path).href);
|
|
72
|
+
_engine_names = Object.keys(mod).filter(n => n !== 'default' && /^[A-Za-z_$][\w$]*$/.test(n));
|
|
73
|
+
return _engine_names;
|
|
74
|
+
}
|
|
75
|
+
/** Absolute path to the engine package entry — the esbuild alias target + export-name source. */
|
|
76
|
+
function engine_entry() {
|
|
77
|
+
return require.resolve('@silkweaver/engine');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Strips leading `import …` lines from a code snippet so it can be inlined inside a
|
|
81
|
+
* function body (timeline moments). Engine references auto-resolve via the inject shim,
|
|
82
|
+
* so the imports are redundant; importing inside a function body is also a syntax error.
|
|
83
|
+
*/
|
|
84
|
+
function _strip_import_lines(code) {
|
|
85
|
+
return code
|
|
86
|
+
.split('\n')
|
|
87
|
+
.filter(line => !/^\s*import\s.+from\s+['"][^'"]+['"]\s*;?\s*$/.test(line) && !/^\s*import\s+['"][^'"]+['"]\s*;?\s*$/.test(line))
|
|
88
|
+
.join('\n');
|
|
89
|
+
}
|
|
90
|
+
/** Converts a CSS hex colour ('#rrggbb') to a GMS BGR integer (0xBBGGRR). */
|
|
91
|
+
function hex_to_bgr(hex) {
|
|
92
|
+
const m = /^#?([0-9a-fA-F]{6})$/.exec((hex ?? '').trim());
|
|
93
|
+
if (!m)
|
|
94
|
+
return 0;
|
|
95
|
+
const n = parseInt(m[1], 16);
|
|
96
|
+
const r = (n >> 16) & 0xFF, g = (n >> 8) & 0xFF, b = n & 0xFF;
|
|
97
|
+
return (b << 16) | (g << 8) | r;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Generates the bootstrapper (_entry.ts) source for a project.
|
|
101
|
+
* @param asset_mode - 'preview' (file:// into project) or 'export' (relative assets/)
|
|
102
|
+
*/
|
|
103
|
+
async function generate_entry_code(project_folder, proj, asset_mode) {
|
|
104
|
+
const object_names = Object.keys(proj.resources.objects ?? {});
|
|
105
|
+
const room_names = Object.keys(proj.resources.rooms ?? {});
|
|
106
|
+
const sprite_names = Object.keys(proj.resources.sprites ?? {});
|
|
107
|
+
const script_names = Object.keys(proj.resources.scripts ?? {});
|
|
108
|
+
const font_names = Object.keys(proj.resources.fonts ?? {});
|
|
109
|
+
const path_names = Object.keys(proj.resources.paths ?? {});
|
|
110
|
+
const timeline_names = Object.keys(proj.resources.timelines ?? {});
|
|
111
|
+
// The engine is resolved from the @silkweaver/engine package (no path coupling).
|
|
112
|
+
const engine_path = engine_entry();
|
|
113
|
+
// Each object is a single class file: objects/<name>.ts (a gm_object subclass).
|
|
114
|
+
// It is imported as-is; metadata/variables/events all live in the class.
|
|
115
|
+
const object_imports = [];
|
|
116
|
+
for (const obj_name of object_names) {
|
|
117
|
+
const class_file = path.join(project_folder, 'objects', `${obj_name}.ts`);
|
|
118
|
+
if (await _path_exists(class_file)) {
|
|
119
|
+
object_imports.push(`import { ${obj_name} } from '${class_file.replace(/\\/g, '/')}'`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
console.warn(`[build] object '${obj_name}' has no objects/${obj_name}.ts — skipped`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Build room setup code
|
|
126
|
+
const room_setups = [];
|
|
127
|
+
const room_var_names = [];
|
|
128
|
+
const room_physics = {};
|
|
129
|
+
for (const room_name of room_names) {
|
|
130
|
+
const rm_json_path = path.join(project_folder, 'rooms', room_name, 'room.json');
|
|
131
|
+
let rm_data = {};
|
|
132
|
+
try {
|
|
133
|
+
rm_data = JSON.parse(await fs.promises.readFile(rm_json_path, 'utf8'));
|
|
134
|
+
}
|
|
135
|
+
catch { /* empty */ }
|
|
136
|
+
room_physics[room_name] = rm_data.physics_world
|
|
137
|
+
? { gx: rm_data.physics_gravity_x ?? 0, gy: rm_data.physics_gravity_y ?? 10 }
|
|
138
|
+
: null;
|
|
139
|
+
const instances = rm_data.instances ?? [];
|
|
140
|
+
const var_name = `_room_${room_name}`;
|
|
141
|
+
// Track the first placed instance of each object so views can follow it.
|
|
142
|
+
const first_inst_by_obj = {};
|
|
143
|
+
const inst_lines = instances.map((inst, idx) => {
|
|
144
|
+
if (!object_names.includes(inst.object_name))
|
|
145
|
+
return '';
|
|
146
|
+
const v = `_inst_${inst.object_name}_${idx}`;
|
|
147
|
+
if (!(inst.object_name in first_inst_by_obj))
|
|
148
|
+
first_inst_by_obj[inst.object_name] = v;
|
|
149
|
+
const lines = [
|
|
150
|
+
` const ${v} = new ${inst.object_name}(${var_name})`,
|
|
151
|
+
` ${var_name}.room_instance_add(${inst.x}, ${inst.y}, ${v})`,
|
|
152
|
+
];
|
|
153
|
+
// Per-instance transform (only emit non-defaults to keep generated code tidy).
|
|
154
|
+
if ((inst.scale_x ?? 1) !== 1)
|
|
155
|
+
lines.push(` ${v}.image_xscale = ${inst.scale_x}`);
|
|
156
|
+
if ((inst.scale_y ?? 1) !== 1)
|
|
157
|
+
lines.push(` ${v}.image_yscale = ${inst.scale_y}`);
|
|
158
|
+
if ((inst.rotation ?? 0) !== 0)
|
|
159
|
+
lines.push(` ${v}.image_angle = ${inst.rotation}`);
|
|
160
|
+
lines.push(` ${v}.register_events()`);
|
|
161
|
+
lines.push(` game_loop.register(EVENT_TYPE.create, ${v}.on_create.bind(${v}))`);
|
|
162
|
+
// Per-instance creation code runs after the object's Create event (bound: this = instance).
|
|
163
|
+
if (inst.creation_code && inst.creation_code.trim()) {
|
|
164
|
+
lines.push(` game_loop.register(EVENT_TYPE.create, (function(this: any){\n${inst.creation_code}\n}).bind(${v}))`);
|
|
165
|
+
}
|
|
166
|
+
return lines.join('\n');
|
|
167
|
+
}).filter(Boolean).join('\n');
|
|
168
|
+
// Background layers (name → runtime id via _background_map).
|
|
169
|
+
const bg_lines = (rm_data.backgrounds ?? []).map((bl, i) => {
|
|
170
|
+
if (!bl.bg_name)
|
|
171
|
+
return '';
|
|
172
|
+
return [
|
|
173
|
+
` ${var_name}.background_index[${i}] = _background_map['${bl.bg_name}'] ?? -1`,
|
|
174
|
+
` ${var_name}.background_visible[${i}] = ${bl.enabled ?? true}`,
|
|
175
|
+
` ${var_name}.background_foreground[${i}] = false`,
|
|
176
|
+
` ${var_name}.background_htiled[${i}] = ${bl.tile_x ?? false}`,
|
|
177
|
+
` ${var_name}.background_vtiled[${i}] = ${bl.tile_y ?? false}`,
|
|
178
|
+
` ${var_name}.background_stretch[${i}] = ${bl.stretch ?? false}`,
|
|
179
|
+
` ${var_name}.background_x[${i}] = 0`,
|
|
180
|
+
` ${var_name}.background_y[${i}] = 0`,
|
|
181
|
+
` ${var_name}.background_color[${i}] = 0xFFFFFF`,
|
|
182
|
+
].join('\n');
|
|
183
|
+
}).filter(Boolean).join('\n');
|
|
184
|
+
// Views / cameras. follow (object name) resolves to the first placed instance.
|
|
185
|
+
const views = rm_data.views ?? [];
|
|
186
|
+
const view_lines = views.some(v => v.enabled)
|
|
187
|
+
? [` ${var_name}.view_enabled = true`].concat(views.map((vw, i) => {
|
|
188
|
+
const fv = vw.follow ? first_inst_by_obj[vw.follow] : undefined;
|
|
189
|
+
return [
|
|
190
|
+
` ${var_name}.view_visible[${i}] = ${vw.enabled ?? false}`,
|
|
191
|
+
` ${var_name}.view_xview[${i}] = ${vw.view_x ?? 0}`,
|
|
192
|
+
` ${var_name}.view_yview[${i}] = ${vw.view_y ?? 0}`,
|
|
193
|
+
` ${var_name}.view_wview[${i}] = ${vw.view_w ?? 640}`,
|
|
194
|
+
` ${var_name}.view_hview[${i}] = ${vw.view_h ?? 480}`,
|
|
195
|
+
` ${var_name}.view_xport[${i}] = ${vw.port_x ?? 0}`,
|
|
196
|
+
` ${var_name}.view_yport[${i}] = ${vw.port_y ?? 0}`,
|
|
197
|
+
` ${var_name}.view_wport[${i}] = ${vw.port_w ?? vw.view_w ?? 640}`,
|
|
198
|
+
` ${var_name}.view_hport[${i}] = ${vw.port_h ?? vw.view_h ?? 480}`,
|
|
199
|
+
fv
|
|
200
|
+
? ` ${var_name}.view_hborder[${i}] = 32\n ${var_name}.view_vborder[${i}] = 32\n ${var_name}.view_object[${i}] = ${fv}.id`
|
|
201
|
+
: ` ${var_name}.view_object[${i}] = -1`,
|
|
202
|
+
].join('\n');
|
|
203
|
+
})).join('\n')
|
|
204
|
+
: '';
|
|
205
|
+
// Tiles — each is a sub-rectangle of a background used as a tileset.
|
|
206
|
+
const tile_lines = (rm_data.tiles ?? []).map((t) => {
|
|
207
|
+
if (!t.bg_name)
|
|
208
|
+
return '';
|
|
209
|
+
const bid = `_background_map['${t.bg_name}']`;
|
|
210
|
+
return ` if (${bid} !== undefined) ${var_name}.tile_add(${bid}, ${t.left}, ${t.top}, ${t.width}, ${t.height}, ${t.x}, ${t.y}, ${t.depth})`;
|
|
211
|
+
}).filter(Boolean).join('\n');
|
|
212
|
+
room_var_names.push(var_name);
|
|
213
|
+
// Instances / layers / tiles go inside a `builder` closure so the room can rebuild
|
|
214
|
+
// itself from this definition when (re-)entered. Non-persistent rooms rebuild fresh
|
|
215
|
+
// on every entry; persistent rooms keep their live state after the first build.
|
|
216
|
+
room_setups.push(`const ${var_name} = new room()
|
|
217
|
+
${var_name}.room_width = ${rm_data.width ?? 640}
|
|
218
|
+
${var_name}.room_height = ${rm_data.height ?? 480}
|
|
219
|
+
${var_name}.room_speed = ${rm_data.room_speed ?? 60}
|
|
220
|
+
${var_name}.room_persistent = ${rm_data.persistent ?? false}
|
|
221
|
+
${var_name}.background_show_color = ${rm_data.bg_show_color ?? true}
|
|
222
|
+
${var_name}.background_solid_color = ${hex_to_bgr(rm_data.bg_color ?? '#000000')}${rm_data.creation_code && rm_data.creation_code.trim()
|
|
223
|
+
? `\n${var_name}.creation_code = () => {\n${rm_data.creation_code}\n}`
|
|
224
|
+
: ''}
|
|
225
|
+
${var_name}.builder = () => {
|
|
226
|
+
${inst_lines}
|
|
227
|
+
${bg_lines}
|
|
228
|
+
${view_lines}
|
|
229
|
+
${tile_lines}
|
|
230
|
+
}`);
|
|
231
|
+
}
|
|
232
|
+
// ── Fonts ────────────────────────────────────────────────────────────
|
|
233
|
+
// CSS-family fonts (family/size/style) read from fonts/<n>/meta.json and
|
|
234
|
+
// registered by name so `draw_set_font('fnt_x')` resolves at runtime.
|
|
235
|
+
const font_setups = [];
|
|
236
|
+
for (const fn of font_names) {
|
|
237
|
+
let fd = {};
|
|
238
|
+
try {
|
|
239
|
+
fd = JSON.parse(await fs.promises.readFile(path.join(project_folder, 'fonts', fn, 'meta.json'), 'utf8'));
|
|
240
|
+
}
|
|
241
|
+
catch { /* default */ }
|
|
242
|
+
const family = typeof fd.font_name === 'string' && fd.font_name.trim() ? fd.font_name : 'Arial';
|
|
243
|
+
const size = Number(fd.size) || 16;
|
|
244
|
+
font_setups.push(` font_register_name('${fn}', new font_resource(${JSON.stringify(family)}, ${size}, ${!!fd.bold}, ${!!fd.italic}))`);
|
|
245
|
+
}
|
|
246
|
+
// ── Paths ────────────────────────────────────────────────────────────
|
|
247
|
+
// Read paths/<n>/path.json and rebuild the path resource inline (point speed
|
|
248
|
+
// is stored 0–100 in the editor; the engine uses a 1 = normal factor).
|
|
249
|
+
const path_setups = [];
|
|
250
|
+
for (const pn of path_names) {
|
|
251
|
+
let pd = {};
|
|
252
|
+
try {
|
|
253
|
+
pd = JSON.parse(await fs.promises.readFile(path.join(project_folder, 'paths', pn, 'path.json'), 'utf8'));
|
|
254
|
+
}
|
|
255
|
+
catch { /* default */ }
|
|
256
|
+
const pts = Array.isArray(pd.points) ? pd.points : [];
|
|
257
|
+
const kind = pd.kind === 'smooth' ? 'path_kind_smooth' : 'path_kind_linear';
|
|
258
|
+
const v = `_path_${pn}`;
|
|
259
|
+
const lines = [
|
|
260
|
+
` const ${v} = path_create()`,
|
|
261
|
+
` path_set_kind(${v}, ${kind})`,
|
|
262
|
+
` path_set_closed(${v}, ${!!pd.closed})`,
|
|
263
|
+
];
|
|
264
|
+
for (const p of pts) {
|
|
265
|
+
const sp = (typeof p.sp === 'number' ? p.sp : 100) / 100;
|
|
266
|
+
lines.push(` path_add_point(${v}, ${Number(p.x) || 0}, ${Number(p.y) || 0}, ${sp})`);
|
|
267
|
+
}
|
|
268
|
+
lines.push(` path_register_name('${pn}', ${v})`);
|
|
269
|
+
path_setups.push(lines.join('\n'));
|
|
270
|
+
}
|
|
271
|
+
// ── Timelines ────────────────────────────────────────────────────────
|
|
272
|
+
// Each moment's code lives in timelines/<n>/step_<step>.ts; inline it as a
|
|
273
|
+
// callback (bare engine calls auto-resolve via the esbuild inject shim).
|
|
274
|
+
const timeline_setups = [];
|
|
275
|
+
for (const tn of timeline_names) {
|
|
276
|
+
let td = {};
|
|
277
|
+
try {
|
|
278
|
+
td = JSON.parse(await fs.promises.readFile(path.join(project_folder, 'timelines', tn, 'timeline.json'), 'utf8'));
|
|
279
|
+
}
|
|
280
|
+
catch { /* default */ }
|
|
281
|
+
const moments = Array.isArray(td.moments) ? td.moments : [];
|
|
282
|
+
const v = `_tl_${tn}`;
|
|
283
|
+
const lines = [` const ${v} = timeline_create()`];
|
|
284
|
+
for (const m of moments) {
|
|
285
|
+
const step = Number(m.step) || 0;
|
|
286
|
+
let code = '';
|
|
287
|
+
try {
|
|
288
|
+
code = await fs.promises.readFile(path.join(project_folder, 'timelines', tn, `step_${step}.ts`), 'utf8');
|
|
289
|
+
}
|
|
290
|
+
catch { /* no code */ }
|
|
291
|
+
code = _strip_import_lines(code);
|
|
292
|
+
if (code.trim())
|
|
293
|
+
lines.push(` timeline_moment_add(${v}, ${step}, () => {\n${code}\n })`);
|
|
294
|
+
}
|
|
295
|
+
lines.push(` timeline_register_name('${tn}', ${v})`);
|
|
296
|
+
timeline_setups.push(lines.join('\n'));
|
|
297
|
+
}
|
|
298
|
+
// Script imports
|
|
299
|
+
const script_imports = script_names.map(s => `import '${path.join(project_folder, 'scripts', s + '.ts').replace(/\\/g, '/')}';`).join('\n');
|
|
300
|
+
// Asset URL builders. Preview points at the live project folder via
|
|
301
|
+
// file://; export points at the copied-in relative 'assets/' folder.
|
|
302
|
+
const asset_dir_url = (kind, name) => asset_mode === 'export'
|
|
303
|
+
? `assets/${kind}/${name}`
|
|
304
|
+
: 'file://' + path.join(project_folder, kind, name).replace(/\\/g, '/');
|
|
305
|
+
const asset_meta_url = (kind, name) => `${asset_dir_url(kind, name)}/meta.json`;
|
|
306
|
+
// Sprite loading code
|
|
307
|
+
const sprite_loads = sprite_names.map(spr_name => `await _load_sprite('${spr_name}', '${asset_meta_url('sprites', spr_name)}', '${asset_dir_url('sprites', spr_name)}')`).join('\n');
|
|
308
|
+
// Background loading code
|
|
309
|
+
const bg_names = Object.keys(proj.resources.backgrounds || {});
|
|
310
|
+
const bg_loads = bg_names.map(bg_name => `await _load_background('${bg_name}', '${asset_meta_url('backgrounds', bg_name)}', '${asset_dir_url('backgrounds', bg_name)}')`).join('\n');
|
|
311
|
+
// Sound loading code
|
|
312
|
+
const sound_names = Object.keys(proj.resources.sounds || {});
|
|
313
|
+
const sound_loads = sound_names.map(s => `await _load_sound('${s}', '${asset_meta_url('sounds', s)}', '${asset_dir_url('sounds', s)}')`).join('\n');
|
|
314
|
+
// Determine start room
|
|
315
|
+
const start_room = proj.settings.startRoom || room_names[0] || '';
|
|
316
|
+
const start_var = start_room ? `_room_${start_room}` : room_var_names[0] ?? 'undefined';
|
|
317
|
+
// Import the entire engine API (esbuild tree-shakes what the game doesn't use)
|
|
318
|
+
// so everything the IDE autocompletes is actually available at runtime.
|
|
319
|
+
const engine_names = await engine_export_names(engine_path);
|
|
320
|
+
const engine_import = `import {\n${engine_names.map(n => ' ' + n).join(',\n')},\n} from '@silkweaver/engine'`;
|
|
321
|
+
const entry_code = `// Auto-generated by Silkweaver IDE — do not edit manually.
|
|
322
|
+
${engine_import}
|
|
323
|
+
|
|
324
|
+
${object_imports.join('\n')}
|
|
325
|
+
|
|
326
|
+
${script_imports}
|
|
327
|
+
|
|
328
|
+
// ── Sprite loader ───────────────────────────────────────────────────────────
|
|
329
|
+
const _sprite_map: Record<string, number> = {}
|
|
330
|
+
|
|
331
|
+
async function _load_sprite(name: string, meta_url: string, img_base: string): Promise<void> {
|
|
332
|
+
// Sprites are loaded and dimensions extracted from meta.json
|
|
333
|
+
// This is a best-effort loader — missing frames are silently skipped
|
|
334
|
+
try {
|
|
335
|
+
const meta = await fetch(meta_url).then(r => r.json())
|
|
336
|
+
|
|
337
|
+
// Create sprite resource
|
|
338
|
+
const spr = new sprite()
|
|
339
|
+
spr.width = meta.width || 32
|
|
340
|
+
spr.height = meta.height || 32
|
|
341
|
+
spr.xoffset = meta.origin_x || 0
|
|
342
|
+
spr.yoffset = meta.origin_y || 0
|
|
343
|
+
// Collision mask rectangle (sprite editor). Only applied when a valid box is set.
|
|
344
|
+
if (typeof meta.mask_w === 'number' && meta.mask_w > 0 && typeof meta.mask_h === 'number' && meta.mask_h > 0) {
|
|
345
|
+
spr.mask_left = meta.mask_x || 0
|
|
346
|
+
spr.mask_top = meta.mask_y || 0
|
|
347
|
+
spr.mask_right = (meta.mask_x || 0) + meta.mask_w
|
|
348
|
+
spr.mask_bottom = (meta.mask_y || 0) + meta.mask_h
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Load all frames specified in meta.json
|
|
352
|
+
const frames = meta.frames || []
|
|
353
|
+
if (frames.length === 0) {
|
|
354
|
+
console.warn(\`[Sprite] \${name} has no frames defined in meta.json\`)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Load each frame image
|
|
359
|
+
for (const frame_meta of frames) {
|
|
360
|
+
const frame_name = frame_meta.name || frame_meta
|
|
361
|
+
const frame_url = img_base + '/' + frame_name
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
// Use texture_manager to load the image and create a WebGL texture
|
|
365
|
+
const tex_entry = await renderer.tex_mgr.load(frame_url, false)
|
|
366
|
+
spr.add_frame({
|
|
367
|
+
texture: tex_entry,
|
|
368
|
+
width: tex_entry.width,
|
|
369
|
+
height: tex_entry.height
|
|
370
|
+
})
|
|
371
|
+
} catch (err) {
|
|
372
|
+
console.warn(\`[Sprite] Failed to load frame \${frame_name} for \${name}:\`, err)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Only register the sprite if at least one frame loaded successfully
|
|
377
|
+
if (spr.frames.length > 0) {
|
|
378
|
+
_sprite_map[name] = spr.id
|
|
379
|
+
sprite_register_name(name, spr.id) // so class-file 'static sprite = name' resolves
|
|
380
|
+
} else {
|
|
381
|
+
console.warn(\`[Sprite] \${name} has no valid frames\`)
|
|
382
|
+
}
|
|
383
|
+
} catch (err) {
|
|
384
|
+
console.warn(\`[Sprite] Failed to load \${name}:\`, err)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Background loader ───────────────────────────────────────────────────────
|
|
389
|
+
const _background_map: Record<string, number> = {}
|
|
390
|
+
|
|
391
|
+
async function _load_background(name: string, meta_url: string, img_base: string): Promise<void> {
|
|
392
|
+
// Backgrounds are loaded from meta.json + single image file
|
|
393
|
+
try {
|
|
394
|
+
const meta = await fetch(meta_url).then(r => r.json())
|
|
395
|
+
|
|
396
|
+
if (!meta.file_name) {
|
|
397
|
+
console.warn(\`[Background] \${name} has no file_name in meta.json\`)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Create background resource
|
|
402
|
+
const bg = new background()
|
|
403
|
+
bg.tile_h = meta.tile_h ?? false
|
|
404
|
+
bg.tile_v = meta.tile_v ?? false
|
|
405
|
+
bg.smooth = meta.smooth ?? false
|
|
406
|
+
|
|
407
|
+
// Load the background image
|
|
408
|
+
const img_url = img_base + '/' + meta.file_name
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
// Use texture_manager to load with appropriate filtering
|
|
412
|
+
const tex_entry = await renderer.tex_mgr.load(img_url, bg.smooth)
|
|
413
|
+
bg.set_texture(tex_entry)
|
|
414
|
+
_background_map[name] = bg.id
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.warn(\`[Background] Failed to load image for \${name}:\`, err)
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
console.warn(\`[Background] Failed to load \${name}:\`, err)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── Sound loader ─────────────────────────────────────────────────────────────
|
|
424
|
+
const _sound_map: Record<string, number> = {}
|
|
425
|
+
|
|
426
|
+
async function _load_sound(name: string, meta_url: string, base: string): Promise<void> {
|
|
427
|
+
try {
|
|
428
|
+
const meta = await fetch(meta_url).then(r => r.json())
|
|
429
|
+
if (!meta.file_name) { console.warn(\`[Sound] \${name} has no file_name in meta.json\`); return }
|
|
430
|
+
const snd = new sound_asset()
|
|
431
|
+
await snd.load_url(base + '/' + meta.file_name, 'default', meta.kind === 'music')
|
|
432
|
+
sound_register_name(name, snd)
|
|
433
|
+
_sound_map[name] = snd.id
|
|
434
|
+
} catch (err) {
|
|
435
|
+
console.warn(\`[Sound] Failed to load \${name}:\`, err)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Bootstrap ───────────────────────────────────────────────────────────────
|
|
440
|
+
export default async function init(canvas: HTMLCanvasElement): Promise<void> {
|
|
441
|
+
renderer.init(canvas, ${proj.settings.windowWidth ?? 640}, ${proj.settings.windowHeight ?? 480})
|
|
442
|
+
// Background clear color: ${proj.settings.displayColor ?? '#000000'}
|
|
443
|
+
game_loop.init_input(canvas)
|
|
444
|
+
|
|
445
|
+
// Load sprites
|
|
446
|
+
${sprite_loads}
|
|
447
|
+
|
|
448
|
+
// Load backgrounds
|
|
449
|
+
${bg_loads}
|
|
450
|
+
|
|
451
|
+
// Load sounds
|
|
452
|
+
${sound_loads}
|
|
453
|
+
|
|
454
|
+
// Register fonts (resolve 'fnt_x' by name)
|
|
455
|
+
${font_setups.join('\n')}
|
|
456
|
+
|
|
457
|
+
// Register paths (resolve via path_get_index('pth_x'))
|
|
458
|
+
${path_setups.join('\n')}
|
|
459
|
+
|
|
460
|
+
// Register timelines (resolve via timeline_get_index('tml_x'))
|
|
461
|
+
${timeline_setups.join('\n')}
|
|
462
|
+
|
|
463
|
+
// Set up rooms
|
|
464
|
+
${room_setups.join('\n')}
|
|
465
|
+
|
|
466
|
+
// Link room order
|
|
467
|
+
${room_var_names.map((v, i) => {
|
|
468
|
+
const prev = room_var_names[i - 1];
|
|
469
|
+
const next = room_var_names[i + 1];
|
|
470
|
+
return [
|
|
471
|
+
prev ? `${v}.room_previous = ${prev}.id` : '',
|
|
472
|
+
next ? `${v}.room_next = ${next}.id` : '',
|
|
473
|
+
].filter(Boolean).join('\n ');
|
|
474
|
+
}).join('\n ')}
|
|
475
|
+
|
|
476
|
+
const start = ${start_var}
|
|
477
|
+
if (!start) { console.error('[Game] No rooms defined.'); return }
|
|
478
|
+
${room_physics[start_room] ? ` physics_world_create(${room_physics[start_room].gx}, ${room_physics[start_room].gy})\n` : ''} game_loop.start(start)
|
|
479
|
+
}
|
|
480
|
+
`;
|
|
481
|
+
return entry_code;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Writes a temporary _entry.ts into the project folder, bundles it to a single
|
|
485
|
+
* ESM game.js at out_path via esbuild, then removes the temporary file.
|
|
486
|
+
*/
|
|
487
|
+
async function bundle_game(project_folder, proj, asset_mode, out_path, minify) {
|
|
488
|
+
const entry_code = await generate_entry_code(project_folder, proj, asset_mode);
|
|
489
|
+
const entry_path = path.join(project_folder, '_entry.ts');
|
|
490
|
+
const globals_path = path.join(project_folder, '_engine_globals.ts');
|
|
491
|
+
await fs.promises.writeFile(entry_path, entry_code, 'utf8');
|
|
492
|
+
// Auto-import management: a shim that re-exports the whole engine API. Passed to esbuild's
|
|
493
|
+
// `inject`, it makes any *bare* engine reference in an object/script file (e.g. `gm_object`,
|
|
494
|
+
// `draw_sprite`) resolve to an auto-injected import — tree-shaken — so users never write or
|
|
495
|
+
// manage `import … from '@silkweaver/engine'` themselves.
|
|
496
|
+
const engine_names = await engine_export_names(engine_entry());
|
|
497
|
+
await fs.promises.writeFile(globals_path, `export { ${engine_names.join(', ')} } from '@silkweaver/engine'\n`, 'utf8');
|
|
498
|
+
try {
|
|
499
|
+
// Run esbuild via its Node API. Alias the package specifier to the resolved engine
|
|
500
|
+
// entry so the generated entry AND any class-per-object files resolve to the same
|
|
501
|
+
// engine (deduped), without needing node_modules inside the project folder.
|
|
502
|
+
const esbuild_api = require('esbuild');
|
|
503
|
+
await esbuild_api.build({
|
|
504
|
+
entryPoints: [entry_path],
|
|
505
|
+
bundle: true,
|
|
506
|
+
outfile: out_path,
|
|
507
|
+
format: 'esm',
|
|
508
|
+
minify,
|
|
509
|
+
alias: { '@silkweaver/engine': engine_entry() },
|
|
510
|
+
inject: [globals_path],
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
await fs.promises.unlink(entry_path).catch(() => { });
|
|
515
|
+
await fs.promises.unlink(globals_path).catch(() => { });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Builds the game to a single bundled game.js at out_path, for the in-IDE preview.
|
|
520
|
+
* @param out_path - Where to write game.js (the caller decides — e.g. exports/game.js)
|
|
521
|
+
*/
|
|
522
|
+
async function build_preview(project_folder, out_path) {
|
|
523
|
+
const proj = await read_project(project_folder);
|
|
524
|
+
await bundle_game(project_folder, proj, 'preview', out_path, false);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Exports a portable, self-contained HTML5 build into out_dir:
|
|
528
|
+
* out_dir/game.js — minified, fully self-contained game (engine inlined)
|
|
529
|
+
* out_dir/index.html — standalone player page
|
|
530
|
+
* out_dir/assets/... — copied sprite/background assets (referenced relatively)
|
|
531
|
+
* @returns The export directory path.
|
|
532
|
+
*/
|
|
533
|
+
async function export_html5(project_folder, out_dir) {
|
|
534
|
+
const proj = await read_project(project_folder);
|
|
535
|
+
await fs.promises.mkdir(out_dir, { recursive: true });
|
|
536
|
+
// 1. Bundle the game with relative asset URLs
|
|
537
|
+
await bundle_game(project_folder, proj, 'export', path.join(out_dir, 'game.js'), true);
|
|
538
|
+
// 2. Copy only the *registered* sprite/background/sound assets next to game.js
|
|
539
|
+
// (matching what generate_entry_code loads — avoids shipping orphaned files).
|
|
540
|
+
for (const kind of ['sprites', 'backgrounds', 'sounds']) {
|
|
541
|
+
for (const name of Object.keys(proj.resources[kind] ?? {})) {
|
|
542
|
+
const src = path.join(project_folder, kind, name);
|
|
543
|
+
if (await _path_exists(src))
|
|
544
|
+
await _copy_dir(src, path.join(out_dir, 'assets', kind, name));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// 3. Write the standalone player page
|
|
548
|
+
await fs.promises.writeFile(path.join(out_dir, 'index.html'), _player_html(proj), 'utf8');
|
|
549
|
+
return out_dir;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Exports a standalone desktop executable for the game.
|
|
553
|
+
*
|
|
554
|
+
* Stages a minimal Electron "player" app (a fixed main process that serves the
|
|
555
|
+
* exported game over a privileged `game://` protocol), then runs
|
|
556
|
+
* @electron/packager to produce a platform binary in out_dir.
|
|
557
|
+
*
|
|
558
|
+
* @param platform - 'win32' | 'darwin' | 'linux' (default 'win32')
|
|
559
|
+
* @param arch - 'x64' | 'arm64' (default 'x64')
|
|
560
|
+
* @returns Absolute path to the packaged application folder.
|
|
561
|
+
*/
|
|
562
|
+
async function export_executable(project_folder, out_dir, platform = 'win32', arch = 'x64') {
|
|
563
|
+
const proj = await read_project(project_folder);
|
|
564
|
+
const app_name = _safe_name(proj.name);
|
|
565
|
+
// Stage: <staging>/{ package.json, main.js, game/ }
|
|
566
|
+
const staging = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sw-exe-'));
|
|
567
|
+
try {
|
|
568
|
+
await export_html5(project_folder, path.join(staging, 'game'));
|
|
569
|
+
await fs.promises.writeFile(path.join(staging, 'package.json'), JSON.stringify({
|
|
570
|
+
name: app_name,
|
|
571
|
+
productName: proj.name || 'Silkweaver Game',
|
|
572
|
+
version: '1.0.0',
|
|
573
|
+
main: 'main.js',
|
|
574
|
+
}, null, 2), 'utf8');
|
|
575
|
+
await fs.promises.writeFile(path.join(staging, 'main.js'), _player_main_js(proj), 'utf8');
|
|
576
|
+
await fs.promises.mkdir(out_dir, { recursive: true });
|
|
577
|
+
// Pin to the running Electron version (fall back to the installed package
|
|
578
|
+
// version when invoked outside Electron, e.g. from a test harness).
|
|
579
|
+
const electron_version = process.versions.electron || require('electron/package.json').version;
|
|
580
|
+
const icon = await _resolve_icon(project_folder);
|
|
581
|
+
const { packager } = await import('@electron/packager');
|
|
582
|
+
const out_paths = await packager({
|
|
583
|
+
dir: staging,
|
|
584
|
+
out: out_dir,
|
|
585
|
+
platform: platform,
|
|
586
|
+
arch: arch,
|
|
587
|
+
overwrite: true,
|
|
588
|
+
electronVersion: electron_version,
|
|
589
|
+
appVersion: '1.0.0',
|
|
590
|
+
...(icon ? { icon } : {}),
|
|
591
|
+
});
|
|
592
|
+
return out_paths[0] ?? out_dir;
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
await fs.promises.rm(staging, { recursive: true, force: true }).catch(() => { });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/** Generates the minimal Electron player main process for a packaged game. */
|
|
599
|
+
function _player_main_js(proj) {
|
|
600
|
+
const w = proj.settings.windowWidth ?? 640;
|
|
601
|
+
const h = proj.settings.windowHeight ?? 480;
|
|
602
|
+
const bg = JSON.stringify(proj.settings.displayColor ?? '#000000');
|
|
603
|
+
const title = JSON.stringify(proj.name || 'Silkweaver Game');
|
|
604
|
+
return `// Auto-generated Silkweaver player shell.
|
|
605
|
+
const { app, BrowserWindow, protocol } = require('electron')
|
|
606
|
+
const path = require('path')
|
|
607
|
+
const fs = require('fs')
|
|
608
|
+
|
|
609
|
+
const GAME_W = ${w}, GAME_H = ${h}, GAME_TITLE = ${title}, GAME_BG = ${bg}
|
|
610
|
+
const GAME_DIR = path.join(__dirname, 'game')
|
|
611
|
+
|
|
612
|
+
const MIME = {
|
|
613
|
+
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
614
|
+
'.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml',
|
|
615
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
616
|
+
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
617
|
+
'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg',
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// A privileged scheme lets the game use ES modules + fetch like a real http origin,
|
|
621
|
+
// without disabling web security or running a local server.
|
|
622
|
+
protocol.registerSchemesAsPrivileged([
|
|
623
|
+
{ scheme: 'game', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
|
|
624
|
+
])
|
|
625
|
+
|
|
626
|
+
function create_window() {
|
|
627
|
+
const win = new BrowserWindow({
|
|
628
|
+
width: GAME_W, height: GAME_H, useContentSize: true,
|
|
629
|
+
title: GAME_TITLE, backgroundColor: GAME_BG,
|
|
630
|
+
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
|
631
|
+
})
|
|
632
|
+
win.setMenuBarVisibility(false)
|
|
633
|
+
win.loadURL('game://app/index.html')
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
app.whenReady().then(() => {
|
|
637
|
+
protocol.handle('game', async (request) => {
|
|
638
|
+
let rel = decodeURIComponent(new URL(request.url).pathname)
|
|
639
|
+
if (rel === '/' || rel === '') rel = '/index.html'
|
|
640
|
+
const file = path.join(GAME_DIR, rel)
|
|
641
|
+
if (!file.startsWith(GAME_DIR)) return new Response('Forbidden', { status: 403 })
|
|
642
|
+
try {
|
|
643
|
+
const data = await fs.promises.readFile(file)
|
|
644
|
+
const ext = path.extname(file).toLowerCase()
|
|
645
|
+
return new Response(data, { headers: { 'content-type': MIME[ext] || 'application/octet-stream' } })
|
|
646
|
+
} catch {
|
|
647
|
+
return new Response('Not found', { status: 404 })
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
create_window()
|
|
651
|
+
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) create_window() })
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })
|
|
655
|
+
`;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Resolves an application icon for packaging. Prefers a project-provided icon
|
|
659
|
+
* (icon.ico / icon.png in the project root), then falls back to the bundled
|
|
660
|
+
* Silkweaver icon. @electron/packager picks the right format per platform.
|
|
661
|
+
*/
|
|
662
|
+
async function _resolve_icon(project_folder) {
|
|
663
|
+
const candidates = [
|
|
664
|
+
path.join(project_folder, 'icon.ico'),
|
|
665
|
+
path.join(project_folder, 'icon.png'),
|
|
666
|
+
path.join(__dirname, '../assets/icon.ico'), // bundled default (packages/build/assets)
|
|
667
|
+
];
|
|
668
|
+
for (const c of candidates) {
|
|
669
|
+
if (await _path_exists(c))
|
|
670
|
+
return c;
|
|
671
|
+
}
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
674
|
+
/** Normalizes a project name into a safe lowercase application name. */
|
|
675
|
+
function _safe_name(name) {
|
|
676
|
+
const s = (name || 'silkweaver-game').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
677
|
+
return s || 'silkweaver-game';
|
|
678
|
+
}
|
|
679
|
+
/** Returns true if a filesystem path exists. */
|
|
680
|
+
async function _path_exists(p) {
|
|
681
|
+
try {
|
|
682
|
+
await fs.promises.access(p);
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** Recursively copies a directory tree from src to dst. */
|
|
690
|
+
async function _copy_dir(src, dst) {
|
|
691
|
+
await fs.promises.mkdir(dst, { recursive: true });
|
|
692
|
+
for (const entry of await fs.promises.readdir(src, { withFileTypes: true })) {
|
|
693
|
+
const s = path.join(src, entry.name);
|
|
694
|
+
const d = path.join(dst, entry.name);
|
|
695
|
+
if (entry.isDirectory())
|
|
696
|
+
await _copy_dir(s, d);
|
|
697
|
+
else
|
|
698
|
+
await fs.promises.copyFile(s, d);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/** Generates the standalone player HTML page for an exported game. */
|
|
702
|
+
function _player_html(proj) {
|
|
703
|
+
const w = proj.settings.windowWidth ?? 640;
|
|
704
|
+
const h = proj.settings.windowHeight ?? 480;
|
|
705
|
+
const bg = proj.settings.displayColor ?? '#000000';
|
|
706
|
+
const title = (proj.name || 'Silkweaver Game').replace(/</g, '<');
|
|
707
|
+
return `<!DOCTYPE html>
|
|
708
|
+
<html lang="en">
|
|
709
|
+
<head>
|
|
710
|
+
<meta charset="UTF-8">
|
|
711
|
+
<title>${title}</title>
|
|
712
|
+
<style>
|
|
713
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
714
|
+
html, body { width: 100%; height: 100%; background: ${bg}; overflow: hidden; }
|
|
715
|
+
canvas { display: block; position: absolute; top: 50%; left: 50%;
|
|
716
|
+
transform: translate(-50%, -50%); image-rendering: pixelated; outline: none; }
|
|
717
|
+
</style>
|
|
718
|
+
</head>
|
|
719
|
+
<body>
|
|
720
|
+
<canvas id="sw-canvas" width="${w}" height="${h}" tabindex="0"></canvas>
|
|
721
|
+
<script type="module">
|
|
722
|
+
const canvas = document.getElementById('sw-canvas')
|
|
723
|
+
canvas.focus()
|
|
724
|
+
canvas.addEventListener('click', () => canvas.focus())
|
|
725
|
+
try {
|
|
726
|
+
const { default: game_init } = await import('./game.js')
|
|
727
|
+
if (typeof game_init === 'function') await game_init(canvas)
|
|
728
|
+
} catch (e) {
|
|
729
|
+
console.error('Failed to start game:', e)
|
|
730
|
+
}
|
|
731
|
+
</script>
|
|
732
|
+
</body>
|
|
733
|
+
</html>
|
|
734
|
+
`;
|
|
735
|
+
}
|
|
736
|
+
// (Event→method mapping is no longer needed — objects are class files with on_* methods.)
|