@v12-engine/create 0.0.0-stage → 0.1.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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -2
  3. package/base/_gitignore +10 -0
  4. package/base/index.html +20 -0
  5. package/base/package.json +23 -0
  6. package/base/src/ground.worker.ts +8 -0
  7. package/base/src/main.ts +61 -0
  8. package/base/tsconfig.json +14 -0
  9. package/base/vite.config.ts +25 -0
  10. package/bin.mjs +10 -0
  11. package/cli.mjs +381 -0
  12. package/index.mjs +286 -0
  13. package/layers/assets/package.json +5 -0
  14. package/layers/editor/package.json +8 -0
  15. package/layers/editor/src/editor.ts +13 -0
  16. package/layers/rooms/server.ts +69 -0
  17. package/layers/server/package.json +8 -0
  18. package/layers/server/server.ts +34 -0
  19. package/layers/source/package.json +12 -0
  20. package/layers/source/tsconfig.json +5 -0
  21. package/layers/source/vite.config.ts +45 -0
  22. package/layers/tests/package.json +8 -0
  23. package/package.json +45 -5
  24. package/starters/campaign/README.md +61 -0
  25. package/starters/campaign/files.txt +42 -0
  26. package/starters/campaign/layers/editor/src/editor.ts +18 -0
  27. package/starters/campaign/layers/rooms/server.ts +39 -0
  28. package/starters/campaign/layers/server/server.ts +33 -0
  29. package/starters/campaign/layers/tests/tests/rules.test.ts +320 -0
  30. package/starters/campaign/public/games/campaign/assets.json +1 -0
  31. package/starters/campaign/src/campaign/campaign.json +52 -0
  32. package/starters/campaign/src/campaign/carry.ts +47 -0
  33. package/starters/campaign/src/campaign/cinematics/intro.json +148 -0
  34. package/starters/campaign/src/campaign/cinematics/outro.json +92 -0
  35. package/starters/campaign/src/campaign/data.json +1 -0
  36. package/starters/campaign/src/campaign/fx.json +1 -0
  37. package/starters/campaign/src/campaign/game.ts +6 -0
  38. package/starters/campaign/src/campaign/ids.ts +77 -0
  39. package/starters/campaign/src/campaign/index.ts +121 -0
  40. package/starters/campaign/src/campaign/kills.ts +16 -0
  41. package/starters/campaign/src/campaign/looks.json +1 -0
  42. package/starters/campaign/src/campaign/maps.ts +34 -0
  43. package/starters/campaign/src/campaign/missions/homecoming.ts +160 -0
  44. package/starters/campaign/src/campaign/missions/siege.ts +91 -0
  45. package/starters/campaign/src/campaign/missions/warlord.ts +177 -0
  46. package/starters/campaign/src/campaign/prefabs/watchpost.prefab.json +18 -0
  47. package/starters/campaign/src/campaign/scenes.ts +46 -0
  48. package/starters/campaign/src/campaign/tiles.ts +8 -0
  49. package/starters/campaign/src/campaign/units.ts +390 -0
  50. package/starters/campaign/src/campaign/view/cinematics.ts +59 -0
  51. package/starters/campaign/src/campaign/view/fire.ts +115 -0
  52. package/starters/campaign/src/campaign/view/index.ts +334 -0
  53. package/starters/campaign/src/campaign/view/labels.ts +105 -0
  54. package/starters/campaign/src/campaign/view/rubble.ts +40 -0
  55. package/starters/campaign/src/campaign/view/waveLane.ts +88 -0
  56. package/starters/campaign/src/campaign/waves.json +36 -0
  57. package/starters/campaign/src/campaign/waves.ts +186 -0
  58. package/starters/campaign/src/campaign-editor.ts +43 -0
  59. package/starters/campaign/src/game.ts +2 -0
  60. package/starters/campaign/src/main.ts +70 -0
  61. package/starters/campaign/src/maps/fort.names.ts +31 -0
  62. package/starters/campaign/src/maps/fort.vgmap.json +1162 -0
  63. package/starters/campaign/src/maps/homestead.names.ts +36 -0
  64. package/starters/campaign/src/maps/homestead.vgmap.json +916 -0
  65. package/starters/campaign/src/maps/pass.names.ts +40 -0
  66. package/starters/campaign/src/maps/pass.vgmap.json +522 -0
  67. package/starters/campaign/src/maps.ts +2 -0
  68. package/starters/campaign/src/rules.ts +20 -0
  69. package/starters/campaign/src/waves-panel.ts +355 -0
  70. package/starters/empty/README.md +71 -0
  71. package/starters/empty/layers/tests/tests/rules.test.ts +31 -0
  72. package/starters/empty/src/game.ts +79 -0
  73. package/starters/empty/src/generate.worker.ts +9 -0
  74. package/starters/empty/src/maps.ts +44 -0
  75. package/starters/empty/src/rules.ts +67 -0
  76. package/starters/empty/src/tiles.ts +12 -0
  77. package/starters/melee/README.md +61 -0
  78. package/starters/melee/files.txt +60 -0
  79. package/starters/melee/layers/editor/src/editor.ts +13 -0
  80. package/starters/melee/layers/tests/tests/rules.test.ts +58 -0
  81. package/starters/melee/src/game.ts +2 -0
  82. package/starters/melee/src/maps/bay.names.ts +28 -0
  83. package/starters/melee/src/maps/bay.vgmap.json +3123 -0
  84. package/starters/melee/src/maps.ts +20 -0
  85. package/starters/melee/src/melee/abilities.ts +376 -0
  86. package/starters/melee/src/melee/bay.ts +319 -0
  87. package/starters/melee/src/melee/blight.ts +102 -0
  88. package/starters/melee/src/melee/cellLayers.json +11 -0
  89. package/starters/melee/src/melee/data.json +18 -0
  90. package/starters/melee/src/melee/floats.json +199 -0
  91. package/starters/melee/src/melee/formations.ts +46 -0
  92. package/starters/melee/src/melee/fx.json +1 -0
  93. package/starters/melee/src/melee/game.ts +6 -0
  94. package/starters/melee/src/melee/generator.ts +116 -0
  95. package/starters/melee/src/melee/heroSpells.ts +38 -0
  96. package/starters/melee/src/melee/hud.json +269 -0
  97. package/starters/melee/src/melee/index.ts +182 -0
  98. package/starters/melee/src/melee/items.ts +362 -0
  99. package/starters/melee/src/melee/languages/he.json +96 -0
  100. package/starters/melee/src/melee/looks.json +1 -0
  101. package/starters/melee/src/melee/maps.ts +53 -0
  102. package/starters/melee/src/melee/pings.json +16 -0
  103. package/starters/melee/src/melee/pings.ts +34 -0
  104. package/starters/melee/src/melee/quality.json +1 -0
  105. package/starters/melee/src/melee/saves.ts +12 -0
  106. package/starters/melee/src/melee/sounds.json +1 -0
  107. package/starters/melee/src/melee/undead.ts +683 -0
  108. package/starters/melee/src/melee/units.ts +785 -0
  109. package/starters/melee/src/melee/view/buildings.ts +33 -0
  110. package/starters/melee/src/melee/view/cheerLane.ts +42 -0
  111. package/starters/melee/src/melee/view/heroSpellLooks.ts +21 -0
  112. package/starters/melee/src/melee/view/index.ts +268 -0
  113. package/starters/melee/src/melee/view/languages.ts +10 -0
  114. package/starters/melee/src/melee/view/looks.ts +659 -0
  115. package/starters/melee/src/melee/view/undeadLooks.ts +220 -0
  116. package/starters/melee/src/melee/view/walls.ts +59 -0
  117. package/starters/melee/src/rules.ts +24 -0
  118. package/starters.mjs +35 -0
  119. package/v12.mjs +111 -0
