@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,334 @@
1
+ import {
2
+ type AbilityLooks,
3
+ FX_FILE,
4
+ type GameView,
5
+ hotView,
6
+ type HudClient,
7
+ type HudPartDef,
8
+ layFiles,
9
+ type ModelDef,
10
+ presetBuffMarker,
11
+ presetFx,
12
+ presetRallyFlag,
13
+ presetSpellStyles,
14
+ presetWeather,
15
+ readCinematic,
16
+ type StatusLine,
17
+ TILE_LOOKS,
18
+ viewListener,
19
+ type UnitLooks,
20
+ withHeroesView
21
+ } from '@v12-engine/client'
22
+ import { LOOKS_FILE } from '@v12-engine/client/looks-file'
23
+ import looksFile from '../looks.json'
24
+ import fxFile from '../fx.json'
25
+ import introFile from '../cinematics/intro.json'
26
+ import outroFile from '../cinematics/outro.json'
27
+ import { ALDRIC, BRANN, type CampaignMessage, type CampaignResource, type CampaignUnit, VARGA } from '../ids'
28
+ import { nextWave, siegeWaves, waveComing } from '../waves'
29
+ import { PARLEY } from '../missions/warlord'
30
+ import { parley } from './cinematics'
31
+ import { holyFire } from './fire'
32
+ import { bossName, towerKills } from './labels'
33
+ import { rubble } from './rubble'
34
+ import { waveLane } from './waveLane'
35
+
36
+ /**
37
+ * How the campaign looks: its own view, next to its rules in `..`. Every unit and building is a model of the shared
38
+ * art (`@v12-engine/assets`, its `CREDITS.md` says whose): KayKit's adventurers with their clips and their gear in hand,
39
+ * Quaternius's buildings and trees. Each is drawn as its parts while its model loads
40
+ */
41
+
42
+ /** A character of the shared art, `scale` times its size, with what it holds in its hands */
43
+ const character = (name: string, scale: number, own: Partial<ModelDef> = {}): ModelDef => ({ src: `characters/${name}.glb`, scale, ...own })
44
+ /** A piece of the adventurers' gear in a hand: the right one when not said */
45
+ const hold = (name: string, hand: 'l' | 'r' = 'r') => ({ src: `gear/${name}.glb`, at: `handslot.${hand}` })
46
+ /** A building of the shared art made `width` across: its size at scale 1 (across, up, deep) is in the package's `manifest.json` */
47
+ const building = (src: string, size: [number, number, number], width: number, own: Partial<ModelDef> = {}): ModelDef => ({
48
+ src,
49
+ scale: width / Math.max(size[0], size[2]),
50
+ ...own
51
+ })
52
+
53
+ const SKIN: [number, number, number] = [0.87, 0.68, 0.53]
54
+ const STONE: [number, number, number] = [0.6, 0.58, 0.55]
55
+ const WOOD: [number, number, number] = [0.45, 0.31, 0.18]
56
+ const RED: [number, number, number] = [0.55, 0.12, 0.1]
57
+ const GOLD: [number, number, number] = [1, 0.8, 0.2]
58
+
59
+ /** A person, as parts: a body in a colour and a head */
60
+ const person = (height: number, body: [number, number, number] | 'team') => [
61
+ {
62
+ shape: 'cylinder' as const,
63
+ size: [0.5, height * 0.6, 0.5] as [number, number, number],
64
+ offset: [0, height * 0.3, 0] as [number, number, number],
65
+ color: body
66
+ },
67
+ {
68
+ shape: 'sphere' as const,
69
+ size: [0.4, 0.4, 0.4] as [number, number, number],
70
+ offset: [0, height * 0.75, 0] as [number, number, number],
71
+ color: SKIN
72
+ }
73
+ ]
74
+
75
+ /** The trees of the shared art's forest a tree is drawn as: a look of it each, by the model's name */
76
+ const TREES = ['common_tree_1', 'common_tree_2', 'common_tree_3', 'common_tree_5', 'pine_1', 'pine_2', 'pine_3', 'pine_5']
77
+
78
+ export const looks: UnitLooks<CampaignUnit> = {
79
+ peasant: { height: 1.5, model: character('barbarian', 0.58, { hold: [hold('axe_1handed')] }), parts: person(1.5, 'team') },
80
+ footman: { height: 1.8, model: character('knight', 0.7, { hold: [hold('sword_1handed'), hold('shield_round', 'l')] }), parts: person(1.8, 'team') },
81
+ archer: {
82
+ height: 1.7,
83
+ model: character('ranger', 0.68, { hold: [hold('bow')], animations: { attack: 'Ranged_2H_Shoot' } }),
84
+ shot: { color: [0.85, 0.75, 0.55], size: 0.2 },
85
+ parts: person(1.7, 'team')
86
+ },
87
+ knight: {
88
+ height: 2.2,
89
+ model: character('knight', 0.9, { hold: [hold('sword_2handed')], animations: { attack: 'Melee_2H_Attack_Chop' } }),
90
+ parts: person(2.2, 'team')
91
+ },
92
+ hall: {
93
+ height: 4.4,
94
+ model: building('rts/castle.glb', [6.31, 2.81, 5.87], 4.4),
95
+ parts: [{ shape: 'box', size: [3.8, 2.6, 3.8], offset: [0, 1.3, 0], color: STONE }]
96
+ },
97
+ farm: {
98
+ height: 2.2,
99
+ model: building('rts/small_farm.glb', [5.437, 3.211, 3.33], 2.6),
100
+ parts: [{ shape: 'box', size: [1.8, 1.4, 1.8], offset: [0, 0.7, 0], color: WOOD }]
101
+ },
102
+ barracks: {
103
+ height: 3.2,
104
+ model: building('rts/barracks.glb', [3.029, 2.158, 3.234], 3.2),
105
+ parts: [{ shape: 'box', size: [2.8, 2, 2.8], offset: [0, 1, 0], color: WOOD }]
106
+ },
107
+ tower: {
108
+ height: 4.8,
109
+ model: building('rts/watch_tower.glb', [1.515, 4.388, 1.558], 1.7, { points: { launch: { offset: [0, 3.6, 0] } } }),
110
+ shot: { color: [0.85, 0.75, 0.55], size: 0.25 },
111
+ parts: [{ shape: 'cylinder', size: [1.5, 3.5, 1.5], offset: [0, 1.75, 0], color: STONE }]
112
+ },
113
+ wall: {
114
+ height: 1.4,
115
+ model: building('rts/stone_wall.glb', [4.681, 1.338, 0.74], 2.2),
116
+ death: { collapse: { look: 'crumble', pieces: 8, dust: true } },
117
+ parts: [{ shape: 'box', size: [2, 1.2, 0.7], offset: [0, 0.6, 0], color: STONE }]
118
+ },
119
+ trader: {
120
+ height: 2.4,
121
+ model: building('village/market_stand.glb', [0.776, 1.563, 1.731], 2.2),
122
+ parts: [{ shape: 'box', size: [1.8, 1.6, 1.8], offset: [0, 0.8, 0], color: [0.7, 0.55, 0.3] }]
123
+ },
124
+ raider: {
125
+ height: 1.8,
126
+ model: character('barbarian', 0.72, { hold: [hold('axe_1handed'), hold('shield_round_barbarian', 'l')] }),
127
+ parts: person(1.8, RED)
128
+ },
129
+ warlock: {
130
+ height: 1.8,
131
+ model: character('mage', 0.72, { hold: [hold('staff')] }),
132
+ shot: { color: [1, 0.45, 0.15], size: 0.3 },
133
+ parts: person(1.8, RED)
134
+ },
135
+ brute: {
136
+ height: 2.5,
137
+ model: character('barbarian', 1.05, { hold: [hold('axe_2handed')], animations: { attack: 'Melee_2H_Attack_Chop' } }),
138
+ parts: person(2.5, RED)
139
+ },
140
+ warlord: {
141
+ height: 2.8,
142
+ model: character('barbarian', 1.2, { hold: [hold('sword_2handed')], animations: { attack: 'Melee_2H_Attack_Chop' } }),
143
+ unitHud: { show: 'always', items: [{ text: 'Varga', size: 13, color: 'owner' }] },
144
+ parts: person(2.8, RED)
145
+ },
146
+ gnoll: { height: 1.6, model: character('rogue_hooded', 0.62, { hold: [hold('dagger')] }), parts: person(1.6, [0.45, 0.4, 0.3]) },
147
+ prisoner: { height: 1.6, model: character('rogue', 0.62), parts: person(1.6, [0.5, 0.5, 0.5]) },
148
+ tree: {
149
+ height: 3,
150
+ looks: Object.fromEntries(TREES.map((look) => [look, { src: `forest/${look}.glb`, scale: 0.85 }])),
151
+ mix: TREES,
152
+ vary: { scale: 0.25, lean: 0.07 },
153
+ parts: [
154
+ { shape: 'cylinder', size: [0.3, 1, 0.3], offset: [0, 0.5, 0], color: [0.36, 0.25, 0.15] },
155
+ { shape: 'cone', size: [1.5, 1.8, 1.5], offset: [0, 1.7, 0], color: [0.15, 0.38, 0.18] }
156
+ ]
157
+ },
158
+ goldmine: {
159
+ height: 3.2,
160
+ model: building('rts/mine.glb', [3.853, 3.508, 4.084], 3.9),
161
+ parts: [
162
+ { shape: 'box', size: [3.5, 2, 3.5], offset: [0, 1, 0], color: [0.4, 0.38, 0.35] },
163
+ { shape: 'sphere', size: [1, 1, 1], offset: [0, 2.4, 0], color: GOLD }
164
+ ]
165
+ }
166
+ }
167
+
168
+ export const effects: AbilityLooks = {
169
+ holyLight: { color: [1, 0.95, 0.45] },
170
+ warCry: { color: [1, 0.6, 0.2], style: 'burst', options: { count: 60, ring: 7, shape: 'about' } },
171
+ rallied: { color: [1, 0.6, 0.2], aura: 'glow' },
172
+ // drawn by a style of the game's own (`styles` below, made in fire.ts)
173
+ judgement: { color: [1, 0.85, 0.35], style: 'campaign.holyFire' },
174
+ drinkPotion: { color: [0.4, 1, 0.5] }
175
+ }
176
+
177
+ /** The lines on the HUD, from what the game reports: the wave, when the next one comes, who is still coming */
178
+ function status(report: Record<string, unknown>): StatusLine[] {
179
+ const { wave, waves, phase, countdown, left } = report as { wave?: number; waves: number; phase: string; countdown: number; left: number }
180
+ if (wave === undefined) return []
181
+ const lines: StatusLine[] = [{ text: `Wave ${wave} of ${waves}` }]
182
+ if (phase === 'waiting') lines.push({ text: `comes in ${countdown}s`, title: 'Call it in now for as much gold as there are seconds left' })
183
+ else if (phase === 'over') return lines
184
+ else lines.push({ text: `${left} coming`, alarm: left > 0 && phase === 'fighting' })
185
+ return lines
186
+ }
187
+
188
+ /** A panel of the campaign's own: what the next wave brings, while the player waits for it (the siege) */
189
+ const nextWavePanel: HudPartDef = {
190
+ id: 'campaign.nextWave',
191
+ place: 'top-right',
192
+ mount(element: HTMLElement, client: HudClient) {
193
+ element.className += ' panel px-3 py-2 text-[13px] text-foreground'
194
+ const show = () => {
195
+ const { phase, wave } = client.state.report as { phase?: string; wave?: number }
196
+ const coming = phase === 'waiting' ? siegeWaves(client.session)[(wave ?? 1) - 1] : undefined
197
+ element.style.display = coming ? '' : 'none'
198
+ if (!coming) return
199
+ const { registry } = client.session
200
+ element.textContent = `Next: ${coming.units.map(({ type, count }) => `${count} ${registry.unit(type).name}`).join(', ')}`
201
+ }
202
+ show()
203
+ const timer = setInterval(show, 250)
204
+ return () => clearInterval(timer)
205
+ }
206
+ }
207
+
208
+ export const campaignView: GameView<CampaignUnit, CampaignResource, CampaignMessage> = layFiles(
209
+ {
210
+ files: {
211
+ // its own folder of files (public/games/campaign, with an assets.json saying what is in it): where the editor puts
212
+ // what is taken in for the game
213
+ assets: 'games/campaign'
214
+ },
215
+ cinematics: {
216
+ // the cinematics, by the ids the rules play them by: two made in the editor's Cinematic panel, one in code
217
+ defs: {
218
+ 'campaign.intro': { def: readCinematic(introFile), back: true },
219
+ 'campaign.outro': { def: readCinematic(outroFile), skippable: false },
220
+ [PARLEY]: { play: parley, back: true }
221
+ },
222
+ // a lane of its own the cinematics may have: a wave called in at a moment (see `waveLane.ts`)
223
+ lanes: [waveLane],
224
+ speakers: {
225
+ [ALDRIC]: { name: 'Aldric', color: '#8fb8ff' },
226
+ [BRANN]: { name: 'Sergeant Brann', color: '#d7c38a' },
227
+ [VARGA]: { name: 'Varga', color: '#ff7a5a' }
228
+ }
229
+ },
230
+ // what the screen does as a wave sets out, besides the flare the rules fire: the camera feels it
231
+ on: [viewListener(waveComing, ({ x, z }, client) => client.camera.shake(x, z, 0.3))],
232
+ // the HUD's half of the heroes the rules take (`withHeroes`): the hero's panel, the heroes' portraits, the shop
233
+ packs: [withHeroesView()],
234
+ hud: {
235
+ parts: [nextWavePanel],
236
+ resources: { gold: { chip: 'background: radial-gradient(circle at 35% 30%, #fff3b8, var(--vg-gold) 45%, #9c6f12)' } },
237
+ status,
238
+ messages: {
239
+ objectiveRaze: 'Burn every building of the Red Hand camp',
240
+ objectiveHall: 'Keep the town hall standing',
241
+ objectiveCaravan: 'Find the lost caravan',
242
+ objectiveHold: 'Hold the fort: wave {wave} of {waves} beaten',
243
+ objectiveTowers: 'Lose no tower',
244
+ objectiveWarlord: 'Bring down Varga',
245
+ objectiveHero: 'Aldric must survive',
246
+ objectivePrisoners: 'Free the prisoners in the cells',
247
+ caravanFound: 'The caravan: {gold} gold recovered',
248
+ waveComing: 'Wave {wave} is coming',
249
+ waveBeaten: 'Wave {wave} beaten: {bonus} gold',
250
+ gateShut: 'The gate is shut',
251
+ gateOpen: 'The gate is open',
252
+ checkpointWave: 'wave {wave}',
253
+ heroFell: 'Aldric has fallen.',
254
+ hallFell: 'The town hall has fallen.',
255
+ homeStart: 'My lord, the Red Hand dug in past the woods while you were away. They raid the farms by night.',
256
+ homeCamp: 'Then we burn their camp. Get the barracks working, sergeant. Every man who can hold a sword.',
257
+ homeFord: 'Their tracks cross here. The camp is just beyond the trees: look.',
258
+ homeDone: 'The camp is ash. They will come back for this, and in numbers.',
259
+ siegeStart: 'They are massing in the war camp to the north, my lord. The whole Red Hand, by the look of the fires.',
260
+ siegeGate: 'Shut the gate when they come and open it between waves. Archers on the walls, towers at the corners.',
261
+ siegeHalfway: 'Half of them are down. Hold the line.',
262
+ siegeLast: 'This is the last of them, and the biggest. Everything we have, now.',
263
+ passStart: 'Varga fled up the pass with what is left. His hold is at the top. The prisoners from the villages are up there too.',
264
+ passShop: 'A trader keeps a stall by the road: potions, a better sword, boots. Spend what we took.',
265
+ passPrisoners: 'They are free, my lord. They will fight for you.',
266
+ passDuel: 'Just you and me, Varga.',
267
+ passCharge: 'For the village!'
268
+ },
269
+ outcome: { victory: 'The mission is accomplished.', defeat: 'The mission has failed.' },
270
+ actions: [
271
+ {
272
+ id: 'nextWave',
273
+ label: 'Next wave',
274
+ description: 'Calls the next wave in now, for as much gold as there are seconds left',
275
+ command: nextWave.make({}),
276
+ // only the siege reports waves: in the other missions the button is not there at all
277
+ visible: (report) => report.wave !== undefined,
278
+ enabled: (report) => report.phase === 'waiting'
279
+ }
280
+ ]
281
+ },
282
+ // what it draws of its own with the client's toolkit: kills over the towers, Varga's name, earth thrown up where a
283
+ // brute's axe comes down (labels.ts, rubble.ts)
284
+ renderers: [towerKills, bossName, rubble],
285
+ fx: {
286
+ abilities: {
287
+ // spell styles of its own, by the names the effects' looks give (`effects` above)
288
+ styles: { ...presetSpellStyles(), 'campaign.holyFire': holyFire },
289
+ looks: effects
290
+ },
291
+ // the ready-made effects the maps and the rules use; what the editor's FX panel wrote is laid over them (`fx.json`)
292
+ defs: presetFx(['campfire', 'torch', 'dust', 'puff', 'spark-burst', 'explosion', 'glow'])
293
+ },
294
+ scene: {
295
+ // the ready-made rain and snow, a building's rally flag, the glow over a unit with a buff: the client draws none
296
+ // of them by itself
297
+ weather: presetWeather(),
298
+ // trees and scenery in front of the player's own units faded out
299
+ seeThrough: {}
300
+ },
301
+ units: {
302
+ rallyFlag: presetRallyFlag(),
303
+ buffMarker: presetBuffMarker(),
304
+ // what the editor's model setup wrote is laid over these (`looks.json`)
305
+ looks
306
+ },
307
+ ground: {
308
+ tiles: TILE_LOOKS
309
+ },
310
+ input: {
311
+ keys: {
312
+ actions: {
313
+ add: [
314
+ {
315
+ id: 'campaign.nextWave',
316
+ label: 'Call the next wave in now',
317
+ group: 'The siege',
318
+ default: ['N'],
319
+ run: (client) => client.runAction('nextWave')
320
+ }
321
+ ]
322
+ }
323
+ }
324
+ }
325
+ },
326
+ // the files the editor writes next to the code, laid over what it says above
327
+ [
328
+ [LOOKS_FILE, looksFile],
329
+ [FX_FILE, fxFile]
330
+ ]
331
+ )
332
+
333
+ // saved while it runs in the dev server: the games and editors of the page take its new looks (hot reload)
334
+ if (import.meta.hot) import.meta.hot.accept((module) => hotView(import.meta.hot, 'my-game', campaignView as GameView, module?.campaignView))
@@ -0,0 +1,105 @@
1
+ import { Vector3 } from '@babylonjs/core/Maths/math.vector'
2
+ import { Control } from '@babylonjs/gui/2D/controls/control'
3
+ import { Rectangle } from '@babylonjs/gui/2D/controls/rectangle'
4
+ import { TextBlock } from '@babylonjs/gui/2D/controls/textBlock'
5
+ import { type EntityView, guiLayer, type Pin, type RendererFactory } from '@v12-engine/client'
6
+ import { kills } from '../kills'
7
+ import { PLAYER, UNIT } from '../ids'
8
+
9
+ /*
10
+ * Words over the world, the two ways a view has of putting them there: an element of the page pinned to a tower
11
+ * (`kit.pin`), and a control of Babylon GUI that follows Varga (`guiLayer`)
12
+ */
13
+
14
+ /** How many a tower has killed, as its snapshot says (the `kills` component is sent to everyone) */
15
+ const killsOf = (entity: EntityView) => (entity.state.components?.[kills.id] as number | undefined) ?? 0
16
+
17
+ /**
18
+ * Over each tower, how many of the Red Hand it has killed, once it has: an element of the page pinned to it. The client
19
+ * moves it with the view, hides it when the tower is out of it, and takes it out when the tower is sold
20
+ */
21
+ export const towerKills: RendererFactory = (kit) => {
22
+ const labels = new Map<number, { pin: Pin; count: number }>()
23
+
24
+ return {
25
+ sync(changes) {
26
+ for (const entity of changes.removed) labels.delete(entity.id)
27
+ for (const entity of kit.state.entities.values()) {
28
+ if (entity.owner !== PLAYER || entity.def.id !== UNIT.tower) continue
29
+ const count = killsOf(entity)
30
+ let label = labels.get(entity.id)
31
+ if (!label) {
32
+ if (!count) continue
33
+ const element = document.createElement('div')
34
+ element.className =
35
+ 'rounded-sm bg-[color-mix(in_srgb,var(--vg-shade)_67%,transparent)] px-1.5 text-[12px] leading-[18px] font-bold whitespace-nowrap text-foreground'
36
+ labels.set(entity.id, (label = { pin: kit.pin(element, { entity: entity.id, height: 0.4 }), count: -1 }))
37
+ }
38
+ if (label.count === count) continue
39
+ label.count = count
40
+ label.pin.element.textContent = `☠ ${count}`
41
+ }
42
+ },
43
+ frame() {},
44
+ dispose() {
45
+ for (const { pin } of labels.values()) pin.remove()
46
+ labels.clear()
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Varga's name over him (the warlord of mission three), drawn with Babylon GUI rather than the page: a control in the scene's GUI layer, moved to
53
+ * him every frame
54
+ */
55
+ export const bossName: RendererFactory = (kit) => {
56
+ const plates = new Map<number, Rectangle>()
57
+ const at = new Vector3()
58
+
59
+ const plateFor = (entity: EntityView) => {
60
+ const plate = new Rectangle(`boss ${entity.id}`)
61
+ plate.adaptWidthToChildren = plate.adaptHeightToChildren = true
62
+ plate.thickness = 1
63
+ plate.color = '#b48cff'
64
+ plate.background = '#140a24cc'
65
+ plate.cornerRadius = 3
66
+ plate.verticalAlignment = Control.VERTICAL_ALIGNMENT_TOP
67
+ const name = new TextBlock(`boss name ${entity.id}`, 'Varga')
68
+ name.color = '#e6d7ff'
69
+ name.fontSize = 15
70
+ // the page's display font: the HUD's headings
71
+ name.fontFamily = getComputedStyle(document.documentElement).getPropertyValue('--font-display') || 'serif'
72
+ name.fontWeight = 'bold'
73
+ name.resizeToFit = true
74
+ name.paddingLeft = name.paddingRight = '8px'
75
+ name.paddingTop = name.paddingBottom = '2px'
76
+ plate.addControl(name)
77
+ guiLayer(kit.scene).addControl(plate)
78
+ return plate
79
+ }
80
+
81
+ return {
82
+ sync(changes) {
83
+ for (const entity of changes.added) if (entity.def.id === UNIT.warlord) plates.set(entity.id, plateFor(entity))
84
+ for (const entity of changes.removed) {
85
+ plates.get(entity.id)?.dispose()
86
+ plates.delete(entity.id)
87
+ }
88
+ },
89
+ frame() {
90
+ for (const [id, plate] of plates) {
91
+ const drawn = kit.drawn(id)
92
+ plate.isVisible = !!drawn?.visible
93
+ if (!drawn?.visible) continue
94
+ // over its bars, which are over its head
95
+ at.copyFrom(drawn.position)
96
+ at.y += drawn.visual.height + 1.6
97
+ plate.moveToVector3(at, kit.scene)
98
+ }
99
+ },
100
+ dispose() {
101
+ for (const plate of plates.values()) plate.dispose()
102
+ plates.clear()
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,40 @@
1
+ import type { DebrisPiece, RendererFactory } from '@v12-engine/client'
2
+ import { UNIT } from '../ids'
3
+
4
+ /*
5
+ * What a brute's axe does where it comes down (an attack with `splash`: the `impact` event), on the screen: earth
6
+ * thrown up, and leaves shaken off the trees near it. Pieces with physics (`kit.breakApart`); nothing of it is in the
7
+ * game: the trees stand
8
+ */
9
+
10
+ const EARTH: DebrisPiece[] = [
11
+ { shape: 'box', size: [0.2, 0.14, 0.18], color: [0.42, 0.31, 0.19], count: 7 },
12
+ { shape: 'box', size: [0.12, 0.1, 0.12], color: [0.33, 0.25, 0.16], count: 5 }
13
+ ]
14
+ const LEAVES: DebrisPiece[] = [
15
+ { shape: 'box', size: [0.22, 0.03, 0.18], color: [0.25, 0.5, 0.18], count: 6 },
16
+ { shape: 'box', size: [0.16, 0.03, 0.14], color: [0.36, 0.58, 0.2], count: 4 },
17
+ { shape: 'cylinder', size: [0.05, 0.4, 0.05], color: [0.4, 0.27, 0.15] }
18
+ ]
19
+ /** How far past the blast a tree still loses leaves */
20
+ const SHAKEN = 1.5
21
+
22
+ export const rubble: RendererFactory = (kit) => ({
23
+ sync(changes) {
24
+ for (const event of changes.events) {
25
+ if (event.type !== 'impact') continue
26
+ const { x, z, radius } = event
27
+ kit.breakApart({ x, z }, EARTH, { strength: 0.8, height: 0.2 })
28
+ for (const entity of kit.state.entities.values()) {
29
+ if (entity.def.id !== UNIT.tree || !entity.inSight) continue
30
+ const dx = entity.pos.x - x
31
+ const dz = entity.pos.z - z
32
+ if (dx * dx + dz * dz > (radius + SHAKEN) ** 2) continue
33
+ // from up in the tree, blown away from the blast
34
+ kit.breakApart(entity.pos, LEAVES, { from: { x, z }, strength: 0.8, height: kit.visualOf(entity).height })
35
+ }
36
+ }
37
+ },
38
+ frame() {},
39
+ dispose() {}
40
+ })
@@ -0,0 +1,88 @@
1
+ import { type Clip, defineLane } from '@v12-engine/client'
2
+ import { CreateTorus } from '@babylonjs/core/Meshes/Builders/torusBuilder.js'
3
+ import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
4
+ import { Color3 } from '@babylonjs/core/Maths/math.color.js'
5
+ import { createEffect, createRoot } from 'solid-js'
6
+ import { builtWaves, nextWave, WAR_CAMP } from '../waves'
7
+
8
+ /*
9
+ * A lane of the campaign's own in its cinematics: a wave called in at a moment of a cinematic, as the player's
10
+ * Next wave button does (the `nextWave` command, so the rules decide, and every screen and the replay agree). Made only
11
+ * of what `@v12-engine/client` gives a game (`defineLane`), the way the client's own lanes are made, it is a lane like
12
+ * theirs: the game plays it from a cinematic's file (`GameView.cinematics.lanes`), and the editor's Cinematic panel draws it on
13
+ * the timeline in its colour, puts its fields in the Inspector with what the wave brings under them, draws where the
14
+ * waves come out on the map while it is looked at, and says in the Problems panel a wave the game has not
15
+ * (`campaignEditor`'s `lanes`).
16
+ */
17
+
18
+ /** An item of the lane: the wave it calls in, by its number (1 the first), for the check and the label; the next one when left out */
19
+ export interface WaveClip extends Clip {
20
+ wave?: number
21
+ }
22
+
23
+ /** The lane's colour on the timeline, and of the ring where the waves come in */
24
+ const COLOR = '#d9a441'
25
+
26
+ export const waveLane = defineLane<WaveClip>({
27
+ kind: 'campaign.wave',
28
+ name: 'Waves',
29
+ read(item, what) {
30
+ if (item.wave !== undefined && (typeof item.wave !== 'number' || item.wave < 1)) throw new Error(`${what}.wave is a wave's number, from 1`)
31
+ return item as WaveClip
32
+ },
33
+ // a moment, not a stretch: a wave called in is not called again by a seek into it
34
+ length: () => 0,
35
+ run(cut, _item, from) {
36
+ if (from > 0) return
37
+ cut.stage.send?.(nextWave.make({}))
38
+ },
39
+ editor: {
40
+ color: COLOR,
41
+ // a moment on the timeline, as a diamond's place: a new one lasts nothing
42
+ moment: true,
43
+ icon: 'campaign.wave',
44
+ hint: 'Calls the next wave in at a moment of the cinematic, as the Next wave button does: the game decides, on every screen',
45
+ fields: [
46
+ { key: 'wave', kind: 'number', label: 'Wave', optional: true, step: 1, min: 1, hint: 'The wave it calls in, for the label and the check' }
47
+ ],
48
+ label: (item) => (item.wave ? `wave ${item.wave}` : 'next wave'),
49
+ check: (item) => (item.wave && item.wave > builtWaves().length ? `the siege has ${builtWaves().length} waves, not ${item.wave}` : undefined),
50
+ // where the waves come out: a ring at the war camp, a point of the fort's map, bright for one selected
51
+ draw(item, at) {
52
+ const entry = at.point(WAR_CAMP)
53
+ if (!entry) return
54
+ const ring = CreateTorus(
55
+ `campaign.wave.${item.at}`,
56
+ { diameter: at.selected ? 5 : 4, thickness: at.selected ? 0.35 : 0.2, tessellation: 32 },
57
+ at.scene
58
+ )
59
+ ring.position.set(entry.x, at.heightAt(entry.x, entry.z) + 0.4, entry.z)
60
+ const paint = new StandardMaterial('campaign.wave.ring', at.scene)
61
+ paint.disableLighting = true
62
+ paint.emissiveColor = at.selected ? new Color3(1, 1, 1) : Color3.FromHexString(COLOR)
63
+ ring.material = paint
64
+ ring.parent = at.root
65
+ },
66
+ // under its fields: what the wave brings, as the waves panel says it
67
+ inspector: {
68
+ mount(element, props) {
69
+ return createRoot((dispose) => {
70
+ const text = document.createElement('p')
71
+ text.className = 'text-[12px] text-muted-foreground'
72
+ element.append(text)
73
+ createEffect(() => {
74
+ const waves = props.items.map((item) => item.wave)
75
+ const wave = waves.length === 1 && waves[0] ? builtWaves()[waves[0] - 1] : undefined
76
+ text.textContent = wave
77
+ ? `Brings ${wave.units.map((unit) => `${unit.count} ${unit.type}`).join(', ')}, one every ${wave.interval} s`
78
+ : 'Brings whichever wave is next'
79
+ })
80
+ return () => {
81
+ text.remove()
82
+ dispose()
83
+ }
84
+ })
85
+ }
86
+ }
87
+ }
88
+ })
@@ -0,0 +1,36 @@
1
+ [
2
+ { "units": [{ "type": "raider", "count": 4 }], "interval": 1.5, "bonus": 60 },
3
+ {
4
+ "units": [
5
+ { "type": "raider", "count": 5 },
6
+ { "type": "warlock", "count": 2 }
7
+ ],
8
+ "interval": 1.2,
9
+ "bonus": 80
10
+ },
11
+ {
12
+ "units": [
13
+ { "type": "raider", "count": 6 },
14
+ { "type": "brute", "count": 1 }
15
+ ],
16
+ "interval": 1.2,
17
+ "bonus": 100
18
+ },
19
+ {
20
+ "units": [
21
+ { "type": "warlock", "count": 4 },
22
+ { "type": "raider", "count": 4 }
23
+ ],
24
+ "interval": 1,
25
+ "bonus": 120
26
+ },
27
+ {
28
+ "units": [
29
+ { "type": "brute", "count": 2 },
30
+ { "type": "raider", "count": 8 },
31
+ { "type": "warlock", "count": 3 }
32
+ ],
33
+ "interval": 0.8,
34
+ "bonus": 200
35
+ }
36
+ ]