mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 11:48:09 +00:00
Move maps generation out of repo, new map structure (#1256)
## Description: Move map generation outside of main repo, it has been rewritten in Go and is much faster. Also refactor how maps are stored, one dir per map. The map binaries are basically identical to before. Some maps like Africa have 1% difference in bytes, but playing it looks exactly the same. Use lazy loading for map data access so only needed files are accessed. Unit tests now load map binary instead of regenerating it from scratch, speeding them up. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { GameMode } from "../core/game/Game";
|
||||
import { GameMapType, GameMode } from "../core/game/Game";
|
||||
import { terrainMapFileLoader } from "../core/game/TerrainMapFileLoader";
|
||||
import { GameID, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { getMapsImage } from "./utilities/Maps";
|
||||
|
||||
@customElement("public-lobby")
|
||||
export class PublicLobby extends LitElement {
|
||||
@state() private lobbies: GameInfo[] = [];
|
||||
@state() public isLobbyHighlighted: boolean = false;
|
||||
@state() private isButtonDebounced: boolean = false;
|
||||
@state() private mapImages: Map<GameID, string> = new Map();
|
||||
private lobbiesInterval: number | null = null;
|
||||
private currLobby: GameInfo | null = null;
|
||||
private debounceDelay: number = 750;
|
||||
@@ -48,12 +49,29 @@ export class PublicLobby extends LitElement {
|
||||
const msUntilStart = l.msUntilStart ?? 0;
|
||||
this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now());
|
||||
}
|
||||
|
||||
// Load map image if not already loaded
|
||||
if (l.gameConfig && !this.mapImages.has(l.gameID)) {
|
||||
this.loadMapImage(l.gameID, l.gameConfig.gameMap);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching lobbies:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMapImage(gameID: GameID, gameMap: string) {
|
||||
try {
|
||||
// Convert string to GameMapType enum value
|
||||
const mapType = gameMap as GameMapType;
|
||||
const data = terrainMapFileLoader.getMapData(mapType);
|
||||
this.mapImages.set(gameID, await data.webpPath());
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error("Failed to load map image:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLobbies(): Promise<GameInfo[]> {
|
||||
try {
|
||||
const response = await fetch(`/api/public_lobbies`);
|
||||
@@ -95,6 +113,8 @@ export class PublicLobby extends LitElement {
|
||||
? lobby.gameConfig.playerTeams || 0
|
||||
: null;
|
||||
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
@@ -107,12 +127,16 @@ export class PublicLobby extends LitElement {
|
||||
? "opacity-70 cursor-not-allowed"
|
||||
: ""}"
|
||||
>
|
||||
<img
|
||||
src="${getMapsImage(lobby.gameConfig.gameMap)}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="place-self-start col-span-full row-span-full h-full -z-10"
|
||||
style="mask-image: linear-gradient(to left, transparent, #fff)"
|
||||
/>
|
||||
${mapImageSrc
|
||||
? html`<img
|
||||
src="${mapImageSrc}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="place-self-start col-span-full row-span-full h-full -z-10"
|
||||
style="mask-image: linear-gradient(to left, transparent, #fff)"
|
||||
/>`
|
||||
: html`<div
|
||||
class="place-self-start col-span-full row-span-full h-full -z-10 bg-gray-300"
|
||||
></div>`}
|
||||
<div
|
||||
class="flex flex-col justify-between h-full col-span-full row-span-full p-4 md:p-6 text-right z-0"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { GameMapType } from "../../core/game/Game";
|
||||
import { getMapsImage } from "../utilities/Maps";
|
||||
import { terrainMapFileLoader } from "../../core/game/TerrainMapFileLoader";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
// Add map descriptions
|
||||
export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
@@ -36,6 +37,9 @@ export class MapDisplay extends LitElement {
|
||||
@property({ type: String }) mapKey = "";
|
||||
@property({ type: Boolean }) selected = false;
|
||||
@property({ type: String }) translation: string = "";
|
||||
@state() private mapWebpPath: string | null = null;
|
||||
@state() private mapName: string | null = null;
|
||||
@state() private isLoading = true;
|
||||
|
||||
static styles = css`
|
||||
.option-card {
|
||||
@@ -86,25 +90,42 @@ export class MapDisplay extends LitElement {
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
const mapValue = GameMapType[this.mapKey as keyof typeof GameMapType];
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadMapData();
|
||||
}
|
||||
|
||||
private async loadMapData() {
|
||||
if (!this.mapKey) return;
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const mapValue = GameMapType[this.mapKey as keyof typeof GameMapType];
|
||||
const data = terrainMapFileLoader.getMapData(mapValue);
|
||||
this.mapWebpPath = await data.webpPath();
|
||||
this.mapName = (await data.nationMap()).name;
|
||||
} catch (error) {
|
||||
console.error("Failed to load map data:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="option-card ${this.selected ? "selected" : ""}">
|
||||
${getMapsImage(mapValue)
|
||||
? html`<img
|
||||
src="${getMapsImage(mapValue)}"
|
||||
alt="${this.mapKey}"
|
||||
class="option-image"
|
||||
/>`
|
||||
: html`<div class="option-image">
|
||||
<p>${this.mapKey}</p>
|
||||
</div>`}
|
||||
<div class="option-card-title">
|
||||
<!-- ${MapDescription[this.mapKey as keyof typeof GameMapType]}-->
|
||||
${this.translation ||
|
||||
MapDescription[this.mapKey as keyof typeof GameMapType]}
|
||||
</div>
|
||||
${this.isLoading
|
||||
? html`<div class="option-image">
|
||||
${translateText("map_component.loading")}
|
||||
</div>`
|
||||
: this.mapWebpPath
|
||||
? html`<img
|
||||
src="${this.mapWebpPath}"
|
||||
alt="${this.mapKey}"
|
||||
class="option-image"
|
||||
/>`
|
||||
: html`<div class="option-image">Error</div>`}
|
||||
<div class="option-card-title">${this.translation || this.mapName}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import africa from "../../../resources/maps/AfricaThumb.webp";
|
||||
import asia from "../../../resources/maps/AsiaThumb.webp";
|
||||
import australia from "../../../resources/maps/AustraliaThumb.webp";
|
||||
import baikal from "../../../resources/maps/BaikalThumb.webp";
|
||||
import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
|
||||
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
|
||||
import britannia from "../../../resources/maps/BritanniaThumb.webp";
|
||||
import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp";
|
||||
import eastasia from "../../../resources/maps/EastAsiaThumb.webp";
|
||||
import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp";
|
||||
import europe from "../../../resources/maps/EuropeThumb.webp";
|
||||
import falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp";
|
||||
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
|
||||
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
|
||||
import halkidiki from "../../../resources/maps/HalkidikiThumb.webp";
|
||||
import iceland from "../../../resources/maps/IcelandThumb.webp";
|
||||
import mars from "../../../resources/maps/MarsThumb.webp";
|
||||
import mena from "../../../resources/maps/MenaThumb.webp";
|
||||
import northAmerica from "../../../resources/maps/NorthAmericaThumb.webp";
|
||||
import oceania from "../../../resources/maps/OceaniaThumb.webp";
|
||||
import pangaea from "../../../resources/maps/PangaeaThumb.webp";
|
||||
import southAmerica from "../../../resources/maps/SouthAmericaThumb.webp";
|
||||
import worldmapgiant from "../../../resources/maps/WorldMapGiantThumb.webp";
|
||||
import world from "../../../resources/maps/WorldMapThumb.webp";
|
||||
|
||||
import { GameMapType } from "../../core/game/Game";
|
||||
|
||||
export function getMapsImage(map: GameMapType): string {
|
||||
switch (map) {
|
||||
case GameMapType.World:
|
||||
return world;
|
||||
case GameMapType.GiantWorldMap:
|
||||
return worldmapgiant;
|
||||
case GameMapType.Oceania:
|
||||
return oceania;
|
||||
case GameMapType.Europe:
|
||||
return europe;
|
||||
case GameMapType.EuropeClassic:
|
||||
return europeClassic;
|
||||
case GameMapType.Mena:
|
||||
return mena;
|
||||
case GameMapType.NorthAmerica:
|
||||
return northAmerica;
|
||||
case GameMapType.SouthAmerica:
|
||||
return southAmerica;
|
||||
case GameMapType.BlackSea:
|
||||
return blackSea;
|
||||
case GameMapType.Africa:
|
||||
return africa;
|
||||
case GameMapType.Pangaea:
|
||||
return pangaea;
|
||||
case GameMapType.Asia:
|
||||
return asia;
|
||||
case GameMapType.Mars:
|
||||
return mars;
|
||||
case GameMapType.Britannia:
|
||||
return britannia;
|
||||
case GameMapType.GatewayToTheAtlantic:
|
||||
return gatewayToTheAtlantic;
|
||||
case GameMapType.Australia:
|
||||
return australia;
|
||||
case GameMapType.Iceland:
|
||||
return iceland;
|
||||
case GameMapType.EastAsia:
|
||||
return eastasia;
|
||||
case GameMapType.BetweenTwoSeas:
|
||||
return betweenTwoSeas;
|
||||
case GameMapType.FaroeIslands:
|
||||
return faroeislands;
|
||||
case GameMapType.DeglaciatedAntarctica:
|
||||
return deglaciatedAntarctica;
|
||||
case GameMapType.FalklandIslands:
|
||||
return falklandislands;
|
||||
case GameMapType.Baikal:
|
||||
return baikal;
|
||||
case GameMapType.Halkidiki:
|
||||
return halkidiki;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,9 @@ export class GameMapImpl implements GameMap {
|
||||
private static readonly IS_LAND_BIT = 7;
|
||||
private static readonly SHORELINE_BIT = 6;
|
||||
private static readonly OCEAN_BIT = 5;
|
||||
private static readonly MAGNITUDE_OFFSET = 4; // Uses bits 3-7 (5 bits)
|
||||
private static readonly MAGNITUDE_MASK = 0x1f; // 11111 in binary
|
||||
|
||||
// State bits (Uint16Array)
|
||||
private static readonly PLAYER_ID_OFFSET = 0; // Uses bits 0-11 (12 bits)
|
||||
private static readonly PLAYER_ID_MASK = 0xfff;
|
||||
private static readonly FALLOUT_BIT = 13;
|
||||
private static readonly DEFENSE_BONUS_BIT = 14;
|
||||
|
||||
@@ -2,18 +2,13 @@ import { GameMapType } from "./Game";
|
||||
import { NationMap } from "./TerrainMapLoader";
|
||||
|
||||
interface MapData {
|
||||
mapBin: string;
|
||||
miniMapBin: string;
|
||||
nationMap: NationMap;
|
||||
mapBin: () => Promise<string>;
|
||||
miniMapBin: () => Promise<string>;
|
||||
nationMap: () => Promise<NationMap>;
|
||||
webpPath: () => Promise<string>;
|
||||
}
|
||||
|
||||
interface MapCache {
|
||||
bin?: string;
|
||||
miniMapBin?: string;
|
||||
nationMap?: NationMap;
|
||||
}
|
||||
|
||||
interface BinModule {
|
||||
export interface BinModule {
|
||||
default: string;
|
||||
}
|
||||
|
||||
@@ -21,90 +16,65 @@ interface NationMapModule {
|
||||
default: NationMap;
|
||||
}
|
||||
|
||||
// Mapping from GameMap enum values to file names
|
||||
const MAP_FILE_NAMES: Record<GameMapType, string> = {
|
||||
[GameMapType.World]: "WorldMap",
|
||||
[GameMapType.GiantWorldMap]: "WorldMapGiant",
|
||||
[GameMapType.Europe]: "Europe",
|
||||
[GameMapType.Mena]: "Mena",
|
||||
[GameMapType.NorthAmerica]: "NorthAmerica",
|
||||
[GameMapType.Oceania]: "Oceania",
|
||||
[GameMapType.BlackSea]: "BlackSea",
|
||||
[GameMapType.Africa]: "Africa",
|
||||
[GameMapType.Pangaea]: "Pangaea",
|
||||
[GameMapType.Asia]: "Asia",
|
||||
[GameMapType.Mars]: "Mars",
|
||||
[GameMapType.SouthAmerica]: "SouthAmerica",
|
||||
[GameMapType.Britannia]: "Britannia",
|
||||
[GameMapType.GatewayToTheAtlantic]: "GatewayToTheAtlantic",
|
||||
[GameMapType.Australia]: "Australia",
|
||||
[GameMapType.Iceland]: "Iceland",
|
||||
[GameMapType.EastAsia]: "EastAsia",
|
||||
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
|
||||
[GameMapType.FaroeIslands]: "FaroeIslands",
|
||||
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
|
||||
[GameMapType.EuropeClassic]: "EuropeClassic",
|
||||
[GameMapType.FalklandIslands]: "FalklandIslands",
|
||||
[GameMapType.Baikal]: "Baikal",
|
||||
[GameMapType.Halkidiki]: "Halkidiki",
|
||||
};
|
||||
|
||||
class GameMapLoader {
|
||||
private maps: Map<GameMapType, MapCache>;
|
||||
private loadingPromises: Map<GameMapType, Promise<MapData>>;
|
||||
private maps: Map<GameMapType, MapData>;
|
||||
|
||||
constructor() {
|
||||
this.maps = new Map<GameMapType, MapCache>();
|
||||
this.loadingPromises = new Map<GameMapType, Promise<MapData>>();
|
||||
this.maps = new Map<GameMapType, MapData>();
|
||||
}
|
||||
|
||||
public async getMapData(map: GameMapType): Promise<MapData> {
|
||||
const cachedMap = this.maps.get(map);
|
||||
if (cachedMap?.bin && cachedMap?.nationMap) {
|
||||
return cachedMap as MapData;
|
||||
}
|
||||
|
||||
if (!this.loadingPromises.has(map)) {
|
||||
this.loadingPromises.set(map, this.loadMapData(map));
|
||||
}
|
||||
|
||||
const data = await this.loadingPromises.get(map)!;
|
||||
this.maps.set(map, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async loadMapData(map: GameMapType): Promise<MapData> {
|
||||
const fileName = MAP_FILE_NAMES[map];
|
||||
if (!fileName) {
|
||||
throw new Error(`No file name mapping found for map: ${map}`);
|
||||
}
|
||||
|
||||
const [binModule, miniBinModule, infoModule] = await Promise.all([
|
||||
import(
|
||||
`!!binary-loader!../../../resources/maps/${fileName}.bin`
|
||||
) as Promise<BinModule>,
|
||||
import(
|
||||
`!!binary-loader!../../../resources/maps/${fileName}Mini.bin`
|
||||
) as Promise<BinModule>,
|
||||
import(
|
||||
`../../../resources/maps/${fileName}.json`
|
||||
) as Promise<NationMapModule>,
|
||||
]);
|
||||
|
||||
return {
|
||||
mapBin: binModule.default,
|
||||
miniMapBin: miniBinModule.default,
|
||||
nationMap: infoModule.default,
|
||||
private createLazyLoader<T>(importFn: () => Promise<T>): () => Promise<T> {
|
||||
let cache: Promise<T> | null = null;
|
||||
return () => {
|
||||
if (!cache) {
|
||||
cache = importFn();
|
||||
}
|
||||
return cache;
|
||||
};
|
||||
}
|
||||
|
||||
public isMapLoaded(map: GameMapType): boolean {
|
||||
const mapData = this.maps.get(map);
|
||||
return !!mapData?.bin && !!mapData?.nationMap;
|
||||
}
|
||||
public getMapData(map: GameMapType): MapData {
|
||||
const cachedMap = this.maps.get(map);
|
||||
if (cachedMap) {
|
||||
return cachedMap;
|
||||
}
|
||||
|
||||
public getLoadedMaps(): GameMapType[] {
|
||||
return Array.from(this.maps.keys()).filter((map) => this.isMapLoaded(map));
|
||||
const key = Object.keys(GameMapType).find((k) => GameMapType[k] === map);
|
||||
const fileName = key?.toLowerCase();
|
||||
|
||||
const mapData = {
|
||||
mapBin: this.createLazyLoader(() =>
|
||||
(
|
||||
import(
|
||||
`!!binary-loader!../../../resources/maps/${fileName}/map.bin`
|
||||
) as Promise<BinModule>
|
||||
).then((m) => m.default),
|
||||
),
|
||||
miniMapBin: this.createLazyLoader(() =>
|
||||
(
|
||||
import(
|
||||
`!!binary-loader!../../../resources/maps/${fileName}/mini_map.bin`
|
||||
) as Promise<BinModule>
|
||||
).then((m) => m.default),
|
||||
),
|
||||
nationMap: this.createLazyLoader(() =>
|
||||
(
|
||||
import(
|
||||
`../../../resources/maps/${fileName}/manifest.json`
|
||||
) as Promise<NationMapModule>
|
||||
).then((m) => m.default),
|
||||
),
|
||||
webpPath: this.createLazyLoader(() =>
|
||||
(
|
||||
import(
|
||||
`../../../resources/maps/${fileName}/thumbnail.webp`
|
||||
) as Promise<{ default: string }>
|
||||
).then((m) => m.default),
|
||||
),
|
||||
} satisfies MapData;
|
||||
|
||||
this.maps.set(map, mapData);
|
||||
return mapData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type TerrainMapData = {
|
||||
const loadedMaps = new Map<GameMapType, TerrainMapData>();
|
||||
|
||||
export interface NationMap {
|
||||
name: string;
|
||||
nations: Nation[];
|
||||
}
|
||||
|
||||
@@ -26,12 +27,12 @@ export async function loadTerrainMap(
|
||||
): Promise<TerrainMapData> {
|
||||
const cached = loadedMaps.get(map);
|
||||
if (cached !== undefined) return cached;
|
||||
const mapFiles = await terrainMapFileLoader.getMapData(map);
|
||||
const mapFiles = terrainMapFileLoader.getMapData(map);
|
||||
|
||||
const gameMap = await genTerrainFromBin(mapFiles.mapBin);
|
||||
const miniGameMap = await genTerrainFromBin(mapFiles.miniMapBin);
|
||||
const gameMap = await genTerrainFromBin(await mapFiles.mapBin());
|
||||
const miniGameMap = await genTerrainFromBin(await mapFiles.miniMapBin());
|
||||
const result = {
|
||||
nationMap: mapFiles.nationMap,
|
||||
nationMap: await mapFiles.nationMap(),
|
||||
gameMap: gameMap,
|
||||
miniGameMap: miniGameMap,
|
||||
};
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
import { Bitmap, decodePNGFromStream } from "pureimage";
|
||||
//import path from "path";
|
||||
//import fs from "fs/promises";
|
||||
//import { createReadStream } from "fs";
|
||||
import { Readable } from "stream";
|
||||
|
||||
const min_island_size = 30;
|
||||
const min_lake_size = 200;
|
||||
|
||||
interface Coord {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
enum TerrainType {
|
||||
Land,
|
||||
Water,
|
||||
}
|
||||
|
||||
class Terrain {
|
||||
public shoreline: boolean = false;
|
||||
public magnitude: number = 0;
|
||||
public ocean: boolean = false;
|
||||
constructor(public type: TerrainType) {}
|
||||
}
|
||||
|
||||
export async function generateMap(
|
||||
imageBuffer: Buffer,
|
||||
removeSmall = true,
|
||||
name: string = "",
|
||||
): Promise<{ map: Uint8Array; miniMap: Uint8Array; thumb: Bitmap }> {
|
||||
const stream = Readable.from(imageBuffer);
|
||||
const img = await decodePNGFromStream(stream);
|
||||
|
||||
console.debug(
|
||||
`Processing Map: ${name}, dimensions: ${img.width}x${img.height}`,
|
||||
);
|
||||
|
||||
const terrain: Terrain[][] = Array(img.width)
|
||||
.fill(null)
|
||||
.map(() => Array(img.height).fill(null));
|
||||
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const color = img.getPixelRGBA(x, y);
|
||||
const alpha = color & 0xff;
|
||||
const blue = (color >> 8) & 0xff;
|
||||
|
||||
if (alpha < 20 || blue === 106) {
|
||||
// transparent
|
||||
terrain[x][y] = new Terrain(TerrainType.Water);
|
||||
} else {
|
||||
terrain[x][y] = new Terrain(TerrainType.Land);
|
||||
terrain[x][y].magnitude = 0;
|
||||
|
||||
// 140 -> 200 = 60
|
||||
const mag = Math.min(200, Math.max(140, blue)) - 140;
|
||||
terrain[x][y].magnitude = mag / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeSmallIslands(terrain, removeSmall);
|
||||
processWater(terrain, removeSmall);
|
||||
|
||||
const miniTerrain = await createMiniMap(terrain);
|
||||
const thumb = await createMapThumbnail(miniTerrain);
|
||||
|
||||
return {
|
||||
map: packTerrain(terrain),
|
||||
miniMap: packTerrain(miniTerrain),
|
||||
thumb: thumb,
|
||||
};
|
||||
}
|
||||
|
||||
async function createMiniMap(tm: Terrain[][]): Promise<Terrain[][]> {
|
||||
// Create 2D array properly with correct dimensions
|
||||
const miniMap: Terrain[][] = Array(Math.floor(tm.length / 2))
|
||||
.fill(null)
|
||||
.map(() => Array(Math.floor(tm[0].length / 2)).fill(null));
|
||||
|
||||
for (let x = 0; x < tm.length; x++) {
|
||||
for (let y = 0; y < tm[0].length; y++) {
|
||||
const miniX = Math.floor(x / 2);
|
||||
const miniY = Math.floor(y / 2);
|
||||
|
||||
if (
|
||||
miniMap[miniX][miniY] === null ||
|
||||
miniMap[miniX][miniY].type !== TerrainType.Water
|
||||
) {
|
||||
// We shrink 4 tiles into 1 tile. If any of the 4 large tiles
|
||||
// has water, then the mini tile is considered water.
|
||||
miniMap[miniX][miniY] = tm[x][y];
|
||||
}
|
||||
}
|
||||
}
|
||||
return miniMap;
|
||||
}
|
||||
|
||||
function processShore(map: Terrain[][]): Coord[] {
|
||||
console.debug("Identifying shorelines");
|
||||
const shorelineWaters: Coord[] = [];
|
||||
for (let x = 0; x < map.length; x++) {
|
||||
for (let y = 0; y < map[0].length; y++) {
|
||||
const tile = map[x][y];
|
||||
const ns = neighbors(x, y, map);
|
||||
if (tile.type === TerrainType.Land) {
|
||||
if (ns.filter((t) => t.type === TerrainType.Water).length > 0) {
|
||||
tile.shoreline = true;
|
||||
}
|
||||
} else {
|
||||
if (ns.filter((t) => t.type === TerrainType.Land).length > 0) {
|
||||
tile.shoreline = true;
|
||||
shorelineWaters.push({ x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return shorelineWaters;
|
||||
}
|
||||
|
||||
function processDistToLand(shorelineWaters: Coord[], map: Terrain[][]) {
|
||||
console.debug(
|
||||
"Setting Water tiles magnitude = Manhattan distance from nearest land",
|
||||
);
|
||||
|
||||
const width = map.length;
|
||||
const height = map[0].length;
|
||||
|
||||
const visited = Array.from({ length: width }, () =>
|
||||
Array(height).fill(false),
|
||||
);
|
||||
const queue: { x: number; y: number; dist: number }[] = [];
|
||||
|
||||
for (const { x, y } of shorelineWaters) {
|
||||
queue.push({ x, y, dist: 0 });
|
||||
visited[x][y] = true;
|
||||
map[x][y].magnitude = 0;
|
||||
}
|
||||
|
||||
const directions = [
|
||||
{ dx: 0, dy: 1 },
|
||||
{ dx: 1, dy: 0 },
|
||||
{ dx: 0, dy: -1 },
|
||||
{ dx: -1, dy: 0 },
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { x, y, dist } = queue.shift()!;
|
||||
|
||||
for (const { dx, dy } of directions) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
|
||||
if (
|
||||
nx >= 0 &&
|
||||
ny >= 0 &&
|
||||
nx < width &&
|
||||
ny < height &&
|
||||
!visited[nx][ny] &&
|
||||
map[nx][ny].type === TerrainType.Water
|
||||
) {
|
||||
visited[nx][ny] = true;
|
||||
map[nx][ny].magnitude = dist + 1;
|
||||
queue.push({ x: nx, y: ny, dist: dist + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function neighbors(x: number, y: number, map: Terrain[][]): Terrain[] {
|
||||
const nCoords: Coord[] = getNeighborCoords(x, y, map);
|
||||
const ns: Terrain[] = [];
|
||||
for (const nCoord of nCoords) {
|
||||
ns.push(map[nCoord.x][nCoord.y]);
|
||||
}
|
||||
return ns;
|
||||
}
|
||||
|
||||
function processWater(map: Terrain[][], removeSmall: boolean) {
|
||||
console.debug("Processing water bodies");
|
||||
const visited = new Set<string>();
|
||||
const waterBodies: { coords: Coord[]; size: number }[] = [];
|
||||
|
||||
// Find all distinct water bodies
|
||||
for (let x = 0; x < map.length; x++) {
|
||||
for (let y = 0; y < map[0].length; y++) {
|
||||
if (map[x][y].type === TerrainType.Water) {
|
||||
const key = `${x},${y}`;
|
||||
if (visited.has(key)) continue;
|
||||
|
||||
const waterBody: Coord[] = getArea(x, y, map, visited);
|
||||
waterBodies.push({
|
||||
coords: waterBody,
|
||||
size: waterBody.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort water bodies by size (largest first)
|
||||
waterBodies.sort((a, b) => b.size - a.size);
|
||||
|
||||
let smallLakes = 0;
|
||||
|
||||
if (waterBodies.length > 0) {
|
||||
// Mark the largest water body as ocean
|
||||
const largestWaterBody = waterBodies[0];
|
||||
for (const coord of largestWaterBody.coords) {
|
||||
map[coord.x][coord.y].ocean = true;
|
||||
}
|
||||
console.debug(`Identified ocean with ${largestWaterBody.size} water tiles`);
|
||||
|
||||
if (removeSmall) {
|
||||
// Assess size of the other water bodies and remove those smaller than min_lake_size
|
||||
console.debug("Searching for small water bodies for removal");
|
||||
for (let w = 1; w < waterBodies.length; w++) {
|
||||
if (waterBodies[w].size < min_lake_size) {
|
||||
smallLakes++;
|
||||
for (const coord of waterBodies[w].coords) {
|
||||
map[coord.x][coord.y].type = TerrainType.Land;
|
||||
map[coord.x][coord.y].magnitude = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.debug(
|
||||
`Identified and removed ${smallLakes} bodies of water smaller than ${min_lake_size} tiles`,
|
||||
);
|
||||
}
|
||||
|
||||
//Identify shoreline tiles, get array of shoreline water tiles
|
||||
const shorelineWaters = processShore(map);
|
||||
//Adjust water tile magnitudes to reflect distance from land
|
||||
processDistToLand(shorelineWaters, map);
|
||||
} else {
|
||||
console.debug("No water bodies found in the map");
|
||||
}
|
||||
}
|
||||
|
||||
function packTerrain(map: Terrain[][]): Uint8Array {
|
||||
const width = map.length;
|
||||
const height = map[0].length;
|
||||
const packedData = new Uint8Array(4 + width * height);
|
||||
|
||||
// Add width and height to the first 4 bytes
|
||||
packedData[0] = width & 0xff;
|
||||
packedData[1] = (width >> 8) & 0xff;
|
||||
packedData[2] = height & 0xff;
|
||||
packedData[3] = (height >> 8) & 0xff;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let y = 0; y < height; y++) {
|
||||
const tile = map[x][y];
|
||||
let packedByte = 0;
|
||||
if (tile === null) {
|
||||
throw new Error(`terrain null at ${x}:${y}`);
|
||||
}
|
||||
|
||||
if (tile.type === TerrainType.Land) {
|
||||
packedByte |= 0b10000000;
|
||||
}
|
||||
if (tile.shoreline) {
|
||||
packedByte |= 0b01000000;
|
||||
}
|
||||
if (tile.ocean) {
|
||||
packedByte |= 0b00100000;
|
||||
}
|
||||
if (tile.type === TerrainType.Land) {
|
||||
packedByte |= Math.min(Math.ceil(tile.magnitude), 31);
|
||||
} else {
|
||||
packedByte |= Math.min(Math.ceil(tile.magnitude / 2), 31);
|
||||
}
|
||||
|
||||
packedData[4 + y * width + x] = packedByte;
|
||||
}
|
||||
}
|
||||
logBinaryAsBits(packedData);
|
||||
return packedData;
|
||||
}
|
||||
|
||||
function getArea(
|
||||
x: number,
|
||||
y: number,
|
||||
map: Terrain[][],
|
||||
visited: Set<string>,
|
||||
): Coord[] {
|
||||
const targetType: TerrainType = map[x][y].type;
|
||||
const area: Coord[] = [];
|
||||
const queue: Coord[] = [{ x, y }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const coord = queue.shift()!;
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
|
||||
if (map[coord.x][coord.y].type === targetType) {
|
||||
area.push({ x: coord.x, y: coord.y });
|
||||
|
||||
const nCoords: Coord[] = getNeighborCoords(coord.x, coord.y, map);
|
||||
for (const nCoord of nCoords) {
|
||||
queue.push({ x: nCoord.x, y: nCoord.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
function removeSmallIslands(map: Terrain[][], removeSmall: boolean) {
|
||||
if (!removeSmall) return;
|
||||
const visited = new Set<string>();
|
||||
const landBodies: { coords: Coord[]; size: number }[] = [];
|
||||
|
||||
// Find all distinct land bodies
|
||||
for (let x = 0; x < map.length; x++) {
|
||||
for (let y = 0; y < map[0].length; y++) {
|
||||
if (map[x][y].type === TerrainType.Land) {
|
||||
const key = `${x},${y}`;
|
||||
if (visited.has(key)) continue;
|
||||
|
||||
const landBody: Coord[] = getArea(x, y, map, visited);
|
||||
landBodies.push({
|
||||
coords: landBody,
|
||||
size: landBody.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let smallIslands = 0;
|
||||
|
||||
for (let b = 0; b < landBodies.length; b++) {
|
||||
if (landBodies[b].size < min_island_size) {
|
||||
smallIslands++;
|
||||
for (const coord of landBodies[b].coords) {
|
||||
map[coord.x][coord.y].type = TerrainType.Water;
|
||||
map[coord.x][coord.y].magnitude = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.debug(
|
||||
`Identified and removed ${smallIslands} islands smaller than ${min_island_size} tiles`,
|
||||
);
|
||||
}
|
||||
|
||||
function logBinaryAsBits(data: Uint8Array, length: number = 8) {
|
||||
const bits = Array.from(data.slice(0, length))
|
||||
.map((b) => b.toString(2).padStart(8, "0"))
|
||||
.join(" ");
|
||||
console.debug(`Binary data (bits):`, bits);
|
||||
}
|
||||
|
||||
function getNeighborCoords(x: number, y: number, map: Terrain[][]): Coord[] {
|
||||
const coords: Coord[] = [];
|
||||
if (x > 0) {
|
||||
coords.push({ x: x - 1, y: y });
|
||||
}
|
||||
if (x < map.length - 1) {
|
||||
coords.push({ x: x + 1, y });
|
||||
}
|
||||
if (y > 0) {
|
||||
coords.push({ x: x, y: y - 1 });
|
||||
}
|
||||
if (y < map[0].length - 1) {
|
||||
coords.push({ x: x, y: y + 1 });
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
async function createMapThumbnail(
|
||||
map: Terrain[][],
|
||||
quality: number = 0.5,
|
||||
): Promise<Bitmap> {
|
||||
console.debug("creating thumbnail");
|
||||
|
||||
const srcWidth = map.length;
|
||||
const srcHeight = map[0].length;
|
||||
|
||||
const targetWidth = Math.max(1, Math.floor(srcWidth * quality));
|
||||
const targetHeight = Math.max(1, Math.floor(srcHeight * quality));
|
||||
|
||||
const bitmap = new Bitmap(targetWidth, targetHeight);
|
||||
|
||||
for (let x = 0; x < targetWidth; x++) {
|
||||
for (let y = 0; y < targetHeight; y++) {
|
||||
const srcX = Math.floor(x / quality);
|
||||
const srcY = Math.floor(y / quality);
|
||||
const terrain =
|
||||
map[Math.min(srcX, srcWidth - 1)][Math.min(srcY, srcHeight - 1)];
|
||||
const rgba = getThumbnailColor(terrain);
|
||||
bitmap.setPixelRGBA_i(x, y, rgba.r, rgba.g, rgba.b, rgba.a);
|
||||
}
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
function getThumbnailColor(t: Terrain): {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
} {
|
||||
if (t.type === TerrainType.Water) {
|
||||
// Shoreline water
|
||||
if (t.shoreline) return { r: 100, g: 143, b: 255, a: 0 };
|
||||
// All other water: adjust based on magnitude
|
||||
const waterAdjRGB: number = 11 - Math.min(t.magnitude / 2, 10) - 10;
|
||||
return {
|
||||
r: Math.max(70 + waterAdjRGB, 0),
|
||||
g: Math.max(132 + waterAdjRGB, 0),
|
||||
b: Math.max(180 + waterAdjRGB, 0),
|
||||
a: 0,
|
||||
};
|
||||
}
|
||||
//shoreline land
|
||||
if (t.shoreline) {
|
||||
return { r: 204, g: 203, b: 158, a: 255 };
|
||||
}
|
||||
let adjRGB: number;
|
||||
if (t.magnitude < 10) {
|
||||
// Plains
|
||||
adjRGB = 220 - 2 * t.magnitude;
|
||||
return {
|
||||
r: 190,
|
||||
g: adjRGB,
|
||||
b: 138,
|
||||
a: 255,
|
||||
};
|
||||
} else if (t.magnitude < 20) {
|
||||
// Highlands
|
||||
adjRGB = 2 * t.magnitude;
|
||||
return {
|
||||
r: 200 + adjRGB,
|
||||
g: 183 + adjRGB,
|
||||
b: 138 + adjRGB,
|
||||
a: 255,
|
||||
};
|
||||
} else {
|
||||
// Mountains
|
||||
adjRGB = Math.floor(230 + t.magnitude / 2);
|
||||
return {
|
||||
r: adjRGB,
|
||||
g: adjRGB,
|
||||
b: adjRGB,
|
||||
a: 255,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import { generateMap } from "./TerrainMapGenerator.js";
|
||||
|
||||
const maps = [
|
||||
"Africa",
|
||||
"Asia",
|
||||
"WorldMap",
|
||||
"WorldMapGiant",
|
||||
"BlackSea",
|
||||
"Europe",
|
||||
"EuropeClassic",
|
||||
"Mars",
|
||||
"Mena",
|
||||
"Oceania",
|
||||
"NorthAmerica",
|
||||
"SouthAmerica",
|
||||
"Britannia",
|
||||
"GatewayToTheAtlantic",
|
||||
"Australia",
|
||||
"Pangaea",
|
||||
"Iceland",
|
||||
"BetweenTwoSeas",
|
||||
"EastAsia",
|
||||
"KnownWorld",
|
||||
"FaroeIslands",
|
||||
"DeglaciatedAntarctica",
|
||||
"FalklandIslands",
|
||||
"Baikal",
|
||||
"Halkidiki",
|
||||
];
|
||||
|
||||
const removeSmall = true;
|
||||
|
||||
async function loadTerrainMaps() {
|
||||
await Promise.all(
|
||||
maps.map(async (map) => {
|
||||
const mapPath = path.resolve(
|
||||
process.cwd(),
|
||||
"resources",
|
||||
"maps",
|
||||
map + ".png",
|
||||
);
|
||||
const imageBuffer = await fs.readFile(mapPath);
|
||||
const {
|
||||
map: mainMap,
|
||||
miniMap,
|
||||
thumb,
|
||||
} = await generateMap(imageBuffer, removeSmall, map);
|
||||
|
||||
const outputPath = path.join(
|
||||
process.cwd(),
|
||||
"resources",
|
||||
"maps",
|
||||
map + ".bin",
|
||||
);
|
||||
const miniOutputPath = path.join(
|
||||
process.cwd(),
|
||||
"resources",
|
||||
"maps",
|
||||
map + "Mini.bin",
|
||||
);
|
||||
const thumbOutputPath = path.join(
|
||||
process.cwd(),
|
||||
"resources",
|
||||
"maps",
|
||||
map + "Thumb.webp",
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
fs.writeFile(outputPath, mainMap),
|
||||
fs.writeFile(miniOutputPath, miniMap),
|
||||
sharp(Buffer.from(thumb.data), {
|
||||
raw: {
|
||||
width: thumb.width,
|
||||
height: thumb.height,
|
||||
channels: 4,
|
||||
},
|
||||
})
|
||||
.webp({ quality: 45 })
|
||||
.toFile(thumbOutputPath),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await loadTerrainMaps();
|
||||
console.log("Terrain maps generated successfully");
|
||||
} catch (error) {
|
||||
console.error("Error generating terrain maps:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user