Skip to main content

tilewright_source/transitions/
resolve.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! Resolving transitions: a chunk's ground tiles from painted terrain.
5
6use super::{Draw, Transitions};
7use crate::{CHUNK_SIZE, Grid, Plane};
8use std::collections::BTreeMap;
9
10/// Tiles along a chunk's side.
11const SIDE: usize = CHUNK_SIZE as usize;
12
13/// Tiles in a chunk.
14const CELLS: usize = SIDE * SIDE;
15
16/// Tiles along the side of a chunk with a border of one tile around it.
17const WINDOW: usize = SIDE + 2;
18
19/// Where the resolver reads painted terrain: the ground grids of chunks by
20/// address. A [`Plane`] is one; an editor can implement it over its own
21/// chunks, such as ones with unsaved edits.
22pub trait TerrainSource {
23    /// The ground grids of the chunk at `(layer, cx, cy)`, one per profile
24    /// ground layer in order, or `None` where there is no chunk.
25    fn chunk_ground(&self, layer: i32, cx: i64, cy: i64) -> Option<&[Grid]>;
26}
27
28impl TerrainSource for Plane {
29    fn chunk_ground(&self, layer: i32, cx: i64, cy: i64) -> Option<&[Grid]> {
30        self.chunks
31            .get(&(layer, cx, cy))
32            .map(|chunk| chunk.ground.as_slice())
33    }
34}
35
36/// The output function of `SplitMix64`.
37const fn mix(value: u64) -> u64 {
38    let value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
39    let value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
40    value ^ (value >> 31)
41}
42
43/// The hash that picks a tile's variant (`docs/spec/FORMAT.md` section 13):
44/// the transition's seed and the tile's layer and plane coordinates, mixed
45/// in that order, so the same world always picks the same variants.
46#[must_use]
47pub fn variant_hash(seed: u32, layer: i32, x: i64, y: i64) -> u64 {
48    let hash = mix(u64::from(seed));
49    let hash = mix(hash ^ i64::from(layer).cast_unsigned());
50    let hash = mix(hash ^ x.cast_unsigned());
51    mix(hash ^ y.cast_unsigned())
52}
53
54/// A chunk's ground tiles with transitions applied: for each ground layer,
55/// each tile's prototype, or `None` for an empty tile. Values borrow from
56/// the source grids and the transitions.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct ResolvedChunk<'a> {
59    /// One list of `chunk_size²` tiles per ground layer, row-major.
60    layers: Vec<Vec<Option<&'a str>>>,
61}
62
63impl<'a> ResolvedChunk<'a> {
64    /// The prototype at row-major tile `index` of ground layer `ground`.
65    #[must_use]
66    pub fn value(&self, ground: usize, index: usize) -> Option<&'a str> {
67        self.layers.get(ground)?.get(index).copied().flatten()
68    }
69
70    /// The prototype at chunk-local `[x, y]` of ground layer `ground`.
71    #[must_use]
72    pub fn at(&self, ground: usize, [x, y]: [u16; 2]) -> Option<&'a str> {
73        let (x, y) = (usize::from(x), usize::from(y));
74        if x < SIDE && y < SIDE {
75            self.value(ground, y * SIDE + x)
76        } else {
77            None
78        }
79    }
80
81    /// How many ground layers it holds.
82    #[must_use]
83    pub fn ground_layers(&self) -> usize {
84        self.layers.len()
85    }
86
87    /// Every prototype a tile of any ground layer holds, with repeats.
88    pub fn values(&self) -> impl Iterator<Item = &'a str> + '_ {
89        self.layers
90            .iter()
91            .flat_map(|tiles| tiles.iter().filter_map(|tile| *tile))
92    }
93
94    /// The tiles as grids, one per ground layer: each palette lists the
95    /// values its tiles use in the order they first appear, row-major.
96    #[must_use]
97    pub fn grids(&self) -> Vec<Grid> {
98        self.layers
99            .iter()
100            .map(|tiles| {
101                let mut indexes: BTreeMap<&str, u16> = BTreeMap::new();
102                let mut palette = Vec::new();
103                let cells = tiles
104                    .iter()
105                    .map(|tile| {
106                        tile.map_or(0, |value| {
107                            *indexes.entry(value).or_insert_with(|| {
108                                palette.push(value.to_owned());
109                                // A chunk has fewer tiles than u16 values.
110                                u16::try_from(palette.len()).unwrap_or(u16::MAX)
111                            })
112                        })
113                    })
114                    .collect();
115                Grid { palette, cells }
116            })
117            .collect()
118    }
119}
120
121/// The neighbours that satisfy `test`, as bits N 1, NE 2, ... NW 128.
122fn neighbour_bits(neighbours: &[Option<usize>; 8], test: impl Fn(Option<usize>) -> bool) -> u8 {
123    let mut bits = 0;
124    for (bit, neighbour) in neighbours.iter().enumerate() {
125        if test(*neighbour) {
126            bits |= 1 << bit;
127        }
128    }
129    bits
130}
131
132/// The value of a grid's cell, or `None` for an empty one or one the grid
133/// does not have.
134fn cell(grid: Option<&Grid>, index: usize) -> Option<&str> {
135    grid?.value(index)
136}
137
138impl Transitions {
139    /// Resolves the chunk at `(layer, cx, cy)` of `source`: its ground
140    /// tiles, one list per profile ground layer, with the transitions
141    /// applied as `docs/spec/FORMAT.md` section 13 defines.
142    ///
143    /// It reads the chunk's grids and the edge tiles of its eight
144    /// neighbours' terrain grids, so tiles along chunk edges agree across
145    /// them. A chunk the source does not have resolves to empty tiles. The
146    /// result depends only on the source's grids and the transitions, so it
147    /// is the same for every caller. A tile looks up only the transitions
148    /// of its own terrain and of its neighbours' terrains, at most
149    /// [`MAXIMUM_TILE_TRANSITIONS`](super::MAXIMUM_TILE_TRANSITIONS), each
150    /// in a table of its neighbourhoods, so the cost is linear in the
151    /// chunk's tiles however large the document.
152    #[must_use]
153    pub fn resolve_chunk<'a, S>(
154        &'a self,
155        source: &'a S,
156        layer: i32,
157        cx: i64,
158        cy: i64,
159    ) -> ResolvedChunk<'a>
160    where
161        S: TerrainSource + ?Sized,
162    {
163        let own = source.chunk_ground(layer, cx, cy).unwrap_or(&[]);
164        let mut layers: Vec<Vec<Option<&'a str>>> = (0..self.ground_layers)
165            .map(|ground| {
166                (0..CELLS)
167                    .map(|index| cell(own.get(ground), index))
168                    .collect()
169            })
170            .collect();
171        let window = self.window(source, layer, cx, cy);
172        let terrain_at = |x: usize, y: usize| window.get(y * WINDOW + x).copied().flatten();
173        let mut drawn: Vec<Option<(usize, &'a str)>> = vec![None; self.ground_layers];
174        // Plane coordinates of the chunk's first tile; a source chunk keeps
175        // them in range, and they are only hashed.
176        let side = i64::from(CHUNK_SIZE);
177        let (left, top) = (cx.wrapping_mul(side), cy.wrapping_mul(side));
178        for (y, plane_y) in (1..=SIDE).zip((0..).map(|offset: i64| top.wrapping_add(offset))) {
179            for (x, plane_x) in (1..=SIDE).zip((0..).map(|offset: i64| left.wrapping_add(offset))) {
180                let Some(terrain) = terrain_at(x, y) else {
181                    continue;
182                };
183                let neighbours = [
184                    terrain_at(x, y - 1),
185                    terrain_at(x + 1, y - 1),
186                    terrain_at(x + 1, y),
187                    terrain_at(x + 1, y + 1),
188                    terrain_at(x, y + 1),
189                    terrain_at(x - 1, y + 1),
190                    terrain_at(x - 1, y),
191                    terrain_at(x - 1, y - 1),
192                ];
193                let index = (y - 1) * SIDE + (x - 1);
194                // A prototype the source places in a layer wins over any
195                // transition there. No transition draws into the terrain
196                // layer, which keeps the painted terrain.
197                let blocked = (0..self.ground_layers.min(8)).fold(0_u8, |blocked, output| {
198                    if output == self.terrain_layer || cell(own.get(output), index).is_some() {
199                        blocked | (1 << output)
200                    } else {
201                        blocked
202                    }
203                });
204                self.draw_tile(
205                    terrain,
206                    &neighbours,
207                    blocked,
208                    (layer, plane_x, plane_y),
209                    &mut drawn,
210                );
211                for (output, entry) in drawn.iter().enumerate() {
212                    if let (Some((_, tile)), Some(slot)) = (
213                        entry,
214                        layers
215                            .get_mut(output)
216                            .and_then(|tiles| tiles.get_mut(index)),
217                    ) {
218                        *slot = Some(*tile);
219                    }
220                }
221            }
222        }
223        ResolvedChunk { layers }
224    }
225
226    /// Sets `drawn`, for each ground layer, to the latest transition that
227    /// draws on the tile `(x, y)` of `layer`, by position, and its tile, or
228    /// to `None`. The tile's terrain is `terrain` and its neighbours' are
229    /// `neighbours`; a transition into a ground layer whose bit is set in
230    /// `blocked` draws nothing.
231    ///
232    /// Only the transitions of the tile's own terrain and of its
233    /// neighbours' terrains can draw on it: an outside transition of any
234    /// other terrain sees no neighbour of its terrain. Each terrain is
235    /// visited once and its transitions latest first, so one that a later
236    /// transition has already beaten in its layer is skipped, and each
237    /// other one costs a lookup in its table of neighbourhoods.
238    fn draw_tile<'a>(
239        &'a self,
240        terrain: usize,
241        neighbours: &[Option<usize>; 8],
242        blocked: u8,
243        (layer, x, y): (i32, i64, i64),
244        drawn: &mut [Option<(usize, &'a str)>],
245    ) {
246        drawn.fill(None);
247        let owners = std::iter::once(terrain).chain(neighbours.iter().enumerate().filter_map(
248            |(seen, neighbour)| {
249                let neighbour = (*neighbour)?;
250                (neighbour != terrain && !neighbours[..seen].contains(&Some(neighbour)))
251                    .then_some(neighbour)
252            },
253        ));
254        for owner in owners {
255            let Some(range) = self
256                .terrains
257                .get(owner)
258                .map(|owner| owner.transitions.clone())
259            else {
260                continue;
261            };
262            // Which neighbours are the owner's terrain, for its outside
263            // transitions.
264            let same = neighbour_bits(neighbours, |neighbour| neighbour == Some(owner));
265            for position in range.rev() {
266                let Some(transition) = self.list.get(position) else {
267                    continue;
268                };
269                let output = transition.layer;
270                if blocked & (1 << output.min(7)) != 0
271                    || drawn
272                        .get(output)
273                        .is_some_and(|entry| entry.is_some_and(|(at, _)| at > position))
274                {
275                    continue;
276                }
277                // Inside transitions of the tile's terrain, and outside ones
278                // of its neighbours' that blend with it.
279                let shows = if owner == terrain {
280                    if transition.draw != Draw::Inside {
281                        continue;
282                    }
283                    neighbour_bits(neighbours, |neighbour| {
284                        neighbour.is_none_or(|neighbour| !transition.blends_with(neighbour))
285                    })
286                } else if transition.draw == Draw::Outside && transition.blends_with(terrain) {
287                    same
288                } else {
289                    continue;
290                };
291                if !transition.draws(shows) {
292                    continue;
293                }
294                let hash = variant_hash(transition.seed, layer, x, y);
295                if let (Some(tile), Some(slot)) =
296                    (transition.draw_at(shows, hash), drawn.get_mut(output))
297                {
298                    *slot = Some((position, tile));
299                }
300            }
301        }
302    }
303
304    /// The terrain of each tile of the chunk at `(layer, cx, cy)` and of the
305    /// border of one tile around it, row-major over [`WINDOW`]² tiles.
306    fn window<S>(&self, source: &S, layer: i32, cx: i64, cy: i64) -> Vec<Option<usize>>
307    where
308        S: TerrainSource + ?Sized,
309    {
310        let mut window = vec![None; WINDOW * WINDOW];
311        for dy in -1_i64..=1 {
312            for dx in -1_i64..=1 {
313                let (Some(x), Some(y)) = (cx.checked_add(dx), cy.checked_add(dy)) else {
314                    continue;
315                };
316                let Some(grid) = source
317                    .chunk_ground(layer, x, y)
318                    .and_then(|grids| grids.get(self.terrain_layer))
319                else {
320                    continue;
321                };
322                // Each palette entry's terrain is looked up once, when a
323                // tile first uses it.
324                let mut terrains: BTreeMap<u16, Option<usize>> = BTreeMap::new();
325                // The window columns and rows this neighbour covers, and the
326                // chunk's own column and row of the first.
327                let span = |offset: i64| -> (usize, usize, usize) {
328                    match offset {
329                        -1 => (0, 1, SIDE - 1),
330                        0 => (1, SIDE + 1, 0),
331                        _ => (SIDE + 1, SIDE + 2, 0),
332                    }
333                };
334                let (column_start, column_end, first_column) = span(dx);
335                let (row_start, row_end, first_row) = span(dy);
336                for (row, local_y) in (row_start..row_end).zip(first_row..) {
337                    for (column, local_x) in (column_start..column_end).zip(first_column..) {
338                        let Some(&value) = grid.cells.get(local_y * SIDE + local_x) else {
339                            continue;
340                        };
341                        if value == 0 {
342                            continue;
343                        }
344                        let terrain = *terrains.entry(value).or_insert_with(|| {
345                            grid.palette
346                                .get(usize::from(value) - 1)
347                                .and_then(|proto| self.terrain_of(proto))
348                        });
349                        if let Some(slot) = window.get_mut(row * WINDOW + column) {
350                            *slot = terrain;
351                        }
352                    }
353                }
354            }
355        }
356        window
357    }
358}