@@ -0,0 +1,2 @@
1
+ /** The maps of the game, by the names every part of the project knows them by: the campaign's, in `src/campaign/maps.ts` */
2
+ export { campaignMap as mapNamed, DEFAULT_MAP, maps } from './campaign/maps'
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The rules: the campaign's, in `src/campaign`. Its units, its missions and its waves are in that folder; this is the
3
+ * name every part of the project knows them by
4
+ */
5
+ import type { GameMap, World } from '@v12-engine/engine'
6
+ import { campaign, startMission } from './campaign'
7
+
8
+ export { ENEMY, PLAYER, UNIT } from './campaign'
9
+
10
+ export const rules = campaign
11
+
12
+ /**
13
+ * What the game does once a world is made from a map, before anyone plays: the map's mission is given (each map is
14
+ * a mission's; a map of none plays on with no objectives). The page's free play, the server, the tests and the
15
+ * editor's Play here each call it with the map; the campaign's own scene gives the mission itself, knowing whether
16
+ * the game is new or loaded from a save
17
+ */
18
+ export function start(world: World, { map }: { map?: GameMap }): void {
19
+ startMission(world, map)
20
+ }
@@ -0,0 +1,355 @@
1
+ import type { EditorClient, EditorPanel, EditorPanelDef, PanelSearchEntry } from '@v12-engine/editor'
2
+ import { filterBy, kit, NumberField } from '@v12-engine/client/kit'
3
+ import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera.js'
4
+ import { CreateTorus } from '@babylonjs/core/Meshes/Builders/torusBuilder.js'
5
+ import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
6
+ import { Color3 } from '@babylonjs/core/Maths/math.color.js'
7
+ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
8
+ import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
9
+ import { createEffect, createRoot, on } from 'solid-js'
10
+ import { readWaves, siegeWaves, WAR_CAMP, type Wave, WAVE_UNITS, wavesFile, wavesOf } from './campaign/waves'
11
+
12
+ /*
13
+ * The waves of the siege, in the campaign's editor: a panel of the game's own that lists them, finds them as they are
14
+ * typed, and changes them: who comes, how often, for how much gold. They are the game's file
15
+ * `src/campaign/waves.json`, written as they change (saved with the map, Ctrl+S), and undone like the rest (Ctrl+Z).
16
+ * It shows where the picked wave comes out, in the scene and in a view of its own, moves that place with a click, says
17
+ * how the waves go while the map is played here, and tells the other panels which wave is picked.
18
+ */
19
+
20
+ /** The waves' file, from the game's folder (`src`) */
21
+ const FILE = 'campaign/waves.json'
22
+ /** Who can come in a wave */
23
+ const INVADERS = WAVE_UNITS
24
+
25
+ // what the waves panel says to the other panels, and what it offers them: typed for whoever listens or asks
26
+ declare module '@v12-engine/editor' {
27
+ interface EditorEvents {
28
+ 'campaign.wave.picked': { wave: number }
29
+ }
30
+ interface EditorPanelApis {
31
+ 'campaign.waves': { pick(wave: number): void }
32
+ }
33
+ }
34
+
35
+ /** A wave in a few words: "8 skeleton · every 1.2 s · 20 gold" */
36
+ const describe = (wave: Wave) =>
37
+ `${wave.units.map((unit) => `${unit.count} ${unit.type}`).join(', ')} · every ${wave.interval} s · ${wave.bonus} gold`
38
+
39
+ /** The waves as they are, to keep apart from what changes next */
40
+ const copy = (waves: readonly Wave[]): Wave[] => waves.map((wave) => ({ ...wave, units: wave.units.map((unit) => ({ ...unit })) }))
41
+
42
+ /**
43
+ * The waves the editor's game has, and the game played in its page: what each plays (`registry.setFile`), not the
44
+ * game's module, which other worlds of the page and a server share
45
+ */
46
+ function useWaves(editor: EditorClient, waves: Wave[]): void {
47
+ editor.registry.setFile(wavesFile, waves)
48
+ editor.world?.registry.setFile(wavesFile, copy(waves))
49
+ }
50
+
51
+ /**
52
+ * The waves changed, as a step of the editor's history: the game plays them (`useWaves`), the file is written, and
53
+ * Ctrl+Z puts back what they were. Steps of one field one after the other (a number dragged) are one step (`merge`)
54
+ */
55
+ function change(editor: EditorClient, name: string, make: (waves: Wave[]) => void, merge?: string): void {
56
+ const was = copy(siegeWaves(editor))
57
+ const next = copy(siegeWaves(editor))
58
+ make(next)
59
+ const put = (waves: Wave[]) => {
60
+ useWaves(editor, copy(waves))
61
+ void editor.files.write(FILE, waves)
62
+ }
63
+ editor.history.step({ name, merge, do: () => put(next), undo: () => put(was) })
64
+ }
65
+
66
+ /** The point where the waves come out of the war camp */
67
+ const entryOf = (editor: EditorClient) => editor.map.points.find((point) => point.name === WAR_CAMP)
68
+
69
+ /** Draws the panel into its element, given the editor and its handle */
70
+ function mount(element: HTMLElement, editor: EditorClient, panel: EditorPanel): void {
71
+ // kept in this browser with the panel: the wave picked, and whether its view of the entry is open
72
+ const picked = panel.state('picked', 0)
73
+ const looking = panel.state('looking', false)
74
+ const filter = panel.filter({ placeholder: 'Find a wave: raider, 3, brute' })
75
+ const pick = (wave: number) => {
76
+ picked.set(Math.max(0, Math.min(wave, siegeWaves(editor).length - 1)))
77
+ editor.bus.emit('campaign.wave.picked', { wave: picked.get() })
78
+ draw()
79
+ }
80
+ // what it offers the other panels; and an entry of the search picked shows its wave
81
+ panel.provide({ pick })
82
+ panel.onReveal((id) => pick(Number(id)))
83
+
84
+ const live = document.createElement('p')
85
+ live.className = kit.classes.text({ kind: 'note', class: 'mt-1.5' })
86
+ const list = document.createElement('div')
87
+ list.setAttribute('role', 'listbox')
88
+ list.className = 'mt-2 flex flex-col'
89
+ const details = document.createElement('div')
90
+ details.className = 'mt-3 flex flex-col gap-2'
91
+ const view = document.createElement('canvas')
92
+ view.className = 'mt-2 w-full rounded-panel border border-border'
93
+ view.style.height = '140px'
94
+ const viewBox = document.createElement('div')
95
+ element.append(filter.element(), live, list, details, viewBox)
96
+ /** What the kit's fields drawn into the details take away, as they are drawn again */
97
+ let fields: (() => void)[] = []
98
+ /** A number of the wave's is being changed in its field: what is drawn again leaves the field be */
99
+ let typing = false
100
+
101
+ /** The list of waves, those the words typed find */
102
+ const drawList = () => {
103
+ const shown = filterBy(
104
+ siegeWaves(editor).map((wave, index) => ({ wave, index })),
105
+ filter(),
106
+ ({ wave, index }) => `wave ${index + 1} ${describe(wave)}${wave.units.some((unit) => unit.type === 'brute') ? ' brute' : ''}`
107
+ )
108
+ list.replaceChildren(
109
+ ...shown.map(({ wave, index }) => {
110
+ const row = document.createElement('button')
111
+ row.type = 'button'
112
+ row.setAttribute('role', 'option')
113
+ row.className = kit.classes.option()
114
+ row.dataset.wave = String(index)
115
+ if (index === picked.get()) row.dataset.state = 'checked'
116
+ row.textContent = `Wave ${index + 1}: ${describe(wave)}`
117
+ row.addEventListener('click', () => pick(index))
118
+ return row
119
+ })
120
+ )
121
+ }
122
+
123
+ /** A number of the picked wave's, as a field of the kit's: each change a step, a drag one step */
124
+ const numberField = (
125
+ label: string,
126
+ value: number,
127
+ set: (wave: Wave, value: number) => void,
128
+ options: { min: number; step: number; merge: string }
129
+ ) => {
130
+ const row = document.createElement('label')
131
+ row.className = 'flex items-center justify-between gap-2 text-[13px]'
132
+ row.append(label)
133
+ // the field, and what goes beside it, at the end of the line
134
+ const end = document.createElement('div')
135
+ end.className = 'flex items-center gap-1'
136
+ const field = document.createElement('div')
137
+ field.className = 'w-24'
138
+ end.append(field)
139
+ row.append(end)
140
+ const wave = picked.get()
141
+ fields.push(
142
+ kit.render(field, NumberField, {
143
+ value,
144
+ min: options.min,
145
+ step: options.step,
146
+ size: 'sm',
147
+ onValue: (next: number) => {
148
+ typing = true
149
+ try {
150
+ change(editor, `${label} of wave ${wave + 1}`, (waves) => set(waves[wave], next), `campaign.wave.${wave}.${options.merge}`)
151
+ } finally {
152
+ typing = false
153
+ }
154
+ }
155
+ })
156
+ )
157
+ return row
158
+ }
159
+
160
+ /** What the picked wave is, to change */
161
+ const drawDetails = () => {
162
+ const index = picked.get()
163
+ const wave = siegeWaves(editor)[index]
164
+ for (const gone of fields.splice(0)) gone()
165
+ details.replaceChildren()
166
+ if (!wave) return
167
+ const title = document.createElement('h3')
168
+ title.className = kit.classes.text({ kind: 'heading' })
169
+ title.textContent = `Wave ${index + 1}`
170
+ details.append(title)
171
+ for (const [at, unit] of wave.units.entries()) {
172
+ const row = numberField(unit.type, unit.count, (changed, count) => void (changed.units[at].count = count), {
173
+ min: 1,
174
+ step: 1,
175
+ merge: `count.${at}`
176
+ })
177
+ row.lastElementChild!.append(
178
+ kit.button('×', () => change(editor, `Take the ${unit.type} out of wave ${index + 1}`, (waves) => void waves[index].units.splice(at, 1)), {
179
+ variant: 'ghost',
180
+ size: 'icon-sm',
181
+ tip: `Take the ${unit.type} out of this wave`
182
+ })
183
+ )
184
+ details.append(row)
185
+ }
186
+ const more = document.createElement('div')
187
+ more.className = 'flex flex-wrap gap-1'
188
+ for (const type of INVADERS.filter((type) => !wave.units.some((unit) => unit.type === type)))
189
+ more.append(
190
+ kit.button(
191
+ `+ ${type}`,
192
+ () => change(editor, `Add ${type} to wave ${index + 1}`, (waves) => void waves[index].units.push({ type, count: 1 })),
193
+ {
194
+ size: 'sm'
195
+ }
196
+ )
197
+ )
198
+ details.append(more)
199
+ details.append(
200
+ numberField('Seconds between', wave.interval, (changed, value) => void (changed.interval = value), { min: 0.1, step: 0.1, merge: 'interval' })
201
+ )
202
+ details.append(
203
+ numberField('Gold for beating it', wave.bonus, (changed, value) => void (changed.bonus = value), { min: 0, step: 5, merge: 'bonus' })
204
+ )
205
+ const buttons = document.createElement('div')
206
+ buttons.className = 'flex flex-wrap gap-1'
207
+ buttons.append(
208
+ // a click in the scene, asked for by the panel: where the waves come onto the map
209
+ kit.button('Move the war camp', () => moveEntry(), { size: 'sm', tip: 'Then click in the scene, where the waves come out' }),
210
+ kit.button(looking.get() ? 'Hide the view' : 'A view of it', () => (looking.set(!looking.get()), drawView(), drawDetails()), {
211
+ size: 'sm',
212
+ tip: 'A view of the war camp, of its own'
213
+ }),
214
+ kit.button(
215
+ 'Take it out',
216
+ () => {
217
+ change(editor, `Take wave ${index + 1} out`, (waves) => void waves.splice(index, 1))
218
+ pick(index)
219
+ },
220
+ { size: 'sm', variant: 'danger' }
221
+ )
222
+ )
223
+ details.append(buttons)
224
+ }
225
+
226
+ const moveEntry = () =>
227
+ panel.pick({
228
+ hint: 'Click where the waves come out (Esc: not now)',
229
+ pick: ({ ground }) => {
230
+ const entry = entryOf(editor)
231
+ if (!ground) return
232
+ if (entry) editor.edit((map) => map.markers.setPoint(entry.id, { x: ground.x, z: ground.z }), 'Move the war camp')
233
+ else editor.edit((map) => void map.markers.addPoint(ground, 0, WAR_CAMP), 'Say where the war camp is')
234
+ }
235
+ })
236
+
237
+ /** Where the picked wave comes in, as a ring in the scene as big as the wave: made again as it or the points change */
238
+ let undraw = () => {}
239
+ let ring: Mesh | undefined
240
+ const drawRing = () => {
241
+ undraw()
242
+ ring = undefined
243
+ undraw = panel.draw(
244
+ (scene, root) => {
245
+ const entry = entryOf(editor)
246
+ const wave = siegeWaves(editor)[picked.get()]
247
+ if (!entry || !wave) return
248
+ const count = wave.units.reduce((sum, unit) => sum + unit.count, 0)
249
+ ring = CreateTorus('campaign.waves.ring', { diameter: 2 + Math.sqrt(count), thickness: 0.25, tessellation: 48 }, scene)
250
+ ring.position.set(entry.x, editor.doc.terrain.heightAt(entry.x, entry.z) + 0.3, entry.z)
251
+ const paint = new StandardMaterial('campaign.waves.ring', scene)
252
+ paint.diffuseColor = wave.units.some((unit) => unit.type === 'brute') ? new Color3(1, 0.3, 0.2) : new Color3(1, 0.75, 0.3)
253
+ ring.material = paint
254
+ ring.parent = root
255
+ },
256
+ { again: (changed) => changed.parts.has('points') }
257
+ )
258
+ }
259
+ // it turns, while the panel shows: what a frame hook does
260
+ panel.frame((dt) => {
261
+ if (ring && !ring.isDisposed()) ring.rotation.y += dt * 0.8
262
+ })
263
+
264
+ /** The view of where the waves come in, while it is asked for: drawn only while the panel shows */
265
+ let unview: (() => void) | undefined
266
+ const drawView = () => {
267
+ unview?.()
268
+ unview = undefined
269
+ view.remove()
270
+ if (!looking.get()) return
271
+ viewBox.append(view)
272
+ const made = panel.viewport(view, {
273
+ every: 2,
274
+ camera: (scene) => {
275
+ const entry = entryOf(editor) ?? { x: editor.map.width / 2, z: editor.map.depth / 2 }
276
+ return new ArcRotateCamera('campaign.waves.view', -Math.PI / 2, 0.9, 18, new Vector3(entry.x, 0, entry.z), scene)
277
+ }
278
+ })
279
+ unview = () => made.dispose()
280
+ }
281
+
282
+ /** How the waves go while the map is played here: the running world's */
283
+ const drawLive = () => {
284
+ const world = editor.world
285
+ if (!editor.playing() || !world) return void (live.textContent = `${siegeWaves(editor).length} waves`)
286
+ const state = wavesOf(world)
287
+ live.textContent =
288
+ state.phase === 'idle'
289
+ ? `${siegeWaves(editor).length} waves: they come in the siege (the fort)`
290
+ : `Played here: wave ${Math.min(state.wave + 1, siegeWaves(editor).length)} of ${siegeWaves(editor).length}, ${state.phase}`
291
+ }
292
+
293
+ const draw = () => {
294
+ drawList()
295
+ if (!typing) drawDetails()
296
+ drawRing()
297
+ drawLive()
298
+ panel.setBadge(siegeWaves(editor).length)
299
+ }
300
+ draw()
301
+ drawView()
302
+ panel.onClose(() => {
303
+ for (const gone of fields.splice(0)) gone()
304
+ })
305
+ // the waves changed (a step done or undone here, the file changed on disk), the words typed, the game played
306
+ const offFile = editor.files.watch(FILE, (text) => {
307
+ if (text !== undefined && text !== `${JSON.stringify(siegeWaves(editor), null, '\t')}\n`) useWaves(editor, readWaves(JSON.parse(text)))
308
+ draw()
309
+ })
310
+ panel.onClose(offFile)
311
+ panel.on('play.started', drawLive)
312
+ panel.on('play.stopped', drawLive)
313
+ panel.on('play.tick', drawLive)
314
+ // the list follows the words typed (the filter is read as a Solid signal)
315
+ createRoot((dispose) => {
316
+ createEffect(on(filter, drawList, { defer: true }))
317
+ panel.onClose(dispose)
318
+ })
319
+ }
320
+
321
+ /** The campaign's waves panel, in the editor's own way of saying a panel */
322
+ export const wavesPanel: EditorPanelDef = {
323
+ id: 'campaign.waves',
324
+ name: 'Waves',
325
+ icon: 'campaign.wave',
326
+ side: 'right',
327
+ // opens and closes it, the user's to move: W is the editor's, Shift+W the game's
328
+ key: 'Shift+KeyW',
329
+ // how many waves there are, on its icon, closed too
330
+ badge: (editor) => siegeWaves(editor).length,
331
+ header: {
332
+ mount(element, { editor }) {
333
+ element.append(
334
+ kit.button(
335
+ 'Add',
336
+ () => {
337
+ change(editor, 'Add a wave', (waves) => void waves.push({ units: [{ type: 'raider', count: 4 }], interval: 1.5, bonus: 60 }))
338
+ editor.panels.api('campaign.waves')?.pick(siegeWaves(editor).length - 1)
339
+ },
340
+ { variant: 'ghost', size: 'sm', tip: 'Add a wave after the last' }
341
+ )
342
+ )
343
+ }
344
+ },
345
+ // each wave, in the search of everything (and alone after `w`): picked, it is shown in the panel
346
+ prefix: 'w',
347
+ search: (editor): PanelSearchEntry[] =>
348
+ siegeWaves(editor).map((wave, index) => ({
349
+ id: String(index),
350
+ label: `Wave ${index + 1}`,
351
+ detail: describe(wave),
352
+ words: `${wave.units.map((unit) => unit.type).join(' ')}${wave.units.some((unit) => unit.type === 'brute') ? ' brute' : ''}`
353
+ })),
354
+ mount
355
+ }
@@ -0,0 +1,71 @@
1
+ # My game
2
+
3
+ A game made with V12, at its start: a flat field, and a scout to walk it. Select it and right-click the ground.
4
+
5
+ | To | Run |
6
+ | ---------------------- | -------------------------------------------------------------------- |
7
+ | Play it | `npm run dev`, and open the page it says: its start screen, Play |
8
+ <!-- @if editor -->
9
+ | Make maps | `npm run dev`, and open `/?editor`: Save writes them into `src/maps` |
10
+ | Check the maps | `npm run map check`: every map in `src/maps` against the game's rules |
11
+ <!-- @end -->
12
+ <!-- @if server -->
13
+ <!-- @if !rooms -->
14
+ | Play it on a server | `npm run server`, beside `npm run dev`, and open `/?server` |
15
+ <!-- @end -->
16
+ <!-- @if rooms -->
17
+ | Play it online | `npm run server`, beside `npm run dev`, and open `/?find-game` |
18
+ | Try it alone online | the same, and join the room `practice`: the computer plays the rest |
19
+ <!-- @end -->
20
+ <!-- @end -->
21
+ <!-- @if tests -->
22
+ | Test the rules | `npm test` |
23
+ <!-- @end -->
24
+ | Check the types | `npm run typecheck` |
25
+ | Build the page to host | `npm run build`, into `dist/` |
26
+
27
+ ## Where to go from here
28
+
29
+ 1. **A second unit type.** Add it to `UNIT` and `units` in `src/rules.ts`, and its look to `src/game.ts`. Give it `attackDamage`, `attackSpeed` and `attackRange` in its `stats`, and an attack in its `attacks`, and it fights.
30
+ 2. **A second player.** `players: 2` in `src/maps.ts`, with a start for each, and `start` gives them a scout too.
31
+ <!-- @if editor -->
32
+ 3. **A map of your own.** Open `/?editor`, paint, raise ground, place units, and Save. Then import `src/maps/field.vgmap.json` in `src/maps.ts`, as its comment says.
33
+ <!-- @end -->
34
+ <!-- @if !editor -->
35
+ 3. **A map of your own.** `createMap` in `src/maps.ts` makes the field; its `ground`, `tiles` and `objects` are yours to fill in code.
36
+ <!-- @end -->
37
+ 4. **Winning.** `victory` in `src/rules.ts`: `coreVictory` makes the last side with a unit of a kind the winner.
38
+ <!-- @if assets -->
39
+ 5. **A model.** One of the shared art's (`import { characters } from '@v12-engine/assets'`, then `model: { src: characters.models.knight, scale: 0.6 }` in its look in `src/game.ts`), or a `.glb` of yours in `public/models/` (`src: 'scout.glb'`). The map editor's Content lists the shared art's trees, rocks and buildings to put down as scenery.
40
+ <!-- @end -->
41
+ <!-- @if !assets -->
42
+ 5. **A model.** A `.glb` in `public/models/`, and `model: { src: 'scout.glb', scale: 1 }` in its look in `src/game.ts`.
43
+ <!-- @end -->
44
+
45
+ ## What is where
46
+
47
+ - `src/rules.ts`: the rules, what there is and what happens once a game starts. The engine alone (`@v12-engine/engine`), so the page, the server and the tests play the same game
48
+ - `src/game.ts`: how it looks, and the game whole (`defineGame`)
49
+ - `src/maps.ts`: the maps. One, made in code. A map says which game it is for (`game`, the rules' `id`)
50
+ - `src/main.ts`: the page, its scenes made by `appScenes`
51
+ <!-- @if editor -->
52
+ - `src/editor.ts`: the map editor's own settings, for `appScenes`
53
+ <!-- @end -->
54
+ <!-- @if server -->
55
+ <!-- @if !rooms -->
56
+ - `server.ts`: a server of the game, one game at a time, `startServer`
57
+ <!-- @end -->
58
+ <!-- @if rooms -->
59
+ - `server.ts`: a server of many games, `GameRooms`: a room for each map, games players make (New game) and are matched for (Play now). The page's `/?find-game` is the client's Find a game
60
+ <!-- @end -->
61
+ <!-- @if ratings -->
62
+ - `ratings.json` (made by the server): a rating for each player, moved by each game that ends with a winner, shown in the lobby
63
+ <!-- @end -->
64
+ <!-- @end -->
65
+ <!-- @if tests -->
66
+ - `tests/`: the rules tested with no page, `testWorld`
67
+ <!-- @end -->
68
+ - `public/` (make it when needed): the game's models (`.glb`, in `public/models/`), sounds and pictures
69
+ <!-- @if assets -->
70
+ - `@v12-engine/assets`: shared models to start with (adventurers with their clips, buildings, trees and rocks, props, weapons), served where `public/`'s files are by `assetsPackage()` in `vite.config.ts`; a file of yours at the same path is the one served, and a build copies what the game uses. Whose each file is: its group's `CREDITS.md` in `node_modules/@v12-engine/assets/groups/`
71
+ <!-- @end -->
@@ -0,0 +1,31 @@
1
+ /** The rules, tested with no page: a world steps here as it does in a page (`@v12-engine/engine/testing`) */
2
+ import { checkRules, createWorldFromMap } from '@v12-engine/engine'
3
+ import { order, run, testWorld } from '@v12-engine/engine/testing'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { mapNamed } from '../src/maps'
6
+ import { rules, start, UNIT } from '../src/rules'
7
+
8
+ describe('the rules', () => {
9
+ it('fit together: every id they name is theirs, every effect has a handler (checkRules)', () => {
10
+ expect(checkRules(rules).filter((problem) => problem.level === 'error')).toEqual([])
11
+ })
12
+
13
+ it('give each player with a start a scout, at it', async () => {
14
+ const map = mapNamed()
15
+ const world = await createWorldFromMap({ map, game: rules, seed: 1 })
16
+ start(world, { map })
17
+ const scouts = [...world.actors.values()].filter((entity) => entity.def.id === UNIT.scout)
18
+ expect(scouts).toHaveLength(map.players.length)
19
+ expect(scouts[0].pos.x).toBeCloseTo(map.players[0].start!.x)
20
+ world.dispose()
21
+ })
22
+
23
+ it('send a scout where it is ordered', async () => {
24
+ const world = await testWorld(rules)
25
+ const scout = world.spawn(UNIT.scout, 1, { x: 10, z: 10 })
26
+ order(world, [scout], { type: 'move', point: { x: 30, z: 10 } })
27
+ run(world, 20, () => scout.pos.x > 29)
28
+ expect(scout.pos.x).toBeGreaterThan(29)
29
+ world.dispose()
30
+ })
31
+ })
@@ -0,0 +1,79 @@
1
+ /** The game, whole: its rules and how it looks. What the page plays and the editor makes maps for */
2
+ import { defineGame, type GameView, hotView, presetBuffMarker, presetRallyFlag, presetSpellStyles, presetWeather, TILE_LOOKS } from '@v12-engine/client'
3
+ import { rules, type Unit } from './rules'
4
+
5
+ /**
6
+ * The look: a unit type is drawn from `parts` (boxes, cylinders, cones, spheres, pyramids) until it has a model, a
7
+ * `.glb` in `public/models/` named by `model` (`{ src: 'scout.glb', scale: 1 }`). `color: 'team'` is its side's colour
8
+ // @if assets
9
+ *
10
+ * The shared art has models to start with: `import { characters } from '@v12-engine/assets'` and
11
+ * `model: { src: characters.models.knight, scale: 0.6 }` (played with its own clips)
12
+ // @end
13
+ */
14
+ export const view: GameView<Unit> = {
15
+ scene: {
16
+ // trees and scenery in front of the player's own units faded out (`seeThrough`): who for, how, how much
17
+ seeThrough: {},
18
+ /**
19
+ * Looks by name to start a map from: the map editor's Look panel puts one on a map (Preset), a scene plays a map with
20
+ * one (`gameScene({ environment: 'dusk' })`), the rules change to one (`world.setEnvironment(view.scene.environments.storm,
21
+ * { over: 20 })`). Each is a `MapEnvironment`: sky, light, mist, wind, clouds, weather, water
22
+ */
23
+ environments: {
24
+ dusk: {
25
+ sky: [0.55, 0.42, 0.5],
26
+ clouds: { cover: 0.4, color: [1, 0.8, 0.7] },
27
+ light: { sunUp: 12, sunAround: 250, sunColor: [1, 0.62, 0.42], sun: 1, ambient: 0.45, ambientColor: [0.8, 0.7, 0.9] },
28
+ fog: { start: 60, end: 200 },
29
+ mist: { ground: { base: 0, top: 2, density: 0.25 } }
30
+ },
31
+ storm: {
32
+ sky: [0.3, 0.33, 0.38],
33
+ clouds: { cover: 0.9, color: [0.55, 0.57, 0.6] },
34
+ cloudLayer: { height: 18, cover: 0.7, opacity: 0.55, shadows: 0.5 },
35
+ light: { sun: 0.7, ambient: 0.5 },
36
+ wind: { around: 70, strength: 0.8, gusts: 0.6 },
37
+ weather: { kind: 'rain', amount: 0.9, splashes: 0.6 },
38
+ fog: { start: 40, end: 140 }
39
+ }
40
+ },
41
+ // the ready-made rain (its map has some), styles of abilities, rally flag and buff glow: the client draws none by itself
42
+ weather: presetWeather()
43
+ },
44
+ fx: {
45
+ abilities: {
46
+ styles: presetSpellStyles()
47
+ }
48
+ },
49
+ units: {
50
+ looks: {
51
+ scout: {
52
+ height: 1.4,
53
+ parts: [
54
+ { shape: 'cylinder', size: [0.6, 1, 0.6], offset: [0, 0.5, 0], color: 'team' },
55
+ { shape: 'sphere', size: [0.45, 0.45, 0.45], offset: [0, 1.2, 0], color: [0.9, 0.8, 0.65] }
56
+ ]
57
+ }
58
+ },
59
+ rallyFlag: presetRallyFlag(),
60
+ buffMarker: presetBuffMarker()
61
+ },
62
+ ground: {
63
+ /**
64
+ * How each tile of the ground (src/tiles.ts) looks: a colour (the minimap's, and the ground's until its picture comes)
65
+ * and a picture repeated over the ground. A picture is a file in `public/textures/` by its path there, or made in
66
+ * code with no file: `pattern: 'grass'` (grass, gravel, rock, cobbles, planks, ice, lava… fifteen), with a `recipe` (a second
67
+ * colour, specks, how strong, and the pattern's own parameters: `params: { seams: 1.2 }`), or a pattern of the
68
+ * game's own (`patterns: { cobbles: definePattern({ name, params, shade }) }` on the view, then `pattern: 'cobbles'`;
69
+ * the map editor offers it with a slider for each parameter)
70
+ */
71
+ // the client's looks of the engine's tiles, made in code (no files), and the road as gravel of a colour of its own
72
+ tiles: { ...TILE_LOOKS, road: { color: [0.55, 0.52, 0.47], pattern: 'gravel' } }
73
+ }
74
+ }
75
+
76
+ export const game = defineGame({ rules, view, check: 'throw' })
77
+
78
+ // saved while it runs (npm run dev): the game and the editor open take its new look, with no new page
79
+ if (import.meta.hot) import.meta.hot.accept((module) => hotView(import.meta.hot, rules.id, view, module?.view))
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The page's worker that makes the random map's preview with the game's generator (`rules.generator`), so the menu
3
+ * goes on while it is made. Given to the app in `main.ts` (`generatorWorker`)
4
+ */
5
+ // the rules alone: nothing of the client is loaded into the worker
6
+ import { serveMapGenerator } from '@v12-engine/engine'
7
+ import { rules } from './rules'
8
+
9
+ serveMapGenerator(rules)
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The maps of the game. There is one, made here in code: a flat field with a player on it and a road across it.
3
+ // @if editor
4
+ * The map editor (`npm run dev`, then `/?editor`) opens it, and Save writes it into `src/maps/field.vgmap.json`. From
5
+ * then on the file is the map: import it here in its place, as the lines below say, and `flatField` can go
6
+ // @end
7
+ */
8
+ import { createMap, type GameMap } from '@v12-engine/engine'
9
+ import { tiles } from './tiles'
10
+ // @if editor
11
+ // import { mapsFrom } from '@v12-engine/engine'
12
+ // import field from './maps/field.vgmap.json'
13
+ // @end
14
+
15
+ /** A flat field of 32 cells a side, a player with a start in its middle, and a road across it */
16
+ function flatField(): GameMap {
17
+ const map = createMap({ name: 'Field', width: 32, depth: 32, players: 1, game: 'my-game', tileset: tiles.map((tile) => tile.id) })
18
+ map.players[0].start = { x: 16, z: 16 }
19
+ // a cell's paint is the place of its tile in the map's tileset, from 1 (0 is none: the ground's own colour)
20
+ const road = map.tileset.indexOf('road') + 1
21
+ for (let z = 20; z < 22; z++) for (let x = 0; x < map.width; x++) map.tiles[z * map.width + x] = road
22
+ return map
23
+ }
24
+
25
+ /** The maps by name */
26
+ // @if editor
27
+ // once the editor has saved the field: mapsFrom({ './maps/field.vgmap.json': field })
28
+ // @end
29
+ export const maps: Readonly<Record<string, GameMap>> = { field: flatField() }
30
+
31
+ // @if editor
32
+ /** The map a game is on when nothing says which, and the one the editor opens first */
33
+ // @end
34
+ // @if !editor
35
+ /** The map a game is on when nothing says which */
36
+ // @end
37
+ export const DEFAULT_MAP = 'field'
38
+
39
+ /** A map by name, as a copy: a game changes nothing of what the next one starts from */
40
+ export function mapNamed(name = DEFAULT_MAP): GameMap {
41
+ const map = maps[name]
42
+ if (!map) throw new Error(`There is no map "${name}": the maps are ${Object.keys(maps).join(', ')}`)
43
+ return structuredClone(map)
44
+ }