@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
package/index.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project to start from (S60): `npm create @v12-engine my-game` puts one together in `my-game` from layers (create
|
|
3
|
+
* N2), each a folder of files laid over the ones before:
|
|
4
|
+
*
|
|
5
|
+
* 1. `base/`: what every project has: its page, its `package.json`, its TypeScript config
|
|
6
|
+
* 2. `layers/<option>/`: what an option on adds (the editor, a server, the tests), in `LAYERS`'s order
|
|
7
|
+
* 3. `starters/<starter>/`: the game, its rules, look and maps, and its README
|
|
8
|
+
* 4. `starters/<starter>/layers/<option>/`: what the starter adds with an option on (its tests)
|
|
9
|
+
*
|
|
10
|
+
* A `package.json` of a layer is merged into the one before (scripts, dependencies), not laid over it. A line of
|
|
11
|
+
* `// @if <option>`, `<!-- @if <option> -->` or `# @if <option>` keeps what follows, to its `@end`, only with the option on (`!<option>`
|
|
12
|
+
* with it off, `a|b` with either on); the marker lines are left out. The command line (`cli.mjs`) asks or is told how, and the install test
|
|
13
|
+
* makes its project with this too (`scripts/install-test/run.mjs`), so what is made is what is tested
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
16
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { DEFAULT_STARTER, starterNamed } from './starters.mjs'
|
|
20
|
+
|
|
21
|
+
export { DEFAULT_STARTER, starterNamed, starters } from './starters.mjs'
|
|
22
|
+
|
|
23
|
+
const here = fileURLToPath(new URL('.', import.meta.url))
|
|
24
|
+
/** What the packages of the library are installed as: the version of this one, as the three are versioned together */
|
|
25
|
+
const own = JSON.parse(await readFile(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'))
|
|
26
|
+
|
|
27
|
+
/** What a package may be called (npm's rules): lower case, no spaces, nothing that leads out of a folder */
|
|
28
|
+
export function packageName(folder) {
|
|
29
|
+
const name = basename(resolve(folder))
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9._~-]+/g, '-')
|
|
32
|
+
.replace(/^[._-]+|-+$/g, '')
|
|
33
|
+
return name || 'my-game'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a game is called where it is shown, from its folder's name: `orc-wars`, Orc wars; `Orc Wars`, Orc Wars. My game
|
|
38
|
+
* where the folder's name makes no name
|
|
39
|
+
*/
|
|
40
|
+
export function titleOf(folder) {
|
|
41
|
+
const words = basename(resolve(folder))
|
|
42
|
+
.replace(/[-_.~\s]+/g, ' ')
|
|
43
|
+
.trim()
|
|
44
|
+
const title = words ? words[0].toUpperCase() + words.slice(1) : ''
|
|
45
|
+
return title && !titleProblem(title) ? title : 'My game'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why a game cannot be called this, or nothing: the title is written into its code, its page and its README as it is,
|
|
50
|
+
* so it has no quotes, backslashes or angle brackets, and fits on a line
|
|
51
|
+
*/
|
|
52
|
+
export function titleProblem(title) {
|
|
53
|
+
if (!title.trim()) return 'A game needs a name'
|
|
54
|
+
if (title.length > 60) return 'A name of 60 letters at most'
|
|
55
|
+
if (/["'`\\<>\n\r]/.test(title)) return 'A name without quotes, backslashes or angle brackets'
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The options that are layers, in the order they are laid: each may have a folder in `layers/` and in a starter's
|
|
61
|
+
* `layers/`, and is a name its files' `@if` lines may ask about
|
|
62
|
+
*/
|
|
63
|
+
export const LAYERS = ['editor', 'server', 'rooms', 'tests', 'assets', 'source']
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What a file's `@if` lines may ask about: the layers, and what changes a few lines and has no files of its own
|
|
67
|
+
* (`ratings`; `single`, a server of one game at a time, where `server` is any server and `rooms` one of rooms;
|
|
68
|
+
* `generator`, the starter's, not the command line's: its random map made in a worker, `starters`)
|
|
69
|
+
*/
|
|
70
|
+
export const OPTIONS = ['editor', 'server', 'single', 'rooms', 'ratings', 'tests', 'assets', 'source', 'generator']
|
|
71
|
+
|
|
72
|
+
/** What a server of the game is: one game at a time (`single`), many games in rooms that players find and make (`rooms`), or none */
|
|
73
|
+
export const SERVERS = ['single', 'rooms', 'none']
|
|
74
|
+
|
|
75
|
+
/** What may install and run a project: its words for that are `commandsOf`'s */
|
|
76
|
+
export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun']
|
|
77
|
+
|
|
78
|
+
/** A package manager's words: what installs, and what runs a script of the project */
|
|
79
|
+
export function commandsOf(packageManager = 'npm') {
|
|
80
|
+
if (!PACKAGE_MANAGERS.includes(packageManager))
|
|
81
|
+
throw new Error(`There is no package manager "${packageManager}": they are ${PACKAGE_MANAGERS.join(', ')}`)
|
|
82
|
+
const install = packageManager === 'yarn' ? 'yarn' : `${packageManager} install`
|
|
83
|
+
const run = (script) =>
|
|
84
|
+
packageManager === 'npm'
|
|
85
|
+
? script === 'test'
|
|
86
|
+
? 'npm test'
|
|
87
|
+
: `npm run ${script}`
|
|
88
|
+
: packageManager === 'bun'
|
|
89
|
+
? `bun run ${script}`
|
|
90
|
+
: `${packageManager} ${script}`
|
|
91
|
+
return { install, run }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The files whose words may be the game's name and may have `@if` lines: its code, its page, its README, its maps */
|
|
95
|
+
const TEXT = new Set(['.ts', '.mjs', '.html', '.md', '.json', '.css'])
|
|
96
|
+
const isText = (path) => TEXT.has(extname(path)) || basename(path) === '_gitignore'
|
|
97
|
+
|
|
98
|
+
/** The order of a project's scripts, as one reads them: the rest after, as they came */
|
|
99
|
+
const SCRIPTS = ['dev', 'build', 'preview', 'server', 'test', 'typecheck']
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Makes a project in `folder`, which is to be empty or not there:
|
|
103
|
+
*
|
|
104
|
+
* - `starter`: the game it starts from (`starters`), `DEFAULT_STARTER` (`empty`) when left out, as on the command line
|
|
105
|
+
* - `title`: what the game is called where it is shown, made from the folder's name when left out (`orc-wars`, Orc wars)
|
|
106
|
+
* - `editor`: its map editor (`/?editor`, `src/editor.ts`, the dev server's plugin); on when left out
|
|
107
|
+
* - `server`: a server of the game: `single`, one game at a time (`server.ts`, `/?server`); `rooms`, a room for each
|
|
108
|
+
* map, games players make and matchmaking (`/?find-game`, Find a game); or `none`. `single` when left out
|
|
109
|
+
* - `ratings`: the server keeps a rating for each player (`ratings.json`); off when left out, and with no server
|
|
110
|
+
* - `tests`: a test of its rules (`tests/`, Vitest); on when left out
|
|
111
|
+
* - `assets`: the shared art (`@v12-engine/assets`: models only: adventurers with their clips, buildings, trees and rocks, props, weapons), served by the dev server and
|
|
112
|
+
* copied into a build as the game uses it (`assetsPackage`); on when left out, and always for a starter that plays
|
|
113
|
+
* with it (`needs`)
|
|
114
|
+
* - `packageManager`: what installs and runs it (`PACKAGE_MANAGERS`): its README and comments say its words; `npm`
|
|
115
|
+
* when left out
|
|
116
|
+
* - `source`: the library read as it is written, for a project inside the library's own repository (`apps/`): its
|
|
117
|
+
* configs name the condition the packages' `exports` give their source under, with the plugins the source needs,
|
|
118
|
+
* and `{{root}}` in the layer's files is the way to the repository's root from the project. Off when left out
|
|
119
|
+
* - `dependencies`: written over the template's (the install test's packed packages, `file:./…`); the library's are
|
|
120
|
+
* `^` this package's version otherwise
|
|
121
|
+
*
|
|
122
|
+
* Nothing is written until the whole project is put together. Gives the project's name, its title, where it is, and
|
|
123
|
+
* the options it was made with
|
|
124
|
+
*/
|
|
125
|
+
export async function createProject(folder, options = {}) {
|
|
126
|
+
const {
|
|
127
|
+
starter = DEFAULT_STARTER,
|
|
128
|
+
editor = true,
|
|
129
|
+
server = 'single',
|
|
130
|
+
tests = true,
|
|
131
|
+
source = false,
|
|
132
|
+
packageManager = 'npm',
|
|
133
|
+
dependencies = {}
|
|
134
|
+
} = options
|
|
135
|
+
const ratings = !!options.ratings && server !== 'none'
|
|
136
|
+
const words = commandsOf(packageManager)
|
|
137
|
+
const needs = starterNamed(starter).needs ?? []
|
|
138
|
+
if (options.assets === false && needs.includes('assets'))
|
|
139
|
+
throw new Error(`The ${starter} starter plays with the shared art (@v12-engine/assets): it cannot be left out`)
|
|
140
|
+
const assets = options.assets ?? true
|
|
141
|
+
if (!SERVERS.includes(server)) throw new Error(`There is no server "${server}": a server is ${SERVERS.join(', ')}`)
|
|
142
|
+
const where = resolve(folder)
|
|
143
|
+
if (notEmpty(where)) throw new Error(`${where} is not empty: a project is made in an empty folder, or a new one`)
|
|
144
|
+
const name = packageName(where)
|
|
145
|
+
const title = options.title ?? titleOf(where)
|
|
146
|
+
const problem = titleProblem(title)
|
|
147
|
+
if (problem) throw new Error(`"${title}": ${problem}`)
|
|
148
|
+
const generator = !!starterNamed(starter).generator
|
|
149
|
+
const on = { editor, server: server !== 'none', single: server === 'single', rooms: server === 'rooms', ratings, tests, assets, source, generator }
|
|
150
|
+
// the way from the project to the repository's root (`{{root}}` in the source layer): this package is `packages/create` of it
|
|
151
|
+
const root = relative(where, resolve(here, '../..')).split(sep).join('/') || '.'
|
|
152
|
+
const files = await putTogether(starter, on)
|
|
153
|
+
const project = JSON.parse(files.get('package.json'))
|
|
154
|
+
project.name = name
|
|
155
|
+
// the map editor's package only in a project with the editor: one without it installs none of it
|
|
156
|
+
for (const library of [
|
|
157
|
+
'@v12-engine/engine',
|
|
158
|
+
'@v12-engine/client',
|
|
159
|
+
...(editor ? ['@v12-engine/editor'] : []),
|
|
160
|
+
...(assets ? ['@v12-engine/assets'] : [])
|
|
161
|
+
])
|
|
162
|
+
project.dependencies[library] = `^${own.version}`
|
|
163
|
+
// the `v12` command's package too, a dev dependency of a project with the editor: the five are versioned as one
|
|
164
|
+
if (project.devDependencies?.['@v12-engine/create']) project.devDependencies['@v12-engine/create'] = `^${own.version}`
|
|
165
|
+
// what is given for the library's packages stands in for those the project has (the install test's packed ones),
|
|
166
|
+
// a dev dependency (the `v12` command's package) among them
|
|
167
|
+
for (const [dependency, version] of Object.entries(dependencies)) {
|
|
168
|
+
if (project.devDependencies && dependency in project.devDependencies) project.devDependencies[dependency] = version
|
|
169
|
+
else if (!dependency.startsWith('@v12-engine/') || dependency in project.dependencies) project.dependencies[dependency] = version
|
|
170
|
+
}
|
|
171
|
+
files.set('package.json', `${JSON.stringify(project, null, '\t')}\n`)
|
|
172
|
+
for (const [path, content] of files) {
|
|
173
|
+
const file = join(where, path === '_gitignore' ? '.gitignore' : path)
|
|
174
|
+
await mkdir(dirname(file), { recursive: true })
|
|
175
|
+
await writeFile(
|
|
176
|
+
file,
|
|
177
|
+
typeof content === 'string'
|
|
178
|
+
? said(
|
|
179
|
+
named(content, name, title).replaceAll('{{root}}', () => root),
|
|
180
|
+
words,
|
|
181
|
+
path
|
|
182
|
+
)
|
|
183
|
+
: content
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
return { name, title, where, starter, editor, server, ratings, tests, assets, source, packageManager }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The files of a project of `starter` with the options `on` (by name, true or false), by their path in it: text with
|
|
191
|
+
* its `@if` lines kept or left out, the rest as bytes. Throws for a marker it cannot read, wherever it is
|
|
192
|
+
*/
|
|
193
|
+
export async function putTogether(starter, on) {
|
|
194
|
+
const folders = [
|
|
195
|
+
join(here, 'base'),
|
|
196
|
+
...LAYERS.filter((layer) => on[layer]).map((layer) => join(here, 'layers', layer)),
|
|
197
|
+
join(here, 'starters', starter),
|
|
198
|
+
...LAYERS.filter((layer) => on[layer]).map((layer) => join(here, 'starters', starter, 'layers', layer))
|
|
199
|
+
]
|
|
200
|
+
const files = new Map()
|
|
201
|
+
for (const folder of folders) {
|
|
202
|
+
if (!existsSync(folder)) continue
|
|
203
|
+
for (const entry of await readdir(folder, { recursive: true, withFileTypes: true })) {
|
|
204
|
+
if (!entry.isFile()) continue
|
|
205
|
+
const path = relative(folder, join(entry.parentPath, entry.name)).split('\\').join('/')
|
|
206
|
+
// a starter's own layers are laid after it, each when its option is on; its list of the files its page asks
|
|
207
|
+
// for (`files.txt`, which the tests read) is no part of a game
|
|
208
|
+
if (folder.endsWith(join('starters', starter)) && (path.startsWith('layers/') || path === 'files.txt')) continue
|
|
209
|
+
const bytes = await readFile(join(entry.parentPath, entry.name))
|
|
210
|
+
if (!isText(path)) {
|
|
211
|
+
files.set(path, bytes)
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const text = kept(bytes.toString('utf8'), on, path)
|
|
215
|
+
// a file all of whose lines are for options off is not there: the Vite config of a project with neither the
|
|
216
|
+
// editor nor the shared art
|
|
217
|
+
if (!text.trim()) {
|
|
218
|
+
files.delete(path)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
files.set(path, basename(path) === 'package.json' && files.has(path) ? merged(files.get(path), text) : text)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return files
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `text` with what its `@if` lines keep for the options `on`, without them; `path` is for what an error says */
|
|
228
|
+
export function kept(text, on, path = 'a file') {
|
|
229
|
+
const marker = /^\s*(?:\/\/|<!--|#)\s*@(if|end)\b\s*(!?)([\w|-]*)\s*(?:-->)?\s*$/
|
|
230
|
+
const open = []
|
|
231
|
+
const lines = []
|
|
232
|
+
text.split('\n').forEach((line, index) => {
|
|
233
|
+
const found = marker.exec(line)
|
|
234
|
+
if (!found) {
|
|
235
|
+
if (open.every(Boolean)) lines.push(line)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const [, word, not, asked] = found
|
|
239
|
+
if (word === 'end') {
|
|
240
|
+
if (!open.length) throw new Error(`${path}:${index + 1}: an @end with no @if`)
|
|
241
|
+
open.pop()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
// `@if editor|server`: any of them on
|
|
245
|
+
const options = asked.split('|')
|
|
246
|
+
for (const option of options) {
|
|
247
|
+
if (!(option in on)) throw new Error(`${path}:${index + 1}: @if ${option}, an option there is not (${Object.keys(on).join(', ')})`)
|
|
248
|
+
}
|
|
249
|
+
const any = options.some((option) => on[option])
|
|
250
|
+
open.push(not ? !any : any)
|
|
251
|
+
})
|
|
252
|
+
if (open.length) throw new Error(`${path}: an @if with no @end`)
|
|
253
|
+
return lines.join('\n')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A layer's `package.json` merged into the one before: its scripts and dependencies added, in the order one reads them */
|
|
257
|
+
function merged(before, layer) {
|
|
258
|
+
const project = JSON.parse(before)
|
|
259
|
+
for (const [key, value] of Object.entries(JSON.parse(layer))) {
|
|
260
|
+
project[key] = value && typeof value === 'object' && !Array.isArray(value) ? { ...project[key], ...value } : value
|
|
261
|
+
}
|
|
262
|
+
const order = (key) => (SCRIPTS.includes(key) ? SCRIPTS.indexOf(key) : SCRIPTS.length)
|
|
263
|
+
if (project.scripts) project.scripts = Object.fromEntries(Object.entries(project.scripts).sort(([a], [b]) => order(a) - order(b)))
|
|
264
|
+
for (const key of ['dependencies', 'devDependencies']) {
|
|
265
|
+
if (project[key]) project[key] = Object.fromEntries(Object.entries(project[key]).sort(([a], [b]) => a.localeCompare(b)))
|
|
266
|
+
}
|
|
267
|
+
return `${JSON.stringify(project, null, '\t')}\n`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The starters' game is `my-game`, My game: the project's is its own, in its code, its page, its README and its maps */
|
|
271
|
+
const named = (text, name, title) => text.replaceAll('my-game', () => name).replaceAll('My game', () => title)
|
|
272
|
+
|
|
273
|
+
/** Folders git or the system leaves in a folder that is still new: a project is made beside them */
|
|
274
|
+
const LEFT_BY_TOOLS = new Set(['.git', '.DS_Store', 'Thumbs.db'])
|
|
275
|
+
|
|
276
|
+
/** Whether there is something in a folder: a project is made in an empty one (a fresh `git init` is empty) */
|
|
277
|
+
export const notEmpty = (where) => existsSync(where) && readdirSync(where).some((entry) => !LEFT_BY_TOOLS.has(entry))
|
|
278
|
+
|
|
279
|
+
/** The starters say npm's words (`npm run dev`, `npm test`): the project says its package manager's, but in its `package.json` */
|
|
280
|
+
const said = (text, words, path) =>
|
|
281
|
+
basename(path) === 'package.json'
|
|
282
|
+
? text
|
|
283
|
+
: text
|
|
284
|
+
.replace(/\bnpm run ([\w:-]+)/g, (_, script) => words.run(script))
|
|
285
|
+
.replace(/\bnpm test\b/g, words.run('test'))
|
|
286
|
+
.replace(/\bnpm install\b/g, words.install)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The map editor of the game, a scene of the app (Map editor on the start screen, or `/?editor`) that `appScenes` makes
|
|
3
|
+
* (`src/main.ts`) with the editor's `editorScene`: it opens on the game's first map, as a file of the project that Save
|
|
4
|
+
* writes back, and Play here tries it in the page's own game. What is set here is the editor's own: what a game adds to
|
|
5
|
+
* it (`extensions`, made with `defineEditorExtension` of `@v12-engine/editor`), another map to open on (`startMap`,
|
|
6
|
+
* `startFile`)
|
|
7
|
+
*/
|
|
8
|
+
import type { AppEditorScene } from '@v12-engine/client/app'
|
|
9
|
+
import '@v12-engine/editor/styles.css'
|
|
10
|
+
|
|
11
|
+
// what the app's scenes say of it (its id, the game, the map it starts on, Play here's scene), and the game's own after.
|
|
12
|
+
// The editor fetched as it first opens: the start screen carries none of it
|
|
13
|
+
export const editor = (scene: AppEditorScene) => import('@v12-engine/editor').then(({ editorScene }) => editorScene({ ...scene }))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A server of many games of the game (`npm run server`), in rooms that share its port. The page finds them at
|
|
3
|
+
* `/?find-game` (`npm run dev` beside it): the games to join or watch, a New game of any map, and Play now to be matched
|
|
4
|
+
* with someone. `PORT` is the port, 8787 when not said
|
|
5
|
+
*/
|
|
6
|
+
import { GameRooms, generatedMode, mapMode, type GameMode, type RoomKind } from '@v12-engine/engine/server'
|
|
7
|
+
// @if ratings
|
|
8
|
+
import { createRatings } from '@v12-engine/engine/server'
|
|
9
|
+
// @end
|
|
10
|
+
import { DEFAULT_MAP, mapNamed, maps } from './src/maps'
|
|
11
|
+
import { rules, start } from './src/rules'
|
|
12
|
+
|
|
13
|
+
/** A game on one of the game's maps, each world started as the page starts it */
|
|
14
|
+
const onMap = (name: string): GameMode => mapMode(rules, mapNamed(name), { start })
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A game on a map the game's generator makes (`rules.generator`): another for each game made, of 128 cells a side for
|
|
18
|
+
* two, turned half about the middle so it is fair; the seed a New game asks for (`settings.seed`) where it says one
|
|
19
|
+
*/
|
|
20
|
+
const random: Record<string, RoomKind> = rules.generator
|
|
21
|
+
? {
|
|
22
|
+
random: {
|
|
23
|
+
name: 'A random map',
|
|
24
|
+
room: (settings) => ({
|
|
25
|
+
mode: generatedMode(rules, rules.generator!, {
|
|
26
|
+
width: 128,
|
|
27
|
+
depth: 128,
|
|
28
|
+
players: 2,
|
|
29
|
+
symmetry: 'half',
|
|
30
|
+
...(Number(settings.seed) > 0 && { seed: Number(settings.seed) }),
|
|
31
|
+
start
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
: {}
|
|
37
|
+
|
|
38
|
+
// @if ratings
|
|
39
|
+
// a rating for each player, moved by every game that ends with a winner, kept in a file (`ratings.json`)
|
|
40
|
+
const ratings = createRatings({ file: 'ratings.json' })
|
|
41
|
+
// a file that cannot be read stops the server here, rather than being written over
|
|
42
|
+
await ratings.ready
|
|
43
|
+
// @end
|
|
44
|
+
// the seats the computer plays in the room to try the game alone in: every player of the first map but the first
|
|
45
|
+
const computer = mapNamed()
|
|
46
|
+
.players.slice(1)
|
|
47
|
+
.map((player) => player.id)
|
|
48
|
+
const rooms = await GameRooms.start({
|
|
49
|
+
port: Number(process.env.PORT ?? 8787),
|
|
50
|
+
rooms: {
|
|
51
|
+
// a room for each map, for people: ws://host:8787/<map>. Its game starts once every seat is taken
|
|
52
|
+
...Object.fromEntries(Object.keys(maps).map((name) => [name, { mode: onMap(name) }])),
|
|
53
|
+
// and one to try the game alone in
|
|
54
|
+
practice: { mode: onMap(DEFAULT_MAP), ai: computer }
|
|
55
|
+
},
|
|
56
|
+
// what players make games of (New game), each closed once nobody has been in it for a minute; and are matched for
|
|
57
|
+
// (Play now), once as many wait as it has seats
|
|
58
|
+
kinds: { ...Object.fromEntries(Object.entries(maps).map(([name, map]) => [name, { name: map.name, room: { mode: onMap(name) } }])), ...random },
|
|
59
|
+
// @if ratings
|
|
60
|
+
ratings,
|
|
61
|
+
// @end
|
|
62
|
+
log: (line) => console.log(line)
|
|
63
|
+
})
|
|
64
|
+
console.log(`The game's server is at ws://localhost:${rooms.port}: open the page at /?find-game to find a game on it`)
|
|
65
|
+
|
|
66
|
+
// stopped (Ctrl+C, or SIGTERM where it is hosted): no new games, and those running have 30 seconds to end
|
|
67
|
+
const stop = () => void rooms.close({ drain: 30 }).then(() => process.exit(0))
|
|
68
|
+
process.once('SIGINT', stop)
|
|
69
|
+
process.once('SIGTERM', stop)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A server of the game (`npm run server`): each game on the first map, the first player's seat for whoever joins and
|
|
3
|
+
* the rest played by the computer. The page joins it at `/?server` (`npm run dev` beside it). `PORT` is the port,
|
|
4
|
+
* 8787 when not said
|
|
5
|
+
*/
|
|
6
|
+
import { startServer } from '@v12-engine/engine/server'
|
|
7
|
+
// @if ratings
|
|
8
|
+
import { createRatings } from '@v12-engine/engine/server'
|
|
9
|
+
// @end
|
|
10
|
+
import { mapNamed } from './src/maps'
|
|
11
|
+
import { rules, start } from './src/rules'
|
|
12
|
+
|
|
13
|
+
const map = mapNamed()
|
|
14
|
+
// @if ratings
|
|
15
|
+
// a rating for each player, moved by every game that ends with a winner, kept in a file (`ratings.json`)
|
|
16
|
+
const ratings = createRatings({ file: 'ratings.json' })
|
|
17
|
+
// a file that cannot be read stops the server here, rather than being written over
|
|
18
|
+
await ratings.ready
|
|
19
|
+
// @end
|
|
20
|
+
const server = await startServer({
|
|
21
|
+
game: rules,
|
|
22
|
+
map,
|
|
23
|
+
start,
|
|
24
|
+
// the seats the computer plays: leave it out for a game of people alone, which starts once every seat is taken
|
|
25
|
+
ai: map.players.slice(1).map((player) => player.id),
|
|
26
|
+
// @if ratings
|
|
27
|
+
ratings,
|
|
28
|
+
// @end
|
|
29
|
+
port: Number(process.env.PORT ?? 8787)
|
|
30
|
+
})
|
|
31
|
+
console.log(`The game's server is at ws://localhost:${server.port}: open the page at /?server to play on it`)
|
|
32
|
+
|
|
33
|
+
// stopped (Ctrl+C), it closes its connections first
|
|
34
|
+
process.once('SIGINT', () => void server.close().then(() => process.exit(0)))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import solid from 'vite-plugin-solid'
|
|
2
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
3
|
+
import { defineConfig } from 'vite'
|
|
4
|
+
// @if tests
|
|
5
|
+
// the `test` key of the config
|
|
6
|
+
import type {} from 'vitest/config'
|
|
7
|
+
// @end
|
|
8
|
+
// what every config of the library's repository says alike: its packages read as they are written
|
|
9
|
+
import { sourceLibrary } from '{{root}}/config/vite.ts'
|
|
10
|
+
// @if editor
|
|
11
|
+
// the editor's plugin as it is written, not as it is built (`@v12-engine/editor/vite`): nothing of the library is built here
|
|
12
|
+
import { editorFiles } from '{{root}}/packages/editor/plugin/editorFiles.ts'
|
|
13
|
+
// @end
|
|
14
|
+
// @if assets
|
|
15
|
+
import { assetsPackage } from '{{root}}/packages/client/plugin/assetsPackage.ts'
|
|
16
|
+
// @end
|
|
17
|
+
|
|
18
|
+
// The library as it is written: a change to the engine or the client shows here at once, and nothing is built. Solid's
|
|
19
|
+
// plugin and Tailwind's are here because the client's source is read (its components, its stylesheet): a project that
|
|
20
|
+
// installs the packages needs neither.
|
|
21
|
+
// @if assets
|
|
22
|
+
// The shared art (@v12-engine/assets) is served where the game's shared files are (/models/…),
|
|
23
|
+
// behind public/: a file of yours at the same path is the one served. A build copies what the game uses of it
|
|
24
|
+
// @end
|
|
25
|
+
// @if editor
|
|
26
|
+
// The map editor keeps the game's maps in the project through the dev server: Save writes them into `src/maps`. Only
|
|
27
|
+
// in `npm run dev`; a built page keeps maps in the browser
|
|
28
|
+
// @end
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
plugins: [
|
|
31
|
+
!process.env.VITEST && solid(),
|
|
32
|
+
tailwindcss(),
|
|
33
|
+
// @if assets
|
|
34
|
+
assetsPackage(),
|
|
35
|
+
// @end
|
|
36
|
+
// @if editor
|
|
37
|
+
editorFiles({ gameFolder: () => 'src' })
|
|
38
|
+
// @end
|
|
39
|
+
],
|
|
40
|
+
// the packages' source on the page, on the dev server's own side and in the tests (the library's `config/vite.ts`)
|
|
41
|
+
...sourceLibrary(),
|
|
42
|
+
// @if tests
|
|
43
|
+
test: { environment: 'node' }
|
|
44
|
+
// @end
|
|
45
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@v12-engine/create",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/DoronTorangy/V12.git",
|
|
11
|
+
"directory": "packages/create"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"description": "A project to start a game from: npm create @v12-engine my-game",
|
|
15
|
+
"bin": {
|
|
16
|
+
"create": "./bin.mjs",
|
|
17
|
+
"create-v12": "./bin.mjs",
|
|
18
|
+
"v12": "./v12.mjs"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./index.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin.mjs",
|
|
25
|
+
"v12.mjs",
|
|
26
|
+
"cli.mjs",
|
|
27
|
+
"index.mjs",
|
|
28
|
+
"starters.mjs",
|
|
29
|
+
"base",
|
|
30
|
+
"layers",
|
|
31
|
+
"starters"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.12"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "vitest run --dir tests",
|
|
38
|
+
"typecheck": "tsc --noEmit && node scripts/typecheckStarters.mjs"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"vitest": "^5.0.5"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@clack/prompts": "^1.8.1",
|
|
45
|
+
"commander": "^15.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# My game
|
|
2
|
+
|
|
3
|
+
A campaign made with V12: three missions on the long road home, a hero carried from one to the next. Burn the Red Hand's camp, hold the fort through a night of waves, and climb the pass to Varga's hold.
|
|
4
|
+
|
|
5
|
+
| To | Run |
|
|
6
|
+
| ------- | -------------------------------------------------------------------- |
|
|
7
|
+
| Play it | `npm run dev`, and open the page it says: its start screen, Campaign |
|
|
8
|
+
<!-- @if editor -->
|
|
9
|
+
| Make maps | `npm run dev`, and open `/?editor`: Save writes them into `src/maps`, with a names file each |
|
|
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
|
+
<!-- @end -->
|
|
19
|
+
<!-- @end -->
|
|
20
|
+
<!-- @if tests -->
|
|
21
|
+
| Test the rules | `npm test` |
|
|
22
|
+
<!-- @end -->
|
|
23
|
+
| Check the types | `npm run typecheck` |
|
|
24
|
+
| Build the page to host | `npm run build`, into `dist/` |
|
|
25
|
+
|
|
26
|
+
## What is where
|
|
27
|
+
|
|
28
|
+
- `src/campaign/`: the game. `index.ts` puts the rules together and gives each map its mission; `ids.ts` names everything once; `units.ts` the units, buildings, the hero, his abilities and the items; `missions/` a file a mission: `homecoming.ts` (objectives, a prefab, areas, a brain that holds and raids), `siege.ts` (the waves, the gate, effects fired from the rules), `warlord.ts` (the hero carried in, a shop, the talk at the gate and its choice, the prisoners); `waves.ts` the waves, as `waves.json` says them; `view/` how it looks and sounds, with the cinematic of the talk in code; `cinematics/` the intro and the outro, made in the editor's Cinematic panel; `prefabs/` what the editor's Save as a prefab wrote; `campaign.json` the campaign's chapters and missions (their maps, words, briefings, debriefs), what the editor's Campaign panel writes, and `scenes.ts` what of them is code, for the page (`defineCampaign`); `data.json`, `looks.json`, `fx.json` and `waves.json` what the editor's Data, model, Effects and Waves panels write
|
|
29
|
+
- `src/rules.ts`, `src/game.ts`, `src/maps.ts`: the game by the names the page, the server and the tests know it by
|
|
30
|
+
- `src/maps/`: the missions' maps, made in the editor. Each has the names its mission's rules look for (`hall`, `hero`, `camp`, `warcamp`, `gate`, `cells`…): a names file beside each says which
|
|
31
|
+
- `src/main.ts`: the page: the start screen, the campaign's menu and its missions, free play, the settings
|
|
32
|
+
|
|
33
|
+
<!-- @if editor -->
|
|
34
|
+
|
|
35
|
+
- `src/editor.ts`: the map editor, with what `src/campaign-editor.ts` adds as one extension: the Waves panel (`src/waves-panel.ts`: the waves listed, found, changed and undone, where they come out drawn in the scene) and the search's entries. Saved while the editor is open, they change in it
|
|
36
|
+
<!-- @end -->
|
|
37
|
+
<!-- @if server -->
|
|
38
|
+
<!-- @if !rooms -->
|
|
39
|
+
|
|
40
|
+
- `server.ts`: a server of the game, one game at a time, `startServer`: the first mission's map, the computer at the Red Hand's seat
|
|
41
|
+
<!-- @end -->
|
|
42
|
+
<!-- @if rooms -->
|
|
43
|
+
|
|
44
|
+
- `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
|
|
45
|
+
<!-- @end -->
|
|
46
|
+
<!-- @end -->
|
|
47
|
+
<!-- @if tests -->
|
|
48
|
+
|
|
49
|
+
- `tests/`: the missions tested with no page: the maps checked, each mission won and lost as it says, the waves, the carried hero, the choice at the gate, the computer playing a minute (`simulate`)
|
|
50
|
+
<!-- @end -->
|
|
51
|
+
- `public/games/campaign/`: the game's own folder of files: what the editor's Assets panel takes in (models, sounds, pictures), with an `assets.json` saying what is there
|
|
52
|
+
- `@v12-engine/assets`: the shared art every unit and building is drawn with (adventurers with their clips and their gear, buildings, trees), served where `public/`'s files are by `assetsPackage()` in `vite.config.ts`, and copied into a build as the game uses it. Whose each file is: its group's `CREDITS.md` in `node_modules/@v12-engine/assets/groups/`
|
|
53
|
+
|
|
54
|
+
## Where to go from here
|
|
55
|
+
|
|
56
|
+
The tutorial that ends in this project, chapter by chapter, is the docs' Let's make a basic campaign. Then:
|
|
57
|
+
|
|
58
|
+
1. **A fourth mission.** A map in the editor (names for what the rules look for), a file in `src/campaign/missions/`, the mission in the Campaign panel (`campaign.json`), its `start` in `scenes.ts` and a line in `index.ts`'s `MISSIONS`.
|
|
59
|
+
2. **A second ability of the hero's own.** `defineEffect` in `units.ts` for what the engine's effects do not do, and a line in `hero.learns`. The docs' Let's add a hero with abilities and items walks through it.
|
|
60
|
+
3. **More for the waves.** `waves.json` in the editor's Waves panel; a new kind of raider in `units.ts` and in `WAVE_UNITS`.
|
|
61
|
+
4. **Your own art.** Drop a glTF on the editor's Assets panel: Set up as a unit writes `looks.json`, which the view lays over its code.
|