mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:50:43 +00:00
4ae1fa2ebe
## Description: Small-island removal previously ran only on the full-size terrain. The compact (4x downscaled) map inherited that filtering before being downscaled, which meant islands that survived at full size could end up as tiny specks in the compact map. This PR runs `removeSmallIslands` on the 4x terrain as well, with half the threshold (15 vs 30 tiles), so very small islands are pruned in compact maps while the full-size map is unchanged. ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin
819 lines
24 KiB
Go
819 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"math"
|
|
|
|
"github.com/chai2010/webp"
|
|
)
|
|
|
|
const (
|
|
// The smallest a body of land or lake can be, all smaller are removed
|
|
minIslandSize = 30
|
|
minLakeSize = 200
|
|
// the recommended max area pixel size for input images
|
|
minRecommendedPixelSize = 2000000
|
|
maxRecommendedPixelSize = 3000000
|
|
// the recommended max number of land tiles in the output bin at full size
|
|
maxRecommendedLandTileCount = 3000000
|
|
)
|
|
|
|
// Holds raw RGBA image data for the thumbnail
|
|
type ThumbData struct {
|
|
Data []byte
|
|
Width int
|
|
Height int
|
|
}
|
|
|
|
// XY coord, origin top left, x extending right, y extends down
|
|
type Coord struct {
|
|
X, Y int
|
|
}
|
|
|
|
// TerrainType represents the classification of a map tile (e.g., Land or Water).
|
|
type TerrainType int
|
|
|
|
// Enumeration of possible TerrainType values.
|
|
const (
|
|
Land TerrainType = iota
|
|
Water
|
|
)
|
|
|
|
// Terrain represents the properties of a single map tile.
|
|
// Magnitude represents elevation for Land (0-30) or distance to land for Water.
|
|
type Terrain struct {
|
|
Type TerrainType
|
|
Shoreline bool
|
|
Magnitude float64
|
|
Ocean bool
|
|
}
|
|
|
|
// MapResult is the output format from the GenerateMap workflow
|
|
type MapResult struct {
|
|
Thumbnail []byte
|
|
Map MapInfo
|
|
Map4x MapInfo
|
|
Map16x MapInfo
|
|
}
|
|
|
|
// MapInfo contains the serialized map data and metadata for a specific scale.
|
|
type MapInfo struct {
|
|
Data []byte // packed map data
|
|
Width int
|
|
Height int
|
|
NumLandTiles int
|
|
}
|
|
|
|
// GeneratorArgs defines the input parameters for the map generation process.
|
|
type GeneratorArgs struct {
|
|
Name string
|
|
ImageBuffer []byte
|
|
RemoveSmall bool
|
|
}
|
|
|
|
// GenerateMap is the main map-generator workflow.
|
|
// - Maps each pixel to a Terrain type based on its blue value
|
|
// - Removes small islands and lakes
|
|
// - Creates a WebP thumbnail
|
|
// - Packs the map data into binary format for full scale, 1/4 tile count (half dimensions), and 1/16 tile count (quarter dimensions)
|
|
//
|
|
// Red/green pixel values have no impact, only blue values are used
|
|
// For Land tiles, "Magnitude" is determined by `(Blue - 140) / 2“.
|
|
// For Water tiles, "Magnitude" is calculated during generation as the distance to the nearest land.
|
|
//
|
|
// Pixel -> Terrain & Magnitude mapping
|
|
// | Input Condition | Terrain Type | Magnitude | Notes |
|
|
// | :----------------- | :-------------- | :----------------- | :------------------------------- |
|
|
// | **Alpha < 20** | Water | Distance to Land\* | Transparent pixels become water. |
|
|
// | **Blue = 106** | Water | Distance to Land\* | Specific key color for water. |
|
|
// | **Blue < 140** | Land (Plains) | 0 | Clamped to minimum magnitude. |
|
|
// | **Blue 140 - 158** | Land (Plains) | 0 - 9 | |
|
|
// | **Blue 159 - 178** | Land (Highland) | 10 - 19 | |
|
|
// | **Blue 179 - 200** | Land (Mountain) | 20 - 30 | |
|
|
// | **Blue > 200** | Land (Mountain) | 30 | Clamped to maximum magnitude. |
|
|
//
|
|
// Misc Notes
|
|
// - It normalizes map width/height to multiples of 4 for the mini map downscaling.
|
|
func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
|
|
logger := LoggerFromContext(ctx)
|
|
img, err := png.Decode(bytes.NewReader(args.ImageBuffer))
|
|
if err != nil {
|
|
return MapResult{}, fmt.Errorf("failed to decode PNG: %w", err)
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
width, height := bounds.Dx(), bounds.Dy()
|
|
|
|
// Ensure width and height are multiples of 4 for the mini map downscaling
|
|
width = width - (width % 4)
|
|
height = height - (height % 4)
|
|
|
|
logger.Info(fmt.Sprintf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height))
|
|
|
|
area := width * height
|
|
if area < minRecommendedPixelSize || area > maxRecommendedPixelSize {
|
|
logger.Debug(fmt.Sprintf("Map area %d pixels is outside recommended range (%d - %d)", area, minRecommendedPixelSize, maxRecommendedPixelSize), PerformanceLogTag)
|
|
}
|
|
|
|
// Initialize terrain grid
|
|
terrain := make([][]Terrain, width)
|
|
for x := range terrain {
|
|
terrain[x] = make([]Terrain, height)
|
|
}
|
|
|
|
// Process each pixel
|
|
for x := 0; x < width; x++ {
|
|
for y := 0; y < height; y++ {
|
|
_, _, b, a := img.At(x, y).RGBA()
|
|
// Convert from 16-bit to 8-bit values
|
|
alpha := uint8(a >> 8)
|
|
blue := uint8(b >> 8)
|
|
|
|
if alpha < 20 || blue == 106 {
|
|
// Transparent or specific blue value = water
|
|
terrain[x][y] = Terrain{Type: Water}
|
|
} 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
|
|
}
|
|
}
|
|
}
|
|
|
|
removeSmallIslands(ctx, terrain, minIslandSize, args.RemoveSmall)
|
|
processWater(ctx, terrain, args.RemoveSmall)
|
|
|
|
terrain4x := createMiniMap(terrain)
|
|
removeSmallIslands(ctx, terrain4x, minIslandSize/2, args.RemoveSmall)
|
|
processWater(ctx, terrain4x, false)
|
|
|
|
terrain16x := createMiniMap(terrain4x)
|
|
processWater(ctx, terrain16x, false)
|
|
|
|
thumb := createMapThumbnail(ctx, terrain4x, 0.5)
|
|
webp, err := convertToWebP(ThumbData{
|
|
Data: thumb.Pix,
|
|
Width: thumb.Bounds().Dx(),
|
|
Height: thumb.Bounds().Dy(),
|
|
})
|
|
if err != nil {
|
|
return MapResult{}, fmt.Errorf("failed to save thumbnail: %w", err)
|
|
}
|
|
|
|
mapData, mapNumLandTiles := packTerrain(ctx, terrain)
|
|
mapData4x, numLandTiles4x := packTerrain(ctx, terrain4x)
|
|
mapData16x, numLandTiles16x := packTerrain(ctx, terrain16x)
|
|
|
|
logger.Debug(fmt.Sprintf("Land Tile Count (1x): %d", mapNumLandTiles))
|
|
logger.Debug(fmt.Sprintf("Land Tile Count (4x): %d", numLandTiles4x))
|
|
logger.Debug(fmt.Sprintf("Land Tile Count (16x): %d", numLandTiles16x))
|
|
|
|
if mapNumLandTiles == 0 {
|
|
return MapResult{}, fmt.Errorf("Map has 0 land tiles")
|
|
}
|
|
if mapNumLandTiles > maxRecommendedLandTileCount {
|
|
logger.Debug(fmt.Sprintf("Map has more land tiles (%d) than recommended maximum (%d)", mapNumLandTiles, maxRecommendedLandTileCount), PerformanceLogTag)
|
|
}
|
|
|
|
return MapResult{
|
|
Map: MapInfo{
|
|
Data: mapData,
|
|
Width: width,
|
|
Height: height,
|
|
NumLandTiles: mapNumLandTiles,
|
|
},
|
|
Map4x: MapInfo{
|
|
Data: mapData4x,
|
|
Width: width / 2,
|
|
Height: height / 2,
|
|
NumLandTiles: numLandTiles4x,
|
|
},
|
|
Map16x: MapInfo{
|
|
Data: mapData16x,
|
|
Width: width / 4,
|
|
Height: height / 4,
|
|
NumLandTiles: numLandTiles16x,
|
|
},
|
|
Thumbnail: webp,
|
|
}, nil
|
|
}
|
|
|
|
// convertToWebP encodes raw RGBA thumbnail data into WebP format.
|
|
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",
|
|
thumb.Width*thumb.Height*4, len(thumb.Data))
|
|
}
|
|
|
|
copy(img.Pix, thumb.Data)
|
|
|
|
// Encode as WebP with quality 45 (equivalent to the JavaScript version)
|
|
webpData, err := webp.EncodeRGBA(img, 45)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode WebP: %w", err)
|
|
}
|
|
|
|
return webpData, nil
|
|
}
|
|
|
|
// createMiniMap downscales the terrain grid by half.
|
|
// It maps 2x2 blocks of input tiles to a single output tile.
|
|
// The logic prioritizes Water: if any of the 4 source tiles is Water,
|
|
// the resulting mini-map tile becomes Water.
|
|
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)
|
|
}
|
|
|
|
for x := 0; x < width; x++ {
|
|
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 {
|
|
miniMap[miniX][miniY] = tm[x][y]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return miniMap
|
|
}
|
|
|
|
// processShore identifies shoreline tiles by checking adjacency.
|
|
// It marks Land tiles as shoreline if they neighbor Water, and Water tiles as
|
|
// shoreline if they neighbor Land.
|
|
// Returns a list of coordinates for all shoreline Water tiles found.
|
|
func processShore(ctx context.Context, terrain [][]Terrain) []Coord {
|
|
logger := LoggerFromContext(ctx)
|
|
logger.Info("Identifying shorelines")
|
|
var shorelineWaters []Coord
|
|
width := len(terrain)
|
|
height := len(terrain[0])
|
|
|
|
for x := 0; x < width; x++ {
|
|
for y := 0; y < height; y++ {
|
|
tile := &terrain[x][y]
|
|
neighbors := getNeighbors(x, y, terrain)
|
|
tile.Shoreline = false
|
|
|
|
if tile.Type == Land {
|
|
// Land tile adjacent to water is shoreline
|
|
for _, n := range neighbors {
|
|
if n.Type == Water {
|
|
tile.Shoreline = true
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
// Water tile adjacent to land is shoreline
|
|
for _, n := range neighbors {
|
|
if n.Type == Land {
|
|
tile.Shoreline = true
|
|
shorelineWaters = append(shorelineWaters, Coord{X: x, Y: y})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return shorelineWaters
|
|
}
|
|
|
|
// processDistToLand calculates the distance of water tiles from the nearest land.
|
|
// It uses a Breadth-First Search (BFS) starting from the shoreline water tiles.
|
|
// The distance is stored in the Magnitude field of the Water tiles.
|
|
func processDistToLand(ctx context.Context, shorelineWaters []Coord, terrain [][]Terrain) {
|
|
logger := LoggerFromContext(ctx)
|
|
logger.Info("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})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// getNeighbors returns a list of Terrain tiles adjacent to the specified coordinates.
|
|
func getNeighbors(x, y int, terrain [][]Terrain) []Terrain {
|
|
coords := getNeighborCoords(x, y, terrain)
|
|
neighbors := make([]Terrain, len(coords))
|
|
for i, coord := range coords {
|
|
neighbors[i] = terrain[coord.X][coord.Y]
|
|
}
|
|
return neighbors
|
|
}
|
|
|
|
// getNeighborCoords returns a list of valid adjacent coordinates (up, down, left, right).
|
|
// It ensures that the returned coordinates are within the bounds of the terrain grid.
|
|
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})
|
|
}
|
|
if x < width-1 {
|
|
coords = append(coords, Coord{X: x + 1, Y: y})
|
|
}
|
|
if y > 0 {
|
|
coords = append(coords, Coord{X: x, Y: y - 1})
|
|
}
|
|
if y < height-1 {
|
|
coords = append(coords, Coord{X: x, Y: y + 1})
|
|
}
|
|
|
|
return coords
|
|
}
|
|
|
|
// processWater identifies and processes bodies of water in the terrain.
|
|
// It finds all connected water bodies and marks the largest one as Ocean.
|
|
// If removeSmall is true, lakes smaller than minLakeSize are converted to Land.
|
|
// Finally, it triggers shoreline identification and distance-to-land calculations.
|
|
func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) {
|
|
logger := LoggerFromContext(ctx)
|
|
logger.Info("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++ {
|
|
if terrain[x][y].Type == Water {
|
|
key := fmt.Sprintf("%d,%d", x, y)
|
|
if visited[key] {
|
|
continue
|
|
}
|
|
|
|
coords := getArea(x, y, terrain, visited)
|
|
waterBodies = append(waterBodies, waterBody{
|
|
coords: coords,
|
|
size: len(coords),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by size (largest first)
|
|
for i := 0; i < len(waterBodies)-1; i++ {
|
|
for j := i + 1; j < len(waterBodies); j++ {
|
|
if waterBodies[j].size > waterBodies[i].size {
|
|
waterBodies[i], waterBodies[j] = waterBodies[j], waterBodies[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
smallLakes := 0
|
|
|
|
if len(waterBodies) > 0 {
|
|
// Mark largest water body as ocean
|
|
largestWaterBody := waterBodies[0]
|
|
for _, coord := range largestWaterBody.coords {
|
|
terrain[coord.X][coord.Y].Ocean = true
|
|
}
|
|
logger.Info(fmt.Sprintf("Identified ocean with %d water tiles", largestWaterBody.size))
|
|
|
|
if removeSmall {
|
|
// Remove small water bodies
|
|
logger.Info("Searching for small water bodies for removal")
|
|
for w := 1; w < len(waterBodies); w++ {
|
|
if waterBodies[w].size < minLakeSize {
|
|
logger.Debug(fmt.Sprintf("Removing small lake at %d,%d (size %d)", waterBodies[w].coords[0].X, waterBodies[w].coords[0].Y, waterBodies[w].size), RemovalLogTag)
|
|
smallLakes++
|
|
for _, coord := range waterBodies[w].coords {
|
|
terrain[coord.X][coord.Y].Type = Land
|
|
terrain[coord.X][coord.Y].Magnitude = 0
|
|
}
|
|
}
|
|
}
|
|
logger.Info(fmt.Sprintf("Identified and removed %d bodies of water smaller than %d tiles", smallLakes, minLakeSize))
|
|
}
|
|
|
|
// Process shorelines and distances
|
|
shorelineWaters := processShore(ctx, terrain)
|
|
processDistToLand(ctx, shorelineWaters, terrain)
|
|
} else {
|
|
logger.Info("No water bodies found in the map")
|
|
}
|
|
}
|
|
|
|
// getArea performs a Breadth-First Search (BFS) to find a contiguous area of tiles
|
|
// sharing the same TerrainType as the passed x,y coordinates.
|
|
// The visited map is updated to prevent reprocessing tiles.
|
|
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
|
|
}
|
|
|
|
// removeSmallIslands identifies and removes small land masses from the terrain.
|
|
// If removeSmall is true, any removed bodies are converted to Water.
|
|
// Land bodies smaller than minSize are removed.
|
|
func removeSmallIslands(ctx context.Context, terrain [][]Terrain, minSize int, removeSmall bool) {
|
|
logger := LoggerFromContext(ctx)
|
|
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++ {
|
|
if terrain[x][y].Type == Land {
|
|
key := fmt.Sprintf("%d,%d", x, y)
|
|
if visited[key] {
|
|
continue
|
|
}
|
|
|
|
coords := getArea(x, y, terrain, visited)
|
|
landBodies = append(landBodies, landBody{
|
|
coords: coords,
|
|
size: len(coords),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
smallIslands := 0
|
|
|
|
for _, body := range landBodies {
|
|
if body.size < minSize {
|
|
logger.Debug(fmt.Sprintf("Removing small island at %d,%d (size %d)", body.coords[0].X, body.coords[0].Y, body.size), RemovalLogTag)
|
|
smallIslands++
|
|
for _, coord := range body.coords {
|
|
terrain[coord.X][coord.Y].Type = Water
|
|
terrain[coord.X][coord.Y].Magnitude = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Info(fmt.Sprintf("Identified and removed %d islands smaller than %d tiles", smallIslands, minSize))
|
|
}
|
|
|
|
// packTerrain serializes the terrain grid into a byte slice.
|
|
// Each byte represents a single tile with bit flags:
|
|
// - Bit 7: Land (1) / Water (0)
|
|
// - Bit 6: Shoreline
|
|
// - Bit 5: Ocean
|
|
// - Bits 0-4: Magnitude (0-31). For Water, this is (Distance / 2).
|
|
//
|
|
// Returns the packed data and the count of land tiles.
|
|
func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLandTiles int) {
|
|
width := len(terrain)
|
|
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++
|
|
}
|
|
if tile.Shoreline {
|
|
packedByte |= 0b01000000
|
|
}
|
|
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(ctx, packedData, 8)
|
|
return packedData, numLandTiles
|
|
}
|
|
|
|
// createMapThumbnail generates an RGBA image representation of the terrain.
|
|
// It scales the map dimensions based on the provided quality factor.
|
|
// Each pixel's color is determined by the terrain type and magnitude via getThumbnailColor.
|
|
func createMapThumbnail(ctx context.Context, terrain [][]Terrain, quality float64) *image.RGBA {
|
|
logger := LoggerFromContext(ctx)
|
|
logger.Info("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
|
|
}
|
|
|
|
// RGBA represents a color with Red, Green, Blue, and Alpha channels.
|
|
// It is used locally for thumbnail generation.
|
|
type RGBA struct {
|
|
R, G, B, A uint8
|
|
}
|
|
|
|
// getThumbnailColor determines the RGBA color for a specific terrain tile for
|
|
// the map preview thumbnail.
|
|
//
|
|
// It handles color generation for Water (shoreline vs deep water) and Land
|
|
// (shoreline, plains, highlands, mountains) based on the tile's magnitude.
|
|
//
|
|
// The thumbnail renders its own set of colors separate from the in-game light/dark
|
|
// color schemes.
|
|
//
|
|
// For thumbnail purposes, the terrain type -> color mapping:
|
|
// - Water Shoreline: (Transparent)
|
|
// - Deep Water: (Transparent)
|
|
// - Land Shoreline: `rgb(204, 203, 158)`
|
|
// - Plains (Mag < 10): `rgb(190, 220, 138)` - `rgb(190, 202, 138)`
|
|
// - Highlands (Mag 10-19): `rgb(220, 203, 158)` - `rgb(238, 221, 176)`
|
|
// - Mountains (Mag >= 20): `rgb(240, 240, 240)` - `rgb(245, 245, 245)`
|
|
func getThumbnailColor(t Terrain) RGBA {
|
|
if t.Type == Water {
|
|
// Shoreline water
|
|
if t.Shoreline {
|
|
return RGBA{R: 100, G: 143, B: 255, A: 0}
|
|
}
|
|
// Other water: adjust based on magnitude
|
|
waterAdjRGB := 11 - math.Min(t.Magnitude/2, 10) - 10
|
|
return RGBA{
|
|
R: uint8(math.Max(70+waterAdjRGB, 0)),
|
|
G: uint8(math.Max(132+waterAdjRGB, 0)),
|
|
B: uint8(math.Max(180+waterAdjRGB, 0)),
|
|
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
|
|
adjRGB = 220 - 2*t.Magnitude
|
|
return RGBA{
|
|
R: 190,
|
|
G: uint8(adjRGB),
|
|
B: 138,
|
|
A: 255,
|
|
}
|
|
} else if t.Magnitude < 20 {
|
|
// Highlands
|
|
adjRGB = 2 * t.Magnitude
|
|
return RGBA{
|
|
R: uint8(200 + adjRGB),
|
|
G: uint8(183 + adjRGB),
|
|
B: uint8(138 + adjRGB),
|
|
A: 255,
|
|
}
|
|
} else {
|
|
// Mountains
|
|
adjRGB = math.Floor(230 + t.Magnitude/2)
|
|
return RGBA{
|
|
R: uint8(adjRGB),
|
|
G: uint8(adjRGB),
|
|
B: uint8(adjRGB),
|
|
A: 255,
|
|
}
|
|
}
|
|
}
|
|
|
|
// logBinaryAsBits logs the binary representation of the first 'length' bytes of data.
|
|
// It is a helper function for debugging packed terrain data.
|
|
func logBinaryAsBits(ctx context.Context, data []byte, length int) {
|
|
logger := LoggerFromContext(ctx)
|
|
if length > len(data) {
|
|
length = len(data)
|
|
}
|
|
|
|
var bits string
|
|
for i := 0; i < length; i++ {
|
|
bits += fmt.Sprintf("%08b ", data[i])
|
|
}
|
|
logger.Info(fmt.Sprintf("Binary data (bits): %s", bits))
|
|
}
|
|
|
|
// createCombinedBinary combines the info JSON, map data, and mini-map data into a single binary buffer.
|
|
//
|
|
// Note: This function is currently unused by the main workflow, which writes separate files instead.
|
|
// It creates a header with the following structure:
|
|
// - Bytes 0-3: Version (1)
|
|
// - Bytes 4-7: Info section offset
|
|
// - Bytes 8-11: Info section size
|
|
// - Bytes 12-15: Map section offset
|
|
// - Bytes 16-19: Map section size
|
|
// - Bytes 20-23: MiniMap section offset
|
|
// - Bytes 24-27: MiniMap section size
|
|
func createCombinedBinary(infoBuffer []byte, mapData []byte, miniMapData []byte) []byte {
|
|
// Calculate section sizes
|
|
infoSize := len(infoBuffer)
|
|
mapSize := len(mapData)
|
|
miniMapSize := len(miniMapData)
|
|
|
|
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
|
|
}
|
|
|
|
// writeUint32 writes a 32-bit unsigned integer to the byte slice at the specified offset.
|
|
// It uses Little Endian byte order.
|
|
// Note: This function is currently unused.
|
|
func writeUint32(data []byte, offset int, value uint32) {
|
|
data[offset] = byte(value & 0xff)
|
|
data[offset+1] = byte((value >> 8) & 0xff)
|
|
data[offset+2] = byte((value >> 16) & 0xff)
|
|
data[offset+3] = byte((value >> 24) & 0xff)
|
|
}
|
|
|
|
// readUint32 reads a 32-bit unsigned integer from the byte slice at the specified offset.
|
|
// It assumes Little Endian byte order.
|
|
// Note: This function is currently unused.
|
|
func readUint32(data []byte, offset int) uint32 {
|
|
return uint32(data[offset]) | uint32(data[offset+1])<<8 | uint32(data[offset+2])<<16 | uint32(data[offset+3])<<24
|
|
}
|
|
|
|
// decodeCombinedBinary parses a combined binary buffer into its constituent parts.
|
|
// It validates the header and extracts the Info JSON, Map data, and MiniMap data sections.
|
|
// Note: This function is currently unused.
|
|
func decodeCombinedBinary(data []byte) (*CombinedBinaryHeader, []byte, []byte, []byte, error) {
|
|
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),
|
|
MiniMapOffset: readUint32(data, 20),
|
|
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
|
|
}
|
|
|
|
// CombinedBinaryHeader represents the metadata header of the combined map file format.
|
|
// Note: This struct is currently unused.
|
|
type CombinedBinaryHeader struct {
|
|
Version uint32
|
|
InfoOffset uint32
|
|
InfoSize uint32
|
|
MapOffset uint32
|
|
MapSize uint32
|
|
MiniMapOffset uint32
|
|
MiniMapSize uint32
|
|
}
|