mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +00:00
Add impassable terrain 🗺️ (#4340)
## Description: Relates to #3725 Adds a new **Impassable** terrain type that enables non-rectangular maps and creates impassable barriers on the map. Painted with pure black (`#000`) in the map editor's `image.png`. **Encoding:** Impassable terrain is encoded in the binary format as `isLand=1, magnitude=31` (previously unused). The Go map generator detects `#000` pixels and produces this encoding. The map generator's minimap downscaling gives impassable highest priority (Impassable > Water > Land). Thumbnails render impassable as transparent so the map picker background shows through. **Rendering:** Impassable tiles render as the map background colour (`rgb(60, 60, 60)`, matching `gl.clearColor` in `Renderer.ts`), making them visually indistinguishable from the area outside the map quad. This enables maps to appear non-rectangular. **Gameplay restrictions:** Impassable terrain cannot be: - Owned (`conquer()` throws) - Attacked (`AttackExecution` skips impassable tiles in both `tick()` and `addNeighbors()`) - Nuked (targeting rejected in `nukeSpawn()`, blast radius filtered in `tilesToDestroy()`) - Spawned on (nations, human players, and structures all reject impassable tiles) - Converted to water (guarded in `WaterManager` and `setWater()`) **Nuke trajectories:** Nuke trajectories cannot cross impassable terrain, matching the existing map-border enforcement. This is checked at launch time in `NukeExecution.tick()`. The client-side trajectory preview turns red with a red X where the arc crosses impassable terrain (reusing the existing SAM-intercept visual pipeline in `NukeTrajectory.ts`). The nuke ghost preview is completely hidden when hovering over impassable terrain (same as hovering outside the map). https://github.com/user-attachments/assets/ff131146-9749-41e0-892a-617e5cd16c54 Impassable terrain is transparent on the thumbnail: <img width="213" height="152" alt="Screenshot 2026-06-18 211640" src="https://github.com/user-attachments/assets/ede16f8c-9239-4ab1-be5d-0ba81cce5e9e" /> Tested with water nukes, made sure there is no water depth gradient near the impassable terrain, just like at the world border: <img width="774" height="771" alt="Screenshot 2026-06-18 212348" src="https://github.com/user-attachments/assets/4429069d-911b-48e8-91e3-7307d42c9397" /> Models used: GLM 5.2 and MiMo 2.5 Pro 😄 ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin
This commit is contained in:
@@ -42,6 +42,7 @@ type TerrainType uint8
|
||||
const (
|
||||
Land TerrainType = iota
|
||||
Water
|
||||
Impassable
|
||||
)
|
||||
|
||||
// Terrain represents the properties of a single map tile.
|
||||
@@ -90,15 +91,20 @@ type GeneratorArgs struct {
|
||||
// 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. |
|
||||
// | 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. |
|
||||
// | **#000 (black)** | Impassable | 31 (fixed) | Solid void; cannot be owned/attacked/nuked. |
|
||||
// | **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. |
|
||||
//
|
||||
// Impassable terrain is encoded in the binary format as isLand=1 + magnitude=31.
|
||||
// It renders as the map background colour (making the map appear non-rectangular)
|
||||
// and cannot be owned, attacked, or nuked. Nuke trajectories cannot cross it.
|
||||
//
|
||||
// Misc Notes
|
||||
// - It normalizes map width/height to multiples of 4 for the mini map downscaling.
|
||||
@@ -132,14 +138,19 @@ func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
|
||||
// Process each pixel
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
_, _, b, a := img.At(x, y).RGBA()
|
||||
r, g, b, a := img.At(x, y).RGBA()
|
||||
// Convert from 16-bit to 8-bit values
|
||||
alpha := uint8(a >> 8)
|
||||
red := uint8(r >> 8)
|
||||
green := uint8(g >> 8)
|
||||
blue := uint8(b >> 8)
|
||||
alpha := uint8(a >> 8)
|
||||
|
||||
if alpha < 20 || blue == 106 {
|
||||
// Transparent or specific blue value = water
|
||||
terrain[x][y] = Terrain{Type: Water}
|
||||
} else if red == 0 && green == 0 && blue == 0 {
|
||||
// Pure black (#000) = impassable terrain
|
||||
terrain[x][y] = Terrain{Type: Impassable}
|
||||
} else {
|
||||
// Land
|
||||
terrain[x][y] = Terrain{Type: Land}
|
||||
@@ -156,13 +167,19 @@ func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
|
||||
|
||||
removeSmallIslands(ctx, terrain, minIslandSize, args.RemoveSmall)
|
||||
processWater(ctx, terrain, args.RemoveSmall)
|
||||
// Water adjacent to impassable terrain should be deep (no depth gradient),
|
||||
// just like water at the map edge. Override the BFS-calculated magnitude
|
||||
// so these tiles render as the deepest shade.
|
||||
setImpassableNeighborWaterDepth(ctx, terrain)
|
||||
|
||||
terrain4x := createMiniMap(terrain)
|
||||
removeSmallIslands(ctx, terrain4x, minIslandSize/2, args.RemoveSmall)
|
||||
processWater(ctx, terrain4x, false)
|
||||
setImpassableNeighborWaterDepth(ctx, terrain4x)
|
||||
|
||||
terrain16x := createMiniMap(terrain4x)
|
||||
processWater(ctx, terrain16x, false)
|
||||
setImpassableNeighborWaterDepth(ctx, terrain16x)
|
||||
|
||||
thumb := createMapThumbnail(ctx, terrain4x, 0.5)
|
||||
webp, err := convertToWebP(ThumbData{
|
||||
@@ -239,8 +256,9 @@ func convertToWebP(thumb ThumbData) ([]byte, error) {
|
||||
|
||||
// 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.
|
||||
// Priority: Impassable > Water > Land. If any of the 4 source tiles is
|
||||
// Impassable, the result is Impassable; else if any is Water, the result is
|
||||
// Water; otherwise the last Land tile wins.
|
||||
func createMiniMap(tm [][]Terrain) [][]Terrain {
|
||||
width := len(tm)
|
||||
height := len(tm[0])
|
||||
@@ -258,12 +276,24 @@ func createMiniMap(tm [][]Terrain) [][]Terrain {
|
||||
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]
|
||||
}
|
||||
if miniX >= miniWidth || miniY >= miniHeight {
|
||||
continue
|
||||
}
|
||||
src := tm[x][y]
|
||||
dst := &miniMap[miniX][miniY]
|
||||
// Impassable wins over everything; once set, keep it.
|
||||
if dst.Type == Impassable {
|
||||
continue
|
||||
}
|
||||
if src.Type == Impassable {
|
||||
*dst = src
|
||||
continue
|
||||
}
|
||||
// Water wins over land; once set to water, keep it.
|
||||
if dst.Type == Water {
|
||||
continue
|
||||
}
|
||||
*dst = src
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,16 +326,17 @@ func processShore(ctx context.Context, terrain [][]Terrain) []Coord {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if tile.Type == Water {
|
||||
// Water tile adjacent to land is shoreline
|
||||
for _, c := range buf[:n] {
|
||||
if terrain[c.X][c.Y].Type == Land {
|
||||
tile.Shoreline = true
|
||||
shorelineWaters = append(shorelineWaters, Coord{X: x, Y: y})
|
||||
break
|
||||
for _, c := range buf[:n] {
|
||||
if terrain[c.X][c.Y].Type == Land {
|
||||
tile.Shoreline = true
|
||||
shorelineWaters = append(shorelineWaters, Coord{X: x, Y: y})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Impassable tiles: never shoreline (renders as background, no outline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +392,33 @@ func processDistToLand(ctx context.Context, shorelineWaters []Coord, terrain [][
|
||||
}
|
||||
}
|
||||
|
||||
// setImpassableNeighborWaterDepth forces water tiles adjacent to impassable
|
||||
// terrain to deep-water magnitude. Without this, the processDistToLand BFS
|
||||
// assigns them a shallow magnitude (close to "land"), producing a visible
|
||||
// depth gradient next to impassable terrain. Impassable terrain is void —
|
||||
// like the map edge — so the water beside it should be uniformly deep.
|
||||
func setImpassableNeighborWaterDepth(ctx context.Context, terrain [][]Terrain) {
|
||||
width := len(terrain)
|
||||
height := len(terrain[0])
|
||||
const deepMagnitude = 20 // packed as 10 (÷2), matches max render depth
|
||||
|
||||
var buf [4]Coord
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
if terrain[x][y].Type != Water {
|
||||
continue
|
||||
}
|
||||
n := neighborCoords(x, y, width, height, &buf)
|
||||
for _, c := range buf[:n] {
|
||||
if terrain[c.X][c.Y].Type == Impassable {
|
||||
terrain[x][y].Magnitude = deepMagnitude
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// neighborCoords fills out with the valid orthogonal neighbours of (x, y) and
|
||||
// returns the count. out must be a caller-allocated [4]Coord buffer; by
|
||||
// reusing the same buffer across calls the caller avoids any heap allocation.
|
||||
@@ -567,6 +625,9 @@ func removeSmallIslands(ctx context.Context, terrain [][]Terrain, minSize int, r
|
||||
// - Bit 5: Ocean
|
||||
// - Bits 0-4: Magnitude (0-31). For Water, this is (Distance / 2).
|
||||
//
|
||||
// Impassable tiles are encoded as 0b10011111 (isLand=1, magnitude=31) and are
|
||||
// NOT counted in numLandTiles (they cannot be owned/attacked/nuked).
|
||||
//
|
||||
// Returns the packed data and the count of land tiles.
|
||||
func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLandTiles int) {
|
||||
width := len(terrain)
|
||||
@@ -577,6 +638,14 @@ func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLand
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
tile := terrain[x][y]
|
||||
|
||||
if tile.Type == Impassable {
|
||||
// Impassable: isLand=1, magnitude=31, no shoreline, no ocean.
|
||||
// Not counted as a land tile (can't be owned/attacked/nuked).
|
||||
packedData[y*width+x] = 0b10011111
|
||||
continue
|
||||
}
|
||||
|
||||
var packedByte byte = 0
|
||||
|
||||
if tile.Type == Land {
|
||||
@@ -652,6 +721,9 @@ type RGBA struct {
|
||||
// color schemes.
|
||||
//
|
||||
// For thumbnail purposes, the terrain type -> color mapping:
|
||||
// - Impassable: (Transparent) — renders as the map background in-game, so
|
||||
// the thumbnail matches by being transparent (the map picker background
|
||||
// shows through).
|
||||
// - Water Shoreline: (Transparent)
|
||||
// - Deep Water: (Transparent)
|
||||
// - Land Shoreline: `rgb(204, 203, 158)`
|
||||
@@ -659,6 +731,9 @@ type RGBA struct {
|
||||
// - 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 == Impassable {
|
||||
return RGBA{R: 0, G: 0, B: 0, A: 0}
|
||||
}
|
||||
if t.Type == Water {
|
||||
// Shoreline water
|
||||
if t.Shoreline {
|
||||
|
||||
Reference in New Issue
Block a user