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:
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 映射。
|
||||
@@ -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、顶点编辑器 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user