Skip to main content

shuyi_ui/grid/
threat.rs

1//! The threat arrows, as every grid skin draws them (game ticket 0448): a
2//! slim straight arrow from each enemy that could attack a tile to that
3//! tile, and a `!` above each of those enemies. Over units and terrain, so
4//! never hidden. Worked out from the skin's tile size, in the art's own
5//! pixels.
6//!
7//! An arrow is a 1-pixel line from the enemy's tile (its middle, or its
8//! edge for a skin whose units are letters: [`Start`]) to just short of the
9//! middle of the tile it threatens, ending in two barbs, with a 1-pixel
10//! dark edge all round. A line at any angle is a row of short
11//! rectangles, one per step across it. An enemy outside the view has its
12//! arrow cut at the view's edge and no mark.
13
14use crate::prelude::*;
15use shuyi_core::Pos;
16
17use super::layout::Grid;
18use super::scene::{MapScene, ThreatView};
19use crate::frame::color::Rgb;
20use crate::frame::glyph_buffer::{GlyphBuffer, Layer, Overlay, PxRect, Rect};
21
22/// How far short of the tile's middle an arrow's tip stops, in pixels.
23/// *Tunable.*
24pub const TIP_SHORT: f32 = 3.0;
25
26/// How long each of an arrowhead's two barbs is, in pixels. *Tunable.*
27pub const BARB_LEN: f32 = 4.0;
28
29/// The cosine and sine of the angle between a barb and the arrow's line
30/// (about 35°). *Tunable.*
31const BARB_TURN: (f32, f32) = (0.82, 0.57);
32
33/// The mark's width, in pixels.
34pub const MARK_W: i32 = 3;
35
36/// The height of the mark's bar, of the gap under it and of its dot.
37pub const MARK_BAR_H: i32 = 6;
38const MARK_GAP_H: i32 = 1;
39const MARK_DOT_H: i32 = 2;
40
41/// Pixels between the mark's lowest pixel and the tile's top edge, so that
42/// it clears the head of the unit standing there (Nick: lower, "it covers
43/// the enemy's head and looks weird"). *Tunable.*
44pub const MARK_ABOVE: i32 = 3;
45
46/// The mark's height, bar to dot.
47pub const MARK_H: i32 = MARK_BAR_H + MARK_GAP_H + MARK_DOT_H;
48
49/// Where on its enemy's tile an arrow starts.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Start {
52    /// In the tile's middle, over the unit's picture.
53    Middle,
54    /// Where the arrow's line leaves the tile, so that it never covers
55    /// the unit's letters (as the path line starts at its unit's edge).
56    Edge,
57}
58
59/// The colours of the arrows and marks.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct Colors {
62    /// The arrow's line and barbs.
63    pub arrow: Rgb,
64    /// The mark over an enemy.
65    pub mark: Rgb,
66    /// The dark edge round both.
67    pub edge: Rgb,
68}
69
70/// The pixels of the straight line from `a` to `b`, both included, as
71/// rectangles one pixel thick: one for each run of pixels in a row (or in a
72/// column, for a line more upright than flat), from `a`'s end.
73fn line(a: (i32, i32), b: (i32, i32)) -> Vec<PxRect> {
74    let (dx, dy) = ((b.0 - a.0).abs(), (b.1 - a.1).abs());
75    let (sx, sy) = ((b.0 - a.0).signum(), (b.1 - a.1).signum());
76    let flat = dx >= dy;
77    let (long, short) = if flat { (dx, dy) } else { (dy, dx) };
78    // A run from `from` to `to`, which share a row or a column.
79    let run = |from: (i32, i32), to: (i32, i32)| {
80        let (x, y) = (from.0.min(to.0), from.1.min(to.1));
81        Rect::new(x, y, (to.0 - from.0).abs() + 1, (to.1 - from.1).abs() + 1)
82    };
83    let mut runs = Vec::new();
84    let (mut from, mut at) = (a, a);
85    let mut err = long / 2;
86    for _ in 0..long {
87        err -= short;
88        let turn = err < 0;
89        if turn {
90            err += long;
91            runs.push(run(from, at));
92        }
93        at = match (flat, turn) {
94            (true, false) => (at.0 + sx, at.1),
95            (false, false) => (at.0, at.1 + sy),
96            (_, true) => (at.0 + sx, at.1 + sy),
97        };
98        if turn {
99            from = at;
100        }
101    }
102    runs.push(run(from, at));
103    runs
104}
105
106/// `r` with one more pixel on every side.
107const fn grown(r: PxRect) -> PxRect {
108    Rect::new(r.x - 1, r.y - 1, r.w + 2, r.h + 2)
109}
110
111/// The furthest from pixel 0 an arrow's end may be, each way: far past any
112/// console, and near enough that a [`line`] to it is quick to work out.
113const FAR: f32 = 32_000.0;
114
115/// The nearest whole pixel to `(x, y)`, no further than [`FAR`] out.
116#[allow(clippy::cast_possible_truncation)] // within `FAR`
117fn pixel(x: f32, y: f32) -> (i32, i32) {
118    let whole = |v: f32| v.round().clamp(-FAR, FAR) as i32;
119    (whole(x), whole(y))
120}
121
122/// The arrow from tile `from` (its middle or its edge: `start`) to just
123/// short of the middle of tile `to`, on `art` (a grid at the art's size):
124/// its line, then its two barbs, as [`line`]s. Nothing for an enemy on the
125/// tile itself.
126fn arrow(from: Pos, to: Pos, art: &Grid, start: Start) -> Vec<PxRect> {
127    let middle = |tile: Pos| {
128        let (x, y) = art.tile_px(tile);
129        (x + art.tile.0 / 2, y + art.tile.1 / 2)
130    };
131    let (mid, end) = (middle(from), middle(to));
132    #[allow(clippy::cast_precision_loss)] // console pixels
133    let (dx, dy) = ((end.0 - mid.0) as f32, (end.1 - mid.1) as f32);
134    if from == to {
135        return Vec::new();
136    }
137    let len = dx.hypot(dy);
138    // One pixel along the arrow.
139    let (ux, uy) = (dx / len, dy / len);
140    #[allow(clippy::cast_precision_loss)]
141    let (tx, ty) = (end.0 as f32 - ux * TIP_SHORT, end.1 as f32 - uy * TIP_SHORT);
142    let tip = pixel(tx, ty);
143    let (cos, sin) = BARB_TURN;
144    // Back along the arrow, turned to each side.
145    let barb = |sin: f32| {
146        let (bx, by) = (-ux * cos + uy * sin, -uy * cos - ux * sin);
147        pixel(tx + bx * BARB_LEN, ty + by * BARB_LEN)
148    };
149    let first = match start {
150        Start::Middle => mid,
151        Start::Edge => {
152            // The first pixel along the arrow that is off the enemy's tile.
153            let (x, y) = art.tile_px(from);
154            let tile = Rect::new(x, y, art.tile.0, art.tile.1);
155            #[allow(clippy::cast_precision_loss)]
156            let along = |k: i32| pixel(mid.0 as f32 + ux * k as f32, mid.1 as f32 + uy * k as f32);
157            let reach = art.tile.0.max(art.tile.1);
158            let out = (1..=reach).map(along).find(|&(x, y)| !tile.contains(x, y));
159            out.unwrap_or(mid)
160        }
161    };
162    let mut rects = line(first, tip);
163    rects.extend(line(tip, barb(sin)));
164    rects.extend(line(tip, barb(-sin)));
165    rects
166}
167
168/// The mark over tile `tile` of `art` (a grid at the art's size, its view's
169/// top at pixel 0): its bar and its dot, centred across the tile, the dot's
170/// last row [`MARK_ABOVE`] pixels clear of the tile's top; moved down to
171/// keep its dark edge inside the view on the view's top rows.
172fn mark(tile: Pos, art: &Grid) -> [PxRect; 2] {
173    let (x, y) = art.tile_px(tile);
174    let x = x + (art.tile.0 - MARK_W) / 2;
175    // Its top row; row 0 is the dark edge's on the view's top row.
176    let top = (y - MARK_ABOVE - MARK_H).max(1);
177    [
178        Rect::new(x, top, MARK_W, MARK_BAR_H),
179        Rect::new(x, top + MARK_BAR_H + MARK_GAP_H, MARK_W, MARK_DOT_H),
180    ]
181}
182
183/// The overlays drawing `threat` on `grid`, all `Over` the glyphs: every
184/// arrow's dark edge, then the arrows (each from its enemy's tile at
185/// `start`); then every mark's dark edge and the marks. Clipped to the
186/// tiles of `grid`. On a zoomed grid every pixel is a square of the zoom's.
187pub fn threat_overlays(
188    threat: &ThreatView,
189    grid: &Grid,
190    start: Start,
191    colors: Colors,
192) -> Vec<Overlay> {
193    let view = grid.bounds();
194    let art = grid.at_art_size();
195    let mut out = Vec::new();
196    let mut add = |rect: PxRect, color: Rgb| {
197        if let Some(rect) = grid.zoomed(grid.corner, rect).intersect(&view) {
198            out.push(Overlay::new(rect, color, Layer::Over));
199        }
200    };
201    let arrows: Vec<PxRect> = threat
202        .enemies
203        .iter()
204        .flat_map(|&enemy| arrow(enemy, threat.tile, &art, start))
205        .collect();
206    for &rect in &arrows {
207        add(grown(rect), colors.edge);
208    }
209    for &rect in &arrows {
210        add(rect, colors.arrow);
211    }
212    let shown = threat.enemies.iter().filter(|&&e| art.offset(e).is_some());
213    let marks: Vec<[PxRect; 2]> = shown.map(|&enemy| mark(enemy, &art)).collect();
214    for [bar, _] in &marks {
215        // One dark box behind both.
216        let both = Rect::new(bar.x, bar.y, MARK_W, MARK_H);
217        add(grown(both), colors.edge);
218    }
219    for part in marks.iter().flatten() {
220        add(*part, colors.mark);
221    }
222    out
223}
224
225/// Paints `scene`'s threat arrows and marks, if it has any, on `grid`, in
226/// `colors` ([`Looks::threat`](super::Looks::threat)), each arrow from its
227/// enemy's tile at `start`.
228pub fn paint<U>(
229    scene: &MapScene<U>,
230    grid: &Grid,
231    start: Start,
232    colors: Colors,
233    buf: &mut GlyphBuffer,
234) {
235    let Some(threat) = &scene.threat else {
236        return;
237    };
238    for overlay in threat_overlays(threat, grid, start, colors) {
239        buf.add_overlay(overlay);
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    const COLORS: Colors = Colors {
248        arrow: Rgb::new(255, 0, 0),
249        mark: Rgb::new(255, 255, 0),
250        edge: Rgb::new(0, 0, 0),
251    };
252
253    fn p(x: i32, y: i32) -> Pos {
254        Pos::new(x, y)
255    }
256
257    /// A 10 × 10 view from tile (0, 0) at pixel (0, 0), 16 px tiles.
258    fn grid() -> Grid {
259        Grid::top_down(p(0, 0), (0, 0), (16, 16), (10, 10), 1)
260    }
261
262    fn threat(tile: Pos, enemies: &[Pos]) -> ThreatView {
263        ThreatView {
264            tile,
265            enemies: enemies.to_vec(),
266        }
267    }
268
269    /// The overlays of `threat` on `grid` in `color`, as `(x, y, w, h)`.
270    fn rects(threat: &ThreatView, grid: &Grid, color: Rgb) -> Vec<(i32, i32, i32, i32)> {
271        let overlays = threat_overlays(threat, grid, Start::Middle, COLORS);
272        assert!(overlays.iter().all(|o| o.layer == Layer::Over));
273        let of = overlays.iter().filter(|o| o.color == color);
274        of.map(|o| (o.rect.x, o.rect.y, o.rect.w, o.rect.h))
275            .collect()
276    }
277
278    /// Every pixel the rectangles cover.
279    fn pixels(rects: &[PxRect]) -> Vec<(i32, i32)> {
280        let all = rects.iter().flat_map(|r| {
281            (r.y..r.y + r.h).flat_map(move |y| (r.x..r.x + r.w).map(move |x| (x, y)))
282        });
283        all.collect()
284    }
285
286    #[test]
287    fn a_line_is_one_rectangle_per_run_of_pixels() {
288        // Flat and upright: one run, whichever way it is drawn.
289        assert_eq!(line((2, 5), (9, 5)), [Rect::new(2, 5, 8, 1)]);
290        assert_eq!(line((9, 5), (2, 5)), [Rect::new(2, 5, 8, 1)]);
291        assert_eq!(line((4, 1), (4, 6)), [Rect::new(4, 1, 1, 6)]);
292        assert_eq!(line((4, 6), (4, 1)), [Rect::new(4, 1, 1, 6)]);
293        assert_eq!(line((3, 3), (3, 3)), [Rect::new(3, 3, 1, 1)]);
294        // 9 across, 2 down: three runs, the middle one longest.
295        assert_eq!(
296            line((0, 0), (9, 2)),
297            [
298                Rect::new(0, 0, 3, 1),
299                Rect::new(3, 1, 4, 1),
300                Rect::new(7, 2, 3, 1)
301            ]
302        );
303        // The same line the other way up and backwards.
304        assert_eq!(
305            line((9, 0), (0, 2)),
306            [
307                Rect::new(7, 0, 3, 1),
308                Rect::new(3, 1, 4, 1),
309                Rect::new(0, 2, 3, 1)
310            ]
311        );
312        // More upright than flat: runs down columns.
313        assert_eq!(
314            line((0, 0), (1, 5)),
315            [Rect::new(0, 0, 1, 3), Rect::new(1, 3, 1, 3)]
316        );
317        assert_eq!(
318            line((1, 5), (0, 0)),
319            [Rect::new(1, 3, 1, 3), Rect::new(0, 0, 1, 3)]
320        );
321        // The diagonal: a pixel a run.
322        let diagonal = line((0, 0), (3, 3));
323        assert_eq!(pixels(&diagonal), [(0, 0), (1, 1), (2, 2), (3, 3)]);
324        assert_eq!(diagonal.len(), 4);
325    }
326
327    #[test]
328    fn a_pixel_is_the_nearest_one_and_never_far_off_the_console() {
329        assert_eq!(pixel(2.4, -2.6), (2, -3));
330        assert_eq!(pixel(2.5, 7.0), (3, 7));
331        assert_eq!(pixel(f32::INFINITY, f32::NEG_INFINITY), (32_000, -32_000));
332        assert_eq!(pixel(1e9, -1e9), (32_000, -32_000));
333        assert_eq!(pixel(f32::NAN, 31_999.6), (0, 32_000));
334    }
335
336    #[test]
337    fn a_line_has_one_pixel_per_step_from_end_to_end() {
338        for (a, b) in [
339            ((0, 0), (40, 13)),
340            ((40, 13), (0, 0)),
341            ((5, 30), (9, -7)),
342            ((-3, 2), (20, 2)),
343            ((7, 7), (-9, 23)),
344        ] {
345            let runs = line(a, b);
346            assert!(runs.iter().all(|r| r.w == 1 || r.h == 1), "{a:?} {b:?}");
347            let px = pixels(&runs);
348            let steps = (b.0 - a.0).abs().max((b.1 - a.1).abs());
349            assert_eq!(px.len(), usize::try_from(steps).unwrap() + 1, "{a:?} {b:?}");
350            assert!(px.contains(&a) && px.contains(&b), "{a:?} {b:?}");
351            // Each run starts a pixel on, across and along, from the last.
352            for pair in runs.windows(2) {
353                let grown = grown(pair[0]);
354                assert!(grown.intersect(&pair[1]).is_some(), "{pair:?}");
355                assert_eq!(pair[0].intersect(&pair[1]), None, "{pair:?}");
356            }
357        }
358    }
359
360    #[test]
361    fn an_arrow_runs_from_the_enemys_middle_to_just_short_of_the_tiles() {
362        // The enemy three tiles east of the tile, on its row: tile (1, 1)
363        // spans pixels 16..32, its middle is (24, 24); the enemy's, (72, 24).
364        let t = threat(p(1, 1), &[p(4, 1)]);
365        let red = rects(&t, &grid(), COLORS.arrow);
366        // The line stops 3 px short of the middle; the barbs go back from
367        // its tip, down and up: 4 px long, about 35° off the line.
368        assert_eq!(red[0], (27, 24, 46, 1));
369        assert_eq!(
370            &red[1..],
371            [(27, 24, 1, 1), (28, 25, 2, 1), (30, 26, 1, 1)]
372                .into_iter()
373                .chain([(27, 24, 1, 1), (28, 23, 2, 1), (30, 22, 1, 1)])
374                .collect::<Vec<_>>()
375        );
376        // The dark edge: every rectangle a pixel bigger each way, under
377        // all the red.
378        let all = threat_overlays(&t, &grid(), Start::Middle, COLORS);
379        let dark: Vec<_> = all.iter().take_while(|o| o.color == COLORS.edge).collect();
380        assert_eq!(dark.len(), red.len());
381        assert_eq!(dark[0].rect, Rect::new(26, 23, 48, 3));
382        for (edge, line) in dark.iter().zip(&red) {
383            let r = edge.rect;
384            assert_eq!((r.x + 1, r.y + 1, r.w - 2, r.h - 2), *line);
385        }
386        let reds = &all[dark.len()..dark.len() + red.len()];
387        assert!(reds.iter().all(|o| o.color == COLORS.arrow));
388    }
389
390    #[test]
391    fn arrows_point_at_the_tile_from_any_side() {
392        // The tip, where the line ends and both barbs start: 3 px from the
393        // middle of tile (4, 4), (72, 72), towards the enemy.
394        let tip = |enemy: Pos| {
395            let px = pixels(&arrow(enemy, p(4, 4), &grid(), Start::Middle));
396            // From the enemy's middle.
397            assert!(px.contains(&(enemy.x * 16 + 8, enemy.y * 16 + 8)));
398            let thrice = |q: &&(i32, i32)| px.iter().filter(|r| r == q).count() == 3;
399            *px.iter().find(thrice).unwrap()
400        };
401        assert_eq!(tip(p(7, 4)), (75, 72));
402        assert_eq!(tip(p(1, 4)), (69, 72));
403        assert_eq!(tip(p(4, 1)), (72, 69));
404        assert_eq!(tip(p(4, 8)), (72, 75));
405        // Diagonally: 3 px back along the line, to the nearest pixel.
406        assert_eq!(tip(p(6, 6)), (74, 74));
407        assert_eq!(tip(p(2, 2)), (70, 70));
408        assert_eq!(tip(p(6, 2)), (74, 70));
409        // An enemy next to the tile still has its arrow.
410        assert_eq!(tip(p(5, 4)), (75, 72));
411        // One on the tile itself (it can't be) has none.
412        for start in [Start::Middle, Start::Edge] {
413            let found = arrow(p(4, 4), p(4, 4), &grid(), start);
414            assert!(found.is_empty(), "{found:?}");
415        }
416        let none = threat(p(4, 4), &[p(4, 4)]);
417        let found = rects(&none, &grid(), COLORS.arrow);
418        assert!(found.is_empty(), "{found:?}");
419    }
420
421    #[test]
422    fn barbs_are_four_pixels_back_from_the_tip_on_both_sides() {
423        let t = threat(p(4, 4), &[p(4, 1)]);
424        let red = rects(&t, &grid(), COLORS.arrow);
425        // Down the column x 72 from the enemy's middle (y 24) to the tip.
426        assert_eq!(red[0], (72, 24, 1, 46));
427        // Each barb: from the tip (72, 69) up and out, to x 70 and x 74.
428        let barb = |x: i32, end: i32| [(72, 69, 1, 1), (x, 67, 1, 2), (end, 66, 1, 1)];
429        assert_eq!(red[1..4], barb(73, 74));
430        assert_eq!(red[4..], barb(71, 70));
431    }
432
433    #[test]
434    fn an_arrow_from_the_edge_starts_on_the_first_pixel_off_the_enemys_tile() {
435        // The first run of the arrow from each side of tile (4, 4), which
436        // spans pixels 64..80 both ways: the enemy's tile is left clear.
437        let first = |enemy: Pos, start| {
438            let all = arrow(enemy, p(4, 4), &grid(), start);
439            let (x, y) = grid().tile_px(enemy);
440            let tile = Rect::new(x, y, 16, 16);
441            let clear = pixels(&all).iter().all(|&(x, y)| !tile.contains(x, y));
442            assert_eq!(clear, start == Start::Edge, "{enemy:?}");
443            all[0]
444        };
445        // East, on the row: from the middle of the enemy's tile (x 120)...
446        assert_eq!(first(p(7, 4), Start::Middle), Rect::new(75, 72, 46, 1));
447        // ...or from the pixel left of that tile (x 112..128).
448        assert_eq!(first(p(7, 4), Start::Edge), Rect::new(75, 72, 37, 1));
449        assert_eq!(first(p(1, 4), Start::Edge), Rect::new(32, 72, 38, 1));
450        assert_eq!(first(p(4, 1), Start::Edge), Rect::new(72, 32, 1, 38));
451        assert_eq!(first(p(4, 8), Start::Edge), Rect::new(72, 75, 1, 53));
452        // Diagonally: out through the tile's corner.
453        assert_eq!(first(p(6, 6), Start::Edge), Rect::new(95, 95, 1, 1));
454        assert_eq!(first(p(2, 2), Start::Edge), Rect::new(48, 48, 1, 1));
455        // Next to the tile, there are 5 px left of the arrow, and its barbs.
456        assert_eq!(first(p(5, 4), Start::Edge), Rect::new(75, 72, 5, 1));
457        let barbs = arrow(p(5, 4), p(4, 4), &grid(), Start::Edge);
458        assert_eq!(barbs.len(), 7);
459        // The rest of the arrow is the same from either start.
460        let (edge, middle) = (
461            arrow(p(7, 4), p(4, 4), &grid(), Start::Edge),
462            arrow(p(7, 4), p(4, 4), &grid(), Start::Middle),
463        );
464        assert_eq!(edge[1..], middle[1..]);
465    }
466
467    #[test]
468    fn a_mark_sits_above_each_enemy_clear_of_its_head() {
469        // Tile (4, 3) spans x 64..80 from y 48: the mark is 3 px wide in
470        // its middle; its dot ends 3 px clear of the tile's top.
471        let t = threat(p(1, 5), &[p(4, 3), p(6, 5)]);
472        let yellow = rects(&t, &grid(), COLORS.mark);
473        assert_eq!(
474            yellow,
475            [
476                (70, 36, 3, 6),
477                (70, 43, 3, 2),
478                (102, 68, 3, 6),
479                (102, 75, 3, 2)
480            ]
481        );
482        assert_eq!((MARK_H, 48 - MARK_ABOVE - MARK_H), (9, 36));
483        // One dark box behind each, a pixel bigger each way; the boxes
484        // before the marks, after every arrow.
485        let all = threat_overlays(&t, &grid(), Start::Middle, COLORS);
486        let n = all.len();
487        let tail: Vec<_> = all[n - 6..].iter().map(|o| (o.rect, o.color)).collect();
488        assert_eq!(tail[0], (Rect::new(69, 35, 5, 11), COLORS.edge));
489        assert_eq!(tail[1], (Rect::new(101, 67, 5, 11), COLORS.edge));
490        assert!(tail[2..].iter().all(|&(_, c)| c == COLORS.mark));
491        assert!(all[..n - 6].iter().all(|o| o.color != COLORS.mark));
492    }
493
494    #[test]
495    fn a_mark_on_the_views_top_row_moves_down_to_stay_inside() {
496        let t = threat(p(1, 5), &[p(4, 0)]);
497        let yellow = rects(&t, &grid(), COLORS.mark);
498        assert_eq!(yellow, [(70, 1, 3, 6), (70, 8, 3, 2)]);
499        let dark = rects(&t, &grid(), COLORS.edge);
500        assert_eq!(dark[dark.len() - 1], (69, 0, 5, 11));
501        // The second row has room for all but one row of it.
502        let t = threat(p(1, 5), &[p(4, 1)]);
503        let yellow = rects(&t, &grid(), COLORS.mark);
504        assert_eq!(yellow, [(70, 4, 3, 6), (70, 11, 3, 2)]);
505    }
506
507    #[test]
508    fn an_enemy_outside_the_view_has_its_arrow_cut_at_the_edge_and_no_mark() {
509        // A 4 × 4 view of 16 px tiles from tile (2, 2), at pixel (8, 4).
510        let view = Grid::top_down(p(2, 2), (8, 4), (16, 16), (4, 4), 1);
511        // The enemy is on the tile's row, three tiles past the right edge.
512        let t = threat(p(3, 3), &[p(8, 3)]);
513        let found = rects(&t, &view, COLORS.mark);
514        assert!(found.is_empty(), "{found:?}");
515        let red = rects(&t, &view, COLORS.arrow);
516        // From the tip, 3 px right of the tile's middle (32, 28), to the
517        // view's right edge at x 72.
518        assert_eq!(red[0], (35, 28, 37, 1));
519        let bounds = view.bounds();
520        assert_eq!(bounds, Rect::new(8, 4, 64, 64));
521        for o in threat_overlays(&t, &view, Start::Middle, COLORS) {
522            assert_eq!(o.rect.intersect(&bounds), Some(o.rect), "{o:?}");
523        }
524        // The same enemy in a view that holds it: the whole arrow, marked.
525        let wide = Grid {
526            tiles: (8, 4),
527            ..view
528        };
529        assert_eq!(rects(&t, &wide, COLORS.arrow)[0], (35, 28, 78, 1));
530        assert_eq!(rects(&t, &wide, COLORS.mark).len(), 2);
531        // The tile itself outside the view: what crosses the view is drawn.
532        let t = threat(p(0, 3), &[p(4, 3)]);
533        let red = rects(&t, &view, COLORS.arrow);
534        assert_eq!(red, [(8, 28, 41, 1)]);
535        assert_eq!(rects(&t, &view, COLORS.mark).len(), 2);
536    }
537
538    #[test]
539    fn on_a_zoomed_grid_every_pixel_is_a_square_of_the_zooms() {
540        let t = threat(p(1, 3), &[p(4, 1), p(3, 0)]);
541        let once = threat_overlays(&t, &grid(), Start::Edge, COLORS);
542        let zoomed = Grid {
543            corner: (8, 16),
544            tile: (32, 32),
545            scale: 2,
546            ..grid()
547        };
548        let twice = threat_overlays(&t, &zoomed, Start::Edge, COLORS);
549        let doubled: Vec<Overlay> = once
550            .iter()
551            .map(|o| {
552                let r = o.rect;
553                let rect = Rect::new(8 + 2 * r.x, 16 + 2 * r.y, 2 * r.w, 2 * r.h);
554                Overlay::new(rect, o.color, o.layer)
555            })
556            .collect();
557        assert_eq!(twice, doubled);
558        assert!(!twice.is_empty(), "{twice:?}");
559    }
560
561    #[test]
562    fn the_geometry_follows_the_tile_size() {
563        // 24 × 20 tiles: tile (1, 1) spans 24..48 × 20..40, middle (36, 30).
564        let big = Grid {
565            tile: (24, 20),
566            ..grid()
567        };
568        let t = threat(p(1, 1), &[p(3, 1)]);
569        assert_eq!(rects(&t, &big, COLORS.arrow)[0], (39, 30, 46, 1));
570        // The mark in the middle of tile (3, 1): x 72..96.
571        assert_eq!(rects(&t, &big, COLORS.mark)[0], (82, 8, 3, 6));
572    }
573
574    #[test]
575    fn paint_adds_the_scenes_threat_over_everything_and_nothing_without_one() {
576        let mut scene = MapScene::<crate::toy::grid::Piece>::new(p(0, 0), (10, 10));
577        let blank = || {
578            let c = crate::frame::glyph_buffer::Cell::new('.', COLORS.edge, COLORS.edge);
579            GlyphBuffer::new(20, 10, c)
580        };
581        let mut buf = blank();
582        paint(&scene, &grid(), Start::Middle, COLORS, &mut buf);
583        assert_eq!(buf, blank());
584        let t = threat(p(1, 1), &[p(4, 2), p(0, 5)]);
585        scene.threat = Some(t.clone());
586        paint(&scene, &grid(), Start::Edge, COLORS, &mut buf);
587        assert_eq!(
588            buf.overlays(),
589            threat_overlays(&t, &grid(), Start::Edge, COLORS)
590        );
591        let found = buf.overlays();
592        assert!(!found.is_empty(), "{found:?}");
593    }
594}