@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,319 @@
1
+ /**
2
+ * The Bay: melee's own map, made by a generator and always the same (master plan FG2). A sea in the middle with the
3
+ * shore all round it, four starts in the corners on high ground, each with its mine and woods behind, more mines along
4
+ * the shore guarded by camps of rising strength, an island in the middle with an alchemist on it reached by fords of
5
+ * shallow water, roads between the starts, the map mirrored both ways so that each corner is the others'. Made of the
6
+ * engine's passes (`basinShape` for the lie of the land) and melee's (`meleeGenerator`'s mines, camps and woods);
7
+ * `bayMap()` is the map itself, which `npm run map generate` writes to `maps/bay.vgmap.json` for the editor
8
+ */
9
+ import {
10
+ basinShape,
11
+ clearGround,
12
+ createRegistry,
13
+ exactMath,
14
+ generateMap,
15
+ type GameMap,
16
+ type GeneratorContext,
17
+ MAP_NEUTRAL,
18
+ type MapGenerator,
19
+ resourcesPass,
20
+ roadsPass,
21
+ scatterPass,
22
+ startsPass,
23
+ terrainPass,
24
+ tilesPass,
25
+ type Vec2,
26
+ WATER_DEEP,
27
+ WATER_NONE,
28
+ WATER_SHALLOW
29
+ } from '@v12-engine/engine'
30
+ import { melee } from './index'
31
+ import { meleeGenerator } from './generator'
32
+
33
+ /** The island is this much of the way from the middle to a corner */
34
+ const ISLAND = 0.16
35
+
36
+ /** Who guards an expansion's mine, by how far from the starts it stands: the farther, the stronger */
37
+ const GUARDS: string[][] = [['gnoll', 'gnoll'], ['gnoll', 'gnoll', 'gnoll'], ['ogre'], ['ogre', 'gnoll', 'gnoll']]
38
+
39
+ /** Who holds the island */
40
+ const ISLAND_GUARD = ['ogre', 'ogre', 'troll']
41
+
42
+ /** A place rounded to the whole cell corner away from the map's middle: the same corner on every mirrored side */
43
+ const awayFromMiddle = (v: number, size: number): number => (v < size / 2 ? Math.floor(v) : Math.ceil(v))
44
+
45
+ /** How far a point is from the nearest start, in cells */
46
+ function fromStarts(ctx: GeneratorContext, at: Vec2): number {
47
+ let near = Infinity
48
+ for (const player of ctx.map.players) if (player.start) near = Math.min(near, exactMath.hypot(player.start.x - at.x, player.start.z - at.z))
49
+ return near
50
+ }
51
+
52
+ /**
53
+ * The island's fords: shallow water from the island's rim towards each corner, as wide as three cells, so that what
54
+ * walks reaches the alchemist. One ford, mirrored to the other three
55
+ */
56
+ const fordsPass = {
57
+ id: 'fords',
58
+ name: 'The fords',
59
+ run(ctx: GeneratorContext) {
60
+ const { map } = ctx
61
+ const { width, depth } = map
62
+ const middle = { x: width / 2, z: depth / 2 }
63
+ const reach = (Math.min(width, depth) / 2) * Math.SQRT2 * 0.55
64
+ for (let t = 0; t < reach; t += 0.5) {
65
+ const x = middle.x - t * Math.SQRT1_2
66
+ const z = middle.z - t * Math.SQRT1_2
67
+ for (const place of ctx.symmetric(x, z))
68
+ for (let dz = -1; dz <= 1; dz++)
69
+ for (let dx = -1; dx <= 1; dx++) {
70
+ const cx = Math.floor(place.x) + dx
71
+ const cz = Math.floor(place.z) + dz
72
+ if (cx < 0 || cz < 0 || cx >= width || cz >= depth) continue
73
+ const c = cz * width + cx
74
+ if (map.water[c] === WATER_DEEP) map.water[c] = WATER_SHALLOW
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The woods behind each start: between the start and its corner, thick, for lumber from the first minute. One start's,
82
+ * mirrored to the others
83
+ */
84
+ const woodsBehindPass = {
85
+ id: 'woods',
86
+ name: 'The woods behind the starts',
87
+ run(ctx: GeneratorContext) {
88
+ const { map, rng } = ctx
89
+ const start = map.players.find((player) => player.start)?.start
90
+ if (!start) return
91
+ // away from the middle: the way to the corner
92
+ const away = exactMath.atan2(start.z - map.depth / 2, start.x - map.width / 2)
93
+ const put = new Set<number>()
94
+ for (let i = 0; i < 400; i++) {
95
+ const far = 9 + rng.next() * 9
96
+ const turn = away + (rng.next() - 0.5) * Math.PI * 0.9
97
+ const x = Math.floor(start.x + exactMath.cos(turn) * far)
98
+ const z = Math.floor(start.z + exactMath.sin(turn) * far)
99
+ if (x < 1 || z < 1 || x >= map.width - 1 || z >= map.depth - 1) continue
100
+ const c = z * map.width + x
101
+ if (put.has(c) || map.water[c] !== 0 || !ctx.free(x + 0.5, z + 0.5, 4, ['resource', 'start'])) continue
102
+ put.add(c)
103
+ for (const place of ctx.symmetric(x + 0.5, z + 0.5)) {
104
+ ctx.mark('trees', place.x, place.z, 0.75)
105
+ ctx.addObject('tree', MAP_NEUTRAL, Math.floor(place.x) + 0.5, Math.floor(place.z) + 0.5)
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ /** The alchemist on the island, with its guards, and the island marked so that nothing else goes there */
112
+ const islandPass = {
113
+ id: 'island',
114
+ name: 'The island',
115
+ run(ctx: GeneratorContext) {
116
+ const { map } = ctx
117
+ const at = { x: Math.round(map.width / 2), z: Math.round(map.depth / 2) }
118
+ ctx.mark('island', at.x, at.z, 10)
119
+ ctx.addObject('alchemist', MAP_NEUTRAL, at.x, at.z, { name: 'the island alchemist' })
120
+ ctx.addCamp(at.x + 4.5, at.z - 4.5, ISLAND_GUARD, { name: 'the island guard' })
121
+ }
122
+ }
123
+
124
+ /** A tavern on the island beside the alchemist, its heroes for whoever crosses: put down last, so that what was there keeps its ids */
125
+ const tavernPass = {
126
+ id: 'tavern',
127
+ name: 'The tavern',
128
+ run(ctx: GeneratorContext) {
129
+ const { map } = ctx
130
+ ctx.addObject('tavern', MAP_NEUTRAL, Math.round(map.width / 2) - 4.5, Math.round(map.depth / 2) + 4.5, { name: 'the island tavern' })
131
+ }
132
+ }
133
+
134
+ /**
135
+ * A goblin shipyard on the shore of each side's part of the bay, its transport ships for whoever comes: on dry land
136
+ * free of everything, with deep water a few cells off, the nearest such to a place between the start and the island;
137
+ * mirrored to the other three. Put down last, as the tavern is
138
+ */
139
+ const shipyardsPass = {
140
+ id: 'shipyards',
141
+ name: 'The goblin shipyards',
142
+ run(ctx: GeneratorContext) {
143
+ const { map } = ctx
144
+ const { width, depth } = map
145
+ const dry = (x: number, z: number) => x >= 0 && z >= 0 && x < width && z < depth && map.water[z * width + x] === WATER_NONE
146
+ const deepNear = (x: number, z: number) => {
147
+ for (let dz = -4; dz <= 4; dz++)
148
+ for (let dx = -4; dx <= 4; dx++) {
149
+ const cx = x + dx
150
+ const cz = z + dz
151
+ if (cx >= 0 && cz >= 0 && cx < width && cz < depth && map.water[cz * width + cx] === WATER_DEEP) return true
152
+ }
153
+ return false
154
+ }
155
+ const wanted = { x: width * 0.2, z: depth * 0.38 }
156
+ let best: { x: number; z: number } | undefined
157
+ let nearest = Infinity
158
+ for (let z = 2; z < depth / 2; z++)
159
+ for (let x = 2; x < width / 2; x++) {
160
+ let fits = true
161
+ for (let dz = -1; dz <= 1 && fits; dz++) for (let dx = -1; dx <= 1 && fits; dx++) fits = dry(x + dx, z + dz)
162
+ if (!fits || !deepNear(x, z) || !ctx.free(x + 0.5, z + 0.5, 3)) continue
163
+ const far = exactMath.hypot(x + 0.5 - wanted.x, z + 0.5 - wanted.z)
164
+ if (far < nearest) {
165
+ nearest = far
166
+ best = { x: x + 0.5, z: z + 0.5 }
167
+ }
168
+ }
169
+ if (!best) return
170
+ for (const place of ctx.symmetric(best.x, best.z)) ctx.addObject('goblinShipyard', MAP_NEUTRAL, place.x, place.z)
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The mines of the bay: one by each start, the same way from each (mirrored from the first, so the walk to it is the
176
+ * same for everyone), and more along the shore, each guarded by a camp the stronger the farther from a start
177
+ */
178
+ const minesPass = {
179
+ id: 'mines',
180
+ name: 'The gold mines',
181
+ run(ctx: GeneratorContext) {
182
+ const { map } = ctx
183
+ const first = map.players.find((player) => player.start)?.start
184
+ if (first) {
185
+ // towards the middle and to one side of the way down (the ramp of `rampsDownPass` goes straight), on the start's
186
+ // level: its workers walk to it, and nothing stands in the way to the shore
187
+ const toward = exactMath.atan2(map.depth / 2 - first.z, map.width / 2 - first.x)
188
+ const aside = toward - Math.PI / 2
189
+ const at = {
190
+ x: Math.round(first.x + exactMath.cos(toward) * 7 + exactMath.cos(aside) * 7),
191
+ z: Math.round(first.z + exactMath.sin(toward) * 7 + exactMath.sin(aside) * 7)
192
+ }
193
+ for (const place of ctx.symmetric(at.x, at.z)) {
194
+ const mine = { x: Math.round(place.x), z: Math.round(place.z) }
195
+ const start = map.players.find((player) => player.start && exactMath.hypot(player.start.x - mine.x, player.start.z - mine.z) < 14)?.start
196
+ clearGround(ctx, mine, 3, true, start ? ctx.levelAt(start.x, start.z) : undefined)
197
+ ctx.mark('resource', mine.x, mine.z, 3)
198
+ ctx.addObject('goldmine', MAP_NEUTRAL, mine.x, mine.z)
199
+ }
200
+ }
201
+ resourcesPass({
202
+ main: false,
203
+ extra: ctx.param('mines', 2),
204
+ spacing: 22,
205
+ from: (ctx, at) => {
206
+ // a mine stands on whole cells (4 by 4), its middle on a corner of them: the corner of the cell away from the map's
207
+ // middle, whose mirror is the mirrored cell's own (a half rounded up on both sides of the map is no mirror)
208
+ const mine = { x: awayFromMiddle(at.x, ctx.map.width), z: awayFromMiddle(at.z, ctx.map.depth) }
209
+ ctx.addObject('goldmine', MAP_NEUTRAL, mine.x, mine.z)
210
+ const far = fromStarts(ctx, at)
211
+ const guards = GUARDS[Math.min(GUARDS.length - 1, Math.floor((far / Math.max(ctx.map.width, ctx.map.depth)) * 8))]
212
+ // guarded from the side of the sea, four whole cells towards the middle each way: the same side of every
213
+ // mirrored one, and whole cells so that the mirrors are exact
214
+ const guard = { x: mine.x + Math.sign(ctx.map.width / 2 - mine.x) * 4, z: mine.z + Math.sign(ctx.map.depth / 2 - mine.z) * 4 }
215
+ ctx.mark('camps', guard.x, guard.z, 3)
216
+ ctx.addCamp(guard.x, guard.z, guards)
217
+ }
218
+ }).run(ctx)
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The way down to the bay: from each start towards the middle, where the ground first drops a level, a ramp four
224
+ * cells wide, so that every base has its own way to the shore (the engine's ramps join what it can across straight
225
+ * stretches of cliff, and a shore this rough has few). One start's, mirrored to the others
226
+ */
227
+ const rampsDownPass = {
228
+ id: 'rampsDown',
229
+ name: 'The ways down',
230
+ run(ctx: GeneratorContext) {
231
+ const { map } = ctx
232
+ const { width } = map
233
+ const first = map.players.find((player) => player.start)?.start
234
+ if (!first) return
235
+ const toward = exactMath.atan2(map.depth / 2 - first.z, map.width / 2 - first.x)
236
+ const dx = exactMath.cos(toward)
237
+ const dz = exactMath.sin(toward)
238
+ let level = ctx.levelAt(first.x, first.z)
239
+ for (let t = 0; t < 60; t += 1) {
240
+ const x = first.x + dx * t
241
+ const z = first.z + dz * t
242
+ const here = ctx.levelAt(x, z)
243
+ if (here === level) continue
244
+ if (here !== level - 1) break
245
+ // the edge is between t - 1 and t: a ramp of the cells two deep either side, four wide across the way
246
+ for (let along = -2; along < 2; along++)
247
+ for (let deep = -2; deep < 2; deep++) {
248
+ const cx = Math.floor(x - dz * along + dx * deep)
249
+ const cz = Math.floor(z + dx * along + dz * deep)
250
+ for (const place of ctx.symmetric(cx + 0.5, cz + 0.5)) {
251
+ const c = Math.floor(place.z) * width + Math.floor(place.x)
252
+ if (c >= 0 && c < map.ramps.length && map.water[c] === 0) map.ramps[c] = 1
253
+ }
254
+ }
255
+ level = here
256
+ if (here === 0) break
257
+ }
258
+ }
259
+ }
260
+
261
+ /** Roads along the shore: from each start to the next round the bay, not across it */
262
+ const roadsAroundPass = {
263
+ id: 'roads',
264
+ name: 'The roads',
265
+ run(ctx: GeneratorContext) {
266
+ const { map } = ctx
267
+ const starts = map.players.flatMap((player) => (player.start ? [player.start] : []))
268
+ const around = [...starts].sort(
269
+ (a, b) => exactMath.atan2(a.z - map.depth / 2, a.x - map.width / 2) - exactMath.atan2(b.z - map.depth / 2, b.x - map.width / 2)
270
+ )
271
+ for (let i = 0; i < around.length; i++) roadsPass({ tile: 'road', width: 2, between: [around[i], around[(i + 1) % around.length]] }).run(ctx)
272
+ }
273
+ }
274
+
275
+ /** The generator of the bay: the parameters of melee's own but the ground's, which the bay's shape decides */
276
+ export const bayGenerator: MapGenerator = {
277
+ params: meleeGenerator.params.filter((param) => !['levels', 'water', 'roughness', 'hills'].includes(param.id)),
278
+ passes: [
279
+ terrainPass({ shape: basinShape({ island: ISLAND, roughness: 0.4 }), levels: 3, water: 0.2, roughness: 0.3, hills: 40, rampWidth: 4 }),
280
+ tilesPass(),
281
+ fordsPass,
282
+ // room for a hall and its workers in each corner: the race puts them there as the game starts
283
+ startsPass({ clearing: 8, spread: 0.46 }),
284
+ islandPass,
285
+ minesPass,
286
+ rampsDownPass,
287
+ woodsBehindPass,
288
+ // camps about the map: on the land, away from the starts, off the mines and each other
289
+ scatterPass({
290
+ kind: 'camps',
291
+ spacing: 24,
292
+ avoid: 28,
293
+ patches: 0,
294
+ avoidKinds: { resource: 8, camps: 20, island: 12 },
295
+ where: (cell) => !cell.shore,
296
+ // who guards it by how far out it stands, not by a draw: a draw is made again for each mirrored one, and differs
297
+ what: (ctx, at) => ctx.addCamp(at.x, at.z, GUARDS[fromStarts(ctx, at) > 40 ? 1 : 0])
298
+ }),
299
+ // woods: trees to cut, thick behind the starts, off the mines and the camps and not on the island
300
+ scatterPass({
301
+ kind: 'trees',
302
+ spacing: 1.5,
303
+ avoid: 12,
304
+ avoidKinds: { resource: 5, camps: 4, island: 12, trees: 1 },
305
+ what: (ctx, at) => void ctx.addObject('tree', MAP_NEUTRAL, Math.floor(at.x) + 0.5, Math.floor(at.z) + 0.5)
306
+ }),
307
+ roadsAroundPass,
308
+ tavernPass,
309
+ shipyardsPass
310
+ ]
311
+ }
312
+
313
+ /** How the bay is made: the same every time */
314
+ export const BAY = { seed: 7, width: 192, depth: 192, players: 4, symmetry: 'both', name: 'The Bay', params: { mines: 2, trees: 0.2 } } as const
315
+
316
+ /** The Bay, made anew: what `maps/bay.vgmap.json` holds, and what the editor opens */
317
+ export function bayMap(): GameMap {
318
+ return generateMap(bayGenerator, { ...BAY }, createRegistry(melee)).map
319
+ }
@@ -0,0 +1,102 @@
1
+ import { defineComponent, presetBlight, defineHook, type Entity, type RaceHookDef, raceOf, type SystemDef, type World, deposit } from '@v12-engine/engine'
2
+
3
+ /*
4
+ * How the Restless plays differently (features-plan C5), in hooks of its race and a system of the game's: its acolytes
5
+ * gather gold only at a cursed mine of their own, standing round it (the engine's `builtOn`), and its buildings go
6
+ * up on blight, which spreads from its halls and cursed mines and heals its units that stand on it
7
+ */
8
+
9
+ /**
10
+ * Ground the Restless has spoiled: the engine's blight layer (`presetBlight`), painted as the game goes (and on a map,
11
+ * by its maker), drawn with the client's `blightLook`. What it does is melee's: the undead build on it, and heal on it
12
+ */
13
+ export const blightLayer = presetBlight
14
+
15
+ /** What spreads blight round it, once it stands finished: the halls and the cursed mine */
16
+ const SOURCES = new Set(['gravehold', 'hallOfBones', 'duskCitadel', 'cursedMine'])
17
+ /** What goes up anywhere: what blight comes from */
18
+ const ANYWHERE = SOURCES
19
+ /** How far blight goes from what spreads it, in cells, and how much further it goes each time the system runs */
20
+ export const BLIGHT_RADIUS = 9
21
+ const SPREAD = 1
22
+ /** Health a second the undead get on blight */
23
+ const HEALING = 2
24
+ /** Ticks between two runs of the system */
25
+ const EVERY = 30
26
+
27
+ /** How far blight has spread from each of what spreads it, by id: kept by a save, it goes on spreading where it was */
28
+ export const blightSpread = defineComponent<Record<string, number>>('melee.blight', { default: () => ({}) })
29
+
30
+ /** Whether the ground at a point is blighted */
31
+ export const blighted = (world: World, x: number, z: number): boolean => world.terrain.cellAt('blight', x, z) > 0
32
+
33
+ /** Whether the whole ground of a building's site is blighted: its corners and its middle */
34
+ function siteBlighted(world: World, site: { x: number; z: number; hw: number; hd: number }): boolean {
35
+ const inset = 0.5
36
+ return [
37
+ [site.x, site.z],
38
+ [site.x - site.hw + inset, site.z - site.hd + inset],
39
+ [site.x + site.hw - inset, site.z - site.hd + inset],
40
+ [site.x - site.hw + inset, site.z + site.hd - inset],
41
+ [site.x + site.hw - inset, site.z + site.hd - inset]
42
+ ].every(([x, z]) => blighted(world, x, z))
43
+ }
44
+
45
+ /** What the Restless does its own way: given to its race (`RaceDef.hooks`), run only for what is of its players */
46
+ export const undeadHooks: RaceHookDef[] = [
47
+ // gold only at a cursed mine of their own, finished: there they gather round it, and hand in on the spot
48
+ defineHook({
49
+ hook: 'canHarvest',
50
+ id: 'undead.hauntedGold',
51
+ run(world, event) {
52
+ if (!event.allowed || event.source.def.resource?.kind !== 'gold') return
53
+ const haunted = world.get(event.source.get(deposit)?.under)
54
+ if (haunted?.alive && haunted.operational && haunted.owner === event.worker.owner) return
55
+ event.allowed = false
56
+ event.reason = { key: 'needsHauntedMine' }
57
+ }
58
+ }),
59
+ // its buildings on blight, but what blight comes from
60
+ defineHook({
61
+ hook: 'canPlace',
62
+ id: 'undead.onBlight',
63
+ run(world, event) {
64
+ if (!event.allowed || event.def.kind !== 'building' || ANYWHERE.has(event.def.id)) return
65
+ if (siteBlighted(world, event.site)) return
66
+ event.allowed = false
67
+ event.reason = { key: 'needsBlight' }
68
+ }
69
+ })
70
+ ]
71
+
72
+ /**
73
+ * Blight spreading from what the undead have finished of what spreads it, a cell further each time up to
74
+ * `BLIGHT_RADIUS`, and the undead on it healed. Blight stays where it spread: only the map's maker takes it away
75
+ */
76
+ export const blightSystem: SystemDef = {
77
+ name: 'melee.blight',
78
+ every: EVERY,
79
+ update(world, dt) {
80
+ // asked for only where something spreads blight: a game with no undead keeps nothing of it
81
+ let radii: Record<string, number> | undefined
82
+ let grew = false
83
+ for (const entity of world.actors.values()) {
84
+ if (!entity.alive || entity.hidden || raceOf(world, entity.owner) !== 'undead') continue
85
+ if (entity.isUnit) heal(world, entity, dt)
86
+ else if (SOURCES.has(entity.def.id) && entity.operational) {
87
+ radii ??= world.state(blightSpread)
88
+ const radius = Math.min((radii[entity.id] ?? 0) + SPREAD, BLIGHT_RADIUS)
89
+ if (radius === radii[entity.id]) continue
90
+ radii[entity.id] = radius
91
+ grew = true
92
+ world.setCells('blight', { kind: 'circle', x: entity.pos.x, z: entity.pos.z, radius: radius + entity.def.radius }, 1)
93
+ }
94
+ }
95
+ if (grew && radii) world.setState(blightSpread, radii)
96
+ }
97
+ }
98
+
99
+ function heal(world: World, unit: Entity, dt: number): void {
100
+ if (unit.hp >= unit.maxHp || !blighted(world, unit.pos.x, unit.pos.z)) return
101
+ world.heal(unit, HEALING * dt)
102
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "bush": {
3
+ "height": 2.9,
4
+ "density": 8,
5
+ "sway": 3,
6
+ "size": [
7
+ 1.2,
8
+ 1.6
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "rules": {
3
+ "combat": {
4
+ "damageTable": {
5
+ "attacks": ["normal", "pierce", "siege", "magic", "chaos", "hero"],
6
+ "armours": ["light", "medium", "heavy", "fortified", "hero", "unarmored"],
7
+ "table": [
8
+ [1, 1.5, 1, 0.7, 1, 1],
9
+ [2, 0.75, 1, 0.35, 0.5, 1.5],
10
+ [1, 0.5, 1, 1.5, 0.5, 1.5],
11
+ [1.25, 0.75, 2, 0.35, 0.5, 1],
12
+ [1, 1, 1, 1, 1, 1],
13
+ [1, 1, 1, 0.5, 1, 1]
14
+ ]
15
+ }
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,199 @@
1
+ {
2
+ "bounty": {
3
+ "text": "+{amount}",
4
+ "style": {
5
+ "color": "#ffd23c",
6
+ "size": 18,
7
+ "stroke": {
8
+ "color": "#1c1206",
9
+ "width": 3
10
+ }
11
+ },
12
+ "motion": [
13
+ {
14
+ "rise": 1.5,
15
+ "ease": "outQuad"
16
+ },
17
+ {
18
+ "pop": {
19
+ "from": 1.5,
20
+ "seconds": 0.2,
21
+ "ease": "outBack"
22
+ }
23
+ }
24
+ ],
25
+ "seconds": 1.4
26
+ },
27
+ "crit": {
28
+ "text": "{amount}!",
29
+ "style": {
30
+ "color": "#ffb02e",
31
+ "size": 24,
32
+ "stroke": {
33
+ "color": "#1c1206",
34
+ "width": 3
35
+ }
36
+ },
37
+ "motion": [
38
+ {
39
+ "rise": 1.4,
40
+ "ease": "outQuad"
41
+ },
42
+ {
43
+ "pop": {
44
+ "from": 2.2,
45
+ "seconds": 0.25,
46
+ "ease": "outBack"
47
+ }
48
+ },
49
+ {
50
+ "shake": {
51
+ "amount": 3,
52
+ "seconds": 0.2
53
+ }
54
+ }
55
+ ],
56
+ "seconds": 1.5,
57
+ "most": 30,
58
+ "within": 60
59
+ },
60
+ "damageTaken": {
61
+ "text": "-{amount}",
62
+ "style": {
63
+ "color": "#ff5040",
64
+ "stroke": {
65
+ "color": "#1c1206",
66
+ "width": 3
67
+ }
68
+ },
69
+ "merge": {
70
+ "window": 0.8,
71
+ "countUp": 0.25,
72
+ "pulse": {
73
+ "pop": {
74
+ "from": 1.3,
75
+ "seconds": 0.15
76
+ }
77
+ }
78
+ },
79
+ "motion": [
80
+ {
81
+ "drift": {
82
+ "x": -26,
83
+ "up": 1
84
+ },
85
+ "ease": "outQuad"
86
+ }
87
+ ],
88
+ "seconds": 1.1,
89
+ "most": 60,
90
+ "within": 60
91
+ },
92
+ "heal": {
93
+ "text": "+{amount}",
94
+ "style": {
95
+ "color": "#7dff6a",
96
+ "stroke": {
97
+ "color": "#1c1206",
98
+ "width": 3
99
+ }
100
+ },
101
+ "merge": {
102
+ "key": "entity+kind",
103
+ "window": 1,
104
+ "countUp": 0.3
105
+ },
106
+ "motion": [
107
+ {
108
+ "rise": 1,
109
+ "ease": "outQuad"
110
+ }
111
+ ],
112
+ "seconds": 1.2,
113
+ "within": 60
114
+ },
115
+ "levelUp": {
116
+ "text": "Level {amount}",
117
+ "style": {
118
+ "color": "#ffd23c",
119
+ "size": 22,
120
+ "stroke": {
121
+ "color": "#1c1206",
122
+ "width": 3
123
+ },
124
+ "background": {
125
+ "color": "rgba(28, 18, 6, 0.78)",
126
+ "padding": 5,
127
+ "radius": 4
128
+ }
129
+ },
130
+ "motion": [
131
+ {
132
+ "bounce": {
133
+ "height": 0.6,
134
+ "times": 3,
135
+ "seconds": 1.2
136
+ }
137
+ },
138
+ {
139
+ "pop": {
140
+ "from": 0.3,
141
+ "seconds": 0.3,
142
+ "ease": "outBack"
143
+ }
144
+ }
145
+ ],
146
+ "offset": {
147
+ "y": -46
148
+ },
149
+ "seconds": 2.4
150
+ },
151
+ "mana": {
152
+ "text": "{+amount}",
153
+ "style": {
154
+ "color": "#6fb6ff",
155
+ "size": 15,
156
+ "stroke": {
157
+ "color": "#1c1206",
158
+ "width": 3
159
+ }
160
+ },
161
+ "at": "chest",
162
+ "motion": [
163
+ {
164
+ "drift": {
165
+ "x": 22,
166
+ "up": 0.8
167
+ },
168
+ "ease": "outQuad"
169
+ }
170
+ ],
171
+ "seconds": 1.2
172
+ },
173
+ "xp": {
174
+ "text": "+{amount} xp",
175
+ "style": {
176
+ "color": "#c99cff",
177
+ "size": 15,
178
+ "stroke": {
179
+ "color": "#1c1206",
180
+ "width": 3
181
+ }
182
+ },
183
+ "merge": {
184
+ "key": "entity+kind",
185
+ "window": 1.5,
186
+ "countUp": 0.4
187
+ },
188
+ "motion": [
189
+ {
190
+ "rise": 0.9,
191
+ "ease": "outQuad"
192
+ }
193
+ ],
194
+ "offset": {
195
+ "y": -40
196
+ },
197
+ "seconds": 1.6
198
+ }
199
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Melee's own formation, beside the engine's (`rows`, `circle`, `square`): the classic battle line, the fighters in front, those
3
+ * who shoot behind them and the casters (healers, those with no attack) at the back. Picked by the formation button
4
+ * beside the minimap, for every order to a point from then on
5
+ */
6
+ import type { Entity, EntityId, FormationDef, Vec2, World } from '@v12-engine/engine'
7
+ import { statsOf } from '@v12-engine/engine'
8
+
9
+ /**
10
+ * Who stands in which row: 0 at the front for those who fight at arm's length, 1 for those who shoot, 2 for casters (those
11
+ * with mana: priests, sorceresses) and those with no attack
12
+ */
13
+ function rowOf(world: World, unit: Entity): number {
14
+ if (!unit.def.attacks?.length || (unit.def.stats.mana ?? 0) > 0) return 2
15
+ const reach = statsOf(world, unit).attackRange ?? 0
16
+ return reach <= 1.5 ? 0 : 1
17
+ }
18
+
19
+ /**
20
+ * The battle line: a row of each kind across the way they go, as wide as needs be, the front row the fighters', each
21
+ * row's units in the order they stand side by side so that their ways do not cross
22
+ *
23
+ * @beta
24
+ */
25
+ export const battleLine: FormationDef = {
26
+ id: 'battleLine',
27
+ name: 'Battle line',
28
+ points(context) {
29
+ const { world, units, centre, right, spacing, snap } = context
30
+ const rows: Entity[][] = [[], [], []]
31
+ for (const unit of units) rows[rowOf(world, unit)].push(unit)
32
+ const filled = rows.filter((row) => row.length)
33
+ const sideways = (unit: Entity) => (unit.pos.x - centre.x) * right.x + (unit.pos.z - centre.z) * right.z
34
+ const points = new Map<EntityId, Vec2>()
35
+ filled.forEach((row, index) => {
36
+ // the rows a step and a half apart: room to turn and to shoot past those in front
37
+ const back = (index - (filled.length - 1) / 2) * spacing * 1.5
38
+ const members = [...row].sort((a, b) => sideways(a) - sideways(b) || a.id - b.id)
39
+ members.forEach((unit, i) => points.set(unit.id, snap(context.place((i - (members.length - 1) / 2) * spacing, -back))))
40
+ })
41
+ return points
42
+ }
43
+ }
44
+
45
+ /** Melee's formations, beside the engine's */
46
+ export const formations: FormationDef[] = [battleLine]