rpfm_extensions/startpos/coords.rs
1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Coordinate systems of the campaign map.
12//!
13//! The campaign map uses three related coordinate systems:
14//!
15//! - **World**: the `f32` positions stored in startpos `Coord2d` nodes.
16//! - **Raw**: the `u16` units stored in the map files (`trade_routes.ptd`). `raw = world * scale + 4`.
17//! - **Cell**: the logical hex grid of the map (`HEX_MAP_DATA`, 1016x720 cells in Attila's main map). A cell is
18//! 6 raw units wide and 8 raw units high, and odd columns are shifted half a cell down.
19//!
20//! The scales of a map ([`MapScale`]) come from its hex grid and its size in world units: the last column starts
21//! at the map's right edge, and the last row of odd columns ends at its bottom edge. Characters placed on cells land
22//! exactly where the current game build (the one BOB uses) puts them: 518 of 518 in Attila's main campaign. CA's
23//! older build of the shipped startposes rounds the horizontal position differently, one ulp away at most.
24
25use getset::CopyGetters;
26use serde_derive::{Deserialize, Serialize};
27
28#[cfg(test)] #[path = "coords_test.rs"] pub(crate) mod coords_test;
29
30/// Offset added to raw coordinates.
31pub const RAW_OFFSET: f32 = 4.0;
32
33/// Raw units per cell, horizontally.
34pub const CELL_RAW_WIDTH: f32 = 6.0;
35
36/// Raw units per cell, vertically.
37pub const CELL_RAW_HEIGHT: f32 = 8.0;
38
39/// Neighbour offsets of a cell in an odd column, in the order the game uses (south first, then clockwise).
40const ODD_COLUMN_NEIGHBOURS: [(i32, i32); 6] = [(0, 1), (1, 1), (1, 0), (0, -1), (-1, 0), (-1, 1)];
41
42/// Neighbour offsets of a cell in an even column, in the order the game uses (south first, then clockwise).
43const EVEN_COLUMN_NEIGHBOURS: [(i32, i32); 6] = [(0, 1), (1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0)];
44
45/// Edges of a hex cell.
46pub const HEX_EDGES: u8 = 6;
47
48/// Edge mask with every edge set.
49const ALL_EDGES: u8 = 0b11_1111;
50
51/// Edge mask of the first two edges.
52const FIRST_TWO_EDGES: u8 = 0b11;
53
54//---------------------------------------------------------------------------//
55// Types
56//---------------------------------------------------------------------------//
57
58/// A cell of the logical hex grid of the campaign map.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, CopyGetters, Serialize, Deserialize)]
60#[getset(get_copy = "pub")]
61pub struct HexCell {
62
63 /// Column.
64 x: u16,
65
66 /// Row.
67 y: u16,
68}
69
70/// World units per raw unit of a campaign map, horizontally and vertically.
71#[derive(Debug, Clone, Copy, PartialEq, CopyGetters)]
72#[getset(get_copy = "pub")]
73pub struct MapScale {
74
75 /// World units per raw unit, horizontally.
76 x: f32,
77
78 /// World units per raw unit, vertically.
79 y: f32,
80
81 /// World units per cell, horizontally.
82 cell_width: f32,
83}
84
85/// A piece covering some edges of a hex cell (a road or a coast), as a shape and a rotation.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, CopyGetters)]
87#[getset(get_copy = "pub")]
88pub struct EdgePiece {
89
90 /// Edge mask of the piece before rotating it.
91 shape: u8,
92
93 /// Edges the shape is rotated by, in the game's direction order.
94 rotation: u8,
95}
96
97//---------------------------------------------------------------------------//
98// Implementations
99//---------------------------------------------------------------------------//
100
101impl HexCell {
102
103 /// Creates a cell from its column and row.
104 pub fn new(x: u16, y: u16) -> Self {
105 Self { x, y }
106 }
107
108 /// Returns the existing neighbours of the cell, in the game's direction order.
109 ///
110 /// # Returns
111 ///
112 /// Up to six cells; neighbours that would fall outside the `u16` range are skipped.
113 pub fn neighbours(&self) -> impl Iterator<Item = HexCell> + '_ {
114 (0..HEX_EDGES).filter_map(|direction| self.neighbour(direction))
115 }
116
117 /// Returns the neighbour in a direction (0 to 5, in the game's direction order).
118 ///
119 /// # Returns
120 ///
121 /// `None` if the neighbour would fall outside the `u16` range, or the direction is over 5.
122 pub fn neighbour(&self, direction: u8) -> Option<HexCell> {
123 let offsets = if self.x % 2 == 1 { &ODD_COLUMN_NEIGHBOURS } else { &EVEN_COLUMN_NEIGHBOURS };
124 let (dx, dy) = offsets.get(usize::from(direction))?;
125 let x = u16::try_from(i32::from(self.x) + dx).ok()?;
126 let y = u16::try_from(i32::from(self.y) + dy).ok()?;
127 Some(HexCell::new(x, y))
128 }
129
130 /// Returns the edge mask of the neighbours matching a predicate: bit `n` stands for the `n`-th neighbour in the
131 /// game's direction order. Neighbours outside the `u16` range never match.
132 pub fn neighbour_mask(&self, mut predicate: impl FnMut(HexCell) -> bool) -> u8 {
133 (0..HEX_EDGES)
134 .filter(|&direction| self.neighbour(direction).is_some_and(&mut predicate))
135 .fold(0, |mask, direction| mask | 1 << direction)
136 }
137
138 /// Returns the cell followed by its neighbours, in the order the game stores slot footprints.
139 pub fn footprint(&self) -> Vec<HexCell> {
140 std::iter::once(*self).chain(self.neighbours()).collect()
141 }
142}
143
144impl MapScale {
145
146 /// Returns the scale of a map.
147 ///
148 /// # Arguments
149 ///
150 /// * `cells` - Columns and rows of the map's hex grid.
151 /// * `world_size` - Size of the map in world units.
152 pub fn new(cells: (u32, u32), world_size: (f32, f32)) -> Self {
153 let width = f64::from(cells.0.saturating_sub(1)) * f64::from(CELL_RAW_WIDTH);
154 let height = f64::from(cells.1) * f64::from(CELL_RAW_HEIGHT) + f64::from(CELL_RAW_HEIGHT / 2.0);
155 let raw_per_world = (width / f64::from(world_size.0)) as f32;
156 Self {
157 x: (f64::from(world_size.0) / width) as f32,
158 y: (f64::from(world_size.1) / height) as f32,
159 cell_width: CELL_RAW_WIDTH / raw_per_world,
160 }
161 }
162
163 /// Returns the cell containing a world position, as the game finds it.
164 ///
165 /// Odd columns are shifted half a cell down, so their rows start half a cell height later. Cells are hexagons
166 /// wider than their column: the first third of a column holds the slanted edges shared with the previous column,
167 /// where positions above or below the edge belong to the previous column's cells.
168 pub fn cell_at(&self, x: f32, y: f32) -> HexCell {
169 let (raw_x, raw_y) = self.world_to_raw(x, y);
170 let columns = raw_x / CELL_RAW_WIDTH;
171 let mut column = columns.floor() as i32;
172 let row_offset = if column % 2 == 1 { CELL_RAW_HEIGHT / 2.0 } else { 0.0 };
173 let rows = (raw_y - row_offset) / CELL_RAW_HEIGHT;
174 let mut row = rows.floor() as i32;
175
176 // Distance into the column in thirds, and height from the middle of the row in rows.
177 let into_column = (columns - column as f32) * 3.0;
178 let from_middle = rows - row as f32 - 0.5;
179 if into_column < 1.0 {
180 let odd = column & 1;
181 if into_column <= from_middle * 2.0 {
182 (column, row) = (column - 1, row + odd);
183 } else if into_column <= from_middle * -2.0 {
184 (column, row) = (column - 1, row + odd - 1);
185 }
186 }
187 HexCell::new(column.max(0) as u16, row.max(0) as u16)
188 }
189
190 /// Returns the world position of a cell's top-left corner, where the game places characters given by cell.
191 ///
192 /// The game multiplies in double precision by single precision scales, and adds the half-cell shift of odd
193 /// columns as a separate single precision term, so the position is computed the same way.
194 pub fn world_position(&self, cell: HexCell) -> (f32, f32) {
195 let x = (f64::from(cell.x) * f64::from(self.cell_width)) as f32;
196 let y = (f64::from(cell.y) * f64::from(CELL_RAW_HEIGHT) * f64::from(self.y)) as f32;
197 let row_offset = if cell.x % 2 == 1 { (f64::from(CELL_RAW_HEIGHT / 2.0) * f64::from(self.y)) as f32 } else { 0.0 };
198 (x, y + row_offset)
199 }
200
201 /// Converts a world position to raw map units.
202 pub fn world_to_raw(&self, x: f32, y: f32) -> (f32, f32) {
203 (x / self.x + RAW_OFFSET, y / self.y + RAW_OFFSET)
204 }
205
206 /// Converts raw map units to a world position.
207 pub fn raw_to_world(&self, x: f32, y: f32) -> (f32, f32) {
208 ((x - RAW_OFFSET) * self.x, (y - RAW_OFFSET) * self.y)
209 }
210
211 /// Converts a raw offset (a vector, no translation) to world units.
212 ///
213 /// Map files store offsets at 100 times the raw scale.
214 pub fn raw_offset_to_world(&self, x: i16, y: i16) -> (f32, f32) {
215 (f32::from(x) * self.x / 100.0, f32::from(y) * self.y / 100.0)
216 }
217}
218
219impl EdgePiece {
220
221 /// Splits an edge mask into a piece shape and a rotation.
222 ///
223 /// The shape is the smallest rotation of the mask with its first two edges set, or the smallest rotation if
224 /// none has them (so a straight road is `0b1001` and a bend is `0b11`). The rotation is the smallest one turning
225 /// the shape into the mask.
226 pub fn from_mask(mask: u8) -> Self {
227 let mask = mask & ALL_EDGES;
228 let rotations = (0..HEX_EDGES).map(|rotation| rotate_edges(mask, rotation));
229 let shape = rotations.clone().filter(|rotated| rotated & FIRST_TWO_EDGES == FIRST_TWO_EDGES).min()
230 .or_else(|| rotations.min())
231 .unwrap_or_default();
232 let rotation = (0..HEX_EDGES).find(|&rotation| rotate_edges(shape, rotation) == mask).unwrap_or_default();
233 Self { shape, rotation }
234 }
235}
236
237/// Rotates an edge mask by some edges, in the game's direction order.
238fn rotate_edges(mask: u8, edges: u8) -> u8 {
239 ((mask << edges) | (mask >> ((HEX_EDGES - edges) % HEX_EDGES))) & ALL_EDGES
240}
241