@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,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules: the campaign's, in `src/campaign`. Its units, its missions and its waves are in that folder; this is the
|
|
3
|
+
* name every part of the project knows them by
|
|
4
|
+
*/
|
|
5
|
+
import type { GameMap, World } from '@v12-engine/engine'
|
|
6
|
+
import { campaign, startMission } from './campaign'
|
|
7
|
+
|
|
8
|
+
export { ENEMY, PLAYER, UNIT } from './campaign'
|
|
9
|
+
|
|
10
|
+
export const rules = campaign
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the game does once a world is made from a map, before anyone plays: the map's mission is given (each map is
|
|
14
|
+
* a mission's; a map of none plays on with no objectives). The page's free play, the server, the tests and the
|
|
15
|
+
* editor's Play here each call it with the map; the campaign's own scene gives the mission itself, knowing whether
|
|
16
|
+
* the game is new or loaded from a save
|
|
17
|
+
*/
|
|
18
|
+
export function start(world: World, { map }: { map?: GameMap }): void {
|
|
19
|
+
startMission(world, map)
|
|
20
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { EditorClient, EditorPanel, EditorPanelDef, PanelSearchEntry } from '@v12-engine/editor'
|
|
2
|
+
import { filterBy, kit, NumberField } from '@v12-engine/client/kit'
|
|
3
|
+
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera.js'
|
|
4
|
+
import { CreateTorus } from '@babylonjs/core/Meshes/Builders/torusBuilder.js'
|
|
5
|
+
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
|
|
6
|
+
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
|
|
7
|
+
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
|
|
8
|
+
import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
|
|
9
|
+
import { createEffect, createRoot, on } from 'solid-js'
|
|
10
|
+
import { readWaves, siegeWaves, WAR_CAMP, type Wave, WAVE_UNITS, wavesFile, wavesOf } from './campaign/waves'
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* The waves of the siege, in the campaign's editor: a panel of the game's own that lists them, finds them as they are
|
|
14
|
+
* typed, and changes them: who comes, how often, for how much gold. They are the game's file
|
|
15
|
+
* `src/campaign/waves.json`, written as they change (saved with the map, Ctrl+S), and undone like the rest (Ctrl+Z).
|
|
16
|
+
* It shows where the picked wave comes out, in the scene and in a view of its own, moves that place with a click, says
|
|
17
|
+
* how the waves go while the map is played here, and tells the other panels which wave is picked.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The waves' file, from the game's folder (`src`) */
|
|
21
|
+
const FILE = 'campaign/waves.json'
|
|
22
|
+
/** Who can come in a wave */
|
|
23
|
+
const INVADERS = WAVE_UNITS
|
|
24
|
+
|
|
25
|
+
// what the waves panel says to the other panels, and what it offers them: typed for whoever listens or asks
|
|
26
|
+
declare module '@v12-engine/editor' {
|
|
27
|
+
interface EditorEvents {
|
|
28
|
+
'campaign.wave.picked': { wave: number }
|
|
29
|
+
}
|
|
30
|
+
interface EditorPanelApis {
|
|
31
|
+
'campaign.waves': { pick(wave: number): void }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A wave in a few words: "8 skeleton · every 1.2 s · 20 gold" */
|
|
36
|
+
const describe = (wave: Wave) =>
|
|
37
|
+
`${wave.units.map((unit) => `${unit.count} ${unit.type}`).join(', ')} · every ${wave.interval} s · ${wave.bonus} gold`
|
|
38
|
+
|
|
39
|
+
/** The waves as they are, to keep apart from what changes next */
|
|
40
|
+
const copy = (waves: readonly Wave[]): Wave[] => waves.map((wave) => ({ ...wave, units: wave.units.map((unit) => ({ ...unit })) }))
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The waves the editor's game has, and the game played in its page: what each plays (`registry.setFile`), not the
|
|
44
|
+
* game's module, which other worlds of the page and a server share
|
|
45
|
+
*/
|
|
46
|
+
function useWaves(editor: EditorClient, waves: Wave[]): void {
|
|
47
|
+
editor.registry.setFile(wavesFile, waves)
|
|
48
|
+
editor.world?.registry.setFile(wavesFile, copy(waves))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The waves changed, as a step of the editor's history: the game plays them (`useWaves`), the file is written, and
|
|
53
|
+
* Ctrl+Z puts back what they were. Steps of one field one after the other (a number dragged) are one step (`merge`)
|
|
54
|
+
*/
|
|
55
|
+
function change(editor: EditorClient, name: string, make: (waves: Wave[]) => void, merge?: string): void {
|
|
56
|
+
const was = copy(siegeWaves(editor))
|
|
57
|
+
const next = copy(siegeWaves(editor))
|
|
58
|
+
make(next)
|
|
59
|
+
const put = (waves: Wave[]) => {
|
|
60
|
+
useWaves(editor, copy(waves))
|
|
61
|
+
void editor.files.write(FILE, waves)
|
|
62
|
+
}
|
|
63
|
+
editor.history.step({ name, merge, do: () => put(next), undo: () => put(was) })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The point where the waves come out of the war camp */
|
|
67
|
+
const entryOf = (editor: EditorClient) => editor.map.points.find((point) => point.name === WAR_CAMP)
|
|
68
|
+
|
|
69
|
+
/** Draws the panel into its element, given the editor and its handle */
|
|
70
|
+
function mount(element: HTMLElement, editor: EditorClient, panel: EditorPanel): void {
|
|
71
|
+
// kept in this browser with the panel: the wave picked, and whether its view of the entry is open
|
|
72
|
+
const picked = panel.state('picked', 0)
|
|
73
|
+
const looking = panel.state('looking', false)
|
|
74
|
+
const filter = panel.filter({ placeholder: 'Find a wave: raider, 3, brute' })
|
|
75
|
+
const pick = (wave: number) => {
|
|
76
|
+
picked.set(Math.max(0, Math.min(wave, siegeWaves(editor).length - 1)))
|
|
77
|
+
editor.bus.emit('campaign.wave.picked', { wave: picked.get() })
|
|
78
|
+
draw()
|
|
79
|
+
}
|
|
80
|
+
// what it offers the other panels; and an entry of the search picked shows its wave
|
|
81
|
+
panel.provide({ pick })
|
|
82
|
+
panel.onReveal((id) => pick(Number(id)))
|
|
83
|
+
|
|
84
|
+
const live = document.createElement('p')
|
|
85
|
+
live.className = kit.classes.text({ kind: 'note', class: 'mt-1.5' })
|
|
86
|
+
const list = document.createElement('div')
|
|
87
|
+
list.setAttribute('role', 'listbox')
|
|
88
|
+
list.className = 'mt-2 flex flex-col'
|
|
89
|
+
const details = document.createElement('div')
|
|
90
|
+
details.className = 'mt-3 flex flex-col gap-2'
|
|
91
|
+
const view = document.createElement('canvas')
|
|
92
|
+
view.className = 'mt-2 w-full rounded-panel border border-border'
|
|
93
|
+
view.style.height = '140px'
|
|
94
|
+
const viewBox = document.createElement('div')
|
|
95
|
+
element.append(filter.element(), live, list, details, viewBox)
|
|
96
|
+
/** What the kit's fields drawn into the details take away, as they are drawn again */
|
|
97
|
+
let fields: (() => void)[] = []
|
|
98
|
+
/** A number of the wave's is being changed in its field: what is drawn again leaves the field be */
|
|
99
|
+
let typing = false
|
|
100
|
+
|
|
101
|
+
/** The list of waves, those the words typed find */
|
|
102
|
+
const drawList = () => {
|
|
103
|
+
const shown = filterBy(
|
|
104
|
+
siegeWaves(editor).map((wave, index) => ({ wave, index })),
|
|
105
|
+
filter(),
|
|
106
|
+
({ wave, index }) => `wave ${index + 1} ${describe(wave)}${wave.units.some((unit) => unit.type === 'brute') ? ' brute' : ''}`
|
|
107
|
+
)
|
|
108
|
+
list.replaceChildren(
|
|
109
|
+
...shown.map(({ wave, index }) => {
|
|
110
|
+
const row = document.createElement('button')
|
|
111
|
+
row.type = 'button'
|
|
112
|
+
row.setAttribute('role', 'option')
|
|
113
|
+
row.className = kit.classes.option()
|
|
114
|
+
row.dataset.wave = String(index)
|
|
115
|
+
if (index === picked.get()) row.dataset.state = 'checked'
|
|
116
|
+
row.textContent = `Wave ${index + 1}: ${describe(wave)}`
|
|
117
|
+
row.addEventListener('click', () => pick(index))
|
|
118
|
+
return row
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A number of the picked wave's, as a field of the kit's: each change a step, a drag one step */
|
|
124
|
+
const numberField = (
|
|
125
|
+
label: string,
|
|
126
|
+
value: number,
|
|
127
|
+
set: (wave: Wave, value: number) => void,
|
|
128
|
+
options: { min: number; step: number; merge: string }
|
|
129
|
+
) => {
|
|
130
|
+
const row = document.createElement('label')
|
|
131
|
+
row.className = 'flex items-center justify-between gap-2 text-[13px]'
|
|
132
|
+
row.append(label)
|
|
133
|
+
// the field, and what goes beside it, at the end of the line
|
|
134
|
+
const end = document.createElement('div')
|
|
135
|
+
end.className = 'flex items-center gap-1'
|
|
136
|
+
const field = document.createElement('div')
|
|
137
|
+
field.className = 'w-24'
|
|
138
|
+
end.append(field)
|
|
139
|
+
row.append(end)
|
|
140
|
+
const wave = picked.get()
|
|
141
|
+
fields.push(
|
|
142
|
+
kit.render(field, NumberField, {
|
|
143
|
+
value,
|
|
144
|
+
min: options.min,
|
|
145
|
+
step: options.step,
|
|
146
|
+
size: 'sm',
|
|
147
|
+
onValue: (next: number) => {
|
|
148
|
+
typing = true
|
|
149
|
+
try {
|
|
150
|
+
change(editor, `${label} of wave ${wave + 1}`, (waves) => set(waves[wave], next), `campaign.wave.${wave}.${options.merge}`)
|
|
151
|
+
} finally {
|
|
152
|
+
typing = false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
return row
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What the picked wave is, to change */
|
|
161
|
+
const drawDetails = () => {
|
|
162
|
+
const index = picked.get()
|
|
163
|
+
const wave = siegeWaves(editor)[index]
|
|
164
|
+
for (const gone of fields.splice(0)) gone()
|
|
165
|
+
details.replaceChildren()
|
|
166
|
+
if (!wave) return
|
|
167
|
+
const title = document.createElement('h3')
|
|
168
|
+
title.className = kit.classes.text({ kind: 'heading' })
|
|
169
|
+
title.textContent = `Wave ${index + 1}`
|
|
170
|
+
details.append(title)
|
|
171
|
+
for (const [at, unit] of wave.units.entries()) {
|
|
172
|
+
const row = numberField(unit.type, unit.count, (changed, count) => void (changed.units[at].count = count), {
|
|
173
|
+
min: 1,
|
|
174
|
+
step: 1,
|
|
175
|
+
merge: `count.${at}`
|
|
176
|
+
})
|
|
177
|
+
row.lastElementChild!.append(
|
|
178
|
+
kit.button('×', () => change(editor, `Take the ${unit.type} out of wave ${index + 1}`, (waves) => void waves[index].units.splice(at, 1)), {
|
|
179
|
+
variant: 'ghost',
|
|
180
|
+
size: 'icon-sm',
|
|
181
|
+
tip: `Take the ${unit.type} out of this wave`
|
|
182
|
+
})
|
|
183
|
+
)
|
|
184
|
+
details.append(row)
|
|
185
|
+
}
|
|
186
|
+
const more = document.createElement('div')
|
|
187
|
+
more.className = 'flex flex-wrap gap-1'
|
|
188
|
+
for (const type of INVADERS.filter((type) => !wave.units.some((unit) => unit.type === type)))
|
|
189
|
+
more.append(
|
|
190
|
+
kit.button(
|
|
191
|
+
`+ ${type}`,
|
|
192
|
+
() => change(editor, `Add ${type} to wave ${index + 1}`, (waves) => void waves[index].units.push({ type, count: 1 })),
|
|
193
|
+
{
|
|
194
|
+
size: 'sm'
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
details.append(more)
|
|
199
|
+
details.append(
|
|
200
|
+
numberField('Seconds between', wave.interval, (changed, value) => void (changed.interval = value), { min: 0.1, step: 0.1, merge: 'interval' })
|
|
201
|
+
)
|
|
202
|
+
details.append(
|
|
203
|
+
numberField('Gold for beating it', wave.bonus, (changed, value) => void (changed.bonus = value), { min: 0, step: 5, merge: 'bonus' })
|
|
204
|
+
)
|
|
205
|
+
const buttons = document.createElement('div')
|
|
206
|
+
buttons.className = 'flex flex-wrap gap-1'
|
|
207
|
+
buttons.append(
|
|
208
|
+
// a click in the scene, asked for by the panel: where the waves come onto the map
|
|
209
|
+
kit.button('Move the war camp', () => moveEntry(), { size: 'sm', tip: 'Then click in the scene, where the waves come out' }),
|
|
210
|
+
kit.button(looking.get() ? 'Hide the view' : 'A view of it', () => (looking.set(!looking.get()), drawView(), drawDetails()), {
|
|
211
|
+
size: 'sm',
|
|
212
|
+
tip: 'A view of the war camp, of its own'
|
|
213
|
+
}),
|
|
214
|
+
kit.button(
|
|
215
|
+
'Take it out',
|
|
216
|
+
() => {
|
|
217
|
+
change(editor, `Take wave ${index + 1} out`, (waves) => void waves.splice(index, 1))
|
|
218
|
+
pick(index)
|
|
219
|
+
},
|
|
220
|
+
{ size: 'sm', variant: 'danger' }
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
details.append(buttons)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const moveEntry = () =>
|
|
227
|
+
panel.pick({
|
|
228
|
+
hint: 'Click where the waves come out (Esc: not now)',
|
|
229
|
+
pick: ({ ground }) => {
|
|
230
|
+
const entry = entryOf(editor)
|
|
231
|
+
if (!ground) return
|
|
232
|
+
if (entry) editor.edit((map) => map.markers.setPoint(entry.id, { x: ground.x, z: ground.z }), 'Move the war camp')
|
|
233
|
+
else editor.edit((map) => void map.markers.addPoint(ground, 0, WAR_CAMP), 'Say where the war camp is')
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
/** Where the picked wave comes in, as a ring in the scene as big as the wave: made again as it or the points change */
|
|
238
|
+
let undraw = () => {}
|
|
239
|
+
let ring: Mesh | undefined
|
|
240
|
+
const drawRing = () => {
|
|
241
|
+
undraw()
|
|
242
|
+
ring = undefined
|
|
243
|
+
undraw = panel.draw(
|
|
244
|
+
(scene, root) => {
|
|
245
|
+
const entry = entryOf(editor)
|
|
246
|
+
const wave = siegeWaves(editor)[picked.get()]
|
|
247
|
+
if (!entry || !wave) return
|
|
248
|
+
const count = wave.units.reduce((sum, unit) => sum + unit.count, 0)
|
|
249
|
+
ring = CreateTorus('campaign.waves.ring', { diameter: 2 + Math.sqrt(count), thickness: 0.25, tessellation: 48 }, scene)
|
|
250
|
+
ring.position.set(entry.x, editor.doc.terrain.heightAt(entry.x, entry.z) + 0.3, entry.z)
|
|
251
|
+
const paint = new StandardMaterial('campaign.waves.ring', scene)
|
|
252
|
+
paint.diffuseColor = wave.units.some((unit) => unit.type === 'brute') ? new Color3(1, 0.3, 0.2) : new Color3(1, 0.75, 0.3)
|
|
253
|
+
ring.material = paint
|
|
254
|
+
ring.parent = root
|
|
255
|
+
},
|
|
256
|
+
{ again: (changed) => changed.parts.has('points') }
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
// it turns, while the panel shows: what a frame hook does
|
|
260
|
+
panel.frame((dt) => {
|
|
261
|
+
if (ring && !ring.isDisposed()) ring.rotation.y += dt * 0.8
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
/** The view of where the waves come in, while it is asked for: drawn only while the panel shows */
|
|
265
|
+
let unview: (() => void) | undefined
|
|
266
|
+
const drawView = () => {
|
|
267
|
+
unview?.()
|
|
268
|
+
unview = undefined
|
|
269
|
+
view.remove()
|
|
270
|
+
if (!looking.get()) return
|
|
271
|
+
viewBox.append(view)
|
|
272
|
+
const made = panel.viewport(view, {
|
|
273
|
+
every: 2,
|
|
274
|
+
camera: (scene) => {
|
|
275
|
+
const entry = entryOf(editor) ?? { x: editor.map.width / 2, z: editor.map.depth / 2 }
|
|
276
|
+
return new ArcRotateCamera('campaign.waves.view', -Math.PI / 2, 0.9, 18, new Vector3(entry.x, 0, entry.z), scene)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
unview = () => made.dispose()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** How the waves go while the map is played here: the running world's */
|
|
283
|
+
const drawLive = () => {
|
|
284
|
+
const world = editor.world
|
|
285
|
+
if (!editor.playing() || !world) return void (live.textContent = `${siegeWaves(editor).length} waves`)
|
|
286
|
+
const state = wavesOf(world)
|
|
287
|
+
live.textContent =
|
|
288
|
+
state.phase === 'idle'
|
|
289
|
+
? `${siegeWaves(editor).length} waves: they come in the siege (the fort)`
|
|
290
|
+
: `Played here: wave ${Math.min(state.wave + 1, siegeWaves(editor).length)} of ${siegeWaves(editor).length}, ${state.phase}`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const draw = () => {
|
|
294
|
+
drawList()
|
|
295
|
+
if (!typing) drawDetails()
|
|
296
|
+
drawRing()
|
|
297
|
+
drawLive()
|
|
298
|
+
panel.setBadge(siegeWaves(editor).length)
|
|
299
|
+
}
|
|
300
|
+
draw()
|
|
301
|
+
drawView()
|
|
302
|
+
panel.onClose(() => {
|
|
303
|
+
for (const gone of fields.splice(0)) gone()
|
|
304
|
+
})
|
|
305
|
+
// the waves changed (a step done or undone here, the file changed on disk), the words typed, the game played
|
|
306
|
+
const offFile = editor.files.watch(FILE, (text) => {
|
|
307
|
+
if (text !== undefined && text !== `${JSON.stringify(siegeWaves(editor), null, '\t')}\n`) useWaves(editor, readWaves(JSON.parse(text)))
|
|
308
|
+
draw()
|
|
309
|
+
})
|
|
310
|
+
panel.onClose(offFile)
|
|
311
|
+
panel.on('play.started', drawLive)
|
|
312
|
+
panel.on('play.stopped', drawLive)
|
|
313
|
+
panel.on('play.tick', drawLive)
|
|
314
|
+
// the list follows the words typed (the filter is read as a Solid signal)
|
|
315
|
+
createRoot((dispose) => {
|
|
316
|
+
createEffect(on(filter, drawList, { defer: true }))
|
|
317
|
+
panel.onClose(dispose)
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The campaign's waves panel, in the editor's own way of saying a panel */
|
|
322
|
+
export const wavesPanel: EditorPanelDef = {
|
|
323
|
+
id: 'campaign.waves',
|
|
324
|
+
name: 'Waves',
|
|
325
|
+
icon: 'campaign.wave',
|
|
326
|
+
side: 'right',
|
|
327
|
+
// opens and closes it, the user's to move: W is the editor's, Shift+W the game's
|
|
328
|
+
key: 'Shift+KeyW',
|
|
329
|
+
// how many waves there are, on its icon, closed too
|
|
330
|
+
badge: (editor) => siegeWaves(editor).length,
|
|
331
|
+
header: {
|
|
332
|
+
mount(element, { editor }) {
|
|
333
|
+
element.append(
|
|
334
|
+
kit.button(
|
|
335
|
+
'Add',
|
|
336
|
+
() => {
|
|
337
|
+
change(editor, 'Add a wave', (waves) => void waves.push({ units: [{ type: 'raider', count: 4 }], interval: 1.5, bonus: 60 }))
|
|
338
|
+
editor.panels.api('campaign.waves')?.pick(siegeWaves(editor).length - 1)
|
|
339
|
+
},
|
|
340
|
+
{ variant: 'ghost', size: 'sm', tip: 'Add a wave after the last' }
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
// each wave, in the search of everything (and alone after `w`): picked, it is shown in the panel
|
|
346
|
+
prefix: 'w',
|
|
347
|
+
search: (editor): PanelSearchEntry[] =>
|
|
348
|
+
siegeWaves(editor).map((wave, index) => ({
|
|
349
|
+
id: String(index),
|
|
350
|
+
label: `Wave ${index + 1}`,
|
|
351
|
+
detail: describe(wave),
|
|
352
|
+
words: `${wave.units.map((unit) => unit.type).join(' ')}${wave.units.some((unit) => unit.type === 'brute') ? ' brute' : ''}`
|
|
353
|
+
})),
|
|
354
|
+
mount
|
|
355
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# My game
|
|
2
|
+
|
|
3
|
+
A game made with V12, at its start: a flat field, and a scout to walk it. Select it and right-click the ground.
|
|
4
|
+
|
|
5
|
+
| To | Run |
|
|
6
|
+
| ---------------------- | -------------------------------------------------------------------- |
|
|
7
|
+
| Play it | `npm run dev`, and open the page it says: its start screen, Play |
|
|
8
|
+
<!-- @if editor -->
|
|
9
|
+
| Make maps | `npm run dev`, and open `/?editor`: Save writes them into `src/maps` |
|
|
10
|
+
| Check the maps | `npm run map check`: every map in `src/maps` against the game's rules |
|
|
11
|
+
<!-- @end -->
|
|
12
|
+
<!-- @if server -->
|
|
13
|
+
<!-- @if !rooms -->
|
|
14
|
+
| Play it on a server | `npm run server`, beside `npm run dev`, and open `/?server` |
|
|
15
|
+
<!-- @end -->
|
|
16
|
+
<!-- @if rooms -->
|
|
17
|
+
| Play it online | `npm run server`, beside `npm run dev`, and open `/?find-game` |
|
|
18
|
+
| Try it alone online | the same, and join the room `practice`: the computer plays the rest |
|
|
19
|
+
<!-- @end -->
|
|
20
|
+
<!-- @end -->
|
|
21
|
+
<!-- @if tests -->
|
|
22
|
+
| Test the rules | `npm test` |
|
|
23
|
+
<!-- @end -->
|
|
24
|
+
| Check the types | `npm run typecheck` |
|
|
25
|
+
| Build the page to host | `npm run build`, into `dist/` |
|
|
26
|
+
|
|
27
|
+
## Where to go from here
|
|
28
|
+
|
|
29
|
+
1. **A second unit type.** Add it to `UNIT` and `units` in `src/rules.ts`, and its look to `src/game.ts`. Give it `attackDamage`, `attackSpeed` and `attackRange` in its `stats`, and an attack in its `attacks`, and it fights.
|
|
30
|
+
2. **A second player.** `players: 2` in `src/maps.ts`, with a start for each, and `start` gives them a scout too.
|
|
31
|
+
<!-- @if editor -->
|
|
32
|
+
3. **A map of your own.** Open `/?editor`, paint, raise ground, place units, and Save. Then import `src/maps/field.vgmap.json` in `src/maps.ts`, as its comment says.
|
|
33
|
+
<!-- @end -->
|
|
34
|
+
<!-- @if !editor -->
|
|
35
|
+
3. **A map of your own.** `createMap` in `src/maps.ts` makes the field; its `ground`, `tiles` and `objects` are yours to fill in code.
|
|
36
|
+
<!-- @end -->
|
|
37
|
+
4. **Winning.** `victory` in `src/rules.ts`: `coreVictory` makes the last side with a unit of a kind the winner.
|
|
38
|
+
<!-- @if assets -->
|
|
39
|
+
5. **A model.** One of the shared art's (`import { characters } from '@v12-engine/assets'`, then `model: { src: characters.models.knight, scale: 0.6 }` in its look in `src/game.ts`), or a `.glb` of yours in `public/models/` (`src: 'scout.glb'`). The map editor's Content lists the shared art's trees, rocks and buildings to put down as scenery.
|
|
40
|
+
<!-- @end -->
|
|
41
|
+
<!-- @if !assets -->
|
|
42
|
+
5. **A model.** A `.glb` in `public/models/`, and `model: { src: 'scout.glb', scale: 1 }` in its look in `src/game.ts`.
|
|
43
|
+
<!-- @end -->
|
|
44
|
+
|
|
45
|
+
## What is where
|
|
46
|
+
|
|
47
|
+
- `src/rules.ts`: the rules, what there is and what happens once a game starts. The engine alone (`@v12-engine/engine`), so the page, the server and the tests play the same game
|
|
48
|
+
- `src/game.ts`: how it looks, and the game whole (`defineGame`)
|
|
49
|
+
- `src/maps.ts`: the maps. One, made in code. A map says which game it is for (`game`, the rules' `id`)
|
|
50
|
+
- `src/main.ts`: the page, its scenes made by `appScenes`
|
|
51
|
+
<!-- @if editor -->
|
|
52
|
+
- `src/editor.ts`: the map editor's own settings, for `appScenes`
|
|
53
|
+
<!-- @end -->
|
|
54
|
+
<!-- @if server -->
|
|
55
|
+
<!-- @if !rooms -->
|
|
56
|
+
- `server.ts`: a server of the game, one game at a time, `startServer`
|
|
57
|
+
<!-- @end -->
|
|
58
|
+
<!-- @if rooms -->
|
|
59
|
+
- `server.ts`: a server of many games, `GameRooms`: a room for each map, games players make (New game) and are matched for (Play now). The page's `/?find-game` is the client's Find a game
|
|
60
|
+
<!-- @end -->
|
|
61
|
+
<!-- @if ratings -->
|
|
62
|
+
- `ratings.json` (made by the server): a rating for each player, moved by each game that ends with a winner, shown in the lobby
|
|
63
|
+
<!-- @end -->
|
|
64
|
+
<!-- @end -->
|
|
65
|
+
<!-- @if tests -->
|
|
66
|
+
- `tests/`: the rules tested with no page, `testWorld`
|
|
67
|
+
<!-- @end -->
|
|
68
|
+
- `public/` (make it when needed): the game's models (`.glb`, in `public/models/`), sounds and pictures
|
|
69
|
+
<!-- @if assets -->
|
|
70
|
+
- `@v12-engine/assets`: shared models to start with (adventurers with their clips, buildings, trees and rocks, props, weapons), served where `public/`'s files are by `assetsPackage()` in `vite.config.ts`; a file of yours at the same path is the one served, and a build copies what the game uses. Whose each file is: its group's `CREDITS.md` in `node_modules/@v12-engine/assets/groups/`
|
|
71
|
+
<!-- @end -->
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The rules, tested with no page: a world steps here as it does in a page (`@v12-engine/engine/testing`) */
|
|
2
|
+
import { checkRules, createWorldFromMap } from '@v12-engine/engine'
|
|
3
|
+
import { order, run, testWorld } from '@v12-engine/engine/testing'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { mapNamed } from '../src/maps'
|
|
6
|
+
import { rules, start, UNIT } from '../src/rules'
|
|
7
|
+
|
|
8
|
+
describe('the rules', () => {
|
|
9
|
+
it('fit together: every id they name is theirs, every effect has a handler (checkRules)', () => {
|
|
10
|
+
expect(checkRules(rules).filter((problem) => problem.level === 'error')).toEqual([])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('give each player with a start a scout, at it', async () => {
|
|
14
|
+
const map = mapNamed()
|
|
15
|
+
const world = await createWorldFromMap({ map, game: rules, seed: 1 })
|
|
16
|
+
start(world, { map })
|
|
17
|
+
const scouts = [...world.actors.values()].filter((entity) => entity.def.id === UNIT.scout)
|
|
18
|
+
expect(scouts).toHaveLength(map.players.length)
|
|
19
|
+
expect(scouts[0].pos.x).toBeCloseTo(map.players[0].start!.x)
|
|
20
|
+
world.dispose()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('send a scout where it is ordered', async () => {
|
|
24
|
+
const world = await testWorld(rules)
|
|
25
|
+
const scout = world.spawn(UNIT.scout, 1, { x: 10, z: 10 })
|
|
26
|
+
order(world, [scout], { type: 'move', point: { x: 30, z: 10 } })
|
|
27
|
+
run(world, 20, () => scout.pos.x > 29)
|
|
28
|
+
expect(scout.pos.x).toBeGreaterThan(29)
|
|
29
|
+
world.dispose()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** The game, whole: its rules and how it looks. What the page plays and the editor makes maps for */
|
|
2
|
+
import { defineGame, type GameView, hotView, presetBuffMarker, presetRallyFlag, presetSpellStyles, presetWeather, TILE_LOOKS } from '@v12-engine/client'
|
|
3
|
+
import { rules, type Unit } from './rules'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The look: a unit type is drawn from `parts` (boxes, cylinders, cones, spheres, pyramids) until it has a model, a
|
|
7
|
+
* `.glb` in `public/models/` named by `model` (`{ src: 'scout.glb', scale: 1 }`). `color: 'team'` is its side's colour
|
|
8
|
+
// @if assets
|
|
9
|
+
*
|
|
10
|
+
* The shared art has models to start with: `import { characters } from '@v12-engine/assets'` and
|
|
11
|
+
* `model: { src: characters.models.knight, scale: 0.6 }` (played with its own clips)
|
|
12
|
+
// @end
|
|
13
|
+
*/
|
|
14
|
+
export const view: GameView<Unit> = {
|
|
15
|
+
scene: {
|
|
16
|
+
// trees and scenery in front of the player's own units faded out (`seeThrough`): who for, how, how much
|
|
17
|
+
seeThrough: {},
|
|
18
|
+
/**
|
|
19
|
+
* Looks by name to start a map from: the map editor's Look panel puts one on a map (Preset), a scene plays a map with
|
|
20
|
+
* one (`gameScene({ environment: 'dusk' })`), the rules change to one (`world.setEnvironment(view.scene.environments.storm,
|
|
21
|
+
* { over: 20 })`). Each is a `MapEnvironment`: sky, light, mist, wind, clouds, weather, water
|
|
22
|
+
*/
|
|
23
|
+
environments: {
|
|
24
|
+
dusk: {
|
|
25
|
+
sky: [0.55, 0.42, 0.5],
|
|
26
|
+
clouds: { cover: 0.4, color: [1, 0.8, 0.7] },
|
|
27
|
+
light: { sunUp: 12, sunAround: 250, sunColor: [1, 0.62, 0.42], sun: 1, ambient: 0.45, ambientColor: [0.8, 0.7, 0.9] },
|
|
28
|
+
fog: { start: 60, end: 200 },
|
|
29
|
+
mist: { ground: { base: 0, top: 2, density: 0.25 } }
|
|
30
|
+
},
|
|
31
|
+
storm: {
|
|
32
|
+
sky: [0.3, 0.33, 0.38],
|
|
33
|
+
clouds: { cover: 0.9, color: [0.55, 0.57, 0.6] },
|
|
34
|
+
cloudLayer: { height: 18, cover: 0.7, opacity: 0.55, shadows: 0.5 },
|
|
35
|
+
light: { sun: 0.7, ambient: 0.5 },
|
|
36
|
+
wind: { around: 70, strength: 0.8, gusts: 0.6 },
|
|
37
|
+
weather: { kind: 'rain', amount: 0.9, splashes: 0.6 },
|
|
38
|
+
fog: { start: 40, end: 140 }
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
// the ready-made rain (its map has some), styles of abilities, rally flag and buff glow: the client draws none by itself
|
|
42
|
+
weather: presetWeather()
|
|
43
|
+
},
|
|
44
|
+
fx: {
|
|
45
|
+
abilities: {
|
|
46
|
+
styles: presetSpellStyles()
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
units: {
|
|
50
|
+
looks: {
|
|
51
|
+
scout: {
|
|
52
|
+
height: 1.4,
|
|
53
|
+
parts: [
|
|
54
|
+
{ shape: 'cylinder', size: [0.6, 1, 0.6], offset: [0, 0.5, 0], color: 'team' },
|
|
55
|
+
{ shape: 'sphere', size: [0.45, 0.45, 0.45], offset: [0, 1.2, 0], color: [0.9, 0.8, 0.65] }
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
rallyFlag: presetRallyFlag(),
|
|
60
|
+
buffMarker: presetBuffMarker()
|
|
61
|
+
},
|
|
62
|
+
ground: {
|
|
63
|
+
/**
|
|
64
|
+
* How each tile of the ground (src/tiles.ts) looks: a colour (the minimap's, and the ground's until its picture comes)
|
|
65
|
+
* and a picture repeated over the ground. A picture is a file in `public/textures/` by its path there, or made in
|
|
66
|
+
* code with no file: `pattern: 'grass'` (grass, gravel, rock, cobbles, planks, ice, lava… fifteen), with a `recipe` (a second
|
|
67
|
+
* colour, specks, how strong, and the pattern's own parameters: `params: { seams: 1.2 }`), or a pattern of the
|
|
68
|
+
* game's own (`patterns: { cobbles: definePattern({ name, params, shade }) }` on the view, then `pattern: 'cobbles'`;
|
|
69
|
+
* the map editor offers it with a slider for each parameter)
|
|
70
|
+
*/
|
|
71
|
+
// the client's looks of the engine's tiles, made in code (no files), and the road as gravel of a colour of its own
|
|
72
|
+
tiles: { ...TILE_LOOKS, road: { color: [0.55, 0.52, 0.47], pattern: 'gravel' } }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const game = defineGame({ rules, view, check: 'throw' })
|
|
77
|
+
|
|
78
|
+
// saved while it runs (npm run dev): the game and the editor open take its new look, with no new page
|
|
79
|
+
if (import.meta.hot) import.meta.hot.accept((module) => hotView(import.meta.hot, rules.id, view, module?.view))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's worker that makes the random map's preview with the game's generator (`rules.generator`), so the menu
|
|
3
|
+
* goes on while it is made. Given to the app in `main.ts` (`generatorWorker`)
|
|
4
|
+
*/
|
|
5
|
+
// the rules alone: nothing of the client is loaded into the worker
|
|
6
|
+
import { serveMapGenerator } from '@v12-engine/engine'
|
|
7
|
+
import { rules } from './rules'
|
|
8
|
+
|
|
9
|
+
serveMapGenerator(rules)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The maps of the game. There is one, made here in code: a flat field with a player on it and a road across it.
|
|
3
|
+
// @if editor
|
|
4
|
+
* The map editor (`npm run dev`, then `/?editor`) opens it, and Save writes it into `src/maps/field.vgmap.json`. From
|
|
5
|
+
* then on the file is the map: import it here in its place, as the lines below say, and `flatField` can go
|
|
6
|
+
// @end
|
|
7
|
+
*/
|
|
8
|
+
import { createMap, type GameMap } from '@v12-engine/engine'
|
|
9
|
+
import { tiles } from './tiles'
|
|
10
|
+
// @if editor
|
|
11
|
+
// import { mapsFrom } from '@v12-engine/engine'
|
|
12
|
+
// import field from './maps/field.vgmap.json'
|
|
13
|
+
// @end
|
|
14
|
+
|
|
15
|
+
/** A flat field of 32 cells a side, a player with a start in its middle, and a road across it */
|
|
16
|
+
function flatField(): GameMap {
|
|
17
|
+
const map = createMap({ name: 'Field', width: 32, depth: 32, players: 1, game: 'my-game', tileset: tiles.map((tile) => tile.id) })
|
|
18
|
+
map.players[0].start = { x: 16, z: 16 }
|
|
19
|
+
// a cell's paint is the place of its tile in the map's tileset, from 1 (0 is none: the ground's own colour)
|
|
20
|
+
const road = map.tileset.indexOf('road') + 1
|
|
21
|
+
for (let z = 20; z < 22; z++) for (let x = 0; x < map.width; x++) map.tiles[z * map.width + x] = road
|
|
22
|
+
return map
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The maps by name */
|
|
26
|
+
// @if editor
|
|
27
|
+
// once the editor has saved the field: mapsFrom({ './maps/field.vgmap.json': field })
|
|
28
|
+
// @end
|
|
29
|
+
export const maps: Readonly<Record<string, GameMap>> = { field: flatField() }
|
|
30
|
+
|
|
31
|
+
// @if editor
|
|
32
|
+
/** The map a game is on when nothing says which, and the one the editor opens first */
|
|
33
|
+
// @end
|
|
34
|
+
// @if !editor
|
|
35
|
+
/** The map a game is on when nothing says which */
|
|
36
|
+
// @end
|
|
37
|
+
export const DEFAULT_MAP = 'field'
|
|
38
|
+
|
|
39
|
+
/** A map by name, as a copy: a game changes nothing of what the next one starts from */
|
|
40
|
+
export function mapNamed(name = DEFAULT_MAP): GameMap {
|
|
41
|
+
const map = maps[name]
|
|
42
|
+
if (!map) throw new Error(`There is no map "${name}": the maps are ${Object.keys(maps).join(', ')}`)
|
|
43
|
+
return structuredClone(map)
|
|
44
|
+
}
|