@datamoc/mw_games 0.15.0 → 0.17.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/README.md +21 -1
  3. package/dist/ai/lua.d.ts.map +1 -1
  4. package/dist/ai/lua.js +1 -0
  5. package/dist/ai/lua.js.map +1 -1
  6. package/dist/core/ActionJournal.d.ts +5 -0
  7. package/dist/core/ActionJournal.d.ts.map +1 -1
  8. package/dist/core/ActionJournal.js +12 -1
  9. package/dist/core/ActionJournal.js.map +1 -1
  10. package/dist/core/Clone.d.ts +31 -0
  11. package/dist/core/Clone.d.ts.map +1 -0
  12. package/dist/core/Clone.js +84 -0
  13. package/dist/core/Clone.js.map +1 -0
  14. package/dist/core/Collection.d.ts +1 -0
  15. package/dist/core/Collection.d.ts.map +1 -1
  16. package/dist/core/Collection.js +6 -2
  17. package/dist/core/Collection.js.map +1 -1
  18. package/dist/core/HttpTransport.d.ts +17 -0
  19. package/dist/core/HttpTransport.d.ts.map +1 -1
  20. package/dist/core/HttpTransport.js +61 -0
  21. package/dist/core/HttpTransport.js.map +1 -1
  22. package/dist/core/Multiplayer.d.ts +9 -0
  23. package/dist/core/Multiplayer.d.ts.map +1 -1
  24. package/dist/core/Multiplayer.js +48 -4
  25. package/dist/core/Multiplayer.js.map +1 -1
  26. package/dist/core/News.d.ts.map +1 -1
  27. package/dist/core/News.js +1 -2
  28. package/dist/core/News.js.map +1 -1
  29. package/dist/core/Sanitize.d.ts +19 -0
  30. package/dist/core/Sanitize.d.ts.map +1 -1
  31. package/dist/core/Sanitize.js +37 -0
  32. package/dist/core/Sanitize.js.map +1 -1
  33. package/dist/core/Save.d.ts +18 -9
  34. package/dist/core/Save.d.ts.map +1 -1
  35. package/dist/core/Save.js +37 -21
  36. package/dist/core/Save.js.map +1 -1
  37. package/dist/core/SaveSync.js +4 -4
  38. package/dist/core/SaveSync.js.map +1 -1
  39. package/dist/core/Settings.d.ts.map +1 -1
  40. package/dist/core/Settings.js +2 -1
  41. package/dist/core/Settings.js.map +1 -1
  42. package/dist/core/State.d.ts.map +1 -1
  43. package/dist/core/State.js +8 -7
  44. package/dist/core/State.js.map +1 -1
  45. package/dist/core/StoredValue.d.ts +1 -1
  46. package/dist/core/StoredValue.d.ts.map +1 -1
  47. package/dist/core/StoredValue.js +3 -2
  48. package/dist/core/StoredValue.js.map +1 -1
  49. package/dist/core/Telemetry.d.ts +19 -1
  50. package/dist/core/Telemetry.d.ts.map +1 -1
  51. package/dist/core/Telemetry.js +26 -1
  52. package/dist/core/Telemetry.js.map +1 -1
  53. package/dist/core/index.d.ts +2 -1
  54. package/dist/core/index.d.ts.map +1 -1
  55. package/dist/core/index.js +2 -1
  56. package/dist/core/index.js.map +1 -1
  57. package/dist/mw_games.global.js +107 -107
  58. package/dist/mwl/MapFile.js +11 -1
  59. package/dist/mwl/MapFile.js.map +1 -1
  60. package/dist/mwl/expression.js +38 -5
  61. package/dist/mwl/expression.js.map +1 -1
  62. package/dist/mwl/fengari.d.ts +8 -0
  63. package/dist/mwl/fengari.d.ts.map +1 -1
  64. package/dist/mwl/fengari.js +72 -2
  65. package/dist/mwl/fengari.js.map +1 -1
  66. package/dist/mwl/persistence.d.ts.map +1 -1
  67. package/dist/mwl/persistence.js +2 -1
  68. package/dist/mwl/persistence.js.map +1 -1
  69. package/dist/mwl/runtime.d.ts.map +1 -1
  70. package/dist/mwl/runtime.js +6 -1
  71. package/dist/mwl/runtime.js.map +1 -1
  72. package/dist/rpg/Marshal.d.ts.map +1 -1
  73. package/dist/rpg/Marshal.js +13 -0
  74. package/dist/rpg/Marshal.js.map +1 -1
  75. package/dist/simulation/Campaign.d.ts.map +1 -1
  76. package/dist/simulation/Campaign.js +9 -5
  77. package/dist/simulation/Campaign.js.map +1 -1
  78. package/dist/simulation/Harness.d.ts.map +1 -1
  79. package/dist/simulation/Harness.js +2 -1
  80. package/dist/simulation/Harness.js.map +1 -1
  81. package/dist/simulation/Runtime.d.ts +10 -0
  82. package/dist/simulation/Runtime.d.ts.map +1 -1
  83. package/dist/simulation/Runtime.js +25 -4
  84. package/dist/simulation/Runtime.js.map +1 -1
  85. package/dist/testing/index.d.ts +105 -0
  86. package/dist/testing/index.d.ts.map +1 -0
  87. package/dist/testing/index.js +134 -0
  88. package/dist/testing/index.js.map +1 -0
  89. package/dist/two-d/render/RpgmAutotile.d.ts +217 -0
  90. package/dist/two-d/render/RpgmAutotile.d.ts.map +1 -1
  91. package/dist/two-d/render/RpgmAutotile.js +756 -0
  92. package/dist/two-d/render/RpgmAutotile.js.map +1 -1
  93. package/dist/two-d/render/TileMap.d.ts +90 -0
  94. package/dist/two-d/render/TileMap.d.ts.map +1 -1
  95. package/dist/two-d/render/TileMap.js +319 -31
  96. package/dist/two-d/render/TileMap.js.map +1 -1
  97. package/dist/two-d/render/index.d.ts +3 -3
  98. package/dist/two-d/render/index.d.ts.map +1 -1
  99. package/dist/two-d/render/index.js +1 -1
  100. package/dist/two-d/render/index.js.map +1 -1
  101. package/dist/version.d.ts +1 -1
  102. package/dist/version.js +1 -1
  103. package/package.json +90 -25
  104. package/sbom.cdx.json +5589 -0
  105. package/tools/benchmark-browser.d.mts +33 -0
  106. package/tools/benchmark-browser.mjs +163 -0
  107. package/tools/benchmark-history.d.mts +8 -0
  108. package/tools/benchmark-history.mjs +31 -0
  109. package/tools/browser-smoke.d.mts +30 -0
  110. package/tools/browser-smoke.mjs +167 -0
  111. package/tools/bundle-size.d.mts +11 -0
  112. package/tools/bundle-size.mjs +138 -0
  113. package/tools/csp.d.mts +7 -0
  114. package/tools/csp.mjs +56 -0
  115. package/tools/emit-page.d.mts +44 -0
  116. package/tools/emit-page.mjs +188 -0
  117. package/tools/extract-rgssad.d.mts +5 -0
  118. package/tools/extract-rgssad.mjs +64 -0
  119. package/tools/find-chrome.d.mts +7 -0
  120. package/tools/find-chrome.mjs +104 -0
  121. package/tools/i18n-edit.d.mts +29 -0
  122. package/tools/i18n-edit.mjs +748 -0
  123. package/tools/make-example-assets.d.mts +2 -0
  124. package/tools/make-example-assets.mjs +727 -0
  125. package/tools/multiplayer-server.d.mts +27 -0
  126. package/tools/multiplayer-server.mjs +195 -0
  127. package/tools/mwl.mjs +1 -0
  128. package/tools/sbom.d.mts +83 -0
  129. package/tools/sbom.mjs +330 -0
  130. package/tools/vendored/rgssad-wasm/LICENSE-MIT +21 -0
  131. package/tools/vendored/rgssad-wasm/README.md +15 -0
  132. package/tools/vendored/rgssad-wasm/package.json +17 -0
  133. package/tools/vendored/rgssad-wasm/rgssad_wasm.d.ts +41 -0
  134. package/tools/vendored/rgssad-wasm/rgssad_wasm.js +6 -0
  135. package/tools/vendored/rgssad-wasm/rgssad_wasm_bg.js +399 -0
  136. package/tools/vendored/rgssad-wasm/rgssad_wasm_bg.wasm +0 -0
  137. package/tools/vendored/rgssad-wasm/rgssad_wasm_bg.wasm.d.ts +17 -0
  138. package/tools/vite.d.mts +17 -0
  139. package/tools/vite.mjs +83 -0
