doom-osm-godmode 1.2.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.
package/src/osm.js ADDED
@@ -0,0 +1,110 @@
1
+ const { closeRing } = require('./geo.js');
2
+
3
+ const USER_AGENT = 'doom-osm-wad/0.1.0';
4
+ const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
5
+
6
+ function buildOverpassQuery(bbox) {
7
+ const bounds = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
8
+
9
+ return `
10
+ [out:json][timeout:45];
11
+ (
12
+ way["building"](${bounds});
13
+ way["natural"="water"](${bounds});
14
+ way["water"](${bounds});
15
+ way["leisure"~"park|garden|pitch"](${bounds});
16
+ way["landuse"~"grass|meadow|recreation_ground|village_green"](${bounds});
17
+ );
18
+ out tags geom;
19
+ `.trim();
20
+ }
21
+
22
+ function inferKind(tags = {}) {
23
+ if (tags.building) {
24
+ return 'building';
25
+ }
26
+
27
+ if (tags.natural === 'water' || tags.water) {
28
+ return 'water';
29
+ }
30
+
31
+ if (
32
+ tags.leisure === 'park' ||
33
+ tags.leisure === 'garden' ||
34
+ tags.leisure === 'pitch' ||
35
+ tags.landuse === 'grass' ||
36
+ tags.landuse === 'meadow' ||
37
+ tags.landuse === 'recreation_ground' ||
38
+ tags.landuse === 'village_green'
39
+ ) {
40
+ return 'park';
41
+ }
42
+
43
+ return null;
44
+ }
45
+
46
+ function overpassToFeatureCollection(payload) {
47
+ const features = [];
48
+
49
+ for (const element of payload.elements || []) {
50
+ if (element.type !== 'way' || !Array.isArray(element.geometry) || element.geometry.length < 3) {
51
+ continue;
52
+ }
53
+
54
+ const kind = inferKind(element.tags);
55
+ if (!kind) {
56
+ continue;
57
+ }
58
+
59
+ const ring = closeRing(
60
+ element.geometry.map((point) => [Number(point.lon), Number(point.lat)])
61
+ );
62
+
63
+ if (!Array.isArray(ring) || ring.length < 4) {
64
+ continue;
65
+ }
66
+
67
+ features.push({
68
+ type: 'Feature',
69
+ properties: {
70
+ id: `way/${element.id}`,
71
+ name: element.tags?.name || null,
72
+ kind,
73
+ tags: element.tags || {}
74
+ },
75
+ geometry: {
76
+ type: 'Polygon',
77
+ coordinates: [ring]
78
+ }
79
+ });
80
+ }
81
+
82
+ return {
83
+ type: 'FeatureCollection',
84
+ features
85
+ };
86
+ }
87
+
88
+ async function fetchFeatureCollectionForBbox(bbox) {
89
+ const query = buildOverpassQuery(bbox);
90
+ const response = await fetch(OVERPASS_URL, {
91
+ method: 'POST',
92
+ headers: {
93
+ 'content-type': 'text/plain;charset=UTF-8',
94
+ 'user-agent': USER_AGENT
95
+ },
96
+ body: query
97
+ });
98
+
99
+ if (!response.ok) {
100
+ throw new Error(`Overpass request failed with ${response.status}.`);
101
+ }
102
+
103
+ const payload = await response.json();
104
+ return overpassToFeatureCollection(payload);
105
+ }
106
+
107
+ module.exports = {
108
+ fetchFeatureCollectionForBbox
109
+ };
110
+
package/src/udmf.js ADDED
@@ -0,0 +1,207 @@
1
+ function formatValue(value) {
2
+ if (typeof value === 'string') {
3
+ return `"${value}"`;
4
+ }
5
+
6
+ if (typeof value === 'boolean') {
7
+ return value ? 'true' : 'false';
8
+ }
9
+
10
+ if (typeof value === 'number') {
11
+ return Number.isInteger(value) ? String(value) : value.toFixed(3);
12
+ }
13
+
14
+ return String(value);
15
+ }
16
+
17
+ function serializeBlock(type, fields) {
18
+ const lines = [`${type}`, '{'];
19
+
20
+ for (const [key, value] of Object.entries(fields)) {
21
+ if (value === undefined || value === null) {
22
+ continue;
23
+ }
24
+ lines.push(` ${key} = ${formatValue(value)};`);
25
+ }
26
+
27
+ lines.push('}');
28
+ return lines.join('\n');
29
+ }
30
+
31
+ function buildTextMap(layout) {
32
+ const vertices = [];
33
+ const vertexIndexByKey = new Map();
34
+ const sectors = [];
35
+ const sidedefs = [];
36
+ const linedefs = [];
37
+ const sectorIndexByCell = new Map();
38
+
39
+ const addVertex = (x, y) => {
40
+ const key = `${x},${y}`;
41
+ if (vertexIndexByKey.has(key)) {
42
+ return vertexIndexByKey.get(key);
43
+ }
44
+
45
+ const index = vertices.length;
46
+ vertices.push({ x, y });
47
+ vertexIndexByKey.set(key, index);
48
+ return index;
49
+ };
50
+
51
+ const addSidedef = (fields) => {
52
+ const index = sidedefs.length;
53
+ sidedefs.push(fields);
54
+ return index;
55
+ };
56
+
57
+ const cellToWorld = (cell) => {
58
+ const x = cell.x * layout.cellSize;
59
+ const y = (layout.gridBounds.maxY - cell.y) * layout.cellSize;
60
+ return {
61
+ topLeft: { x, y: y + layout.cellSize },
62
+ topRight: { x: x + layout.cellSize, y: y + layout.cellSize },
63
+ bottomRight: { x: x + layout.cellSize, y },
64
+ bottomLeft: { x, y }
65
+ };
66
+ };
67
+
68
+ for (const cell of layout.cells) {
69
+ const sectorIndex = sectors.length;
70
+ sectors.push({
71
+ heightfloor: cell.theme.floorHeight,
72
+ heightceiling: cell.theme.ceilingHeight,
73
+ texturefloor: cell.theme.floorTexture,
74
+ textureceiling: cell.theme.ceilingTexture,
75
+ lightlevel: cell.theme.lightLevel
76
+ });
77
+ sectorIndexByCell.set(`${cell.x},${cell.y}`, sectorIndex);
78
+ }
79
+
80
+ const neighborOffsets = [
81
+ { dx: 0, dy: -1, edge: 'north' },
82
+ { dx: 1, dy: 0, edge: 'east' },
83
+ { dx: 0, dy: 1, edge: 'south' },
84
+ { dx: -1, dy: 0, edge: 'west' }
85
+ ];
86
+
87
+ const edgeVertices = (points, edge) => {
88
+ if (edge === 'north') {
89
+ return [points.topLeft, points.topRight];
90
+ }
91
+
92
+ if (edge === 'east') {
93
+ return [points.topRight, points.bottomRight];
94
+ }
95
+
96
+ if (edge === 'south') {
97
+ return [points.bottomRight, points.bottomLeft];
98
+ }
99
+
100
+ return [points.bottomLeft, points.topLeft];
101
+ };
102
+
103
+ for (const cell of layout.cells) {
104
+ const currentSector = sectorIndexByCell.get(`${cell.x},${cell.y}`);
105
+ const points = cellToWorld(cell);
106
+
107
+ for (const neighborOffset of neighborOffsets) {
108
+ const neighborKey = `${cell.x + neighborOffset.dx},${cell.y + neighborOffset.dy}`;
109
+ const neighborSector = sectorIndexByCell.get(neighborKey);
110
+
111
+ if (neighborSector !== undefined && currentSector > neighborSector) {
112
+ continue;
113
+ }
114
+
115
+ const [start, end] = edgeVertices(points, neighborOffset.edge);
116
+ const v1 = addVertex(start.x, start.y);
117
+ const v2 = addVertex(end.x, end.y);
118
+
119
+ if (neighborSector === undefined) {
120
+ const front = addSidedef({
121
+ sector: currentSector,
122
+ texturetop: '-',
123
+ texturebottom: '-',
124
+ texturemiddle: cell.theme.wallTexture
125
+ });
126
+
127
+ linedefs.push({
128
+ v1,
129
+ v2,
130
+ sidefront: front,
131
+ blocking: true
132
+ });
133
+ continue;
134
+ }
135
+
136
+ const front = addSidedef({
137
+ sector: currentSector,
138
+ texturetop: '-',
139
+ texturebottom: '-',
140
+ texturemiddle: '-'
141
+ });
142
+ const back = addSidedef({
143
+ sector: neighborSector,
144
+ texturetop: '-',
145
+ texturebottom: '-',
146
+ texturemiddle: '-'
147
+ });
148
+
149
+ linedefs.push({
150
+ v1,
151
+ v2,
152
+ sidefront: front,
153
+ sideback: back,
154
+ twosided: true
155
+ });
156
+ }
157
+ }
158
+
159
+ const startWorldX = (layout.playerStart.x + 0.5) * layout.cellSize;
160
+ const startWorldY = (layout.gridBounds.maxY - layout.playerStart.y + 0.5) * layout.cellSize;
161
+
162
+ const blocks = [
163
+ 'namespace = "ZDoom";',
164
+ '',
165
+ ...vertices.map((vertex) => serializeBlock('vertex', vertex)),
166
+ '',
167
+ ...linedefs.map((linedef) => serializeBlock('linedef', linedef)),
168
+ '',
169
+ ...sidedefs.map((sidedef) => serializeBlock('sidedef', sidedef)),
170
+ '',
171
+ ...sectors.map((sector) => serializeBlock('sector', sector)),
172
+ '',
173
+ serializeBlock('thing', {
174
+ x: startWorldX,
175
+ y: startWorldY,
176
+ angle: 90,
177
+ type: 1,
178
+ skill1: true,
179
+ skill2: true,
180
+ skill3: true,
181
+ skill4: true,
182
+ skill5: true,
183
+ single: true,
184
+ cooperative: true,
185
+ dm: true
186
+ })
187
+ ];
188
+
189
+ return `${blocks.join('\n\n')}\n`;
190
+ }
191
+
192
+ function buildMapInfo(mapName, title) {
193
+ return [
194
+ `map ${mapName} "${title}"`,
195
+ '{',
196
+ ' sky1 = "SKY1", 0.0',
197
+ ' music = "$MUSIC_RUNNIN"',
198
+ '}',
199
+ ''
200
+ ].join('\n');
201
+ }
202
+
203
+ module.exports = {
204
+ buildMapInfo,
205
+ buildTextMap
206
+ };
207
+
package/src/wad.js ADDED
@@ -0,0 +1,67 @@
1
+ function encodeName(name) {
2
+ const buffer = Buffer.alloc(8);
3
+ buffer.write(String(name).slice(0, 8).toUpperCase(), 'ascii');
4
+ return buffer;
5
+ }
6
+
7
+ function createWadBuffer(options) {
8
+ const lumps = [
9
+ {
10
+ name: 'MAPINFO',
11
+ data: Buffer.from(options.mapInfo, 'utf8')
12
+ },
13
+ {
14
+ name: options.mapName,
15
+ data: Buffer.alloc(0)
16
+ },
17
+ {
18
+ name: 'TEXTMAP',
19
+ data: Buffer.from(options.textMap, 'utf8')
20
+ },
21
+ {
22
+ name: 'OSMJSON',
23
+ data: Buffer.from(options.metadataJson, 'utf8')
24
+ },
25
+ {
26
+ name: 'ENDMAP',
27
+ data: Buffer.alloc(0)
28
+ }
29
+ ];
30
+
31
+ const headerSize = 12;
32
+ let dataOffset = headerSize;
33
+ const dataBuffers = [];
34
+ const directoryEntries = [];
35
+
36
+ for (const lump of lumps) {
37
+ dataBuffers.push(lump.data);
38
+ directoryEntries.push({
39
+ filepos: dataOffset,
40
+ size: lump.data.length,
41
+ name: lump.name
42
+ });
43
+ dataOffset += lump.data.length;
44
+ }
45
+
46
+ const directoryOffset = dataOffset;
47
+ const directoryBuffer = Buffer.alloc(directoryEntries.length * 16);
48
+
49
+ directoryEntries.forEach((entry, index) => {
50
+ const base = index * 16;
51
+ directoryBuffer.writeInt32LE(entry.filepos, base);
52
+ directoryBuffer.writeInt32LE(entry.size, base + 4);
53
+ encodeName(entry.name).copy(directoryBuffer, base + 8);
54
+ });
55
+
56
+ const header = Buffer.alloc(headerSize);
57
+ header.write('PWAD', 0, 'ascii');
58
+ header.writeInt32LE(directoryEntries.length, 4);
59
+ header.writeInt32LE(directoryOffset, 8);
60
+
61
+ return Buffer.concat([header, ...dataBuffers, directoryBuffer]);
62
+ }
63
+
64
+ module.exports = {
65
+ createWadBuffer
66
+ };
67
+
@@ -0,0 +1,171 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const { loadFeatureCollectionFromFile, prepareFeatureSet } = require('./feature-set.js');
4
+ const { searchPlace } = require('./geocode.js');
5
+ const { bboxAroundPoint, bboxFromNominatimResult } = require('./geo.js');
6
+ const { ensureDir, readJson, slugify, writeJson, writeText } = require('./lib/fs.js');
7
+ const { buildLayout } = require('./layout.js');
8
+ const { fetchFeatureCollectionForBbox } = require('./osm.js');
9
+ const { buildMapInfo, buildTextMap } = require('./udmf.js');
10
+ const { createWadBuffer } = require('./wad.js');
11
+
12
+ async function buildArtifacts(featureCollection, options) {
13
+ const featureSet = prepareFeatureSet(featureCollection, {
14
+ title: options.title,
15
+ maxRooms: options.maxRooms
16
+ });
17
+ const layout = buildLayout(featureSet);
18
+ const textMap = buildTextMap(layout);
19
+ const mapInfo = buildMapInfo(options.mapName, options.title);
20
+
21
+ const metadata = {
22
+ title: options.title,
23
+ mapName: options.mapName,
24
+ source: options.source,
25
+ featureCount: featureSet.featureCount,
26
+ exportedFeatures: featureSet.features.length,
27
+ features: featureSet.features.map((feature) => ({
28
+ id: feature.id,
29
+ name: feature.name,
30
+ kind: feature.kind,
31
+ areaMeters: Math.round(feature.areaMeters),
32
+ boundsMeters: {
33
+ width: Number(feature.boundsMeters.width.toFixed(2)),
34
+ height: Number(feature.boundsMeters.height.toFixed(2))
35
+ }
36
+ }))
37
+ };
38
+
39
+ const wadBuffer = createWadBuffer({
40
+ mapName: options.mapName,
41
+ mapInfo,
42
+ textMap,
43
+ metadataJson: JSON.stringify(metadata, null, 2)
44
+ });
45
+
46
+ await ensureDir(options.outputDir);
47
+ await writeJson(path.join(options.outputDir, 'site.json'), {
48
+ ...featureSet,
49
+ features: featureSet.features.map((feature) => ({
50
+ id: feature.id,
51
+ name: feature.name,
52
+ kind: feature.kind,
53
+ areaMeters: Number(feature.areaMeters.toFixed(2)),
54
+ boundsMeters: {
55
+ width: Number(feature.boundsMeters.width.toFixed(2)),
56
+ height: Number(feature.boundsMeters.height.toFixed(2))
57
+ },
58
+ sourceProperties: feature.sourceProperties,
59
+ sourceRing: feature.sourceRing
60
+ }))
61
+ });
62
+ await writeJson(path.join(options.outputDir, 'layout.json'), layout);
63
+ await writeText(path.join(options.outputDir, 'TEXTMAP.udmf'), textMap);
64
+ await writeText(path.join(options.outputDir, 'MAPINFO.txt'), mapInfo);
65
+ await fs.writeFile(path.join(options.outputDir, `${options.mapName}.wad`), wadBuffer);
66
+
67
+ return {
68
+ outputDir: options.outputDir,
69
+ mapPath: path.join(options.outputDir, `${options.mapName}.wad`),
70
+ featureCount: featureSet.featureCount,
71
+ exportedFeatures: featureSet.features.length
72
+ };
73
+ }
74
+
75
+ async function buildFromPlace(placeQuery, options = {}) {
76
+ const results = await searchPlace(placeQuery, 5);
77
+ if (!Array.isArray(results) || results.length === 0) {
78
+ throw new Error(`No geocoding result for "${placeQuery}".`);
79
+ }
80
+
81
+ const best = results[0];
82
+ const radiusMeters = options.radiusMeters;
83
+ const bbox = radiusMeters
84
+ ? bboxAroundPoint(Number(best.lat), Number(best.lon), radiusMeters)
85
+ : bboxFromNominatimResult(best);
86
+
87
+ const featureCollection = await fetchFeatureCollectionForBbox(bbox);
88
+ const title = options.title || best.display_name.split(',')[0];
89
+ const mapName = options.mapName || 'MAP01';
90
+ const outputDir = options.outputDir || path.resolve('output', slugify(title));
91
+
92
+ return buildArtifacts(featureCollection, {
93
+ title,
94
+ mapName,
95
+ maxRooms: options.maxRooms || 12,
96
+ outputDir,
97
+ source: {
98
+ type: 'place-search',
99
+ placeQuery,
100
+ chosenResult: {
101
+ displayName: best.display_name,
102
+ lat: Number(best.lat),
103
+ lon: Number(best.lon)
104
+ },
105
+ bbox
106
+ }
107
+ });
108
+ }
109
+
110
+ async function buildFromConfig(configPath) {
111
+ const absoluteConfigPath = path.resolve(configPath);
112
+ const configDir = path.dirname(absoluteConfigPath);
113
+ const config = await readJson(absoluteConfigPath);
114
+
115
+ if (config.inputGeoJson) {
116
+ const inputPath = path.resolve(configDir, config.inputGeoJson);
117
+ const featureCollection = await loadFeatureCollectionFromFile(inputPath);
118
+ return buildArtifacts(featureCollection, {
119
+ title: config.title || 'Imported Site',
120
+ mapName: config.mapName || 'MAP01',
121
+ maxRooms: config.maxRooms || 12,
122
+ outputDir: path.resolve(configDir, config.outputDir || path.join('output', slugify(config.title || 'site'))),
123
+ source: {
124
+ type: 'geojson',
125
+ inputGeoJson: inputPath
126
+ }
127
+ });
128
+ }
129
+
130
+ if (config.placeQuery) {
131
+ return buildFromPlace(config.placeQuery, {
132
+ title: config.title,
133
+ mapName: config.mapName,
134
+ maxRooms: config.maxRooms,
135
+ radiusMeters: config.radiusMeters,
136
+ outputDir: path.resolve(configDir, config.outputDir || path.join('output', slugify(config.title || config.placeQuery)))
137
+ });
138
+ }
139
+
140
+ throw new Error(`Config ${absoluteConfigPath} must include inputGeoJson or placeQuery.`);
141
+ }
142
+
143
+ async function buildDemo(outputDir) {
144
+ const configPath = path.resolve(__dirname, '..', 'examples', 'demo-site.json');
145
+ const result = await buildFromConfig(configPath);
146
+
147
+ if (!outputDir) {
148
+ return result;
149
+ }
150
+
151
+ const targetDir = path.resolve(outputDir);
152
+ await ensureDir(targetDir);
153
+
154
+ const files = ['layout.json', 'MAP01.wad', 'MAPINFO.txt', 'site.json', 'TEXTMAP.udmf'];
155
+ for (const file of files) {
156
+ await fs.copyFile(path.join(result.outputDir, file), path.join(targetDir, file));
157
+ }
158
+
159
+ return {
160
+ ...result,
161
+ outputDir: targetDir,
162
+ mapPath: path.join(targetDir, 'MAP01.wad')
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ buildDemo,
168
+ buildFromConfig,
169
+ buildFromPlace
170
+ };
171
+