diff --git a/map-generator/README.md b/map-generator/README.md index 4d1ac66e2..90d5d2cc0 100644 --- a/map-generator/README.md +++ b/map-generator/README.md @@ -10,24 +10,59 @@ This is a tool to generate map files for OpenFront. ## Creating a new map -1. Create a new folder in assets/maps/ +1. Create a new folder in `assets/maps/` 2. Create image.png 3. Create info.json with name and countries 4. Add the map name in main.go 5. Run the generator: `go run .` -6. Find the output folder at generated/maps/ +6. Find the output folder at `../resources/maps/` ## Create image.png -1. Download world map (warning very large file) https://drive.google.com/file/d/1W2oMPj1L5zWRyPhh8LfmnY3_kve-FBR2/view?usp=sharing -2. Crop the file (recommend Gimp), we recommend roughly 2 million pixels for performance reasons. Do not go over 4 million pixels. +1. [Download world map (warning very large file)](https://drive.google.com/file/d/1W2oMPj1L5zWRyPhh8LfmnY3_kve-FBR2/view?usp=sharing) +2. Crop the file (recommend Gimp) + +- We recommend roughly 2 million pixels for performance reasons +- Do not go over 4 million pixels. ## Create info.json - Look at existing info.json for structure -- Use country codes found here: https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes +- [Use country codes found here](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) + +Example: + +```json +{ + "name": "MySampleMap", + "nations": [ + { + "coordinates": [396, 364], + "name": "United States", + "strength": 3, + "flag": "us" + } + ] +} +``` + +## To Enable In-Game + +- Add a translation for the map name to `resources/lang/en.json` +- Add the MapDescription `src/client/components/Maps.ts` +- Add the numPlayersConfig `src/core/configuration/DefaultConfig.ts` +- Add the GameMapType `src/core/game/Game.ts` +- To add to the map playlist, modify `src/server/MapPlaylist.ts` ## Notes - Islands smaller than 30 tiles (pixels) are automatically removed by the script. - Bodies of water smaller than 200 tiles (pixels) are also removed. + +## 🛠️ Development Tools + +- **Format map-generator code**: + + ```bash + go fmt . + ``` diff --git a/map-generator/main.go b/map-generator/main.go index ee58a7ed5..b465c024e 100644 --- a/map-generator/main.go +++ b/map-generator/main.go @@ -71,13 +71,12 @@ func inputMapDir(isTest bool) (string, error) { return "", fmt.Errorf("failed to get working directory: %w", err) } if isTest { - return filepath.Join(cwd, "assets", "test_maps"), nil + return filepath.Join(cwd, "assets", "test_maps"), nil } else { - return filepath.Join(cwd, "assets", "maps"), nil + return filepath.Join(cwd, "assets", "maps"), nil } } - func processMap(name string, isTest bool) error { outputMapBaseDir, err := outputMapDir(isTest) if err != nil { @@ -119,18 +118,18 @@ func processMap(name string, isTest bool) error { } manifest["map"] = map[string]interface{}{ - "width": result.Map.Width, - "height": result.Map.Height, + "width": result.Map.Width, + "height": result.Map.Height, "num_land_tiles": result.Map.NumLandTiles, - } + } manifest["map4x"] = map[string]interface{}{ - "width": result.Map4x.Width, - "height": result.Map4x.Height, + "width": result.Map4x.Width, + "height": result.Map4x.Height, "num_land_tiles": result.Map4x.NumLandTiles, } manifest["map16x"] = map[string]interface{}{ - "width": result.Map16x.Width, - "height": result.Map16x.Height, + "width": result.Map16x.Width, + "height": result.Map16x.Height, "num_land_tiles": result.Map16x.NumLandTiles, } @@ -150,13 +149,13 @@ func processMap(name string, isTest bool) error { if err := os.WriteFile(filepath.Join(mapDir, "thumbnail.webp"), result.Thumbnail, 0644); err != nil { return fmt.Errorf("failed to write thumbnail for %s: %w", name, err) } - + // Serialize the updated manifest to JSON updatedManifest, err := json.MarshalIndent(manifest, "", " ") if err != nil { return fmt.Errorf("failed to serialize manifest for %s: %w", name, err) } - + if err := os.WriteFile(filepath.Join(mapDir, "manifest.json"), updatedManifest, 0644); err != nil { return fmt.Errorf("failed to write manifest for %s: %w", name, err) } @@ -196,6 +195,6 @@ func main() { if err := loadTerrainMaps(); err != nil { log.Fatalf("Error generating terrain maps: %v", err) } - + fmt.Println("Terrain maps generated successfully") } diff --git a/map-generator/map_generator.go b/map-generator/map_generator.go index f6b7bcbbf..0e0ad1f48 100644 --- a/map-generator/map_generator.go +++ b/map-generator/map_generator.go @@ -43,20 +43,20 @@ type Terrain struct { type MapResult struct { Thumbnail []byte - Map MapInfo - Map4x MapInfo - Map16x MapInfo + Map MapInfo + Map4x MapInfo + Map16x MapInfo } type MapInfo struct { - Data []byte - Width int - Height int + Data []byte + Width int + Height int NumLandTiles int } type GeneratorArgs struct { - Name string + Name string ImageBuffer []byte RemoveSmall bool } @@ -96,7 +96,7 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { } else { // Land terrain[x][y] = Terrain{Type: Land} - + // Calculate magnitude from blue channel (140-200 range) mag := math.Min(200, math.Max(140, float64(blue))) - 140 terrain[x][y].Magnitude = mag / 2 @@ -108,11 +108,11 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { processWater(terrain, args.RemoveSmall) terrain4x := createMiniMap(terrain) - processWater(terrain4x, false) - + processWater(terrain4x, false) + terrain16x := createMiniMap(terrain4x) processWater(terrain16x, false) - + thumb := createMapThumbnail(terrain4x, 0.5) webp, err := convertToWebP(ThumbData{ Data: thumb.Pix, @@ -129,21 +129,21 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { return MapResult{ Map: MapInfo{ - Data: mapData, - Width: width, - Height: height, + Data: mapData, + Width: width, + Height: height, NumLandTiles: mapNumLandTiles, }, Map4x: MapInfo{ - Data: mapData4x, - Width: width / 2, - Height: height / 2, + Data: mapData4x, + Width: width / 2, + Height: height / 2, NumLandTiles: numLandTiles4x, }, Map16x: MapInfo{ - Data: mapData16x, - Width: width / 4, - Height: height / 4, + Data: mapData16x, + Width: width / 4, + Height: height / 4, NumLandTiles: numLandTiles16x, }, Thumbnail: webp, @@ -153,13 +153,13 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { func convertToWebP(thumb ThumbData) ([]byte, error) { // Create RGBA image from raw data img := image.NewRGBA(image.Rect(0, 0, thumb.Width, thumb.Height)) - + // Copy the raw RGBA data if len(thumb.Data) != thumb.Width*thumb.Height*4 { - return nil, fmt.Errorf("invalid thumb data length: expected %d, got %d", + return nil, fmt.Errorf("invalid thumb data length: expected %d, got %d", thumb.Width*thumb.Height*4, len(thumb.Data)) } - + copy(img.Pix, thumb.Data) // Encode as WebP with quality 45 (equivalent to the JavaScript version) @@ -174,10 +174,10 @@ func convertToWebP(thumb ThumbData) ([]byte, error) { func createMiniMap(tm [][]Terrain) [][]Terrain { width := len(tm) height := len(tm[0]) - + miniWidth := width / 2 miniHeight := height / 2 - + miniMap := make([][]Terrain, miniWidth) for x := range miniMap { miniMap[x] = make([]Terrain, miniHeight) @@ -187,7 +187,7 @@ func createMiniMap(tm [][]Terrain) [][]Terrain { for y := 0; y < height; y++ { miniX := x / 2 miniY := y / 2 - + if miniX < miniWidth && miniY < miniHeight { // If any of the 4 tiles has water, mini tile is water if miniMap[miniX][miniY].Type != Water { @@ -196,7 +196,7 @@ func createMiniMap(tm [][]Terrain) [][]Terrain { } } } - + return miniMap } @@ -210,7 +210,7 @@ func processShore(terrain [][]Terrain) []Coord { for y := 0; y < height; y++ { tile := &terrain[x][y] neighbors := getNeighbors(x, y, terrain) - + if tile.Type == Land { // Land tile adjacent to water is shoreline for _, n := range neighbors { @@ -231,47 +231,47 @@ func processShore(terrain [][]Terrain) []Coord { } } } - + return shorelineWaters } func processDistToLand(shorelineWaters []Coord, terrain [][]Terrain) { log.Println("Setting Water tiles magnitude = Manhattan distance from nearest land") - + width := len(terrain) height := len(terrain[0]) - + visited := make([][]bool, width) for x := range visited { visited[x] = make([]bool, height) } - + type queueItem struct { x, y, dist int } - + queue := make([]queueItem, 0) - + // Initialize queue with shoreline waters for _, coord := range shorelineWaters { queue = append(queue, queueItem{x: coord.X, y: coord.Y, dist: 0}) visited[coord.X][coord.Y] = true terrain[coord.X][coord.Y].Magnitude = 0 } - + directions := []Coord{{0, 1}, {1, 0}, {0, -1}, {-1, 0}} - + for len(queue) > 0 { current := queue[0] queue = queue[1:] - + for _, dir := range directions { nx := current.x + dir.X ny := current.y + dir.Y - + if nx >= 0 && ny >= 0 && nx < width && ny < height && !visited[nx][ny] && terrain[nx][ny].Type == Water { - + visited[nx][ny] = true terrain[nx][ny].Magnitude = float64(current.dist + 1) queue = append(queue, queueItem{x: nx, y: ny, dist: current.dist + 1}) @@ -293,7 +293,7 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord { width := len(terrain) height := len(terrain[0]) var coords []Coord - + if x > 0 { coords = append(coords, Coord{X: x - 1, Y: y}) } @@ -306,21 +306,21 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord { if y < height-1 { coords = append(coords, Coord{X: x, Y: y + 1}) } - + return coords } func processWater(terrain [][]Terrain, removeSmall bool) { log.Println("Processing water bodies") visited := make(map[string]bool) - + type waterBody struct { coords []Coord size int } - + var waterBodies []waterBody - + // Find all distinct water bodies for x := 0; x < len(terrain); x++ { for y := 0; y < len(terrain[0]); y++ { @@ -329,7 +329,7 @@ func processWater(terrain [][]Terrain, removeSmall bool) { if visited[key] { continue } - + coords := getArea(x, y, terrain, visited) waterBodies = append(waterBodies, waterBody{ coords: coords, @@ -338,7 +338,7 @@ func processWater(terrain [][]Terrain, removeSmall bool) { } } } - + // Sort by size (largest first) for i := 0; i < len(waterBodies)-1; i++ { for j := i + 1; j < len(waterBodies); j++ { @@ -347,9 +347,9 @@ func processWater(terrain [][]Terrain, removeSmall bool) { } } } - + smallLakes := 0 - + if len(waterBodies) > 0 { // Mark largest water body as ocean largestWaterBody := waterBodies[0] @@ -357,7 +357,7 @@ func processWater(terrain [][]Terrain, removeSmall bool) { terrain[coord.X][coord.Y].Ocean = true } log.Printf("Identified ocean with %d water tiles", largestWaterBody.size) - + if removeSmall { // Remove small water bodies log.Println("Searching for small water bodies for removal") @@ -370,10 +370,10 @@ func processWater(terrain [][]Terrain, removeSmall bool) { } } } - log.Printf("Identified and removed %d bodies of water smaller than %d tiles", + log.Printf("Identified and removed %d bodies of water smaller than %d tiles", smallLakes, minLakeSize) } - + // Process shorelines and distances shorelineWaters := processShore(terrain) processDistToLand(shorelineWaters, terrain) @@ -386,25 +386,25 @@ func getArea(x, y int, terrain [][]Terrain, visited map[string]bool) []Coord { targetType := terrain[x][y].Type var area []Coord queue := []Coord{{X: x, Y: y}} - + for len(queue) > 0 { coord := queue[0] queue = queue[1:] - + key := fmt.Sprintf("%d,%d", coord.X, coord.Y) if visited[key] { continue } visited[key] = true - + if terrain[coord.X][coord.Y].Type == targetType { area = append(area, coord) - + neighborCoords := getNeighborCoords(coord.X, coord.Y, terrain) queue = append(queue, neighborCoords...) } } - + return area } @@ -412,16 +412,16 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { if !removeSmall { return } - + visited := make(map[string]bool) - + type landBody struct { coords []Coord size int } - + var landBodies []landBody - + // Find all distinct land bodies for x := 0; x < len(terrain); x++ { for y := 0; y < len(terrain[0]); y++ { @@ -430,7 +430,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { if visited[key] { continue } - + coords := getArea(x, y, terrain, visited) landBodies = append(landBodies, landBody{ coords: coords, @@ -439,9 +439,9 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { } } } - + smallIslands := 0 - + for _, body := range landBodies { if body.size < minIslandSize { smallIslands++ @@ -451,8 +451,8 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { } } } - - log.Printf("Identified and removed %d islands smaller than %d tiles", + + log.Printf("Identified and removed %d islands smaller than %d tiles", smallIslands, minIslandSize) } @@ -461,12 +461,12 @@ func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) { height := len(terrain[0]) packedData := make([]byte, width*height) numLandTiles = 0 - + for x := 0; x < width; x++ { for y := 0; y < height; y++ { tile := terrain[x][y] var packedByte byte = 0 - + if tile.Type == Land { packedByte |= 0b10000000 numLandTiles++ @@ -477,46 +477,46 @@ func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) { if tile.Ocean { packedByte |= 0b00100000 } - + if tile.Type == Land { packedByte |= byte(math.Min(math.Ceil(tile.Magnitude), 31)) } else { packedByte |= byte(math.Min(math.Ceil(tile.Magnitude/2), 31)) } - + packedData[y*width+x] = packedByte } } - + logBinaryAsBits(packedData, 8) return packedData, numLandTiles } func createMapThumbnail(terrain [][]Terrain, quality float64) *image.RGBA { log.Println("Creating thumbnail") - + srcWidth := len(terrain) srcHeight := len(terrain[0]) - + targetWidth := int(math.Max(1, math.Floor(float64(srcWidth)*quality))) targetHeight := int(math.Max(1, math.Floor(float64(srcHeight)*quality))) - + img := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight)) - + for x := 0; x < targetWidth; x++ { for y := 0; y < targetHeight; y++ { srcX := int(math.Floor(float64(x) / quality)) srcY := int(math.Floor(float64(y) / quality)) - + srcX = int(math.Min(float64(srcX), float64(srcWidth-1))) srcY = int(math.Min(float64(srcY), float64(srcHeight-1))) - + terrain := terrain[srcX][srcY] rgba := getThumbnailColor(terrain) img.Set(x, y, color.RGBA{R: rgba.R, G: rgba.G, B: rgba.B, A: rgba.A}) } } - + return img } @@ -539,12 +539,12 @@ func getThumbnailColor(t Terrain) RGBA { A: 0, } } - + // Shoreline land if t.Shoreline { return RGBA{R: 204, G: 203, B: 158, A: 255} } - + var adjRGB float64 if t.Magnitude < 10 { // Plains @@ -580,7 +580,7 @@ func logBinaryAsBits(data []byte, length int) { if length > len(data) { length = len(data) } - + var bits string for i := 0; i < length; i++ { bits += fmt.Sprintf("%08b ", data[i]) @@ -593,7 +593,7 @@ func createCombinedBinary(infoBuffer []byte, mapData []byte, miniMapData []byte) infoSize := len(infoBuffer) mapSize := len(mapData) miniMapSize := len(miniMapData) - + // Header structure: // Bytes 0-3: Version (1) // Bytes 4-7: Info section offset (always 28) @@ -602,35 +602,35 @@ func createCombinedBinary(infoBuffer []byte, mapData []byte, miniMapData []byte) // Bytes 16-19: Map section size // Bytes 20-23: MiniMap section offset // Bytes 24-27: MiniMap section size - + headerSize := 28 infoOffset := headerSize mapOffset := infoOffset + infoSize miniMapOffset := mapOffset + mapSize - + totalSize := miniMapOffset + miniMapSize combined := make([]byte, totalSize) - + // Write version writeUint32(combined, 0, 1) - + // Write info section info writeUint32(combined, 4, uint32(infoOffset)) writeUint32(combined, 8, uint32(infoSize)) - + // Write map section info writeUint32(combined, 12, uint32(mapOffset)) writeUint32(combined, 16, uint32(mapSize)) - + // Write miniMap section info writeUint32(combined, 20, uint32(miniMapOffset)) writeUint32(combined, 24, uint32(miniMapSize)) - + // Copy data sections copy(combined[infoOffset:], infoBuffer) copy(combined[mapOffset:], mapData) copy(combined[miniMapOffset:], miniMapData) - + return combined } @@ -649,38 +649,38 @@ func decodeCombinedBinary(data []byte) (*CombinedBinaryHeader, []byte, []byte, [ if len(data) < 28 { return nil, nil, nil, nil, fmt.Errorf("data too short for header") } - + header := &CombinedBinaryHeader{ - Version: readUint32(data, 0), - InfoOffset: readUint32(data, 4), - InfoSize: readUint32(data, 8), - MapOffset: readUint32(data, 12), - MapSize: readUint32(data, 16), + Version: readUint32(data, 0), + InfoOffset: readUint32(data, 4), + InfoSize: readUint32(data, 8), + MapOffset: readUint32(data, 12), + MapSize: readUint32(data, 16), MiniMapOffset: readUint32(data, 20), - MiniMapSize: readUint32(data, 24), + MiniMapSize: readUint32(data, 24), } - + // Validate offsets and sizes if header.InfoOffset+header.InfoSize > uint32(len(data)) || header.MapOffset+header.MapSize > uint32(len(data)) || header.MiniMapOffset+header.MiniMapSize > uint32(len(data)) { return nil, nil, nil, nil, fmt.Errorf("invalid offsets or sizes in header") } - + // Extract sections infoData := data[header.InfoOffset : header.InfoOffset+header.InfoSize] mapData := data[header.MapOffset : header.MapOffset+header.MapSize] miniMapData := data[header.MiniMapOffset : header.MiniMapOffset+header.MiniMapSize] - + return header, infoData, mapData, miniMapData, nil } type CombinedBinaryHeader struct { - Version uint32 - InfoOffset uint32 - InfoSize uint32 - MapOffset uint32 - MapSize uint32 + Version uint32 + InfoOffset uint32 + InfoSize uint32 + MapOffset uint32 + MapSize uint32 MiniMapOffset uint32 - MiniMapSize uint32 -} \ No newline at end of file + MiniMapSize uint32 +} diff --git a/package.json b/package.json index 31fbc9083..9e93c8b16 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "perf": "npx tsx tests/perf/*.ts", "test:coverage": "jest --coverage", "format": "prettier --ignore-unknown --write .", + "format:map-generator": "cd map-generator && go fmt .", "lint": "eslint", "lint:fix": "eslint --fix", "prepare": "husky",