@@ -0,0 +1,727 @@
1
+ #!/usr/bin/env node
2
+ import { deflateSync } from 'node:zlib';
3
+ import { existsSync, realpathSync } from 'node:fs';
4
+ import { writeFile, mkdir } from 'node:fs/promises';
5
+ import { join, dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ /**
9
+ * Generates the art and sound the examples use.
10
+ *
11
+ * These are drawn and synthesised here rather than downloaded, for one reason: everything
12
+ * `mwg` ships has to be redistributable under its own licence. Borrowing a tileset means
13
+ * inheriting whatever that tileset's terms are, and game art is exactly where those terms
14
+ * bite. Generated assets have no such history.
15
+ *
16
+ * They are deliberately plain. If you want real pixel art in your own game, Kenney
17
+ * (kenney.nl) publishes large tilesets under CC0, which imposes nothing.
18
+ */
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ //this repository's examples read their assets from here; an installed package has no examples
22
+ const EXAMPLE_ASSETS = join(here, '..', 'examples', 'assets');
23
+
24
+ const TILE = 16;
25
+ const COLUMNS = 8;
26
+ const ROWS = 2;
27
+
28
+ // ---------------------------------------------------------------- PNG encoding
29
+
30
+ function crc32(buf) {
31
+ let crc = ~0;
32
+ for (const byte of buf) {
33
+ crc ^= byte;
34
+ for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
35
+ }
36
+ return ~crc >>> 0;
37
+ }
38
+
39
+ function chunk(type, data) {
40
+ const head = Buffer.alloc(8);
41
+ head.writeUInt32BE(data.length, 0);
42
+ head.write(type, 4, 'ascii');
43
+
44
+ const crcInput = Buffer.concat([Buffer.from(type, 'ascii'), data]);
45
+ const tail = Buffer.alloc(4);
46
+ tail.writeUInt32BE(crc32(crcInput), 0);
47
+
48
+ return Buffer.concat([head, data, tail]);
49
+ }
50
+
51
+ /** @param pixels RGBA bytes, row-major */
52
+ function encodePng(width, height, pixels) {
53
+ //each scanline is prefixed with its filter type; 0 means "no filter"
54
+ const raw = Buffer.alloc(height * (1 + width * 4));
55
+ for (let y = 0; y < height; y++) {
56
+ const to = y * (1 + width * 4);
57
+ raw[to] = 0;
58
+ pixels.copy(raw, to + 1, y * width * 4, (y + 1) * width * 4);
59
+ }
60
+
61
+ const ihdr = Buffer.alloc(13);
62
+ ihdr.writeUInt32BE(width, 0);
63
+ ihdr.writeUInt32BE(height, 4);
64
+ ihdr[8] = 8; //bit depth
65
+ ihdr[9] = 6; //colour type: RGBA
66
+ //10..12 stay zero: deflate, adaptive filtering, no interlace
67
+
68
+ return Buffer.concat([
69
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
70
+ chunk('IHDR', ihdr),
71
+ chunk('IDAT', deflateSync(raw, { level: 9 })),
72
+ chunk('IEND', Buffer.alloc(0)),
73
+ ]);
74
+ }
75
+
76
+ // ---------------------------------------------------------------- the tileset
77
+
78
+ class Canvas {
79
+ constructor(width, height) {
80
+ this.width = width;
81
+ this.height = height;
82
+ this.data = Buffer.alloc(width * height * 4);
83
+ }
84
+
85
+ set(x, y, [r, g, b, a = 255]) {
86
+ if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
87
+ const i = (y * this.width + x) * 4;
88
+ this.data[i] = r;
89
+ this.data[i + 1] = g;
90
+ this.data[i + 2] = b;
91
+ this.data[i + 3] = a;
92
+ }
93
+
94
+ rect(x, y, w, h, color) {
95
+ for (let dy = 0; dy < h; dy++) {
96
+ for (let dx = 0; dx < w; dx++) this.set(x + dx, y + dy, color);
97
+ }
98
+ }
99
+
100
+ border(x, y, w, h, color) {
101
+ for (let dx = 0; dx < w; dx++) {
102
+ this.set(x + dx, y, color);
103
+ this.set(x + dx, y + h - 1, color);
104
+ }
105
+ for (let dy = 0; dy < h; dy++) {
106
+ this.set(x, y + dy, color);
107
+ this.set(x + w - 1, y + dy, color);
108
+ }
109
+ }
110
+
111
+ /** a filled circle, which is all the "creature" shapes need */
112
+ disc(cx, cy, radius, color) {
113
+ for (let dy = -radius; dy <= radius; dy++) {
114
+ for (let dx = -radius; dx <= radius; dx++) {
115
+ if (dx * dx + dy * dy <= radius * radius) this.set(cx + dx, cy + dy, color);
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * A deterministic hash, so the speckling on the floor tiles is the same every build and
123
+ * the generated file does not churn in git.
124
+ */
125
+ function noise(x, y, salt) {
126
+ let h = Math.imul(x * 374761393 + y * 668265263 + salt * 1442695041, 2246822519);
127
+ h = (h ^ (h >>> 13)) >>> 0;
128
+ return (h % 1000) / 1000;
129
+ }
130
+
131
+ const PALETTE = {
132
+ stone: [96, 96, 104],
133
+ stoneDark: [72, 72, 80],
134
+ stoneLight: [124, 124, 132],
135
+ wall: [58, 54, 62],
136
+ wallTop: [86, 80, 92],
137
+ water: [56, 88, 148],
138
+ waterLight: [78, 118, 184],
139
+ grass: [78, 116, 66],
140
+ door: [132, 96, 52],
141
+ gold: [214, 178, 66],
142
+ hero: [232, 224, 208],
143
+ heroDark: [176, 168, 152],
144
+ rat: [148, 112, 88],
145
+ blob: [116, 168, 92],
146
+ spike: [156, 156, 164],
147
+ rust: [168, 64, 48],
148
+ };
149
+
150
+ /** the tile index each name sits at, exported so the examples do not use magic numbers */
151
+ const TILES = {
152
+ FLOOR: 0,
153
+ FLOOR_WORN: 1,
154
+ WALL: 2,
155
+ WALL_TOP: 3,
156
+ WATER: 4,
157
+ GRASS: 5,
158
+ DOOR: 6,
159
+ COIN: 7,
160
+ HERO: 8,
161
+ RAT: 9,
162
+ BLOB: 10,
163
+ EMPTY: 11,
164
+ TRAP: 12,
165
+ };
166
+
167
+ function drawTile(canvas, index, draw) {
168
+ const x = (index % COLUMNS) * TILE;
169
+ const y = Math.floor(index / COLUMNS) * TILE;
170
+ draw(x, y);
171
+ }
172
+
173
+ function buildTileset() {
174
+ const canvas = new Canvas(COLUMNS * TILE, ROWS * TILE);
175
+
176
+ //floor: flat stone with a little deterministic speckle so tiling is visible
177
+ drawTile(canvas, TILES.FLOOR, (ox, oy) => {
178
+ canvas.rect(ox, oy, TILE, TILE, PALETTE.stone);
179
+ for (let y = 0; y < TILE; y++) {
180
+ for (let x = 0; x < TILE; x++) {
181
+ const n = noise(x, y, 1);
182
+ if (n > 0.88) canvas.set(ox + x, oy + y, PALETTE.stoneLight);
183
+ else if (n < 0.12) canvas.set(ox + x, oy + y, PALETTE.stoneDark);
184
+ }
185
+ }
186
+ });
187
+
188
+ //a second floor variant, so a map does not look like graph paper
189
+ drawTile(canvas, TILES.FLOOR_WORN, (ox, oy) => {
190
+ canvas.rect(ox, oy, TILE, TILE, PALETTE.stone);
191
+ for (let y = 0; y < TILE; y++) {
192
+ for (let x = 0; x < TILE; x++) {
193
+ const n = noise(x, y, 7);
194
+ if (n > 0.72) canvas.set(ox + x, oy + y, PALETTE.stoneDark);
195
+ }
196
+ }
197
+ canvas.rect(ox + 3, oy + 9, 5, 1, PALETTE.stoneDark);
198
+ canvas.rect(ox + 10, oy + 4, 3, 1, PALETTE.stoneDark);
199
+ });
200
+
201
+ //wall: brickwork, offset every other course
202
+ drawTile(canvas, TILES.WALL, (ox, oy) => {
203
+ canvas.rect(ox, oy, TILE, TILE, PALETTE.wall);
204
+ for (let row = 0; row < 4; row++) {
205
+ const y = oy + row * 4;
206
+ canvas.rect(ox, y, TILE, 1, PALETTE.wallTop);
207
+ const offset = row % 2 === 0 ? 0 : 8;
208
+ for (let brick = 0; brick < 2; brick++) {
209
+ const x = ox + ((offset + brick * 8) % TILE);
210
+ canvas.rect(x, y, 1, 4, PALETTE.wallTop);
211
+ }
212
+ }
213
+ });
214
+
215
+ drawTile(canvas, TILES.WALL_TOP, (ox, oy) => {
216
+ canvas.rect(ox, oy, TILE, TILE, PALETTE.wallTop);
217
+ canvas.border(ox, oy, TILE, TILE, PALETTE.wall);
218
+ });
219
+
220
+ drawTile(canvas, TILES.WATER, (ox, oy) => {
221
+ canvas.rect(ox, oy, TILE, TILE, PALETTE.water);
222
+ for (let y = 0; y < TILE; y++) {
223
+ for (let x = 0; x < TILE; x++) {
224
+ if (noise(x, y, 3) > 0.8) canvas.set(ox + x, oy + y, PALETTE.waterLight);
225
+ }
226
+ }
227
+ });
228
+
229
+ drawTile(canvas, TILES.GRASS, (ox, oy) => {
230
+ canvas.rect(ox, oy, TILE, TILE, PALETTE.stone);
231
+ for (let x = 0; x < TILE; x++) {
232
+ const height = 3 + Math.floor(noise(x, 0, 5) * 5);
233
+ for (let y = 0; y < height; y++) {
234
+ canvas.set(ox + x, oy + TILE - 1 - y, PALETTE.grass);
235
+ }
236
+ }
237
+ });
238
+
239
+ drawTile(canvas, TILES.DOOR, (ox, oy) => {
240
+ canvas.rect(ox, oy, TILE, TILE, PALETTE.wall);
241
+ canvas.rect(ox + 3, oy + 2, 10, 14, PALETTE.door);
242
+ canvas.border(ox + 3, oy + 2, 10, 14, PALETTE.wallTop);
243
+ canvas.rect(ox + 10, oy + 9, 1, 2, PALETTE.gold);
244
+ });
245
+
246
+ drawTile(canvas, TILES.COIN, (ox, oy) => {
247
+ canvas.disc(ox + 8, oy + 9, 4, PALETTE.gold);
248
+ canvas.disc(ox + 7, oy + 8, 2, [240, 214, 120]);
249
+ });
250
+
251
+ //the "creatures" are simple on purpose: what matters is that they take a colour well
252
+ drawTile(canvas, TILES.HERO, (ox, oy) => {
253
+ canvas.disc(ox + 8, oy + 5, 3, PALETTE.hero);
254
+ canvas.rect(ox + 5, oy + 8, 6, 6, PALETTE.hero);
255
+ canvas.rect(ox + 5, oy + 12, 6, 2, PALETTE.heroDark);
256
+ canvas.set(ox + 7, oy + 5, [40, 40, 48]);
257
+ canvas.set(ox + 9, oy + 5, [40, 40, 48]);
258
+ });
259
+
260
+ drawTile(canvas, TILES.RAT, (ox, oy) => {
261
+ canvas.rect(ox + 3, oy + 8, 9, 5, PALETTE.rat);
262
+ canvas.disc(ox + 12, oy + 9, 2, PALETTE.rat);
263
+ canvas.rect(ox + 1, oy + 10, 3, 1, PALETTE.rat);
264
+ canvas.set(ox + 13, oy + 8, [30, 20, 20]);
265
+ });
266
+
267
+ drawTile(canvas, TILES.BLOB, (ox, oy) => {
268
+ canvas.disc(ox + 8, oy + 10, 5, PALETTE.blob);
269
+ canvas.disc(ox + 6, oy + 8, 2, [150, 200, 120]);
270
+ });
271
+
272
+ //a sprung trap: the same floor a hidden one disguises itself as, plus the spikes a
273
+ //revealed one shows once discovered
274
+ drawTile(canvas, TILES.TRAP, (ox, oy) => {
275
+ canvas.rect(ox, oy, TILE, TILE, PALETTE.stone);
276
+ for (let y = 0; y < TILE; y++) {
277
+ for (let x = 0; x < TILE; x++) {
278
+ const n = noise(x, y, 1);
279
+ if (n > 0.88) canvas.set(ox + x, oy + y, PALETTE.stoneLight);
280
+ else if (n < 0.12) canvas.set(ox + x, oy + y, PALETTE.stoneDark);
281
+ }
282
+ }
283
+ for (const sx of [3, 7, 11]) {
284
+ for (let row = 0; row < 6; row++) {
285
+ const width = 6 - row;
286
+ canvas.rect(ox + sx - Math.floor(width / 2), oy + 13 - row, width, 1, PALETTE.spike);
287
+ }
288
+ canvas.set(ox + sx, oy + 7, PALETTE.rust);
289
+ }
290
+ });
291
+
292
+ //TILES.EMPTY is left fully transparent on purpose
293
+
294
+ return encodePng(canvas.width, canvas.height, canvas.data);
295
+ }
296
+
297
+ // ------------------------------------------------- backdrop and characters
298
+
299
+ /**
300
+ * A backdrop for the dialogue example.
301
+ *
302
+ * Crude on purpose: a wall, a floor, a window with light falling from it. Enough for a
303
+ * character to stand in front of and for a scene change to be visible.
304
+ */
305
+ function buildBackdrop(palette) {
306
+ const width = 320;
307
+ const height = 180;
308
+ const canvas = new Canvas(width, height);
309
+ const horizon = Math.round(height * 0.62);
310
+
311
+ //wall, shaded slightly darker towards the floor
312
+ for (let y = 0; y < horizon; y++) {
313
+ const shade = 1 - (y / horizon) * 0.25;
314
+ canvas.rect(
315
+ 0,
316
+ y,
317
+ width,
318
+ 1,
319
+ palette.wall.map((c) => Math.round(c * shade)),
320
+ );
321
+ }
322
+
323
+ //floor, in perspective-ish bands that get shorter towards the horizon
324
+ for (let y = horizon; y < height; y++) {
325
+ const depth = (y - horizon) / (height - horizon);
326
+ const shade = 0.55 + depth * 0.45;
327
+ canvas.rect(
328
+ 0,
329
+ y,
330
+ width,
331
+ 1,
332
+ palette.floor.map((c) => Math.round(c * shade)),
333
+ );
334
+ }
335
+ for (let x = 0; x < width; x += 24) {
336
+ for (let y = horizon; y < height; y++) {
337
+ if (noise(x, y, 21) > 0.7) canvas.set(x, y, palette.floorLine);
338
+ }
339
+ }
340
+
341
+ //a window, and the light it throws on the floor
342
+ const wx = 210;
343
+ const wy = 24;
344
+ canvas.rect(wx, wy, 64, 56, palette.sky);
345
+ canvas.border(wx - 2, wy - 2, 68, 60, palette.frame);
346
+ canvas.rect(wx + 30, wy, 2, 56, palette.frame);
347
+ canvas.rect(wx, wy + 26, 64, 2, palette.frame);
348
+
349
+ for (let y = horizon; y < height; y++) {
350
+ const spread = (y - horizon) * 0.7;
351
+ const left = Math.round(wx - spread * 0.4);
352
+ const width_ = Math.round(64 + spread);
353
+ for (let x = left; x < left + width_; x++) {
354
+ if (x < 0 || x >= 320) continue;
355
+ const i = (y * 320 + x) * 4;
356
+ //lighten what is already there rather than painting over it
357
+ canvas.data[i] = Math.min(255, canvas.data[i] + 26);
358
+ canvas.data[i + 1] = Math.min(255, canvas.data[i + 1] + 24);
359
+ canvas.data[i + 2] = Math.min(255, canvas.data[i + 2] + 14);
360
+ }
361
+ }
362
+
363
+ return encodePng(width, height, canvas.data);
364
+ }
365
+
366
+ /**
367
+ * Character parts, drawn one at a time.
368
+ *
369
+ * The figure is decomposed so that every surface a game would want to vary is its own
370
+ * colour: skin, eyes, hair, the upper garment and the lower one. Two characters are then
371
+ * two palettes over the same silhouette, which is how a real game gets a townful of people
372
+ * out of one drawing, and how a player-made character works at all.
373
+ *
374
+ * Frames are 64x96, standing on the bottom edge, and only the face differs between them.
375
+ */
376
+ const CHARACTER_FRAME = { width: 64, height: 96 };
377
+
378
+ /** the expression each frame carries, in the order they are drawn */
379
+ const EXPRESSIONS = ['neutral', 'happy', 'cross'];
380
+
381
+ function shade(color, factor) {
382
+ return color.map((c) => Math.max(0, Math.min(255, Math.round(c * factor))));
383
+ }
384
+
385
+ const PARTS = {
386
+ /** trousers or skirt, plus the shoes below them */
387
+ lower(canvas, ox, palette) {
388
+ canvas.rect(ox + 20, 68, 24, 20, palette.bottom);
389
+ //a gap between the legs, so the silhouette does not read as a single block
390
+ canvas.rect(ox + 31, 76, 2, 12, shade(palette.bottom, 0.6));
391
+ canvas.rect(ox + 19, 88, 11, 5, shade(palette.bottom, 0.45));
392
+ canvas.rect(ox + 34, 88, 11, 5, shade(palette.bottom, 0.45));
393
+ },
394
+
395
+ /** shirt and sleeves; the hands are drawn after, so they sit on top of the cuffs */
396
+ upper(canvas, ox, palette) {
397
+ canvas.rect(ox + 19, 43, 26, 26, palette.top);
398
+ canvas.rect(ox + 14, 45, 5, 20, shade(palette.top, 0.85));
399
+ canvas.rect(ox + 45, 45, 5, 20, shade(palette.top, 0.85));
400
+ //a collar, which is what stops the head looking stuck onto a rectangle
401
+ canvas.rect(ox + 27, 43, 10, 3, shade(palette.top, 0.7));
402
+ },
403
+
404
+ hands(canvas, ox, palette) {
405
+ canvas.rect(ox + 14, 65, 5, 5, palette.skin);
406
+ canvas.rect(ox + 45, 65, 5, 5, palette.skin);
407
+ },
408
+
409
+ head(canvas, ox, palette) {
410
+ //neck first, so the jaw overlaps it
411
+ canvas.rect(ox + 28, 40, 8, 5, shade(palette.skin, 0.85));
412
+ canvas.disc(ox + 32, 30, 13, palette.skin);
413
+ },
414
+
415
+ hair(canvas, ox, palette) {
416
+ canvas.disc(ox + 32, 22, 13, palette.hair);
417
+ canvas.rect(ox + 19, 22, 26, 4, palette.hair);
418
+ //sideburns, framing the face
419
+ canvas.rect(ox + 19, 26, 3, 10, palette.hair);
420
+ canvas.rect(ox + 42, 26, 3, 10, palette.hair);
421
+ },
422
+
423
+ /** the eyes carry their own colour, with a dark pupil so they read at this size */
424
+ face(canvas, ox, palette, expression) {
425
+ const dark = [28, 24, 30];
426
+ const mouth = shade(palette.skin, 0.55);
427
+
428
+ if (expression === 'happy') {
429
+ //closed, curved-up eyes: no iris is visible, so no eye colour here
430
+ canvas.rect(ox + 25, 30, 4, 1, dark);
431
+ canvas.set(ox + 24, 31, dark);
432
+ canvas.set(ox + 29, 31, dark);
433
+ canvas.rect(ox + 35, 30, 4, 1, dark);
434
+ canvas.set(ox + 34, 31, dark);
435
+ canvas.set(ox + 39, 31, dark);
436
+
437
+ canvas.rect(ox + 28, 36, 8, 1, mouth);
438
+ canvas.set(ox + 27, 35, mouth);
439
+ canvas.set(ox + 36, 35, mouth);
440
+ return;
441
+ }
442
+
443
+ //open eyes: white, iris, pupil
444
+ for (const x of [25, 34]) {
445
+ canvas.rect(ox + x, 28, 5, 4, [242, 240, 238]);
446
+ canvas.rect(ox + x + 1, 29, 3, 3, palette.eyes);
447
+ canvas.rect(ox + x + 2, 30, 1, 2, dark);
448
+ }
449
+
450
+ if (expression === 'cross') {
451
+ //brows angled inwards, and a flat mouth
452
+ canvas.rect(ox + 24, 25, 5, 1, palette.hair);
453
+ canvas.set(ox + 29, 26, palette.hair);
454
+ canvas.rect(ox + 35, 25, 5, 1, palette.hair);
455
+ canvas.set(ox + 34, 26, palette.hair);
456
+ canvas.rect(ox + 29, 37, 6, 1, mouth);
457
+ } else {
458
+ canvas.rect(ox + 24, 24, 5, 1, palette.hair);
459
+ canvas.rect(ox + 35, 24, 5, 1, palette.hair);
460
+ canvas.rect(ox + 29, 36, 6, 1, mouth);
461
+ }
462
+ },
463
+ };
464
+
465
+ /**
466
+ * Builds one character sheet from a five-colour palette.
467
+ *
468
+ * @param palette skin, eyes, hair, top, bottom, each an [r, g, b] triple
469
+ */
470
+ function buildCharacter(palette) {
471
+ const { width, height } = CHARACTER_FRAME;
472
+ const canvas = new Canvas(width * EXPRESSIONS.length, height);
473
+
474
+ EXPRESSIONS.forEach((expression, index) => {
475
+ const ox = index * width;
476
+
477
+ //back to front, so each part overlaps the one before it correctly
478
+ PARTS.lower(canvas, ox, palette);
479
+ PARTS.upper(canvas, ox, palette);
480
+ PARTS.hands(canvas, ox, palette);
481
+ PARTS.head(canvas, ox, palette);
482
+ PARTS.hair(canvas, ox, palette);
483
+ PARTS.face(canvas, ox, palette, expression);
484
+ });
485
+
486
+ return {
487
+ png: encodePng(canvas.width, canvas.height, canvas.data),
488
+ frameWidth: width,
489
+ frameHeight: height,
490
+ expressions: EXPRESSIONS.length,
491
+ };
492
+ }
493
+
494
+ // ------------------------------------------------------------------- SVG
495
+
496
+ /**
497
+ * A small vector icon, to verify SVG textures actually load through the compiled `data:`
498
+ * URI path (roadmap item 15) - not just resolve to one. `tools/compile-resources` already
499
+ * lists the MIME type; whether Pixi's SVG parser rasterises a `data:image/svg+xml` source
500
+ * correctly when it is reached through an *aliased* path with no `.svg` on the URL itself
501
+ * is the part that needed checking, not assumed from the MIME entry alone.
502
+ */
503
+ function buildGemIcon() {
504
+ return (
505
+ '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">' +
506
+ '<polygon points="16,2 30,12 25,30 7,30 2,12" fill="#d6b242" stroke="#8a6a1e" stroke-width="1.5"/>' +
507
+ '<polygon points="16,2 30,12 16,16" fill="#f0d97a"/>' +
508
+ '<polygon points="2,12 16,16 7,30" fill="#b8901e"/>' +
509
+ '</svg>\n'
510
+ );
511
+ }
512
+
513
+ // ---------------------------------------------------------------- sound
514
+
515
+ const SAMPLE_RATE = 22050;
516
+
517
+ function encodeWav(samples) {
518
+ const data = Buffer.alloc(samples.length * 2);
519
+ for (let i = 0; i < samples.length; i++) {
520
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
521
+ data.writeInt16LE(Math.round(clamped * 0x7fff), i * 2);
522
+ }
523
+
524
+ const header = Buffer.alloc(44);
525
+ header.write('RIFF', 0, 'ascii');
526
+ header.writeUInt32LE(36 + data.length, 4);
527
+ header.write('WAVE', 8, 'ascii');
528
+ header.write('fmt ', 12, 'ascii');
529
+ header.writeUInt32LE(16, 16); //chunk size
530
+ header.writeUInt16LE(1, 20); //PCM
531
+ header.writeUInt16LE(1, 22); //mono
532
+ header.writeUInt32LE(SAMPLE_RATE, 24);
533
+ header.writeUInt32LE(SAMPLE_RATE * 2, 28); //byte rate
534
+ header.writeUInt16LE(2, 32); //block align
535
+ header.writeUInt16LE(16, 34); //bits per sample
536
+ header.write('data', 36, 'ascii');
537
+ header.writeUInt32LE(data.length, 40);
538
+
539
+ return Buffer.concat([header, data]);
540
+ }
541
+
542
+ /**
543
+ * @param duration seconds
544
+ * @param voice receives the time in seconds and the progress 0..1, returns -1..1
545
+ */
546
+ function synth(duration, voice) {
547
+ const count = Math.floor(duration * SAMPLE_RATE);
548
+ const samples = new Float32Array(count);
549
+ for (let i = 0; i < count; i++) {
550
+ samples[i] = voice(i / SAMPLE_RATE, i / count);
551
+ }
552
+ return samples;
553
+ }
554
+
555
+ const decay = (progress, sharpness = 5) => Math.exp(-sharpness * progress);
556
+ const square = (phase) => (Math.sin(phase) >= 0 ? 1 : -1);
557
+
558
+ const SOUNDS = {
559
+ //a short blip: menus, picking things up
560
+ 'blip.wav': synth(0.09, (t, p) => 0.35 * decay(p, 9) * square(2 * Math.PI * 880 * t)),
561
+
562
+ //a duller thud, pitched down over its length: a step, a door
563
+ 'step.wav': synth(0.12, (t, p) => 0.3 * decay(p, 12) * Math.sin(2 * Math.PI * (220 - 90 * p) * t)),
564
+
565
+ //noise burst plus a low tone: a hit landing
566
+ 'hit.wav': synth(0.16, (t, p) => {
567
+ const noiseValue = (noise(Math.floor(t * SAMPLE_RATE), 0, 11) - 0.5) * 2;
568
+ return decay(p, 14) * (0.35 * noiseValue + 0.25 * Math.sin(2 * Math.PI * 140 * t));
569
+ }),
570
+
571
+ //a rising two-note figure: something good happened
572
+ 'pickup.wav': synth(0.22, (t, p) => {
573
+ const frequency = p < 0.5 ? 660 : 990;
574
+ return 0.28 * decay(p, 4) * square(2 * Math.PI * frequency * t);
575
+ }),
576
+ };
577
+
578
+ /**
579
+ * A short melody loop: `notesHz` played one after another over `duration`, each with its
580
+ * own soft attack/decay so consecutive notes do not click into each other. For
581
+ * `examples/audio`'s `Music.playTracks` demo - three of these, not one, is the whole point:
582
+ * a playlist needs more than one track to show a "several tracks" behaviour at all.
583
+ */
584
+ function melody(duration, notesHz, waveform) {
585
+ const noteDuration = duration / notesHz.length;
586
+ return synth(duration, (t) => {
587
+ const index = Math.min(notesHz.length - 1, Math.floor(t / noteDuration));
588
+ const localP = (t - index * noteDuration) / noteDuration;
589
+ return 0.25 * Math.sin(Math.PI * localP) * waveform(2 * Math.PI * notesHz[index] * t);
590
+ });
591
+ }
592
+
593
+ const MUSIC = {
594
+ //C E G C, sine - a gentle, rising figure
595
+ 'tune_dawn.wav': melody(4, [261.63, 329.63, 392.0, 523.25], Math.sin),
596
+ //G B D F, square - punchier, for an active scene
597
+ 'tune_march.wav': melody(4, [196.0, 246.94, 293.66, 349.23], square),
598
+ //A C F C(low), sine - a descending figure, for a quieter moment
599
+ 'tune_dusk.wav': melody(4, [220.0, 261.63, 174.61, 130.81], Math.sin),
600
+ };
601
+
602
+ // ---------------------------------------------------------------- write it out
603
+
604
+ /** every file `writePlaceholderAssets` writes, so it can refuse to overwrite one first */
605
+ const NAMES = [
606
+ 'tiles.png',
607
+ 'backdrop_room.png',
608
+ 'char_alice.png',
609
+ 'char_bob.png',
610
+ ...Object.keys(SOUNDS),
611
+ ...Object.keys(MUSIC),
612
+ 'icon_gem.svg',
613
+ 'tiles.json',
614
+ ];
615
+
616
+ /**
617
+ * Writes the placeholder set into `out`: a 16px tileset and its `tiles.json`, a backdrop, two
618
+ * characters, sound effects, two looping tunes and an SVG icon, all drawn and synthesised here,
619
+ * so they carry no licence but the project's own (item 389). Refuses to replace a file that
620
+ * already exists unless `force`, since `out` may be a game's real asset folder.
621
+ */
622
+ export async function writePlaceholderAssets(out, { force = false } = {}) {
623
+ if (!force) {
624
+ const existing = NAMES.filter((name) => existsSync(join(out, name)));
625
+ if (existing.length) throw new Error(`${out} already holds ${existing.join(', ')}; pass force to replace them`);
626
+ }
627
+ await mkdir(out, { recursive: true });
628
+ const png = buildTileset();
629
+ await writeFile(join(out, 'tiles.png'), png);
630
+
631
+ const backdrop = buildBackdrop({
632
+ wall: [72, 62, 78],
633
+ floor: [96, 78, 62],
634
+ floorLine: [78, 62, 48],
635
+ sky: [126, 168, 208],
636
+ frame: [52, 44, 56],
637
+ });
638
+ await writeFile(join(out, 'backdrop_room.png'), backdrop);
639
+
640
+ //two people, one silhouette: only the five colours differ
641
+ const characters = {
642
+ 'char_alice.png': buildCharacter({
643
+ skin: [236, 198, 168],
644
+ eyes: [64, 128, 92],
645
+ hair: [168, 84, 52],
646
+ top: [188, 92, 108],
647
+ bottom: [58, 62, 96],
648
+ }),
649
+ 'char_bob.png': buildCharacter({
650
+ skin: [166, 122, 92],
651
+ eyes: [78, 108, 168],
652
+ hair: [42, 38, 40],
653
+ top: [96, 148, 120],
654
+ bottom: [92, 78, 62],
655
+ }),
656
+ };
657
+ for (const [name, character] of Object.entries(characters)) {
658
+ await writeFile(join(out, name), character.png);
659
+ }
660
+
661
+ for (const [name, samples] of Object.entries(SOUNDS)) {
662
+ await writeFile(join(out, name), encodeWav(samples));
663
+ }
664
+
665
+ for (const [name, samples] of Object.entries(MUSIC)) {
666
+ await writeFile(join(out, name), encodeWav(samples));
667
+ }
668
+
669
+ const gemIcon = buildGemIcon();
670
+ await writeFile(join(out, 'icon_gem.svg'), gemIcon);
671
+
672
+ //the examples import this rather than hard-coding tile numbers and frame sizes
673
+ await writeFile(
674
+ join(out, 'tiles.json'),
675
+ JSON.stringify(
676
+ {
677
+ tileSize: TILE,
678
+ columns: COLUMNS,
679
+ rows: ROWS,
680
+ tiles: TILES,
681
+ character: {
682
+ frameWidth: CHARACTER_FRAME.width,
683
+ frameHeight: CHARACTER_FRAME.height,
684
+ expressions: Object.fromEntries(EXPRESSIONS.map((name, i) => [name, i])),
685
+ },
686
+ },
687
+ null,
688
+ '\t',
689
+ ) + '\n',
690
+ 'utf8',
691
+ );
692
+
693
+ const kb = (n) => (n / 1024).toFixed(1) + ' KB';
694
+ console.log(`tiles.png ${kb(png.length)} (${COLUMNS}x${ROWS} tiles of ${TILE}px)`);
695
+ console.log(`backdrop_room.png ${kb(backdrop.length)} (320x180)`);
696
+ for (const [name, character] of Object.entries(characters)) {
697
+ console.log(
698
+ `${name.padEnd(12)}${kb(character.png.length)} (${character.expressions} expressions of ${character.frameWidth}x${character.frameHeight})`,
699
+ );
700
+ }
701
+ for (const [name, samples] of Object.entries(SOUNDS)) {
702
+ console.log(`${name.padEnd(12)}${kb(samples.length * 2 + 44)}`);
703
+ }
704
+ for (const [name, samples] of Object.entries(MUSIC)) {
705
+ console.log(`${name.padEnd(14)}${kb(samples.length * 2 + 44)}`);
706
+ }
707
+ console.log(`icon_gem.svg${kb(gemIcon.length)}`);
708
+ console.log(`\nwritten to ${out} - generated, so redistributable under the project licence`);
709
+ }
710
+
711
+ /**
712
+ * `mwg-placeholder-assets <folder> [--force]`: the set above, for a game to prototype with before
713
+ * its own art exists. With no folder inside this repository, it regenerates `examples/assets`.
714
+ */
715
+ //realpath, since an npm `bin` shim reaches this file through a symlink
716
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
717
+ const args = process.argv.slice(2);
718
+ const folder = args.find((arg) => !arg.startsWith('--'));
719
+ const inRepository = existsSync(join(here, '..', 'examples'));
720
+ if (!folder && !inRepository) {
721
+ console.error('usage: mwg-placeholder-assets <folder> [--force]');
722
+ process.exit(1);
723
+ }
724
+ await writePlaceholderAssets(folder ? resolve(folder) : EXAMPLE_ASSETS, {
725
+ force: !folder || args.includes('--force'),
726
+ });
727
+ }