@v12-engine/create 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +26 -2
- package/base/_gitignore +10 -0
- package/base/index.html +20 -0
- package/base/package.json +23 -0
- package/base/src/ground.worker.ts +8 -0
- package/base/src/main.ts +61 -0
- package/base/tsconfig.json +14 -0
- package/base/vite.config.ts +25 -0
- package/bin.mjs +10 -0
- package/cli.mjs +381 -0
- package/index.mjs +286 -0
- package/layers/assets/package.json +5 -0
- package/layers/editor/package.json +8 -0
- package/layers/editor/src/editor.ts +13 -0
- package/layers/rooms/server.ts +69 -0
- package/layers/server/package.json +8 -0
- package/layers/server/server.ts +34 -0
- package/layers/source/package.json +12 -0
- package/layers/source/tsconfig.json +5 -0
- package/layers/source/vite.config.ts +45 -0
- package/layers/tests/package.json +8 -0
- package/package.json +46 -5
- package/starters/campaign/README.md +61 -0
- package/starters/campaign/files.txt +42 -0
- package/starters/campaign/layers/editor/src/editor.ts +18 -0
- package/starters/campaign/layers/rooms/server.ts +39 -0
- package/starters/campaign/layers/server/server.ts +33 -0
- package/starters/campaign/layers/tests/tests/rules.test.ts +320 -0
- package/starters/campaign/public/games/campaign/assets.json +1 -0
- package/starters/campaign/src/campaign/campaign.json +52 -0
- package/starters/campaign/src/campaign/carry.ts +47 -0
- package/starters/campaign/src/campaign/cinematics/intro.json +148 -0
- package/starters/campaign/src/campaign/cinematics/outro.json +92 -0
- package/starters/campaign/src/campaign/data.json +1 -0
- package/starters/campaign/src/campaign/fx.json +1 -0
- package/starters/campaign/src/campaign/game.ts +6 -0
- package/starters/campaign/src/campaign/ids.ts +77 -0
- package/starters/campaign/src/campaign/index.ts +121 -0
- package/starters/campaign/src/campaign/kills.ts +16 -0
- package/starters/campaign/src/campaign/looks.json +1 -0
- package/starters/campaign/src/campaign/maps.ts +34 -0
- package/starters/campaign/src/campaign/missions/homecoming.ts +160 -0
- package/starters/campaign/src/campaign/missions/siege.ts +91 -0
- package/starters/campaign/src/campaign/missions/warlord.ts +177 -0
- package/starters/campaign/src/campaign/prefabs/watchpost.prefab.json +18 -0
- package/starters/campaign/src/campaign/scenes.ts +46 -0
- package/starters/campaign/src/campaign/tiles.ts +8 -0
- package/starters/campaign/src/campaign/units.ts +390 -0
- package/starters/campaign/src/campaign/view/cinematics.ts +59 -0
- package/starters/campaign/src/campaign/view/fire.ts +115 -0
- package/starters/campaign/src/campaign/view/index.ts +334 -0
- package/starters/campaign/src/campaign/view/labels.ts +105 -0
- package/starters/campaign/src/campaign/view/rubble.ts +40 -0
- package/starters/campaign/src/campaign/view/waveLane.ts +88 -0
- package/starters/campaign/src/campaign/waves.json +36 -0
- package/starters/campaign/src/campaign/waves.ts +186 -0
- package/starters/campaign/src/campaign-editor.ts +43 -0
- package/starters/campaign/src/game.ts +2 -0
- package/starters/campaign/src/main.ts +70 -0
- package/starters/campaign/src/maps/fort.names.ts +31 -0
- package/starters/campaign/src/maps/fort.vgmap.json +1162 -0
- package/starters/campaign/src/maps/homestead.names.ts +36 -0
- package/starters/campaign/src/maps/homestead.vgmap.json +916 -0
- package/starters/campaign/src/maps/pass.names.ts +40 -0
- package/starters/campaign/src/maps/pass.vgmap.json +522 -0
- package/starters/campaign/src/maps.ts +2 -0
- package/starters/campaign/src/rules.ts +20 -0
- package/starters/campaign/src/waves-panel.ts +355 -0
- package/starters/empty/README.md +71 -0
- package/starters/empty/layers/tests/tests/rules.test.ts +31 -0
- package/starters/empty/src/game.ts +79 -0
- package/starters/empty/src/generate.worker.ts +9 -0
- package/starters/empty/src/maps.ts +44 -0
- package/starters/empty/src/rules.ts +67 -0
- package/starters/empty/src/tiles.ts +12 -0
- package/starters/melee/README.md +61 -0
- package/starters/melee/files.txt +60 -0
- package/starters/melee/layers/editor/src/editor.ts +13 -0
- package/starters/melee/layers/tests/tests/rules.test.ts +58 -0
- package/starters/melee/src/game.ts +2 -0
- package/starters/melee/src/maps/bay.names.ts +28 -0
- package/starters/melee/src/maps/bay.vgmap.json +3123 -0
- package/starters/melee/src/maps.ts +20 -0
- package/starters/melee/src/melee/abilities.ts +376 -0
- package/starters/melee/src/melee/bay.ts +319 -0
- package/starters/melee/src/melee/blight.ts +102 -0
- package/starters/melee/src/melee/cellLayers.json +11 -0
- package/starters/melee/src/melee/data.json +18 -0
- package/starters/melee/src/melee/floats.json +199 -0
- package/starters/melee/src/melee/formations.ts +46 -0
- package/starters/melee/src/melee/fx.json +1 -0
- package/starters/melee/src/melee/game.ts +6 -0
- package/starters/melee/src/melee/generator.ts +116 -0
- package/starters/melee/src/melee/heroSpells.ts +38 -0
- package/starters/melee/src/melee/hud.json +269 -0
- package/starters/melee/src/melee/index.ts +182 -0
- package/starters/melee/src/melee/items.ts +362 -0
- package/starters/melee/src/melee/languages/he.json +96 -0
- package/starters/melee/src/melee/looks.json +1 -0
- package/starters/melee/src/melee/maps.ts +53 -0
- package/starters/melee/src/melee/pings.json +16 -0
- package/starters/melee/src/melee/pings.ts +34 -0
- package/starters/melee/src/melee/quality.json +1 -0
- package/starters/melee/src/melee/saves.ts +12 -0
- package/starters/melee/src/melee/sounds.json +1 -0
- package/starters/melee/src/melee/undead.ts +683 -0
- package/starters/melee/src/melee/units.ts +785 -0
- package/starters/melee/src/melee/view/buildings.ts +33 -0
- package/starters/melee/src/melee/view/cheerLane.ts +42 -0
- package/starters/melee/src/melee/view/heroSpellLooks.ts +21 -0
- package/starters/melee/src/melee/view/index.ts +268 -0
- package/starters/melee/src/melee/view/languages.ts +10 -0
- package/starters/melee/src/melee/view/looks.ts +659 -0
- package/starters/melee/src/melee/view/undeadLooks.ts +220 -0
- package/starters/melee/src/melee/view/walls.ts +59 -0
- package/starters/melee/src/rules.ts +24 -0
- package/starters.mjs +35 -0
- 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,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]
|