@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,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
|
+
]
|