feat(hexmap): port the Catlike Coding hex map as a module and interactive example Adds src/modules/hexmap, an editable pointy-top hex grid with 5x5 chunk dirty tracking, per-chunk mesh generation (terrain fans, blend strips, terraces, cliffs, water, river channels, roads, city walls, decorations and a fog overlay), A* pathfinding, reference-counted fog of war, a unit registry with destination-reserving travel, a versioned save payload and a procedural map generator ported from the reference MapGenerator. Ships the interactive example as a new examples/hex-terrain-3d directory; the unrelated mesh.hexterrain procgen example in examples/hex-terrain is left byte-identical to dev.

This commit is contained in:
西风逍遥游 committed 2026-09-18 10:09:33 +08:00
1 parent d29e6e77be
commit af76e3194f
53 files changed
+11301

No files matched your search

@@ -47,6 +47,12 @@ eve_declare_module(NAME map LAYER 4 SCRIPT Map SLOT map
DEPS data filesystem graphics grid
THIRDPARTY poco
GROUP 2d 3d)
# Interactive 3D hex map: an editable pointy-top cell grid plus per-chunk mesh
# generation (ground fans, blend strips, terraces, cliffs, water, rivers, roads,
# city walls, decorations and the fog overlay).
eve_declare_module(NAME hexmap LAYER 4 SCRIPT HexMap SLOT hexmap
DEPS graphics
GROUP 3d)
eve_declare_module(NAME map_editing LAYER 4
DEPS editing
OPTIONAL_DEPS map
+35
View File
@@ -116,3 +116,38 @@ procedural PBR surfaces, perturbs the boundary with world-space FBM and performs
GGX metallic-roughness lighting evaluation. This gives stable, asset-free
transitions and validates the end-to-end path, but it supports only two layers per
vertex and should not become the permanent terrain data format.
## Update: the `hexmap` module
The hex map was subsequently ported from the Catlike Coding reference project as a
dedicated module rather than another procgen recipe, because an *editable* map needs
authoritative cell data and incremental mesh updates that a one-shot generator
cannot provide. `docs/usr/modules/hexmap.md` is the module contract;
`examples/hex-terrain-3d` is the interactive editor over it. It deliberately lives
in its own directory so that `examples/hex-terrain` — the `mesh.hexterrain` proof
described above — keeps shipping unchanged.
What that closes from the gap list above:
- Gaps 1 and 5 (no cell data, no chunking, no picking): `hexmap::HexMap` owns the
cell grid (elevation, water, terrain, feature levels, river/road connections),
partitions it into 5x5 chunks with dirty tracking, answers neighbour/edge
queries, and converts a world-space ray back to a cell.
- Gap 5's mesh side: `buildChunkSurfaceMesh` generates the terrain, water, river
and road streams per chunk, and `HexMapModule::rebuildChunk` updates only the
dirty surfaces in place.
- Gap 7's "one baked mesh" half: the walkable terrain is now the chunked,
independent surface the audit asked for.
What still stands:
- Gaps 2-4 (bindable texture arrays, independent roughness/metallic/AO maps, a
CPU-to-GPU `PbrTextureSet`) are unchanged. The example therefore still shades the
terrain procedurally from a packed vertex encoding, now three layers instead of
two; `HexMapMesh.h` documents that as a compatibility bridge.
- Gap 6 (production hydrology: basin filling, flow accumulation, confluences) is
unchanged. The module enforces the reference's downhill/lake-outflow rule and
draws channels, but does not solve drainage.
- `mesh.hexterrain` and its generator are untouched and still ship the original
proof; the module is additive.
+267
View File
@@ -0,0 +1,267 @@
# 六边形立体地图(Hex Map)
**脚本入口:** `hexmap`(`eve.HexMap`,模块槽位 `hexmap`)
`hexmap` 是引擎的**可编辑 3D 六边形地图**层:持有尖顶(pointy-top)六边形格网、
按 5×5 分块、按面(terrain / water / river / road)生成 CPU 网格并上传 GPU,同时
提供世界坐标拾取、邻居查询与笔刷编辑。参考实现是 Catlike Coding 的
[Hex Map](https://catlikecoding.com/unity/hex-map/) 工程;坐标、高程台地、悬崖、
水面与河道几何均与之一致。
## 布局与常量
| 项 | 值 | 说明 |
|----|----|------|
| 朝向 | 尖顶 | XZ 平面,+X 向东,+Z 向北,Y 为上 |
| `outerRadius` | 10 | 六边形外接圆半径 |
| `innerRadius` | 8.66025404 | `outerRadius * √3/2` |
| `elevationStep` | 3 | 每级高程的世界高度 |
| `solidFactor` / `waterFactor` | 0.8 / 0.6 | 实心区与水面内缩因子 |
| `terracesPerSlope` | 2(`terraceSteps` = 5) | 斜坡台地级数 |
| `kChunkSizeX/Z` | 5 / 5 | 分块尺寸;地图尺寸必须是它的整数倍 |
| 地面扰动 | XZ ±4,Y ±1.5 | `HexNoise`(种子决定,无需贴图资源) |
偏移坐标到轴坐标:`axial = offset - (z/2, 0)`(`z/2` 向下取整)。世界坐标:
`x = (axialX + 0.5·axialZ) · innerDiameter`,`z = axialZ · outerRadius · 1.5`。
`HexCoordinates::fromWorldPosition` 是其精确逆变换(含立方体取整修正)。
## C++ API 快查
### `HexMetrics`([HexMetrics.h](../../../src/modules/hexmap/HexMetrics.h))
常量与角点/边采样助手:`firstCorner` / `firstSolidCorner` / `firstWaterCorner` /
`bridge` / `waterBridge` / `solidEdgeMiddle` / `terraceLerp` / `wallLerp`,
以及 `elevationY` / `streamBedY` / `waterSurfaceY`。`EdgeVertices` 是边上 5 个
采样点(v1…v5)。
### `HexCoordinates`([HexCoordinates.h](../../../src/modules/hexmap/HexCoordinates.h))
轴坐标 `(x, z)`,`y = -x - z`。`step(direction, distance)`、`distanceTo`、
`fromOffset` / `offsetX` / `offsetZ`、`chunkColumn` / `chunkRow`、
`toWorldPosition` / `fromWorldPosition`。
### `HexMap`([HexMap.h](../../../src/modules/hexmap/HexMap.h))
值类型格网,`reset(cellCountX, cellCountZ, seed)` 建立地图(尺寸必须是 5 的倍数)。
- 拓扑:`contains` / `indexOf` / `coordinatesAt` / `tryGetNeighbor` / `edgeTypeTo`
- 分块:`chunkIndexOf` / `chunkColumnOf` / `chunkRowOf` / `chunkCenter` /
`takeDirtyChunk` / `markAllChunksDirty` / `dirtyChunkCount`
- 拾取:`pickCell(rayOrigin, rayDirection)` —— 无碰撞体,改为在候选格高程平面上
迭代求交并二分收敛
- 查询:`elevation` / `waterLevel` / `terrainType` / `urbanLevel` / `farmLevel` /
`plantLevel` / `specialIndex` / `isUnderwater` / `hasRiver` / `hasRoad` / `isWalled`
- 编辑:`setElevation` / `setWaterLevel` / `setTerrainType` / `setUrbanLevel` /
`setFarmLevel` / `setPlantLevel` / `setSpecialIndex` / `setWalled` /
`setOutgoingRiver` / `removeRiver` / `addRoad` / `removeRoads`
- 笔刷:`editElevation` / `editWaterLevel` / `editTerrainType` / `editFeatureLevel`
(六边形圆盘,`collectBrush` 可单独枚举)
跨格约束与参考工程一致:河流只能顺坡或从湖面流出;道路不能跨两级以上高程、
不能穿过河流中段、城市/特殊地物会移除道路;特殊地物在有河时被忽略。
### 网格生成([HexMapMesh.h](../../../src/modules/hexmap/HexMapMesh.h))
`buildChunkSurfaceMesh(map, chunkIndex, surface, out)`,`surface` 取
`HexSurface::{Terrain, Water, River, Road}`。生成物是 `HexMeshData`
(position / normal / uv / index,三角形不共享顶点,`finalize()` 写平面法线)。
分块所有权与参考工程一致:地面扇形按本块 25 格生成,**只有 NE / E / SE**
方向的混合带、台地与悬崖归属本块,其余由邻块生成,因此块与块之间不会重复或漏缝。
顶点只有两个纹理坐标,地形三层信息按
`u = layerA + layerB·8 + layerC·64`、`v = weightB + weightC·16` 打包;这是当前
顶点布局的**兼容桥**,不是地形数据格式。
`HexSurface::Fog`(值 4)是唯一不能由 `buildChunkSurfaceMesh` 生成的流,因为它
除了格子还需要可见性计数器,由 `buildFogMesh(map, visibility, chunkIndex, out)`
生成:每个"当前不可见"的格子输出一根六棱柱(顶面 + 侧裙),裙脚垂到最低可编辑
高程之下,因此迷雾边界上的悬崖立面也会被覆盖;第一纹理坐标 `u` 承载明暗
(`0` = 曾经探索过但此刻无人看见,`1` = 从未探索)。迷雾流在可见性跨越 0/1 时
自动把所在块标脏。
另外两条流由 [HexFeatures.h](../../../src/modules/hexmap/HexFeatures.h) 生成,也不走
`buildChunkSurfaceMesh`:`HexSurface::Wall`(值 5,`buildWallMesh`)输出城墙、墙塔
与桥梁,`HexSurface::Feature`(值 6,`buildFeatureMesh`)输出 urban / farm / plant /
special 地物。两者共用一条片元着色器,因为第一纹理坐标已经标明了部件种类:
| `u` | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| 部件 | 城墙 | 墙塔 | 桥梁 | urban | farm | plant | special |
城墙的归属规则与地形一致:只有当 `d ∈ {NE, E, SE}` 或该方向已经在网格之外时,
才由本块生成该边的城墙——这样每条内部边恰好被一侧生成,而地图边界仍然闭合。
道路横穿城墙边界时会开出城门(只生成两侧外段 + 两个端帽);高程差产生楔形挡墙。
### 地物与城墙([HexFeatures.h](../../../src/modules/hexmap/HexFeatures.h))
参考工程实例化 prefab 模型;本引擎不携带资源,因此 `HexFeatures.cpp` 用程序化几何
替代(墙=两侧面 + 顶盖,塔/桥/建筑=盒体,plant=压扁八面体,special=锥形柱)。
`HexHashGrid` 是参考工程 `hashGrid` 表的确定性替代:由 `(gridX, gridZ, seed,
channel)` 的整数哈希直接导出五个分量,因此不需要分配表、也没有全局状态,同一地图
种子永远得到同一套地物布局。`featureThreshold(level, index)` 返回参考工程的
三级阈值表 `{0,0,0.4} / {0,0.4,0.6} / {0.4,0.6,0.8}`,越界参数被钳制。
拾取规则与参考工程一致:urban / farm / plant 三个集合各自用格子哈希判定是否
命中,命中的里面哈希最小者胜出;`specialIndex` 非零时该格不生成普通地物。
### 地图生成([HexMapGenerator.h](../../../src/modules/hexmap/HexMapGenerator.h))
`generateHexMap(map, settings)` 把参考工程 `MapGenerator` 的流水线移植为纯 CPU 步骤:
1. 全部格子初始化到 `elevationMinimum`;
2. `regionCount` 个陆地区域从种子格生长(抖动、随机抬升/下沉,遵守地图与区域边界);
3. 海岸线侵蚀直至陆地占比接近 `landPercentage`;
4. 顺坡开凿河流(含湖泊概率与已有的汇流判定);
5. 40 轮下风向水汽传输(蒸发、降水、坡面径流、下渗),得到湿度与温度;
6. 按参考工程的生物群系表写地形类型与植被等级。
可复现性只依赖 `settings.seed` 与网格本身:不使用时间、`random_device` 或可变静态
状态。`elevationMaximum` 会被钳制到 `HexMetrics::kMaxElevation`;不合法或放不下的
区域参数在改动地图之前就以 `InvalidArgument` 拒绝。生成后的地图**全部格子未探索**,
因此调用方必须重新放置单位并重建全部块。
### 搜索与寻路([HexSearch.h](../../../src/modules/hexmap/HexSearch.h))
- `HexSearchContext`:可复用的搜索草稿(每格一条 `HexSearchData` + 按优先级分桶
的前沿队列)。用**相位**(`beginPhase()`,每次 +2)代替"清空 visited",因此一次
搜索不付全图重置的代价。
- `isValidDestination(map, c, occupied)`:`explored && explorable && !underwater &&
!occupied`;地形部分由 `HexCell.h` 的 `canHoldUnit()` 单点定义。
- `moveCost(map, from, to, direction, occupied)`:悬崖与墙界不可通行;起点有路 = 1,
否则平地 5 / 斜坡 10,再加目标格的 urban+farm+plant 等级。返回**负数**表示阻断。
- `findPath(map, scratch, from, to, rules, occupied) -> Result<HexPath>`:A\*(启发式
为六边形距离),代价按 `speed` 折算成"回合",`HexPath::turns` 给出每个格子到达
时的回合数。目标不可进入时返回 `NotFound`,端点越界或 scratch 尺寸不匹配返回
`InvalidArgument`。
- `collectVisibleCells(map, scratch, from, range, out)`:视线可达格(距离 + 目标格
`viewElevation` 与 `range + 起点 viewElevation` 比较,且不超过直接六边形距离)。
### 可见性与迷雾([HexVisibility.h](../../../src/modules/hexmap/HexVisibility.h))
`HexVisibility` 是**引用计数**而非布尔:多个观察者重叠时,只有最后一个离开才让格子
转回不可见;某格第一次被看见时同时把地图上的 `explored` **单向闩锁**置位,此后不再
回落。`increase` / `decrease` / `clear` / `resetVisibility`(按当前单位重算全图)。
### 单位([HexUnits.h](../../../src/modules/hexmap/HexUnits.h))
`HexUnitRegistry` 持有单位、寻路占用与视野贡献:
- `addUnit` 立即授予视野;`unitIdAt` / `isOccupied` 用于寻路占用谓词
(`occupancyQuery()` 返回借用 `*this` 的可调用对象)。
- 单位 id 就是注册表下标,删除会让后面的 id 前移;调用方不要跨删除缓存 id。
- 移动沿二次贝塞尔曲线(Unity 参考工程的转角做法),`advance(map, visibility,
scratch, id, dt)` 既推进动画又返回**当前位姿样本**(世界坐标已含高程与扰动),
`dt <= 0` 表示只读不推进。**目的地自 `beginTravel` 起即被占用**,视野则跟着
实际走过的格子移动(`visionIndex`)。
- `restore` 先清空再校验,被拒绝的载荷不会留下"半恢复"的注册表。
### 存档([HexSerializer.h](../../../src/modules/hexmap/HexSerializer.h))
`saveHexMap(map, units, out)` / `loadHexMap(bytes, map, units)`:小端、显式逐字节
写出的自描述载荷(magic `EVEHEX\0\0` + 版本 + 尺寸 + 种子 + 每格 `HexValues` /
`HexFlags` + 单位表)。版本不匹配会**拒绝**而不是猜测;载荷在触碰输出前整体校验
(长度、magic、版本、尺寸上限 512 且为 5 的倍数、单位格在界内、单位格可站立、
单位不重复),因此被拒绝的载荷不会留下部分写入的地图。修订号字段只作诊断。
## 脚本 API(`hexmap`)
```squirrel
hexmap.newGrid(gfx, cellCountX, cellCountZ, seed) // -> Result
hexmap.hasGrid() / cellCountX() / cellCountZ() / chunkCountX() / chunkCountZ() / chunkCount() / seed()
hexmap.surfaceCount() // -> 7,见下面的 surface 编号表
hexmap.takeDirtyChunk() // -> 脏块索引,-1 表示没有
hexmap.rebuildChunk(gfx, chunkIndex) // -> 生成出几何的面数
hexmap.rebuildDirtyChunks(gfx) // -> 重建全部脏块,返回面数合计
hexmap.chunkMeshAt(chunkIndex, surface) // -> Mesh(借用),无几何时为 null
hexmap.releaseMeshes(gfx) // 释放本模块创建的全部网格
hexmap.elevation(x, z) / waterLevel(x, z) / terrainType(x, z) / urbanLevel(x, z) / ...
hexmap.isUnderwater(x, z) / hasRiver(x, z) / hasRoad(x, z) / isWalled(x, z)
hexmap.specialIndex(x, z) / farmLevel(x, z) / plantLevel(x, z)
hexmap.setElevation(x, z, v) / setWaterLevel(x, z, v) / setTerrainType(x, z, v) / ...
hexmap.setUrbanLevel(x, z, v) / setFarmLevel(x, z, v) / setPlantLevel(x, z, v) / setSpecialIndex(x, z, v)
hexmap.setWalled(x, z, v) / setOutgoingRiver(x, z, direction) / removeRiver(x, z)
hexmap.addRoad(x, z, direction) / removeRoads(x, z)
hexmap.editElevation(x, z, radius, delta) / editWaterLevel(x, z, radius, delta)
hexmap.editTerrainType(x, z, radius, terrainType) / editFeatureLevel(x, z, radius, feature, delta)
// 世界坐标(含高程与扰动);同一坐标系的逐分量版本,供脚本直接喂给 renderable
hexmap.cellPositionX(x, z) / cellPositionY(x, z) / cellPositionZ(x, z)
hexmap.chunkCenterX(chunkIndex) / chunkCenterY(chunkIndex) / chunkCenterZ(chunkIndex)
hexmap.pickCell(ox, oy, oz, dx, dy, dz) // -> {ok, value=[x, z]}
// 迷雾
hexmap.isExplored(x, z) / isExplorable(x, z) / setExplored(x, z, v) / setExplorable(x, z, v)
hexmap.isCellVisible(x, z) / visibleCellCount() / resetVisibility()
// 单位
hexmap.unitCount() / addUnit(x, z, orientation) / removeUnit(id) / removeAllUnits()
hexmap.unitIdAt(x, z)
hexmap.unitSample(id) // -> {ok, value=[cellX, cellZ, worldX, worldY, worldZ, yaw, traveling]}
hexmap.travelUnit(id, path) / advanceUnits(dt)
// 寻路 / 存档 / 生成
hexmap.findPath(fx, fz, tx, tz) // -> {ok, value=[[x, z, turn], ...]}
hexmap.saveMap() // -> {ok, value=<二进制安全字符串>}
hexmap.loadMap(gfx, blob) // -> Result
hexmap.generateMap(gfx, seed, landPercentage, waterLevel, riverPercentage) // -> Result
```
`surface` 取 `0=Terrain, 1=Water, 2=River, 3=Road, 4=Fog, 5=Wall, 6=Feature`。
`x, z` 是偏移坐标(列、行)——`pickCell` 的返回值同样是偏移坐标,与 `elevation` /
`editElevation` / `cellPositionX` 等所有格子接口同一坐标系,因此拾取结果可以直接回喂
给它们(`findPath` 与 `unitSample` 返回的格子也已经是偏移坐标)。`unitSample` 复用
`advance(dt=0)`,因此返回的是含高程与扰动的插值位姿,不是地格中心。`generateMap` 按
**当前网格尺寸**重新生成,会丢弃全部单位与迷雾状态,调用方必须在成功后重新放置单位
并重建全部块。
## 生命周期与所有权
- 模块**只持有一张活动地图**(参考工程的编辑器同样只编辑一张格子);`newGrid`
会释放上一张地图生成的全部 GPU 网格。
- 模块拥有 `HexMap` 与它创建的每个 `graphics::Mesh`;`chunkMeshAt` 返回**借用**
指针,`Renderable3D` 只能在模块存活且未换图期间引用它。
- 某面在一张地图内可能从"有几何"变为"无几何"(例如最后一条道路被擦掉):此时
该面的 GPU 网格**延迟释放**(保留到下次 `newGrid`),`chunkMeshAt` 返回
`null`,调用方据此隐藏 renderable,不会出现悬垂指针。
- `loadMap` 走 `adoptGrid`:先释放旧网格、接管已解码的地图、重建搜索草稿与迷雾
计数器,再恢复单位。**调用方必须在成功后重建全部块并重新绑定 renderable**,
因为所有块都是脏的且没有任何 renderable 指向新网格。
- 主/渲染线程亲和;不调用未知回调,不跨外部调用持锁。
## 示例
`examples/hex-terrain-3d` 是本模块的完整示例(与之无关的 `examples/hex-terrain` 是
`mesh.hexterrain` 配方,保持原样):`WASD` 平移、`Q/E` 旋转、滚轮缩放,
`1`–`5` 切换高程/水面/地形/河流/道路模式,`[`/`]` 调整笔刷半径,`,`/`.` 切换地形
调色板,左右键升降或拖拽绘制河流与道路,`R` 换种子、`F` 换地图尺寸、`N` 重建。
第二轮补上战争迷雾、单位与寻路、存档:`tab` 切换选中单位,`g` 规划到光标格的路径,
`space` 出发,`escape` 取消预览,`t`/`y` 增删单位,`F5`/`F9` 存档与读档。第三轮补上
城墙/桥梁/地物与地图生成器:`m` 按当前尺寸生成一张程序化地图(重新随机种子)。
存档载荷经 `eve.Filesystem().writeText()` 落到保存目录,读回时先整体校验再接管
网格,因此损坏的文件不会破坏当前地图。
相机方面示例做了一个必要的修正:`eve.Camera3D()` 的默认裁剪范围是 `0.1..100`,
比一张地图的跨度还小,超过 100 世界单位的地形会被静默裁掉,远端的缩放档位因此只
能看到空屏。示例现在每帧按地图跨度推导远裁剪面,并让最远缩放距离随之放大
(`ZOOM_FAR_MARGIN`),所以任何地图尺寸都能完整入画。
## 已知边界(尚未实现)
瀑布与河口(estuary)几何尚未移植;河道是等宽中心线带,未做 7 分支交汇解算;迷雾按
"已探索 / 当前可见"两态渲染(不区分"当前可见"之外的中间态透明度),且迷雾柱顶取
自身与邻居的最高面,因此在高差极大的边界上会比参考工程略"鼓";地物是程序化几何
而非参考工程的 prefab 比例。
水面与参考工程逐方向对齐:邻居是水就发一个中心三角形 + 桥接四边形(桥接只由
NE/E/SE 侧发,避免水/水边界重复),邻居是陆地就发四个细分楔形三角形 + 四段岸带;
岸带**平铺在水面高度**,只借用邻格的实体角点平面位置,不借用它的高程——倾斜到岸顶的
写法会在每条六边形边上留下一条亮带。`HexSurface::Water` 因此完全水平,测试
`hexmap.mesh.waterSurfaceIsFlatAtAShore` 会断言所有水面顶点同高、所有法线朝 +Y。
地图生成器的两处刻意偏离参考工程(均已在源码中注释):侵蚀取共享搜索前沿中最低的
格子,而参考工程是均匀随机挑选可侵蚀格;温度抖动使用私有的值噪声,而参考工程采样
噪声贴图。生成器的 `HemisphereMode` 只实现参考工程的 `North` 映射。
+1
View File
@@ -145,6 +145,7 @@ Windows 本地直接跑同样的命令即可:脚本自身按 UTF-8 写 stdout/
| [housegen](housegen/README.md) | 房屋布局生成 + GLB kit 实例化 |
| [linear-structures](linear-structures/README.md) | 线性可拼接结构:栅栏 / 石墙 / 桥 / 长城 / 树篱 / 拒马 |
| [hex-terrain](hex-terrain/README.md) | 无源美术的 3D 六边形世界:`mesh.hexterrain` 配方 |
| [hex-terrain-3d](hex-terrain-3d/README.md) | 可交互 3D 六边形地图:高程台地与悬崖、水面、河流、道路、城墙与地物、战争迷雾、单位寻路、程序化地图生成、存档、相机操控与笔刷编辑 |
| [terrain-preview](terrain-preview/README.md) | 直写交换链的 3D 地形预览:三种侵蚀 + 河湖水面 + 自动抓帧 |
| [terrain-gallery](terrain-gallery/README.md) | 三个固定 seed 在相同生成参数、光照与材质下的并排对比 |
| [mesh-modifier-lab](mesh-modifier-lab/README.md) | 13 种网格变形变体并排对照:类型化修饰图(bend / twist / FFD / 切面 / 样条 / 声波)、雕刻笔刷、粘液回弹、Mesh Fit、顶点编辑器 |
+149
View File
@@ -0,0 +1,149 @@
# Hex Map 3D
An interactive pointy-top 3D hex map: elevation terraces and cliffs, water,
rivers, roads, walls and decorations, units with A* pathfinding, fog of war,
procedural map generation, save/load, an orbit camera, mouse picking and a
hex-shaped brush. It is a port of the Catlike Coding
[Hex Map](https://catlikecoding.com/unity/hex-map/) reference project onto
EVEngine's `hexmap` module.
This directory is the module's interactive reference example. The separate
[`hex-terrain`](../hex-terrain/README.md) example is unrelated: it is the
asset-free `mesh.hexterrain` procgen recipe and it still ships unchanged.
Everything geometric and everything the fog/units/pathfinding rules decide lives
in C++ (`src/modules/hexmap`): the editable cell grid, its 5x5 chunk partition,
the picking query, the per-chunk mesh builders (terrain, water, river, road, the
fog overlay, walls and decorations), the search/visibility scratch, the unit
registry, the save-payload codec and the procedural map generator. This example
owns the camera, the input mapping, the brush tool, the per-chunk renderables, the
unit markers and the HUD.
Run with `make run/win32-debug GAME=examples/hex-terrain-3d` (Linux:
`make run/linux-debug GAME=examples/hex-terrain-3d`).
## Controls
| Key / input | Action |
|---|---|
| `W` `A` `S` `D` (or arrows) | Pan the camera on the XZ plane |
| `Q` / `E` | Rotate the camera |
| Mouse wheel (or `Z` / `X`) | Zoom; the far end tracks the map extent |
| `1` .. `5` | Edit mode: Elevation, Water, Terrain, River, Road |
| `[` / `]` | Decrease / increase the hex brush radius (`0`..`6`) |
| `,` / `.` | Cycle the terrain palette (Sand, Grass, Mud, Stone, Snow) |
| Left mouse | Raise / paint / start a river or road drag; plan a unit path |
| Right mouse | Lower / erase (removes terrain paint, rivers or roads) |
| `R` | New deterministic seed and rebuild the starter map |
| `F` | Cycle the map size (20, 30, 40) |
| `M` | Generate a procedural map (fresh seed) at the size `F` selected |
| `N` | Mark every chunk dirty and rebuild |
| `tab` | Select the next unit |
| `G` | Plan a path from the selected unit to the hovered cell |
| `space` | Send the selected unit along the planned path |
| `escape` | Clear the planned path |
| `T` / `Y` | Add a unit on the hovered cell / remove the selected unit |
| `J` / `K` | Toggle the hovered cell's *explored* / *explorable* flag |
| `F5` / `F9` | Save / load the map, its units and its explored flags |
Rivers and roads are painted by dragging from a cell to its neighbour, matching
the reference editor. The module rejects uphill rivers, roads that climb more
than one elevation step, and roads that cross a river mid-channel.
## What is implemented
- Pointy-top hex grid with the reference metrics (`outerRadius 10`,
`elevationStep 3`, `solidFactor 0.8`, `waterFactor 0.6`, two terraces per
slope, `chunkSize 5x5`).
- Ground fans, blend strips between neighbours, terraced slopes and cliffs, and
the corner matrix that closes the triple junctions (7 cases).
- Water surface with a shore band, river channels carved to the stream bed, and
road ribbons. The water is triangulated per direction like the reference: an
open-water wedge plus a bridge quad towards flooded neighbours, or a subdivided
four-triangle shore wedge plus a four-quad bank strip towards land — and that
strip stays **flat at the water height**, borrowing only the neighbour's solid
corner positions. Tilting it up to the bank reads as a bright band tracing every
hexagon edge, which is why the whole surface is now provably horizontal.
- Deterministic per-cell XZ and height noise, so no texture assets are required.
- Five terrain layers blended per vertex and shaded with GGX lighting, slope
rock, a snow line and moving cloud shade.
- Fog of war: cells are counted per viewer, the first viewer latches a cell
*explored* one-way, and the overlay draws a hexagonal column over every cell
no viewer currently sees (dim for remembered ground, near black for ground
never seen). The column also carries a skirt so the cliff faces along a fog
boundary are covered.
- Units on a hex grid: A* over the cell graph with roads, slopes, cliffs, walls,
water and other units as movers, a turn budget derived from the unit's speed,
and a Bezier walk whose vision follows the corridor it travels.
- Save/load as a self-describing little-endian payload (grid + explored flags +
units) written through `eve.Filesystem()`; the payload is validated in full
before any observable state changes.
- City/farm walls, wall towers and bridges generated as geometry (the reference
instantiates prefabs; EVEngine ships none). Walls follow the reference's
`wallLerp`/`wallThicknessOffset` construction, open a gate where a road crosses
the boundary, and fill the high side of a step with a wedge.
- Urban, farm, plant and special decorations, picked exactly like the reference:
the three collections are each tested against a deterministic per-cell hash and
the lowest passing hash wins, with a special feature suppressing the ordinary
decoration of its cell.
- A procedural map generator ported from the reference `MapGenerator`: seeded land
regions grown with jitter and occasional rises/sinks, coastal erosion towards a
land-percentage target, downhill rivers with lakes, a 40-cycle downwind moisture
transport, and the reference's temperature/moisture biome table with plant
levels. Generation is reproducible from a seed alone.
- Orbit camera, ray picking with an iterative elevation refinement, a hex cursor
and an immediate-mode HUD.
The example seeds a deterministic starter map (a two-step central plateau, a
flooded basin, two rivers draining off the plateau, a road crossing the lowland
and a walled district with gates where the road crosses its wall) plus three
units, so every system is visible on the first frame. `M` replaces it with a
procedurally generated map.
## Camera
`eve.Camera3D()` defaults to a `0.1 .. 100` clip range. That is smaller than a
single map span once the grid passes a few chunks, so anything beyond 100 world
units was silently clipped and the far half of the zoom range showed an empty
frame. The example now derives the far plane from the map extent every frame and
scales the far zoom distance with it (`ZOOM_FAR_MARGIN`), so the whole map always
fits the view whatever the map size.
## Shaders
The five fragment shaders are shipped as SPIR-V next to their GLSL sources
because runtime GLSL compilation is unavailable on Windows. The engine's
built-in Mesh3D vertex stage already produces the varyings they consume, so no
vertex stage is shipped. Regenerate them after editing a `.frag`:
```sh
glslc -fshader-stage=frag shaders/hex_map_terrain.frag -o shaders/hex_map_terrain.frag.spv
glslc -fshader-stage=frag shaders/hex_map_water.frag -o shaders/hex_map_water.frag.spv
glslc -fshader-stage=frag shaders/hex_map_river.frag -o shaders/hex_map_river.frag.spv
glslc -fshader-stage=frag shaders/hex_map_road.frag -o shaders/hex_map_road.frag.spv
glslc -fshader-stage=frag shaders/hex_map_fog.frag -o shaders/hex_map_fog.frag.spv
glslc -fshader-stage=frag shaders/hex_map_feature.frag -o shaders/hex_map_feature.frag.spv
```
There are six stages but seven surface streams: `hex_map_feature` serves both the
wall stream and the decoration stream, because a vertex's first texture coordinate
already says which of the seven parts it belongs to
(`0` wall, `1` tower, `2` bridge, `3` urban, `4` farm, `5` plant, `6` special).
The terrain fragment stage decodes three terrain layers from the two available
texture coordinates (`u = layerA + layerB*8 + layerC*64`,
`v = weightB + weightC*16`); `HexMapMesh.h` documents that encoding as a
compatibility bridge, not as the terrain data format. The fog stage reads its
single coordinate as the shade (`0` explored-but-unseen, `1` never explored).
## Not ported yet
Waterfalls and estuaries, the reference's wall-tower *prefab* proportions, and
the fog overlay's per-cell visibility blend (it is a two-shade
remembered/unseen pair). The map generator's erosion takes the lowest cell from
the shared search frontier where the reference erodes a uniformly random cell,
and its temperature jitter uses a private value noise instead of the reference's
noise texture sample. `docs/usr/modules/hexmap.md` lists the same boundary from
the module side.
`hex-map.png` is captured by the example itself after 90 frames.
+9
View File
@@ -0,0 +1,9 @@
// examples/hex-terrain-3d -- interactive hex map window configuration.
// No `modules` key: the example runs against the full default module set.
config = {
width = 1280
height = 800
title = "EVEngine Hex Map"
debug = false
hotReload = true
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,118 @@
#version 450
// Structures and decorations of the hex map: city/farm walls, wall towers,
// bridges and the urban / farm / plant / special decorations.
//
// Two mesh streams feed this shader (`HexSurface::Wall` and
// `HexSurface::Feature`); the mesh builders encode which part a vertex belongs
// to in the first texture coordinate:
//
// 0 wall 1 tower 2 bridge
// 3 urban 4 farm 5 plant 6 special
//
// Because the parts are drawn with different materials but share one pipeline,
// the shader picks its albedo/roughness from that code rather than from a
// texture, which keeps the example asset-free.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
void main() {
// Codes are written as integer-valued floats; a half step snaps them back.
int part = int(floor(vUV.x + 0.5));
vec3 albedo;
float roughness;
if (part == 0) {
// Wall: dressed stone with a coursing pattern so the thin strips read.
float course = step(0.5, fract(vWorldPos.y * 0.35));
albedo = mix(vec3(0.46, 0.44, 0.40), vec3(0.58, 0.56, 0.51), course);
roughness = 0.78;
} else if (part == 1) {
// Tower: slightly lighter stone, keeps the silhouette distinct.
albedo = vec3(0.63, 0.61, 0.56);
roughness = 0.70;
} else if (part == 2) {
// Bridge: timber planks across the channel.
float plank = step(0.5, fract(vUV.y * 8.0));
albedo = mix(vec3(0.36, 0.25, 0.15), vec3(0.46, 0.33, 0.20), plank);
roughness = 0.85;
} else if (part == 3) {
// Urban: plastered walls and a tiled roof, split by height.
albedo = vWorldPos.y > 0.0 ? vec3(0.72, 0.68, 0.60) : vec3(0.55, 0.30, 0.24);
albedo = mix(vec3(0.55, 0.30, 0.24), vec3(0.78, 0.74, 0.66),
step(0.5, fract(vWorldPos.y * 0.5 + 0.5)));
roughness = 0.72;
} else if (part == 4) {
// Farm: tilled rows.
float row = noise(vWorldPos.xz * 0.9);
albedo = mix(vec3(0.52, 0.42, 0.22), vec3(0.66, 0.55, 0.30), row);
roughness = 0.92;
} else if (part == 5) {
// Plant: foliage, darker at the base.
float shade = clamp(vNormal.y * 0.4 + 0.6, 0.0, 1.0);
albedo = mix(vec3(0.14, 0.30, 0.11), vec3(0.30, 0.52, 0.18), shade);
roughness = 0.88;
} else {
// Special: pale monument stone.
albedo = mix(vec3(0.62, 0.60, 0.55), vec3(0.80, 0.79, 0.75),
step(0.5, fract(vWorldPos.y * 0.28)));
roughness = 0.60;
}
vec3 N = normalize(vNormal);
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndl = max(dot(N, L), 0.0);
float ndv = max(dot(N, V), 0.001);
float ndh = max(dot(N, H), 0.0);
float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha;
float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001);
float k = pow(roughness + 1.0, 2.0) / 8.0;
float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k));
vec3 F0 = vec3(0.04);
vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001);
vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 2.2;
float sky = max(0.0, N.y) * 0.18 + 0.10;
vec3 color = direct + albedo * (ubo.ambient.rgb * 0.60 + vec3(0.10, 0.12, 0.15) * sky);
float cloudField = noise(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * ubo.cloud.z * 0.012);
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
color = color / (1.0 + color * 0.72);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
@@ -0,0 +1,71 @@
#version 450
// Fog-of-war overlay of the hex map.
//
// The mesh builder (HexFogMesh.cpp) covers every cell that is not currently
// fully visible with a hexagonal column and encodes the shade in the first
// texture coordinate:
// uv.x = 0 -> explored before, but no viewer sees it right now (dim)
// uv.x = 1 -> never explored (opaque black)
//
// The shader outputs final display colours directly: the overlay is an
// unlit UI-like surface, so the lit-surface tone mapping of the terrain shader
// would darken it a second time.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
return s;
}
void main() {
float unknown = step(0.5, vUV.x);
// The renderer tone-maps and gamma-encodes this surface like any other, so
// these are *linear* values: a mid grey-blue for ground that was explored but
// is not watched right now, and near black for ground never seen at all.
vec3 seen = vec3(0.10, 0.13, 0.20);
vec3 unseen = vec3(0.015, 0.02, 0.04);
vec3 color = mix(seen, unseen, unknown);
// Large-scale mottling keeps the flat column tops from reading as plastic.
float mottle = fbm(vWorldPos.xz * 0.06);
color *= 0.86 + 0.28 * mottle * (1.0 - unknown * 0.6);
// A faint lift on the vertical walls so the fog column has a silhouette.
float wall = 1.0 - abs(normalize(vNormal).y);
color *= 1.0 - wall * 0.22;
float cloudField = fbm(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * ubo.cloud.z * 0.012);
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
Binary file not shown.
@@ -0,0 +1,69 @@
#version 450
// River channel of the hex map. uv.x runs along the channel, uv.y across it.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
void main() {
float time = ubo.cloud.z;
float along = vUV.x * 6.0;
float across = clamp(vUV.y, 0.0, 1.0);
vec2 p = vWorldPos.xz;
// Water flows downstream: ripples travel with +uv.x.
float ripple =
sin((p.x * 0.55 + p.y * 0.35) - time * 2.6) * 0.45 +
sin((p.x * 1.30 - p.y * 0.90) - time * 4.1) * 0.25 +
noise(p * 1.9 - vec2(time * 0.9, 0.0)) * 0.35;
float bank = smoothstep(0.0, 0.35, across) * smoothstep(1.0, 0.65, across);
vec3 N = normalize(vec3(dFdx(ripple), 1.0, dFdy(ripple)) * 1.8 + vNormal);
N = normalize(mix(vNormal, N, 0.35 + 0.55 * bank));
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndv = max(dot(N, V), 0.001);
vec3 albedo = mix(vec3(0.05, 0.20, 0.26), vec3(0.10, 0.40, 0.44), bank);
float fresnel = 0.03 + 0.97 * pow(1.0 - ndv, 4.0);
vec3 color = mix(albedo, vec3(0.32, 0.52, 0.62), fresnel * 0.55);
color += ubo.lightColor.rgb * pow(max(dot(N, H), 0.0), 72.0) * 0.70;
color += albedo * ubo.ambient.rgb;
// Wet banks darken and desaturate towards the channel edges.
color *= mix(0.72, 1.10, bank);
color += vec3(0.55, 0.72, 0.70) * pow(max(dot(N, H), 0.0), 28.0) * (1.0 - bank) * 0.35;
color = 1.0 - exp(-color * 1.3);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
@@ -0,0 +1,58 @@
#version 450
// Road ribbon of the hex map. uv.x runs along the road, uv.y across it.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
void main() {
float time = ubo.cloud.z;
float across = clamp(vUV.y, 0.0, 1.0);
vec2 p = vWorldPos.xz;
float grain = noise(p * 3.4);
// Two wheel ruts with a lighter crown, like the reference road material.
float ruts = smoothstep(0.10, 0.22, abs(across - 0.28)) * smoothstep(0.10, 0.22, abs(across - 0.72));
vec3 albedo = mix(vec3(0.30, 0.25, 0.18), vec3(0.46, 0.40, 0.31), grain);
albedo = mix(albedo * 0.72, albedo, ruts);
albedo *= smoothstep(0.0, 0.14, across) * smoothstep(1.0, 0.86, across) * 0.35 + 0.65;
vec3 N = normalize(vNormal + vec3(dFdx(grain), 0.0, dFdy(grain)) * 0.10);
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
float ndl = max(dot(N, L), 0.0);
vec3 color = albedo * (ubo.ambient.rgb + ubo.lightColor.rgb * ndl * 1.35);
color *= mix(0.80, 1.0, clamp(N.y * 1.2 + 0.2, 0.0, 1.0));
float cloudField = noise(p * 0.02 + ubo.cloudWind.xy * time * 0.012);
color *= 1.0 - ubo.cloud.x * smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField) * 0.30;
color = 1.0 - exp(-color * 1.25);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
@@ -0,0 +1,121 @@
#version 450
// Terrain surface of the hex map.
//
// The mesh builder packs three terrain layers into the two available texture
// coordinates (see HexMapMesh.h):
// uv.x = layerA + layerB * 8 + layerC * 64
// uv.y = weightB + weightC * 16
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
return s;
}
// Unity-parity palette: 0 Sand, 1 Grass, 2 Mud, 3 Stone, 4 Snow.
vec3 terrainAlbedo(int id, vec2 p, float grain, float patchiness) {
if (id == 0) return mix(vec3(0.74, 0.66, 0.42), vec3(0.88, 0.80, 0.55), grain);
if (id == 1) return mix(vec3(0.24, 0.45, 0.14), vec3(0.44, 0.62, 0.22), grain * 0.7 + patchiness * 0.3);
if (id == 2) return mix(vec3(0.31, 0.23, 0.14), vec3(0.45, 0.35, 0.22), grain);
if (id == 3) return mix(vec3(0.36, 0.36, 0.37), vec3(0.62, 0.61, 0.58), grain);
return mix(vec3(0.80, 0.86, 0.92), vec3(0.98, 0.99, 1.00), grain * 0.6 + 0.35);
}
float terrainRoughness(int id) {
if (id == 0) return 0.92;
if (id == 1) return 0.88;
if (id == 2) return 0.74;
if (id == 3) return 0.68;
return 0.42;
}
void main() {
float packedIndices = vUV.x;
float packedWeights = vUV.y;
int layerA = int(mod(packedIndices, 8.0) + 0.5);
int layerB = int(mod(floor(packedIndices / 8.0), 8.0) + 0.5);
int layerC = int(floor(packedIndices / 64.0) + 0.5);
float weightB = fract(packedWeights);
float weightC = floor(packedWeights) / 16.0;
float weightA = max(0.0, 1.0 - weightB - weightC);
float time = ubo.cloud.z;
vec2 p = vWorldPos.xz * 0.55;
float grain = noise(p * 9.0);
float patchiness = fbm(p * 1.6);
vec3 albedo = terrainAlbedo(layerA, p, grain, patchiness) * weightA
+ terrainAlbedo(layerB, p + 11.3, grain, patchiness) * weightB
+ terrainAlbedo(layerC, p + 23.9, grain, patchiness) * weightC;
float roughness = terrainRoughness(layerA) * weightA
+ terrainRoughness(layerB) * weightB
+ terrainRoughness(layerC) * weightC;
// Snow line and rock reach on high ground, plus large-scale colour breakup.
float altitude = vWorldPos.y;
float snow = smoothstep(15.0, 21.0, altitude + fbm(p * 2.3) * 2.2);
float rock = smoothstep(0.55, 0.95, 1.0 - abs(vNormal.y));
albedo = mix(albedo, vec3(0.42, 0.40, 0.37), rock * 0.55);
albedo = mix(albedo, vec3(0.94, 0.96, 0.99), snow * 0.85);
roughness = mix(roughness, 0.38, snow * 0.7);
float detail = fbm(p * 6.0);
vec3 N = normalize(vNormal + vec3(dFdx(detail), 0.0, dFdy(detail)) * 0.22);
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndl = max(dot(N, L), 0.0);
float ndv = max(dot(N, V), 0.001);
float ndh = max(dot(N, H), 0.0);
float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha;
float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001);
float k = pow(roughness + 1.0, 2.0) / 8.0;
float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k));
vec3 F0 = vec3(0.04);
vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001);
vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 2.6;
float sky = max(0.0, N.y) * 0.16 + 0.06;
vec3 color = direct + albedo * (ubo.ambient.rgb * 0.55 + vec3(0.10, 0.12, 0.15) * sky);
// Slope shading so cliffs read as rock even without a texture.
color *= mix(0.72, 1.0, clamp(N.y * 1.35 + 0.20, 0.0, 1.0));
float cloudField = fbm(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * time * 0.012);
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
// Reinhard keeps mid-tones saturated instead of clipping to white.
color = color / (1.0 + color * 0.72);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
@@ -0,0 +1,82 @@
#version 450
// Water surface of the hex map. uv.x is the shore parameter emitted by the
// water mesh builder: 0 in open water, approaching 1 on the shoreline band.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
void main() {
float time = ubo.cloud.z;
float shore = clamp(vUV.x, 0.0, 1.0);
vec2 p = vWorldPos.xz;
// Two crossing wave trains plus fine chop; the shoreline gets shorter waves.
float wave =
sin(p.x * 0.42 + time * 1.35) * 0.55 +
sin(p.y * 0.36 - time * 1.05) * 0.45 +
sin((p.x + p.y) * 0.85 + time * 2.10) * 0.28 +
noise(p * 0.9 + time * 0.25) * 0.35;
float slope = shore * 0.35 + 0.06;
vec3 N = normalize(vec3(dFdx(wave) * 1.6, 1.0, dFdy(wave) * 1.6) + vNormal * slope);
N = normalize(mix(N, vNormal, shore * 0.4));
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndl = max(dot(N, L), 0.0);
float ndv = max(dot(N, V), 0.001);
// Depth proxy: open water is deep, the shore band is shallow.
float depth = 1.0 - shore;
vec3 deep = vec3(0.012, 0.055, 0.105);
vec3 shallow = vec3(0.055, 0.30, 0.36);
vec3 albedo = mix(shallow, deep, smoothstep(0.15, 0.85, depth));
float fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
vec3 sky = mix(vec3(0.10, 0.20, 0.28), vec3(0.42, 0.62, 0.78), max(N.y, 0.0));
vec3 color = mix(albedo, sky, fresnel * 0.72);
color += ubo.lightColor.rgb * pow(max(dot(N, H), 0.0), 96.0) * 0.95;
color += albedo * ndl * ubo.lightColor.rgb * 0.35;
color += albedo * ubo.ambient.rgb;
// Foam rides the shoreline band and breaks up with noise.
float foamNoise = noise(p * 1.35 + vec2(time * 0.20, -time * 0.16));
float foam = smoothstep(0.55, 0.98, shore) * smoothstep(0.30, 0.75, foamNoise + shore * 0.35);
foam += smoothstep(0.90, 1.0, shore) * 0.55;
color = mix(color, vec3(0.90, 0.96, 0.98), clamp(foam, 0.0, 0.85));
float cloudField = noise(p * 0.02 + ubo.cloudWind.xy * time * 0.012);
color *= 1.0 - ubo.cloud.x * smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField) * 0.30;
color = 1.0 - exp(-color * 1.35);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
# Capture a reproducible frame of the interactive hex map example.
#
# The example captures its own frame with the engine-owned `gfx.saveFramePng`
# path (readback inside the renderer) instead of grabbing the X server, so this
# wrapper only needs to start it headlessly and collect the result.
#
# This is the capture helper for `examples/hex-terrain-3d` (the `hexmap` module's
# interactive example). `scripts/capture_hex_terrain.sh` is the unrelated one for
# the `mesh.hexterrain` procgen example in `examples/hex-terrain`.
#
# Usage:
# scripts/capture_hex_terrain_3d.sh [output.png]
#
# Environment:
# HEX_TERRAIN_3D_CAPTURE_DELAY seconds to let the example run before collecting
# the captured frame (default 8)
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
game_dir="$repo_dir/examples/hex-terrain-3d"
default_output="$game_dir/hex-map.png"
output=${1:-"$default_output"}
if [ "${HEX_TERRAIN_3D_CAPTURE_INSIDE_XVFB:-0}" != 1 ]; then
HEX_TERRAIN_3D_CAPTURE_INSIDE_XVFB=1 exec xvfb-run -a \
--server-args="-screen 0 800x600x24" "$0" "$output"
fi
export ALSOFT_DRIVERS=null
export VK_ICD_FILENAMES=${VK_ICD_FILENAMES:-/usr/share/vulkan/icd.d/lvp_icd.json}
if [ -x "$repo_dir/build/linux-debug/src/engine/eve" ]; then
eve_bin="$repo_dir/build/linux-debug/src/engine/eve"
elif [ -x "$repo_dir/build/win32-debug/src/engine/eve.exe" ]; then
eve_bin="$repo_dir/build/win32-debug/src/engine/eve.exe"
else
echo "capture_hex_terrain_3d: no engine binary found (build linux-debug or win32-debug)" >&2
exit 1
fi
rm -f "$default_output"
cd "$game_dir"
"$eve_bin" run > /tmp/hexmap-3d-capture.log 2>&1 &
engine_pid=$!
cleanup() {
kill -TERM "$engine_pid" 2>/dev/null || true
sleep 1
kill -KILL "$engine_pid" 2>/dev/null || true
wait "$engine_pid" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
sleep "${HEX_TERRAIN_3D_CAPTURE_DELAY:-8}"
if [ ! -f "$default_output" ]; then
echo "capture_hex_terrain_3d: the example did not capture a frame; see /tmp/hexmap-3d-capture.log" >&2
exit 1
fi
if [ "$output" != "$default_output" ]; then
cp "$default_output" "$output"
fi
echo "capture_hex_terrain_3d: wrote $output"
+220
View File
@@ -0,0 +1,220 @@
#pragma once
/** @file HexCell.h @brief Packed per-cell state records of the hex map. */
#include "hexmap/HexMetrics.h"
#include <cstdint>
namespace eve::hexmap {
/** @brief Terrain palette index of a hex cell. */
enum class HexTerrainType : std::int32_t {
Sand = 0,
Grass = 1,
Mud = 2,
Stone = 3,
Snow = 4,
};
/** @brief Number of entries in the terrain palette. */
inline constexpr std::int32_t kHexTerrainTypeCount = 5;
/** @brief Terrain type index clamped into the supported palette range. */
[[nodiscard]] constexpr std::int32_t clampTerrainType(std::int32_t index) noexcept {
return index < 0 ? 0 : (index >= kHexTerrainTypeCount ? kHexTerrainTypeCount - 1 : index);
}
/**
* @brief Packed numeric cell state.
*
* Bit layout (most significant first): `TTTTTTTT SSSSSSSS PPFFUUWW WWWEEEEE`
* where `T` is the terrain type, `S` the special-feature index, `P` the plant
* level, `F` the farm level, `U` the urban level, `W` the water level and `E`
* the elevation (stored biased by 15 so five bits cover `[-15, 16]`).
*
* @note This is a transient in-memory record; it is not a persistence format.
*/
class HexValues {
public:
constexpr HexValues() noexcept = default;
explicit constexpr HexValues(std::uint32_t packed) noexcept : packed_(packed) {}
[[nodiscard]] constexpr std::uint32_t raw() const noexcept { return packed_; }
[[nodiscard]] constexpr std::int32_t elevation() const noexcept {
return static_cast<std::int32_t>(get(31u, 0u)) - 15;
}
[[nodiscard]] constexpr std::int32_t waterLevel() const noexcept { return static_cast<std::int32_t>(get(31u, 5u)); }
[[nodiscard]] constexpr std::int32_t urbanLevel() const noexcept { return static_cast<std::int32_t>(get(3u, 10u)); }
[[nodiscard]] constexpr std::int32_t farmLevel() const noexcept { return static_cast<std::int32_t>(get(3u, 12u)); }
[[nodiscard]] constexpr std::int32_t plantLevel() const noexcept { return static_cast<std::int32_t>(get(3u, 14u)); }
[[nodiscard]] constexpr std::int32_t specialIndex() const noexcept {
return static_cast<std::int32_t>(get(255u, 16u));
}
[[nodiscard]] constexpr std::int32_t terrainType() const noexcept {
return static_cast<std::int32_t>(get(255u, 24u));
}
/** @brief Elevation used for line of sight: the higher of land and water. */
[[nodiscard]] constexpr std::int32_t viewElevation() const noexcept {
return elevation() > waterLevel() ? elevation() : waterLevel();
}
/** @brief Whether the cell is flooded above its terrain surface. */
[[nodiscard]] constexpr bool isUnderwater() const noexcept { return waterLevel() > elevation(); }
[[nodiscard]] constexpr HexValues withElevation(std::int32_t value) const noexcept {
return HexValues{with(static_cast<std::uint32_t>(value + 15) & 31u, 31u, 0u)};
}
[[nodiscard]] constexpr HexValues withWaterLevel(std::int32_t value) const noexcept {
return HexValues{with(static_cast<std::uint32_t>(value) & 31u, 31u, 5u)};
}
[[nodiscard]] constexpr HexValues withUrbanLevel(std::int32_t value) const noexcept {
return HexValues{with(static_cast<std::uint32_t>(value) & 3u, 3u, 10u)};
}
[[nodiscard]] constexpr HexValues withFarmLevel(std::int32_t value) const noexcept {
return HexValues{with(static_cast<std::uint32_t>(value) & 3u, 3u, 12u)};
}
[[nodiscard]] constexpr HexValues withPlantLevel(std::int32_t value) const noexcept {
return HexValues{with(static_cast<std::uint32_t>(value) & 3u, 3u, 14u)};
}
[[nodiscard]] constexpr HexValues withSpecialIndex(std::int32_t value) const noexcept {
return HexValues{with(static_cast<std::uint32_t>(value) & 255u, 255u, 16u)};
}
[[nodiscard]] constexpr HexValues withTerrainType(std::int32_t value) const noexcept {
return HexValues{with(static_cast<std::uint32_t>(value) & 255u, 255u, 24u)};
}
private:
[[nodiscard]] constexpr std::uint32_t get(std::uint32_t mask, std::uint32_t shift) const noexcept {
return (packed_ >> shift) & mask;
}
[[nodiscard]] constexpr std::uint32_t with(std::uint32_t value, std::uint32_t mask,
std::uint32_t shift) const noexcept {
return (packed_ & ~(mask << shift)) | (value << shift);
}
std::uint32_t packed_ = 0;
};
/**
* @brief Packed boolean cell state (roads, rivers, walls, exploration).
*
* Bits 0-5 are roads per direction, 6-11 incoming rivers, 12-17 outgoing
* rivers, 18 walled, 20 explored and 21 explorable. Direction bit order matches
* `HexDirection`, so the direction helpers are plain shifts.
*/
class HexFlags {
public:
constexpr HexFlags() noexcept = default;
explicit constexpr HexFlags(std::uint32_t packed) noexcept : packed_(packed) {}
[[nodiscard]] constexpr std::uint32_t raw() const noexcept { return packed_; }
[[nodiscard]] static constexpr HexFlags direction(HexDirection d) noexcept {
return HexFlags{1u << static_cast<std::uint32_t>(d)};
}
[[nodiscard]] constexpr bool has(HexFlags other) const noexcept {
return (packed_ & other.packed_) == other.packed_ && other.packed_ != 0u;
}
[[nodiscard]] constexpr bool hasNone(HexFlags other) const noexcept { return (packed_ & other.packed_) == 0u; }
[[nodiscard]] constexpr HexFlags with(HexFlags other) const noexcept { return HexFlags{packed_ | other.packed_}; }
[[nodiscard]] constexpr HexFlags without(HexFlags other) const noexcept {
return HexFlags{packed_ & ~other.packed_};
}
[[nodiscard]] constexpr bool hasRoad(HexDirection d) const noexcept { return has(direction(d)); }
[[nodiscard]] constexpr bool hasRiverIn(HexDirection d) const noexcept {
return has(HexFlags{direction(d).packed_ << 6});
}
[[nodiscard]] constexpr bool hasRiverOut(HexDirection d) const noexcept {
return has(HexFlags{direction(d).packed_ << 12});
}
/** @brief Whether any river flows into this cell. */
[[nodiscard]] constexpr bool hasAnyRiverIn() const noexcept { return (packed_ & (0x3fu << 6)) != 0u; }
/** @brief Whether any river leaves this cell. */
[[nodiscard]] constexpr bool hasAnyRiverOut() const noexcept { return (packed_ & (0x3fu << 12)) != 0u; }
/** @brief Whether this cell has any river connection at all. */
[[nodiscard]] constexpr bool hasRiver() const noexcept { return hasAnyRiverIn() || hasAnyRiverOut(); }
/** @brief Whether this cell has any road. */
[[nodiscard]] constexpr bool hasRoad() const noexcept { return (packed_ & 0x3fu) != 0u; }
/** @brief Whether a river enters and/or leaves through `d`. */
[[nodiscard]] constexpr bool hasRiverThrough(HexDirection d) const noexcept {
return hasRiverIn(d) || hasRiverOut(d);
}
/** @brief Whether the cell is a river source or sink (exactly one connection). */
[[nodiscard]] constexpr bool hasRiverBeginOrEnd() const noexcept {
const std::uint32_t in = (packed_ >> 6) & 0x3fu;
const std::uint32_t out = (packed_ >> 12) & 0x3fu;
const auto bits = [](std::uint32_t v) {
std::uint32_t n = 0;
while (v) {
n += v & 1u;
v >>= 1;
}
return n;
};
return (bits(in) + bits(out)) == 1u;
}
[[nodiscard]] constexpr bool isWalled() const noexcept { return (packed_ & (1u << 18)) != 0u; }
[[nodiscard]] constexpr bool isExplored() const noexcept { return (packed_ & (1u << 20)) != 0u; }
[[nodiscard]] constexpr bool isExplorable() const noexcept { return (packed_ & (1u << 21)) != 0u; }
[[nodiscard]] constexpr HexFlags withRoad(HexDirection d) const noexcept { return with(direction(d)); }
[[nodiscard]] constexpr HexFlags withoutRoad(HexDirection d) const noexcept { return without(direction(d)); }
[[nodiscard]] constexpr HexFlags withRiverIn(HexDirection d) const noexcept {
return with(HexFlags{direction(d).packed_ << 6});
}
[[nodiscard]] constexpr HexFlags withoutRiverIn(HexDirection d) const noexcept {
return without(HexFlags{direction(d).packed_ << 6});
}
[[nodiscard]] constexpr HexFlags withRiverOut(HexDirection d) const noexcept {
return with(HexFlags{direction(d).packed_ << 12});
}
[[nodiscard]] constexpr HexFlags withoutRiverOut(HexDirection d) const noexcept {
return without(HexFlags{direction(d).packed_ << 12});
}
[[nodiscard]] constexpr HexFlags withWalled(bool value) const noexcept {
return value ? with(HexFlags{1u << 18}) : without(HexFlags{1u << 18});
}
[[nodiscard]] constexpr HexFlags withExplored(bool value) const noexcept {
return value ? with(HexFlags{1u << 20}) : without(HexFlags{1u << 20});
}
[[nodiscard]] constexpr HexFlags withExplorable(bool value) const noexcept {
return value ? with(HexFlags{1u << 21}) : without(HexFlags{1u << 21});
}
/** @brief Mask of every river bit. */
[[nodiscard]] static constexpr HexFlags riverMask() noexcept { return HexFlags{(0x3fu << 6) | (0x3fu << 12)}; }
/** @brief Mask of every road bit. */
[[nodiscard]] static constexpr HexFlags roadMask() noexcept { return HexFlags{0x3fu}; }
private:
std::uint32_t packed_ = 0;
};
/** @brief One cell of the hex map: packed values plus packed flags. */
struct HexCellData {
HexValues values{};
HexFlags flags{};
};
/**
* @brief Whether a cell record is in a state that can hold a unit.
*
* This is the single rule shared by pathfinding's destination test, the unit
* registry's restore path and the save-payload validator: a unit may stand on a
* cell that has been explored, may still be explored, and is not flooded.
* Occupancy by another unit is deliberately *not* part of this predicate,
* because a travelling unit reserves its own destination.
*
* @param cell Cell record to test.
* @return True when the cell can hold a unit.
*/
[[nodiscard]] constexpr bool canHoldUnit(const HexCellData& cell) noexcept {
return cell.flags.isExplored() && cell.flags.isExplorable() && !cell.values.isUnderwater();
}
} // namespace eve::hexmap
+31
View File
@@ -0,0 +1,31 @@
#include "hexmap/HexCoordinates.h"
namespace eve::hexmap {
HexCoordinates HexCoordinates::fromWorldPosition(HexVec3 position) noexcept {
const float diameter = HexMetrics::innerDiameter();
if (diameter <= 0.f) return HexCoordinates{};
const float fx = position.x / diameter;
const float fy = -fx;
const float offset = position.z / (HexMetrics::outerRadius() * 3.f);
const float ax = fx - offset;
const float ay = fy - offset;
std::int32_t ix = static_cast<std::int32_t>(std::lround(ax));
std::int32_t iy = static_cast<std::int32_t>(std::lround(ay));
std::int32_t iz = static_cast<std::int32_t>(std::lround(-ax - ay));
if (ix + iy + iz != 0) {
const float dx = std::fabs(ax - static_cast<float>(ix));
const float dy = std::fabs(ay - static_cast<float>(iy));
const float dz = std::fabs(-ax - ay - static_cast<float>(iz));
if (dx > dy && dx > dz)
ix = -iy - iz;
else if (dz > dy)
iz = -ix - iy;
}
return HexCoordinates{ix, iz};
}
} // namespace eve::hexmap
+124
View File
@@ -0,0 +1,124 @@
#pragma once
/** @file HexCoordinates.h @brief Axial hex coordinates and world-space conversion. */
#include "hexmap/HexMetrics.h"
#include <cmath>
#include <cstdint>
namespace eve::hexmap {
/**
* @brief Axial coordinates of one hex cell.
*
* The third cube component is derived: `y = -x - z`. The map uses the
* odd-row-offset-right layout of the reference hex-map project, so the axial
* pair is directly the `(column, row)` pair with even rows starting at column 0
* and odd rows shifted half a cell to the right.
*/
struct HexCoordinates {
std::int32_t x = 0;
std::int32_t z = 0;
constexpr HexCoordinates() noexcept = default;
constexpr HexCoordinates(std::int32_t xValue, std::int32_t zValue) noexcept : x(xValue), z(zValue) {}
/** @brief Cube Y component (`-x - z`). */
[[nodiscard]] constexpr std::int32_t y() const noexcept { return -x - z; }
[[nodiscard]] constexpr bool operator==(const HexCoordinates& other) const noexcept {
return x == other.x && z == other.z;
}
[[nodiscard]] constexpr bool operator!=(const HexCoordinates& other) const noexcept { return !(*this == other); }
/**
* @brief Returns the neighbouring coordinates one step in `direction`.
* @note The row offset is implicit in the axial pair, so stepping is a pure
* integer offset and never needs the odd-row correction.
*/
[[nodiscard]] constexpr HexCoordinates step(HexDirection direction, std::int32_t distance = 1) const noexcept {
switch (direction) {
case HexDirection::NE: return HexCoordinates{x, z + distance};
case HexDirection::E: return HexCoordinates{x + distance, z};
case HexDirection::SE: return HexCoordinates{x + distance, z - distance};
case HexDirection::SW: return HexCoordinates{x, z - distance};
case HexDirection::W: return HexCoordinates{x - distance, z};
case HexDirection::NW: return HexCoordinates{x - distance, z + distance};
}
return *this;
}
/** @brief Cube distance to another cell (wrapping is not supported). */
[[nodiscard]] constexpr std::int32_t distanceTo(const HexCoordinates& other) const noexcept {
const std::int32_t dx = x - other.x;
const std::int32_t dy = y() - other.y();
const std::int32_t dz = z - other.z;
const auto absi = [](std::int32_t v) { return v < 0 ? -v : v; };
return (absi(dx) + absi(dy) + absi(dz)) / 2;
}
/** @brief Hex-space X: east-west neighbour spacing is 1. */
[[nodiscard]] constexpr float hexX() const noexcept {
const std::int32_t half = floorDiv2(z);
return static_cast<float>(x + half) + ((z & 1) == 0 ? 0.f : 0.5f);
}
/** @brief Hex-space Z: row spacing is `outerToInner`. */
[[nodiscard]] constexpr float hexZ() const noexcept { return static_cast<float>(z) * HexMetrics::kOuterToInner; }
/** @brief Chunk column this cell belongs to. */
[[nodiscard]] constexpr std::int32_t chunkColumn() const noexcept {
return (x + floorDiv2(z)) / HexMetrics::kChunkSizeX;
}
/** @brief Chunk row this cell belongs to. */
[[nodiscard]] constexpr std::int32_t chunkRow() const noexcept { return z / HexMetrics::kChunkSizeZ; }
/** @brief Converts an odd-row offset pair into axial coordinates. */
[[nodiscard]] static constexpr HexCoordinates fromOffset(std::int32_t offsetX, std::int32_t offsetZ) noexcept {
return HexCoordinates{offsetX - floorDiv2(offsetZ), offsetZ};
}
/** @brief Offset X of this cell inside the rectangular grid. */
[[nodiscard]] constexpr std::int32_t offsetX() const noexcept { return x + floorDiv2(z); }
/** @brief Offset Z (row) of this cell inside the rectangular grid. */
[[nodiscard]] constexpr std::int32_t offsetZ() const noexcept { return z; }
/**
* @brief World-space centre of a cell before elevation and perturbation.
*
* The odd-row offset of an axial pair is `x + z/2`, so the reference layout
* `(offsetX + (z odd ? 0.5 : 0)) * innerDiameter` collapses to
* `(x + 0.5 * z) * innerDiameter` and the result is the exact inverse of
* `fromWorldPosition`.
*
* @param coordinates Cell coordinates.
* @return Centre position on the XZ plane; Y is always 0.
*/
[[nodiscard]] static HexVec3 toWorldPosition(HexCoordinates coordinates) noexcept {
const float diameter = HexMetrics::innerDiameter();
const float fx = static_cast<float>(coordinates.x) + 0.5f * static_cast<float>(coordinates.z);
return HexVec3{fx * diameter, 0.f, static_cast<float>(coordinates.z) * HexMetrics::rowSpacing()};
}
/**
* @brief Converts a world-space XZ position back to cell coordinates.
*
* Uses the cube-rounding correction so the result is the hex containing the
* sample point rather than its nearest centre.
*
* @param position World position; only X and Z are read.
* @return The containing cell coordinates (may be outside the grid bounds).
*/
[[nodiscard]] static HexCoordinates fromWorldPosition(HexVec3 position) noexcept;
private:
/** @brief Truncating-toward-negative-infinity halving, matching floor division. */
[[nodiscard]] static constexpr std::int32_t floorDiv2(std::int32_t value) noexcept {
return value >= 0 ? value / 2 : -(((-value) + 1) / 2);
}
};
} // namespace eve::hexmap
+652
View File
@@ -0,0 +1,652 @@
/**
* @file HexFeatures.cpp
* @brief Walls, towers, bridges and decorations of one hex chunk.
*/
#include "hexmap/HexFeatures.h"
#include "hexmap/HexCell.h"
#include "hexmap/HexCoordinates.h"
#include "hexmap/HexMap.h"
#include "hexmap/HexMeshData.h"
#include "hexmap/HexMetrics.h"
#include "hexmap/HexNoise.h"
#include <cmath>
#include <cstdint>
namespace eve::hexmap {
namespace {
/**
* @brief Perturbation contract of this translation unit.
*
* `HexMap::cellPosition()` already carries the vertical noise term, so every
* position derived from a cell centre takes only the horizontal part of the
* perturbation, the same split the terrain builder uses. Feature positions are
* perturbed exactly once, inside the geometry emitters, so no helper here
* displaces a position on its own.
*/
[[nodiscard]] HexVec3 horizontalPerturb(const HexNoise& noise, HexVec3 position) noexcept {
const HexVec4 sample = noise.sample(position.x, position.z);
position.x += (sample.x * 2.f - 1.f) * HexMetrics::kCellPerturbStrength;
position.z += (sample.z * 2.f - 1.f) * HexMetrics::kCellPerturbStrength;
return position;
}
/** @brief Dot product of two hex vectors. */
[[nodiscard]] float dot(HexVec3 a, HexVec3 b) noexcept { return a.x * b.x + a.y * b.y + a.z * b.z; }
/** @brief Cross product of two hex vectors. */
[[nodiscard]] HexVec3 cross(HexVec3 a, HexVec3 b) noexcept {
return HexVec3{a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x};
}
/** @brief Unit-length `v`, or the zero vector when `v` is degenerate. */
[[nodiscard]] HexVec3 normalize(HexVec3 v) noexcept {
const float length = std::sqrt(dot(v, v));
if (length <= 1e-6f) return HexVec3{};
return HexVec3{v.x / length, v.y / length, v.z / length};
}
/** @brief Whether a vector is the zero vector. */
[[nodiscard]] bool isZero(HexVec3 v) noexcept { return v.x == 0.f && v.y == 0.f && v.z == 0.f; }
/**
* @brief Rotates a horizontal vector about the world up axis.
*
* @param orientation Hash component in `[0, 1)`; the full turn is applied here,
* matching the reference's `360 degrees * hash`.
* @param v Vector to rotate; its Y component is preserved.
*/
[[nodiscard]] HexVec3 yaw(float orientation, HexVec3 v) noexcept {
const float angle = orientation * 6.28318530718f;
const float cosine = std::cos(angle);
const float sine = std::sin(angle);
return HexVec3{v.x * cosine - v.z * sine, v.y, v.x * sine + v.z * cosine};
}
/** @brief Texture code of a plain wall segment. */
constexpr float kWallCode = 0.f;
/** @brief Texture code of a wall tower. */
constexpr float kTowerCode = 1.f;
/** @brief Texture code of a bridge. */
constexpr float kBridgeCode = 2.f;
/** @brief Texture code of an urban decoration. */
constexpr float kUrbanCode = 3.f;
/** @brief Texture code of a farm decoration. */
constexpr float kFarmCode = 4.f;
/** @brief Texture code of a plant decoration. */
constexpr float kPlantCode = 5.f;
/** @brief Texture code of a special decoration. */
constexpr float kSpecialCode = 6.f;
/** @brief Half-width of a bridge slab relative to a cell's inner radius. */
constexpr float kBridgeHalfWidth = 0.25f;
/** @brief Half-thickness of a bridge slab relative to the wall height. */
constexpr float kBridgeHalfThickness = 0.15f;
/** @brief Half-extent of a wall tower relative to the wall thickness. */
constexpr float kTowerHalfExtent = 1.5f;
/** @brief Height of a wall tower relative to the wall thickness. */
constexpr float kTowerHeight = 2.f;
/** @brief Emits a triangle whose three vertices share one texture code. */
void emitTriangle(HexMeshData& out, const HexNoise& noise, const HexVec3& p0, const HexVec3& p1, const HexVec3& p2,
float code) {
const auto i0 = static_cast<std::uint32_t>(out.vertexCount());
out.addVertex(horizontalPerturb(noise, p0), code, 0.f);
out.addVertex(horizontalPerturb(noise, p1), code, 0.f);
out.addVertex(horizontalPerturb(noise, p2), code, 0.f);
out.addTriangle(i0, i0 + 1u, i0 + 2u);
}
/**
* @brief Emits a quad from an edge grid: corner, then the two edge vectors.
*
* The emitted ring is `c`, `c + e1`, `c + e1 + e2`, `c + e2`. Both emitted
* triangles then face the direction of `cross(e1, e2)`, so callers order the
* two edge vectors such that their cross product points outwards. Passing the
* corner as the far end of the edges instead flips both triangles, and swapping
* the two edges flips them the other way.
*/
void emitEdgeQuad(HexMeshData& out, const HexNoise& noise, const HexVec3& corner, const HexVec3& e1, const HexVec3& e2,
float code) {
const auto i0 = static_cast<std::uint32_t>(out.vertexCount());
out.addVertex(horizontalPerturb(noise, corner), code, 0.f);
out.addVertex(horizontalPerturb(noise, corner + e1), code, 0.f);
out.addVertex(horizontalPerturb(noise, corner + e1 + e2), code, 0.f);
out.addVertex(horizontalPerturb(noise, corner + e2), code, 0.f);
out.addTriangle(i0 + 2u, i0 + 1u, i0);
out.addTriangle(i0 + 3u, i0 + 2u, i0);
}
/** @brief Emits one face of an axis-aligned box centred on `centre`. */
void emitBoxFace(HexMeshData& out, const HexNoise& noise, HexVec3 centre, HexVec3 e1, HexVec3 e2, float code) {
const HexVec3 corner = centre - e1 * 0.5f - e2 * 0.5f;
emitEdgeQuad(out, noise, corner, e1, e2, code);
}
/**
* @brief Emits a closed box centred on `origin`.
*
* @param right First unit axis.
* @param up Second unit axis; must be orthogonal to `right`.
* @param fwd Third unit axis; must be `cross(right, up)` so the box closes.
* @param halfExtents Half-size along `right`, `up` and `fwd`.
* @param code Texture code written into every vertex.
*/
void emitBox(HexMeshData& out, const HexNoise& noise, HexVec3 origin, HexVec3 right, HexVec3 up, HexVec3 fwd,
HexVec3 halfExtents, float code) {
const HexVec3 r = right * (2.f * halfExtents.x);
const HexVec3 u = up * (2.f * halfExtents.y);
const HexVec3 f = fwd * (2.f * halfExtents.z);
// `r`, `u` and `f` are the box's full edge vectors, so each face's two edge
// arguments below are complete edges of that face.
emitBoxFace(out, noise, origin + r * 0.5f, u, f * -1.f, code);
emitBoxFace(out, noise, origin - r * 0.5f, u, f, code);
emitBoxFace(out, noise, origin + u * 0.5f, r, f, code);
emitBoxFace(out, noise, origin - u * 0.5f, r, f * -1.f, code);
emitBoxFace(out, noise, origin - f * 0.5f, r, u, code);
emitBoxFace(out, noise, origin + f * 0.5f, r, u * -1.f, code);
}
/** @brief Emits the walls, wall towers and bridges of one chunk. */
class WallMesher {
public:
WallMesher(const HexMap& map, HexMeshData& out) noexcept : map_(map), out_(out) {}
/** @brief Builds every cell of `chunkIndex`. */
void run(std::int32_t chunkIndex) {
for (std::int32_t row = 0; row < HexMetrics::kChunkSizeZ; ++row) {
for (std::int32_t column = 0; column < HexMetrics::kChunkSizeX; ++column) {
buildCell(map_.chunkCell(chunkIndex, column, row));
}
}
}
private:
/** @brief Emits the walls of the edges this chunk owns plus the cell's corners. */
void buildCell(HexCoordinates coordinates) {
if (!map_.contains(coordinates)) return;
const HexCellData* cellData = map_.cell(coordinates);
if (cellData == nullptr) return;
const HexVec3 center = map_.cellPosition(coordinates);
for (std::int32_t index = 0; index < kHexDirectionCount; ++index) {
const auto direction = static_cast<HexDirection>(index);
HexCoordinates neighbour{};
const bool hasNeighbour = map_.getNeighbor(coordinates, direction, neighbour);
const HexCellData* neighbourData = hasNeighbour ? map_.cell(neighbour) : nullptr;
const HexVec3 neighbourPosition = hasNeighbour ? map_.cellPosition(neighbour) : HexVec3{};
const bool isOwnDirection =
static_cast<std::int32_t>(direction) <= static_cast<std::int32_t>(HexDirection::SE);
const EdgeVertices near = solidEdge(center, direction);
const float farY = neighbourData != nullptr ? neighbourPosition.y : near.v1.y;
const EdgeVertices far = shiftedEdge(center, direction, farY);
// Chunk ownership: a wall is emitted for the NE, E and SE edges of a
// cell, or when the neighbour is outside the grid. Every interior
// edge is the NE/E/SE edge of exactly one of its two cells, so no
// wall is generated twice.
if (isOwnDirection || neighbourData == nullptr) {
const bool hasRiver = map_.hasRiverThrough(coordinates, direction) ||
(hasNeighbour && map_.hasRiverThrough(neighbour, opposite(direction)));
const bool hasRoad = cellData->flags.hasRoad(direction) ||
(hasNeighbour && neighbourData->flags.hasRoad(opposite(direction)));
addWall(near, cellData, far, neighbourData, hasRiver, hasRoad);
if (neighbourData != nullptr && cellData->flags.hasRoad(direction) && hasRiver) {
addBridge(center, neighbourPosition);
}
}
if (!isOwnDirection) continue;
HexCoordinates nextCoordinates{};
if (!map_.getNeighbor(coordinates, next(direction), nextCoordinates)) continue;
const HexCellData* nextCell = map_.cell(nextCoordinates);
if (nextCell == nullptr) continue;
HexVec3 left = near.v5 + HexMetrics::bridge(next(direction));
left.y = map_.cellPosition(nextCoordinates).y;
addWallCorner(near.v5, cellData, left, neighbourData, far.v5, nextCell);
}
}
/** @brief Emits the wall between two cell edges when only one side is walled. */
void addWall(const EdgeVertices& near, const HexCellData* nearCell, const EdgeVertices& far,
const HexCellData* farCell, bool hasRiver, bool hasRoad) {
if (farCell == nullptr) return;
if (nearCell->flags.isWalled() == farCell->flags.isWalled()) return;
if (nearCell->values.isUnderwater() || farCell->values.isUnderwater()) return;
if (edgeType(nearCell->values.elevation(), farCell->values.elevation()) == HexEdgeType::Cliff) return;
addWallSegment(near.v1, far.v1, near.v2, far.v2, false);
if (hasRiver || hasRoad) {
addWallCap(near.v2, far.v2);
addWallCap(far.v4, near.v4);
} else {
addWallSegment(near.v2, far.v2, near.v3, far.v3, false);
addWallSegment(near.v3, far.v3, near.v4, far.v4, false);
}
addWallSegment(near.v4, far.v4, near.v5, far.v5, false);
}
/** @brief One box-like wall segment; optionally carries a tower at its middle. */
void addWallSegment(HexVec3 nearLeft, HexVec3 farLeft, HexVec3 nearRight, HexVec3 farRight, bool addTower) {
const HexVec3 left = HexMetrics::wallLerp(nearLeft, farLeft);
const HexVec3 right = HexMetrics::wallLerp(nearRight, farRight);
const HexVec3 leftOffset = HexMetrics::wallThicknessOffset(nearLeft, farLeft);
const HexVec3 rightOffset = HexMetrics::wallThicknessOffset(nearRight, farRight);
const HexVec3 leftLow = left - leftOffset;
const HexVec3 rightLow = right - rightOffset;
HexVec3 leftTop = leftLow;
leftTop.y = left.y + HexMetrics::kWallHeight;
HexVec3 rightTop = rightLow;
rightTop.y = right.y + HexMetrics::kWallHeight;
emitEdgeQuad(out_, map_.noise(), leftLow, rightLow - leftLow, leftTop - leftLow, kWallCode);
const HexVec3 leftInnerLow = left + leftOffset;
const HexVec3 rightInnerLow = right + rightOffset;
HexVec3 leftInnerTop = leftInnerLow;
leftInnerTop.y = left.y + HexMetrics::kWallHeight;
HexVec3 rightInnerTop = rightInnerLow;
rightInnerTop.y = right.y + HexMetrics::kWallHeight;
emitEdgeQuad(out_, map_.noise(), rightInnerLow, leftInnerLow - rightInnerLow, rightInnerTop - rightInnerLow,
kWallCode);
emitEdgeQuad(out_, map_.noise(), leftTop, rightTop - leftTop, leftInnerTop - leftTop, kWallCode);
if (addTower) addTowerAt(left, right);
}
/** @brief Emits a short wide box straddling a wall segment. */
void addTowerAt(HexVec3 left, HexVec3 right) {
const HexVec3 along = right - left;
if (dot(along, along) <= 1e-8f) return;
const HexVec3 fwd = normalize(along);
const HexVec3 rightAxis = normalize(cross(fwd, HexVec3{0.f, 1.f, 0.f}));
if (isZero(rightAxis)) return;
const float halfWidth = HexMetrics::kWallThickness * kTowerHalfExtent;
const float halfHeight = HexMetrics::kWallThickness * kTowerHeight * 0.5f;
const HexVec3 halfExtents{halfWidth, halfHeight, halfWidth};
const HexVec3 centre = (left + right) * 0.5f + HexVec3{0.f, halfHeight, 0.f};
emitBox(out_, map_.noise(), centre, rightAxis, HexVec3{0.f, 1.f, 0.f}, fwd, halfExtents, kTowerCode);
}
/** @brief Closes a wall end with a half-thickness quad. */
void addWallCap(HexVec3 near, HexVec3 far) {
const HexVec3 center = HexMetrics::wallLerp(near, far);
const HexVec3 thickness = HexMetrics::wallThicknessOffset(near, far);
const HexVec3 lowA = center - thickness;
const HexVec3 lowB = center + thickness;
HexVec3 topA = lowA;
topA.y = center.y + HexMetrics::kWallHeight;
HexVec3 topB = lowB;
topB.y = center.y + HexMetrics::kWallHeight;
// The cap is the wall's cross-section: topA -> lowA -> lowB -> topB.
// `emitEdgeQuad` walks `c`, `c + e1`, `c + e1 + e2`, `c + e2`, so the
// second edge has to span the far side at wall height. Using the near
// side's offset instead (`lowB - topA`) would drag the fourth corner one
// wall height below the base and skew the cap.
emitEdgeQuad(out_, map_.noise(), topA, lowA - topA, topB - topA, kWallCode);
}
/** @brief Fills the triangular gap where a wall meets a higher cell. */
void addWallWedge(HexVec3 near, HexVec3 far, HexVec3 point) {
const HexVec3 center = HexMetrics::wallLerp(near, far);
const HexVec3 thickness = HexMetrics::wallThicknessOffset(near, far);
HexVec3 flatPoint = point;
flatPoint.y = center.y;
HexVec3 topPoint = flatPoint;
topPoint.y = center.y + HexMetrics::kWallHeight;
const HexVec3 lowA = center - thickness;
const HexVec3 lowB = center + thickness;
HexVec3 topA = lowA;
topA.y = topPoint.y;
HexVec3 topB = lowB;
topB.y = topPoint.y;
// Each side spans from its wall centre to the apex, so the pair of edge
// vectors has to be ordered for the face to point away from the wall.
emitEdgeQuad(out_, map_.noise(), flatPoint, topPoint - flatPoint, lowA - flatPoint, kWallCode);
emitEdgeQuad(out_, map_.noise(), flatPoint, lowB - flatPoint, topPoint - flatPoint, kWallCode);
emitTriangle(out_, map_.noise(), topPoint, topB, topA, kWallCode);
}
/** @brief Emits the wedge or cap of the three-cell wall corner. */
void addWallSegmentAtPivot(HexVec3 pivot, const HexCellData* pivotCell, HexVec3 left, const HexCellData* leftCell,
HexVec3 right, const HexCellData* rightCell) {
if (pivotCell->values.isUnderwater()) return;
const bool hasLeftWall = leftCell != nullptr && !leftCell->values.isUnderwater() &&
edgeType(pivotCell->values.elevation(), leftCell->values.elevation()) !=
HexEdgeType::Cliff;
const bool hasRightWall = rightCell != nullptr && !rightCell->values.isUnderwater() &&
edgeType(pivotCell->values.elevation(), rightCell->values.elevation()) !=
HexEdgeType::Cliff;
if (hasLeftWall && hasRightWall) {
bool hasTower = false;
if (leftCell->values.elevation() == rightCell->values.elevation()) {
const HexVec3 midpoint = (pivot + left + right) * (1.f / 3.f);
const HexHash hash = HexHashGrid(map_.seed()).sample(midpoint);
hasTower = hash.e < kWallTowerThreshold;
}
addWallSegment(pivot, left, pivot, right, hasTower);
} else if (hasLeftWall) {
if (leftCell->values.elevation() < rightCell->values.elevation()) {
addWallWedge(pivot, left, right);
} else {
addWallCap(pivot, left);
}
} else if (hasRightWall) {
if (rightCell->values.elevation() < leftCell->values.elevation()) {
addWallWedge(right, pivot, left);
} else {
addWallCap(right, pivot);
}
}
}
/** @brief Dispatches the three-cell corner onto the pivot/left/right triple. */
void addWallCorner(HexVec3 c1, const HexCellData* cell1, HexVec3 c2, const HexCellData* cell2, HexVec3 c3,
const HexCellData* cell3) {
if (cell2 == nullptr || cell3 == nullptr) return;
const bool w1 = cell1->flags.isWalled();
const bool w2 = cell2->flags.isWalled();
const bool w3 = cell3->flags.isWalled();
if (w1) {
if (w2) {
if (!w3) addWallSegmentAtPivot(c3, cell3, c1, cell1, c2, cell2);
} else if (w3) {
addWallSegmentAtPivot(c2, cell2, c3, cell3, c1, cell1);
} else {
addWallSegmentAtPivot(c1, cell1, c2, cell2, c3, cell3);
}
} else if (w2) {
if (w3) {
addWallSegmentAtPivot(c1, cell1, c2, cell2, c3, cell3);
} else {
addWallSegmentAtPivot(c2, cell2, c3, cell3, c1, cell1);
}
} else if (w3) {
addWallSegmentAtPivot(c3, cell3, c1, cell1, c2, cell2);
}
}
/** @brief Emits a slab spanning the two road centres across a river. */
void addBridge(HexVec3 center, HexVec3 neighbourPosition) {
const HexVec3 span = neighbourPosition - center;
const float length = std::sqrt(dot(span, span));
if (length <= 1e-6f) return;
const HexVec3 fwd = span * (1.f / length);
const HexVec3 rightAxis = normalize(cross(HexVec3{0.f, 1.f, 0.f}, fwd));
if (isZero(rightAxis)) return;
const float halfWidth = HexMetrics::innerRadius() * kBridgeHalfWidth;
const float halfThickness = HexMetrics::kWallHeight * kBridgeHalfThickness;
const HexVec3 halfExtents{halfWidth, halfThickness, length * 0.5f};
emitBox(out_, map_.noise(), center, rightAxis, HexVec3{0.f, 1.f, 0.f}, fwd, halfExtents, kBridgeCode);
}
/** @brief Edge frame of the solid border in `direction`, centred on `center`. */
[[nodiscard]] static EdgeVertices solidEdge(HexVec3 center, HexDirection direction) noexcept {
return EdgeVertices{center + HexMetrics::firstSolidCorner(direction),
center + HexMetrics::secondSolidCorner(direction)};
}
/** @brief Solid border of `direction`, bridged to the neighbour and raised to `targetY`. */
[[nodiscard]] static EdgeVertices shiftedEdge(HexVec3 center, HexDirection direction, float targetY) noexcept {
const HexVec3 bridge = HexMetrics::bridge(direction);
EdgeVertices edge = solidEdge(center, direction);
edge.v1 = edge.v1 + bridge;
edge.v2 = edge.v2 + bridge;
edge.v3 = edge.v3 + bridge;
edge.v4 = edge.v4 + bridge;
edge.v5 = edge.v5 + bridge;
edge.v1.y = targetY;
edge.v2.y = targetY;
edge.v3.y = targetY;
edge.v4.y = targetY;
edge.v5.y = targetY;
return edge;
}
const HexMap& map_;
HexMeshData& out_;
};
/** @brief Emits the urban, farm, plant and special decorations of one chunk. */
class FeatureMesher {
public:
explicit FeatureMesher(const HexMap& map, HexMeshData& out) noexcept
: map_(map), out_(out), grid_(map.seed()) {}
/** @brief Builds every cell of `chunkIndex`. */
void run(std::int32_t chunkIndex) {
for (std::int32_t row = 0; row < HexMetrics::kChunkSizeZ; ++row) {
for (std::int32_t column = 0; column < HexMetrics::kChunkSizeX; ++column) {
buildCell(map_.chunkCell(chunkIndex, column, row));
}
}
}
private:
/** @brief Emits at most one ordinary decoration and the cell's special one. */
void buildCell(HexCoordinates coordinates) {
if (!map_.contains(coordinates)) return;
const HexCellData* cellData = map_.cell(coordinates);
if (cellData == nullptr) return;
if (cellData->values.isUnderwater()) return;
const std::int32_t urbanLevel = cellData->values.urbanLevel();
const std::int32_t farmLevel = cellData->values.farmLevel();
const std::int32_t plantLevel = cellData->values.plantLevel();
const std::int32_t special = cellData->values.specialIndex();
if (urbanLevel == 0 && farmLevel == 0 && plantLevel == 0 && special == 0) return;
const HexVec3 position = map_.cellPosition(coordinates);
const HexHash hash = grid_.sample(position);
if (special == 0) {
const int urban = pick(urbanLevel, hash.a);
const int farm = pick(farmLevel, hash.b);
const int plant = pick(plantLevel, hash.c);
// The reference compares the three collections by their hash and
// keeps the lowest one that passed its level's thresholds.
std::int32_t collection = -1;
float usedHash = hash.a;
if (urban >= 0) collection = 0;
if (farm >= 0 && (collection < 0 || hash.b < usedHash)) {
collection = 1;
usedHash = hash.b;
}
if (plant >= 0 && (collection < 0 || hash.c < usedHash)) collection = 2;
if (collection == 0) addUrban(position, urbanLevel, hash.e);
if (collection == 1) addFarm(position, hash.e);
if (collection == 2) addPlant(position, hash.e);
}
if (special != 0) addSpecial(position, hash.e);
}
/**
* @brief Reproduces the reference collection pick.
*
* @param level Feature level of the cell; `0` disables the collection.
* @param hash Threshold hash of the collection.
* @return The index of the first threshold above `hash`, or `-1` when none is.
*/
[[nodiscard]] static int pick(std::int32_t level, float hash) noexcept {
if (level <= 0) return -1;
for (std::int32_t index = 0; index < kFeatureThresholdCount; ++index) {
if (hash < featureThreshold(level - 1, index)) return static_cast<int>(index);
}
return -1;
}
/** @brief Emits a stack of one to three boxes whose size follows the urban level. */
void addUrban(HexVec3 position, std::int32_t level, float orientation) {
const int boxes = level < 1 ? 1 : (level > 3 ? 3 : level);
const float total = HexMetrics::kWallHeight * (0.75f + 0.35f * static_cast<float>(level - 1));
const float base = HexMetrics::innerRadius() * 0.42f;
const float piece = total / static_cast<float>(boxes);
// The orientation hash turns the stack so neighbouring cities do not all
// face the same way; it is applied as the mesh's yaw about the world up axis.
const HexVec3 right = yaw(orientation, HexVec3{1.f, 0.f, 0.f});
const HexVec3 fwd = yaw(orientation, HexVec3{0.f, 0.f, 1.f});
float lift = 0.f;
for (int index = 0; index < boxes; ++index) {
const float scale = 1.f - 0.25f * static_cast<float>(index);
const HexVec3 halfExtents{base * scale, piece * 0.5f, base * scale};
const HexVec3 centre = position + HexVec3{0.f, lift + piece * 0.5f, 0.f};
emitBox(out_, map_.noise(), centre, right, HexVec3{0.f, 1.f, 0.f}, fwd, halfExtents, kUrbanCode);
lift += piece;
}
}
/** @brief Emits a low flat box representing a farm. */
void addFarm(HexVec3 position, float orientation) {
const float half = HexMetrics::kWallHeight * 0.12f;
const HexVec3 right = yaw(orientation, HexVec3{1.f, 0.f, 0.f});
const HexVec3 fwd = yaw(orientation, HexVec3{0.f, 0.f, 1.f});
const HexVec3 halfExtents{HexMetrics::innerRadius() * 0.5f, half, HexMetrics::innerRadius() * 0.5f};
emitBox(out_, map_.noise(), position + HexVec3{0.f, half, 0.f}, right, HexVec3{0.f, 1.f, 0.f}, fwd,
halfExtents, kFarmCode);
}
/** @brief Emits a squashed octahedron representing a plant or tree. */
void addPlant(HexVec3 position, float orientation) {
const float height = HexMetrics::kWallHeight * 0.55f;
const float radius = HexMetrics::innerRadius() * 0.3f;
const float waist = position.y + height * 0.35f;
const HexVec3 top{position.x, position.y + height, position.z};
const HexVec3 bottom{position.x, position.y, position.z};
const HexVec3 first = yaw(orientation, HexVec3{radius, 0.f, 0.f});
const HexVec3 ring[4] = {HexVec3{position.x + first.x, waist, position.z + first.z},
HexVec3{position.x - first.z, waist, position.z + first.x},
HexVec3{position.x - first.x, waist, position.z - first.z},
HexVec3{position.x + first.z, waist, position.z - first.x}};
for (int index = 0; index < 4; ++index) {
const int next = (index + 1) % 4;
emitTriangle(out_, map_.noise(), ring[index], top, ring[next], kPlantCode);
emitTriangle(out_, map_.noise(), ring[next], bottom, ring[index], kPlantCode);
}
}
/** @brief Emits a tall tapered column representing a special feature. */
void addSpecial(HexVec3 position, float orientation) {
const float height = HexMetrics::kWallHeight * 1.5f;
const float base = HexMetrics::innerRadius() * 0.3f;
const float footer = height * 0.2f;
const float shaft = height * 0.55f;
const HexVec3 right = yaw(orientation, HexVec3{1.f, 0.f, 0.f});
const HexVec3 fwd = yaw(orientation, HexVec3{0.f, 0.f, 1.f});
emitBox(out_, map_.noise(), position + HexVec3{0.f, footer * 0.5f, 0.f}, right, HexVec3{0.f, 1.f, 0.f}, fwd,
HexVec3{base, footer * 0.5f, base}, kSpecialCode);
emitBox(out_, map_.noise(), position + HexVec3{0.f, footer + shaft * 0.5f, 0.f}, right, HexVec3{0.f, 1.f, 0.f},
fwd, HexVec3{base * 0.6f, shaft * 0.5f, base * 0.6f}, kSpecialCode);
}
const HexMap& map_;
HexMeshData& out_;
HexHashGrid grid_;
};
} // namespace
HexHash HexHashGrid::sample(HexVec3 position) const noexcept {
const auto wrap = [](std::int32_t value) noexcept {
std::int32_t wrapped = value % kSize;
if (wrapped < 0) wrapped += kSize;
return static_cast<std::uint32_t>(wrapped);
};
const auto grid = [](float coordinate) noexcept {
return static_cast<std::int32_t>(std::floor(coordinate * kScale));
};
const std::uint32_t x = wrap(grid(position.x));
const std::uint32_t z = wrap(grid(position.z));
// Five independent streams of one integer hash; the channel constant mirrors
// the lane separation used by `HexNoise`.
const auto lane = [&](std::uint32_t channel) noexcept {
std::uint32_t h = x * 0x9E3779B1u;
h ^= z * 0x85EBCA77u;
h = (h ^ (h >> 15)) * 0xC2B2AE3Du;
h ^= (seed_ + channel * 0x9E3779B9u) * 0x27D4EB2Fu;
h = (h ^ (h >> 13)) * 0x165667B1u;
h ^= h >> 16;
return static_cast<float>(h >> 8) * (1.f / 16777216.f);
};
HexHash hash;
hash.a = lane(0u);
hash.b = lane(1u);
hash.c = lane(2u);
hash.d = lane(3u);
hash.e = lane(4u);
return hash;
}
float featureThreshold(std::int32_t level, std::int32_t index) noexcept {
// The reference table has three levels with three entries each; both indices
// are clamped so an out-of-range level or entry can never read past it.
if (level < 0) level = 0;
if (level > 2) level = 2;
if (index < 0) index = 0;
if (index > kFeatureThresholdCount - 1) index = kFeatureThresholdCount - 1;
constexpr float kThresholds[3][kFeatureThresholdCount] = {
{0.0f, 0.0f, 0.4f},
{0.0f, 0.4f, 0.6f},
{0.4f, 0.6f, 0.8f},
};
return kThresholds[static_cast<std::size_t>(level)][static_cast<std::size_t>(index)];
}
void buildWallMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out) {
out.clear();
if (map.empty() || chunkIndex < 0 || chunkIndex >= map.chunkCount()) {
out.finalize();
return;
}
WallMesher mesher(map, out);
mesher.run(chunkIndex);
out.finalize();
}
void buildFeatureMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out) {
out.clear();
if (map.empty() || chunkIndex < 0 || chunkIndex >= map.chunkCount()) {
out.finalize();
return;
}
FeatureMesher mesher(map, out);
mesher.run(chunkIndex);
out.finalize();
}
} // namespace eve::hexmap
+105
View File
@@ -0,0 +1,105 @@
#pragma once
/** @file HexFeatures.h @brief Deterministic feature placement for hex decorations and walls. */
#include "hexmap/HexMap.h"
#include "hexmap/HexMeshData.h"
#include "hexmap/HexMetrics.h"
#include <cstdint>
namespace eve::hexmap {
/** @brief Five-component pseudo-random value used to pick and orient one feature. */
struct HexHash {
float a = 0.f;
float b = 0.f;
float c = 0.f;
float d = 0.f;
float e = 0.f;
};
/**
* @brief Deterministic replacement for the reference project's hash grid.
*
* The reference samples an array of `HexHash` cells indexed by
* `worldXZ * hashGridScale`, seeded once from `Random`. This implementation
* derives the same five values from a pure integer hash of the grid cell and the
* seed, so a map's decoration layout is reproducible from the map seed alone and
* no table has to be allocated or kept alive.
*
* @note Immutable after construction; safe to share between chunk builds.
*/
class HexHashGrid {
public:
/** @brief Spacing of the hash cells, matching the reference `hashGridScale`. */
static constexpr float kScale = 0.25f;
/** @brief Size of the reference grid; only used to reproduce its wrap. */
static constexpr std::int32_t kSize = 256;
/**
* @brief Creates a hash field for `seed`.
* @param seed Deterministic seed; the same seed always yields the same field.
*/
explicit HexHashGrid(std::uint32_t seed = 0u) noexcept : seed_(seed) {}
/** @brief Samples the five-component hash at a world-space position. */
[[nodiscard]] HexHash sample(HexVec3 position) const noexcept;
private:
std::uint32_t seed_ = 0u;
};
/**
* @brief Feature-placement thresholds of the reference project.
*
* A feature level (1-3) picks a collection by comparing the cell's hash against
* these thresholds; the last entry of each row is the catch-all.
*/
[[nodiscard]] float featureThreshold(std::int32_t level, std::int32_t index) noexcept;
/** @brief Number of threshold entries per feature level. */
inline constexpr std::int32_t kFeatureThresholdCount = 3;
/** @brief Height of a city/farm wall, matching `HexMetrics::kWallHeight`. */
inline constexpr float kWallTowerThreshold = 0.5f;
/** @brief Edge length the reference bridge model is designed for. */
inline constexpr float kBridgeDesignLength = 7.f;
/**
* @brief Builds the wall and bridge geometry of one chunk.
*
* Walls appear along the boundary between a walled and an unwalled cell when
* neither side is underwater and the two do not differ by a cliff. A bridge is
* emitted where a road crosses a river on the chunk's own edges.
*
* The single texture coordinate encodes the part so one shader can shade them
* differently; the complete code table, shared with `buildFeatureMesh`, is
* `0` wall, `1` tower, `2` bridge, `3` urban, `4` farm, `5` plant, `6` special.
* The second coordinate is reserved.
*
* @param map Source map; must contain `chunkIndex`.
* @param chunkIndex Chunk to build.
* @param out Destination mesh; cleared and finalized by this call.
*/
void buildWallMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out);
/**
* @brief Builds the urban, farm, plant and special decorations of one chunk.
*
* Every cell emits at most one decoration, chosen exactly like the reference
* project: the urban, farm and plant collections are each tested against the
* cell's hash and the one with the lowest passing hash wins. Special features
* suppress ordinary decorations entirely.
*
* The single texture coordinate encodes the collection (`3` urban, `4` farm,
* `5` plant, `6` special); the second carries the hash's orientation selector so
* a shader could rotate the decoration.
*
* @param map Source map; must contain `chunkIndex`.
* @param chunkIndex Chunk to build.
* @param out Destination mesh; cleared and finalized by this call.
*/
void buildFeatureMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out);
} // namespace eve::hexmap
+146
View File
@@ -0,0 +1,146 @@
/**
* @file HexFogMesh.cpp
* @brief Fog-of-war overlay mesh of one hex chunk.
*/
#include "hexmap/HexMapMesh.h"
#include "hexmap/HexCoordinates.h"
#include "hexmap/HexMap.h"
#include "hexmap/HexMeshData.h"
#include "hexmap/HexMetrics.h"
#include "hexmap/HexNoise.h"
#include "hexmap/HexVisibility.h"
#include <cstdint>
namespace eve::hexmap {
namespace {
/** @brief Fog shade carried in the first texture coordinate: explored, but no viewer now. */
constexpr float kFogShadeSeen = 0.f;
/** @brief Fog shade carried in the first texture coordinate: never explored. */
constexpr float kFogShadeUnknown = 1.f;
/** @brief Vertical clearance of the fog cap above the highest surface it covers. */
constexpr float kFogCapLift = 0.25f;
/** @brief How far below the lowest editable elevation the fog skirt reaches. */
constexpr float kFogSkirtDepth = 6.f;
/**
* @brief Highest surface a cell can show: its land or its water, whichever is higher.
*
* A cell whose water level is above its elevation draws its water surface above
* its ground, so the fog cap has to clear the water too.
*/
[[nodiscard]] float cellTopY(const HexMap& map, HexCoordinates coordinates) noexcept {
const float land = map.cellPosition(coordinates).y;
const float surface = HexMetrics::waterSurfaceY(map.waterLevel(coordinates));
return land > surface ? land : surface;
}
/**
* @brief Highest surface of a cell or any of its direct neighbours.
*
* The blend strip between two cells rises towards the higher one, so a cap that
* only clears its own cell would be pierced by the neighbour's half of the strip.
*/
[[nodiscard]] float neighbourhoodTopY(const HexMap& map, HexCoordinates coordinates) noexcept {
float top = cellTopY(map, coordinates);
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
HexCoordinates neighbour{};
if (!map.getNeighbor(coordinates, static_cast<HexDirection>(i), neighbour)) continue;
const float candidate = cellTopY(map, neighbour);
if (candidate > top) top = candidate;
}
return top;
}
/** @brief Emits one triangle whose three vertices share a shade. */
void emitTriangle(HexMeshData& out, const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, float shade) {
const auto i0 = static_cast<std::uint32_t>(out.vertexCount());
out.addVertex(p0, shade, 0.f);
out.addVertex(p1, shade, 0.f);
out.addVertex(p2, shade, 0.f);
out.addTriangle(i0, i0 + 1u, i0 + 2u);
}
/**
* @brief Emits the closed column that hides one cell.
*
* A flat cap alone would leave the cliff faces along a fog boundary lit, because
* the terrain's vertical walls rise from the neighbouring cell's surface up to
* this one's. The column therefore also carries a skirt reaching below the
* lowest editable elevation.
*
* @param map Source map.
* @param coordinates Cell to cover; must be inside the grid.
* @param shade Fog shade to write into the first texture coordinate.
* @param out Destination mesh.
*/
void appendFogColumn(const HexMap& map, HexCoordinates coordinates, float shade, HexMeshData& out) {
const HexNoise& noise = map.noise();
const HexVec3 ground = map.cellGroundPosition(coordinates);
const float top = neighbourhoodTopY(map, coordinates) + kFogCapLift;
const float bottom = HexMetrics::elevationY(HexMetrics::kMinElevation) - kFogSkirtDepth;
HexVec3 ring[kHexDirectionCount];
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
HexVec3 corner = noise.perturb(ground + HexMetrics::corner(static_cast<HexDirection>(i)));
corner.y = top;
ring[i] = corner;
}
HexVec3 center = noise.perturb(HexVec3{ground.x, 0.f, ground.z});
center.y = top;
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const std::int32_t next = (i + 1) % kHexDirectionCount;
emitTriangle(out, center, ring[i], ring[next], shade);
HexVec3 topNear = ring[i];
HexVec3 topFar = ring[next];
HexVec3 botFar = ring[next];
HexVec3 botNear = ring[i];
botNear.y = bottom;
botFar.y = bottom;
const auto i0 = static_cast<std::uint32_t>(out.vertexCount());
out.addVertex(topNear, shade, 0.f);
out.addVertex(topFar, shade, 0.f);
out.addVertex(botFar, shade, 0.f);
out.addVertex(botNear, shade, 0.f);
out.addQuad(i0, i0 + 1u, i0 + 2u, i0 + 3u);
}
}
} // namespace
void buildFogMesh(const HexMap& map, const HexVisibility& visibility, std::int32_t chunkIndex, HexMeshData& out) {
out.clear();
if (map.empty() || visibility.cellCount() != map.cellCount() || chunkIndex < 0 ||
chunkIndex >= map.chunkCount()) {
out.finalize();
return;
}
for (std::int32_t row = 0; row < HexMetrics::kChunkSizeZ; ++row) {
for (std::int32_t column = 0; column < HexMetrics::kChunkSizeX; ++column) {
const HexCoordinates coordinates = map.chunkCell(chunkIndex, column, row);
if (!map.contains(coordinates)) continue;
const std::int32_t index = map.indexOf(coordinates);
if (index < 0) continue;
float shade = 0.f;
if (!map.isExplored(coordinates)) {
shade = kFogShadeUnknown;
} else if (!visibility.isVisible(index)) {
shade = kFogShadeSeen;
} else {
// Fully visible: no overlay at all.
continue;
}
appendFogColumn(map, coordinates, shade, out);
}
}
out.finalize();
}
} // namespace eve::hexmap
+658
View File
@@ -0,0 +1,658 @@
#include "hexmap/HexMap.h"
#include "common/Diagnostic.h"
#include <algorithm>
#include <cmath>
#include <limits>
namespace eve::hexmap {
namespace {
[[nodiscard]] Result<void> invalidArgument(std::string message) {
return Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), "hexmap"));
}
[[nodiscard]] std::int32_t clampInt(std::int32_t value, std::int32_t low, std::int32_t high) noexcept {
return value < low ? low : (value > high ? high : value);
}
[[nodiscard]] std::int32_t layerIndex(std::int32_t feature) noexcept { return clampInt(feature, 0, 2); }
} // namespace
// --- construction -----------------------------------------------------------
Result<void> HexMap::reset(std::int32_t cellCountX, std::int32_t cellCountZ, std::uint32_t seed) {
if (cellCountX <= 0 || cellCountZ <= 0) return invalidArgument("hex map size must be positive");
if (cellCountX % HexMetrics::kChunkSizeX != 0 || cellCountZ % HexMetrics::kChunkSizeZ != 0)
return invalidArgument("hex map size must be a multiple of the 5x5 chunk size");
cellCountX_ = cellCountX;
cellCountZ_ = cellCountZ;
chunkCountX_ = cellCountX / HexMetrics::kChunkSizeX;
chunkCountZ_ = cellCountZ / HexMetrics::kChunkSizeZ;
// `HexValues` stores the elevation biased by +15, so a zeroed record would read
// back as -15. Every cell therefore starts explicitly at elevation 0 and water 0.
// Every cell also starts *explorable but unexplored*, which is the fog-of-war
// precondition of the reference project: a viewer may reveal it, nothing has yet.
HexCellData initial{};
initial.values = initial.values.withElevation(0).withWaterLevel(0);
initial.flags = initial.flags.withExplorable(true).withExplored(false);
cells_.assign(static_cast<std::size_t>(cellCountX_) * static_cast<std::size_t>(cellCountZ_), initial);
chunkDirty_.assign(static_cast<std::size_t>(chunkCount()), 0u);
dirtyQueue_.clear();
noise_.reset(seed);
revision_ = 0;
markAllChunksDirty();
return Result<void>::success();
}
// --- topology ---------------------------------------------------------------
std::int32_t HexMap::indexOf(HexCoordinates coordinates) const noexcept {
if (!contains(coordinates)) return -1;
return coordinates.offsetX() + coordinates.z * cellCountX_;
}
HexCoordinates HexMap::coordinatesAt(std::int32_t index) const noexcept {
if (index < 0 || index >= cellCount()) return HexCoordinates{};
return HexCoordinates::fromOffset(index % cellCountX_, index / cellCountX_);
}
const HexCellData* HexMap::cellAt(std::int32_t index) const noexcept {
if (index < 0 || index >= cellCount()) return nullptr;
return &cells_[static_cast<std::size_t>(index)];
}
const HexCellData* HexMap::cell(HexCoordinates coordinates) const noexcept { return cellAt(indexOf(coordinates)); }
HexCellData* HexMap::mutableCell(HexCoordinates coordinates) noexcept {
const std::int32_t index = indexOf(coordinates);
if (index < 0) return nullptr;
return &cells_[static_cast<std::size_t>(index)];
}
bool HexMap::getNeighbor(HexCoordinates coordinates, HexDirection direction, HexCoordinates& out) const noexcept {
const HexCoordinates candidate = coordinates.step(direction);
if (!contains(candidate)) return false;
out = candidate;
return true;
}
HexEdgeType HexMap::edgeTypeTo(HexCoordinates a, HexCoordinates b) const noexcept {
if (!contains(a) || !contains(b)) return HexEdgeType::Flat;
if (a.distanceTo(b) != 1) return HexEdgeType::Flat;
return edgeType(elevation(a), elevation(b));
}
// --- chunking ---------------------------------------------------------------
std::int32_t HexMap::chunkIndexOf(HexCoordinates coordinates) const noexcept {
if (!contains(coordinates)) return -1;
return coordinates.chunkColumn() + coordinates.chunkRow() * chunkCountX_;
}
std::int32_t HexMap::chunkColumnOf(std::int32_t chunkIndex) const noexcept {
return chunkCountX_ > 0 ? chunkIndex % chunkCountX_ : 0;
}
std::int32_t HexMap::chunkRowOf(std::int32_t chunkIndex) const noexcept {
return chunkCountX_ > 0 ? chunkIndex / chunkCountX_ : 0;
}
HexCoordinates HexMap::chunkCell(std::int32_t chunkIndex, std::int32_t column, std::int32_t row) const noexcept {
const std::int32_t offsetX = chunkColumnOf(chunkIndex) * HexMetrics::kChunkSizeX + column;
const std::int32_t offsetZ = chunkRowOf(chunkIndex) * HexMetrics::kChunkSizeZ + row;
return HexCoordinates::fromOffset(offsetX, offsetZ);
}
HexVec3 HexMap::chunkCenter(std::int32_t chunkIndex) const noexcept {
if (chunkIndex < 0 || chunkIndex >= chunkCount()) return HexVec3{};
const HexVec3 a = cellPosition(chunkCell(chunkIndex, 0, 0));
const HexVec3 b = cellPosition(chunkCell(chunkIndex, HexMetrics::kChunkSizeX - 1, HexMetrics::kChunkSizeZ - 1));
return HexVec3{(a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f, (a.z + b.z) * 0.5f};
}
void HexMap::markChunkDirty(std::int32_t chunkIndex) noexcept {
if (chunkIndex < 0 || chunkIndex >= chunkCount()) return;
auto& flag = chunkDirty_[static_cast<std::size_t>(chunkIndex)];
if (flag != 0u) return;
flag = 1u;
dirtyQueue_.push_back(chunkIndex);
}
void HexMap::markChunkDirtyAndNeighbors(std::int32_t chunkIndex) noexcept {
if (chunkIndex < 0 || chunkIndex >= chunkCount()) return;
markChunkDirty(chunkIndex);
const std::int32_t column = chunkColumnOf(chunkIndex);
const std::int32_t row = chunkRowOf(chunkIndex);
for (std::int32_t dz = -1; dz <= 1; ++dz) {
for (std::int32_t dx = -1; dx <= 1; ++dx) {
if (dx == 0 && dz == 0) continue;
const std::int32_t cx = column + dx;
const std::int32_t cz = row + dz;
if (cx < 0 || cx >= chunkCountX_ || cz < 0 || cz >= chunkCountZ_) continue;
markChunkDirty(cx + cz * chunkCountX_);
}
}
}
void HexMap::markAllChunksDirty() noexcept {
dirtyQueue_.clear();
for (std::int32_t i = 0; i < chunkCount(); ++i) {
chunkDirty_[static_cast<std::size_t>(i)] = 1u;
dirtyQueue_.push_back(i);
}
}
std::int32_t HexMap::takeDirtyChunk() noexcept {
if (dirtyQueue_.empty()) return -1;
const std::int32_t chunkIndex = dirtyQueue_.back();
dirtyQueue_.pop_back();
chunkDirty_[static_cast<std::size_t>(chunkIndex)] = 0u;
return chunkIndex;
}
// --- geometry ---------------------------------------------------------------
HexVec3 HexMap::cellPosition(HexCoordinates coordinates) const noexcept {
HexVec3 position = HexCoordinates::toWorldPosition(coordinates);
const std::int32_t level = elevation(coordinates);
position.y = HexMetrics::elevationY(level) + noise_.elevationPerturb(position.x, position.z);
return position;
}
Result<HexCoordinates> HexMap::pickCell(HexVec3 rayOrigin, HexVec3 rayDirection) const noexcept {
if (empty())
return Result<HexCoordinates>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "hex map is empty", "hexmap.pick"));
const float dirLength =
std::sqrt(rayDirection.x * rayDirection.x + rayDirection.y * rayDirection.y + rayDirection.z * rayDirection.z);
if (dirLength <= 1e-6f)
return Result<HexCoordinates>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "ray direction must be non-zero", "hexmap.pick"));
rayDirection.x /= dirLength;
rayDirection.y /= dirLength;
rayDirection.z /= dirLength;
// Bounding box of the whole map, padded by one cell and one elevation step.
HexVec3 low = cellGroundPosition(HexCoordinates::fromOffset(0, 0));
HexVec3 high = cellGroundPosition(HexCoordinates::fromOffset(cellCountX_ - 1, cellCountZ_ - 1));
const float pad = HexMetrics::kOuterRadius;
low.x -= pad;
low.z -= pad;
low.y = HexMetrics::elevationY(HexMetrics::kMinElevation) - HexMetrics::kElevationStep;
high.x += pad;
high.z += pad;
high.y = HexMetrics::elevationY(HexMetrics::kMaxElevation) + HexMetrics::kElevationStep;
float tEnter = 0.f;
float tExit = std::numeric_limits<float>::max();
const float origin[3] = {rayOrigin.x, rayOrigin.y, rayOrigin.z};
const float direction[3] = {rayDirection.x, rayDirection.y, rayDirection.z};
const float boxLow[3] = {low.x, low.y, low.z};
const float boxHigh[3] = {high.x, high.y, high.z};
for (int axis = 0; axis < 3; ++axis) {
if (std::fabs(direction[axis]) < 1e-8f) {
if (origin[axis] < boxLow[axis] || origin[axis] > boxHigh[axis]) {
return Result<HexCoordinates>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "ray misses the hex map bounds", "hexmap.pick"));
}
continue;
}
const float inv = 1.f / direction[axis];
float t0 = (boxLow[axis] - origin[axis]) * inv;
float t1 = (boxHigh[axis] - origin[axis]) * inv;
if (t0 > t1) std::swap(t0, t1);
tEnter = std::max(tEnter, t0);
tExit = std::min(tExit, t1);
}
if (tExit < tEnter)
return Result<HexCoordinates>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "ray misses the hex map bounds", "hexmap.pick"));
const float step = HexMetrics::kElevationStep * 0.5f;
const float maxRayLength = tExit;
const int maxSteps = 512;
float previousT = tEnter;
HexCoordinates previousCoordinates{};
bool havePrevious = false;
for (int i = 0; i <= maxSteps; ++i) {
const float t = std::min(tEnter + static_cast<float>(i) * step, maxRayLength);
const HexVec3 point{rayOrigin.x + rayDirection.x * t, rayOrigin.y + rayDirection.y * t,
rayOrigin.z + rayDirection.z * t};
const HexCoordinates coordinates = HexCoordinates::fromWorldPosition(point);
if (contains(coordinates)) {
const float surfaceY = cellPosition(coordinates).y;
if (point.y <= surfaceY) {
// Bisect between the last sample above the surface and this one.
float lo = havePrevious ? previousT : tEnter;
float hi = t;
HexCoordinates best = contains(previousCoordinates) ? previousCoordinates : coordinates;
for (int iteration = 0; iteration < 8; ++iteration) {
const float mid = (lo + hi) * 0.5f;
const HexVec3 sample{rayOrigin.x + rayDirection.x * mid, rayOrigin.y + rayDirection.y * mid,
rayOrigin.z + rayDirection.z * mid};
const HexCoordinates candidate = HexCoordinates::fromWorldPosition(sample);
if (!contains(candidate)) break;
best = candidate;
if (sample.y <= cellPosition(candidate).y)
hi = mid;
else
lo = mid;
}
if (contains(best)) return Result<HexCoordinates>::success(best);
return Result<HexCoordinates>::success(coordinates);
}
previousCoordinates = coordinates;
havePrevious = true;
}
previousT = t;
if (t >= maxRayLength) break;
}
return Result<HexCoordinates>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "ray never reached the hex surface", "hexmap.pick"));
}
// --- cell queries -----------------------------------------------------------
std::int32_t HexMap::elevation(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values.elevation() : 0;
}
std::int32_t HexMap::waterLevel(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values.waterLevel() : 0;
}
std::int32_t HexMap::terrainType(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values.terrainType() : 0;
}
std::int32_t HexMap::urbanLevel(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values.urbanLevel() : 0;
}
std::int32_t HexMap::farmLevel(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values.farmLevel() : 0;
}
std::int32_t HexMap::plantLevel(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values.plantLevel() : 0;
}
std::int32_t HexMap::specialIndex(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values.specialIndex() : 0;
}
bool HexMap::isUnderwater(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values.isUnderwater() : false;
}
bool HexMap::hasRiver(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->flags.hasRiver() : false;
}
bool HexMap::hasRoad(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->flags.hasRoad() : false;
}
bool HexMap::hasRiverThrough(HexCoordinates c, HexDirection d) const noexcept {
const HexCellData* data = cell(c);
return data ? data->flags.hasRiverThrough(d) : false;
}
bool HexMap::isWalled(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->flags.isWalled() : false;
}
bool HexMap::isExplored(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->flags.isExplored() : false;
}
bool HexMap::isExplorable(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->flags.isExplorable() : false;
}
HexValues HexMap::values(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->values : HexValues{};
}
HexFlags HexMap::flags(HexCoordinates c) const noexcept {
const HexCellData* data = cell(c);
return data ? data->flags : HexFlags{};
}
// --- cell authoring ---------------------------------------------------------
void HexMap::refreshCellDependents(HexCoordinates coordinates) noexcept {
++revision_;
markChunkDirtyAndNeighbors(chunkIndexOf(coordinates));
}
void HexMap::validateRivers(HexCoordinates coordinates) noexcept {
const HexCellData* data = cell(coordinates);
if (!data || !data->flags.hasRiver()) return;
// A river may only leave a cell that is at least as high as its neighbour,
// unless the cell itself is a lake at exactly its own elevation.
const std::int32_t fromElevation = elevation(coordinates);
const std::int32_t fromWater = waterLevel(coordinates);
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
if (!data->flags.hasRiverOut(direction)) continue;
HexCoordinates neighbour{};
const bool canFlow = getNeighbor(coordinates, direction, neighbour) &&
(fromElevation >= elevation(neighbour) || fromWater == fromElevation);
if (!canFlow) {
removeRiver(coordinates).ignore("river validation must keep the map consistent");
return;
}
}
}
Result<void> HexMap::setElevation(HexCoordinates c, std::int32_t value) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
const std::int32_t clamped = clampInt(value, HexMetrics::kMinElevation, HexMetrics::kMaxElevation);
if (data->values.elevation() == clamped) return Result<void>::success();
data->values = data->values.withElevation(clamped);
// Roads cannot span a difference of more than one elevation step.
bool hasInvalidRoad = false;
for (std::int32_t i = 0; i < kHexDirectionCount && !hasInvalidRoad; ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
if (!data->flags.hasRoad(direction)) continue;
HexCoordinates neighbour{};
if (!getNeighbor(c, direction, neighbour)) continue;
const std::int32_t delta = elevation(neighbour) - clamped;
if (delta > 1 || delta < -1) hasInvalidRoad = true;
}
if (hasInvalidRoad) removeRoads(c).ignore("elevation change must drop invalid roads");
refreshCellDependents(c);
validateRivers(c);
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
HexCoordinates neighbour{};
if (getNeighbor(c, static_cast<HexDirection>(i), neighbour)) validateRivers(neighbour);
}
return Result<void>::success();
}
Result<void> HexMap::setWaterLevel(HexCoordinates c, std::int32_t value) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
const std::int32_t clamped = clampInt(value, 0, HexMetrics::kMaxElevation);
if (data->values.waterLevel() == clamped) return Result<void>::success();
data->values = data->values.withWaterLevel(clamped);
refreshCellDependents(c);
validateRivers(c);
return Result<void>::success();
}
Result<void> HexMap::setTerrainType(HexCoordinates c, std::int32_t value) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
data->values = data->values.withTerrainType(clampTerrainType(value));
++ ++revision_;
return Result<void>::success();
}
Result<void> HexMap::setUrbanLevel(HexCoordinates c, std::int32_t value) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
data->values = data->values.withUrbanLevel(clampInt(value, 0, 3));
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> HexMap::setFarmLevel(HexCoordinates c, std::int32_t value) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
data->values = data->values.withFarmLevel(clampInt(value, 0, 3));
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> HexMap::setPlantLevel(HexCoordinates c, std::int32_t value) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
data->values = data->values.withPlantLevel(clampInt(value, 0, 3));
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> HexMap::setSpecialIndex(HexCoordinates c, std::int32_t index) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
if (data->flags.hasRiver()) return Result<void>::success();
data->values = data->values.withSpecialIndex(clampInt(index, 0, 3));
removeRoads(c).ignore("special features replace roads");
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> HexMap::setWalled(HexCoordinates c, bool walled) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
data->flags = data->flags.withWalled(walled);
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> HexMap::setExplored(HexCoordinates c, bool explored) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
data->flags = data->flags.withExplored(explored);
// Fog state changes do not alter geometry, so the chunks stay clean: only the
// revision is bumped so a renderer can tell that the visible set changed.
++revision_;
return Result<void>::success();
}
Result<void> HexMap::setExplorable(HexCoordinates c, bool explorable) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
data->flags = data->flags.withExplorable(explorable);
++revision_;
return Result<void>::success();
}
Result<void> HexMap::setCellState(HexCoordinates c, HexValues values, HexFlags flags) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
data->values = values;
data->flags = flags;
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> HexMap::setOutgoingRiver(HexCoordinates c, HexDirection direction) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
HexCoordinates neighbour{};
if (!getNeighbor(c, direction, neighbour)) return invalidArgument("river direction leaves the hex map");
if (data->flags.hasAnyRiverOut() && data->flags.hasRiverOut(direction)) return Result<void>::success();
if (elevation(c) < elevation(neighbour) && waterLevel(c) != elevation(c))
return invalidArgument("a river cannot flow uphill");
// Drop only the links this call replaces, mirroring the reference cell's
// `RemoveOutgoingRiver` + same-edge `RemoveIncomingRiver`. Calling the full
// `removeRiver` here also erased the *incoming* link from the upstream cell, so
// carving a channel step by step wiped the step behind it and only the last
// edge survived; that is why a 40-cell walk produced a 2-cell river.
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const HexDirection previous = static_cast<HexDirection>(i);
if (!data->flags.hasRiverOut(previous)) continue;
data->flags = data->flags.withoutRiverOut(previous);
HexCoordinates previousNeighbour{};
if (getNeighbor(c, previous, previousNeighbour)) {
HexCellData* previousData = mutableCell(previousNeighbour);
previousData->flags = previousData->flags.withoutRiverIn(opposite(previous));
refreshCellDependents(previousNeighbour);
}
}
data = mutableCell(c);
// A river may not leave and enter through the same edge.
if (data->flags.hasRiverIn(direction)) {
data->flags = data->flags.withoutRiverIn(direction);
HexCellData* otherData = mutableCell(neighbour);
otherData->flags = otherData->flags.withoutRiverOut(opposite(direction));
}
data = mutableCell(c);
data->flags = data->flags.withRiverOut(direction).withoutRoad(direction);
data->values = data->values.withSpecialIndex(0);
HexCellData* other = mutableCell(neighbour);
other->flags = other->flags.withRiverIn(opposite(direction)).withoutRoad(opposite(direction));
other->values = other->values.withSpecialIndex(0);
refreshCellDependents(c);
refreshCellDependents(neighbour);
return Result<void>::success();
}
Result<void> HexMap::removeRiver(HexCoordinates c) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
if (!data->flags.hasRiver()) return Result<void>::success();
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
data->flags = data->flags.withoutRiverIn(direction).withoutRiverOut(direction);
}
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
HexCoordinates neighbour{};
if (!getNeighbor(c, direction, neighbour)) continue;
HexCellData* other = mutableCell(neighbour);
other->flags = other->flags.withoutRiverIn(opposite(direction)).withoutRiverOut(opposite(direction));
refreshCellDependents(neighbour);
}
refreshCellDependents(c);
return Result<void>::success();
}
Result<void> HexMap::addRoad(HexCoordinates c, HexDirection direction) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
HexCoordinates neighbour{};
if (!getNeighbor(c, direction, neighbour)) return invalidArgument("road direction leaves the hex map");
if (data->flags.hasRoad(direction)) return Result<void>::success();
if (data->flags.hasRiverThrough(direction)) return Result<void>::success();
if (data->flags.hasRiver() && !data->flags.hasRiverBeginOrEnd()) {
// A road may only join a river at a begin or end cell.
return Result<void>::success();
}
HexCellData* other = mutableCell(neighbour);
if (other->values.specialIndex() != 0 || data->values.specialIndex() != 0) return Result<void>::success();
if (other->flags.hasRiverThrough(opposite(direction))) return Result<void>::success();
const std::int32_t delta = elevation(neighbour) - elevation(c);
if (delta > 1 || delta < -1) return Result<void>::success();
data->flags = data->flags.withRoad(direction);
other->flags = other->flags.withRoad(opposite(direction));
refreshCellDependents(c);
refreshCellDependents(neighbour);
return Result<void>::success();
}
Result<void> HexMap::removeRoads(HexCoordinates c) {
HexCellData* data = mutableCell(c);
if (!data) return invalidArgument("cell is outside the hex map");
if (!data->flags.hasRoad()) return Result<void>::success();
data->flags = data->flags.without(HexFlags::roadMask());
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
HexCoordinates neighbour{};
if (!getNeighbor(c, direction, neighbour)) continue;
HexCellData* other = mutableCell(neighbour);
other->flags = other->flags.withoutRoad(opposite(direction));
refreshCellDependents(neighbour);
}
refreshCellDependents(c);
return Result<void>::success();
}
// --- brush authoring --------------------------------------------------------
void HexMap::collectBrush(HexCoordinates center, std::int32_t radius, std::vector<std::int32_t>& out) const {
out.clear();
if (!contains(center) || radius < 0) return;
// The brush reaches `radius` steps, so its offset-space footprint is at most
// `2 * radius` columns wide and `radius` rows tall.
const std::int32_t centerColumn = center.offsetX();
const std::int32_t minColumn = std::max(0, centerColumn - radius * 2);
const std::int32_t maxColumn = std::min(cellCountX_ - 1, centerColumn + radius * 2);
const std::int32_t minZ = std::max(0, center.z - radius);
const std::int32_t maxZ = std::min(cellCountZ_ - 1, center.z + radius);
for (std::int32_t z = minZ; z <= maxZ; ++z) {
for (std::int32_t column = minColumn; column <= maxColumn; ++column) {
const HexCoordinates candidate = HexCoordinates::fromOffset(column, z);
if (center.distanceTo(candidate) <= radius) out.push_back(column + z * cellCountX_);
}
}
}
Result<void> HexMap::editElevation(HexCoordinates center, std::int32_t radius, std::int32_t delta) {
if (!contains(center)) return invalidArgument("brush centre is outside the hex map");
if (radius < 0) return invalidArgument("brush radius must be non-negative");
std::vector<std::int32_t> cells;
collectBrush(center, radius, cells);
for (std::int32_t index : cells) {
const HexCoordinates coordinates = coordinatesAt(index);
const std::int32_t next = elevation(coordinates) + delta;
setElevation(coordinates, next).ignore("brush elevation edit clamps per cell");
}
return Result<void>::success();
}
Result<void> HexMap::editWaterLevel(HexCoordinates center, std::int32_t radius, std::int32_t delta) {
if (!contains(center)) return invalidArgument("brush centre is outside the hex map");
if (radius < 0) return invalidArgument("brush radius must be non-negative");
std::vector<std::int32_t> cells;
collectBrush(center, radius, cells);
for (std::int32_t index : cells) {
const HexCoordinates coordinates = coordinatesAt(index);
setWaterLevel(coordinates, waterLevel(coordinates) + delta).ignore("brush water edit clamps per cell");
}
return Result<void>::success();
}
Result<void> HexMap::editTerrainType(HexCoordinates center, std::int32_t radius, std::int32_t terrainType) {
if (!contains(center)) return invalidArgument("brush centre is outside the hex map");
if (radius < 0) return invalidArgument("brush radius must be non-negative");
std::vector<std::int32_t> cells;
collectBrush(center, radius, cells);
for (std::int32_t index : cells) {
const HexCoordinates coordinates = coordinatesAt(index);
setTerrainType(coordinates, terrainType).ignore("brush terrain edit clamps per cell");
}
return Result<void>::success();
}
Result<void> HexMap::editFeatureLevel(HexCoordinates center, std::int32_t radius, std::int32_t feature,
std::int32_t delta) {
if (!contains(center)) return invalidArgument("brush centre is outside the hex map");
if (radius < 0) return invalidArgument("brush radius must be non-negative");
std::vector<std::int32_t> cells;
collectBrush(center, radius, cells);
const std::int32_t layer = layerIndex(feature);
for (std::int32_t index : cells) {
const HexCoordinates coordinates = coordinatesAt(index);
const HexCellData* data = cellAt(index);
if (!data || data->flags.hasRiver()) continue;
if (data->values.specialIndex() != 0) continue;
const std::int32_t current = layer == 0 ? data->values.urbanLevel()
: layer == 1 ? data->values.farmLevel()
: data->values.plantLevel();
const std::int32_t next = clampInt(current + delta, 0, 3);
if (layer == 0)
setUrbanLevel(coordinates, next).ignore("brush urban edit clamps per cell");
else if (layer == 1)
setFarmLevel(coordinates, next).ignore("brush farm edit clamps per cell");
else
setPlantLevel(coordinates, next).ignore("brush plant edit clamps per cell");
}
return Result<void>::success();
}
} // namespace eve::hexmap
+316
View File
@@ -0,0 +1,316 @@
#pragma once
/** @file HexMap.h @brief Editable hex cell grid: topology, queries, picking and authoring. */
#include "common/Result.h"
#include "hexmap/HexCell.h"
#include "hexmap/HexCoordinates.h"
#include "hexmap/HexMetrics.h"
#include "hexmap/HexNoise.h"
#include <cstdint>
#include <vector>
namespace eve::hexmap {
/** @brief Surface streams a chunk mesh is generated for. */
enum class HexSurface : std::int32_t {
Terrain = 0,
Water = 1,
River = 2,
Road = 3,
/**
* @brief Fog-of-war overlay of a chunk.
*
* Unlike the other streams this one depends on `HexVisibility` as well as on
* the grid, so it is built by `buildFogMesh` and not by `buildChunkSurfaceMesh`.
*/
Fog = 4,
/** @brief City/farm walls, wall towers and bridges; built by `buildWallMesh`. */
Wall = 5,
/** @brief Urban, farm, plant and special decorations; built by `buildFeatureMesh`. */
Feature = 6,
};
/** @brief Number of chunk surface streams. */
inline constexpr std::int32_t kHexSurfaceCount = 7;
/** @brief Stable identifier of one chunk surface stream. */
struct HexSurfaceId {
std::int32_t chunkIndex = 0;
HexSurface surface = HexSurface::Terrain;
};
/**
* @brief An editable, chunked, pointy-top hex map.
*
* The grid is rectangular in odd-row offset space: `cellCountX` columns and
* `cellCountZ` rows, with odd rows shifted half a cell to the right. Cells are
* partitioned into `chunkSizeX * chunkSizeZ` chunks; every mutation marks the
* owning chunk (and the chunks of affected neighbours) dirty so a renderer can
* rebuild only what changed.
*
* Ownership and lifetime: `HexMap` owns its cell storage and its noise field and
* is a plain value type. It performs no I/O, opens no GPU resources and invokes
* no callbacks, so it is safe to hold on any single thread.
*
* Determinism: cell positions, perturbation and any future generation step are
* derived from the construction seed and the cell coordinates only. Two maps
* built with the same size and seed produce identical positions.
*/
class HexMap {
public:
HexMap() = default;
/**
* @brief Replaces the grid with a new `cellCountX * cellCountZ` map.
*
* Existing cells are discarded and every chunk is marked dirty. Cell count
* must be a positive multiple of the chunk size in each dimension because
* chunk meshes own the blend strips on their north and west borders.
*
* @param cellCountX Columns; positive multiple of `kChunkSizeX`.
* @param cellCountZ Rows; positive multiple of `kChunkSizeZ`.
* @param seed Deterministic seed for the noise field.
* @return Success, or InvalidArgument when a dimension is out of range.
*/
[[nodiscard]] Result<void> reset(std::int32_t cellCountX, std::int32_t cellCountZ, std::uint32_t seed);
/** @brief Whether the map holds any cell. */
[[nodiscard]] bool empty() const noexcept { return cells_.empty(); }
/** @brief Number of columns. */
[[nodiscard]] std::int32_t cellCountX() const noexcept { return cellCountX_; }
/** @brief Number of rows. */
[[nodiscard]] std::int32_t cellCountZ() const noexcept { return cellCountZ_; }
/** @brief Number of chunk columns. */
[[nodiscard]] std::int32_t chunkCountX() const noexcept { return chunkCountX_; }
/** @brief Number of chunk rows. */
[[nodiscard]] std::int32_t chunkCountZ() const noexcept { return chunkCountZ_; }
/** @brief Total number of chunks. */
[[nodiscard]] std::int32_t chunkCount() const noexcept { return chunkCountX_ * chunkCountZ_; }
/** @brief Total number of cells. */
[[nodiscard]] std::int32_t cellCount() const noexcept { return static_cast<std::int32_t>(cells_.size()); }
/** @brief The seed this map was built with. */
[[nodiscard]] std::uint32_t seed() const noexcept { return noise_.seed(); }
/** @brief Monotonic counter bumped by every mutation; used to detect stale meshes. */
[[nodiscard]] std::uint64_t revision() const noexcept { return revision_; }
/** @brief Deterministic noise field shared by this map's mesh builders. */
[[nodiscard]] const HexNoise& noise() const noexcept { return noise_; }
// --- topology -----------------------------------------------------------
/**
* @brief Whether `coordinates` address a cell inside the grid.
*
* The grid is the rectangle `[0, cellCountX) x [0, cellCountZ)` in
* **odd-row offset space**. An axial pair is inside only when its offset
* column `x + z/2` is in range, so cells with a negative axial X are valid
* members of the rectangle.
*/
[[nodiscard]] bool contains(HexCoordinates coordinates) const noexcept {
const std::int32_t column = coordinates.offsetX();
return column >= 0 && column < cellCountX_ && coordinates.z >= 0 && coordinates.z < cellCountZ_;
}
/** @brief Linear cell index of `coordinates` (offset order), or -1 when outside the grid. */
[[nodiscard]] std::int32_t indexOf(HexCoordinates coordinates) const noexcept;
/** @brief Coordinates of a linear cell index; out-of-range indices return `(0, 0)`. */
[[nodiscard]] HexCoordinates coordinatesAt(std::int32_t index) const noexcept;
/**
* @brief Cell record, or null when outside the grid.
* @ownership Borrowed; the map owns the record.
* @lifetime Valid until this map is destroyed or `reset` is called; any mutation
* of the map's cell storage invalidates the pointer.
*/
[[nodiscard]] const HexCellData* cell(HexCoordinates coordinates) const noexcept;
/**
* @brief Cell record by linear index, or null when out of range.
* @ownership Borrowed; the map owns the record.
* @lifetime Valid until this map is destroyed or `reset` is called; any mutation
* of the map's cell storage invalidates the pointer.
*/
[[nodiscard]] const HexCellData* cellAt(std::int32_t index) const noexcept;
/** @brief Neighbour of `coordinates` in `direction`; false when it is off-grid. */
[[nodiscard]] bool getNeighbor(HexCoordinates coordinates, HexDirection direction,
HexCoordinates& out) const noexcept;
/** @brief Relationship between two adjacent cells; Flat when not adjacent or out of range. */
[[nodiscard]] HexEdgeType edgeTypeTo(HexCoordinates a, HexCoordinates b) const noexcept;
// --- chunking -----------------------------------------------------------
/** @brief Chunk index owning `coordinates`, or -1 when outside the grid. */
[[nodiscard]] std::int32_t chunkIndexOf(HexCoordinates coordinates) const noexcept;
/** @brief Chunk column of a chunk index. */
[[nodiscard]] std::int32_t chunkColumnOf(std::int32_t chunkIndex) const noexcept;
/** @brief Chunk row of a chunk index. */
[[nodiscard]] std::int32_t chunkRowOf(std::int32_t chunkIndex) const noexcept;
/**
* @brief Coordinates of the cell at `(column, row)` inside a chunk.
*
* Chunks are squares in **odd-row offset space**, so every mesh builder must
* enumerate cells through this helper rather than constructing axial
* coordinates from the loop indices directly.
*
* @param chunkIndex Chunk to address.
* @param column Column inside the chunk, `[0, kChunkSizeX)`.
* @param row Row inside the chunk, `[0, kChunkSizeZ)`.
* @return The cell's axial coordinates (not range checked).
*/
[[nodiscard]] HexCoordinates chunkCell(std::int32_t chunkIndex, std::int32_t column,
std::int32_t row) const noexcept;
/** @brief World-space centre of a chunk, at the first cell's elevation. */
[[nodiscard]] HexVec3 chunkCenter(std::int32_t chunkIndex) const noexcept;
/** @brief Marks every chunk dirty. */
void markAllChunksDirty() noexcept;
/** @brief Marks one chunk and its direct chunk neighbours dirty. */
void markChunkDirtyAndNeighbors(std::int32_t chunkIndex) noexcept;
/**
* @brief Pops the next dirty chunk.
*
* @return The dirty chunk index, or -1 when nothing is pending.
* @note Callers loop until -1 is returned; the returned chunk is no longer
* reported dirty until it is mutated again.
*/
[[nodiscard]] std::int32_t takeDirtyChunk() noexcept;
/** @brief Number of chunks currently pending a rebuild. */
[[nodiscard]] std::int32_t dirtyChunkCount() const noexcept {
return static_cast<std::int32_t>(dirtyQueue_.size());
}
// --- geometry -----------------------------------------------------------
/** @brief Cell centre including elevation and vertical perturbation. */
[[nodiscard]] HexVec3 cellPosition(HexCoordinates coordinates) const noexcept;
/** @brief Cell centre at `y = 0` (no elevation, no perturbation). */
[[nodiscard]] HexVec3 cellGroundPosition(HexCoordinates coordinates) const noexcept {
return HexCoordinates::toWorldPosition(coordinates);
}
/**
* @brief Finds the cell under a world-space ray.
*
* The map has no collision geometry, so the ray is marched against the
* elevation plane of the candidate cell and refined until it converges.
*
* @param rayOrigin Ray origin in world space.
* @param rayDirection Normalised ray direction in world space.
* @return The hit cell, or NotFound when the ray misses the grid or points away.
*/
[[nodiscard]] Result<HexCoordinates> pickCell(HexVec3 rayOrigin, HexVec3 rayDirection) const noexcept;
// --- cell queries -------------------------------------------------------
[[nodiscard]] std::int32_t elevation(HexCoordinates c) const noexcept;
[[nodiscard]] std::int32_t waterLevel(HexCoordinates c) const noexcept;
[[nodiscard]] std::int32_t terrainType(HexCoordinates c) const noexcept;
[[nodiscard]] std::int32_t urbanLevel(HexCoordinates c) const noexcept;
[[nodiscard]] std::int32_t farmLevel(HexCoordinates c) const noexcept;
[[nodiscard]] std::int32_t plantLevel(HexCoordinates c) const noexcept;
[[nodiscard]] std::int32_t specialIndex(HexCoordinates c) const noexcept;
[[nodiscard]] bool isUnderwater(HexCoordinates c) const noexcept;
[[nodiscard]] bool hasRiver(HexCoordinates c) const noexcept;
[[nodiscard]] bool hasRoad(HexCoordinates c) const noexcept;
[[nodiscard]] bool hasRiverThrough(HexCoordinates c, HexDirection d) const noexcept;
[[nodiscard]] bool isWalled(HexCoordinates c) const noexcept;
/** @brief Whether a cell has ever been seen by a viewer (the fog-of-war latch). */
[[nodiscard]] bool isExplored(HexCoordinates c) const noexcept;
/** @brief Whether a cell may ever be revealed by a viewer. */
[[nodiscard]] bool isExplorable(HexCoordinates c) const noexcept;
/** @brief Full packed value record of a cell (zero when outside the grid). */
[[nodiscard]] HexValues values(HexCoordinates c) const noexcept;
/** @brief Full packed flag record of a cell (zero when outside the grid). */
[[nodiscard]] HexFlags flags(HexCoordinates c) const noexcept;
// --- cell authoring -----------------------------------------------------
/** @brief Sets a cell's elevation, clamped to the editable range, and refreshes dependents. */
[[nodiscard]] Result<void> setElevation(HexCoordinates c, std::int32_t elevation);
/** @brief Sets a cell's water level, clamped to `[0, maxElevation]`. */
[[nodiscard]] Result<void> setWaterLevel(HexCoordinates c, std::int32_t waterLevel);
/** @brief Sets a cell's terrain palette index, clamped to the palette range. */
[[nodiscard]] Result<void> setTerrainType(HexCoordinates c, std::int32_t terrainType);
/** @brief Sets a cell's urban level, clamped to `[0, 3]`. */
[[nodiscard]] Result<void> setUrbanLevel(HexCoordinates c, std::int32_t level);
/** @brief Sets a cell's farm level, clamped to `[0, 3]`. */
[[nodiscard]] Result<void> setFarmLevel(HexCoordinates c, std::int32_t level);
/** @brief Sets a cell's plant level, clamped to `[0, 3]`. */
[[nodiscard]] Result<void> setPlantLevel(HexCoordinates c, std::int32_t level);
/** @brief Sets a cell's special-feature index; ignored while the cell carries a river. */
[[nodiscard]] Result<void> setSpecialIndex(HexCoordinates c, std::int32_t index);
/** @brief Sets whether a cell is walled. */
[[nodiscard]] Result<void> setWalled(HexCoordinates c, bool walled);
/** @brief Sets the explored (fog-of-war) latch of a cell. */
[[nodiscard]] Result<void> setExplored(HexCoordinates c, bool explored);
/** @brief Sets whether a cell may ever be explored. */
[[nodiscard]] Result<void> setExplorable(HexCoordinates c, bool explorable);
/**
* @brief Writes a complete cell record verbatim.
*
* This is the restore path used by the save format. It deliberately skips the
* neighbour fix-ups of the individual authoring setters (`setOutgoingRiver`,
* `addRoad`, ...) because a payload carries both sides of every connection;
* the caller is responsible for a consistent payload and must finish with
* `markAllChunksDirty()`.
*
* @param c Cell to overwrite.
* @param values Packed values to store.
* @param flags Packed flags to store.
* @return Success, or InvalidArgument when `c` is outside the grid.
* @cost Marks the owning chunk and its neighbours dirty.
*/
[[nodiscard]] Result<void> setCellState(HexCoordinates c, HexValues values, HexFlags flags);
/** @brief Forces a river to leave the cell through `direction`, mirroring the neighbour. */
[[nodiscard]] Result<void> setOutgoingRiver(HexCoordinates c, HexDirection direction);
/** @brief Removes every river connection of the cell and its neighbours. */
[[nodiscard]] Result<void> removeRiver(HexCoordinates c);
/** @brief Adds a road through `direction` when both cells can carry one. */
[[nodiscard]] Result<void> addRoad(HexCoordinates c, HexDirection direction);
/** @brief Removes every road of the cell and its neighbours. */
[[nodiscard]] Result<void> removeRoads(HexCoordinates c);
// --- brush authoring ----------------------------------------------------
/**
* @brief Raises or lowers every cell within `radius` steps of `center`.
*
* @param center Brush centre.
* @param radius Hex distance reached by the brush; 0 edits only the centre.
* @param delta Elevation change; clamped per cell to the editable range.
* @return Success, or InvalidArgument when `center` is outside the grid or `radius` is negative.
*/
[[nodiscard]] Result<void> editElevation(HexCoordinates center, std::int32_t radius, std::int32_t delta);
/** @brief Applies `additive` water-level change over a hex brush. */
[[nodiscard]] Result<void> editWaterLevel(HexCoordinates center, std::int32_t radius, std::int32_t delta);
/** @brief Paints a terrain palette index over a hex brush. */
[[nodiscard]] Result<void> editTerrainType(HexCoordinates center, std::int32_t radius, std::int32_t terrainType);
/** @brief Applies `additive` change to one feature level (`0` urban, `1` farm, `2` plant). */
[[nodiscard]] Result<void> editFeatureLevel(HexCoordinates center, std::int32_t radius, std::int32_t feature,
std::int32_t delta);
/** @brief Enumerates the cells of a hex brush into `out` (cleared first). */
void collectBrush(HexCoordinates center, std::int32_t radius, std::vector<std::int32_t>& out) const;
private:
void markChunkDirty(std::int32_t chunkIndex) noexcept;
void refreshCellDependents(HexCoordinates coordinates) noexcept;
/**
* @brief Mutable cell record for an in-place edit.
* @ownership Borrowed; the map owns the record.
* @lifetime Valid until this map is destroyed or `reset` is called.
*/
HexCellData* mutableCell(HexCoordinates coordinates) noexcept;
void validateRivers(HexCoordinates coordinates) noexcept;
std::int32_t cellCountX_ = 0;
std::int32_t cellCountZ_ = 0;
std::int32_t chunkCountX_ = 0;
std::int32_t chunkCountZ_ = 0;
std::uint64_t revision_ = 0;
HexNoise noise_{};
std::vector<HexCellData> cells_;
std::vector<std::uint8_t> chunkDirty_;
std::vector<std::int32_t> dirtyQueue_;
};
} // namespace eve::hexmap
File diff suppressed because it is too large. Load diff
+103
View File
@@ -0,0 +1,103 @@
#pragma once
/** @file HexMapGenerator.h @brief Deterministic procedural map generation for the hex grid. */
#include "common/Result.h"
#include "hexmap/HexMap.h"
#include "hexmap/HexMetrics.h"
#include <cstdint>
namespace eve::hexmap {
/**
* @brief Tunables of the reference project's map generator.
*
* The defaults are the reference `MapGeneratorSettings` defaults, so a call with
* a bare settings value reproduces the Unity generator's behaviour.
*/
struct HexMapGeneratorSettings {
/** @brief Deterministic seed; the same seed and grid always produce the same map. */
std::uint32_t seed = 0u;
/** @brief Probability that a land seed starts one elevation step higher. */
float highRiseProbability = 0.25f;
/** @brief Probability that a land seed starts one elevation step lower. */
float sinkProbability = 0.2f;
/** @brief Probability that a region grows by a random block instead of a square. */
float jitterProbability = 0.25f;
/** @brief Smallest land-region growth step, in cells. */
std::int32_t chunkSizeMin = 30;
/** @brief Largest land-region growth step, in cells. */
std::int32_t chunkSizeMax = 100;
/** @brief Target share of the map that ends up above the water level, in percent. */
std::int32_t landPercentage = 50;
/** @brief Cells at or below this elevation are flooded. */
std::int32_t waterLevel = 3;
/** @brief Lowest elevation the generator writes. */
std::int32_t elevationMinimum = -2;
/** @brief Highest elevation the generator writes. */
std::int32_t elevationMaximum = 8;
/** @brief Unused border of the map in the X direction, in cells. */
std::int32_t mapBorderX = 5;
/** @brief Unused border of the map in the Z direction, in cells. */
std::int32_t mapBorderZ = 5;
/** @brief Gap kept between two generated regions, in cells. */
std::int32_t regionBorder = 5;
/** @brief Number of independent land regions. */
std::int32_t regionCount = 1;
/** @brief Share of the land that is eroded back towards the water level, in percent. */
std::int32_t erosionPercentage = 50;
/** @brief Moisture every cell starts with, in `[0, 1]`. */
float startingMoisture = 0.1f;
/** @brief Share of a cell's moisture that evaporates before it spreads. */
float evaporationFactor = 0.5f;
/** @brief Share of a cell's moisture that rains out as it spreads downwind. */
float precipitationFactor = 0.25f;
/** @brief Share of a cell's moisture that runs downhill instead of spreading. */
float runoffFactor = 0.25f;
/** @brief Share of a cell's moisture that seeps into the ground. */
float seepageFactor = 0.25f;
/** @brief Direction the wind blows towards, used by the moisture model. */
HexDirection windDirection = HexDirection::NW;
/** @brief Wind strength, in cells the moisture is pushed per step. */
float windStrength = 4.f;
/** @brief Target share of the map covered by rivers, in percent. */
std::int32_t riverPercentage = 10;
/** @brief Probability that a low point becomes an extra lake instead of land. */
float extraLakeProbability = 0.25f;
/** @brief Temperature at the lowest elevation. */
float lowTemperature = 0.f;
/** @brief Temperature at the highest elevation. */
float highTemperature = 1.f;
/** @brief Per-cell random spread added to the temperature. */
float temperatureJitter = 0.1f;
};
/**
* @brief Replaces the grid's contents with a procedurally generated map.
*
* The generator mirrors the reference project's pipeline: land regions grow from
* seeded cells with jitter and occasional rises/sinks, the coastline is eroded
* back towards the water level until the target land share is met, rivers are
* carved downhill, moisture is transported downwind to pick the terrain type,
* and plant levels follow the moisture.
*
* @param map Grid to fill. Its size and seed must already be set by
* `HexMap::reset`; the settings' own seed only drives the generator's
* internal choices, so two calls with the same settings and the same
* grid produce the same map.
* @param settings Generator tunables.
* @return Success, or InvalidArgument when the grid is empty.
* @cost Proportional to the cell count; allocates one scratch value per cell.
* @note Every cell is left *explorable but unexplored* and the whole map is
* marked dirty, so the caller rebuilds every chunk afterwards.
*/
[[nodiscard]] Result<void> generateHexMap(HexMap& map, const HexMapGeneratorSettings& settings);
} // namespace eve::hexmap
+25
View File
@@ -0,0 +1,25 @@
#include "hexmap/HexMapMesh.h"
namespace eve::hexmap {
void buildChunkSurfaceMesh(const HexMap& map, std::int32_t chunkIndex, HexSurface surface, HexMeshData& out) {
switch (surface) {
case HexSurface::Terrain: buildTerrainMesh(map, chunkIndex, out); return;
case HexSurface::Water: buildWaterMesh(map, chunkIndex, out); return;
case HexSurface::River: buildRiverMesh(map, chunkIndex, out); return;
case HexSurface::Road: buildRoadMesh(map, chunkIndex, out); return;
case HexSurface::Fog:
// The fog overlay also depends on the visibility counters, which a grid-only
// helper cannot see; `HexMapModule::rebuildChunk` dispatches it to `buildFogMesh`.
break;
case HexSurface::Wall:
case HexSurface::Feature:
// Both are declared by `HexFeatures.h` and dispatched by
// `HexMapModule::rebuildChunk`; `HexMapMesh` stays free of that header.
break;
}
out.clear();
out.finalize();
}
} // namespace eve::hexmap
+121
View File
@@ -0,0 +1,121 @@
#pragma once
/** @file HexMapMesh.h @brief Per-chunk mesh generation for every hex map surface. */
#include "hexmap/HexMap.h"
#include "hexmap/HexMeshData.h"
#include "hexmap/HexVisibility.h"
#include <cstdint>
namespace eve::hexmap {
/**
* @brief Encodes the three contributing cells of a terrain vertex.
*
* The vertex stream only carries two texture coordinates, so the terrain
* shader's inputs are packed into them:
*
* `u = terrainA + terrainB * 8 + terrainC * 64`
* `v = weightB + weightC * 16`
*
* Terrain indices are in `[0, 4]` and weights in `[0, 1]`, so both values stay
* well inside float32's exact-integer range and the shader recovers the inputs
* with `mod`/`fract`. This is a deliberate compatibility bridge for the current
* two-coordinate vertex layout and must not become the terrain data format.
*/
struct HexTerrainVertexCode {
/** @brief First texture coordinate, carrying the three terrain indices. */
[[nodiscard]] static float encodeIndices(std::int32_t a, std::int32_t b, std::int32_t c) noexcept {
return static_cast<float>(a) + static_cast<float>(b) * 8.f + static_cast<float>(c) * 64.f;
}
/** @brief Second texture coordinate, carrying the secondary/tertiary weights. */
[[nodiscard]] static float encodeWeights(float weightB, float weightC) noexcept { return weightB + weightC * 16.f; }
};
/** @brief Terrain-layer weights of a vertex; the three entries sum to 1. */
struct HexTerrainWeights {
float a = 1.f;
float b = 0.f;
float c = 0.f;
/** @brief Weights of the first cell alone. */
[[nodiscard]] static HexTerrainWeights primary() noexcept { return HexTerrainWeights{1.f, 0.f, 0.f}; }
/** @brief Weights of two cells, `t` towards the second. */
[[nodiscard]] static HexTerrainWeights blend(float t) noexcept { return HexTerrainWeights{1.f - t, t, 0.f}; }
/** @brief Weights of three cells, `t2`/`t3` towards the second and third. */
[[nodiscard]] static HexTerrainWeights blend3(float t2, float t3) noexcept {
return HexTerrainWeights{1.f - t2 - t3, t2, t3};
}
/** @brief Interpolates two weight sets. */
[[nodiscard]] static HexTerrainWeights lerp(const HexTerrainWeights& a, const HexTerrainWeights& b,
float t) noexcept {
return HexTerrainWeights{a.a + (b.a - a.a) * t, a.b + (b.b - a.b) * t, a.c + (b.c - a.c) * t};
}
};
/**
* @brief Builds the ground, terrace and cliff surface of one chunk.
*
* The result covers the chunk's own cells plus the blend strips it owns towards
* its NE, E and SE neighbours, matching the reference project's chunk-border
* ownership rule.
*
* @param map Source map; must contain `chunkIndex`.
* @param chunkIndex Chunk to build.
* @param out Destination mesh; cleared and finalized by this call.
*/
void buildTerrainMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out);
/**
* @brief Builds the water surface and shore band of one chunk.
*
* Only cells that are underwater contribute. `u` carries the shore parameter
* (`0` for open water, `1` at the shoreline) and `v` is reserved.
*/
void buildWaterMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out);
/**
* @brief Builds the river channel surface of one chunk.
*
* `u` runs along the channel and `v` across it, so the shader can animate flow.
*/
void buildRiverMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out);
/**
* @brief Builds the road surface of one chunk.
*
* `u` runs along the road and `v` across it.
*/
void buildRoadMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out);
/**
* @brief Builds one chunk surface by dispatching on `surface`.
*
* @param map Source map; must contain `chunkIndex`.
* @param chunkIndex Chunk to build.
* @param surface Which stream to generate; `HexSurface::Fog` is not supported here
* because it also needs the visibility state, use `buildFogMesh`.
* @param out Destination mesh; cleared and finalized by this call.
*/
void buildChunkSurfaceMesh(const HexMap& map, std::int32_t chunkIndex, HexSurface surface, HexMeshData& out);
/**
* @brief Builds the fog-of-war overlay of one chunk.
*
* Only cells that are *not* fully visible contribute: an unexplored cell is
* covered by an opaque dark column, an explored cell that no viewer currently
* sees is covered by a dimmer one. The column is a hexagonal prism rather than a
* flat cap so it also covers the cliff faces along a fog boundary.
*
* The single texture coordinate carries the fog shade (`0` explored-but-unseen,
* `1` unexplored); the second coordinate is reserved.
*
* @param map Source map; must contain `chunkIndex`.
* @param visibility Visibility counters of the same map.
* @param chunkIndex Chunk to build.
* @param out Destination mesh; cleared and finalized by this call.
*/
void buildFogMesh(const HexMap& map, const HexVisibility& visibility, std::int32_t chunkIndex, HexMeshData& out);
} // namespace eve::hexmap
+539
View File
@@ -0,0 +1,539 @@
#include "hexmap/HexMapModule.h"
#include "common/Diagnostic.h"
#include "common/SquirrelBinding.h"
#include "common/Value.h"
#include "graphics/Graphics.h"
#include "graphics/Mesh.h"
#include "hexmap/HexFeatures.h"
#include "hexmap/HexMapGenerator.h"
#include <simplesquirrel/simplesquirrel.hpp>
#include <functional>
#include <utility>
namespace eve::hexmap {
namespace {
[[nodiscard]] Result<void> invalidArgument(std::string message) {
return Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), "hexmap"));
}
[[nodiscard]] bool validDirection(std::int32_t direction) noexcept {
return direction >= 0 && direction < kHexDirectionCount;
}
/**
* @brief Converts the script-facing cell address into axial coordinates.
*
* Scripts address cells with odd-row **offset** `(column, row)` pairs, matching
* the reference editor's cell labels and the `hexmap` documentation.
*/
[[nodiscard]] HexCoordinates fromScriptCell(int x, int z) noexcept { return HexCoordinates::fromOffset(x, z); }
[[nodiscard]] HexDirection toDirection(std::int32_t direction) noexcept { return static_cast<HexDirection>(direction); }
} // namespace
Module_IMPL(HexMapModule, new HexMapModule());
HexMapModule::HexMapModule() = default;
HexMapModule::~HexMapModule() = default;
std::int32_t HexMapModule::meshSlot(std::int32_t chunkIndex, HexSurface surface) const noexcept {
return chunkIndex * kHexSurfaceCount + static_cast<std::int32_t>(surface);
}
void HexMapModule::clearMeshSlots() noexcept {
const auto slots = static_cast<std::size_t>(map_.chunkCount() * kHexSurfaceCount);
meshes_.assign(slots, nullptr);
meshPopulated_.assign(slots, 0u);
}
void HexMapModule::syncScratch() {
scratch_.resize(map_.cellCount());
}
Result<void> HexMapModule::generateMap(graphics::Graphics* gfx, std::uint32_t seed, std::int32_t landPercentage,
std::int32_t waterLevel, std::int32_t riverPercentage) {
if (!gfx) return invalidArgument("generateMap requires a graphics device");
if (map_.empty()) return invalidArgument("generateMap requires an existing grid");
if (!meshes_.empty()) releaseMeshes(gfx);
const std::int32_t cellCountX = map_.cellCountX();
const std::int32_t cellCountZ = map_.cellCountZ();
auto reset = map_.reset(cellCountX, cellCountZ, seed);
if (!reset) return reset;
visibility_.reset(map_.cellCount());
units_.removeAll(visibility_);
syncScratch();
clearMeshSlots();
HexMapGeneratorSettings settings;
settings.seed = seed;
settings.landPercentage = landPercentage;
settings.waterLevel = waterLevel;
settings.riverPercentage = riverPercentage;
return generateHexMap(map_, settings);
}
Result<void> HexMapModule::adoptGrid(graphics::Graphics* gfx, HexMap&& restored,
const std::vector<HexUnitState>& units) { if (!gfx) return invalidArgument("adoptGrid requires a graphics device");
if (restored.empty()) return invalidArgument("adoptGrid requires a non-empty grid");
if (!meshes_.empty()) releaseMeshes(gfx);
map_ = std::move(restored);
visibility_.reset(map_.cellCount());
units_.removeAll(visibility_);
syncScratch();
clearMeshSlots();
return units_.restore(map_, visibility_, scratch_, units);
}
void HexMapModule::releaseMeshes(graphics::Graphics* gfx) {
if (!gfx) return;
for (graphics::Mesh*& mesh : meshes_) {
if (!mesh) continue;
(void)gfx->releaseMesh(mesh);
mesh = nullptr;
}
meshPopulated_.assign(meshes_.size(), 0u);
}
Result<void> HexMapModule::newGrid(graphics::Graphics* gfx, std::int32_t cellCountX, std::int32_t cellCountZ,
std::uint32_t seed) {
if (!gfx) return invalidArgument("newGrid requires a graphics device");
if (!meshes_.empty()) releaseMeshes(gfx);
auto reset = map_.reset(cellCountX, cellCountZ, seed);
if (!reset) {
clearMeshSlots();
return reset;
}
visibility_.reset(map_.cellCount());
units_.removeAll(visibility_);
syncScratch();
clearMeshSlots();
return Result<void>::success();
}
graphics::Mesh* HexMapModule::chunkMeshAt(std::int32_t chunkIndex, HexSurface surface) const {
if (chunkIndex < 0 || chunkIndex >= map_.chunkCount()) return nullptr;
const std::int32_t slot = meshSlot(chunkIndex, surface);
if (slot < 0 || static_cast<std::size_t>(slot) >= meshes_.size()) return nullptr;
// A surface that lost all geometry keeps its last GPU mesh allocated but is
// no longer exposed, so a renderable can never be asked to draw stale data.
if (meshPopulated_[static_cast<std::size_t>(slot)] == 0u) return nullptr;
return meshes_[static_cast<std::size_t>(slot)];
}
std::int32_t HexMapModule::rebuildChunk(graphics::Graphics* gfx, std::int32_t chunkIndex) {
if (!gfx || map_.empty()) return 0;
if (chunkIndex < 0 || chunkIndex >= map_.chunkCount()) return 0;
std::int32_t rebuilt = 0;
HexMeshData meshData;
for (std::int32_t s = 0; s < kHexSurfaceCount; ++s) {
const auto surface = static_cast<HexSurface>(s);
const std::int32_t slot = meshSlot(chunkIndex, surface);
const auto slotIndex = static_cast<std::size_t>(slot);
graphics::Mesh*& mesh = meshes_[slotIndex];
std::uint8_t& populated = meshPopulated_[slotIndex];
if (surface == HexSurface::Fog) {
buildFogMesh(map_, visibility_, chunkIndex, meshData);
} else if (surface == HexSurface::Wall) {
buildWallMesh(map_, chunkIndex, meshData);
} else if (surface == HexSurface::Feature) {
buildFeatureMesh(map_, chunkIndex, meshData);
} else {
buildChunkSurfaceMesh(map_, chunkIndex, surface, meshData);
}
if (meshData.empty()) {
// The surface lost all geometry (for example the last road was removed).
// The GPU mesh is retired lazily: it stays allocated until the grid is
// replaced, so a renderable that still references it is never dangling.
populated = 0u;
continue;
}
const int vertexCount = static_cast<int>(meshData.vertexCount());
const int indexCount = static_cast<int>(meshData.indices().size());
const auto upload = [&]() {
return gfx->newMeshFromArrays(meshData.positions().data(), meshData.normals().data(), meshData.uvs().data(),
vertexCount, meshData.indices().data(), indexCount);
};
if (mesh &&
!gfx->updateMeshVertices(mesh, meshData.positions().data(), meshData.normals().data(),
meshData.uvs().data(), vertexCount, meshData.indices().data(), indexCount)) {
// The backend rejected the in-place update; recreate rather than keep stale geometry.
(void)gfx->releaseMesh(mesh);
mesh = nullptr;
}
if (!mesh) mesh = upload();
populated = mesh ? 1u : 0u;
if (mesh) ++rebuilt;
}
return rebuilt;
}
std::int32_t HexMapModule::rebuildDirtyChunks(graphics::Graphics* gfx) {
if (!gfx || map_.empty()) return 0;
std::int32_t rebuilt = 0;
for (;;) {
const std::int32_t chunkIndex = map_.takeDirtyChunk();
if (chunkIndex < 0) break;
rebuilt += rebuildChunk(gfx, chunkIndex);
}
return rebuilt;
}
// --- script bindings --------------------------------------------------------
void HexMapModule::expose(ssq::Table& table) {
// The script class is `eve.HexMap` (the manifest's SCRIPT name and the slot's
// class), which deliberately differs from this C++ module's name.
auto cls = table.addClass("HexMap", HexMapModule::create, false);
expose(cls);
}
void HexMapModule::expose(ssq::Class& cls) {
const auto vm = cls.getHandle();
cls.addFunc("newGrid", [vm](HexMapModule* self, graphics::Graphics* gfx, int cellCountX, int cellCountZ, int seed) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
if (seed < 0) seed = 0;
return script::projectResult(vm, self->newGrid(gfx, cellCountX, cellCountZ, static_cast<std::uint32_t>(seed)));
});
cls.addFunc("hasGrid", [](HexMapModule* self) { return self != nullptr && self->hasGrid(); });
cls.addFunc("cellCountX", [](HexMapModule* self) { return self ? self->map().cellCountX() : 0; });
cls.addFunc("cellCountZ", [](HexMapModule* self) { return self ? self->map().cellCountZ() : 0; });
cls.addFunc("chunkCountX", [](HexMapModule* self) { return self ? self->map().chunkCountX() : 0; });
cls.addFunc("chunkCountZ", [](HexMapModule* self) { return self ? self->map().chunkCountZ() : 0; });
cls.addFunc("chunkCount", [](HexMapModule* self) { return self ? self->map().chunkCount() : 0; });
cls.addFunc("seed", [](HexMapModule* self) { return self ? static_cast<std::int64_t>(self->map().seed()) : 0; });
cls.addFunc("surfaceCount", [](HexMapModule*) { return kHexSurfaceCount; });
cls.addFunc("dirtyChunkCount", [](HexMapModule* self) { return self ? self->map().dirtyChunkCount() : 0; });
cls.addFunc("markAllChunksDirty", [](HexMapModule* self) {
if (self) self->map().markAllChunksDirty();
});
cls.addFunc("takeDirtyChunk", [](HexMapModule* self) { return self ? self->map().takeDirtyChunk() : -1; });
cls.addFunc("rebuildChunk", [](HexMapModule* self, graphics::Graphics* gfx, int chunkIndex) {
return self ? self->rebuildChunk(gfx, chunkIndex) : 0;
});
cls.addFunc("rebuildDirtyChunks",
[](HexMapModule* self, graphics::Graphics* gfx) { return self ? self->rebuildDirtyChunks(gfx) : 0; });
cls.addFunc("chunkMeshAt", [](HexMapModule* self, int chunkIndex, int surface) -> graphics::Mesh* {
if (!self || surface < 0 || surface >= kHexSurfaceCount) return nullptr;
return self->chunkMeshAt(chunkIndex, static_cast<HexSurface>(surface));
});
cls.addFunc("releaseMeshes", [](HexMapModule* self, graphics::Graphics* gfx) {
if (self) self->releaseMeshes(gfx);
});
cls.addFunc("chunkCenterX",
[](HexMapModule* self, int chunkIndex) { return self ? self->map().chunkCenter(chunkIndex).x : 0.f; });
cls.addFunc("chunkCenterY",
[](HexMapModule* self, int chunkIndex) { return self ? self->map().chunkCenter(chunkIndex).y : 0.f; });
cls.addFunc("chunkCenterZ",
[](HexMapModule* self, int chunkIndex) { return self ? self->map().chunkCenter(chunkIndex).z : 0.f; });
// --- cell queries ---
cls.addFunc("elevation", [](HexMapModule* self, int x, int z) {
return self ? self->map().elevation(fromScriptCell(x, z)) : 0;
});
cls.addFunc("waterLevel", [](HexMapModule* self, int x, int z) {
return self ? self->map().waterLevel(fromScriptCell(x, z)) : 0;
});
cls.addFunc("terrainType", [](HexMapModule* self, int x, int z) {
return self ? self->map().terrainType(fromScriptCell(x, z)) : 0;
});
cls.addFunc("urbanLevel", [](HexMapModule* self, int x, int z) {
return self ? self->map().urbanLevel(fromScriptCell(x, z)) : 0;
});
cls.addFunc("farmLevel", [](HexMapModule* self, int x, int z) {
return self ? self->map().farmLevel(fromScriptCell(x, z)) : 0;
});
cls.addFunc("plantLevel", [](HexMapModule* self, int x, int z) {
return self ? self->map().plantLevel(fromScriptCell(x, z)) : 0;
});
cls.addFunc("specialIndex", [](HexMapModule* self, int x, int z) {
return self ? self->map().specialIndex(fromScriptCell(x, z)) : 0;
});
cls.addFunc("isUnderwater", [](HexMapModule* self, int x, int z) {
return self != nullptr && self->map().isUnderwater(fromScriptCell(x, z));
});
cls.addFunc("hasRiver", [](HexMapModule* self, int x, int z) {
return self != nullptr && self->map().hasRiver(fromScriptCell(x, z));
});
cls.addFunc("hasRoad", [](HexMapModule* self, int x, int z) {
return self != nullptr && self->map().hasRoad(fromScriptCell(x, z));
});
cls.addFunc("isWalled", [](HexMapModule* self, int x, int z) {
return self != nullptr && self->map().isWalled(fromScriptCell(x, z));
});
cls.addFunc("cellPositionX", [](HexMapModule* self, int x, int z) {
return self ? self->map().cellPosition(fromScriptCell(x, z)).x : 0.f;
});
cls.addFunc("cellPositionY", [](HexMapModule* self, int x, int z) {
return self ? self->map().cellPosition(fromScriptCell(x, z)).y : 0.f;
});
cls.addFunc("cellPositionZ", [](HexMapModule* self, int x, int z) {
return self ? self->map().cellPosition(fromScriptCell(x, z)).z : 0.f;
});
// --- picking ---
cls.addFunc("pickCell", [vm](HexMapModule* self, float ox, float oy, float oz, float dx, float dy, float dz) {
// Scripts address cells with odd-row **offset** `(column, row)` pairs, which
// is what every other binding converts through `fromScriptCell`. The picked
// cell has to come back in the same space, or feeding it straight back into
// `elevation(x, z)` / `editElevation(x, z, ...)` would address a different
// cell (and would report negative columns along the offset row's left edge).
const auto project = [](HexCoordinates coordinates) {
return Value(Value::Array{Value(static_cast<std::int64_t>(coordinates.offsetX())),
Value(static_cast<std::int64_t>(coordinates.offsetZ()))});
};
if (!self)
return script::projectResult(
vm,
Result<HexCoordinates>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "hex map module is not available", "hexmap.pick")),
project);
return script::projectResult(vm, self->map().pickCell(HexVec3{ox, oy, oz}, HexVec3{dx, dy, dz}), project);
});
// --- cell authoring ---
cls.addFunc("setElevation", [vm](HexMapModule* self, int x, int z, int value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setElevation(fromScriptCell(x, z), value));
});
cls.addFunc("setWaterLevel", [vm](HexMapModule* self, int x, int z, int value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setWaterLevel(fromScriptCell(x, z), value));
});
cls.addFunc("setTerrainType", [vm](HexMapModule* self, int x, int z, int value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setTerrainType(fromScriptCell(x, z), value));
});
cls.addFunc("setUrbanLevel", [vm](HexMapModule* self, int x, int z, int value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setUrbanLevel(fromScriptCell(x, z), value));
});
cls.addFunc("setFarmLevel", [vm](HexMapModule* self, int x, int z, int value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setFarmLevel(fromScriptCell(x, z), value));
});
cls.addFunc("setPlantLevel", [vm](HexMapModule* self, int x, int z, int value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setPlantLevel(fromScriptCell(x, z), value));
});
cls.addFunc("setSpecialIndex", [vm](HexMapModule* self, int x, int z, int value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setSpecialIndex(fromScriptCell(x, z), value));
});
cls.addFunc("setWalled", [vm](HexMapModule* self, int x, int z, bool value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setWalled(fromScriptCell(x, z), value));
});
cls.addFunc("setOutgoingRiver", [vm](HexMapModule* self, int x, int z, int direction) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
if (!validDirection(direction))
return script::projectResult(vm, invalidArgument("river direction must be in [0, 5]"));
return script::projectResult(vm, self->map().setOutgoingRiver(fromScriptCell(x, z), toDirection(direction)));
});
cls.addFunc("removeRiver", [vm](HexMapModule* self, int x, int z) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().removeRiver(fromScriptCell(x, z)));
});
cls.addFunc("addRoad", [vm](HexMapModule* self, int x, int z, int direction) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
if (!validDirection(direction))
return script::projectResult(vm, invalidArgument("road direction must be in [0, 5]"));
return script::projectResult(vm, self->map().addRoad(fromScriptCell(x, z), toDirection(direction)));
});
cls.addFunc("removeRoads", [vm](HexMapModule* self, int x, int z) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().removeRoads(fromScriptCell(x, z)));
});
// --- brush authoring ---
cls.addFunc("editElevation", [vm](HexMapModule* self, int x, int z, int radius, int delta) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().editElevation(fromScriptCell(x, z), radius, delta));
});
cls.addFunc("editWaterLevel", [vm](HexMapModule* self, int x, int z, int radius, int delta) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().editWaterLevel(fromScriptCell(x, z), radius, delta));
});
cls.addFunc("editTerrainType", [vm](HexMapModule* self, int x, int z, int radius, int terrainType) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().editTerrainType(fromScriptCell(x, z), radius, terrainType));
});
cls.addFunc("editFeatureLevel", [vm](HexMapModule* self, int x, int z, int radius, int feature, int delta) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().editFeatureLevel(fromScriptCell(x, z), radius, feature, delta));
});
// --- generation ---
cls.addFunc("generateMap", [vm](HexMapModule* self, graphics::Graphics* gfx, int seed, int landPercentage,
int waterLevel, int riverPercentage) {
if (!self || !gfx) return script::projectResult(vm, invalidArgument("hex map module is not available"));
if (seed < 0) seed = 0;
return script::projectResult(vm, self->generateMap(gfx, static_cast<std::uint32_t>(seed), landPercentage,
waterLevel, riverPercentage));
});
// --- fog of war ---
cls.addFunc("isExplored", [](HexMapModule* self, int x, int z) {
return self != nullptr && self->map().isExplored(fromScriptCell(x, z));
});
cls.addFunc("isExplorable", [](HexMapModule* self, int x, int z) {
return self != nullptr && self->map().isExplorable(fromScriptCell(x, z));
});
cls.addFunc("setExplored", [vm](HexMapModule* self, int x, int z, bool value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setExplored(fromScriptCell(x, z), value));
});
cls.addFunc("setExplorable", [vm](HexMapModule* self, int x, int z, bool value) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(vm, self->map().setExplorable(fromScriptCell(x, z), value));
});
cls.addFunc("isCellVisible", [](HexMapModule* self, int x, int z) {
if (!self) return false;
const std::int32_t index = self->map().indexOf(fromScriptCell(x, z));
return index >= 0 && self->visibility().isVisible(index);
});
cls.addFunc("visibleCellCount",
[](HexMapModule* self) { return self ? self->visibility().visibleCellCount() : 0; });
cls.addFunc("resetVisibility", [](HexMapModule* self) {
if (self) self->units().refreshVisibility(self->map(), self->visibility(), self->scratch());
});
// --- units ---
cls.addFunc("unitCount", [](HexMapModule* self) { return self ? self->units().unitCount() : 0; });
cls.addFunc("addUnit", [vm](HexMapModule* self, int x, int z, float orientation) {
if (!self) {
return script::projectResult(
vm,
Result<std::int32_t>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "hex map module is not available", "hexmap")),
[](std::int32_t id) { return Value(static_cast<std::int64_t>(id)); });
}
return script::projectResult(
vm, self->units().addUnit(self->map(), self->visibility(), self->scratch(), fromScriptCell(x, z), orientation),
[](std::int32_t id) { return Value(static_cast<std::int64_t>(id)); });
});
cls.addFunc("removeUnit", [vm](HexMapModule* self, int unitId) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
return script::projectResult(
vm, self->units().removeUnit(self->map(), self->visibility(), self->scratch(), unitId));
});
cls.addFunc("removeAllUnits", [](HexMapModule* self) {
if (self) self->units().removeAll(self->visibility());
});
cls.addFunc("unitIdAt", [](HexMapModule* self, int x, int z) {
return self ? self->units().unitIdAt(fromScriptCell(x, z)) : -1;
});
// Sample layout: [offsetX, offsetZ, worldX, worldY, worldZ, orientation, traveling].
cls.addFunc("unitSample", [vm](HexMapModule* self, int unitId) {
const auto project = [](const HexUnitSample& sample) {
return Value(Value::Array{
Value(static_cast<std::int64_t>(sample.location.offsetX())),
Value(static_cast<std::int64_t>(sample.location.offsetZ())),
Value(static_cast<double>(sample.position.x)),
Value(static_cast<double>(sample.position.y)),
Value(static_cast<double>(sample.position.z)),
Value(static_cast<double>(sample.orientation)),
Value(static_cast<std::int64_t>(sample.traveling ? 1 : 0)),
});
};
if (!self) {
return script::projectResult(
vm,
Result<HexUnitSample>::failure(
Diagnostic::error(DiagnosticCode::NotFound, "hex map module is not available", "hexmap")),
project);
}
return script::projectResult(vm, self->units().advance(self->map(), self->visibility(), self->scratch(), unitId, 0.f),
project);
});
cls.addFunc("travelUnit", [vm](HexMapModule* self, int unitId, ssq::Object path) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
auto owned = script::valueFromSquirrel(path);
if (!owned) return script::projectResult(vm, invalidArgument("unit path must be an array of [x, z] cells"));
if (!owned.value().isArray())
return script::projectResult(vm, invalidArgument("unit path must be an array of [x, z] cells"));
std::vector<std::int32_t> cells;
const Value& list = owned.value();
cells.reserve(list.arraySize());
for (std::size_t i = 0; i < list.arraySize(); ++i) {
const Value& entry = list.at(i);
if (!entry.isArray() || entry.arraySize() < 2)
return script::projectResult(vm, invalidArgument("unit path entries must be [x, z] pairs"));
const Value& ax = entry.at(0);
const Value& az = entry.at(1);
if (!ax.isNumeric() || !az.isNumeric())
return script::projectResult(vm, invalidArgument("unit path entries must hold numeric cells"));
const std::int32_t index =
self->map().indexOf(fromScriptCell(static_cast<int>(ax.asInt()), static_cast<int>(az.asInt())));
if (index < 0) return script::projectResult(vm, invalidArgument("unit path leaves the hex map"));
cells.push_back(index);
}
return script::projectResult(vm,
self->units().beginTravel(self->map(), self->visibility(), self->scratch(), unitId,
cells));
});
cls.addFunc("advanceUnits", [](HexMapModule* self, float dt) {
if (self) self->units().advanceAll(self->map(), self->visibility(), self->scratch(), dt);
});
// --- pathfinding ---
// Result layout: an array of [offsetX, offsetZ, turn] triples from origin to goal.
cls.addFunc("findPath", [vm](HexMapModule* self, int fromX, int fromZ, int toX, int toZ) {
const auto project = [self](const HexPath& path) {
Value::Array entries;
entries.reserve(path.size());
for (std::size_t i = 0; i < path.cells.size(); ++i) {
const HexCoordinates coordinates = self ? self->map().coordinatesAt(path.cells[i]) : HexCoordinates{};
const std::int32_t turn = i < path.turns.size() ? path.turns[i] : 0;
entries.push_back(Value(Value::Array{Value(static_cast<std::int64_t>(coordinates.offsetX())),
Value(static_cast<std::int64_t>(coordinates.offsetZ())),
Value(static_cast<std::int64_t>(turn))}));
}
return Value(std::move(entries));
};
if (!self) {
return script::projectResult(
vm,
Result<HexPath>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "hex map module is not available", "hexmap")),
project);
}
return script::projectResult(
vm,
findPath(self->map(), self->scratch(), fromScriptCell(fromX, fromZ), fromScriptCell(toX, toZ),
self->units().tuning().moveRules(), self->units().occupancyQuery()),
project);
});
// --- persistence ---
cls.addFunc("saveMap", [vm](HexMapModule* self) {
if (!self) return script::projectResult(vm, invalidArgument("hex map module is not available"));
std::vector<std::uint8_t> bytes;
auto saved = saveHexMap(self->map(), self->units().snapshot(), bytes);
if (!saved) return script::projectResult(vm, std::move(saved));
return script::projectStatusResult(
vm, saved.status(), true, true,
Value::string(std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size())));
});
cls.addFunc("loadMap", [vm](HexMapModule* self, graphics::Graphics* gfx, std::string blob) {
if (!self || !gfx) return script::projectResult(vm, invalidArgument("hex map module is not available"));
std::vector<std::uint8_t> bytes(blob.begin(), blob.end());
HexMap restored;
std::vector<HexUnitState> units;
auto loaded = loadHexMap(bytes, restored, units);
if (!loaded) return script::projectResult(vm, std::move(loaded));
return script::projectResult(vm, self->adoptGrid(gfx, std::move(restored), units));
});
}
} // namespace eve::hexmap
+172
View File
@@ -0,0 +1,172 @@
#pragma once
/** @file HexMapModule.h @brief Script-facing hex map module: one editable map plus its chunk meshes. */
#include "common/Module.h"
#include "common/Result.h"
#include "hexmap/HexMap.h"
#include "hexmap/HexMapMesh.h"
#include "hexmap/HexSearch.h"
#include "hexmap/HexSerializer.h"
#include "hexmap/HexUnits.h"
#include "hexmap/HexVisibility.h"
#include <cstdint>
#include <vector>
namespace eve::graphics {
class Graphics;
class Mesh;
} // namespace eve::graphics
namespace eve::hexmap {
/**
* @brief Owns the active hex map and the GPU meshes generated from it.
*
* The module exposes exactly one editable map to scripts (the reference
* hex-map editor also edits a single grid). `newGrid` replaces it and releases
* the meshes generated from the previous map.
*
* Ownership and lifetime:
* - The module owns `HexMap` and every `graphics::Mesh` it created.
* - `chunkMeshAt` returns a borrowed mesh; a `Renderable3D` may reference it
* only while this module is alive and the grid has not been replaced.
* - `releaseMeshes` must be called with the same `Graphics` device that created
* the meshes, before that device is destroyed.
*
* Thread affinity: main/render thread only; no callbacks are invoked and no
* lock is held across an external call.
*/
class HexMapModule : public Module {
public:
Module_REG(HexMapModule);
/** @brief Constructs an empty module with no map and no meshes. */
HexMapModule();
/** @brief Destructor; GPU meshes must already have been released. */
~HexMapModule() override;
/**
* @brief Replaces the active grid and releases the meshes of the previous one.
*
* @param gfx Graphics device used to release previously created meshes.
* @param cellCountX Columns; must be a positive multiple of the chunk size.
* @param cellCountZ Rows; must be a positive multiple of the chunk size.
* @param seed Deterministic seed for the cell noise field.
* @return Success, or InvalidArgument for an unsupported size.
* @cost Releases every owned mesh and allocates `cellCountX * cellCountZ` cells;
* proportional to the map area, amortized over a whole-map rebuild.
*/
[[nodiscard]] Result<void> newGrid(graphics::Graphics* gfx, std::int32_t cellCountX, std::int32_t cellCountZ,
std::uint32_t seed);
/**
* @brief Rebuilds every chunk surface currently marked dirty.
*
* @param gfx Graphics device that owns the chunk meshes.
* @return Number of chunk surfaces that were (re)generated.
* @cost One CPU mesh build plus one vertex-buffer update per dirty surface.
*/
std::int32_t rebuildDirtyChunks(graphics::Graphics* gfx);
/**
* @brief Rebuilds all surfaces of one chunk.
*
* @param gfx Graphics device that owns the chunk meshes.
* @param chunkIndex Chunk to regenerate.
* @return Number of surfaces that produced geometry.
* @cost One CPU mesh build plus one vertex-buffer update per surface.
*/
std::int32_t rebuildChunk(graphics::Graphics* gfx, std::int32_t chunkIndex);
/**
* @brief Borrowed GPU mesh of one chunk surface.
*
* @param chunkIndex Chunk index in `[0, chunkCount())`.
* @param surface Surface stream to query.
* @return The mesh, or null when that surface has no geometry or was never built.
* @ownership Borrowed; the module retains ownership until `releaseMeshes`.
*/
[[nodiscard]] graphics::Mesh* chunkMeshAt(std::int32_t chunkIndex, HexSurface surface) const;
/**
* @brief Releases every mesh owned by the module.
* @param gfx Device that created them; null releases nothing.
* @note The script must call this before the grid is replaced by a different
* device or before shutdown; `newGrid` only releases through itself.
*/
void releaseMeshes(graphics::Graphics* gfx);
/** @brief The active map. */
[[nodiscard]] const HexMap& map() const noexcept { return map_; }
/** @brief The active map (mutable, for internal build steps). */
[[nodiscard]] HexMap& map() noexcept { return map_; }
/** @brief Whether a grid has been created. */
[[nodiscard]] bool hasGrid() const noexcept { return !map_.empty(); }
/** @brief Fog-of-war counters of the active map. */
[[nodiscard]] HexVisibility& visibility() noexcept { return visibility_; }
/** @brief Units of the active map. */
[[nodiscard]] HexUnitRegistry& units() noexcept { return units_; }
/** @brief Search scratch sized to the active map. */
[[nodiscard]] HexSearchContext& scratch() noexcept { return scratch_; }
/**
* @brief Replaces the active grid with an already-decoded map.
*
* The load path: the payload was decoded into `restored` before this call, so
* the module only has to release the meshes of the previous grid, adopt the
* new one and rebuild its derived state (scratch, fog of war, mesh slots).
* The caller must follow it with a whole-map rebuild, because every chunk of
* the adopted grid starts dirty and no renderable points at the new meshes.
*
* @param gfx Device that owns the current meshes; required.
* @param restored Decoded grid, moved into the module.
* @param units Unit states to restore on the adopted grid.
* @return Success, or InvalidArgument when the device or unit states are invalid.
* @cost Releases every owned mesh and allocates the derived state for the new grid.
*/
[[nodiscard]] Result<void> adoptGrid(graphics::Graphics* gfx, HexMap&& restored,
const std::vector<HexUnitState>& units);
/**
* @brief Re-sizes the search scratch to the active map.
* @cost Proportional to the cell count; called by `newGrid` and by load.
*/
void syncScratch();
/**
* @brief Regenerates the active grid procedurally and drops every unit.
*
* The grid is rebuilt from `seed` first, because the terrain perturbation has
* to match the generation seed; that releases every mesh, unit and visibility
* counter of the previous grid. The caller must rebuild every chunk afterwards
* and place new units: a generated map has **no explored cells**, so nothing is
* visible until a unit grants vision.
*
* @param gfx Device that owns the current meshes; required.
* @param seed Deterministic seed for both the grid noise and the generator.
* @param landPercentage Target share of land, in percent; clamped by the generator.
* @param waterLevel Cells at or below this elevation are flooded; clamped by the generator.
* @param riverPercentage Target share of the map covered by rivers, in percent; clamped by the generator.
* @return Success, or InvalidArgument when there is no grid or no device, or a
* failure forwarded from the generator.
* @cost Releases every owned mesh and allocates the whole grid; proportional to the cell count.
*/
[[nodiscard]] Result<void> generateMap(graphics::Graphics* gfx, std::uint32_t seed, std::int32_t landPercentage,
std::int32_t waterLevel, std::int32_t riverPercentage);
private:
[[nodiscard]] std::int32_t meshSlot(std::int32_t chunkIndex, HexSurface surface) const noexcept;
void clearMeshSlots() noexcept;
HexMap map_{};
std::vector<graphics::Mesh*> meshes_{};
std::vector<std::uint8_t> meshPopulated_{};
HexSearchContext scratch_{};
HexVisibility visibility_{};
HexUnitRegistry units_{};
};
} // namespace eve::hexmap
+68
View File
@@ -0,0 +1,68 @@
#include "hexmap/HexMeshData.h"
#include <cmath>
namespace eve::hexmap {
void HexMeshData::clear() noexcept {
positions_.clear();
normals_.clear();
uvs_.clear();
indices_.clear();
}
std::uint32_t HexMeshData::addVertex(HexVec3 position, float u, float v) {
const auto index = static_cast<std::uint32_t>(positions_.size() / 3u);
positions_.push_back(position.x);
positions_.push_back(position.y);
positions_.push_back(position.z);
uvs_.push_back(u);
uvs_.push_back(v);
return index;
}
void HexMeshData::addTriangle(std::uint32_t a, std::uint32_t b, std::uint32_t c) {
indices_.push_back(a);
indices_.push_back(b);
indices_.push_back(c);
}
void HexMeshData::addQuad(std::uint32_t a, std::uint32_t b, std::uint32_t c, std::uint32_t d) {
addTriangle(a, c, b);
addTriangle(b, c, d);
}
void HexMeshData::finalize() noexcept {
normals_.assign(positions_.size(), 0.f);
for (std::size_t t = 0; t + 2 < indices_.size(); t += 3) {
const std::uint32_t ia = indices_[t];
const std::uint32_t ib = indices_[t + 1];
const std::uint32_t ic = indices_[t + 2];
const auto at = [this](std::uint32_t i) {
const std::size_t base = static_cast<std::size_t>(i) * 3u;
return HexVec3{positions_[base], positions_[base + 1], positions_[base + 2]};
};
const HexVec3 a = at(ia);
const HexVec3 b = at(ib);
const HexVec3 c = at(ic);
const HexVec3 ab = b - a;
const HexVec3 ac = c - a;
HexVec3 n{ab.y * ac.z - ab.z * ac.y, ab.z * ac.x - ab.x * ac.z, ab.x * ac.y - ab.y * ac.x};
const float len = std::sqrt(n.x * n.x + n.y * n.y + n.z * n.z);
if (len > 1e-8f) {
n.x /= len;
n.y /= len;
n.z /= len;
} else {
n = HexVec3{0.f, 1.f, 0.f};
}
for (std::uint32_t i : {ia, ib, ic}) {
const std::size_t base = static_cast<std::size_t>(i) * 3u;
normals_[base] = n.x;
normals_[base + 1] = n.y;
normals_[base + 2] = n.z;
}
}
}
} // namespace eve::hexmap
+74
View File
@@ -0,0 +1,74 @@
#pragma once
/** @file HexMeshData.h @brief CPU vertex/index container shared by the hex surface builders. */
#include "hexmap/HexMetrics.h"
#include <cstdint>
#include <vector>
namespace eve::hexmap {
/**
* @brief CPU-side triangle soup for one hex chunk surface.
*
* Vertices are intentionally not shared between triangles: the reference
* hex-map mesh builder emits every triangle with its own three vertices, which
* keeps the blend/terrace/cliff seams crisp and makes flat face normals exact.
* `finalize()` therefore computes per-face normals and duplicates them across
* the three vertices of each triangle.
*
* @note Owns plain host memory; upload is the caller's responsibility
* (`gfx.newMeshFromArrays` / `gfx.updateMeshVertices`).
*/
class HexMeshData {
public:
/** @brief Removes every vertex and index, keeping the allocated capacity. */
void clear() noexcept;
/** @brief Whether the mesh has no triangles. */
[[nodiscard]] bool empty() const noexcept { return indices_.empty(); }
/** @brief Number of vertices. */
[[nodiscard]] std::size_t vertexCount() const noexcept { return positions_.size() / 3u; }
/** @brief Number of triangles. */
[[nodiscard]] std::size_t triangleCount() const noexcept { return indices_.size() / 3u; }
/**
* @brief Appends one vertex.
*
* @param position Vertex position in world space. Callers apply noise
* perturbation themselves so a vertex is never displaced twice.
* @param u First texture coordinate (surface-specific encoding).
* @param v Second texture coordinate (surface-specific encoding).
* @return Index of the new vertex.
*/
std::uint32_t addVertex(HexVec3 position, float u, float v);
/** @brief Appends a triangle with counter-clockwise winding. */
void addTriangle(std::uint32_t a, std::uint32_t b, std::uint32_t c);
/** @brief Appends a quad as two triangles (`a,c,b` and `b,c,d`). */
void addQuad(std::uint32_t a, std::uint32_t b, std::uint32_t c, std::uint32_t d);
/** @brief Computes flat per-face normals. Must be called before upload. */
void finalize() noexcept;
/** @brief Interleaved-free position stream (`xyz` per vertex). */
[[nodiscard]] const std::vector<float>& positions() const noexcept { return positions_; }
/** @brief Normal stream (`xyz` per vertex), valid after `finalize()`. */
[[nodiscard]] const std::vector<float>& normals() const noexcept { return normals_; }
/** @brief Texture coordinate stream (`uv` per vertex). */
[[nodiscard]] const std::vector<float>& uvs() const noexcept { return uvs_; }
/** @brief Triangle index stream. */
[[nodiscard]] const std::vector<std::uint32_t>& indices() const noexcept { return indices_; }
/** @brief Whether `finalize()` produced a normal for every vertex. */
[[nodiscard]] bool hasNormals() const noexcept { return normals_.size() == positions_.size(); }
private:
std::vector<float> positions_;
std::vector<float> normals_;
std::vector<float> uvs_;
std::vector<std::uint32_t> indices_;
};
} // namespace eve::hexmap
+315
View File
@@ -0,0 +1,315 @@
#pragma once
/** @file HexMetrics.h @brief Pointy-top hex metrics, directions and vertex helpers. */
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace eve::hexmap {
/**
* @brief Minimal 3-component float vector used by the hex mesh builders.
*
* The hex map needs only a handful of vector operations, so it carries its own
* value type and keeps rendering-backend types out of its public headers; see the
* module boundary rules in AGENTS.md.
*/
struct HexVec3 {
float x = 0.f;
float y = 0.f;
float z = 0.f;
};
[[nodiscard]] constexpr HexVec3 operator+(HexVec3 a, HexVec3 b) noexcept {
return HexVec3{a.x + b.x, a.y + b.y, a.z + b.z};
}
[[nodiscard]] constexpr HexVec3 operator-(HexVec3 a, HexVec3 b) noexcept {
return HexVec3{a.x - b.x, a.y - b.y, a.z - b.z};
}
[[nodiscard]] constexpr HexVec3 operator*(HexVec3 a, float s) noexcept { return HexVec3{a.x * s, a.y * s, a.z * s}; }
[[nodiscard]] constexpr HexVec3 operator*(float s, HexVec3 a) noexcept { return a * s; }
[[nodiscard]] constexpr HexVec3& operator+=(HexVec3& a, HexVec3 b) noexcept {
a = a + b;
return a;
}
/** @brief Four-component float vector, matching the reference four-channel noise sample. */
struct HexVec4 {
float x = 0.f;
float y = 0.f;
float z = 0.f;
float w = 0.f;
};
/** @brief Linear interpolation between two positions. */
[[nodiscard]] constexpr HexVec3 lerp(HexVec3 a, HexVec3 b, float t) noexcept {
return HexVec3{a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t};
}
/** @brief Blends the three terrain-layer weights of a hex vertex. */
[[nodiscard]] constexpr HexVec3 weightsLerp(HexVec3 a, HexVec3 b, float t) noexcept { return lerp(a, b, t); }
/** @brief Hex facing directions, counter-clockwise from north-east. */
enum class HexDirection : std::int32_t { NE = 0, E = 1, SE = 2, SW = 3, W = 4, NW = 5 };
/** @brief Number of hex edges / facing directions. */
inline constexpr std::int32_t kHexDirectionCount = 6;
/** @brief The direction opposite to `d`. */
[[nodiscard]] constexpr HexDirection opposite(HexDirection d) noexcept {
const auto v = static_cast<std::int32_t>(d);
return static_cast<HexDirection>(v < 3 ? v + 3 : v - 3);
}
/** @brief The next direction counter-clockwise (NE wraps to NW). */
[[nodiscard]] constexpr HexDirection previous(HexDirection d) noexcept {
return d == HexDirection::NE ? HexDirection::NW : static_cast<HexDirection>(static_cast<int>(d) - 1);
}
/** @brief The next direction clockwise (NW wraps to NE). */
[[nodiscard]] constexpr HexDirection next(HexDirection d) noexcept {
return d == HexDirection::NW ? HexDirection::NE : static_cast<HexDirection>(static_cast<int>(d) + 1);
}
/** @brief Two steps counter-clockwise. */
[[nodiscard]] constexpr HexDirection previous2(HexDirection d) noexcept {
const auto v = static_cast<std::int32_t>(d) - 2;
return static_cast<HexDirection>(v < 0 ? v + 6 : v);
}
/** @brief Two steps clockwise. */
[[nodiscard]] constexpr HexDirection next2(HexDirection d) noexcept {
const auto v = static_cast<std::int32_t>(d) + 2;
return static_cast<HexDirection>(v > 5 ? v - 6 : v);
}
/** @brief Relationship between two neighbouring cells of different elevation. */
enum class HexEdgeType : std::int32_t { Flat = 0, Slope = 1, Cliff = 2 };
/** @brief The relationship between two elevations (single-step changes are slopes). */
[[nodiscard]] constexpr HexEdgeType edgeType(int elevation1, int elevation2) noexcept {
if (elevation1 == elevation2) return HexEdgeType::Flat;
const int delta = elevation2 - elevation1;
if (delta == 1 || delta == -1) return HexEdgeType::Slope;
return HexEdgeType::Cliff;
}
/**
* @brief Hexagon metrics shared by the map model and every mesh builder.
*
* The layout matches the Catlike Coding "Hex Map" reference project: pointy-top
* hexes on the XZ plane, +X east, +Z north, odd rows shifted half a cell to the
* right, Y up. All radii are expressed in world units; `outerRadius` is settable
* so a caller can rescale the whole map without touching the derived ratios.
*/
class HexMetrics {
public:
/** @brief Ratio of the outer radius to the inner radius. */
static constexpr float kOuterToInner = 0.866025404f;
/** @brief Ratio of the inner radius to the outer radius. */
static constexpr float kInnerToOuter = 1.f / kOuterToInner;
/** @brief Default outer (corner) radius of one hex cell. */
static constexpr float kDefaultOuterRadius = 10.f;
/** @brief Vertical distance between two elevation levels. */
static constexpr float kElevationStep = 3.f;
/** @brief Terraces generated per slope. */
static constexpr int kTerracesPerSlope = 2;
/** @brief Terrace interpolation steps per slope (`terracesPerSlope * 2 + 1`). */
static constexpr int kTerraceSteps = kTerracesPerSlope * 2 + 1;
/** @brief Horizontal fraction of one terrace interpolation step. */
static constexpr float kHorizontalTerraceStepSize = 1.f / static_cast<float>(kTerraceSteps);
/** @brief Vertical fraction of one terrace interpolation step. */
static constexpr float kVerticalTerraceStepSize = 1.f / static_cast<float>(kTerracesPerSlope + 1);
/** @brief Factor of the solid, uniform-colour hex inscribed in a cell. */
static constexpr float kSolidFactor = 0.8f;
/** @brief Factor of the blend region between two neighbouring cells. */
static constexpr float kBlendFactor = 1.f - kSolidFactor;
/** @brief Factor of the solid water surface inscribed in a cell. */
static constexpr float kWaterFactor = 0.6f;
/** @brief Factor of the water blend region between two cells. */
static constexpr float kWaterBlendFactor = 1.f - kWaterFactor;
/** @brief Strength of the XZ position perturbation. */
static constexpr float kCellPerturbStrength = 4.f;
/** @brief Strength of the vertical elevation perturbation. */
static constexpr float kElevationPerturbStrength = 1.5f;
/** @brief Elevation offset of a carved river bed relative to the cell. */
static constexpr float kStreamBedElevationOffset = -1.75f;
/** @brief Elevation offset of a water surface relative to the water level. */
static constexpr float kWaterElevationOffset = -0.5f;
/** @brief Width of the river channel. */
static constexpr float kRiverSurfaceScale = 0.6f;
/** @brief Height of a city/farm wall. */
static constexpr float kWallHeight = 4.f;
/** @brief Vertical wall offset; negative so walls do not float above the surface. */
static constexpr float kWallYOffset = -1.f;
/** @brief Wall thickness. */
static constexpr float kWallThickness = 0.75f;
/** @brief Wall elevation offset, matching the vertical terrace step size. */
static constexpr float kWallElevationOffset = kVerticalTerraceStepSize;
/** @brief Chunk size in the X dimension. */
static constexpr int kChunkSizeX = 5;
/** @brief Chunk size in the Z dimension. */
static constexpr int kChunkSizeZ = 5;
/** @brief Minimum editable elevation. */
static constexpr int kMinElevation = -4;
/** @brief Maximum editable elevation. */
static constexpr int kMaxElevation = 8;
/** @brief Outer (corner) radius of one hex cell in world units. */
static constexpr float kOuterRadius = kDefaultOuterRadius;
/** @brief Inner (edge) radius of one hex cell in world units. */
static constexpr float kInnerRadius = kDefaultOuterRadius * kOuterToInner;
/** @brief Width of one hex column. */
static constexpr float kInnerDiameter = kInnerRadius * 2.f;
/** @brief Row spacing between hex rows. */
static constexpr float kRowSpacing = kDefaultOuterRadius * 1.5f;
/** @brief Radius of a hex cell in world units. */
[[nodiscard]] static constexpr float outerRadius() noexcept { return kOuterRadius; }
/** @brief Inner radius of a hex cell. */
[[nodiscard]] static constexpr float innerRadius() noexcept { return kInnerRadius; }
/** @brief Width of one hex column. */
[[nodiscard]] static constexpr float innerDiameter() noexcept { return kInnerDiameter; }
/** @brief Row spacing between hex rows. */
[[nodiscard]] static constexpr float rowSpacing() noexcept { return kRowSpacing; }
/** @brief Outer corner `direction` of a unit hex, scaled by `scale`. */
[[nodiscard]] static HexVec3 corner(HexDirection direction, float scale = 1.f) noexcept {
const float r = kOuterRadius * scale;
const float i = kInnerRadius * scale;
switch (direction) {
case HexDirection::NE: return HexVec3{0.f, 0.f, r};
case HexDirection::E: return HexVec3{i, 0.f, 0.5f * r};
case HexDirection::SE: return HexVec3{i, 0.f, -0.5f * r};
case HexDirection::SW: return HexVec3{0.f, 0.f, -r};
case HexDirection::W: return HexVec3{-i, 0.f, -0.5f * r};
case HexDirection::NW: return HexVec3{-i, 0.f, 0.5f * r};
}
return HexVec3{0.f, 0.f, r};
}
/** @brief First (counter-clockwise) outer corner of `direction`. */
[[nodiscard]] static HexVec3 firstCorner(HexDirection d) noexcept { return corner(d); }
/** @brief Second (clockwise) outer corner of `direction`. */
[[nodiscard]] static HexVec3 secondCorner(HexDirection d) noexcept { return corner(next(d)); }
/** @brief First solid corner of `direction`. */
[[nodiscard]] static HexVec3 firstSolidCorner(HexDirection d) noexcept { return corner(d, kSolidFactor); }
/** @brief Second solid corner of `direction`. */
[[nodiscard]] static HexVec3 secondSolidCorner(HexDirection d) noexcept { return corner(next(d), kSolidFactor); }
/** @brief Midpoint of the solid edge of `direction`. */
[[nodiscard]] static HexVec3 solidEdgeMiddle(HexDirection d) noexcept {
return (corner(d) + corner(next(d))) * (0.5f * kSolidFactor);
}
/** @brief First water corner of `direction`. */
[[nodiscard]] static HexVec3 firstWaterCorner(HexDirection d) noexcept { return corner(d, kWaterFactor); }
/** @brief Second water corner of `direction`. */
[[nodiscard]] static HexVec3 secondWaterCorner(HexDirection d) noexcept { return corner(next(d), kWaterFactor); }
/** @brief Bridge vector from the solid edge of `direction` to the neighbour. */
[[nodiscard]] static HexVec3 bridge(HexDirection d) noexcept {
return (corner(d) + corner(next(d))) * kBlendFactor;
}
/** @brief Bridge vector from the water edge of `direction` to the neighbour. */
[[nodiscard]] static HexVec3 waterBridge(HexDirection d) noexcept {
return (corner(d) + corner(next(d))) * kWaterBlendFactor;
}
/**
* @brief Interpolates a position along a terraced slope.
*
* @param a Start position (low side).
* @param b End position (high side).
* @param step Terrace step in `[0, kTerraceSteps]`.
*/
[[nodiscard]] static HexVec3 terraceLerp(HexVec3 a, HexVec3 b, int step) noexcept {
const float h = static_cast<float>(step) * kHorizontalTerraceStepSize;
a.x += (b.x - a.x) * h;
a.z += (b.z - a.z) * h;
const float v = static_cast<float>((step + 1) / 2) * kVerticalTerraceStepSize;
a.y += (b.y - a.y) * v;
return a;
}
/** @brief Interpolates terrain weights along a terraced slope. */
[[nodiscard]] static HexVec3 terraceLerpWeights(HexVec3 a, HexVec3 b, int step) noexcept {
const float h = static_cast<float>(step) * kHorizontalTerraceStepSize;
return lerp(a, b, h);
}
/** @brief Position of the midpoint of a wall between two elevations. */
[[nodiscard]] static HexVec3 wallLerp(HexVec3 near, HexVec3 far) noexcept {
near.x += (far.x - near.x) * 0.5f;
near.z += (far.z - near.z) * 0.5f;
const float v = near.y < far.y ? kWallElevationOffset : (1.f - kWallElevationOffset);
near.y += (far.y - near.y) * v + kWallYOffset;
return near;
}
/** @brief Half-thickness offset applied to a wall segment. */
[[nodiscard]] static HexVec3 wallThicknessOffset(HexVec3 near, HexVec3 far) noexcept {
float dx = far.x - near.x;
float dz = far.z - near.z;
const float len = std::sqrt(dx * dx + dz * dz);
if (len <= 1e-6f) return HexVec3{0.f, 0.f, 0.f};
const float s = (kWallThickness * 0.5f) / len;
return HexVec3{dx * s, 0.f, dz * s};
}
/** @brief Y coordinate of a cell's elevation surface. */
[[nodiscard]] static float elevationY(int elevation) noexcept {
return static_cast<float>(elevation) * kElevationStep;
}
/** @brief Y coordinate of a river bed inside a cell. */
[[nodiscard]] static float streamBedY(int elevation) noexcept {
return static_cast<float>(elevation) * kElevationStep + kStreamBedElevationOffset;
}
/** @brief Y coordinate of a water surface at `waterLevel`. */
[[nodiscard]] static float waterSurfaceY(int waterLevel) noexcept {
return static_cast<float>(waterLevel) * kElevationStep + kWaterElevationOffset;
}
/** @brief Distance from a cell centre to its nearest edge midpoint. */
[[nodiscard]] static float solidRadius() noexcept { return innerRadius() * kSolidFactor; }
};
/** @brief Five sample points along one hex edge, from v1 to v5. */
struct EdgeVertices {
HexVec3 v1;
HexVec3 v2;
HexVec3 v3;
HexVec3 v4;
HexVec3 v5;
EdgeVertices() = default;
/** @brief Evenly samples the segment `c1 -> c2` at 0, 0.25, 0.5, 0.75 and 1. */
EdgeVertices(HexVec3 c1, HexVec3 c2) noexcept {
v1 = c1;
v2 = lerp(c1, c2, 0.25f);
v3 = lerp(c1, c2, 0.5f);
v4 = lerp(c1, c2, 0.75f);
v5 = c2;
}
/** @brief Samples the segment using an explicit outer step for v2/v4. */
EdgeVertices(HexVec3 c1, HexVec3 c2, float outerStep) noexcept {
v1 = c1;
v2 = lerp(c1, c2, outerStep);
v3 = lerp(c1, c2, 0.5f);
v4 = lerp(c1, c2, 1.f - outerStep);
v5 = c2;
}
/** @brief Terrace-interpolates every sample between two edges. */
[[nodiscard]] static EdgeVertices terraceLerp(const EdgeVertices& a, const EdgeVertices& b, int step) noexcept {
EdgeVertices result;
result.v1 = HexMetrics::terraceLerp(a.v1, b.v1, step);
result.v2 = HexMetrics::terraceLerp(a.v2, b.v2, step);
result.v3 = HexMetrics::terraceLerp(a.v3, b.v3, step);
result.v4 = HexMetrics::terraceLerp(a.v4, b.v4, step);
result.v5 = HexMetrics::terraceLerp(a.v5, b.v5, step);
return result;
}
};
} // namespace eve::hexmap
+68
View File
@@ -0,0 +1,68 @@
#include "hexmap/HexNoise.h"
#include <cmath>
namespace eve::hexmap {
namespace {
/** @brief Integer hash with good avalanche on the low bits (xxhash-style mix). */
[[nodiscard]] std::uint32_t hash3(std::uint32_t x, std::uint32_t y, std::uint32_t z) noexcept {
std::uint32_t h = x * 0x9E3779B1u;
h ^= y * 0x85EBCA77u;
h = (h ^ (h >> 15)) * 0xC2B2AE3Du;
h ^= z * 0x27D4EB2Fu;
h = (h ^ (h >> 13)) * 0x165667B1u;
return h ^ (h >> 16);
}
[[nodiscard]] float toUnit(std::uint32_t value) noexcept {
// 24 significant bits -> [0, 1)
return static_cast<float>(value >> 8) * (1.f / 16777216.f);
}
[[nodiscard]] float smooth(float t) noexcept { return t * t * (3.f - 2.f * t); }
} // namespace
float HexNoise::lattice(int ix, int iz, int channel) const noexcept {
return toUnit(hash3(static_cast<std::uint32_t>(ix), static_cast<std::uint32_t>(iz),
seed_ + static_cast<std::uint32_t>(channel) * 0x9E3779B9u));
}
HexVec4 HexNoise::sample(float worldX, float worldZ) const noexcept {
const float fx = worldX * (1.f / kCellScale);
const float fz = worldZ * (1.f / kCellScale);
const float bx = std::floor(fx);
const float bz = std::floor(fz);
const int ix = static_cast<int>(bx);
const int iz = static_cast<int>(bz);
const float tx = smooth(fx - bx);
const float tz = smooth(fz - bz);
HexVec4 result;
float* channels[4] = {&result.x, &result.y, &result.z, &result.w};
for (int channel = 0; channel < 4; ++channel) {
const float c00 = lattice(ix, iz, channel);
const float c10 = lattice(ix + 1, iz, channel);
const float c01 = lattice(ix, iz + 1, channel);
const float c11 = lattice(ix + 1, iz + 1, channel);
const float top = c00 + (c10 - c00) * tx;
const float bottom = c01 + (c11 - c01) * tx;
*channels[channel] = top + (bottom - top) * tz;
}
return result;
}
HexVec3 HexNoise::perturb(HexVec3 position) const noexcept {
const HexVec4 n = sample(position.x, position.z);
position.x += (n.x * 2.f - 1.f) * HexMetrics::kCellPerturbStrength;
position.z += (n.z * 2.f - 1.f) * HexMetrics::kCellPerturbStrength;
return position;
}
float HexNoise::elevationPerturb(float worldX, float worldZ) const noexcept {
const HexVec4 n = sample(worldX, worldZ);
return (n.y * 2.f - 1.f) * HexMetrics::kElevationPerturbStrength;
}
} // namespace eve::hexmap
+68
View File
@@ -0,0 +1,68 @@
#pragma once
/** @file HexNoise.h @brief Deterministic asset-free noise for hex perturbation and generation. */
#include "hexmap/HexMetrics.h"
#include <cstdint>
namespace eve::hexmap {
/**
* @brief Deterministic four-channel value noise over the XZ plane.
*
* The reference hex-map project samples a four-channel `Noise.png` texture.
* This implementation reproduces the same sampling contract (four independent
* channels, smooth bilinear interpolation, repeatable for a given seed) without
* shipping a texture asset, so generated maps stay reproducible from a seed
* alone.
*
* @note Immutable after construction and safe to share across the map and its
* mesh builders; it holds no process-global state.
*/
class HexNoise {
public:
/** @brief Scale of the noise relative to one hex cell (world units per noise unit). */
static constexpr float kCellScale = 43.3f;
/**
* @brief Creates a noise field for `seed`.
* @param seed Deterministic seed; the same seed always yields the same field.
*/
explicit HexNoise(std::uint32_t seed = 0u) noexcept : seed_(seed) {}
/** @brief Reseeds the field. */
void reset(std::uint32_t seed) noexcept { seed_ = seed; }
/** @brief The seed this field was created with. */
[[nodiscard]] std::uint32_t seed() const noexcept { return seed_; }
/**
* @brief Samples the four noise channels at a world XZ position.
* @param worldX World X coordinate.
* @param worldZ World Z coordinate.
* @return Channel values in `[0, 1]`; `.x`/`.z` drive XZ perturbation and
* `.y` drives vertical elevation perturbation.
*/
[[nodiscard]] HexVec4 sample(float worldX, float worldZ) const noexcept;
/**
* @brief Perturbs a position in XZ by `±kCellPerturbStrength`.
* @param position Input position; Y is preserved.
* @return Perturbed position.
*/
[[nodiscard]] HexVec3 perturb(HexVec3 position) const noexcept;
/**
* @brief Vertical perturbation applied to a cell's elevation, `±kElevationPerturbStrength`.
* @param worldX World X coordinate.
* @param worldZ World Z coordinate.
*/
[[nodiscard]] float elevationPerturb(float worldX, float worldZ) const noexcept;
private:
[[nodiscard]] float lattice(int ix, int iz, int channel) const noexcept;
std::uint32_t seed_ = 0u;
};
} // namespace eve::hexmap
+206
View File
@@ -0,0 +1,206 @@
#include "hexmap/HexMapMesh.h"
#include "hexmap/HexCell.h"
#include "hexmap/HexMeshData.h"
#include "hexmap/HexMetrics.h"
#include "hexmap/HexNoise.h"
#include <array>
#include <cmath>
#include <cstdint>
namespace eve::hexmap {
namespace {
/** @brief One pending surface vertex: a world position plus its texture coordinates. */
struct SurfaceVertex {
HexVec3 position;
float u = 0.f;
float v = 0.f;
};
/**
* @brief Whether three positions span no area, so the face would be invisible.
*
* The test reuses the right-handed cross product `HexMeshData::finalize()` derives
* its face normals from.
*/
[[nodiscard]] bool isDegenerate(HexVec3 a, HexVec3 b, HexVec3 c) noexcept {
const HexVec3 ab = b - a;
const HexVec3 ac = c - a;
const HexVec3 normal{ab.y * ac.z - ab.z * ac.y, ab.z * ac.x - ab.x * ac.z, ab.x * ac.y - ab.y * ac.x};
return (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z) <= 1e-8f;
}
/**
* @brief Appends one triangle, perturbing every position exactly once.
*
* `a -> b -> c` must resolve to a `+Y` normal (counter-clockwise seen from above,
* using up = `+Z`), otherwise the face is culled.
*
* @param out Destination mesh.
* @param noise Map noise, applied here so no call site can displace a vertex twice.
*/
void emitTriangle(HexMeshData& out, const SurfaceVertex& a, const SurfaceVertex& b, const SurfaceVertex& c,
const HexNoise& noise) {
const HexVec3 pa = noise.perturb(a.position);
const HexVec3 pb = noise.perturb(b.position);
const HexVec3 pc = noise.perturb(c.position);
if (isDegenerate(pa, pb, pc)) return;
out.addTriangle(out.addVertex(pa, a.u, a.v), out.addVertex(pb, b.u, b.v), out.addVertex(pc, c.u, c.v));
}
/**
* @brief Appends the quad whose four corners run `r0 -> r1 -> r2 -> r3` around its outline.
*
* `HexMeshData::addQuad(a, b, c, d)` splits the ring `a -> b -> d -> c` along `b -> c`,
* so the corners are re-ordered here and every call site keeps readable outline order.
*/
void emitQuad(HexMeshData& out, const SurfaceVertex& r0, const SurfaceVertex& r1, const SurfaceVertex& r2,
const SurfaceVertex& r3, const HexNoise& noise) {
emitTriangle(out, r0, r3, r1, noise);
emitTriangle(out, r1, r3, r2, noise);
}
/** @brief Unit horizontal normal of `forward`, turned a quarter circle around `+Y`. */
[[nodiscard]] HexVec3 acrossDirection(HexVec3 forward) noexcept {
const float length = std::sqrt(forward.x * forward.x + forward.z * forward.z);
if (length <= 1e-6f) return HexVec3{0.f, 0.f, 0.f};
return HexVec3{forward.z / length, 0.f, -forward.x / length};
}
/** @brief One cross-section of a strip: a centreline point, its across direction and its width. */
struct StripStation {
HexVec3 position;
HexVec3 across;
float halfWidth = 0.f;
float u = 0.f;
};
/**
* @brief Appends the ribbon segment between two cross-sections.
*
* The outline runs `minus0 -> plus0 -> plus1 -> minus1`, the ring order these helpers
* resolve to an upward normal. Both across directions must belong to the same
* direction of travel; a zero half-width folds one pair of corners together and
* leaves only the tapering triangle.
*
* @param out Destination mesh.
* @param from Upstream cross-section.
* @param to Downstream cross-section.
* @param noise Map noise, applied once per emitted position.
*/
void emitSegment(HexMeshData& out, const StripStation& from, const StripStation& to, const HexNoise& noise) {
const SurfaceVertex minus0{from.position - from.across * from.halfWidth, from.u, 0.f};
const SurfaceVertex plus0{from.position + from.across * from.halfWidth, from.u, 1.f};
const SurfaceVertex plus1{to.position + to.across * to.halfWidth, to.u, 1.f};
const SurfaceVertex minus1{to.position - to.across * to.halfWidth, to.u, 0.f};
emitQuad(out, minus0, plus0, plus1, minus1, noise);
}
} // namespace
void buildRiverMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out) {
out.clear();
if (chunkIndex < 0 || chunkIndex >= map.chunkCount()) {
out.finalize();
return;
}
const HexNoise& noise = map.noise();
const float halfWidth = HexMetrics::innerRadius() * HexMetrics::kRiverSurfaceScale * 0.5f;
for (std::int32_t row = 0; row < HexMetrics::kChunkSizeZ; ++row) {
for (std::int32_t column = 0; column < HexMetrics::kChunkSizeX; ++column) {
const HexCoordinates coordinates = map.chunkCell(chunkIndex, column, row);
const HexCellData* data = map.cell(coordinates);
if (data == nullptr || !map.hasRiver(coordinates)) continue;
const HexVec3 ground = map.cellGroundPosition(coordinates);
const HexVec3 centre{ground.x, HexMetrics::streamBedY(map.elevation(coordinates)), ground.z};
// Per-edge nodes of the channel, keyed by HexDirection index.
std::array<bool, 6> connected{};
std::array<bool, 6> incoming{};
std::array<bool, 6> outgoing{};
std::array<HexVec3, 6> edgePosition{};
std::int32_t connectionCount = 0;
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
if (!map.hasRiverThrough(coordinates, direction)) continue;
connected[i] = true;
incoming[i] = data->flags.hasRiverIn(direction);
outgoing[i] = data->flags.hasRiverOut(direction);
++connectionCount;
// The edge crossing sits on the solid edge midpoint, the same point the
// terrain mesh owns on a shared border.
edgePosition[i] = centre + HexMetrics::solidEdgeMiddle(direction);
}
if (connectionCount == 0) continue;
std::int32_t emitted = 0;
// Every incoming/outgoing pair bends through the cell centre.
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
if (!incoming[i]) continue;
for (std::int32_t j = 0; j < kHexDirectionCount; ++j) {
if (!outgoing[j] || j == i) continue;
const StripStation start{edgePosition[i], acrossDirection(centre - edgePosition[i]), halfWidth,
0.f};
const StripStation middle{centre, acrossDirection(edgePosition[j] - edgePosition[i]), halfWidth,
0.5f};
const StripStation finish{edgePosition[j], acrossDirection(edgePosition[j] - centre), halfWidth,
1.f};
emitSegment(out, start, middle, noise);
emitSegment(out, middle, finish, noise);
++emitted;
}
}
// A begin/end cell (HexFlags::hasRiverBeginOrEnd) has exactly one connection, so
// there is no pair: the channel tapers from the edge to the cell centre instead.
if (emitted == 0 && connectionCount == 1) {
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
if (!connected[i]) continue;
if (outgoing[i]) {
const StripStation start{centre, acrossDirection(edgePosition[i] - centre), 0.f, 0.5f};
const StripStation finish{edgePosition[i], acrossDirection(edgePosition[i] - centre), halfWidth,
1.f};
emitSegment(out, start, finish, noise);
} else {
const StripStation start{edgePosition[i], acrossDirection(centre - edgePosition[i]), halfWidth,
0.f};
const StripStation finish{centre, acrossDirection(centre - edgePosition[i]), 0.f, 0.5f};
emitSegment(out, start, finish, noise);
}
++emitted;
}
}
// Degenerate topologies (for example a sink fed by two rivers) carry no direction
// pair at all; join the collected edges in direction order so the cell is not blank.
if (emitted == 0) {
std::int32_t previous = -1;
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
if (!connected[i]) continue;
if (previous >= 0) {
const StripStation start{edgePosition[previous],
acrossDirection(centre - edgePosition[previous]), halfWidth, 0.f};
const StripStation middle{centre, acrossDirection(edgePosition[i] - edgePosition[previous]),
halfWidth, 0.5f};
const StripStation finish{edgePosition[i], acrossDirection(edgePosition[i] - centre), halfWidth,
1.f};
emitSegment(out, start, middle, noise);
emitSegment(out, middle, finish, noise);
++emitted;
}
previous = i;
}
}
}
}
out.finalize();
}
} // namespace eve::hexmap
+156
View File
@@ -0,0 +1,156 @@
#include "hexmap/HexMapMesh.h"
#include "hexmap/HexCell.h"
#include "hexmap/HexMeshData.h"
#include "hexmap/HexMetrics.h"
#include "hexmap/HexNoise.h"
#include <cmath>
#include <cstdint>
namespace eve::hexmap {
namespace {
/** @brief One pending surface vertex: a world position plus its texture coordinates. */
struct SurfaceVertex {
HexVec3 position;
float u = 0.f;
float v = 0.f;
};
/**
* @brief Whether three positions span no area, so the face would be invisible.
*
* The test reuses the right-handed cross product `HexMeshData::finalize()` derives
* its face normals from.
*/
[[nodiscard]] bool isDegenerate(HexVec3 a, HexVec3 b, HexVec3 c) noexcept {
const HexVec3 ab = b - a;
const HexVec3 ac = c - a;
const HexVec3 normal{ab.y * ac.z - ab.z * ac.y, ab.z * ac.x - ab.x * ac.z, ab.x * ac.y - ab.y * ac.x};
return (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z) <= 1e-8f;
}
/**
* @brief Appends one triangle, perturbing every position exactly once.
*
* `a -> b -> c` must resolve to a `+Y` normal (counter-clockwise seen from above,
* using up = `+Z`), otherwise the face is culled.
*
* @param out Destination mesh.
* @param noise Map noise, applied here so no call site can displace a vertex twice.
*/
void emitTriangle(HexMeshData& out, const SurfaceVertex& a, const SurfaceVertex& b, const SurfaceVertex& c,
const HexNoise& noise) {
const HexVec3 pa = noise.perturb(a.position);
const HexVec3 pb = noise.perturb(b.position);
const HexVec3 pc = noise.perturb(c.position);
if (isDegenerate(pa, pb, pc)) return;
out.addTriangle(out.addVertex(pa, a.u, a.v), out.addVertex(pb, b.u, b.v), out.addVertex(pc, c.u, c.v));
}
/**
* @brief Appends the quad whose four corners run `r0 -> r1 -> r2 -> r3` around its outline.
*
* `HexMeshData::addQuad(a, b, c, d)` splits the ring `a -> b -> d -> c` along `b -> c`,
* so the corners are re-ordered here and every call site keeps readable outline order.
*/
void emitQuad(HexMeshData& out, const SurfaceVertex& r0, const SurfaceVertex& r1, const SurfaceVertex& r2,
const SurfaceVertex& r3, const HexNoise& noise) {
emitTriangle(out, r0, r3, r1, noise);
emitTriangle(out, r1, r3, r2, noise);
}
/** @brief Unit horizontal normal of `forward`, turned a quarter circle around `+Y`. */
[[nodiscard]] HexVec3 acrossDirection(HexVec3 forward) noexcept {
const float length = std::sqrt(forward.x * forward.x + forward.z * forward.z);
if (length <= 1e-6f) return HexVec3{0.f, 0.f, 0.f};
return HexVec3{forward.z / length, 0.f, -forward.x / length};
}
/** @brief One cross-section of a strip: a centreline point, its across direction and its width. */
struct StripStation {
HexVec3 position;
HexVec3 across;
float halfWidth = 0.f;
float u = 0.f;
};
/**
* @brief Appends the ribbon segment between two cross-sections.
*
* The outline runs `minus0 -> plus0 -> plus1 -> minus1`, the ring order these helpers
* resolve to an upward normal. Both across directions must belong to the same
* direction of travel; a zero half-width folds one pair of corners together and
* leaves only the tapering triangle.
*
* @param out Destination mesh.
* @param from Upstream cross-section.
* @param to Downstream cross-section.
* @param noise Map noise, applied once per emitted position.
*/
void emitSegment(HexMeshData& out, const StripStation& from, const StripStation& to, const HexNoise& noise) {
const SurfaceVertex minus0{from.position - from.across * from.halfWidth, from.u, 0.f};
const SurfaceVertex plus0{from.position + from.across * from.halfWidth, from.u, 1.f};
const SurfaceVertex plus1{to.position + to.across * to.halfWidth, to.u, 1.f};
const SurfaceVertex minus1{to.position - to.across * to.halfWidth, to.u, 0.f};
emitQuad(out, minus0, plus0, plus1, minus1, noise);
}
} // namespace
void buildRoadMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out) {
out.clear();
if (chunkIndex < 0 || chunkIndex >= map.chunkCount()) {
out.finalize();
return;
}
const HexNoise& noise = map.noise();
const float halfWidth = HexMetrics::innerRadius() * HexMetrics::kSolidFactor * 0.375f;
// Lift the surface off the terrain so the two coplanar layers cannot z-fight.
constexpr float kRoadLift = 0.05f;
for (std::int32_t row = 0; row < HexMetrics::kChunkSizeZ; ++row) {
for (std::int32_t column = 0; column < HexMetrics::kChunkSizeX; ++column) {
const HexCoordinates coordinates = map.chunkCell(chunkIndex, column, row);
const HexCellData* data = map.cell(coordinates);
if (data == nullptr || !map.hasRoad(coordinates)) continue;
const HexVec3 start = map.cellPosition(coordinates);
const HexVec3 ground = map.cellGroundPosition(coordinates);
// Emit each edge once, from the chunk that owns that border.
for (std::int32_t i = 0; i <= static_cast<std::int32_t>(HexDirection::SE); ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
if (!data->flags.hasRoad(direction)) continue;
HexCoordinates neighbour{};
if (!map.getNeighbor(coordinates, direction, neighbour)) continue;
const HexVec3 neighbourCentre = map.cellPosition(neighbour);
const HexVec3 edgeMiddle = HexMetrics::solidEdgeMiddle(direction);
// The crossing sits halfway between the two surfaces so the ribbon bends with
// the slope instead of stepping at the border.
const HexVec3 crossing{ground.x + edgeMiddle.x, (start.y + neighbourCentre.y) * 0.5f,
ground.z + edgeMiddle.z};
const StripStation from{
{start.x, start.y + kRoadLift, start.z}, acrossDirection(crossing - start), halfWidth, 0.f};
const StripStation middle{{crossing.x, crossing.y + kRoadLift, crossing.z},
acrossDirection(neighbourCentre - start),
halfWidth,
0.5f};
const StripStation finish{{neighbourCentre.x, neighbourCentre.y + kRoadLift, neighbourCentre.z},
acrossDirection(neighbourCentre - crossing),
halfWidth,
1.f};
emitSegment(out, from, middle, noise);
emitSegment(out, middle, finish, noise);
}
}
}
out.finalize();
}
} // namespace eve::hexmap
+302
View File
@@ -0,0 +1,302 @@
/**
* @file HexSearch.cpp
* @brief Cell-graph search: the bucket-list frontier, pathfinding and visibility.
*/
#include "hexmap/HexSearch.h"
#include "common/Diagnostic.h"
#include <algorithm>
#include <string>
namespace eve::hexmap {
namespace {
[[nodiscard]] Result<HexPath> pathInvalidArgument(const std::string& message) {
return Result<HexPath>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, message, "hexmap"));
}
[[nodiscard]] Result<void> voidInvalidArgument(const std::string& message) {
return Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, message, "hexmap"));
}
[[nodiscard]] Result<HexPath> unreachable(const std::string& message) {
return Result<HexPath>::failure(Diagnostic::error(DiagnosticCode::NotFound, message, "hexmap"));
}
/**
* @brief Turn index a cell reached at `distance` is entered on.
*
* Mirrors `(distance - 1) / speed` of the reference algorithm, clamped so the
* origin (distance 0) reports turn 0 instead of a negative value.
*/
[[nodiscard]] std::int32_t turnOf(std::int32_t distance, std::int32_t speed) noexcept {
if (distance <= 0) return 0;
return (distance - 1) / speed;
}
} // namespace
// --- HexSearchContext -------------------------------------------------------
void HexSearchContext::resize(std::int32_t cellCount) {
if (cellCount <= 0) {
data_.clear();
} else {
data_.assign(static_cast<std::size_t>(cellCount), HexSearchData{});
}
buckets_.clear();
minimum_ = 0;
}
HexSearchData& HexSearchContext::data(std::int32_t cellIndex) noexcept {
if (cellIndex < 0 || cellIndex >= cellCount()) return sink_;
return data_[static_cast<std::size_t>(cellIndex)];
}
const HexSearchData& HexSearchContext::data(std::int32_t cellIndex) const noexcept {
if (cellIndex < 0 || cellIndex >= cellCount()) return sink_;
return data_[static_cast<std::size_t>(cellIndex)];
}
std::int32_t HexSearchContext::beginPhase() noexcept {
phase_ += 2;
// Clearing the frontier is only this: the bucket array holds no owned state,
// and the per-cell records are invalidated lazily by the phase check, so a
// new search never pays for resetting the grid.
buckets_.clear();
minimum_ = 0;
return phase_;
}
void HexSearchContext::enqueue(std::int32_t cellIndex) noexcept {
if (data_.empty() || cellIndex < 0 || cellIndex >= cellCount()) return;
std::int32_t priority = data_[cellIndex].priority();
if (priority < 0) priority = 0;
if (priority < minimum_) minimum_ = priority;
if (static_cast<std::size_t>(priority) >= buckets_.size())
buckets_.resize(static_cast<std::size_t>(priority) + 1, -1);
// Link the cell at the head of its bucket: equal-priority cells form an
// intrusive singly linked list through `nextWithSamePriority`.
data_[cellIndex].nextWithSamePriority = buckets_[static_cast<std::size_t>(priority)];
buckets_[static_cast<std::size_t>(priority)] = cellIndex;
}
HexSearchPop HexSearchContext::dequeue(std::int32_t& outCellIndex) noexcept {
const std::int32_t bucketCount = static_cast<std::int32_t>(buckets_.size());
for (; minimum_ < bucketCount; ++minimum_) {
const std::int32_t head = buckets_[static_cast<std::size_t>(minimum_)];
if (head < 0) continue;
buckets_[static_cast<std::size_t>(minimum_)] = data(head).nextWithSamePriority;
outCellIndex = head;
return HexSearchPop::Cell;
}
return HexSearchPop::Empty;
}
void HexSearchContext::unlink(std::int32_t cellIndex, std::int32_t bucket) noexcept {
if (bucket < 0 || static_cast<std::size_t>(bucket) >= buckets_.size()) return;
std::int32_t current = buckets_[static_cast<std::size_t>(bucket)];
if (current < 0) return;
if (current == cellIndex) {
buckets_[static_cast<std::size_t>(bucket)] = data(current).nextWithSamePriority;
return;
}
while (true) {
const std::int32_t next = data(current).nextWithSamePriority;
if (next < 0) return;
if (next == cellIndex) {
data(current).nextWithSamePriority = data(next).nextWithSamePriority;
return;
}
current = next;
}
}
void HexSearchContext::change(std::int32_t cellIndex, std::int32_t oldPriority) noexcept {
unlink(cellIndex, oldPriority);
enqueue(cellIndex);
}
// --- movement rules ---------------------------------------------------------
bool isValidDestination(const HexMap& map, HexCoordinates coordinates, const HexOccupancyQuery& occupied) {
if (!map.contains(coordinates)) return false;
// `canHoldUnit` is the shared "explored, explorable, above water" terrain rule;
// occupancy by another actor is the caller's extra clause.
const HexCellData* cell = map.cell(coordinates);
if (cell == nullptr || !canHoldUnit(*cell)) return false;
if (occupied && occupied(coordinates)) return false;
return true;
}
std::int32_t moveCost(const HexMap& map, HexCoordinates from, HexCoordinates to, HexDirection direction,
const HexOccupancyQuery& occupied) {
if (!isValidDestination(map, to, occupied)) return -1;
const HexEdgeType type = edgeType(map.elevation(from), map.elevation(to));
if (type == HexEdgeType::Cliff) return -1;
if (map.flags(from).hasRoad(direction)) return 1;
if (map.isWalled(from) != map.isWalled(to)) return -1;
const std::int32_t base = type == HexEdgeType::Flat ? 5 : 10;
return base + map.urbanLevel(to) + map.farmLevel(to) + map.plantLevel(to);
}
// --- pathfinding ------------------------------------------------------------
Result<HexPath> findPath(const HexMap& map, HexSearchContext& scratch, HexCoordinates from, HexCoordinates to,
const HexMoveRules& rules, const HexOccupancyQuery& occupied) {
if (map.empty()) return pathInvalidArgument("cannot search an empty hex map");
if (!map.contains(from)) return pathInvalidArgument("path origin is outside the hex map");
if (!map.contains(to)) return pathInvalidArgument("path goal is outside the hex map");
if (scratch.cellCount() != map.cellCount())
return pathInvalidArgument("search scratch must be resized to map.cellCount() before findPath");
if (!isValidDestination(map, to, occupied)) return unreachable("path goal is not a valid destination");
const std::int32_t speed = std::max<std::int32_t>(1, rules.speed);
const std::int32_t start = map.indexOf(from);
const std::int32_t goal = map.indexOf(to);
const HexCoordinates goalCoord = to;
const std::int32_t phase = scratch.beginPhase();
scratch.data(start).searchPhase = phase;
scratch.enqueue(start);
bool found = false;
while (true) {
std::int32_t current = -1;
if (scratch.dequeue(current) != HexSearchPop::Cell) break;
const std::int32_t currentDistance = scratch.data(current).distance;
// Marking the record with `phase + 1` is how the reference says "expanded":
// an untouched record still carries the older phase, so the distinction
// needs no clearing pass.
scratch.data(current).searchPhase = phase + 1;
if (current == goal) {
found = true;
break;
}
const std::int32_t currentTurn = turnOf(currentDistance, speed);
const HexCoordinates currentCoord = map.coordinatesAt(current);
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
HexCoordinates neighbour{};
if (!map.getNeighbor(currentCoord, direction, neighbour)) continue;
const std::int32_t neighbourIndex = map.indexOf(neighbour);
if (neighbourIndex < 0) continue;
// Already expanded: its bucket entry was consumed and it must not be
// relaxed again within this phase.
if (scratch.data(neighbourIndex).searchPhase == phase + 1) continue;
const std::int32_t step = moveCost(map, currentCoord, neighbour, direction, occupied);
if (step < 0) continue;
std::int32_t distance = currentDistance + step;
const std::int32_t turn = turnOf(distance, speed);
if (turn > currentTurn) distance = turn * speed + step;
HexSearchData& neighbourData = scratch.data(neighbourIndex);
if (neighbourData.searchPhase != phase) {
// A fresh record is fully overwritten; it carries no bucket link yet.
neighbourData.searchPhase = phase;
neighbourData.distance = distance;
neighbourData.pathFrom = current;
neighbourData.heuristic = neighbour.distanceTo(goalCoord);
scratch.enqueue(neighbourIndex);
} else if (distance < neighbourData.distance) {
// The priority must be read before the record is updated: `change`
// unlinks the cell from the bucket it is still sitting in.
const std::int32_t oldPriority = neighbourData.priority();
neighbourData.distance = distance;
neighbourData.pathFrom = current;
scratch.change(neighbourIndex, oldPriority);
}
}
}
if (!found) return unreachable("no path exists between the two cells");
HexPath path;
for (std::int32_t index = goal; index != start; index = scratch.data(index).pathFrom) {
if (index < 0) return unreachable("path reconstruction lost the chain to the origin");
const std::int32_t distance = scratch.data(index).distance;
path.cells.push_back(index);
path.turns.push_back(turnOf(distance, speed));
}
path.cells.push_back(start);
path.turns.push_back(turnOf(scratch.data(start).distance, speed));
std::reverse(path.cells.begin(), path.cells.end());
std::reverse(path.turns.begin(), path.turns.end());
return Result<HexPath>::success(std::move(path));
}
// --- visibility -------------------------------------------------------------
Result<void> collectVisibleCells(const HexMap& map, HexSearchContext& scratch, HexCoordinates from,
std::int32_t range, std::vector<std::int32_t>& out) {
out.clear();
if (map.empty()) return voidInvalidArgument("cannot collect visibility from an empty hex map");
if (!map.contains(from)) return voidInvalidArgument("view origin is outside the hex map");
if (scratch.cellCount() != map.cellCount())
return voidInvalidArgument("search scratch must be resized to map.cellCount() before collectVisibleCells");
const std::int32_t searchRange = range + map.values(from).viewElevation();
const std::int32_t start = map.indexOf(from);
const std::int32_t phase = scratch.beginPhase();
scratch.data(start).searchPhase = phase;
scratch.data(start).distance = 0;
scratch.enqueue(start);
while (true) {
std::int32_t current = -1;
if (scratch.dequeue(current) != HexSearchPop::Cell) break;
// Nearest first: the frontier always pops the cheapest step count, so the
// produced order is stable for a given map.
scratch.data(current).searchPhase = phase + 1;
out.push_back(current);
const std::int32_t currentDistance = scratch.data(current).distance;
const HexCoordinates currentCoord = map.coordinatesAt(current);
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const HexDirection direction = static_cast<HexDirection>(i);
HexCoordinates neighbour{};
if (!map.getNeighbor(currentCoord, direction, neighbour)) continue;
if (!map.isExplorable(neighbour)) continue;
const std::int32_t neighbourIndex = map.indexOf(neighbour);
if (neighbourIndex < 0) continue;
if (scratch.data(neighbourIndex).searchPhase == phase + 1) continue;
const std::int32_t distance = currentDistance + 1;
if (distance + map.values(neighbour).viewElevation() > searchRange) continue;
if (distance > from.distanceTo(neighbour)) continue;
HexSearchData& neighbourData = scratch.data(neighbourIndex);
if (neighbourData.searchPhase != phase) {
neighbourData.searchPhase = phase;
neighbourData.distance = distance;
neighbourData.pathFrom = current;
neighbourData.heuristic = 0;
scratch.enqueue(neighbourIndex);
} else if (distance < neighbourData.distance) {
const std::int32_t oldPriority = neighbourData.priority();
neighbourData.distance = distance;
neighbourData.pathFrom = current;
scratch.change(neighbourIndex, oldPriority);
}
}
}
return Result<void>::success();
}
} // namespace eve::hexmap
+227
View File
@@ -0,0 +1,227 @@
#pragma once
/** @file HexSearch.h @brief Cell-graph search used by pathfinding and visibility. */
#include "common/Result.h"
#include "hexmap/HexMap.h"
#include <cstdint>
#include <functional>
#include <vector>
namespace eve::hexmap {
/**
* @brief Per-cell scratch record of one search.
*
* The fields mirror the reference hex-map project's cell search data: a search
* is described by a monotonically increasing *phase* instead of a visited
* clearing pass, so a search never pays for resetting the whole grid.
*/
struct HexSearchData {
/** @brief Shortest distance from the search origin found so far. */
std::int32_t distance = 0;
/** @brief Cell the path entered this cell from, or -1. */
std::int32_t pathFrom = -1;
/** @brief Remaining-distance estimate towards the search goal (0 for blind searches). */
std::int32_t heuristic = 0;
/** @brief Phase this record was written in; a record from an older phase is unvisited. */
std::int32_t searchPhase = 0;
/** @brief Next cell index sharing this record's priority bucket, or -1. */
std::int32_t nextWithSamePriority = -1;
/** @brief Ordering key of the priority queue (`distance + heuristic`). */
[[nodiscard]] std::int32_t priority() const noexcept { return distance + heuristic; }
};
/**
* @brief Outcome of one frontier pop.
*
* A named status rather than a bare `bool`, so a caller cannot read a
* meaningless cell index out of an empty queue by accident.
*/
enum class HexSearchPop : std::int32_t {
/** @brief A cell was removed; the out-parameter holds it. */
Cell = 0,
/** @brief The frontier is empty; the out-parameter is untouched. */
Empty = 1,
};
/**
* @brief Reusable search scratch: one record per cell plus a priority bucket queue.
*
* The queue is the reference project's bucket-list frontier: an array indexed by
* priority, where equal-priority cells form an intrusive singly linked list
* through `HexSearchData::nextWithSamePriority`. Priorities are small bounded
* integers - move costs and step counts - so a bucket list beats a binary heap
* here and needs no comparison function.
*
* Ownership and lifetime: owns plain host memory sized to `cellCount`. It is a
* scratch buffer, not a result: callers may reuse one instance across searches
* and across maps of the same size.
*
* Thread affinity: no thread safety; a context must not be shared between
* concurrent searches.
*/
class HexSearchContext {
public:
HexSearchContext() = default;
/**
* @brief Sizes the scratch for `cellCount` cells, preserving nothing.
* @param cellCount Number of cells of the map this context searches.
*/
void resize(std::int32_t cellCount);
/** @brief Number of cells this context can hold. */
[[nodiscard]] std::int32_t cellCount() const noexcept { return static_cast<std::int32_t>(data_.size()); }
/**
* @brief Mutable record of one cell.
* @param cellIndex Linear cell index in `[0, cellCount())`.
* @return The record; out-of-range indices return a shared sink record.
*/
[[nodiscard]] HexSearchData& data(std::int32_t cellIndex) noexcept;
/** @brief Const overload of `data`. */
[[nodiscard]] const HexSearchData& data(std::int32_t cellIndex) const noexcept;
/**
* @brief Starts a new search: clears the frontier and advances the phase.
* @return The new phase; records whose `searchPhase` differs are unvisited.
* @note Phases advance by two so a cell can be marked "in the frontier" and
* "expanded" by two distinct values, matching the reference algorithm.
*/
[[nodiscard]] std::int32_t beginPhase() noexcept;
/** @brief Current phase. */
[[nodiscard]] std::int32_t phase() const noexcept { return phase_; }
/** @brief Adds a cell at its current priority. */
void enqueue(std::int32_t cellIndex) noexcept;
/**
* @brief Removes the lowest-priority cell.
* @param outCellIndex Receives the cell index; untouched when the frontier is empty.
* @return `Cell` when a cell was popped, `Empty` when the frontier ran dry.
*/
[[nodiscard]] HexSearchPop dequeue(std::int32_t& outCellIndex) noexcept;
/**
* @brief Re-queues a cell whose priority changed after it was enqueued.
* @param cellIndex Cell to move.
* @param oldPriority Priority the cell was enqueued with.
*/
void change(std::int32_t cellIndex, std::int32_t oldPriority) noexcept;
private:
void unlink(std::int32_t cellIndex, std::int32_t bucket) noexcept;
std::vector<HexSearchData> data_;
std::vector<std::int32_t> buckets_;
std::int32_t minimum_ = 0;
std::int32_t phase_ = 0;
HexSearchData sink_{};
};
/** @brief Movement tuning of one actor; cells per turn and vision radius in cells. */
struct HexMoveRules {
/** @brief Movement points available per turn. */
std::int32_t speed = 24;
/** @brief Vision radius in cells, added to the cell's own view elevation. */
std::int32_t visionRange = 3;
};
/**
* @brief Predicate answering whether a cell is already occupied by another actor.
*
* An empty query means "the grid is empty": the search then only rejects cells
* that the terrain itself forbids.
*/
using HexOccupancyQuery = std::function<bool(HexCoordinates)>;
/**
* @brief Whether an actor may occupy a cell.
*
* A destination must be explored, explorable, not underwater and not occupied.
* This mirrors `HexUnit.IsValidDestination` in the reference project; the
* `occupied` query supplies the "no other unit" clause.
*
* @param map Source map.
* @param coordinates Cell to test.
* @param occupied Occupancy predicate; may be empty.
* @return True when the cell can hold an actor.
*/
[[nodiscard]] bool isValidDestination(const HexMap& map, HexCoordinates coordinates,
const HexOccupancyQuery& occupied = {});
/**
* @brief Cost of moving between two adjacent cells.
*
* Cliffs and wall boundaries are impassable. A road on the source cell costs a
* single point; otherwise flat ground costs 5, a slope 10, plus the target
* cell's urban, farm and plant levels.
*
* @param map Source map.
* @param from Source cell.
* @param to Destination cell.
* @param direction Direction of travel from `from` to `to`.
* @param occupied Occupancy predicate; may be empty.
* @return A positive cost, or a negative value when the move is blocked.
*/
[[nodiscard]] std::int32_t moveCost(const HexMap& map, HexCoordinates from, HexCoordinates to, HexDirection direction,
const HexOccupancyQuery& occupied = {});
/** @brief One found path: the cells from origin to goal, with the turn each one is reached on. */
struct HexPath {
/** @brief Linear cell indices, `cells.front()` is the origin and `cells.back()` the goal. */
std::vector<std::int32_t> cells;
/** @brief Turn index for each entry of `cells`; parallel to `cells`. */
std::vector<std::int32_t> turns;
/** @brief Whether the path holds no cell. */
[[nodiscard]] bool empty() const noexcept { return cells.empty(); }
/** @brief Number of cells on the path, inclusive of both ends. */
[[nodiscard]] std::size_t size() const noexcept { return cells.size(); }
};
/**
* @brief Finds the cheapest path between two cells.
*
* The result is a plan, not a commitment: it is computed against the current
* cell state and is invalidated by any later map mutation, so callers re-run it
* rather than caching it across edits.
*
* @param map Source map.
* @param scratch Search scratch, resized by the caller to `map.cellCount()`.
* @param from Origin cell; must be inside the grid.
* @param to Goal cell; must be inside the grid and a valid destination.
* @param rules Movement rules of the travelling actor.
* @param occupied Occupancy predicate of *other* actors; may be empty.
* @return The path, or NotFound when the goal is unreachable / InvalidArgument
* when an endpoint is outside the grid.
* @cost Proportional to the number of cells expanded; bounded by `map.cellCount()`.
*/
[[nodiscard]] Result<HexPath> findPath(const HexMap& map, HexSearchContext& scratch, HexCoordinates from,
HexCoordinates to, const HexMoveRules& rules,
const HexOccupancyQuery& occupied = {});
/**
* @brief Collects every cell visible from `from` within `range`.
*
* Visibility is line of sight on the hex grid: a cell is visible when the
* shortest path distance to it, plus its own view elevation, stays within
* `range + from.viewElevation`, and that distance is not longer than the direct
* hex distance (which rejects cells hidden behind a ridge).
*
* @param map Source map.
* @param scratch Search scratch, resized to `map.cellCount()`.
* @param from Viewing cell; must be inside the grid.
* @param range Vision radius in cells, excluding the viewer's own elevation bonus.
* @param out Receives the visible cells, nearest first; cleared first.
* @return Success, or InvalidArgument when `from` is outside the grid.
* @cost Proportional to the visible region, not to the map.
*/
[[nodiscard]] Result<void> collectVisibleCells(const HexMap& map, HexSearchContext& scratch, HexCoordinates from,
std::int32_t range, std::vector<std::int32_t>& out);
} // namespace eve::hexmap
+260
View File
@@ -0,0 +1,260 @@
#include "hexmap/HexSerializer.h"
#include "common/Diagnostic.h"
#include "hexmap/HexMetrics.h"
#include <bit>
#include <cstddef>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
namespace eve::hexmap {
namespace {
/** @brief Bytes of `kHexSaveMagic`. */
constexpr std::size_t kMagicSize = sizeof(kHexSaveMagic);
/** @brief Bytes before the per-cell records: magic, version, two dimensions, seed and revision. */
constexpr std::size_t kHeaderSize = kMagicSize + 4 + 4 + 4 + 4 + 8;
/** @brief Bytes per cell record: the packed values word plus the packed flags word. */
constexpr std::size_t kCellRecordSize = 8;
/** @brief Bytes per unit record: a cell index plus a facing angle. */
constexpr std::size_t kUnitRecordSize = 8;
/**
* @brief Largest accepted grid dimension.
*
* A stored payload is untrusted input, so the dimensions are bounded before any
* storage is sized from them. 512 keeps one grid at most 512 x 512 cells, which
* matches the smallest sane bound for an interactive editor grid.
*/
constexpr std::uint32_t kMaxGridDimension = 512;
/** @brief Diagnostic for a payload that cannot be decoded. */
[[nodiscard]] Diagnostic invalidArgument(std::string message) {
return Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), "hexmap.save");
}
/** @brief Appends a little-endian 32-bit word. */
void pushU32(std::vector<std::uint8_t>& out, std::uint32_t value) {
out.push_back(static_cast<std::uint8_t>(value & 0xffu));
out.push_back(static_cast<std::uint8_t>((value >> 8) & 0xffu));
out.push_back(static_cast<std::uint8_t>((value >> 16) & 0xffu));
out.push_back(static_cast<std::uint8_t>((value >> 24) & 0xffu));
}
/** @brief Appends a little-endian 64-bit word. */
void pushU64(std::vector<std::uint8_t>& out, std::uint64_t value) {
for (std::uint32_t shift = 0; shift < 64; shift += 8)
out.push_back(static_cast<std::uint8_t>((value >> shift) & 0xffu));
}
/** @brief Appends a little-endian signed 32-bit word. */
void pushI32(std::vector<std::uint8_t>& out, std::int32_t value) { pushU32(out, static_cast<std::uint32_t>(value)); }
/** @brief Appends the little-endian bit pattern of a float. */
void pushF32(std::vector<std::uint8_t>& out, float value) { pushU32(out, std::bit_cast<std::uint32_t>(value)); }
/**
* @brief Bounds-checked little-endian cursor over a payload.
*
* A short read leaves the cursor where it is and clears `ok`; every accessor then
* returns zero, so a caller that checks `ok` once after a run of reads cannot read
* past the buffer. Input bytes are never reinterpreted as a struct.
*/
class Reader {
public:
Reader(const std::uint8_t* data, std::size_t size) noexcept : data_(data), size_(size) {}
/** @brief Whether every read so far fitted inside the payload. */
[[nodiscard]] bool ok() const noexcept { return ok_; }
/** @brief Current absolute cursor position. */
[[nodiscard]] std::size_t offset() const noexcept { return offset_; }
/** @brief Bytes left after the cursor. */
[[nodiscard]] std::size_t remaining() const noexcept { return size_ - offset_; }
/** @brief Absolute seek. @return False when `offset` is past the end. */
[[nodiscard]] bool seek(std::size_t offset) noexcept {
if (offset > size_) {
ok_ = false;
return false;
}
offset_ = offset;
return true;
}
/** @brief Relative skip. @return False when the payload ends first. */
[[nodiscard]] bool skip(std::size_t count) noexcept { return seek(offset_ + count); }
[[nodiscard]] std::uint32_t u32() noexcept {
if (remaining() < 4) {
ok_ = false;
return 0u;
}
std::uint32_t value = 0;
for (std::size_t index = 0; index < 4; ++index)
value |= static_cast<std::uint32_t>(data_[offset_ + index]) << (8u * static_cast<unsigned>(index));
offset_ += 4;
return value;
}
[[nodiscard]] std::uint64_t u64() noexcept {
if (remaining() < 8) {
ok_ = false;
return 0u;
}
std::uint64_t value = 0;
for (std::size_t index = 0; index < 8; ++index)
value |= static_cast<std::uint64_t>(data_[offset_ + index]) << (8u * static_cast<unsigned>(index));
offset_ += 8;
return value;
}
[[nodiscard]] std::int32_t i32() noexcept { return static_cast<std::int32_t>(u32()); }
[[nodiscard]] float f32() noexcept { return std::bit_cast<float>(u32()); }
private:
const std::uint8_t* data_ = nullptr;
std::size_t size_ = 0;
std::size_t offset_ = 0;
bool ok_ = true;
};
} // namespace
// --- writing -----------------------------------------------------------------
Result<void> saveHexMap(const HexMap& map, const std::vector<HexUnitState>& units, std::vector<std::uint8_t>& out) {
if (map.empty()) return Result<void>::failure(invalidArgument("cannot serialize an empty hex map"));
const std::int32_t cellCount = map.cellCount();
const std::size_t payload =
kHeaderSize + static_cast<std::size_t>(cellCount) * kCellRecordSize + 4 + units.size() * kUnitRecordSize;
out.clear();
out.reserve(payload);
for (const std::uint8_t byte : kHexSaveMagic) out.push_back(byte);
pushU32(out, kHexSaveVersion);
pushU32(out, static_cast<std::uint32_t>(map.cellCountX()));
pushU32(out, static_cast<std::uint32_t>(map.cellCountZ()));
pushU32(out, map.seed());
pushU64(out, map.revision());
for (std::int32_t index = 0; index < cellCount; ++index) {
// `cellAt` is non-null for every index below `cellCount()`, the loop bound.
const HexCellData* cell = map.cellAt(index);
pushU32(out, cell->values.raw());
pushU32(out, cell->flags.raw());
}
pushU32(out, static_cast<std::uint32_t>(units.size()));
for (const HexUnitState& unit : units) {
pushI32(out, unit.locationIndex);
pushF32(out, unit.orientation);
}
return Result<void>::success();
}
// --- reading -----------------------------------------------------------------
Result<void> loadHexMap(const std::vector<std::uint8_t>& bytes, HexMap& map, std::vector<HexUnitState>& units) {
if (bytes.size() < kHeaderSize) return Result<void>::failure(invalidArgument("hex save payload is truncated"));
for (std::size_t index = 0; index < kMagicSize; ++index) {
if (bytes[index] != kHexSaveMagic[index])
return Result<void>::failure(invalidArgument("hex save payload has an unknown magic prefix"));
}
Reader reader(bytes.data(), bytes.size());
// The payload size check above already covers the magic, so this can only fail
// if that check is ever relaxed.
if (!reader.skip(kMagicSize)) return Result<void>::failure(invalidArgument("hex save payload is truncated"));
const std::uint32_t version = reader.u32();
if (version != kHexSaveVersion) {
return Result<void>::failure(invalidArgument("hex save version " + std::to_string(version) +
" is not supported (expected " + std::to_string(kHexSaveVersion) +
")"));
}
const std::uint32_t cellCountX = reader.u32();
const std::uint32_t cellCountZ = reader.u32();
const std::uint32_t seed = reader.u32();
// Diagnostic only: the restored map's revision is whatever `reset` produces.
const std::uint64_t savedRevision = reader.u64();
(void)savedRevision;
if (cellCountX == 0 || cellCountZ == 0)
return Result<void>::failure(invalidArgument("hex save grid dimensions must be positive"));
if (cellCountX > kMaxGridDimension || cellCountZ > kMaxGridDimension)
return Result<void>::failure(invalidArgument("hex save grid dimensions exceed the 512 cell limit"));
if (cellCountX % static_cast<std::uint32_t>(HexMetrics::kChunkSizeX) != 0u ||
cellCountZ % static_cast<std::uint32_t>(HexMetrics::kChunkSizeZ) != 0u)
return Result<void>::failure(
invalidArgument("hex save grid dimensions must be a multiple of the 5x5 chunk size"));
const std::size_t cellCount = static_cast<std::size_t>(cellCountX) * static_cast<std::size_t>(cellCountZ);
const std::size_t cellsEnd = kHeaderSize + cellCount * kCellRecordSize;
const std::size_t unitsStart = cellsEnd + 4;
if (bytes.size() < unitsStart)
return Result<void>::failure(invalidArgument("hex save payload is truncated before the unit records"));
if (!reader.seek(cellsEnd)) return Result<void>::failure(invalidArgument("hex save payload is truncated"));
const std::uint32_t unitCount = reader.u32();
if (static_cast<std::size_t>(unitCount) > (bytes.size() - unitsStart) / kUnitRecordSize)
return Result<void>::failure(invalidArgument("hex save unit count exceeds the payload size"));
if (unitsStart + static_cast<std::size_t>(unitCount) * kUnitRecordSize != bytes.size())
return Result<void>::failure(invalidArgument("hex save payload size does not match its contents"));
// Decode into locals first: a rejected payload must leave both outputs alone.
std::vector<HexCellData> cells(cellCount);
if (!reader.seek(kHeaderSize)) return Result<void>::failure(invalidArgument("hex save payload is truncated"));
for (std::size_t index = 0; index < cellCount; ++index) {
cells[index].values = HexValues{reader.u32()};
cells[index].flags = HexFlags{reader.u32()};
}
std::vector<HexUnitState> restored(unitCount);
if (!reader.seek(unitsStart)) return Result<void>::failure(invalidArgument("hex save payload is truncated"));
for (std::uint32_t index = 0; index < unitCount; ++index) {
const std::int32_t locationIndex = reader.i32();
const float orientation = reader.f32();
if (locationIndex < 0 || static_cast<std::size_t>(locationIndex) >= cellCount)
return Result<void>::failure(invalidArgument("hex save unit cell is outside the grid"));
restored[index].locationIndex = locationIndex;
restored[index].orientation = orientation;
}
if (!reader.ok()) return Result<void>::failure(invalidArgument("hex save payload is truncated"));
// The unit list must be legal *before* the grid is adopted, because the unit
// registry consumes it afterwards and would otherwise reject the whole list
// against a grid the caller had already replaced. `canHoldUnit` is the same
// terrain rule the registry applies through `isValidDestination`.
for (std::size_t index = 0; index < restored.size(); ++index) {
const auto cell = static_cast<std::size_t>(restored[index].locationIndex);
if (!canHoldUnit(cells[cell]))
return Result<void>::failure(invalidArgument("hex save unit stands on an unusable cell"));
for (std::size_t other = 0; other < index; ++other) {
if (restored[other].locationIndex == restored[index].locationIndex)
return Result<void>::failure(invalidArgument("hex save units occupy the same cell"));
}
}
// Everything decoded; only now are the outputs rebuilt.
auto resized = map.reset(static_cast<std::int32_t>(cellCountX), static_cast<std::int32_t>(cellCountZ), seed);
if (!resized.ok()) return Result<void>::failure(resized.status());
for (std::size_t index = 0; index < cellCount; ++index) {
map.setCellState(map.coordinatesAt(static_cast<std::int32_t>(index)), cells[index].values, cells[index].flags)
.ignore("every index below the freshly reset cell count is inside the grid");
}
map.markAllChunksDirty();
units = std::move(restored);
return Result<void>::success();
}
} // namespace eve::hexmap
+70
View File
@@ -0,0 +1,70 @@
#pragma once
/** @file HexSerializer.h @brief Versioned payload codec for a hex map and its units. */
#include "common/Result.h"
#include "hexmap/HexMap.h"
#include "hexmap/HexUnits.h"
#include <cstdint>
#include <vector>
namespace eve::hexmap {
/** @brief Magic prefix of a hex map payload; ASCII `EVEHEX\0\0`. */
inline constexpr std::uint8_t kHexSaveMagic[8] = {'E', 'V', 'E', 'H', 'E', 'X', 0, 0};
/** @brief Format version written by `saveHexMap`. */
inline constexpr std::uint32_t kHexSaveVersion = 1;
/**
* @brief Serializes a map and its units into a self-describing byte payload.
*
* Every field is little-endian and written byte by byte:
*
* | offset | type | field |
* |--------|---------------|------------------------------------------|
* | 0 | `uint8[8]` | `kHexSaveMagic` |
* | 8 | `uint32` | format version |
* | 12 | `uint32` | cell count X |
* | 16 | `uint32` | cell count Z |
* | 20 | `uint32` | generation seed |
* | 24 | `uint64` | map revision at save time (diagnostic) |
* | 32 | `uint32[2*N]` | per cell: packed `HexValues`, `HexFlags` |
* | ... | `uint32` | unit count |
* | ... | `(int32,float)` | per unit: cell index, orientation |
*
* The payload is a *snapshot format*, not a save-game format for arbitrary game
* state: it carries the grid and the units that the map itself owns. Unknown
* fields are not preserved because the version is rejected outright when it is
* not `kHexSaveVersion`; a newer writer must therefore bump the version and add
* its migration in `loadHexMap`.
*
* @param map Map to serialize.
* @param units Unit states, in registry order.
* @param out Receives the payload; cleared first.
* @return Success, or InvalidArgument when the map holds no cells.
* @cost Proportional to the cell count.
*/
[[nodiscard]] Result<void> saveHexMap(const HexMap& map, const std::vector<HexUnitState>& units,
std::vector<std::uint8_t>& out);
/**
* @brief Restores a map and its units from a payload produced by `saveHexMap`.
*
* The whole payload is validated before `map` is touched, so a rejected payload
* leaves both `map` and `units` unchanged. On success `map` is rebuilt from
* scratch through `HexMap::reset` — no partially applied cell writes are
* observable.
*
* @param bytes Payload to decode.
* @param map Receives the restored grid.
* @param units Receives the restored unit states; cleared first.
* @return Success, or InvalidArgument for a truncated, mis-magic'd, wrong-version
* or out-of-range payload.
* @cost Proportional to the cell count.
*/
[[nodiscard]] Result<void> loadHexMap(const std::vector<std::uint8_t>& bytes, HexMap& map,
std::vector<HexUnitState>& units);
} // namespace eve::hexmap
+477
View File
@@ -0,0 +1,477 @@
#include "hexmap/HexMapMesh.h"
#include "hexmap/HexCoordinates.h"
#include "hexmap/HexMap.h"
#include "hexmap/HexMeshData.h"
#include "hexmap/HexMetrics.h"
#include "hexmap/HexNoise.h"
#include <algorithm>
#include <array>
#include <cstdint>
namespace eve::hexmap {
namespace {
/**
* @brief Perturbation contract of this translation unit.
*
* `HexMesher::vertex` is the single place a position is displaced, so no position is ever perturbed
* twice. Every helper below therefore works on *unperturbed* positions. `HexMap::cellPosition()`
* already carries the vertical noise term, so cell centres (and the edge frames built from them) take
* only the horizontal part of the perturbation - the same split the map itself uses between
* `HexNoise::perturb` (XZ) and `HexNoise::elevationPerturb` (Y). Vertices that are pure interpolations
* between two such positions inherit their Y exactly, which is what keeps a chunk's seam with its
* neighbour closed.
*/
/** @brief Terrain palette index of a cell, or `0` when the cell is absent. */
[[nodiscard]] std::int32_t terrainOf(const HexCellData* cell) noexcept {
return cell == nullptr ? 0 : cell->values.terrainType();
}
/** @brief Elevation of a cell, or `0` when the cell is absent. */
[[nodiscard]] std::int32_t elevationOf(const HexCellData* cell) noexcept {
return cell == nullptr ? 0 : cell->values.elevation();
}
/** @brief Horizontal-only perturbation, `±kCellPerturbStrength` in XZ with Y preserved. */
[[nodiscard]] HexVec3 horizontalPerturb(const HexNoise& noise, HexVec3 position) noexcept {
const HexVec4 sample = noise.sample(position.x, position.z);
position.x += (sample.x * 2.f - 1.f) * HexMetrics::kCellPerturbStrength;
position.z += (sample.z * 2.f - 1.f) * HexMetrics::kCellPerturbStrength;
return position;
}
/** @brief The three cells meeting at a corner, kept together while they are sorted by elevation. */
struct CornerCells {
HexVec3 up{};
HexVec3 left{};
HexVec3 right{};
const HexCellData* upCell = nullptr;
const HexCellData* leftCell = nullptr;
const HexCellData* rightCell = nullptr;
};
/** @brief Emits the ground fans, blend strips, terraces and cliffs of one chunk. */
class HexMesher {
public:
HexMesher(const HexMap& map, HexMeshData& out) noexcept : map_(map), out_(out) {}
/** @brief Builds every cell of `chunkIndex`. */
void run(std::int32_t chunkIndex) {
for (std::int32_t row = 0; row < HexMetrics::kChunkSizeZ; ++row) {
for (std::int32_t column = 0; column < HexMetrics::kChunkSizeX; ++column) {
buildCell(map_.chunkCell(chunkIndex, column, row));
}
}
}
private:
// --- per-cell entry point -----------------------------------------------
/** @brief Emits the six fans of one cell plus the connection strips it owns. */
void buildCell(HexCoordinates coordinates) {
if (!map_.contains(coordinates)) return;
const HexCellData* cellData = map_.cell(coordinates);
if (cellData == nullptr) return;
const HexVec3 center = map_.cellPosition(coordinates);
for (std::int32_t index = 0; index < kHexDirectionCount; ++index) {
const auto direction = static_cast<HexDirection>(index);
EdgeVertices near = solidEdge(center, direction);
if (riverThrough(coordinates, direction, cellData)) {
// Carve a shallow channel along the river centreline (the middle sample only).
near.v3.y = HexMetrics::streamBedY(cellData->values.elevation());
}
appendEdgeFan(center, near, cellData);
HexCoordinates neighbourCoordinates{};
if (!map_.getNeighbor(coordinates, direction, neighbourCoordinates)) continue;
const HexCellData* neighbour = map_.cell(neighbourCoordinates);
if (neighbour == nullptr) continue;
appendConnection(coordinates, center, near, direction, cellData, neighbour, neighbourCoordinates);
}
}
/** @brief Whether a river crosses the edge shared with `direction`'s neighbour. */
[[nodiscard]] bool riverThrough(HexCoordinates coordinates, HexDirection direction,
const HexCellData* cellData) const noexcept {
if (cellData != nullptr && cellData->flags.hasRiverThrough(direction)) return true;
HexCoordinates neighbour{};
if (!map_.getNeighbor(coordinates, direction, neighbour)) return false;
const HexCellData* other = map_.cell(neighbour);
return other != nullptr && other->flags.hasRiverThrough(opposite(direction));
}
/** @brief Emits the strip/terrace/cliff towards one neighbour and the corner it closes. */
void appendConnection(HexCoordinates coordinates, HexVec3 center, const EdgeVertices& near, HexDirection direction,
const HexCellData* cellData, const HexCellData* neighbour,
HexCoordinates neighbourCoordinates) {
// Chunk ownership rule: only the NE, E and SE edges of a cell belong to the
// owning chunk, so every shared boundary is emitted exactly once - by the cell
// that owns that edge, never by both. The reference gates its whole
// `TriangulateConnection` call the same way. Emitting the strip from the other
// side too puts two coplanar (or, on a slope, two interpenetrating) copies on
// every boundary, which is what makes the terrain look like it has seams.
//
// The gate has to come before the strip is built: it also covers the corner
// below, which is why the corner matrix only ever runs for these three
// directions.
if (static_cast<std::int32_t>(direction) > static_cast<std::int32_t>(HexDirection::SE)) return;
const EdgeVertices far = shiftedEdge(center, direction, map_.cellPosition(neighbourCoordinates).y);
const HexTerrainWeights nearWeights = HexTerrainWeights::primary();
const HexTerrainWeights farWeights = HexTerrainWeights::blend(1.f);
switch (map_.edgeTypeTo(coordinates, neighbourCoordinates)) {
case HexEdgeType::Flat: appendBlendStrip(near, far, nearWeights, farWeights, cellData, neighbour); break;
case HexEdgeType::Slope: appendEdgeTerraces(near, nearWeights, far, farWeights, cellData, neighbour); break;
case HexEdgeType::Cliff:
// The blend strip would already be a vertical wall here, but its winding flips with the
// sign of the elevation step; the vertical winding is correct in both directions.
emitQuadVertical(near.v1, near.v5, far.v1, far.v5, nearWeights, nearWeights, farWeights, farWeights,
cellData, neighbour, cellData);
break;
}
HexCoordinates nextCoordinates{};
if (!map_.getNeighbor(coordinates, next(direction), nextCoordinates)) return;
const HexCellData* nextCell = map_.cell(nextCoordinates);
CornerCells corner{};
corner.up = near.v5;
// Third corner vertex: this cell's corner carried across by the bridge of the
// *next* edge, exactly as the reference builds `e1.v5 + GetBridge(d.Next())`.
corner.left = near.v5 + HexMetrics::bridge(next(direction));
corner.left.y = map_.cellPosition(nextCoordinates).y;
corner.right = far.v5;
corner.upCell = cellData;
corner.leftCell = neighbour;
corner.rightCell = nextCell;
appendCorner(corner);
}
// --- corner matrix ------------------------------------------------------
/** @brief Sorts the three corner cells by elevation and dispatches on the two edge types. */
void appendCorner(const CornerCells& corner) {
const std::array<HexVec3, 3> positions{corner.up, corner.left, corner.right};
const std::array<const HexCellData*, 3> cells{corner.upCell, corner.leftCell, corner.rightCell};
std::array<std::int32_t, 3> order{0, 1, 2};
for (std::int32_t i = 0; i < 2; ++i) {
for (std::int32_t j = i + 1; j < 3; ++j) {
if (elevationOf(cells[static_cast<std::size_t>(order[static_cast<std::size_t>(j)])]) <
elevationOf(cells[static_cast<std::size_t>(order[static_cast<std::size_t>(i)])])) {
std::swap(order[static_cast<std::size_t>(i)], order[static_cast<std::size_t>(j)]);
}
}
}
const HexVec3 bottom = positions[static_cast<std::size_t>(order[0])];
const HexVec3 low = positions[static_cast<std::size_t>(order[1])];
const HexVec3 high = positions[static_cast<std::size_t>(order[2])];
const HexCellData* bottomCell = cells[static_cast<std::size_t>(order[0])];
const HexCellData* lowCell = cells[static_cast<std::size_t>(order[1])];
const HexCellData* highCell = cells[static_cast<std::size_t>(order[2])];
const HexEdgeType lowEdge = edgeType(elevationOf(bottomCell), elevationOf(lowCell));
const HexEdgeType highEdge = edgeType(elevationOf(bottomCell), elevationOf(highCell));
if (lowEdge == HexEdgeType::Slope && highEdge == HexEdgeType::Slope) {
cornerTerraces(bottom, low, high, bottomCell, lowCell, highCell);
} else if (lowEdge == HexEdgeType::Slope && highEdge == HexEdgeType::Flat) {
cornerTerraces(low, high, bottom, lowCell, highCell, bottomCell);
} else if (lowEdge == HexEdgeType::Flat && highEdge == HexEdgeType::Slope) {
cornerTerraces(high, bottom, low, highCell, bottomCell, lowCell);
} else if (lowEdge == HexEdgeType::Slope && highEdge == HexEdgeType::Cliff) {
cornerTerracesCliff(bottom, low, high, bottomCell, lowCell, highCell, lowEdge);
} else if (lowEdge == HexEdgeType::Cliff && highEdge == HexEdgeType::Slope) {
cornerCliffTerraces(bottom, low, high, bottomCell, lowCell, highCell, highEdge);
} else if (edgeType(elevationOf(lowCell), elevationOf(highCell)) == HexEdgeType::Slope) {
if (elevationOf(lowCell) < elevationOf(highCell)) {
cornerCliffTerraces(high, bottom, low, highCell, bottomCell, lowCell, highEdge);
} else {
cornerTerracesCliff(low, high, bottom, lowCell, highCell, bottomCell, lowEdge);
}
} else {
// Mirror of the reference winding: this engine's front face is the opposite
// handedness, so the flat corner fan is emitted high-before-low.
emitTriangle(bottom, high, low, HexTerrainWeights::primary(), bottomCell, lowCell, highCell);
}
}
/** @brief Five-step terraced fan across a corner whose both sides of `bottom` are slopes. */
void cornerTerraces(const HexVec3& bottom, const HexVec3& left, const HexVec3& right, const HexCellData* bottomCell,
const HexCellData* leftCell, const HexCellData* rightCell) {
const HexTerrainWeights bottomWeights = HexTerrainWeights::primary();
const HexTerrainWeights leftWeights = HexTerrainWeights::primary();
const HexTerrainWeights rightWeights = HexTerrainWeights::primary();
HexVec3 lastLeft = bottom;
HexVec3 lastRight = bottom;
HexTerrainWeights lastLeftWeights = bottomWeights;
HexTerrainWeights lastRightWeights = bottomWeights;
for (std::int32_t step = 1; step < HexMetrics::kTerracesPerSlope * 2; ++step) {
const float t = static_cast<float>(step) * HexMetrics::kHorizontalTerraceStepSize;
const HexTerrainWeights wl = HexTerrainWeights::lerp(bottomWeights, leftWeights, t);
const HexTerrainWeights wr = HexTerrainWeights::lerp(bottomWeights, rightWeights, t);
const HexVec3 boundaryLeft = HexMetrics::terraceLerp(bottom, left, step);
const HexVec3 boundaryRight = HexMetrics::terraceLerp(bottom, right, step);
emitQuadForward(boundaryLeft, boundaryRight, lastLeft, lastRight, wl, wr, lastLeftWeights, lastRightWeights,
bottomCell, leftCell, rightCell);
lastLeft = boundaryLeft;
lastRight = boundaryRight;
lastLeftWeights = wl;
lastRightWeights = wr;
}
emitQuadForward(lastLeft, lastRight, left, right, lastLeftWeights, lastRightWeights, leftWeights, rightWeights,
bottomCell, leftCell, rightCell);
}
/**
* @brief Fills the sliver whose `left`/`right` sides are constant-weight edges.
*
* One triangle per terrace step walks from `bottom` to `right`; the corner's third cell vanishes at
* `bottom`, so the band ends flush with the cliff foot.
*/
void appendBoundaryTriangle(HexVec3 bottom, const HexTerrainWeights& bottomWeights, HexVec3 left,
const HexTerrainWeights& leftWeights, HexVec3 right,
const HexTerrainWeights& rightWeights, const HexCellData* bottomCell,
const HexCellData* leftCell, const HexCellData* rightCell) {
HexVec3 leftSide = HexMetrics::terraceLerp(bottom, left, 1);
HexVec3 rightSide = HexMetrics::terraceLerp(bottom, right, 1);
for (std::int32_t step = 1; step < HexMetrics::kTerracesPerSlope * 2; ++step) {
const float t = static_cast<float>(step) * HexMetrics::kHorizontalTerraceStepSize;
const HexTerrainWeights wl = HexTerrainWeights::lerp(bottomWeights, leftWeights, t);
const HexTerrainWeights wr = HexTerrainWeights::lerp(bottomWeights, rightWeights, t);
emitTriangle(leftSide, rightSide, bottom, wl, wr, bottomWeights, bottomCell, leftCell, rightCell);
leftSide = rightSide;
rightSide = HexMetrics::terraceLerp(bottom, right, step + 1);
}
emitTriangle(leftSide, rightSide, bottom, leftWeights, rightWeights, bottomWeights, bottomCell, leftCell,
rightCell);
}
/** @brief Terraced corner where the `high` side rises to a cliff foot. */
void cornerTerracesCliff(const HexVec3& bottom, const HexVec3& low, const HexVec3& high,
const HexCellData* bottomCell, const HexCellData* lowCell, const HexCellData* highCell,
HexEdgeType lowEdge) {
const HexTerrainWeights bottomWeights = HexTerrainWeights::primary();
const HexTerrainWeights lowWeights = HexTerrainWeights::primary();
const HexTerrainWeights highWeights = HexTerrainWeights::primary();
const float span = static_cast<float>(elevationOf(highCell) - elevationOf(bottomCell));
const float blend = span > 1e-6f ? 1.f / span : 1.f;
const HexVec3 boundary = lerp(bottom, high, blend);
const HexTerrainWeights boundaryWeights = HexTerrainWeights::lerp(bottomWeights, highWeights, blend);
const std::uint32_t anchor = static_cast<std::uint32_t>(out_.vertexCount());
vertex(boundary, boundaryWeights, bottomCell, lowCell, highCell);
appendBoundaryTriangle(bottom, bottomWeights, low, lowWeights, boundary, boundaryWeights, bottomCell, lowCell,
highCell);
emitTriangleFrom(anchor, low, bottom, lowWeights, bottomWeights, bottomCell, lowCell, highCell);
if (lowEdge == HexEdgeType::Slope) {
appendBoundaryTriangle(boundary, boundaryWeights, bottom, bottomWeights, high, highWeights, bottomCell,
lowCell, highCell);
} else {
emitTriangle(high, bottom, low, highWeights, bottomWeights, lowWeights, bottomCell, lowCell, highCell);
}
}
/** @brief Terraced corner where the `low` side rises to a cliff foot (mirror of the above). */
void cornerCliffTerraces(const HexVec3& bottom, const HexVec3& low, const HexVec3& high,
const HexCellData* bottomCell, const HexCellData* lowCell, const HexCellData* highCell,
HexEdgeType highEdge) {
const HexTerrainWeights bottomWeights = HexTerrainWeights::primary();
const HexTerrainWeights lowWeights = HexTerrainWeights::primary();
const HexTerrainWeights highWeights = HexTerrainWeights::primary();
const float span = static_cast<float>(elevationOf(lowCell) - elevationOf(bottomCell));
const float blend = span > 1e-6f ? 1.f / span : 1.f;
const HexVec3 boundary = lerp(bottom, low, blend);
const HexTerrainWeights boundaryWeights = HexTerrainWeights::lerp(bottomWeights, lowWeights, blend);
const std::uint32_t anchor = static_cast<std::uint32_t>(out_.vertexCount());
vertex(boundary, boundaryWeights, bottomCell, lowCell, highCell);
appendBoundaryTriangle(bottom, bottomWeights, high, highWeights, boundary, boundaryWeights, bottomCell,
highCell, lowCell);
emitTriangleFrom(anchor, high, bottom, highWeights, bottomWeights, bottomCell, highCell, lowCell);
if (highEdge == HexEdgeType::Slope) {
appendBoundaryTriangle(boundary, boundaryWeights, bottom, bottomWeights, low, lowWeights, bottomCell,
highCell, lowCell);
} else {
emitTriangle(low, bottom, high, lowWeights, bottomWeights, highWeights, bottomCell, highCell, lowCell);
}
}
// --- vertex and triangle primitives -------------------------------------
/** @brief Emits one vertex; the single perturbation point of this file. */
void vertex(HexVec3 position, const HexTerrainWeights& weights, const HexCellData* t0, const HexCellData* t1,
const HexCellData* t2) {
const float u = HexTerrainVertexCode::encodeIndices(terrainOf(t0), terrainOf(t1), terrainOf(t2));
const float v = HexTerrainVertexCode::encodeWeights(weights.b, weights.c);
out_.addVertex(horizontalPerturb(map_.noise(), position), u, v);
}
/** @brief Emits a triangle whose three vertices share one weight set and one cell triple. */
void emitTriangle(const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, const HexTerrainWeights& w,
const HexCellData* t0, const HexCellData* t1, const HexCellData* t2) {
const std::uint32_t i0 = static_cast<std::uint32_t>(out_.vertexCount());
vertex(p0, w, t0, t1, t2);
vertex(p1, w, t0, t1, t2);
vertex(p2, w, t0, t1, t2);
out_.addTriangle(i0, i0 + 1u, i0 + 2u);
}
/** @brief Emits a triangle with one weight set per vertex. */
void emitTriangle(const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, const HexTerrainWeights& w0,
const HexTerrainWeights& w1, const HexTerrainWeights& w2, const HexCellData* t0,
const HexCellData* t1, const HexCellData* t2) {
const std::uint32_t i0 = static_cast<std::uint32_t>(out_.vertexCount());
vertex(p0, w0, t0, t1, t2);
vertex(p1, w1, t0, t1, t2);
vertex(p2, w2, t0, t1, t2);
out_.addTriangle(i0, i0 + 1u, i0 + 2u);
}
/**
* @brief Emits a horizontal quad `(a, b, c, d)` as `(a, c, b)` and `(b, c, d)`.
*
* The argument order is the `a -> b -> d -> c` ring used by `HexMeshData::addQuad`.
* Splitting along the other diagonal instead (`(a,b,c),(b,c,d)`) gives the two
* triangles opposite face normals, which the renderer turns into a sawtooth band
* along every hex edge.
*/
void emitQuadForward(const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, const HexVec3& p3,
const HexTerrainWeights& w0, const HexTerrainWeights& w1, const HexTerrainWeights& w2,
const HexTerrainWeights& w3, const HexCellData* t0, const HexCellData* t1,
const HexCellData* t2) {
const std::uint32_t i0 = static_cast<std::uint32_t>(out_.vertexCount());
vertex(p0, w0, t0, t1, t2);
vertex(p1, w1, t0, t1, t2);
vertex(p2, w2, t0, t1, t2);
vertex(p3, w3, t0, t1, t2);
out_.addTriangle(i0, i0 + 2u, i0 + 1u);
out_.addTriangle(i0 + 1u, i0 + 2u, i0 + 3u);
}
/** @brief Emits a vertical quad in the bottom/top alternating order used by cliff strips. */
void emitQuadVertical(const HexVec3& p0, const HexVec3& p1, const HexVec3& p2, const HexVec3& p3,
const HexTerrainWeights& w0, const HexTerrainWeights& w1, const HexTerrainWeights& w2,
const HexTerrainWeights& w3, const HexCellData* t0, const HexCellData* t1,
const HexCellData* t2) {
const std::uint32_t i0 = static_cast<std::uint32_t>(out_.vertexCount());
vertex(p0, w0, t0, t1, t2);
vertex(p1, w1, t0, t1, t2);
vertex(p2, w2, t0, t1, t2);
vertex(p3, w3, t0, t1, t2);
out_.addTriangle(i0, i0 + 2u, i0 + 1u);
out_.addTriangle(i0 + 1u, i0 + 2u, i0 + 3u);
}
/** @brief Emits a triangle that reuses an already emitted anchor vertex as its first corner. */
void emitTriangleFrom(std::uint32_t anchor, const HexVec3& p1, const HexVec3& p2, const HexTerrainWeights& w1,
const HexTerrainWeights& w2, const HexCellData* t0, const HexCellData* t1,
const HexCellData* t2) {
const std::uint32_t i1 = static_cast<std::uint32_t>(out_.vertexCount());
vertex(p1, w1, t0, t1, t2);
vertex(p2, w2, t0, t1, t2);
out_.addTriangle(anchor, i1, i1 + 1u);
}
/** @brief Four triangles fanning from `center` along one solid edge. */
void appendEdgeFan(HexVec3 center, const EdgeVertices& edge, const HexCellData* cellData) {
const HexTerrainWeights w = HexTerrainWeights::primary();
const HexVec3 samples[5] = {edge.v1, edge.v2, edge.v3, edge.v4, edge.v5};
for (std::int32_t i = 0; i < 4; ++i) {
emitTriangle(center, samples[i], samples[i + 1], w, cellData, cellData, cellData);
}
}
/** @brief Four quads bridging `near` to `far`, blending the two cells' terrain types. */
void appendBlendStrip(const EdgeVertices& near, const EdgeVertices& far, const HexTerrainWeights& nearWeights,
const HexTerrainWeights& farWeights, const HexCellData* nearCell,
const HexCellData* farCell) {
const HexVec3 nearPositions[5] = {near.v1, near.v2, near.v3, near.v4, near.v5};
const HexVec3 farPositions[5] = {far.v1, far.v2, far.v3, far.v4, far.v5};
for (std::int32_t i = 0; i < 4; ++i) {
const float t0 = static_cast<float>(i) * 0.25f;
const float t1 = static_cast<float>(i + 1) * 0.25f;
const HexTerrainWeights w0 = HexTerrainWeights::lerp(nearWeights, farWeights, t0);
const HexTerrainWeights w1 = HexTerrainWeights::lerp(nearWeights, farWeights, t1);
// Both vertices of an edge share their parameter: the near pair carries w0
// and the far pair carries w1. Swapping them makes every quad blend along the
// wrong axis and turns the band into a sawtooth.
emitQuadForward(nearPositions[i], nearPositions[i + 1], farPositions[i], farPositions[i + 1], w0, w0, w1,
w1, nearCell, farCell, nearCell);
}
}
/** @brief Terraces a slope between two edges; one quad per terrace step plus the closing quad. */
void appendEdgeTerraces(EdgeVertices near, const HexTerrainWeights& nearWeights, EdgeVertices far,
const HexTerrainWeights& farWeights, const HexCellData* nearCell,
const HexCellData* farCell) {
for (std::int32_t step = 1; step < HexMetrics::kTerracesPerSlope * 2; ++step) {
const HexTerrainWeights w1 = HexTerrainWeights::lerp(
nearWeights, farWeights, static_cast<float>(step) * HexMetrics::kHorizontalTerraceStepSize);
const EdgeVertices middle = EdgeVertices::terraceLerp(near, far, step);
emitQuadForward(near.v1, near.v5, middle.v1, middle.v5, nearWeights, nearWeights, w1, w1, nearCell, farCell,
nearCell);
near = middle;
}
emitQuadForward(near.v1, near.v5, far.v1, far.v5, nearWeights, nearWeights, farWeights, farWeights, nearCell,
farCell, nearCell);
}
// --- edge frames --------------------------------------------------------
/** @brief Edge frame of the solid border in `direction`, centred on `center` at its own elevation. */
[[nodiscard]] static EdgeVertices solidEdge(HexVec3 center, HexDirection direction) noexcept {
return EdgeVertices{center + HexMetrics::firstSolidCorner(direction),
center + HexMetrics::secondSolidCorner(direction)};
}
/** @brief Solid border of `direction`, bridged to the neighbour and raised to `targetY`. */
[[nodiscard]] static EdgeVertices shiftedEdge(HexVec3 center, HexDirection direction, float targetY) noexcept {
const HexVec3 bridge = HexMetrics::bridge(direction);
EdgeVertices edge = solidEdge(center, direction);
edge.v1 = edge.v1 + bridge;
edge.v2 = edge.v2 + bridge;
edge.v3 = edge.v3 + bridge;
edge.v4 = edge.v4 + bridge;
edge.v5 = edge.v5 + bridge;
edge.v1.y = targetY;
edge.v2.y = targetY;
edge.v3.y = targetY;
edge.v4.y = targetY;
edge.v5.y = targetY;
return edge;
}
const HexMap& map_;
HexMeshData& out_;
};
} // namespace
void buildTerrainMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out) {
out.clear();
if (chunkIndex < 0 || chunkIndex >= map.chunkCount()) {
out.finalize();
return;
}
HexMesher mesher(map, out);
mesher.run(chunkIndex);
out.finalize();
}
} // namespace eve::hexmap
+392
View File
@@ -0,0 +1,392 @@
#include "hexmap/HexUnits.h"
#include "common/Diagnostic.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <string>
#include <utility>
#include <vector>
namespace eve::hexmap {
namespace {
/**
* @brief Diagnostic for a rejected argument.
*
* Returned as a `Diagnostic` rather than a `Result<void>` so one helper can build
* the failure arm of every `Result<T>` shape this translation unit returns.
*/
[[nodiscard]] Diagnostic invalidArgument(std::string message) {
return Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), "hexmap");
}
/** @brief Diagnostic for an unknown unit id. */
[[nodiscard]] Diagnostic notFound(std::string message) {
return Diagnostic::error(DiagnosticCode::NotFound, std::move(message), "hexmap");
}
/** @brief Wraps an angle in degrees into `[-180, 180)`. */
[[nodiscard]] float wrapDegrees(float degrees) noexcept {
if (!std::isfinite(degrees)) return 0.f;
float wrapped = std::fmod(degrees + 180.f, 360.f);
if (wrapped < 0.f) wrapped += 360.f;
return wrapped - 180.f;
}
/** @brief Shortest signed turn from `from` to `to`, in `[-180, 180)`. */
[[nodiscard]] float angularDifference(float from, float to) noexcept { return wrapDegrees(to - from); }
/** @brief Yaw of a horizontal direction, using the reference `LookRotation` convention. */
[[nodiscard]] float yawFromDirection(HexVec3 direction) noexcept {
return std::atan2(direction.x, direction.z) * (180.f / std::numbers::pi_v<float>);
}
/** @brief Point on the quadratic Bézier of one travel segment. */
[[nodiscard]] HexVec3 bezierPoint(HexVec3 a, HexVec3 b, HexVec3 c, float t) noexcept {
const float r = 1.f - t;
return a * (r * r) + b * (2.f * r * t) + c * (t * t);
}
/** @brief Horizontal derivative of the quadratic Bézier of one travel segment; Y is dropped. */
[[nodiscard]] HexVec3 bezierDerivative(HexVec3 a, HexVec3 b, HexVec3 c, float t) noexcept {
const HexVec3 slope = (b - a) * (1.f - t) + (c - b) * t;
return HexVec3{2.f * slope.x, 0.f, 2.f * slope.z};
}
/** @brief Control points of the travel segment that enters `path[segment]`. */
struct TravelSegment {
/** @brief Cell the unit walks away from on this segment. */
HexCoordinates from{};
/** @brief Cell the unit walks towards on this segment. */
HexCoordinates to{};
/** @brief Curve start: the previous segment's end point. */
HexVec3 a{};
/** @brief Curve middle: the cell the unit leaves. */
HexVec3 b{};
/** @brief Curve end: half way to the destination cell centre. */
HexVec3 c{};
};
/**
* @brief Resolves the quadratic Bézier the unit follows on its current segment.
*
* This mirrors the reference project: the curve ends at the midpoint between the
* segment's start cell and its destination, and the unit snaps onto the
* destination cell centre as soon as travel completes.
*
* @param map Map that owns the cells.
* @param path Travel plan; `path[segment]` is the cell being entered.
* @param segment Index of the travelled segment, `[1, path.size() - 1]`.
* @return The control points and the two cells of the segment.
*/
[[nodiscard]] TravelSegment travelSegment(const HexMap& map, const std::vector<std::int32_t>& path,
std::int32_t segment) noexcept {
const std::int32_t last = static_cast<std::int32_t>(path.size()) - 1;
const std::int32_t index = segment < 1 ? 1 : (segment > last ? last : segment);
TravelSegment result;
result.from = map.coordinatesAt(path[static_cast<std::size_t>(index - 1)]);
result.to = map.coordinatesAt(path[static_cast<std::size_t>(index)]);
result.b = map.cellPosition(result.from);
result.c = (result.b + map.cellPosition(result.to)) * 0.5f;
if (index <= 1) {
result.a = result.b;
} else {
result.a = (map.cellPosition(map.coordinatesAt(path[static_cast<std::size_t>(index - 2)])) + result.b) * 0.5f;
}
return result;
}
} // namespace
// --- lookup -----------------------------------------------------------------
HexUnitRegistry::Unit* HexUnitRegistry::find(std::int32_t unitId) noexcept {
if (unitId < 0 || unitId >= static_cast<std::int32_t>(units_.size())) return nullptr;
return &units_[static_cast<std::size_t>(unitId)];
}
const HexUnitRegistry::Unit* HexUnitRegistry::find(std::int32_t unitId) const noexcept {
if (unitId < 0 || unitId >= static_cast<std::int32_t>(units_.size())) return nullptr;
return &units_[static_cast<std::size_t>(unitId)];
}
std::int32_t HexUnitRegistry::unitIdAt(HexCoordinates coordinates) const noexcept {
// Ids are positional, and `Unit::location` caches the occupied coordinates so
// this lookup needs no map.
for (std::size_t index = 0; index < units_.size(); ++index) {
if (units_[index].location == coordinates) return static_cast<std::int32_t>(index);
}
return -1;
}
HexOccupancyQuery HexUnitRegistry::occupancyQuery() const {
// The returned callable borrows `*this` and reads the cached occupied cells, so
// it stays valid exactly as long as this registry does; see the ownership note
// on `HexUnitRegistry::occupancyQuery`.
return HexOccupancyQuery{[this](HexCoordinates coordinates) { return isOccupied(coordinates); }};
}
// --- adding and removing ----------------------------------------------------
Result<std::int32_t> HexUnitRegistry::addUnit(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
HexCoordinates location, float orientation) {
if (map.empty()) return Result<std::int32_t>::failure(invalidArgument("cannot add a unit to an empty map"));
if (!map.contains(location))
return Result<std::int32_t>::failure(invalidArgument("unit location is outside the hex map"));
if (isOccupied(location))
return Result<std::int32_t>::failure(invalidArgument("unit location is already occupied"));
Unit unit;
unit.locationIndex = map.indexOf(location);
unit.location = location;
unit.orientation = orientation;
unit.visionIndex = unit.locationIndex;
units_.push_back(std::move(unit));
// The id is the unit's position in the registry, so it stays valid until a
// removal shifts the ids that follow it.
const std::int32_t id = static_cast<std::int32_t>(units_.size()) - 1;
auto granted = visibility.increase(map, scratch, location, tuning_.visionRange);
if (!granted.ok()) {
units_.pop_back();
return Result<std::int32_t>::failure(granted.status());
}
return Result<std::int32_t>::success(id);
}
Result<void> HexUnitRegistry::removeUnit(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
std::int32_t unitId) {
const Unit* unit = find(unitId);
if (unit == nullptr) return Result<void>::failure(notFound("unknown unit id"));
// Vision is always granted from `visionIndex`; while a unit travels that is the
// cell it walks through, not the destination cell `locationIndex` reserves.
const HexCoordinates viewer = map.coordinatesAt(unit->visionIndex);
auto withdrawn = visibility.decrease(map, scratch, viewer, tuning_.visionRange);
if (!withdrawn.ok()) return Result<void>::failure(withdrawn.status());
// Ids are positional: every unit after `unitId` shifts down by one, so callers
// must re-read `sample`/`snapshot` instead of reusing ids captured earlier.
units_.erase(units_.begin() + static_cast<std::ptrdiff_t>(unitId));
return Result<void>::success();
}
void HexUnitRegistry::removeAll(HexVisibility& visibility) noexcept {
units_.clear();
visibility.clear();
}
// --- snapshots ---------------------------------------------------------------
Result<HexUnitSample> HexUnitRegistry::makeSample(const HexMap& map, std::int32_t unitId, const Unit& unit) const {
HexUnitSample sample;
sample.id = unitId;
sample.location = unit.location;
sample.orientation = unit.orientation;
sample.traveling = unit.traveling;
if (unit.locationIndex < 0 || unit.locationIndex >= map.cellCount()) {
// The unit's cell is no longer part of the map; `refreshPositions` is what
// takes such a unit off the grid. Until then its position stays at origin.
return Result<HexUnitSample>::success(sample);
}
if (!unit.traveling || unit.path.size() < 2) {
sample.position = map.cellPosition(unit.location);
return Result<HexUnitSample>::success(sample);
}
const TravelSegment segment = travelSegment(map, unit.path, unit.segment);
sample.travelFrom = segment.from;
sample.position = bezierPoint(segment.a, segment.b, segment.c, unit.t);
return Result<HexUnitSample>::success(sample);
}
// --- travel ------------------------------------------------------------------
Result<void> HexUnitRegistry::beginTravel(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
std::int32_t unitId, const std::vector<std::int32_t>& path) {
Unit* unit = find(unitId);
if (unit == nullptr) return Result<void>::failure(invalidArgument("unknown unit id"));
if (path.empty()) return Result<void>::failure(invalidArgument("travel path must not be empty"));
if (path.front() != unit->locationIndex)
return Result<void>::failure(invalidArgument("travel path must start on the unit's cell"));
const std::int32_t cellCount = map.cellCount();
for (std::size_t index = 0; index < path.size(); ++index) {
if (path[index] < 0 || path[index] >= cellCount)
return Result<void>::failure(invalidArgument("travel path leaves the hex map"));
if (index > 0 && map.coordinatesAt(path[index - 1]).distanceTo(map.coordinatesAt(path[index])) != 1)
return Result<void>::failure(invalidArgument("travel path steps between non-adjacent cells"));
}
const std::int32_t destinationIndex = path.back();
const HexCoordinates destination = map.coordinatesAt(destinationIndex);
// The unit reserves the destination itself, so the reachability test must run
// without this registry's own occupancy predicate.
if (!isValidDestination(map, destination, {}))
return Result<void>::failure(invalidArgument("travel destination is not a valid destination"));
if (path.size() <= 1) return Result<void>::success();
const HexCoordinates origin = unit->location;
const HexCoordinates firstStep = map.coordinatesAt(path[1]);
// Visibility moves before the unit does, so a failed sweep leaves the unit
// exactly where it was instead of half-way into a travel plan.
auto withdrawn = visibility.decrease(map, scratch, origin, tuning_.visionRange);
if (!withdrawn.ok()) return Result<void>::failure(withdrawn.status());
auto granted = visibility.increase(map, scratch, firstStep, tuning_.visionRange);
if (!granted.ok()) {
visibility.increase(map, scratch, origin, tuning_.visionRange)
.ignore("restore the origin viewer after a failed travel start");
return Result<void>::failure(granted.status());
}
unit->path.assign(path.begin(), path.end());
unit->segment = 1;
unit->t = 0.f;
unit->traveling = true;
unit->locationIndex = destinationIndex;
unit->location = destination;
unit->visionIndex = path[1];
return Result<void>::success();
}
Result<HexUnitSample> HexUnitRegistry::advance(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
std::int32_t unitId, float dt) {
Unit* unit = find(unitId);
if (unit == nullptr) return Result<HexUnitSample>::failure(notFound("unknown unit id"));
if (!unit->traveling) return makeSample(map, unitId, *unit);
const float step = dt > 0.f ? dt : 0.f;
unit->t += step * tuning_.travelSpeed;
const std::int32_t lastSegment = static_cast<std::int32_t>(unit->path.size()) - 1;
while (unit->t >= 1.f && unit->segment < lastSegment) {
unit->t -= 1.f;
++unit->segment;
const HexCoordinates left = map.coordinatesAt(unit->path[static_cast<std::size_t>(unit->segment - 1)]);
const HexCoordinates right = map.coordinatesAt(unit->path[static_cast<std::size_t>(unit->segment)]);
auto withdrawn = visibility.decrease(map, scratch, left, tuning_.visionRange);
if (!withdrawn.ok()) return Result<HexUnitSample>::failure(withdrawn.status());
auto granted = visibility.increase(map, scratch, right, tuning_.visionRange);
if (!granted.ok()) return Result<HexUnitSample>::failure(granted.status());
unit->visionIndex = unit->path[static_cast<std::size_t>(unit->segment)];
}
if (unit->t >= 1.f) {
// The final segment is consumed: the unit stands on its reserved destination.
unit->traveling = false;
unit->t = 0.f;
unit->segment = 1;
unit->visionIndex = unit->locationIndex;
unit->path.clear();
return makeSample(map, unitId, *unit);
}
if (dt > 0.f) {
const TravelSegment segment = travelSegment(map, unit->path, unit->segment);
const HexVec3 velocity = bezierDerivative(segment.a, segment.b, segment.c, unit->t);
if (velocity.x * velocity.x + velocity.z * velocity.z > 1e-8f) {
const float heading = yawFromDirection(velocity);
const float turn = tuning_.rotationSpeed * dt;
const float delta = angularDifference(unit->orientation, heading);
const float applied = delta > turn ? turn : (delta < -turn ? -turn : delta);
unit->orientation = wrapDegrees(unit->orientation + applied);
}
}
return makeSample(map, unitId, *unit);
}
void HexUnitRegistry::advanceAll(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch, float dt) {
const std::int32_t count = unitCount();
for (std::int32_t unitId = 0; unitId < count; ++unitId) {
// Every cell a unit can step onto was validated by `beginTravel`, so the
// per-unit vision sweeps cannot fail here; `advance` reports a failure
// rather than continuing past it.
advance(map, visibility, scratch, unitId, dt).ignore("travel cells are validated by beginTravel");
}
}
// --- bulk state --------------------------------------------------------------
void HexUnitRegistry::refreshVisibility(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch) {
visibility.clear();
for (const Unit& unit : units_) {
// The viewer cell is `visionIndex`, the cell whose fog of war this unit
// currently holds; an idle unit's `visionIndex` is its occupied cell.
visibility.increase(map, scratch, map.coordinatesAt(unit.visionIndex), tuning_.visionRange)
.ignore("unit cells are validated when a unit is added or restored");
}
}
void HexUnitRegistry::refreshPositions(const HexMap& map) noexcept {
// Positions are derived from the map on demand, so an idle unit picks up a new
// elevation or perturbation without any write here. What this pass does check
// is that the cached cell still exists: a unit outside the grid is taken off
// the grid so `unitIdAt` stops reporting it.
const std::int32_t cellCount = map.cellCount();
for (Unit& unit : units_) {
if (unit.locationIndex >= 0 && unit.locationIndex < cellCount) continue;
unit.locationIndex = -1;
unit.location = HexCoordinates{};
unit.visionIndex = -1;
unit.traveling = false;
unit.segment = 1;
unit.t = 0.f;
unit.path.clear();
}
}
std::vector<HexUnitState> HexUnitRegistry::snapshot() const {
std::vector<HexUnitState> states;
states.reserve(units_.size());
for (const Unit& unit : units_) {
HexUnitState state;
state.locationIndex = unit.locationIndex;
state.orientation = unit.orientation;
states.push_back(state);
}
return states;
}
Result<void> HexUnitRegistry::restore(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
const std::vector<HexUnitState>& states) {
// The registry is cleared before validation, which is why a rejected payload
// leaves it empty instead of partially populated: there is no half-restored
// registry any caller could observe. The map's explored latches stay latched
// because those are one-way by design.
units_.clear();
visibility.clear();
const std::int32_t cellCount = map.cellCount();
std::vector<std::int32_t> claimed;
claimed.reserve(states.size());
for (const HexUnitState& state : states) {
if (state.locationIndex < 0 || state.locationIndex >= cellCount)
return Result<void>::failure(invalidArgument("restored unit cell is outside the hex map"));
if (std::find(claimed.begin(), claimed.end(), state.locationIndex) != claimed.end())
return Result<void>::failure(invalidArgument("restored units occupy the same cell"));
if (!isValidDestination(map, map.coordinatesAt(state.locationIndex), {}))
return Result<void>::failure(invalidArgument("restored unit cell is not a valid destination"));
claimed.push_back(state.locationIndex);
}
units_.reserve(states.size());
for (const HexUnitState& state : states) {
Unit unit;
unit.locationIndex = state.locationIndex;
unit.location = map.coordinatesAt(state.locationIndex);
unit.orientation = state.orientation;
unit.visionIndex = state.locationIndex;
units_.push_back(std::move(unit));
}
refreshVisibility(map, visibility, scratch);
return Result<void>::success();
}
} // namespace eve::hexmap
+234
View File
@@ -0,0 +1,234 @@
#pragma once
/** @file HexUnits.h @brief Units that occupy cells, travel along paths and carry vision. */
#include "common/Result.h"
#include "hexmap/HexSearch.h"
#include "hexmap/HexVisibility.h"
#include <cstdint>
#include <vector>
namespace eve::hexmap {
/** @brief Per-unit tuning shared by every unit of a registry. */
struct HexUnitTuning {
/** @brief Movement points per turn, matching `HexMoveRules::speed`. */
std::int32_t speed = 24;
/** @brief Vision radius in cells, matching `HexMoveRules::visionRange`. */
std::int32_t visionRange = 3;
/** @brief Travel speed in path segments per second. */
float travelSpeed = 4.f;
/** @brief Turn rate in degrees per second while travelling. */
float rotationSpeed = 180.f;
/** @brief The pathfinding rules implied by this tuning. */
[[nodiscard]] HexMoveRules moveRules() const noexcept { return HexMoveRules{speed, visionRange}; }
};
/** @brief Serializable state of one unit. */
struct HexUnitState {
/** @brief Linear index of the occupied cell, or -1 when the unit is not on the grid. */
std::int32_t locationIndex = -1;
/** @brief Facing angle in degrees. */
float orientation = 0.f;
};
/** @brief Snapshot of one unit used to drive rendering. */
struct HexUnitSample {
/** @brief Stable unit id. */
std::int32_t id = -1;
/** @brief Cell the unit occupies; for a travelling unit this is its destination. */
HexCoordinates location{};
/** @brief Cell the unit is currently walking away from, or `(0, 0)` when idle. */
HexCoordinates travelFrom{};
/** @brief Interpolated world position. */
HexVec3 position{};
/** @brief Interpolated facing angle in degrees. */
float orientation = 0.f;
/** @brief Whether the unit is still moving along its path. */
bool traveling = false;
};
/**
* @brief Owns the units of one map and their fog-of-war contribution.
*
* A unit reserves its **destination** cell as soon as travel starts, exactly like
* the reference project: the destination is what other actors path around, and
* the travelled-through cells are never occupied. Vision moves with the unit as
* it enters each cell of the path, so the revealed corridor follows the walk.
*
* Ownership and lifetime: owns one record per unit and one travel plan per
* travelling unit. `occupancyQuery()` returns a `std::function` that borrows
* `*this`; it stays valid only while this registry is alive and unmodified.
*
* Thread affinity: main thread only; no locking and no callbacks.
*/
class HexUnitRegistry {
public:
HexUnitRegistry() = default;
/** @brief Current tuning. */
[[nodiscard]] const HexUnitTuning& tuning() const noexcept { return tuning_; }
/** @brief Replaces the tuning; ongoing travel keeps its plan but uses the new speed. */
void setTuning(const HexUnitTuning& tuning) noexcept { tuning_ = tuning; }
/** @brief Whether no unit is registered. */
[[nodiscard]] bool empty() const noexcept { return units_.empty(); }
/** @brief Number of registered units. */
[[nodiscard]] std::int32_t unitCount() const noexcept { return static_cast<std::int32_t>(units_.size()); }
/**
* @brief Adds a unit on `location` and grants its vision.
*
* @param map Map the unit is placed on.
* @param visibility Fog-of-war state to update.
* @param scratch Search scratch, resized to `map.cellCount()`.
* @param location Cell to occupy; must be inside the grid and free.
* @param orientation Initial facing angle in degrees.
* @return The new unit id, or InvalidArgument when the cell is outside the
* grid or already occupied.
* @cost One visibility sweep over the unit's vision radius.
*/
[[nodiscard]] Result<std::int32_t> addUnit(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
HexCoordinates location, float orientation);
/**
* @brief Removes a unit and withdraws its vision.
* @return Success, or NotFound for an unknown id.
*/
[[nodiscard]] Result<void> removeUnit(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
std::int32_t unitId);
/** @brief Removes every unit and clears the visibility counters of the map. */
void removeAll(HexVisibility& visibility) noexcept;
/** @brief Id of the unit occupying a cell, or -1 when the cell is free. */
[[nodiscard]] std::int32_t unitIdAt(HexCoordinates coordinates) const noexcept;
/** @brief Whether any unit occupies a cell. */
[[nodiscard]] bool isOccupied(HexCoordinates coordinates) const noexcept {
return unitIdAt(coordinates) >= 0;
}
/**
* @brief Occupancy predicate bound to this registry.
* @ownership The returned callable borrows `*this`; it must not outlive the registry.
*/
[[nodiscard]] HexOccupancyQuery occupancyQuery() const;
/**
* @brief Starts travelling along `path`.
*
* `path` holds linear cell indices from the unit's current cell to the
* destination, inclusive, as produced by `findPath`. The destination is
* reserved immediately; visibility moves to the first step and then follows
* the walk, so the fog is revealed along the corridor the unit walks.
*
* @param map Map the unit lives on.
* @param visibility Fog-of-war state to update.
* @param scratch Search scratch.
* @param unitId Unit to move.
* @param path Cell indices to walk.
* @return Success, or InvalidArgument for an unknown id, a path that does not
* start on the unit's cell, a non-adjacent step, or an out-of-range index.
* @cost One visibility sweep when the unit leaves its cell and one when it enters.
*/
[[nodiscard]] Result<void> beginTravel(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
std::int32_t unitId, const std::vector<std::int32_t>& path);
/**
* @brief Advances one unit's travel by `dt` seconds and reports where it now is.
*
* This is also the single way to read a unit's current pose: `dt <= 0` performs
* no motion and no turning, so a caller that only wants to draw an idle unit
* calls this with a zero delta and uses the returned sample. The sample's
* position is elevation- and perturbation-aware and interpolated along the
* travel curve, which is what a renderable needs.
*
* @param map Map the unit lives on.
* @param visibility Fog-of-war state to update as the unit crosses cells.
* @param scratch Search scratch.
* @param unitId Unit to advance.
* @param dt Elapsed seconds; values `<= 0` leave both position and facing alone.
* @return The updated sample, or NotFound for an unknown id.
* @cost One visibility sweep per cell crossed.
*/
[[nodiscard]] Result<HexUnitSample> advance(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
std::int32_t unitId, float dt);
/** @brief Advances every travelling unit by `dt` seconds. */
void advanceAll(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch, float dt);
/**
* @brief Re-derives the whole fog of war from the units' current cells.
*
* Use after loading a map, after the terrain changed, or after a bulk edit
* invalidated the counters.
*
* @cost One visibility sweep per unit.
*/
void refreshVisibility(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch);
/** @brief Re-snaps every idle unit onto its cell's current position. */
void refreshPositions(const HexMap& map) noexcept;
/** @brief Serializable state of every unit, in id order. */
[[nodiscard]] std::vector<HexUnitState> snapshot() const;
/**
* @brief Replaces every unit with `states`, granting their vision.
*
* The registry is cleared first; a failure leaves it empty rather than
* partially populated.
*
* @return Success, or InvalidArgument for an out-of-range or duplicated cell.
*/
[[nodiscard]] Result<void> restore(HexMap& map, HexVisibility& visibility, HexSearchContext& scratch,
const std::vector<HexUnitState>& states);
private:
struct Unit {
std::int32_t locationIndex = -1;
float orientation = 0.f;
bool traveling = false;
/** @brief Destination-reserving path; `path[0]` is the cell the unit left. */
std::vector<std::int32_t> path;
/** @brief Index of the next path entry being walked towards. */
std::int32_t segment = 1;
/** @brief Progress inside the current segment in `[0, 1)`. */
float t = 0.f;
/** @brief Cell currently granting the unit's vision. */
std::int32_t visionIndex = -1;
/**
* @brief Cell the unit occupies, cached as coordinates.
*
* `locationIndex` is the same cell in linear form; the coordinates are
* cached because `unitIdAt` and `isOccupied` have no map to resolve an
* index against. While a unit travels this is its reserved destination.
*/
HexCoordinates location{};
};
/**
* @brief Borrowed record of one unit.
* @param unitId Unit id (registry position).
* @return The record.
* @ownership Borrowed; this registry owns the record.
* @lifetime Valid until the next unit is added, removed or restored.
* @nullable Yes, for an unknown id.
*/
[[nodiscard]] Unit* find(std::int32_t unitId) noexcept;
/**
* @brief Borrowed const record of one unit.
* @ownership Borrowed; this registry owns the record.
* @lifetime Valid until the next unit is added, removed or restored.
* @nullable Yes, for an unknown id.
*/
[[nodiscard]] const Unit* find(std::int32_t unitId) const noexcept;
[[nodiscard]] Result<HexUnitSample> makeSample(const HexMap& map, std::int32_t unitId, const Unit& unit) const;
HexUnitTuning tuning_{};
std::vector<Unit> units_;
};
} // namespace eve::hexmap
+113
View File
@@ -0,0 +1,113 @@
/**
* @file HexVisibility.cpp
* @brief Reference-counted cell visibility and the explored latch it drives.
*/
#include "hexmap/HexVisibility.h"
#include "common/Diagnostic.h"
#include <algorithm>
#include <string>
#include <utility>
#include <vector>
namespace eve::hexmap {
namespace {
[[nodiscard]] Result<void> visibilityInvalidArgument(std::string message) {
return Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), "hexmap"));
}
/**
* @brief Checks the two arguments `increase`/`decrease` share.
* @return Success, or the InvalidArgument failure both entry points raise.
*/
[[nodiscard]] Result<void> validateView(const HexMap& map, const HexVisibility& visibility, HexCoordinates from) {
if (!map.contains(from)) return visibilityInvalidArgument("view origin is outside the hex map");
if (visibility.cellCount() != map.cellCount())
return visibilityInvalidArgument("visibility counters must be reset to map.cellCount() before a view update");
return Result<void>::success();
}
} // namespace
// --- state ------------------------------------------------------------------
void HexVisibility::reset(std::int32_t cellCount) {
counts_.assign(cellCount > 0 ? static_cast<std::size_t>(cellCount) : 0u, 0);
touched_.clear();
}
bool HexVisibility::isVisible(std::int32_t cellIndex) const noexcept {
if (cellIndex < 0 || cellIndex >= cellCount()) return false;
return counts_[static_cast<std::size_t>(cellIndex)] > 0;
}
std::int32_t HexVisibility::visibility(std::int32_t cellIndex) const noexcept {
if (cellIndex < 0 || cellIndex >= cellCount()) return 0;
return counts_[static_cast<std::size_t>(cellIndex)];
}
std::int32_t HexVisibility::visibleCellCount() const noexcept { return static_cast<std::int32_t>(touched_.size()); }
// --- viewers ----------------------------------------------------------------
Result<void> HexVisibility::increase(HexMap& map, HexSearchContext& scratch, HexCoordinates from,
std::int32_t range) {
Result<void> valid = validateView(map, *this, from);
if (!valid.ok()) return valid;
std::vector<std::int32_t> cells;
Result<void> collected = collectVisibleCells(map, scratch, from, range, cells);
if (!collected.ok()) return collected;
for (const std::int32_t index : cells) {
// `collectVisibleCells` only reports indices of this map, so the counter is in range.
if (++counts_[static_cast<std::size_t>(index)] != 1) continue;
// First viewer of this cell: latch it explored and remember the counter so
// `clear` can zero the map without a full scan.
touched_.push_back(index);
map.setExplored(map.coordinatesAt(index), true)
.ignore("the cell index comes from this map, so the explored latch cannot be rejected");
// The fog overlay of this cell changed shade, so its chunk must be rebuilt.
map.markChunkDirtyAndNeighbors(map.chunkIndexOf(map.coordinatesAt(index)));
}
return Result<void>::success();
}
Result<void> HexVisibility::decrease(HexMap& map, HexSearchContext& scratch, HexCoordinates from,
std::int32_t range) {
Result<void> valid = validateView(map, *this, from);
if (!valid.ok()) return valid;
std::vector<std::int32_t> cells;
Result<void> collected = collectVisibleCells(map, scratch, from, range, cells);
if (!collected.ok()) return collected;
for (const std::int32_t index : cells) {
std::int32_t& counter = counts_[static_cast<std::size_t>(index)];
if (counter == 0) continue;
if (--counter != 0) continue;
// The cell lost its last viewer. `touched_` order is unspecified, so a
// swap-and-pop removal is fine.
const auto entry = std::find(touched_.begin(), touched_.end(), index);
if (entry != touched_.end()) {
*entry = touched_.back();
touched_.pop_back();
}
// The cell became invisible again: its fog overlay shade changed.
map.markChunkDirtyAndNeighbors(map.chunkIndexOf(map.coordinatesAt(index)));
}
return Result<void>::success();
}
void HexVisibility::clear() noexcept {
for (const std::int32_t index : touched_) {
counts_[static_cast<std::size_t>(index)] = 0;
}
// The map's explored flags are a one-way latch and stay untouched.
touched_.clear();
}
} // namespace eve::hexmap
+93
View File
@@ -0,0 +1,93 @@
#pragma once
/** @file HexVisibility.h @brief Reference-counted cell visibility and explored state. */
#include "common/Result.h"
#include "hexmap/HexSearch.h"
#include <cstdint>
#include <vector>
namespace eve::hexmap {
/**
* @brief Fog-of-war bookkeeping: how many viewers currently see each cell.
*
* Visibility is a *counter*, not a flag, because several actors overlap: a cell
* stops being visible only when the last viewer that covered it stops looking.
* The first time a cell becomes visible it is also marked *explored*, which is a
* one-way latch: explored cells stay explored after the viewer leaves and only
* the visibility counter drops.
*
* Invariant: `counts_[i] == 0` for every cell that no viewer covers, and
* `counts_[i] > 0` exactly for the cells `isVisible(i)` reports.
*
* Ownership and lifetime: owns one counter per cell; `reset` re-sizes it and
* `clear` zeroes it. It does not own the map it annotates.
*
* Thread affinity: main thread only; no locking.
*/
class HexVisibility {
public:
HexVisibility() = default;
/**
* @brief Sizes the counter array for `cellCount` cells and zeroes it.
* @param cellCount Number of cells of the annotated map.
* @note This does not clear the map's explored flags; callers that rebuild a
* map get a fresh map whose flags are already clear.
*/
void reset(std::int32_t cellCount);
/** @brief Number of tracked cells. */
[[nodiscard]] std::int32_t cellCount() const noexcept { return static_cast<std::int32_t>(counts_.size()); }
/** @brief Whether any viewer currently sees the cell. */
[[nodiscard]] bool isVisible(std::int32_t cellIndex) const noexcept;
/** @brief Number of viewers currently covering the cell. */
[[nodiscard]] std::int32_t visibility(std::int32_t cellIndex) const noexcept;
/** @brief Number of cells with at least one viewer. */
[[nodiscard]] std::int32_t visibleCellCount() const noexcept;
/**
* @brief Adds one viewer at `from`.
*
* @param map Map whose explored flags are latched; must contain `from`.
* @param scratch Search scratch, resized to `map.cellCount()`.
* @param from Viewing cell.
* @param range Vision radius in cells.
* @return Success, or InvalidArgument when `from` is outside the grid.
* @cost Proportional to the visible region.
*/
[[nodiscard]] Result<void> increase(HexMap& map, HexSearchContext& scratch, HexCoordinates from,
std::int32_t range);
/**
* @brief Removes one viewer at `from`.
*
* Counters are clamped at zero, so a mismatched decrease cannot make a cell
* permanently invisible by going negative.
*
* @param map Map being annotated.
* @param scratch Search scratch, resized to `map.cellCount()`.
* @param from Viewing cell.
* @param range Vision radius in cells.
* @return Success, or InvalidArgument when `from` is outside the grid.
* @cost Proportional to the visible region.
*/
[[nodiscard]] Result<void> decrease(HexMap& map, HexSearchContext& scratch, HexCoordinates from,
std::int32_t range);
/**
* @brief Drops every viewer, leaving the map's explored flags untouched.
* @cost Proportional to the number of cell counters, not to the visible region.
*/
void clear() noexcept;
private:
std::vector<std::int32_t> counts_;
/** @brief Linear indices with a non-zero counter, used by `clear` to avoid a full scan. */
std::vector<std::int32_t> touched_;
};
} // namespace eve::hexmap
+229
View File
@@ -0,0 +1,229 @@
#include "hexmap/HexMapMesh.h"
#include "hexmap/HexMeshData.h"
#include "hexmap/HexMetrics.h"
#include "hexmap/HexNoise.h"
#include <cstdint>
namespace eve::hexmap {
namespace {
/** @brief One pending surface vertex: a world position plus its texture coordinates. */
struct SurfaceVertex {
HexVec3 position;
float u = 0.f;
float v = 0.f;
};
/**
* @brief Whether three positions span no area, so the face would be invisible.
*
* The test reuses the right-handed cross product `HexMeshData::finalize()` derives
* its face normals from.
*/
[[nodiscard]] bool isDegenerate(HexVec3 a, HexVec3 b, HexVec3 c) noexcept {
const HexVec3 ab = b - a;
const HexVec3 ac = c - a;
const HexVec3 normal{ab.y * ac.z - ab.z * ac.y, ab.z * ac.x - ab.x * ac.z, ab.x * ac.y - ab.y * ac.x};
return (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z) <= 1e-8f;
}
/**
* @brief Appends one triangle, perturbing every position exactly once.
*
* `a -> b -> c` must resolve to a `+Y` normal, otherwise the face is culled. The
* argument order is the reference project's, so a ported `AddTriangle(a, b, c)`
* becomes `emitTriangle(out, a, b, c)` unchanged.
*
* @param out Destination mesh.
* @param noise Map noise, applied here so no call site can displace a vertex twice.
*/
void emitTriangle(HexMeshData& out, const SurfaceVertex& a, const SurfaceVertex& b, const SurfaceVertex& c,
const HexNoise& noise) {
const HexVec3 pa = noise.perturb(a.position);
const HexVec3 pb = noise.perturb(b.position);
const HexVec3 pc = noise.perturb(c.position);
if (isDegenerate(pa, pb, pc)) return;
out.addTriangle(out.addVertex(pa, a.u, a.v), out.addVertex(pb, b.u, b.v), out.addVertex(pc, c.u, c.v));
}
/**
* @brief Appends the quad whose four corners run `r0 -> r1 -> r2 -> r3` around its outline.
*
* `HexMeshData::addQuad(a, b, c, d)` splits the ring `a -> b -> d -> c` along `b -> c`,
* so the corners are re-ordered here and every call site keeps readable outline order.
* The reference project's `AddQuad(a, b, c, d)` is therefore
* `emitQuad(out, a, b, d, c, noise)`.
*/
void emitQuad(HexMeshData& out, const SurfaceVertex& r0, const SurfaceVertex& r1, const SurfaceVertex& r2,
const SurfaceVertex& r3, const HexNoise& noise) {
emitTriangle(out, r0, r3, r1, noise);
emitTriangle(out, r1, r3, r2, noise);
}
/** @brief The water surface height of a cell, or `offGridHeight` when it is off-grid. */
[[nodiscard]] float waterHeightOf(const HexMap& map, HexCoordinates coordinates, float offGridHeight) noexcept {
return map.contains(coordinates) ? HexMetrics::waterSurfaceY(map.waterLevel(coordinates)) : offGridHeight;
}
/**
* @brief Whether a cell exists and is flooded.
*
* Off-grid neighbours are *not* underwater: the reference routes them through the
* open-water branch so the map border still gets a water triangle.
*/
[[nodiscard]] bool isWater(const HexMap& map, HexCoordinates coordinates) noexcept {
return map.contains(coordinates) && map.isUnderwater(coordinates);
}
/**
* @brief Emits the open-water wedge and, where the neighbour is water, its bridge.
*
* This is the reference project's `TriangulateOpenWater`: **one** centre triangle for
* this direction (not a whole-cell fan), a bridge quad towards a water neighbour that
* the owning chunk owns, and the corner triangle that closes the third cell of the
* junction.
*
* @param map Source map.
* @param coordinates Cell the wedge belongs to.
* @param direction Direction of the wedge.
* @param centre Water-surface centre of `coordinates`.
* @param noise Map noise.
* @param out Destination mesh.
*/
void appendOpenWater(const HexMap& map, HexCoordinates coordinates, HexDirection direction, HexVec3 centre,
const HexNoise& noise, HexMeshData& out) {
const SurfaceVertex c1{centre + HexMetrics::firstWaterCorner(direction), 0.f, 0.f};
const SurfaceVertex c2{centre + HexMetrics::secondWaterCorner(direction), 0.f, 0.f};
emitTriangle(out, SurfaceVertex{centre, 0.f, 0.f}, c1, c2, noise);
HexCoordinates neighbour{};
if (static_cast<std::int32_t>(direction) > static_cast<std::int32_t>(HexDirection::SE) ||
!map.getNeighbor(coordinates, direction, neighbour)) {
// Only the NE, E and SE edges bridge outward: for a water/water boundary the
// other side would emit the same quad, so exactly one chunk owns it. An
// off-grid neighbour has nothing to bridge to.
return;
}
const HexVec3 offset = HexMetrics::waterBridge(direction);
const float farY = waterHeightOf(map, neighbour, centre.y);
const SurfaceVertex e1{{c1.position.x + offset.x, farY, c1.position.z + offset.z}, 0.f, 0.f};
const SurfaceVertex e2{{c2.position.x + offset.x, farY, c2.position.z + offset.z}, 0.f, 0.f};
emitQuad(out, c1, c2, e2, e1, noise);
if (static_cast<std::int32_t>(direction) > static_cast<std::int32_t>(HexDirection::E)) return;
HexCoordinates nextNeighbour{};
if (!map.getNeighbor(coordinates, next(direction), nextNeighbour) || !isWater(map, nextNeighbour)) return;
// The third cell of the junction is water too, so its own wedge stops at the
// water corner and this triangle fills the gap between the two bridges.
HexVec3 corner = map.cellGroundPosition(nextNeighbour) + HexMetrics::firstWaterCorner(previous(direction));
corner.y = farY;
emitTriangle(out, c2, e2, SurfaceVertex{corner, 0.f, 0.f}, noise);
}
/**
* @brief Emits the shoreline wedge and the bank strip of one direction.
*
* This is the reference project's `TriangulateWaterShore`. Two details matter for the
* result:
*
* - the wedge is **subdivided into four triangles** along a five-sample water edge, so
* the water edge tessellates like the bank it meets; and
* - the strip is **flat at the water height** - the far edge only carries the
* neighbour's solid corner *positions*, never the neighbour's elevation. A strip
* that tilts up to the bank instead reads as a bright band tracing every hexagon
* edge, which is exactly the seam this port exists to avoid. The bank terrain
* simply rises through the flat water, and the far edge's foam parameter marks where.
*
* @param map Source map.
* @param coordinates Flooded cell the strip belongs to.
* @param direction Direction towards the land neighbour.
* @param centre Water-surface centre of `coordinates`.
* @param noise Map noise.
* @param out Destination mesh.
*/
void appendWaterShore(const HexMap& map, HexCoordinates coordinates, HexDirection direction, HexVec3 centre,
const HexNoise& noise, HexMeshData& out) {
const EdgeVertices near{centre + HexMetrics::firstWaterCorner(direction),
centre + HexMetrics::secondWaterCorner(direction)};
const SurfaceVertex nearSamples[5] = {SurfaceVertex{near.v1, 0.f, 0.f}, SurfaceVertex{near.v2, 0.f, 0.f},
SurfaceVertex{near.v3, 0.f, 0.f}, SurfaceVertex{near.v4, 0.f, 0.f},
SurfaceVertex{near.v5, 0.f, 0.f}};
const SurfaceVertex wedgeCentre{centre, 0.f, 0.f};
for (std::int32_t i = 0; i < 4; ++i) {
emitTriangle(out, wedgeCentre, nearSamples[i], nearSamples[i + 1], noise);
}
const HexCoordinates neighbour = coordinates.step(direction);
const HexVec3 neighbourGround = map.cellGroundPosition(neighbour);
const HexVec3 bank{neighbourGround.x, centre.y, neighbourGround.z};
// Second corner first, so sample `i` of the far edge lines up with sample `i` of
// the near edge across the strip - the reference orders them the same way.
const EdgeVertices far{bank + HexMetrics::secondSolidCorner(opposite(direction)),
bank + HexMetrics::firstSolidCorner(opposite(direction))};
const SurfaceVertex farSamples[5] = {SurfaceVertex{far.v1, 1.f, 0.f}, SurfaceVertex{far.v2, 1.f, 0.f},
SurfaceVertex{far.v3, 1.f, 0.f}, SurfaceVertex{far.v4, 1.f, 0.f},
SurfaceVertex{far.v5, 1.f, 0.f}};
for (std::int32_t i = 0; i < 4; ++i) {
emitQuad(out, nearSamples[i], nearSamples[i + 1], farSamples[i + 1], farSamples[i], noise);
}
// Close the junction with the next cell around: its water edge when it is flooded,
// its bank corner when it is land. Either way the vertex stays at the water height.
HexCoordinates nextNeighbour{};
if (!map.getNeighbor(coordinates, next(direction), nextNeighbour)) return;
const HexVec3 corner =
isWater(map, nextNeighbour)
? map.cellGroundPosition(nextNeighbour) + HexMetrics::firstWaterCorner(previous(direction))
: map.cellGroundPosition(nextNeighbour) + HexMetrics::firstSolidCorner(previous(direction));
HexVec3 capped = corner;
capped.y = centre.y;
emitTriangle(out, nearSamples[4], farSamples[4], SurfaceVertex{capped, 0.f, 0.f}, noise);
}
} // namespace
void buildWaterMesh(const HexMap& map, std::int32_t chunkIndex, HexMeshData& out) {
out.clear();
if (chunkIndex < 0 || chunkIndex >= map.chunkCount()) {
out.finalize();
return;
}
const HexNoise& noise = map.noise();
for (std::int32_t row = 0; row < HexMetrics::kChunkSizeZ; ++row) {
for (std::int32_t column = 0; column < HexMetrics::kChunkSizeX; ++column) {
const HexCoordinates coordinates = map.chunkCell(chunkIndex, column, row);
if (map.cell(coordinates) == nullptr || !map.isUnderwater(coordinates)) continue;
const HexVec3 ground = map.cellGroundPosition(coordinates);
const HexVec3 centre{ground.x, HexMetrics::waterSurfaceY(map.waterLevel(coordinates)), ground.z};
// Dispatch per direction, exactly like the reference: a flooded direction
// gets the open-water wedge, a land direction gets the shore wedge and its
// strip. Emitting a full-cell fan up front instead would double the centre
// triangles of every shoreline direction and leave the strip to fight it.
for (std::int32_t i = 0; i < kHexDirectionCount; ++i) {
const auto direction = static_cast<HexDirection>(i);
HexCoordinates neighbour{};
const bool hasNeighbour = map.getNeighbor(coordinates, direction, neighbour);
const bool neighbourWater = hasNeighbour && isWater(map, neighbour);
if (hasNeighbour && !neighbourWater) {
appendWaterShore(map, coordinates, direction, centre, noise, out);
} else {
appendOpenWater(map, coordinates, direction, centre, noise, out);
}
}
}
}
out.finalize();
}
} // namespace eve::hexmap
+1081
View File
File diff suppressed because it is too large. Load diff