A comprehensive technical guide for implementing 3D hexagonal maps in Godot 4, based on the Nyika project. This covers coordinate systems, terrain rendering, entity placement, camera controls, and integrating external 3D assets.
- Hex Coordinate System
- Terrain Rendering
- Heightmap Loading
- Entity Placement
- Camera System
- Mini-Map
- Rivers and Water
- Territory Borders
- External Asset Integration (Meshy AI)
- Shaders
- Common Pitfalls
We use pointy-top hexagons with odd-row offset coordinates (axial system).
Row 0: ⬡ ⬡ ⬡ ⬡ ← Even row: no offset
Row 1: ⬡ ⬡ ⬡ ⬡ ← Odd row: offset right by half hex width
Row 2: ⬡ ⬡ ⬡ ⬡
# HexUtils.gd
const OUTER_RADIUS: float = 1.0 # Center to corner (vertex)
const INNER_RADIUS: float = 0.866025404 # sqrt(3)/2 - center to edge midpoint
const X_OFFSET: float = 1.732050808 # sqrt(3) - horizontal spacing between centers
const Z_OFFSET: float = 1.5 # Vertical spacing between row centersstatic func grid_to_world(grid_pos: Vector2i) -> Vector3:
var x: float
var z: float = grid_pos.y * Z_OFFSET
# Odd rows are offset by half a hex width
if grid_pos.y % 2 == 1:
x = (grid_pos.x + 0.5) * X_OFFSET
else:
x = grid_pos.x * X_OFFSET
return Vector3(x, 0.0, z)This is trickier - use cube coordinate rounding for accuracy:
static func world_to_grid(world_pos: Vector3) -> Vector2i:
# Convert to fractional axial coordinates
var q: float = (sqrt(3.0) / 3.0 * world_pos.x - 1.0 / 3.0 * world_pos.z) / OUTER_RADIUS
var r: float = (2.0 / 3.0 * world_pos.z) / OUTER_RADIUS
# Convert to cube coordinates and round
var cube = _axial_to_cube(Vector2(q, r))
var rounded = _cube_round(cube)
var axial = _cube_to_axial(rounded)
return Vector2i(int(axial.x), int(axial.y))
static func _axial_to_cube(axial: Vector2) -> Vector3:
var x = axial.x
var z = axial.y
var y = -x - z
return Vector3(x, y, z)
static func _cube_round(cube: Vector3) -> Vector3:
var rx = round(cube.x)
var ry = round(cube.y)
var rz = round(cube.z)
var x_diff = abs(rx - cube.x)
var y_diff = abs(ry - cube.y)
var z_diff = abs(rz - cube.z)
if x_diff > y_diff and x_diff > z_diff:
rx = -ry - rz
elif y_diff > z_diff:
ry = -rx - rz
else:
rz = -rx - ry
return Vector3(rx, ry, rz)
static func _cube_to_axial(cube: Vector3) -> Vector2:
return Vector2(cube.x, cube.z)Critical: Odd and even rows have different neighbor offsets!
# Neighbor offsets for even rows (y % 2 == 0)
const EVEN_ROW_OFFSETS: Array[Vector2i] = [
Vector2i(1, 0), # East
Vector2i(0, -1), # Southeast
Vector2i(-1, -1), # Southwest
Vector2i(-1, 0), # West
Vector2i(-1, 1), # Northwest
Vector2i(0, 1), # Northeast
]
# Neighbor offsets for odd rows (y % 2 == 1)
const ODD_ROW_OFFSETS: Array[Vector2i] = [
Vector2i(1, 0), # East
Vector2i(1, -1), # Southeast
Vector2i(0, -1), # Southwest
Vector2i(-1, 0), # West
Vector2i(0, 1), # Northwest
Vector2i(1, 1), # Northeast
]
static func get_neighbors(pos: Vector2i) -> Array[Vector2i]:
var offsets = ODD_ROW_OFFSETS if pos.y % 2 == 1 else EVEN_ROW_OFFSETS
var neighbors: Array[Vector2i] = []
for offset in offsets:
neighbors.append(pos + offset)
return neighborsConvert to cube coordinates for accurate distance:
static func hex_distance(a: Vector2i, b: Vector2i) -> int:
var ac = _offset_to_cube(a)
var bc = _offset_to_cube(b)
return int((abs(ac.x - bc.x) + abs(ac.y - bc.y) + abs(ac.z - bc.z)) / 2)
static func _offset_to_cube(pos: Vector2i) -> Vector3i:
var x = pos.x - (pos.y - (pos.y & 1)) / 2
var z = pos.y
var y = -x - z
return Vector3i(x, y, z)Instead of per-tile noise, use discrete elevation tiers for a clean "board game" aesthetic:
# GameConstants.gd
const TERRAIN_TIER_HEIGHT: float = 0.25
const TERRAIN_BASE_THICKNESS: float = 0.15
const TERRAIN_ELEVATION_TIERS: Dictionary = {
"terrain_water_deep": 0,
"terrain_water_shallow": 1,
"terrain_coastal": 2,
"terrain_wetland": 2,
"terrain_plains": 3,
"terrain_forest": 3,
"terrain_desert": 3,
"terrain_urban": 3,
"terrain_hills": 4,
"terrain_mountains": 6,
}
static func get_terrain_elevation_at(grid_pos: Vector2i) -> float:
var terrain_type = WorldState.get_terrain_at(grid_pos)
var tier = TERRAIN_ELEVATION_TIERS.get(terrain_type, 3)
return TERRAIN_BASE_THICKNESS + (tier * TERRAIN_TIER_HEIGHT)Generate hexagonal prisms with two surfaces (top for terrain texture, sides for cliffs):
static func create_hex_prism_mesh(height: float, radius: float = OUTER_RADIUS) -> ArrayMesh:
var st = SurfaceTool.new()
var mesh = ArrayMesh.new()
# Calculate hex corners
var corners: Array[Vector3] = []
for i in range(6):
var angle = PI / 3.0 * i - PI / 6.0 # Start at top-right for pointy-top
corners.append(Vector3(
cos(angle) * radius,
0.0,
sin(angle) * radius
))
# SURFACE 0: Top face (terrain texture)
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var top_center = Vector3(0, height, 0)
for i in range(6):
var next = (i + 1) % 6
st.set_normal(Vector3.UP)
st.set_uv(_calculate_hex_uv(top_center))
st.add_vertex(top_center)
st.set_uv(_calculate_hex_uv(corners[i] + Vector3(0, height, 0)))
st.add_vertex(corners[i] + Vector3(0, height, 0))
st.set_uv(_calculate_hex_uv(corners[next] + Vector3(0, height, 0)))
st.add_vertex(corners[next] + Vector3(0, height, 0))
st.commit(mesh)
# SURFACE 1: Side walls (cliff texture)
st.begin(Mesh.PRIMITIVE_TRIANGLES)
for i in range(6):
var next = (i + 1) % 6
var top1 = corners[i] + Vector3(0, height, 0)
var top2 = corners[next] + Vector3(0, height, 0)
var bot1 = corners[i]
var bot2 = corners[next]
# Calculate outward normal
var edge = top2 - top1
var normal = edge.cross(Vector3.UP).normalized()
st.set_normal(normal)
# Triangle 1
st.add_vertex(top1)
st.add_vertex(bot1)
st.add_vertex(top2)
# Triangle 2
st.add_vertex(top2)
st.add_vertex(bot1)
st.add_vertex(bot2)
st.commit(mesh)
return meshPre-generate one mesh per elevation tier, then reuse via MultiMeshInstance3D or individual instances:
var _prism_cache: Dictionary = {} # tier -> ArrayMesh
func _generate_prism_cache() -> void:
for tier in range(7):
var height = TERRAIN_BASE_THICKNESS + (tier * TERRAIN_TIER_HEIGHT)
_prism_cache[tier] = HexUtils.create_hex_prism_mesh(height)
func _get_mesh_for_terrain(terrain_type: String) -> ArrayMesh:
var tier = TERRAIN_ELEVATION_TIERS.get(terrain_type, 3)
return _prism_cache[tier]Apply different materials to top surface vs side walls:
func _create_hex_instance(grid_pos: Vector2i, terrain_type: String) -> MeshInstance3D:
var mesh_instance = MeshInstance3D.new()
mesh_instance.mesh = _get_mesh_for_terrain(terrain_type)
mesh_instance.position = HexUtils.grid_to_world(grid_pos)
# Surface 0: Top - terrain material (grass, sand, etc.)
mesh_instance.set_surface_override_material(0, _get_terrain_material(terrain_type))
# Surface 1: Sides - cliff/dirt material
mesh_instance.set_surface_override_material(1, _cliff_material)
return mesh_instanceMaps are automatically discovered from assets/maps/ by scanning for image files:
# HeightmapLoader.gd
const MAPS_DIRECTORY: String = "res://assets/maps/"
const SUPPORTED_EXTENSIONS: Array[String] = [".jpg", ".jpeg", ".png"]
static func get_available_maps() -> Array[MapInfo]:
var maps: Array[MapInfo] = []
var dir = DirAccess.open(MAPS_DIRECTORY)
dir.list_dir_begin()
var filename = dir.get_next()
while filename != "":
if not dir.current_is_dir():
var ext = filename.get_extension().to_lower()
if ("." + ext) in SUPPORTED_EXTENSIONS:
maps.append(_create_map_info_from_file(filename))
filename = dir.get_next()
return mapsEach heightmap can have an optional .txt sidecar file with the same base name:
assets/maps/
├── nyika.jpg # Heightmap image
├── nyika.txt # Optional description: "The continent of Nyika..."
├── river_canyons.jpg
└── river_canyons.txt
The heightmap loader converts grayscale values to terrain types:
# Tuned for heightmaps where rivers/water are dark gray (not pure black)
const WATER_DEEP_THRESHOLD: int = 50 # 0-50 = deep water
const WATER_SHALLOW_THRESHOLD: int = 85 # 51-85 = shallow water (rivers, lakes)
const PLAINS_THRESHOLD: int = 120 # 86-120 = lowlands
const HILLS_THRESHOLD: int = 170 # 121-170 = hills
const MOUNTAIN_THRESHOLD: int = 220 # 171-220 = mountains
# 221-255 = mountain peaksImportant: These thresholds are tuned for heightmaps where water features are drawn in dark gray (~60-80) rather than pure black. If your heightmap uses pure black for water, you may need lower thresholds.
The game uses a square grid (default 200×200) to match most heightmap images. Rectangular heightmaps will be stretched to fit.
All entities must be placed at the correct terrain elevation:
func place_entity_on_hex(entity: Node3D, grid_pos: Vector2i) -> void:
var world_pos = HexUtils.grid_to_world(grid_pos)
var terrain_height = GameConstants.get_terrain_elevation_at(grid_pos)
entity.position = Vector3(world_pos.x, terrain_height, world_pos.z)Many 3D models (especially from Meshy AI) have their origin at the center rather than the bottom. Calculate and apply Y-offset:
var _y_offset_cache: Dictionary = {}
func get_y_offset(model_key: String, instance: Node3D) -> float:
if _y_offset_cache.has(model_key):
return _y_offset_cache[model_key]
var aabb = _get_combined_aabb(instance)
var y_offset = -aabb.position.y # Lift so bottom touches ground
_y_offset_cache[model_key] = y_offset
return y_offset
func _get_combined_aabb(node: Node3D) -> AABB:
var combined = AABB()
var found_mesh = false
if node is MeshInstance3D:
combined = node.get_aabb()
found_mesh = true
for child in node.get_children():
if child is Node3D:
var child_aabb = _get_combined_aabb(child)
if child_aabb.size != Vector3.ZERO:
if not found_mesh:
combined = child_aabb
found_mesh = true
else:
combined = combined.merge(child_aabb)
return combined
# Usage when instantiating:
var instance = model_scene.instantiate()
var scale = get_model_scale(model_key)
instance.scale = scale
var y_offset = get_y_offset(model_key, instance) * scale.y
instance.position.y = terrain_height + y_offsetFor cities with multiple buildings, use geometry-aware ring placement:
func calculate_building_placement(index: int, building_geometry: Dictionary, total_count: int) -> Dictionary:
var width = building_geometry.width
var depth = building_geometry.depth
# Adaptive radius based on building size
var max_dim = maxf(width, depth)
var base_radius = 0.3 + max_dim * 0.4
# Distribute evenly around center
var angle = (float(index) / float(total_count)) * TAU
var offset = Vector3(
cos(angle) * base_radius,
0, # Y-offset handled separately
sin(angle) * base_radius
)
# Rotate building so long axis is tangent to circle
var rotation = angle + PI / 2.0
if depth > width: # Building is deeper than wide
rotation += PI / 2.0
return {"offset": offset, "rotation": rotation}For hex maps, orthographic projection prevents perspective distortion:
@onready var camera: Camera3D = $Camera3D
var current_zoom: float = 15.0
var camera_target: Vector3 = Vector3.ZERO
const ZOOM_MIN: float = 5.0
const ZOOM_MAX: float = 25.0
const PAN_SPEED: float = 30.0
func _ready() -> void:
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.near = 0.1
camera.far = 500.0
_update_camera()
func _update_camera() -> void:
# 45-degree angle for isometric-like view
var offset = Vector3(0, current_zoom * 0.7, current_zoom * 0.7)
camera.position = camera_target + offset
camera.look_at(camera_target, Vector3.UP)
camera.size = current_zoom # Orthographic size
func _input(event: InputEvent) -> void:
# Zoom with mouse wheel
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
current_zoom = max(ZOOM_MIN, current_zoom - 2.0)
_update_camera()
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
current_zoom = min(ZOOM_MAX, current_zoom + 2.0)
_update_camera()
func _process(delta: float) -> void:
# WASD panning (scale speed with zoom for consistent feel)
var pan_input = Vector2.ZERO
if Input.is_action_pressed("ui_up"): pan_input.y -= 1
if Input.is_action_pressed("ui_down"): pan_input.y += 1
if Input.is_action_pressed("ui_left"): pan_input.x -= 1
if Input.is_action_pressed("ui_right"): pan_input.x += 1
if pan_input != Vector2.ZERO:
var zoom_factor = current_zoom / 15.0
camera_target.x += pan_input.x * PAN_SPEED * zoom_factor * delta
camera_target.z += pan_input.y * PAN_SPEED * zoom_factor * delta
_clamp_to_bounds()
_update_camera()Use a ground plane collision shape for hex picking:
# In GameMap scene, add:
# - StaticBody3D (ground_plane)
# - CollisionShape3D with BoxShape3D covering map extent
func _on_ground_plane_input_event(_camera: Node, event: InputEvent, position: Vector3, _normal: Vector3, _shape_idx: int) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
var grid_pos = HexUtils.world_to_grid(position)
emit_signal("hex_clicked", grid_pos)The mini-map displays the entire map in a fixed-size panel (192×160 pixels), automatically scaling to fit any map dimensions:
# MiniMap.gd
@export var target_width: float = 192.0
@export var target_height: float = 160.0
func _rebuild_map_texture() -> void:
_map_width = WorldState.map_dimensions.x
_map_height = WorldState.map_dimensions.y
# Calculate hex_size to fit map within target dimensions
var hex_size_for_width = target_width / ((_map_width + 0.5) * 1.732)
var hex_size_for_height = target_height / (_map_height * 1.5 + 0.5)
hex_size = min(hex_size_for_width, hex_size_for_height)
# Generate texture at computed size
_pixel_width = int((_map_width + 0.5) * hex_size * 1.732)
_pixel_height = int(_map_height * hex_size * 1.5 + hex_size * 0.5)Each terrain type maps to a distinct color for quick identification:
const TERRAIN_COLORS: Dictionary = {
"plains": Color(0.45, 0.65, 0.35), # Green
"forest": Color(0.2, 0.45, 0.2), # Dark green
"hills": Color(0.55, 0.5, 0.35), # Brown-green
"mountains": Color(0.5, 0.45, 0.4), # Gray-brown
"mountain_peak": Color(0.85, 0.85, 0.9), # Snow white
"desert": Color(0.85, 0.75, 0.5), # Sand
"water_shallow": Color(0.3, 0.5, 0.7), # Light blue
"water_deep": Color(0.15, 0.3, 0.55), # Dark blue
}Cities and units are drawn as simple shapes on top of the terrain:
func _draw_cities() -> void:
for city in WorldState.cities.values():
var pixel_pos = _grid_to_pixel(city.position)
draw_rect(Rect2(pixel_pos - Vector2(marker_size, marker_size) * 0.5,
Vector2(marker_size, marker_size)), CITY_COLOR)
func _draw_units() -> void:
for unit in WorldState.units.values():
var pixel_pos = _grid_to_pixel(unit.position)
var color = PLAYER_UNIT_COLOR if unit.owner_faction_id == player_faction_id else UNIT_COLOR
draw_circle(pixel_pos, marker_radius, color)A white rectangle shows the current camera viewport area:
func _draw_viewport_rect() -> void:
var center = _world_to_pixel(game_map.camera_target)
var half_width = game_map.current_zoom * 0.8
var half_height = game_map.current_zoom * 0.5
var scale_x = _pixel_width / (_map_bounds_max.x - _map_bounds_min.x)
var scale_z = _pixel_height / (_map_bounds_max.z - _map_bounds_min.z)
var rect = Rect2(
center.x - half_width * scale_x,
center.y - half_height * scale_z,
half_width * scale_x * 2,
half_height * scale_z * 2
)
draw_rect(rect, viewport_rect_color, false, viewport_rect_width)Clicking on the mini-map pans the main camera to that position:
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
var world_pos = _pixel_to_world(event.position)
var grid_pos = HexUtils.world_to_grid(world_pos)
game_map.center_on_position(grid_pos)Generate rivers as continuous ribbon meshes following hex paths:
func generate_river_mesh(hex_path: Array[Vector2i]) -> MeshInstance3D:
if hex_path.size() < 2:
return null
var st = SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
# Build path with smooth curves
var curve = Curve3D.new()
for i in range(hex_path.size()):
var world_pos = HexUtils.grid_to_world(hex_path[i])
var height = GameConstants.get_terrain_elevation_at(hex_path[i]) + RIVER_HEIGHT_OFFSET
curve.add_point(Vector3(world_pos.x, height, world_pos.z))
# Sample curve at regular intervals
var samples = curve.get_baked_points()
var prev_left: Vector3
var prev_right: Vector3
var uv_distance: float = 0.0
for i in range(samples.size()):
var pos = samples[i]
var tangent = _get_curve_tangent(curve, float(i) / samples.size())
var perpendicular = tangent.cross(Vector3.UP).normalized()
var left = pos - perpendicular * RIVER_WIDTH * 0.5
var right = pos + perpendicular * RIVER_WIDTH * 0.5
if i > 0:
# Add quad between previous and current cross-section
_add_river_quad(st, prev_left, prev_right, left, right, uv_distance)
uv_distance += pos.distance_to(samples[i - 1])
prev_left = left
prev_right = right
var mesh = ArrayMesh.new()
st.commit(mesh)
var mesh_instance = MeshInstance3D.new()
mesh_instance.mesh = mesh
mesh_instance.material_override = river_material
return mesh_instance// river_water.gdshader
shader_type spatial;
render_mode blend_mix, unshaded;
uniform vec4 shallow_color: source_color = vec4(0.2, 0.6, 0.8, 0.9);
uniform vec4 deep_color: source_color = vec4(0.1, 0.3, 0.5, 0.95);
uniform float flow_speed: hint_range(0.0, 2.0) = 0.5;
uniform sampler2D foam_texture;
void fragment() {
// Scroll UVs for flow animation
vec2 flow_uv = UV + vec2(0.0, TIME * flow_speed);
// Depth-based color (darker in center)
float depth = smoothstep(0.0, 0.5, abs(UV.x - 0.5));
vec4 water_color = mix(deep_color, shallow_color, depth);
// Foam from vertex color (encoded by mesh generator)
float foam_amount = COLOR.r;
vec4 foam = texture(foam_texture, flow_uv * 2.0);
ALBEDO = mix(water_color.rgb, foam.rgb, foam_amount * foam.a);
ALPHA = water_color.a;
}Draw borders only on edges between owned and unowned hexes:
func create_territory_border(territory_hexes: Array[Vector2i], color: Color) -> MeshInstance3D:
var st = SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
for hex_pos in territory_hexes:
var neighbors = HexUtils.get_neighbors(hex_pos)
for i in range(6):
var neighbor = neighbors[i]
# Only draw edge if neighbor is NOT in territory
if neighbor not in territory_hexes:
var edge_points = _get_hex_edge(hex_pos, i)
_add_border_segment(st, edge_points[0], edge_points[1], color)
var mesh = ArrayMesh.new()
st.commit(mesh)
var instance = MeshInstance3D.new()
instance.mesh = mesh
return instance
func _get_hex_edge(hex_pos: Vector2i, edge_index: int) -> Array[Vector3]:
var center = HexUtils.grid_to_world(hex_pos)
var height = GameConstants.get_terrain_elevation_at(hex_pos) + BORDER_HEIGHT_OFFSET
var corners = HexUtils.get_hex_corners(center, HexUtils.OUTER_RADIUS)
var start = corners[edge_index] + Vector3(0, height, 0)
var end = corners[(edge_index + 1) % 6] + Vector3(0, height, 0)
return [start, end]Meshy AI models require specific import settings to avoid color corruption:
Critical setting: Disable light baking to prevent vertex color modification:
# In .glb.import file
[params]
meshes/light_baking=0 # MUST be 0, not 1To fix existing imports in bulk:
for f in assets/textures/buildings/*.glb.import; do
sed -i 's/meshes\/light_baking=1/meshes\/light_baking=0/' "$f"
doneMeshy GLB materials don't import cleanly. Extract textures and recreate materials:
func fix_meshy_materials(instance: Node3D, model_key: String) -> void:
var meshes = _find_mesh_instances(instance)
var mat = _get_or_create_material(model_key)
if mat == null:
return
for mesh in meshes:
for i in range(mesh.get_surface_override_material_count()):
mesh.set_surface_override_material(i, mat)
func _get_or_create_material(model_key: String) -> StandardMaterial3D:
if _material_cache.has(model_key):
return _material_cache[model_key]
# Try multiple texture naming patterns
var albedo_paths = [
ASSET_PATH + model_key + "_Image_0.jpg",
ASSET_PATH + model_key + "_0.jpg",
]
var albedo_tex: Texture2D = null
for path in albedo_paths:
if ResourceLoader.exists(path):
albedo_tex = load(path)
break
if albedo_tex == null:
return null
var mat = StandardMaterial3D.new()
mat.albedo_texture = albedo_tex
# Try to find normal map
var normal_paths = [
ASSET_PATH + model_key + "_Image_1.jpg",
ASSET_PATH + model_key + "_1.jpg",
]
for path in normal_paths:
if ResourceLoader.exists(path):
mat.normal_enabled = true
mat.normal_texture = load(path)
break
_material_cache[model_key] = mat
return matSome models need special treatment (e.g., gold with emission to fix crushed shadows):
const MATERIAL_OVERRIDES: Dictionary = {
"res_gold": {
"emission_enabled": true,
"emission": Color(1.0, 0.85, 0.4),
"emission_energy_multiplier": 0.3,
},
}
func apply_material_overrides(model_key: String, instance: Node3D) -> void:
if not MATERIAL_OVERRIDES.has(model_key):
return
var overrides = MATERIAL_OVERRIDES[model_key]
var meshes = _find_mesh_instances(instance)
for mesh in meshes:
for i in range(mesh.get_surface_override_material_count()):
var mat = StandardMaterial3D.new()
# Copy original texture
var original = mesh.mesh.surface_get_material(i)
if original is StandardMaterial3D:
mat.albedo_texture = original.albedo_texture
# Apply overrides
for prop in overrides:
mat.set(prop, overrides[prop])
mesh.set_surface_override_material(i, mat)// hex_overlay.gdshader
shader_type spatial;
render_mode unshaded, depth_draw_opaque;
uniform vec4 overlay_color: source_color = vec4(1.0, 1.0, 0.0, 0.5);
uniform vec4 border_color: source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform float opacity: hint_range(0.0, 1.0) = 0.5;
uniform float border_width: hint_range(0.0, 0.3) = 0.1;
uniform bool fill_interior = true;
uniform bool animate = false;
uniform float pulse_speed: hint_range(0.5, 4.0) = 2.0;
void fragment() {
// Calculate distance from hex center (approximate)
float dist_from_center = length(UV - vec2(0.5));
float hex_edge = 0.45; // Approximate hex boundary in UV space
float edge_factor = smoothstep(hex_edge - border_width, hex_edge, dist_from_center);
float alpha = opacity;
if (animate) {
alpha *= 0.7 + 0.3 * sin(TIME * pulse_speed);
}
if (fill_interior) {
vec4 color = mix(overlay_color, border_color, edge_factor);
ALBEDO = color.rgb;
ALPHA = color.a * alpha;
} else {
// Border only
float border_factor = smoothstep(hex_edge - border_width, hex_edge - border_width * 0.5, dist_from_center)
* (1.0 - smoothstep(hex_edge, hex_edge + 0.01, dist_from_center));
ALBEDO = border_color.rgb;
ALPHA = border_color.a * border_factor * alpha;
}
}Problem: Manually calculating neighbor offsets without accounting for odd/even rows.
Solution: Always use HexUtils.get_neighbors() which handles row parity automatically.
Problem: Converting grid→world→grid doesn't return the original position due to floating point errors.
Solution: Use cube coordinate rounding in world_to_grid() for accuracy.
Problem: Imported Meshy GLB models appear with wrong colors (washed out, black shadows).
Solution:
- Set
meshes/light_baking=0in GLB import settings - Extract and recreate materials manually
Problem: 3D models placed at terrain height appear partially underground.
Solution: Calculate AABB-based Y-offset to account for models with origin at center.
Problem: Camera pan limits don't account for odd-row hex offsets.
Solution: Calculate bounds using:
var max_x = (map_width - 0.5) * HexUtils.X_OFFSET # Account for odd row shift
var max_z = (map_height - 1) * HexUtils.Z_OFFSETProblem: Same resource type appears at different sizes across map.
Solution: Use fixed scale factors per resource type, don't vary by deposit size.
Problem: Selection overlays flicker with terrain surface.
Solution: Add small Y offset (0.01-0.05) to overlay meshes above terrain.
| File | Purpose |
|---|---|
scripts/map/HexUtils.gd |
Core hex math (autoload) |
scripts/map/HexMetrics.gd |
River/elevation calculations |
scripts/map/HexBorder.gd |
Territory border generation |
scripts/map/TerrainGrid.gd |
3D hex prism terrain renderer |
scripts/map/TerrainGenerator.gd |
Procedural terrain generation |
scripts/map/HeightmapLoader.gd |
Heightmap auto-discovery and loading |
scripts/map/RiverRenderer.gd |
Smooth ribbon river meshes |
scripts/map/GameMap.gd |
Camera controls, click detection |
scripts/ui/minimap/MiniMap.gd |
Strategic overview minimap |
scripts/autoload/FactionBuildings.gd |
Building model management |
scripts/autoload/ResourceModels.gd |
Resource deposit models |
scripts/autoload/InfrastructureModels.gd |
Infrastructure models |
scripts/autoload/GameConstants.gd |
Elevation tiers, terrain types |
assets/shaders/hex_overlay.gdshader |
Selection highlighting |
assets/shaders/river_water.gdshader |
Animated water |
- Red Blob Games: Hexagonal Grids - Essential hex math reference
- Catlike Coding: Hex Map Tutorial - Unity-focused but concepts transfer
- Godot 4 Documentation - Official docs