@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,148 @@
1
+ {
2
+ "id": "campaign.intro",
3
+ "name": "Intro",
4
+ "length": 22,
5
+ "lanes": [
6
+ {
7
+ "id": "camera",
8
+ "kind": "camera",
9
+ "items": [
10
+ {
11
+ "at": 0,
12
+ "value": {
13
+ "x": 20,
14
+ "z": 60,
15
+ "distance": 44,
16
+ "yaw": 0.4,
17
+ "pitch": 0.95
18
+ }
19
+ },
20
+ {
21
+ "at": 6,
22
+ "value": {
23
+ "x": 22,
24
+ "z": 58,
25
+ "distance": 22,
26
+ "yaw": 0.9,
27
+ "pitch": 0.7
28
+ },
29
+ "path": "smooth",
30
+ "ease": "inOutCubic"
31
+ },
32
+ {
33
+ "at": 11,
34
+ "value": {
35
+ "x": 24,
36
+ "z": 56,
37
+ "distance": 26,
38
+ "yaw": 1.3,
39
+ "pitch": 0.8
40
+ },
41
+ "ease": "inOutSine"
42
+ },
43
+ {
44
+ "at": 12,
45
+ "value": {
46
+ "x": 58,
47
+ "z": 22,
48
+ "distance": 30,
49
+ "yaw": 2.4,
50
+ "pitch": 0.85
51
+ },
52
+ "transition": "dissolve"
53
+ },
54
+ {
55
+ "at": 17,
56
+ "value": {
57
+ "x": 62,
58
+ "z": 18,
59
+ "distance": 20,
60
+ "yaw": 2.9,
61
+ "pitch": 0.65
62
+ },
63
+ "path": "smooth",
64
+ "ease": "inOutCubic"
65
+ },
66
+ {
67
+ "at": 18,
68
+ "value": {
69
+ "x": 22,
70
+ "z": 58,
71
+ "distance": 32,
72
+ "yaw": 0,
73
+ "pitch": 0.95
74
+ },
75
+ "transition": "dissolve"
76
+ }
77
+ ]
78
+ },
79
+ {
80
+ "id": "screen",
81
+ "kind": "screen",
82
+ "items": [
83
+ {
84
+ "at": 0,
85
+ "for": 22,
86
+ "letterbox": 2.39,
87
+ "vignette": 0.3,
88
+ "in": 0.6,
89
+ "out": 0.8
90
+ },
91
+ {
92
+ "at": 0,
93
+ "for": 2,
94
+ "fade": 1,
95
+ "in": 0,
96
+ "out": 2
97
+ }
98
+ ]
99
+ },
100
+ {
101
+ "id": "dialog",
102
+ "kind": "dialog",
103
+ "items": [
104
+ {
105
+ "at": 2,
106
+ "for": 4,
107
+ "speaker": "campaign.aldric",
108
+ "text": "Home. Three years on the road, and the woods have gone dark while we were away."
109
+ },
110
+ {
111
+ "at": 6.5,
112
+ "for": 4.5,
113
+ "speaker": "campaign.brann",
114
+ "text": "The hall still stands, my lord. The farms, not all of them."
115
+ },
116
+ {
117
+ "at": 12.5,
118
+ "for": 4.5,
119
+ "speaker": "campaign.brann",
120
+ "text": "The Red Hand. Dug in beyond the trees, raiding by night."
121
+ },
122
+ {
123
+ "at": 17.5,
124
+ "for": 4,
125
+ "speaker": "campaign.aldric",
126
+ "text": "Then we burn them out."
127
+ }
128
+ ]
129
+ }
130
+ ],
131
+ "markers": [
132
+ {
133
+ "at": 0,
134
+ "name": "home",
135
+ "kind": "section"
136
+ },
137
+ {
138
+ "at": 12,
139
+ "name": "camp",
140
+ "kind": "section"
141
+ },
142
+ {
143
+ "at": 18,
144
+ "name": "back",
145
+ "kind": "section"
146
+ }
147
+ ]
148
+ }
@@ -0,0 +1,92 @@
1
+ {
2
+ "id": "campaign.outro",
3
+ "name": "Outro",
4
+ "length": 16,
5
+ "lanes": [
6
+ {
7
+ "id": "camera",
8
+ "kind": "camera",
9
+ "items": [
10
+ {
11
+ "at": 0,
12
+ "value": {
13
+ "x": 46,
14
+ "z": 20,
15
+ "distance": 18,
16
+ "yaw": 2.4,
17
+ "pitch": 0.6
18
+ }
19
+ },
20
+ {
21
+ "at": 8,
22
+ "value": {
23
+ "x": 44,
24
+ "z": 22,
25
+ "distance": 36,
26
+ "yaw": 3.0,
27
+ "pitch": 0.8
28
+ },
29
+ "path": "smooth",
30
+ "ease": "inOutSine"
31
+ },
32
+ {
33
+ "at": 16,
34
+ "value": {
35
+ "x": 36,
36
+ "z": 30,
37
+ "distance": 64,
38
+ "yaw": 3.6,
39
+ "pitch": 0.9
40
+ },
41
+ "ease": "inOutSine"
42
+ }
43
+ ]
44
+ },
45
+ {
46
+ "id": "screen",
47
+ "kind": "screen",
48
+ "items": [
49
+ {
50
+ "at": 0,
51
+ "for": 16,
52
+ "letterbox": 2.39,
53
+ "vignette": 0.35,
54
+ "in": 0.6,
55
+ "out": 0
56
+ },
57
+ {
58
+ "at": 13,
59
+ "for": 3,
60
+ "fade": 1,
61
+ "in": 3,
62
+ "out": 0
63
+ }
64
+ ]
65
+ },
66
+ {
67
+ "id": "dialog",
68
+ "kind": "dialog",
69
+ "items": [
70
+ {
71
+ "at": 1,
72
+ "for": 4,
73
+ "speaker": "campaign.brann",
74
+ "text": "It is done, my lord. The Red Hand is finished."
75
+ },
76
+ {
77
+ "at": 6,
78
+ "for": 5,
79
+ "speaker": "campaign.aldric",
80
+ "text": "Take the prisoners home. Then we rebuild the farms. The long road ends where it began."
81
+ }
82
+ ]
83
+ }
84
+ ],
85
+ "markers": [
86
+ {
87
+ "at": 13,
88
+ "name": "fade",
89
+ "kind": "section"
90
+ }
91
+ ]
92
+ }
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,6 @@
1
+ import { defineGame } from '@v12-engine/client'
2
+ import { campaign } from './index'
3
+ import { campaignView } from './view'
4
+
5
+ /** The campaign's game, whole: its rules and how it looks. What a page launches, and its editor makes maps for */
6
+ export const campaignGame = defineGame({ rules: campaign, view: campaignView })
@@ -0,0 +1,77 @@
1
+ import { defineIds, type IdOf, type PlayerId } from '@v12-engine/engine'
2
+
3
+ /*
4
+ * The ids of the campaign, named once (`defineIds`): the compiler checks them wherever they are written, in the rules
5
+ * and in the view (`GameView<CampaignUnit, CampaignResource, CampaignMessage>`), where plain text would be found wrong
6
+ * only when the game runs
7
+ */
8
+
9
+ /** Its unit types: the player's, the Red Hand's, the creeps', and what the maps are dressed with */
10
+ export const UNIT = defineIds(
11
+ 'peasant',
12
+ 'footman',
13
+ 'archer',
14
+ 'knight',
15
+ 'hall',
16
+ 'farm',
17
+ 'barracks',
18
+ 'tower',
19
+ 'wall',
20
+ 'trader',
21
+ 'raider',
22
+ 'warlock',
23
+ 'brute',
24
+ 'warlord',
25
+ 'gnoll',
26
+ 'prisoner',
27
+ 'tree',
28
+ 'goldmine'
29
+ )
30
+ export type CampaignUnit = IdOf<typeof UNIT>
31
+
32
+ /** What its players have amounts of */
33
+ export const RESOURCE = defineIds('gold')
34
+ export type CampaignResource = IdOf<typeof RESOURCE>
35
+
36
+ /** The keys of what it says: the rules say the key, the view has the words (`GameView.hud.messages`) */
37
+ export const MSG = defineIds(
38
+ 'objectiveRaze',
39
+ 'objectiveHall',
40
+ 'objectiveCaravan',
41
+ 'objectiveHold',
42
+ 'objectiveTowers',
43
+ 'objectiveWarlord',
44
+ 'objectiveHero',
45
+ 'objectivePrisoners',
46
+ 'caravanFound',
47
+ 'waveComing',
48
+ 'waveBeaten',
49
+ 'gateShut',
50
+ 'gateOpen',
51
+ 'checkpointWave',
52
+ 'heroFell',
53
+ 'hallFell',
54
+ 'homeStart',
55
+ 'homeCamp',
56
+ 'homeFord',
57
+ 'homeDone',
58
+ 'siegeStart',
59
+ 'siegeGate',
60
+ 'siegeHalfway',
61
+ 'siegeLast',
62
+ 'passStart',
63
+ 'passShop',
64
+ 'passPrisoners',
65
+ 'passDuel',
66
+ 'passCharge'
67
+ )
68
+ export type CampaignMessage = IdOf<typeof MSG>
69
+
70
+ /** Who is who: the player at the screen, and the Red Hand */
71
+ export const PLAYER: PlayerId = 1
72
+ export const ENEMY: PlayerId = 2
73
+
74
+ /** Who speaks: their names and looks are the view's (`GameView.cinematics.speakers`) */
75
+ export const ALDRIC = 'campaign.aldric'
76
+ export const BRANN = 'campaign.brann'
77
+ export const VARGA = 'campaign.varga'
@@ -0,0 +1,121 @@
1
+ import { type GameMap, type GameModule, hotRules, objectiveVictory, prefabsFrom, withData, withHeroes, type World } from '@v12-engine/engine'
2
+ import { ENEMY, PLAYER } from './ids'
3
+ import { mapKeyOf, maps } from './maps'
4
+ import { homecoming, homecomingListeners, homecomingSequences, outpost, redHand, reinforcements } from './missions/homecoming'
5
+ import { siege, siegeListeners, siegeSequences } from './missions/siege'
6
+ import { parley, vargasMen, warlord, warlordListeners, warlordSequences } from './missions/warlord'
7
+ import { countKills, kills } from './kills'
8
+ import { tiles } from './tiles'
9
+ import { abilities, buffs, effects, items, units } from './units'
10
+ import { nextWave, reportWaves, siegeWaves, waveBeaten, waveComing, waveListeners, waves, wavesFile, waveTimers } from './waves'
11
+ import dataFile from './data.json'
12
+ import watchpost from './prefabs/watchpost.prefab.json'
13
+
14
+ export { campaignMap, DEFAULT_MAP, maps } from './maps'
15
+ export { ENEMY, MSG, PLAYER, UNIT, type CampaignMessage, type CampaignResource, type CampaignUnit } from './ids'
16
+ export { homecoming, siege, warlord }
17
+ export { kills } from './kills'
18
+ export { homecomingCarry, type RoadData, siegeCarriedIn, warlordCarry } from './carry'
19
+ export { builtWaves, readWaves, siegeWaves, type Wave, WAVE_UNITS, wavesFile, wavesOf } from './waves'
20
+
21
+ /**
22
+ * The campaign's game: a game module of its own on the engine's public API, and nothing else. Its missions are won
23
+ * and lost by their objectives (`objectiveVictory`), not by who has buildings left; each mission's rules are in
24
+ * `missions/`, and the maps say which mission they are (`startMission`)
25
+ *
26
+ * What it brings, and what of the engine it uses for that:
27
+ * - its units, buildings, hero, abilities, items (data: `units.ts`), and what the editor's Data panel may change of them
28
+ * - a resource, gold, dug at mines and paid as bounty
29
+ * - the waves of the siege (`waves.ts`): timers, a component in the world, an event, a command to call one in early
30
+ * - how many each tower has killed (`kills.ts`): a component of an entity every player is sent, which its view pins
31
+ * over the tower
32
+ * - missions: objectives, sequences of dialog and cinematics, areas of the map the rules watch, timers, prefabs, and
33
+ * computer players that are mission brains (`@v12-engine/engine/ai`), each listed so that a save finds it by its id
34
+ */
35
+ export const campaign: GameModule = withData(
36
+ {
37
+ // the maps of this game say they are its own by this id: change it, and change theirs (`"game"`) with it
38
+ id: 'my-game',
39
+ name: 'My game',
40
+ units,
41
+ abilities,
42
+ buffs,
43
+ items,
44
+ effects,
45
+ resources: [{ id: 'gold', name: 'Gold', start: 300 }],
46
+ currency: 'gold',
47
+ // its hero, who earns levels and is brought back at an altar, and the creeps' camps (`withHeroes`)
48
+ packs: [withHeroes()],
49
+ tiles,
50
+ // the maps it ships: a save of a game on one keeps only what changed of its ground
51
+ maps: Object.values(maps),
52
+ // the outpost in code, and the designers' prefabs (`prefabs/*.prefab.json`), as the rules may spawn them too
53
+ prefabs: [outpost, ...prefabsFrom({ 'watchpost.prefab.json': watchpost }, { prefix: 'campaign.' })],
54
+ brains: [redHand, vargasMen],
55
+ commands: [nextWave],
56
+ listeners: [...waveListeners, countKills, ...homecomingListeners, ...siegeListeners, ...warlordListeners],
57
+ timers: [...waveTimers, reinforcements],
58
+ files: [wavesFile],
59
+ sequences: [...homecomingSequences, ...siegeSequences, ...warlordSequences],
60
+ // what it keeps of its own: the waves' state, each tower's kills (sent to everyone, pinned over it by the view), and
61
+ // the answer at Varga's gate
62
+ components: [waves, kills, parley],
63
+ events: [waveBeaten, waveComing],
64
+ editor: {
65
+ // the player and the Red Hand, always: the editor sets where they start, not who they are
66
+ players: [
67
+ { id: PLAYER, name: 'Aldric', team: 1, color: [0.2, 0.45, 0.95] },
68
+ { id: ENEMY, name: 'The Red Hand', team: 2, color: [0.9, 0.2, 0.18], controller: 'computer' }
69
+ ],
70
+ playersNote: 'Aldric is you. The Red Hand is the computer, as each mission says.',
71
+ // where the game names a unit type beyond the map: the waves that send it (Used by, Find references)
72
+ references: (ref, _map, registry) =>
73
+ 'type' in ref
74
+ ? siegeWaves({ registry }).flatMap((wave, i) =>
75
+ wave.units.some((unit) => unit.type === ref.type) ? [{ what: `wave ${i + 1}`, where: 'waves.json' }] : []
76
+ )
77
+ : []
78
+ },
79
+ victory: objectiveVictory,
80
+ report: (world) => reportWaves(world),
81
+ // a save made before a patch is loaded after it (`saves.migrate` takes the step)
82
+ saves: { version: 1 },
83
+ // what the editor's Data panel may change, kept in `data.json`: numbers and prices, never what a unit is
84
+ editable: {
85
+ units: (def) =>
86
+ def.kind === 'building'
87
+ ? ['name', { path: 'stats.health', min: 1 }, 'stats.armor', { path: 'cost.gold', kind: 'integer', min: 0 }, 'buildTime']
88
+ : def.kind === 'unit'
89
+ ? [
90
+ 'name',
91
+ { path: 'stats.health', min: 1 },
92
+ 'stats.attackDamage',
93
+ 'stats.attackSpeed',
94
+ 'stats.armor',
95
+ 'stats.moveSpeed',
96
+ { path: 'cost.gold', kind: 'integer', min: 0 }
97
+ ]
98
+ : null,
99
+ abilities: [{ path: 'cooldown', min: 0 }, { path: 'manaCost', min: 0 }, 'description'],
100
+ items: [{ path: 'cost.gold', kind: 'integer', min: 0 }, 'description']
101
+ }
102
+ },
103
+ dataFile
104
+ )
105
+
106
+ /** Each map's mission, by the map's file: what `startMission` gives a world made from it */
107
+ const MISSIONS: Record<string, (world: World, fresh: boolean) => void> = { homestead: homecoming, fort: siege, pass: warlord }
108
+
109
+ /**
110
+ * Gives a world the mission of the map it was made from: the campaign's scene does it with `fresh` as it knows it;
111
+ * the editor's Play here, a server and the tests do it through `start` with a fresh world. A map of none (one made in
112
+ * the editor for trying things) gets no mission: the game plays on, won by nobody
113
+ */
114
+ export function startMission(world: World, map: Pick<GameMap, 'name'> | undefined, fresh = true): void {
115
+ const key = map && mapKeyOf(map)
116
+ const mission = key && MISSIONS[key]
117
+ if (mission) mission(world, fresh)
118
+ }
119
+
120
+ // saved while it runs in the dev server: the games and editors of the page take the new version (hot reload)
121
+ if (import.meta.hot) import.meta.hot.accept((module) => hotRules(import.meta.hot, campaign, module?.campaign))
@@ -0,0 +1,16 @@
1
+ import { entityDied } from '@v12-engine/engine'
2
+ import { defineComponent, defineListener } from '@v12-engine/engine'
3
+ import { ENEMY, PLAYER } from './ids'
4
+
5
+ /** How many of the Red Hand a tower of the fort has finished off. Everyone sees it: the view puts it over the tower */
6
+ export const kills = defineComponent<number>('campaign.kills', { replicate: 'everyone' })
7
+
8
+ /** One of the Red Hand that dies counts for the building of the player's that killed it */
9
+ export const countKills = defineListener({
10
+ event: entityDied,
11
+ id: 'campaign.countKills',
12
+ run(_world, { entity, killer }) {
13
+ if (entity.owner !== ENEMY || !killer || killer.owner !== PLAYER || killer.def.kind !== 'building') return
14
+ killer.set(kills, (killer.get(kills) ?? 0) + 1)
15
+ }
16
+ })
@@ -0,0 +1,34 @@
1
+ import { type GameMap, mapsFrom } from '@v12-engine/engine'
2
+
3
+ import homestead from '../maps/homestead.vgmap.json'
4
+ import fort from '../maps/fort.vgmap.json'
5
+ import pass from '../maps/pass.vgmap.json'
6
+
7
+ /**
8
+ * The maps of the campaign, one a mission: the files in `src/maps`, made in the editor (`/?editor`, whose Save writes
9
+ * them there, with a names file each). A new map is a new file there, imported here as these are. (Not with Vite's
10
+ * `import.meta.glob`: a server run by Node has no such thing.)
11
+ */
12
+ const files = {
13
+ './maps/homestead.vgmap.json': homestead,
14
+ './maps/fort.vgmap.json': fort,
15
+ './maps/pass.vgmap.json': pass
16
+ }
17
+
18
+ /** The maps by the name of their file, without `.vgmap.json`: `homestead` */
19
+ export const maps: Readonly<Record<string, GameMap>> = mapsFrom(files)
20
+
21
+ /** The map a game is on when nothing says which, and the one the editor opens first */
22
+ export const DEFAULT_MAP = 'homestead'
23
+
24
+ /** A map by name, as a copy: a game changes nothing of what the next one starts from */
25
+ export function campaignMap(name = DEFAULT_MAP): GameMap {
26
+ const map = maps[name]
27
+ if (!map) throw new Error(`The campaign has no map "${name}": its maps are ${Object.keys(maps).join(', ')}`)
28
+ return structuredClone(map)
29
+ }
30
+
31
+ /** The file a map came from, by its name as the map says it: how the rules know which mission a map is */
32
+ export function mapKeyOf(map: Pick<GameMap, 'name'>): string | undefined {
33
+ return Object.entries(maps).find(([, each]) => each.name === map.name)?.[0]
34
+ }
@@ -0,0 +1,160 @@
1
+ import { enteredArea, entityDied } from '@v12-engine/engine'
2
+ import {
3
+ areaShapeOf,
4
+ cinematic,
5
+ defineListener,
6
+ defineObjective,
7
+ definePrefab,
8
+ defineSequence,
9
+ defineTimer,
10
+ earn,
11
+ talk,
12
+ wait,
13
+ waitFor,
14
+ type World
15
+ } from '@v12-engine/engine'
16
+ import { holdPosition, missionBrain, waveFrom } from '@v12-engine/engine/ai'
17
+ import { ALDRIC, BRANN, ENEMY, MSG, PLAYER, UNIT } from '../ids'
18
+
19
+ /*
20
+ * Mission one, Homecoming: Aldric comes home to a base with the Red Hand dug in beyond the woods. Burn their camp,
21
+ * keep the hall. On the way: a ford where Brann has a word, a lost caravan worth finding, an outpost the rules put
22
+ * up from a prefab, and a computer that holds its camp and sends what reaches it at the hall
23
+ */
24
+
25
+ export const razeCamp = defineObjective({ id: 'campaign.raze', text: MSG.objectiveRaze })
26
+ export const keepHall = defineObjective({ id: 'campaign.hall', text: MSG.objectiveHall })
27
+ export const findCaravan = defineObjective({ id: 'campaign.caravan', text: MSG.objectiveCaravan, required: false })
28
+
29
+ /** The areas of the map the rules watch, by the names of its regions: added as the mission starts (a save keeps them) */
30
+ const FORD = 'campaign.ford'
31
+ const CARAVAN = 'campaign.caravan'
32
+ const REGIONS: [string, string][] = [
33
+ [FORD, 'ford'],
34
+ [CARAVAN, 'caravan']
35
+ ]
36
+
37
+ /** Gold in the caravan's wreck */
38
+ const CARAVAN_GOLD = 200
39
+
40
+ /**
41
+ * An outpost: a tower between two walls and an archer behind them. A prefab in code, spawned where the map says
42
+ * (`outpost`); the designers' prefabs are files beside it (`prefabs/`), placed in the editor
43
+ */
44
+ export const outpost = definePrefab({
45
+ id: 'campaign.outpost',
46
+ name: 'Outpost',
47
+ contents: {
48
+ objects: [
49
+ { type: UNIT.tower, x: 0, z: 0 },
50
+ { type: UNIT.wall, x: -2.5, z: 0.5 },
51
+ { type: UNIT.wall, x: 2.5, z: 0.5 },
52
+ { type: UNIT.archer, x: 0, z: -1.8, facing: Math.PI }
53
+ ]
54
+ }
55
+ })
56
+
57
+ /**
58
+ * The Red Hand of the camp: it holds the camp with what it has, and every so often sends what the reinforcements
59
+ * bring (`waveFrom`: what the mission spawns at the camp goes for the hall in threes). A mission brain: parts and
60
+ * nothing else, no building, no training (`@v12-engine/engine/ai`)
61
+ */
62
+ export const redHand = missionBrain({
63
+ id: 'campaign.redHand',
64
+ modules: [holdPosition('camp'), waveFrom('camp', { group: 3, every: 90 })]
65
+ })
66
+
67
+ /** Every so often, two raiders more at the camp: the brain sends them */
68
+ export const reinforcements = defineTimer('campaign.reinforcements', (world) => {
69
+ if (world.winner !== null) return
70
+ const camp = world.map.point('camp')
71
+ if (!enemyBuildings(world).length) return
72
+ for (const dx of [-1.5, 1.5]) world.spawn(UNIT.raider, ENEMY, { x: camp.x + dx, z: camp.z + 1 })
73
+ })
74
+
75
+ /** The Red Hand's buildings, standing */
76
+ export const enemyBuildings = (world: World) => [...world.index.ownedBy(ENEMY)].filter((entity) => entity.alive && entity.def.kind === 'building')
77
+
78
+ /** The intro, then Brann's briefing: a sequence, skippable a line at a time, saved halfway like everything else */
79
+ export const briefing = defineSequence('campaign.briefing', [
80
+ // the screen lets the first tick pass before it draws: the intro is the first thing seen, the game waiting
81
+ wait(0),
82
+ cinematic('campaign.intro', { hold: true, wait: 'ended' }),
83
+ wait(0.5),
84
+ ...talk(PLAYER, [
85
+ { speaker: BRANN, text: MSG.homeStart, seconds: 5 },
86
+ { speaker: ALDRIC, text: MSG.homeCamp, seconds: 5 }
87
+ ])
88
+ ])
89
+
90
+ /** At the ford, a word, and the camp shown for a moment: whoever of the player's walks in first */
91
+ export const atTheFord = defineSequence('campaign.atTheFord', [
92
+ waitFor(enteredArea, (_world, { area, entity }) => area === FORD && entity.owner === PLAYER),
93
+ (world) => {
94
+ const camp = world.map.point('camp')
95
+ world.reveal(world.player(PLAYER).team, camp, 12, 10)
96
+ },
97
+ ...talk(PLAYER, [{ speaker: BRANN, text: MSG.homeFord, seconds: 5 }])
98
+ ])
99
+
100
+ /** The caravan found: its gold, and the optional objective done */
101
+ export const caravanFound = defineListener({
102
+ event: enteredArea,
103
+ id: 'campaign.caravanFound',
104
+ run(world, { area, entity }) {
105
+ if (area !== CARAVAN || entity.owner !== PLAYER || !world.objectives.has(PLAYER, findCaravan.id)) return
106
+ if (world.objectives.get(PLAYER, findCaravan.id).status !== 'open') return
107
+ earn(world.player(PLAYER).resources, 'gold', CARAVAN_GOLD)
108
+ world.objectives.complete(PLAYER, findCaravan.id)
109
+ world.message(PLAYER, { key: MSG.caravanFound, params: { gold: CARAVAN_GOLD } }, 'info')
110
+ }
111
+ })
112
+
113
+ /** A building fell: the camp's, one less to raze, the last of them the mission won; the hall, the mission lost */
114
+ export const buildingFell = defineListener({
115
+ event: entityDied,
116
+ id: 'campaign.homeBuildingFell',
117
+ run(world, { entity }) {
118
+ if (entity.def.kind !== 'building' || !world.objectives.has(PLAYER, razeCamp.id)) return
119
+ const { objectives } = world
120
+ if (entity.owner === ENEMY) {
121
+ const left = enemyBuildings(world).length
122
+ const total = objectives.get(PLAYER, razeCamp.id).progress?.max ?? left
123
+ objectives.progress(PLAYER, razeCamp.id, total - left)
124
+ if (left) return
125
+ objectives.complete(PLAYER, razeCamp.id)
126
+ objectives.complete(PLAYER, keepHall.id)
127
+ if (objectives.get(PLAYER, findCaravan.id).status === 'open') objectives.fail(PLAYER, findCaravan.id)
128
+ world.sequences.start(wellDone)
129
+ } else if (entity.owner === PLAYER && entity.def.id === UNIT.hall) objectives.fail(PLAYER, keepHall.id, MSG.hallFell)
130
+ }
131
+ })
132
+
133
+ const wellDone = defineSequence('campaign.homeDone', talk(PLAYER, [{ speaker: ALDRIC, text: MSG.homeDone, seconds: 4 }]))
134
+
135
+ export const homecomingSequences = [briefing, atTheFord, wellDone]
136
+ export const homecomingListeners = [caravanFound, buildingFell]
137
+
138
+ /**
139
+ * Gives the mission: once, as the world is made (`fresh`). A game loaded from a save has all of it already: the
140
+ * objectives, the areas, the sequences halfway, the computer's seat and its memory, the timers
141
+ */
142
+ export function homecoming(world: World, fresh: boolean): void {
143
+ if (!fresh) return
144
+ const { objectives } = world
145
+ objectives.add(PLAYER, razeCamp, { progress: { value: 0, max: enemyBuildings(world).length } })
146
+ objectives.add(PLAYER, keepHall)
147
+ objectives.add(PLAYER, findCaravan)
148
+ for (const [id, region] of REGIONS) {
149
+ const shape = areaShapeOf({ region }, world.map.regions)
150
+ if (shape) world.areas.add({ id, shape })
151
+ }
152
+ // the outpost, where the map says: a prefab spawned by the rules
153
+ world.spawnPrefab(outpost, { at: world.map.point('outpost'), owner: PLAYER })
154
+ // the Red Hand's computer, remembered with the world: a save seats it again as it loads
155
+ const hall = world.named('hall')
156
+ world.computers.seat(ENEMY, { enemyBases: [hall ? { x: hall.pos.x, z: hall.pos.z } : world.map.point('outpost')], brain: redHand })
157
+ world.timers.every(90, reinforcements, {})
158
+ world.sequences.start(briefing)
159
+ world.sequences.start(atTheFord)
160
+ }