@v12-engine/create 0.1.0 → 0.4.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 (263) hide show
  1. package/README.md +4 -1
  2. package/base/_gitignore +5 -0
  3. package/base/package.json +3 -3
  4. package/base/src/main.ts +36 -10
  5. package/base/src/navigation.worker.ts +7 -0
  6. package/base/vite.config.ts +24 -2
  7. package/cli.mjs +21 -6
  8. package/index.mjs +27 -7
  9. package/layers/desktop/electron/app.mjs +237 -0
  10. package/layers/desktop/electron/config.mjs +16 -0
  11. package/layers/desktop/electron/dev.mjs +63 -0
  12. package/layers/desktop/electron/files.mjs +86 -0
  13. package/layers/desktop/electron/icons/icon.icns +0 -0
  14. package/layers/desktop/electron/icons/icon.png +0 -0
  15. package/layers/desktop/electron/main.mjs +12 -0
  16. package/layers/desktop/electron/package.mjs +75 -0
  17. package/layers/desktop/electron/preload.cjs +41 -0
  18. package/layers/desktop/electron/presentation.m +87 -0
  19. package/layers/desktop/electron/serve.mjs +76 -0
  20. package/layers/desktop/electron/tools.mjs +63 -0
  21. package/layers/desktop/electron/updates.mjs +71 -0
  22. package/layers/desktop/package.json +13 -0
  23. package/layers/desktop/src/desktop.ts +175 -0
  24. package/layers/desktop/src/shell.ts +279 -0
  25. package/layers/desktop/tests/main.test.ts +189 -0
  26. package/layers/desktop/tests/shell.test.ts +152 -0
  27. package/layers/rooms/server.ts +13 -10
  28. package/layers/server/server.ts +4 -5
  29. package/layers/source/vite.config.ts +21 -1
  30. package/package.json +2 -1
  31. package/starters/campaign/README.md +9 -2
  32. package/starters/campaign/layers/rooms/server.ts +3 -3
  33. package/starters/campaign/layers/server/server.ts +3 -5
  34. package/starters/campaign/layers/tests/tests/rules.test.ts +40 -21
  35. package/starters/campaign/src/campaign/game.ts +26 -5
  36. package/starters/campaign/src/campaign/index.ts +104 -68
  37. package/starters/campaign/src/campaign/kills.ts +1 -1
  38. package/starters/campaign/src/campaign/missions/homecoming.ts +7 -7
  39. package/starters/campaign/src/campaign/missions/siege.ts +6 -6
  40. package/starters/campaign/src/campaign/missions/warlord.ts +13 -14
  41. package/starters/campaign/src/campaign/scenes.ts +8 -7
  42. package/starters/campaign/src/campaign/units.ts +59 -20
  43. package/starters/campaign/src/campaign/view/attacks.ts +19 -0
  44. package/starters/campaign/src/campaign/view/fire.ts +2 -2
  45. package/starters/campaign/src/campaign/view/index.ts +54 -43
  46. package/starters/campaign/src/campaign/waves.ts +18 -17
  47. package/starters/campaign/src/game.ts +8 -1
  48. package/starters/campaign/src/main.ts +38 -19
  49. package/starters/campaign/src/maps/fort.names.ts +2 -2
  50. package/starters/campaign/src/maps/fort.vgmap.json +102 -2
  51. package/starters/campaign/src/maps/homestead.names.ts +2 -2
  52. package/starters/campaign/src/maps/homestead.vgmap.json +85 -1
  53. package/starters/campaign/src/maps/pass.names.ts +2 -2
  54. package/starters/campaign/src/maps/pass.vgmap.json +69 -1
  55. package/starters/campaign/src/waves-panel.ts +2 -1
  56. package/starters/empty/README.md +18 -10
  57. package/starters/empty/layers/tests/tests/rules.test.ts +16 -17
  58. package/starters/empty/src/game.ts +11 -75
  59. package/starters/empty/src/generate.worker.ts +5 -5
  60. package/starters/empty/src/rules.ts +83 -25
  61. package/starters/empty/src/view.ts +82 -0
  62. package/starters/examples/README.md +83 -0
  63. package/starters/examples/layers/tests/tests/examples.test.ts +30 -0
  64. package/starters/examples/package.json +5 -0
  65. package/starters/examples/src/examples/abilities/areas.ts +165 -0
  66. package/starters/examples/src/examples/abilities/areasLooks.ts +30 -0
  67. package/starters/examples/src/examples/abilities/buffs.ts +287 -0
  68. package/starters/examples/src/examples/abilities/buffsLooks.ts +46 -0
  69. package/starters/examples/src/examples/abilities/changing.ts +228 -0
  70. package/starters/examples/src/examples/abilities/changingLooks.ts +61 -0
  71. package/starters/examples/src/examples/abilities/control.ts +158 -0
  72. package/starters/examples/src/examples/abilities/controlLooks.ts +78 -0
  73. package/starters/examples/src/examples/abilities/damage.ts +156 -0
  74. package/starters/examples/src/examples/abilities/damageLooks.ts +37 -0
  75. package/starters/examples/src/examples/abilities/family.ts +29 -0
  76. package/starters/examples/src/examples/abilities/index.ts +32 -0
  77. package/starters/examples/src/examples/abilities/kit.ts +179 -0
  78. package/starters/examples/src/examples/abilities/lookKit.ts +71 -0
  79. package/starters/examples/src/examples/abilities/looks.ts +47 -0
  80. package/starters/examples/src/examples/abilities/missiles.ts +203 -0
  81. package/starters/examples/src/examples/abilities/missilesLooks.ts +38 -0
  82. package/starters/examples/src/examples/abilities/moves.ts +206 -0
  83. package/starters/examples/src/examples/abilities/movesLooks.ts +52 -0
  84. package/starters/examples/src/examples/abilities/recasts.ts +181 -0
  85. package/starters/examples/src/examples/abilities/recastsLooks.ts +41 -0
  86. package/starters/examples/src/examples/abilities/resources.ts +166 -0
  87. package/starters/examples/src/examples/abilities/resourcesLooks.ts +42 -0
  88. package/starters/examples/src/examples/abilities/summons.ts +296 -0
  89. package/starters/examples/src/examples/abilities/summonsLooks.ts +115 -0
  90. package/starters/examples/src/examples/abilities/triggers.ts +204 -0
  91. package/starters/examples/src/examples/abilities/triggersLooks.ts +53 -0
  92. package/starters/examples/src/examples/abilities/vision.ts +137 -0
  93. package/starters/examples/src/examples/abilities/visionLooks.ts +50 -0
  94. package/starters/examples/src/examples/animation/index.ts +235 -0
  95. package/starters/examples/src/examples/animation/looks.ts +444 -0
  96. package/starters/examples/src/examples/catalog.ts +52 -0
  97. package/starters/examples/src/examples/cinematics/index.ts +217 -0
  98. package/starters/examples/src/examples/cinematics/looks.ts +476 -0
  99. package/starters/examples/src/examples/cinematics/tour.json +43 -0
  100. package/starters/examples/src/examples/cinematics/village.ts +88 -0
  101. package/starters/examples/src/examples/common/looks.ts +65 -0
  102. package/starters/examples/src/examples/common/units.ts +70 -0
  103. package/starters/examples/src/examples/construction/index.ts +329 -0
  104. package/starters/examples/src/examples/construction/looks.ts +275 -0
  105. package/starters/examples/src/examples/custom-animation/bones.ts +92 -0
  106. package/starters/examples/src/examples/custom-animation/index.ts +190 -0
  107. package/starters/examples/src/examples/custom-animation/looks.ts +523 -0
  108. package/starters/examples/src/examples/custom-animation/runes.ts +82 -0
  109. package/starters/examples/src/examples/custom-fx/glaive.look.ts +110 -0
  110. package/starters/examples/src/examples/custom-fx/glaive.ts +40 -0
  111. package/starters/examples/src/examples/custom-fx/gpu.look.ts +145 -0
  112. package/starters/examples/src/examples/custom-fx/gpu.ts +22 -0
  113. package/starters/examples/src/examples/custom-fx/index.ts +25 -0
  114. package/starters/examples/src/examples/custom-fx/lights.look.ts +144 -0
  115. package/starters/examples/src/examples/custom-fx/lights.ts +25 -0
  116. package/starters/examples/src/examples/custom-fx/looks.ts +36 -0
  117. package/starters/examples/src/examples/custom-fx/meteor.look.ts +133 -0
  118. package/starters/examples/src/examples/custom-fx/meteor.ts +26 -0
  119. package/starters/examples/src/examples/custom-fx/parts.look.ts +193 -0
  120. package/starters/examples/src/examples/custom-fx/parts.ts +21 -0
  121. package/starters/examples/src/examples/custom-fx/portal.look.ts +193 -0
  122. package/starters/examples/src/examples/custom-fx/portal.ts +48 -0
  123. package/starters/examples/src/examples/custom-fx/renderer.look.ts +139 -0
  124. package/starters/examples/src/examples/custom-fx/renderer.ts +18 -0
  125. package/starters/examples/src/examples/custom-fx/ribbons.look.ts +151 -0
  126. package/starters/examples/src/examples/custom-fx/ribbons.ts +22 -0
  127. package/starters/examples/src/examples/custom-fx/shader.ts +42 -0
  128. package/starters/examples/src/examples/custom-fx/shield.look.ts +226 -0
  129. package/starters/examples/src/examples/custom-fx/shield.ts +43 -0
  130. package/starters/examples/src/examples/define.ts +86 -0
  131. package/starters/examples/src/examples/destruction/index.ts +570 -0
  132. package/starters/examples/src/examples/destruction/looks.ts +447 -0
  133. package/starters/examples/src/examples/dialogs/index.ts +383 -0
  134. package/starters/examples/src/examples/dialogs/looks.ts +546 -0
  135. package/starters/examples/src/examples/dynamic-map/index.ts +603 -0
  136. package/starters/examples/src/examples/dynamic-map/looks.ts +301 -0
  137. package/starters/examples/src/examples/fx/blood.look.ts +62 -0
  138. package/starters/examples/src/examples/fx/blood.ts +59 -0
  139. package/starters/examples/src/examples/fx/feel.look.ts +86 -0
  140. package/starters/examples/src/examples/fx/feel.ts +57 -0
  141. package/starters/examples/src/examples/fx/fight.ts +28 -0
  142. package/starters/examples/src/examples/fx/floats.look.ts +108 -0
  143. package/starters/examples/src/examples/fx/floats.ts +24 -0
  144. package/starters/examples/src/examples/fx/gallery.look.ts +168 -0
  145. package/starters/examples/src/examples/fx/gallery.ts +23 -0
  146. package/starters/examples/src/examples/fx/index.ts +30 -0
  147. package/starters/examples/src/examples/fx/links.look.ts +83 -0
  148. package/starters/examples/src/examples/fx/links.ts +49 -0
  149. package/starters/examples/src/examples/fx/looks.ts +55 -0
  150. package/starters/examples/src/examples/fx/map.look.ts +93 -0
  151. package/starters/examples/src/examples/fx/map.ts +33 -0
  152. package/starters/examples/src/examples/fx/marks.look.ts +100 -0
  153. package/starters/examples/src/examples/fx/marks.ts +36 -0
  154. package/starters/examples/src/examples/fx/shots.look.ts +92 -0
  155. package/starters/examples/src/examples/fx/shots.ts +71 -0
  156. package/starters/examples/src/examples/fx/spells.look.ts +87 -0
  157. package/starters/examples/src/examples/fx/spells.ts +122 -0
  158. package/starters/examples/src/examples/fx/stage.ts +31 -0
  159. package/starters/examples/src/examples/gui/babylonScreen.ts +167 -0
  160. package/starters/examples/src/examples/gui/babylonWorld.ts +191 -0
  161. package/starters/examples/src/examples/gui/htmlPart.ts +95 -0
  162. package/starters/examples/src/examples/gui/index.ts +143 -0
  163. package/starters/examples/src/examples/gui/keys.ts +91 -0
  164. package/starters/examples/src/examples/gui/layout.ts +42 -0
  165. package/starters/examples/src/examples/gui/looks.ts +270 -0
  166. package/starters/examples/src/examples/gui/minimap.ts +127 -0
  167. package/starters/examples/src/examples/gui/ownInterface.ts +129 -0
  168. package/starters/examples/src/examples/gui/plates.ts +87 -0
  169. package/starters/examples/src/examples/gui/radial.ts +104 -0
  170. package/starters/examples/src/examples/gui/solidPart.ts +107 -0
  171. package/starters/examples/src/examples/looks.ts +45 -0
  172. package/starters/examples/src/examples/mechanics/battle.ts +51 -0
  173. package/starters/examples/src/examples/mechanics/damage.ts +80 -0
  174. package/starters/examples/src/examples/mechanics/flying.ts +93 -0
  175. package/starters/examples/src/examples/mechanics/formations.ts +58 -0
  176. package/starters/examples/src/examples/mechanics/highGround.ts +82 -0
  177. package/starters/examples/src/examples/mechanics/index.ts +44 -0
  178. package/starters/examples/src/examples/mechanics/looks.ts +289 -0
  179. package/starters/examples/src/examples/mechanics/maze.ts +119 -0
  180. package/starters/examples/src/examples/mechanics/orders.ts +68 -0
  181. package/starters/examples/src/examples/mechanics/selection.ts +30 -0
  182. package/starters/examples/src/examples/moba/index.ts +377 -0
  183. package/starters/examples/src/examples/moba/looks.ts +349 -0
  184. package/starters/examples/src/examples/moba/maps.ts +69 -0
  185. package/starters/examples/src/examples/moba/spells.ts +34 -0
  186. package/starters/examples/src/examples/moba/units.ts +196 -0
  187. package/starters/examples/src/examples/nature/camera.ts +15 -0
  188. package/starters/examples/src/examples/nature/grass.ts +64 -0
  189. package/starters/examples/src/examples/nature/index.ts +327 -0
  190. package/starters/examples/src/examples/nature/land.ts +173 -0
  191. package/starters/examples/src/examples/nature/looks.ts +577 -0
  192. package/starters/examples/src/examples/nature/maps.ts +370 -0
  193. package/starters/examples/src/examples/nature/skies.ts +324 -0
  194. package/starters/examples/src/examples/nature/weather.ts +80 -0
  195. package/starters/examples/src/examples/performance/index.ts +263 -0
  196. package/starters/examples/src/examples/performance/looks.ts +372 -0
  197. package/starters/examples/src/examples/performance/meter.ts +158 -0
  198. package/starters/examples/src/examples/post/grades.ts +86 -0
  199. package/starters/examples/src/examples/post/index.ts +202 -0
  200. package/starters/examples/src/examples/post/looks.ts +884 -0
  201. package/starters/examples/src/examples/post/passes.ts +213 -0
  202. package/starters/examples/src/examples/rts/ai.ts +49 -0
  203. package/starters/examples/src/examples/rts/airlift.ts +43 -0
  204. package/starters/examples/src/examples/rts/base.ts +129 -0
  205. package/starters/examples/src/examples/rts/build.ts +65 -0
  206. package/starters/examples/src/examples/rts/camps.ts +36 -0
  207. package/starters/examples/src/examples/rts/carry.ts +103 -0
  208. package/starters/examples/src/examples/rts/creeps.ts +61 -0
  209. package/starters/examples/src/examples/rts/data.ts +406 -0
  210. package/starters/examples/src/examples/rts/ferry.ts +41 -0
  211. package/starters/examples/src/examples/rts/garrison.ts +50 -0
  212. package/starters/examples/src/examples/rts/harvest.ts +29 -0
  213. package/starters/examples/src/examples/rts/hero.ts +67 -0
  214. package/starters/examples/src/examples/rts/index.ts +77 -0
  215. package/starters/examples/src/examples/rts/looks.ts +721 -0
  216. package/starters/examples/src/examples/rts/shop.ts +46 -0
  217. package/starters/examples/src/examples/rts/tech.ts +39 -0
  218. package/starters/examples/src/examples/rts/train.ts +48 -0
  219. package/starters/examples/src/examples/rts/waterside.ts +185 -0
  220. package/starters/examples/src/examples/showcase/index.ts +714 -0
  221. package/starters/examples/src/examples/showcase/looks.ts +238 -0
  222. package/starters/examples/src/game.ts +17 -0
  223. package/starters/examples/src/main.ts +82 -0
  224. package/starters/examples/src/rules.ts +31 -0
  225. package/starters/examples/src/shell/look.ts +79 -0
  226. package/starters/examples/src/shell/registry.ts +7 -0
  227. package/starters/examples/src/shell/scenes.ts +178 -0
  228. package/starters/examples/src/shell/shell.ts +290 -0
  229. package/starters/examples/src/shell/style.ts +202 -0
  230. package/starters/examples/src/view.ts +50 -0
  231. package/starters/melee/README.md +12 -3
  232. package/starters/melee/layers/tests/tests/heroes.test.ts +402 -0
  233. package/starters/melee/layers/tests/tests/rules.test.ts +10 -8
  234. package/starters/melee/src/game.ts +32 -2
  235. package/starters/melee/src/maps/bay.names.ts +2 -2
  236. package/starters/melee/src/maps/bay.vgmap.json +198 -1
  237. package/starters/melee/src/maps.ts +11 -9
  238. package/starters/melee/src/melee/abilities.ts +1 -194
  239. package/starters/melee/src/melee/blight.ts +7 -7
  240. package/starters/melee/src/melee/cellLayers.json +3 -3
  241. package/starters/melee/src/melee/data.json +1 -18
  242. package/starters/melee/src/melee/game.ts +6 -4
  243. package/starters/melee/src/melee/heroes.ts +1025 -0
  244. package/starters/melee/src/melee/index.ts +64 -61
  245. package/starters/melee/src/melee/items.ts +15 -362
  246. package/starters/melee/src/melee/languages/he.json +14 -2
  247. package/starters/melee/src/melee/pings.ts +5 -6
  248. package/starters/melee/src/melee/saves.ts +1 -1
  249. package/starters/melee/src/melee/undead.ts +15 -230
  250. package/starters/melee/src/melee/units.ts +37 -171
  251. package/starters/melee/src/melee/view/attacks.ts +19 -0
  252. package/starters/melee/src/melee/view/heroSpellLooks.ts +39 -8
  253. package/starters/melee/src/melee/view/index.ts +56 -52
  254. package/starters/melee/src/melee/view/looks.ts +86 -63
  255. package/starters/melee/src/melee/view/undeadLooks.ts +70 -31
  256. package/starters/melee/src/melee/view/walls.ts +1 -1
  257. package/starters.mjs +6 -0
  258. package/v12.mjs +14 -15
  259. package/starters/campaign/src/rules.ts +0 -20
  260. package/starters/empty/src/maps.ts +0 -44
  261. package/starters/empty/src/tiles.ts +0 -12
  262. package/starters/melee/src/melee/heroSpells.ts +0 -38
  263. package/starters/melee/src/rules.ts +0 -24
