maps.ts•6.37 kB
import { LRUCache } from 'lru-cache';
import { readYAML } from './yaml.js';
import { getString } from './strings.js';
import { readDVPLFile } from '../dvpl/reader.js';
import { Sc2ReadStream, extractMapEntities } from '../streams/sc2.js';
import type { Config } from '../config.js';
import type { GameMap, MapSpawnPoint, MapEntity, TerrainSummary, MapBounds } from '../data/types.js';
import { existsSync, readdirSync } from 'fs';
import { join } from 'path';
interface RawSpawnPoint {
respawnNumber: number;
points: number[][];
}
interface RawMap {
id: number;
tags?: string;
localName: string;
avaliableInTrainingRoom: boolean;
spriteFrame: number;
supremacyPointsThreshold?: number;
availableModes: number[];
shadowMapsAvailable?: boolean;
assaultRespawnPoints: {
allies: RawSpawnPoint[];
enemies: RawSpawnPoint[];
};
levels?: number[];
}
interface MapsYAML {
maps: Record<string, RawMap>;
}
interface CachedTerrain {
entities: MapEntity[];
summary: TerrainSummary;
bounds: MapBounds;
}
const terrainCache = new LRUCache<number, CachedTerrain>({ max: 20 });
let mapsCache: Map<number, GameMap> | null = null;
let maps3dPath: string = '';
const VALID_MAP_TAG = /^[a-zA-Z0-9_-]+$/;
export async function loadMaps(config: Config): Promise<void> {
if (mapsCache) return;
mapsCache = new Map();
maps3dPath = config.paths.maps3d;
const data = await readYAML<MapsYAML>(config.paths.maps);
for (const [mapTag, rawMap] of Object.entries(data.maps)) {
const stringKey = `#maps:${mapTag}:${rawMap.localName}`;
const name = getString(stringKey);
const gameMap: GameMap = {
id: rawMap.id,
name: name !== stringKey ? name : formatMapName(mapTag),
tag: mapTag,
modes: rawMap.availableModes,
alliesSpawns: rawMap.assaultRespawnPoints.allies.map(parseSpawnPoint),
enemiesSpawns: rawMap.assaultRespawnPoints.enemies.map(parseSpawnPoint),
};
mapsCache.set(rawMap.id, gameMap);
}
}
function parseSpawnPoint(raw: RawSpawnPoint): MapSpawnPoint {
return {
respawnNumber: raw.respawnNumber,
points: raw.points,
};
}
function formatMapName(tag: string): string {
return tag
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function calculateBounds(entities: MapEntity[]): MapBounds {
let minX = Infinity, maxX = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
for (const e of entities) {
minX = Math.min(minX, e.position[0]);
maxX = Math.max(maxX, e.position[0]);
minZ = Math.min(minZ, e.position[2]);
maxZ = Math.max(maxZ, e.position[2]);
}
return {
minX: Math.round(minX),
maxX: Math.round(maxX),
minZ: Math.round(minZ),
maxZ: Math.round(maxZ),
width: Math.round(maxX - minX),
height: Math.round(maxZ - minZ),
};
}
function calculateSummary(entities: MapEntity[]): TerrainSummary {
const counts: Record<string, number> = {};
for (const e of entities) {
counts[e.type] = (counts[e.type] || 0) + 1;
}
return {
bushes: counts['bush'] || 0,
trees: counts['tree'] || 0,
buildings: counts['building'] || 0,
rocks: counts['rock'] || 0,
walls: counts['wall'] || 0,
bridges: counts['bridge'] || 0,
capturePoints: counts['capture_point'] || 0,
totalCover: (counts['bush'] || 0) + (counts['tree'] || 0) + (counts['building'] || 0) + (counts['rock'] || 0),
};
}
function findSc2Path(mapTag: string): string | null {
if (!VALID_MAP_TAG.test(mapTag)) {
return null;
}
const folders = [
`${mapTag.split('_').slice(0, 2).join('_')}_${mapTag.split('_').slice(-1)[0]}`,
mapTag,
];
for (const folder of folders) {
if (!VALID_MAP_TAG.test(folder)) continue;
const sc2Path = join(maps3dPath, folder, `${folder}.sc2`);
if (existsSync(sc2Path + '.dvpl')) {
return sc2Path;
}
}
const entries = readdirSync(maps3dPath);
for (const entry of entries) {
if (!VALID_MAP_TAG.test(entry)) continue;
if (entry.toLowerCase().includes(mapTag.split('_').slice(-1)[0])) {
const sc2Path = join(maps3dPath, entry, `${entry}.sc2`);
if (existsSync(sc2Path + '.dvpl')) {
return sc2Path;
}
}
}
return null;
}
export async function loadMapTerrain(mapId: number): Promise<CachedTerrain | null> {
const map = mapsCache?.get(mapId);
if (!map) return null;
const cached = terrainCache.get(mapId);
if (cached) return cached;
const sc2Path = findSc2Path(map.tag);
if (!sc2Path) return null;
try {
const buffer = await readDVPLFile(sc2Path);
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
const stream = new Sc2ReadStream(arrayBuffer);
const data = stream.sc2();
const entities = extractMapEntities(data);
const terrainData: CachedTerrain = {
entities,
summary: calculateSummary(entities),
bounds: calculateBounds(entities),
};
terrainCache.set(mapId, terrainData);
return terrainData;
} catch {
return null;
}
}
export function getMaps(): GameMap[] {
return mapsCache ? Array.from(mapsCache.values()) : [];
}
export function getMap(id: number): GameMap | undefined {
return mapsCache?.get(id);
}
export function getMapByName(name: string): GameMap | undefined {
if (!mapsCache) return undefined;
const lowerName = name.toLowerCase();
for (const map of mapsCache.values()) {
if (map.name.toLowerCase() === lowerName || map.tag.toLowerCase() === lowerName) {
return map;
}
}
return undefined;
}
export interface MapWithTerrain extends GameMap {
terrain: MapEntity[];
terrainSummary: TerrainSummary;
bounds: MapBounds;
}
export async function getMapWithTerrain(nameOrId: string | number): Promise<MapWithTerrain | undefined> {
let map: GameMap | undefined;
if (typeof nameOrId === 'number') {
map = getMap(nameOrId);
} else {
map = getMapByName(nameOrId);
}
if (!map) return undefined;
const terrainData = await loadMapTerrain(map.id);
if (!terrainData) return undefined;
return {
...map,
terrain: terrainData.entities,
terrainSummary: terrainData.summary,
bounds: terrainData.bounds,
};
}
export function getTerrainCacheStats(): { size: number; max: number } {
return { size: terrainCache.size, max: 20 };
}