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:
FloPinguin
2026-06-19 14:54:09 -07:00
committed by GitHub
parent 6e892839e8
commit 805f0968b1
27 changed files with 2530 additions and 50 deletions
+99 -24
View File
@@ -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 {