@@ -0,0 +1,63 @@
1
+ // `npm run app`: the game in its shell while it is developed: the dev server started (Vite, on port 3100) and the
2
+ // shell on it, so a change to the page shows as it does in a browser. `npm run app:editor` (`--attach`) starts no
3
+ // server: it opens the shell on the one already running, which is the map editor's Run panel's way, beside the dev
4
+ // server the editor is open on. Closing the app stops what this started.
5
+ // `--port <n>` puts the server on another port, or attaches to one there; `--page <address>` attaches to a page
6
+ // served elsewhere (`--attach` is then implied): `node electron/dev.mjs --page http://localhost:3000/`
7
+ import { spawn } from 'node:child_process'
8
+ import { buildNative, electronBinary, project } from './tools.mjs'
9
+
10
+ const PORT = 3100
11
+
12
+ /** The value given after a flag, as `--port 3200` or `--port=3200` */
13
+ const option = (name) => {
14
+ const at = process.argv.findIndex((argument) => argument === name || argument.startsWith(`${name}=`))
15
+ if (at < 0) return undefined
16
+ const argument = process.argv[at]
17
+ return argument.includes('=') ? argument.slice(argument.indexOf('=') + 1) : process.argv[at + 1]
18
+ }
19
+
20
+ const askedPort = option('--port')
21
+ const port = askedPort === undefined ? PORT : Number(askedPort)
22
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
23
+ console.error(`--port takes a port, not "${askedPort}"`)
24
+ process.exit(1)
25
+ }
26
+ const page = option('--page')
27
+ const attach = process.argv.includes('--attach') || page !== undefined
28
+ let address = `http://localhost:${port}/`
29
+ if (page !== undefined) {
30
+ try {
31
+ address = new URL(page).href
32
+ } catch {
33
+ console.error(`--page takes an address, as http://localhost:3000/, not "${page}"`)
34
+ process.exit(1)
35
+ }
36
+ }
37
+
38
+ buildNative()
39
+ const binary = electronBinary()
40
+ let server
41
+ if (attach) {
42
+ const answered = await fetch(address).then(
43
+ (response) => response.ok,
44
+ () => false
45
+ )
46
+ if (!answered) {
47
+ console.error(`No dev server at ${address}: start it first (npm run dev), or run npm run app, which starts one`)
48
+ process.exit(1)
49
+ }
50
+ } else {
51
+ const { createServer } = await import('vite')
52
+ server = await createServer({ root: project, server: { port, strictPort: true } })
53
+ await server.listen()
54
+ server.printUrls()
55
+ }
56
+
57
+ const shell = spawn(binary, [project], { stdio: 'inherit', env: { ...process.env, V12_PAGE: address } })
58
+ const stop = async (code) => {
59
+ await server?.close()
60
+ process.exit(code ?? 0)
61
+ }
62
+ shell.on('exit', (code) => void stop(code))
63
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => shell.kill(signal))
@@ -0,0 +1,86 @@
1
+ // What the page may ask of the disk, on the main process's side: records in the folders named here, and the two log
2
+ // files, nothing else. The page's words are not trusted as a path. No Electron here, so that the tests run it in Node.
3
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile, appendFile } from 'node:fs/promises'
4
+ import { createHash } from 'node:crypto'
5
+ import { join } from 'node:path'
6
+
7
+ /** The folders of records a page may name */
8
+ export const RECORD_FOLDERS = ['saves', 'settings']
9
+ /** The files of the log a page may name */
10
+ export const LOG_FILES = ['errors.log', 'errors.old.log']
11
+
12
+ function folderIn(base, folder) {
13
+ if (!RECORD_FOLDERS.includes(folder)) throw new Error(`"${folder}" is not a folder of the app's`)
14
+ return join(base, folder)
15
+ }
16
+
17
+ /**
18
+ * The name of a record's file, from its id: the id itself where it is a plain name (letters, digits, `_`, `-` and dots,
19
+ * not first), and otherwise the id written in letters and digits (base64url) after a `~`, which no plain name has: a
20
+ * checkpoint's id has colons and braces in it. Nothing that moves out of the folder either way. A very long id is
21
+ * named by its hash
22
+ */
23
+ export function fileNameOf(id) {
24
+ if (typeof id !== 'string' || !id) throw new Error(`"${id}" is not the id of a record`)
25
+ if (/^[\w-][\w.-]{0,119}$/.test(id)) return id
26
+ const written = Buffer.from(id).toString('base64url')
27
+ return written.length <= 160 ? `~${written}` : `~h${createHash('sha256').update(id).digest('base64url')}`
28
+ }
29
+
30
+ function recordIn(base, folder, id) {
31
+ return join(folderIn(base, folder), `${fileNameOf(id)}.json`)
32
+ }
33
+
34
+ function logIn(base, file) {
35
+ if (!LOG_FILES.includes(file)) throw new Error(`"${file}" is not a file of the log`)
36
+ return join(base, file)
37
+ }
38
+
39
+ /** The records under the app's data folder, as text, each whole on the disk or not at all */
40
+ export function recordFiles(base) {
41
+ return {
42
+ async list(folder) {
43
+ const path = folderIn(base, folder)
44
+ let names
45
+ try {
46
+ names = await readdir(path)
47
+ } catch {
48
+ return []
49
+ }
50
+ const texts = await Promise.all(
51
+ names.filter((name) => name.endsWith('.json')).map((name) => readFile(join(path, name), 'utf8').catch(() => undefined))
52
+ )
53
+ return texts.filter((text) => text !== undefined)
54
+ },
55
+ read: (folder, id) => readFile(recordIn(base, folder, id), 'utf8').catch(() => undefined),
56
+ async write(folder, id, text) {
57
+ const file = recordIn(base, folder, id)
58
+ await mkdir(folderIn(base, folder), { recursive: true })
59
+ const partial = `${file}.tmp`
60
+ try {
61
+ await writeFile(partial, text)
62
+ await rename(partial, file)
63
+ } catch (error) {
64
+ await rm(partial, { force: true })
65
+ throw error
66
+ }
67
+ },
68
+ remove: (folder, id) => rm(recordIn(base, folder, id), { force: true })
69
+ }
70
+ }
71
+
72
+ /** The two files of the error log, in the system's folder of the app's logs */
73
+ export function logFiles(base) {
74
+ return {
75
+ async append(file, text) {
76
+ await mkdir(base, { recursive: true })
77
+ await appendFile(logIn(base, file), text)
78
+ },
79
+ size: (file) =>
80
+ stat(logIn(base, file)).then(
81
+ (found) => found.size,
82
+ () => 0
83
+ ),
84
+ rename: async (from, to) => rename(logIn(base, from), logIn(base, to))
85
+ }
86
+ }
@@ -0,0 +1,12 @@
1
+ // The app's main process (`main` in `package.json`): the shell, on the built page, or on the dev server where
2
+ // `npm run app` says one (`dev.mjs`). `V12_USER_DATA` puts the app's folder elsewhere, for a check that is not to touch
3
+ // the player's saves
4
+ import { app } from 'electron'
5
+ import { startShell } from './app.mjs'
6
+
7
+ const { V12_PAGE: page, V12_USER_DATA: userData } = process.env
8
+ // (no `await` at the top of the file: Electron's start waits for this file, and the app's being ready waits for that)
9
+ startShell({ ...(page && !app.isPackaged && { page }), ...(userData && { userData }) }).catch((error) => {
10
+ console.error(error)
11
+ app.exit(1)
12
+ })
@@ -0,0 +1,75 @@
1
+ // `npm run package`: the app to hand out, by Electron's own "manual packaging" (its Application Packaging guide), which
2
+ // adds no package to the project: the page built (`vite build`), then Electron's own app copied with the main process and
3
+ // the built page under `resources/app`, named and given its version and icon. Into `out/`: on macOS `out/<name>.app`,
4
+ // signed ad hoc so that it opens on this Mac (a zip of it beside, for the updater's feed, with `--zip`); on Windows and
5
+ // Linux a folder `out/<name>-<system>-<processor>` with the app's executable in it. It is made for the system it runs on.
6
+ // Signing for other Macs (Apple's notarization), Windows' certificate and installers are Electron Forge's
7
+ // (`docs/scenes/desktop.md`, Before the app is handed out).
8
+ import { execFileSync } from 'node:child_process'
9
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { IDENTIFIER, NAME } from './config.mjs'
12
+ import { buildNative, electronDist, electronVersion, project } from './tools.mjs'
13
+
14
+ /** The main process's files: what the app runs, and none of what makes it (this file, the addon's source, the dev server's) */
15
+ const RUNS = ['main.mjs', 'app.mjs', 'config.mjs', 'files.mjs', 'serve.mjs', 'updates.mjs', 'preload.cjs', 'presentation.node', 'icons/icon.png']
16
+
17
+ const manifest = JSON.parse(readFileSync(join(project, 'package.json'), 'utf8'))
18
+ const version = manifest.version ?? '0.0.0'
19
+ const zip = process.argv.includes('--zip')
20
+
21
+ buildNative()
22
+ const dist = electronDist()
23
+ const { build } = await import('vite')
24
+ await build({ root: project, logLevel: 'warn' })
25
+ if (!existsSync(join(project, 'dist/index.html'))) throw new Error('The page was not built: no dist/index.html')
26
+
27
+ const out = join(project, 'out')
28
+ mkdirSync(out, { recursive: true })
29
+ let made
30
+ if (process.platform === 'darwin') {
31
+ made = join(out, `${NAME}.app`)
32
+ rmSync(made, { recursive: true, force: true })
33
+ // (`cp -R` keeps the bundle's links and modes, which a copy by Node may not)
34
+ execFileSync('cp', ['-R', join(dist, 'Electron.app'), made])
35
+ const resources = join(made, 'Contents/Resources')
36
+ putApp(resources)
37
+ cpSync(join(project, 'electron/icons/icon.icns'), join(resources, 'electron.icns'))
38
+ const plist = join(made, 'Contents/Info.plist')
39
+ for (const [key, value] of [
40
+ ['CFBundleDisplayName', NAME],
41
+ ['CFBundleName', NAME],
42
+ ['CFBundleIdentifier', IDENTIFIER],
43
+ ['CFBundleShortVersionString', version],
44
+ ['CFBundleVersion', version]
45
+ ])
46
+ execFileSync('plutil', ['-replace', key, '-string', value, plist])
47
+ // ad hoc: this Mac opens it; another refuses it until it is signed with a certificate and notarized
48
+ execFileSync('codesign', ['--force', '--deep', '--sign', '-', made], { stdio: 'inherit' })
49
+ if (zip) execFileSync('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', made, join(out, `${NAME}-${version}-darwin-${process.arch}.zip`)])
50
+ } else {
51
+ made = join(out, `${NAME}-${process.platform}-${process.arch}`)
52
+ rmSync(made, { recursive: true, force: true })
53
+ cpSync(dist, made, { recursive: true, verbatimSymlinks: true })
54
+ putApp(join(made, 'resources'))
55
+ const executable = process.platform === 'win32' ? 'electron.exe' : 'electron'
56
+ renameSync(join(made, executable), join(made, process.platform === 'win32' ? `${NAME}.exe` : NAME))
57
+ }
58
+ console.log(`${made} (Electron ${electronVersion()}, version ${version})`)
59
+
60
+ /** The app's own files under Electron's resources, in the place of its default app */
61
+ function putApp(resources) {
62
+ rmSync(join(resources, 'default_app.asar'), { force: true })
63
+ const inside = join(resources, 'app')
64
+ rmSync(inside, { recursive: true, force: true })
65
+ for (const file of RUNS) {
66
+ const from = join(project, 'electron', file)
67
+ // (the addon is there on macOS only)
68
+ if (existsSync(from)) cpSync(from, join(inside, 'electron', file))
69
+ }
70
+ cpSync(join(project, 'dist'), join(inside, 'dist'), { recursive: true })
71
+ writeFileSync(
72
+ join(inside, 'package.json'),
73
+ `${JSON.stringify({ name: manifest.name, productName: NAME, version, type: 'module', main: 'electron/main.mjs' }, null, '\t')}\n`
74
+ )
75
+ }
@@ -0,0 +1,41 @@
1
+ // All that the page may ask of the main process (`app.mjs`), and nothing of Electron's own: `src/shell.ts` is the one
2
+ // file that reads it
3
+ const { contextBridge, ipcRenderer } = require('electron')
4
+
5
+ /** A message of the main process's, to a function of the page's; gives back what stops it */
6
+ const listen = (channel, listener) => {
7
+ const heard = () => listener()
8
+ ipcRenderer.on(channel, heard)
9
+ return () => ipcRenderer.removeListener(channel, heard)
10
+ }
11
+
12
+ contextBridge.exposeInMainWorld('v12Shell', {
13
+ fullscreen: (on) => ipcRenderer.invoke('v12:fullscreen', on),
14
+ close: () => ipcRenderer.invoke('v12:close'),
15
+ destroy: () => ipcRenderer.invoke('v12:destroy'),
16
+ onResized: (listener) => listen('v12:resized', listener),
17
+ onCloseRequested: (listener) => {
18
+ ipcRenderer.send('v12:close-is-heard', true)
19
+ const off = listen('v12:close-requested', listener)
20
+ return () => {
21
+ ipcRenderer.send('v12:close-is-heard', false)
22
+ off()
23
+ }
24
+ },
25
+ openFolder: (folder) => ipcRenderer.invoke('v12:open-folder', folder),
26
+ records: {
27
+ list: (folder) => ipcRenderer.invoke('v12:records', 'list', folder),
28
+ read: (folder, id) => ipcRenderer.invoke('v12:records', 'read', folder, id),
29
+ write: (folder, id, text) => ipcRenderer.invoke('v12:records', 'write', folder, id, text),
30
+ remove: (folder, id) => ipcRenderer.invoke('v12:records', 'remove', folder, id)
31
+ },
32
+ log: {
33
+ append: (file, text) => ipcRenderer.invoke('v12:log', 'append', file, text),
34
+ size: (file) => ipcRenderer.invoke('v12:log', 'size', file),
35
+ rename: (from, to) => ipcRenderer.invoke('v12:log', 'rename', from, to)
36
+ },
37
+ updates: {
38
+ check: () => ipcRenderer.invoke('v12:update-check'),
39
+ install: () => ipcRenderer.invoke('v12:update-install')
40
+ }
41
+ })
@@ -0,0 +1,87 @@
1
+ // What Electron's API does not have: the app's presentation options (`NSApp.presentationOptions`), said and read. Without
2
+ // it `setSimpleFullScreen` leaves AutoHideDock|AutoHideMenuBar, whose Dock and menu bar come back at the screen's edges.
3
+ // A Node-API addon, whose functions are stable across Electron's versions, with the few it asks for declared here so that
4
+ // no headers are needed: `tools.mjs` compiles it with the system's clang (Xcode's command line tools) and nothing else,
5
+ // as `npm run app` and `npm run package` start. Electron gives the symbols as the addon is loaded (`-undefined dynamic_lookup`).
6
+ #import <AppKit/AppKit.h>
7
+ #include <stddef.h>
8
+ #include <stdint.h>
9
+
10
+ typedef struct napi_env__ *napi_env;
11
+ typedef struct napi_value__ *napi_value;
12
+ typedef struct napi_callback_info__ *napi_callback_info;
13
+ typedef int napi_status;
14
+ typedef napi_value (*napi_callback)(napi_env, napi_callback_info);
15
+ extern napi_status napi_get_cb_info(napi_env, napi_callback_info, size_t *, napi_value *, napi_value *, void **);
16
+ extern napi_status napi_get_value_uint32(napi_env, napi_value, uint32_t *);
17
+ extern napi_status napi_create_uint32(napi_env, uint32_t, napi_value *);
18
+ extern napi_status napi_create_function(napi_env, const char *, size_t, napi_callback, void *, napi_value *);
19
+ extern napi_status napi_set_named_property(napi_env, napi_value, const char *, napi_value);
20
+
21
+ static uint32_t first_argument(napi_env env, napi_callback_info info) {
22
+ size_t count = 1;
23
+ napi_value argument = NULL;
24
+ uint32_t value = 0;
25
+ napi_get_cb_info(env, info, &count, &argument, NULL, NULL);
26
+ if (count > 0) napi_get_value_uint32(env, argument, &value);
27
+ return value;
28
+ }
29
+
30
+ static napi_value number(napi_env env, uint32_t value) {
31
+ napi_value out = NULL;
32
+ napi_create_uint32(env, value, &out);
33
+ return out;
34
+ }
35
+
36
+ /** The app's options said; what they read as after */
37
+ static napi_value set_options(napi_env env, napi_callback_info info) {
38
+ NSApp.presentationOptions = (NSApplicationPresentationOptions)first_argument(env, info);
39
+ return number(env, (uint32_t)NSApp.presentationOptions);
40
+ }
41
+
42
+ /** The options of this app, whether or not it is the active one */
43
+ static napi_value own_options(napi_env env, napi_callback_info info) {
44
+ return number(env, (uint32_t)NSApp.presentationOptions);
45
+ }
46
+
47
+ /** The options the system holds now: the active app's */
48
+ static napi_value system_options(napi_env env, napi_callback_info info) {
49
+ return number(env, (uint32_t)NSApp.currentSystemPresentationOptions);
50
+ }
51
+
52
+ /**
53
+ * A key pressed and let go, as an AppKit event posted inside the app, by its key code: for the probes alone, as macOS
54
+ * refuses an agent real keys. It comes in by the app's event queue, where the system's own have already been through
55
+ * what the system takes (Cmd+Tab never gets this far)
56
+ */
57
+ static napi_value post_key(napi_env env, napi_callback_info info) {
58
+ uint32_t code = first_argument(env, info);
59
+ for (int down = 1; down >= 0; down--) {
60
+ NSEvent *event = [NSEvent keyEventWithType:down ? NSEventTypeKeyDown : NSEventTypeKeyUp
61
+ location:NSZeroPoint
62
+ modifierFlags:0
63
+ timestamp:NSProcessInfo.processInfo.systemUptime
64
+ windowNumber:NSApp.keyWindow.windowNumber
65
+ context:nil
66
+ characters:code == 53 ? @"\x1b" : @""
67
+ charactersIgnoringModifiers:code == 53 ? @"\x1b" : @""
68
+ isARepeat:NO
69
+ keyCode:(unsigned short)code];
70
+ [NSApp postEvent:event atStart:NO];
71
+ }
72
+ return number(env, code);
73
+ }
74
+
75
+ static void put(napi_env env, napi_value exports, const char *name, napi_callback callback) {
76
+ napi_value function = NULL;
77
+ napi_create_function(env, name, (size_t)-1, callback, NULL, &function);
78
+ napi_set_named_property(env, exports, name, function);
79
+ }
80
+
81
+ napi_value napi_register_module_v1(napi_env env, napi_value exports) {
82
+ put(env, exports, "setOptions", set_options);
83
+ put(env, exports, "ownOptions", own_options);
84
+ put(env, exports, "systemOptions", system_options);
85
+ put(env, exports, "postKey", post_key);
86
+ return exports;
87
+ }
@@ -0,0 +1,76 @@
1
+ // The built page, answered from a scheme of the app's own: modules, WebAssembly and what the browser keeps all want an
2
+ // origin, which a `file:` page has not. Parts of a file where a part is asked (music). No Electron here: the tests run it in Node.
3
+ import { readFile } from 'node:fs/promises'
4
+ import { extname, join, normalize, sep } from 'node:path'
5
+
6
+ const TYPES = {
7
+ '.html': 'text/html; charset=utf-8',
8
+ '.js': 'text/javascript',
9
+ '.mjs': 'text/javascript',
10
+ '.css': 'text/css',
11
+ '.json': 'application/json',
12
+ '.wasm': 'application/wasm',
13
+ '.png': 'image/png',
14
+ '.jpg': 'image/jpeg',
15
+ '.jpeg': 'image/jpeg',
16
+ '.webp': 'image/webp',
17
+ '.ktx2': 'image/ktx2',
18
+ '.svg': 'image/svg+xml',
19
+ '.woff2': 'font/woff2',
20
+ '.woff': 'font/woff',
21
+ '.mp3': 'audio/mpeg',
22
+ '.ogg': 'audio/ogg',
23
+ '.wav': 'audio/wav',
24
+ '.glb': 'model/gltf-binary',
25
+ '.gltf': 'model/gltf+json'
26
+ }
27
+
28
+ /**
29
+ * The page's content security policy: its own origin and nothing else, with `blob:` and `data:` for what Babylon makes in
30
+ * the page (textures, sounds, workers), `'wasm-unsafe-eval'` for WebAssembly (the navigation, the physics) and inline
31
+ * styles (the HUD's). `connect`: addresses besides the app's own, such as a game's server
32
+ */
33
+ export const contentPolicy = (connect = []) =>
34
+ [
35
+ "default-src 'self'",
36
+ "script-src 'self' 'wasm-unsafe-eval'",
37
+ "style-src 'self' 'unsafe-inline'",
38
+ "img-src 'self' data: blob:",
39
+ "font-src 'self' data:",
40
+ "media-src 'self' data: blob:",
41
+ "worker-src 'self' blob:",
42
+ `connect-src 'self' blob: data:${connect.map((address) => ` ${address}`).join('')}`,
43
+ "object-src 'none'",
44
+ "base-uri 'none'",
45
+ "frame-src 'none'"
46
+ ].join('; ')
47
+
48
+ /** The answer to a request for a file of the built page in `root`; never a file outside it */
49
+ export async function answer(root, request, policy) {
50
+ const { pathname } = new URL(request.url)
51
+ const asked = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).slice(1)
52
+ const path = normalize(join(root, asked))
53
+ if (!path.startsWith(root.endsWith(sep) ? root : root + sep)) return new Response('Not found', { status: 404 })
54
+ let bytes
55
+ try {
56
+ bytes = await readFile(path)
57
+ } catch {
58
+ return new Response('Not found', { status: 404 })
59
+ }
60
+ const headers = {
61
+ 'Content-Type': TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream',
62
+ 'Accept-Ranges': 'bytes',
63
+ 'Content-Security-Policy': policy
64
+ }
65
+ const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '')
66
+ if (range && (range[1] || range[2])) {
67
+ const start = range[1] ? Number(range[1]) : Math.max(0, bytes.length - Number(range[2]))
68
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), bytes.length - 1) : bytes.length - 1
69
+ if (start > end) return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${bytes.length}` } })
70
+ return new Response(bytes.subarray(start, end + 1), {
71
+ status: 206,
72
+ headers: { ...headers, 'Content-Range': `bytes ${start}-${end}/${bytes.length}` }
73
+ })
74
+ }
75
+ return new Response(bytes, { status: 200, headers })
76
+ }
@@ -0,0 +1,63 @@
1
+ // What `dev.mjs` and `package.mjs` share: Electron itself, fetched the first time it is asked for and not by the
2
+ // install (its package has no install step: some 100 to 260 MB, which a project's other work does not pay for), and the
3
+ // presentation addon compiled on macOS.
4
+ import { execFileSync } from 'node:child_process'
5
+ import { existsSync, statSync } from 'node:fs'
6
+ import { createRequire } from 'node:module'
7
+ import { dirname, join } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ /** The project's folder */
11
+ export const project = fileURLToPath(new URL('..', import.meta.url))
12
+ const require = createRequire(join(project, 'package.json'))
13
+
14
+ /** Electron's executable, fetched where it is not there yet (into Electron's cache, then its package's folder) */
15
+ export const electronBinary = () => require('electron')
16
+
17
+ /** Electron's own folder of the app (`Electron.app` on macOS, the folder of `electron.exe` or `electron` elsewhere), fetched as above */
18
+ export function electronDist() {
19
+ electronBinary()
20
+ return join(dirname(require.resolve('electron/package.json')), 'dist')
21
+ }
22
+
23
+ /** Electron's version, which a packaged app says it was made with */
24
+ export const electronVersion = () => require('electron/package.json').version
25
+
26
+ /**
27
+ * `presentation.m` compiled into `electron/presentation.node`, on macOS where it is not there or is older than its
28
+ * source. Nothing elsewhere, which has no such options. Without Xcode's command line tools (`xcode-select --install`)
29
+ * it says so and the shell runs without it, the Dock and the menu bar auto-hiding at the edges
30
+ */
31
+ export function buildNative() {
32
+ if (process.platform !== 'darwin') return false
33
+ const source = join(project, 'electron/presentation.m')
34
+ const built = join(project, 'electron/presentation.node')
35
+ if (existsSync(built) && statSync(built).mtimeMs >= statSync(source).mtimeMs) return true
36
+ try {
37
+ execFileSync(
38
+ 'clang',
39
+ [
40
+ '-bundle',
41
+ '-undefined',
42
+ 'dynamic_lookup',
43
+ '-fobjc-arc',
44
+ '-O2',
45
+ '-mmacosx-version-min=12.0',
46
+ '-arch',
47
+ process.arch === 'arm64' ? 'arm64' : 'x86_64',
48
+ '-framework',
49
+ 'AppKit',
50
+ source,
51
+ '-o',
52
+ built
53
+ ],
54
+ { stdio: 'inherit' }
55
+ )
56
+ return true
57
+ } catch (error) {
58
+ console.warn(
59
+ `The presentation addon was not compiled (${error.message}): the Dock and the menu bar will auto-hide in full screen. xcode-select --install gives clang`
60
+ )
61
+ return false
62
+ }
63
+ }
@@ -0,0 +1,71 @@
1
+ // The app's updates, by Electron's own `autoUpdater` (Squirrel) from a folder of static files, the way of Electron's
2
+ // docs' "Updating Applications" that needs no server and no package: on macOS a `RELEASES.json` beside the zipped app,
3
+ // on Windows Squirrel.Windows' `RELEASES` beside its installer's packages. Squirrel.Mac installs only into an app signed
4
+ // with a real certificate, and Squirrel.Windows only into an app its installer put there (Electron Forge's
5
+ // `maker-squirrel`): the ad hoc signed app of `npm run package` finds nothing, and asks nothing while the address is a
6
+ // placeholder (`config.mjs`). No Electron here: it is handed in, so that the tests run it in Node.
7
+
8
+ /**
9
+ * The feed for this system and processor (`setFeedURL`'s options), or nothing where there is none: a placeholder's
10
+ * address (a host under `.invalid`), or a system Squirrel does not update (Linux)
11
+ */
12
+ export function feedOf(address, platform = process.platform, arch = process.arch) {
13
+ let host
14
+ try {
15
+ host = new URL(address).hostname
16
+ } catch {
17
+ return undefined
18
+ }
19
+ if (host.endsWith('.invalid') || (platform !== 'darwin' && platform !== 'win32')) return undefined
20
+ const folder = `${address.replace(/\/+$/, '')}/${platform}/${arch}`
21
+ return platform === 'darwin' ? { url: `${folder}/RELEASES.json`, serverType: 'json' } : { url: folder }
22
+ }
23
+
24
+ /**
25
+ * What the page asks of updates: `check()`, a newer version downloaded and ready (its name and notes), or nothing; and
26
+ * `install()`, which quits and starts the new one. `updater` is Electron's `autoUpdater`, `feed` what `feedOf` gave,
27
+ * `packaged` whether this is a packaged app (`app.isPackaged`: one run from the project has nothing to update)
28
+ */
29
+ export function updatesOf(updater, feed, packaged) {
30
+ let fed = false
31
+ let asking
32
+ return {
33
+ check() {
34
+ if (!feed || !packaged) return Promise.resolve(undefined)
35
+ if (asking) return asking
36
+ if (!fed) {
37
+ updater.setFeedURL(feed)
38
+ fed = true
39
+ }
40
+ asking = new Promise((resolve, reject) => {
41
+ const done = () => {
42
+ updater.off('update-downloaded', downloaded)
43
+ updater.off('update-not-available', none)
44
+ updater.off('error', failed)
45
+ asking = undefined
46
+ }
47
+ // (Squirrel downloads what it finds before it says so: "downloaded" is the one to wait for)
48
+ const downloaded = (_event, notes, name) => {
49
+ done()
50
+ resolve({ version: name || 'new', ...(notes && { notes: String(notes) }) })
51
+ }
52
+ const none = () => {
53
+ done()
54
+ resolve(undefined)
55
+ }
56
+ const failed = (error) => {
57
+ done()
58
+ reject(error)
59
+ }
60
+ updater.on('update-downloaded', downloaded)
61
+ updater.on('update-not-available', none)
62
+ updater.on('error', failed)
63
+ updater.checkForUpdates()
64
+ })
65
+ return asking
66
+ },
67
+ install() {
68
+ updater.quitAndInstall()
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "main": "electron/main.mjs",
3
+ "scripts": {
4
+ "dev": "vite --port 3100 --strictPort",
5
+ "app": "node electron/dev.mjs",
6
+ "app:editor": "node electron/dev.mjs --attach",
7
+ "package": "node electron/package.mjs"
8
+ },
9
+ "devDependencies": {
10
+ "electron": "45.2.1",
11
+ "happy-dom": "^21.0.3"
12
+ }
13
+ }