pikadick/commands/tic_tac_toe/
renderer.rs1use anyhow::Context;
2use once_cell::sync::Lazy;
3use std::{
4 sync::Arc,
5 time::Instant,
6};
7use tiny_skia::{
8 Paint,
9 Path,
10 PathBuilder,
11 Pixmap,
12 Rect,
13 Stroke,
14 Transform,
15};
16use tokio::sync::Semaphore;
17use tracing::info;
18use ttf_parser::OutlineBuilder;
19
20const FONT_BYTES: &[u8] =
21 include_bytes!("../../../assets/Averia_Serif_Libre/AveriaSerifLibre-Light.ttf");
22static FONT_FACE: Lazy<ttf_parser::Face<'static>> =
23 Lazy::new(|| ttf_parser::Face::parse(FONT_BYTES, 0).expect("failed to load `FONT_BYTES`"));
24
25const RENDERED_SIZE: u16 = 300;
26const SQUARE_SIZE: u16 = RENDERED_SIZE / 3;
27const SQUARE_SIZE_USIZE: usize = SQUARE_SIZE as usize;
28const SQUARE_SIZE_F32: f32 = SQUARE_SIZE as f32;
29const HALF_SQUARE_SIZE_F32: f32 = SQUARE_SIZE_F32 / 2.0;
30
31const MAX_PARALLEL_RENDER_LIMIT: usize = 4;
32
33#[derive(Debug, Clone)]
35pub(crate) struct Renderer {
36 background_pixmap: Arc<Pixmap>,
37 number_paths: Arc<[Path]>,
38
39 render_semaphore: Arc<Semaphore>,
40}
41
42#[allow(clippy::new_without_default)]
43impl Renderer {
44 pub(crate) fn new() -> anyhow::Result<Self> {
46 let mut background_pixmap = Pixmap::new(RENDERED_SIZE.into(), RENDERED_SIZE.into())
47 .context("failed to create background pixmap")?;
48
49 let mut paint = Paint::default();
50 for i in 0..3 {
51 for j in 0..3 {
52 let x = i * SQUARE_SIZE;
53 let y = j * SQUARE_SIZE;
54 let square =
55 Rect::from_xywh(f32::from(x), f32::from(y), SQUARE_SIZE_F32, SQUARE_SIZE_F32)
56 .context("failed to make square")?;
57
58 if (j * 3 + i) % 2 == 0 {
59 paint.set_color_rgba8(255, 0, 0, 255);
60 } else {
61 paint.set_color_rgba8(119, 119, 119, 255);
62 }
63
64 background_pixmap.fill_rect(square, &paint, Transform::identity(), None);
65 }
66 }
67
68 let mut number_paths = Vec::with_capacity(10);
69 let mut paint = Paint::default();
70 paint.set_color_rgba8(255, 255, 255, 255);
71 for i in b'0'..=b'9' {
72 let glyph_id = FONT_FACE
73 .glyph_index(char::from(i))
74 .with_context(|| format!("missing glyph for '{}'", char::from(i)))?;
75
76 let mut builder = SkiaBuilder::new();
77 let _bb = FONT_FACE
78 .outline_glyph(glyph_id, &mut builder)
79 .with_context(|| format!("missing glyph bounds for '{}'", char::from(i)))?;
80 let path = builder.into_path().with_context(|| {
81 format!("failed to generate glyph path for '{}'", char::from(i))
82 })?;
83
84 number_paths.push(path);
85 }
86
87 Ok(Self {
88 background_pixmap: Arc::new(background_pixmap),
89 number_paths: Arc::from(number_paths),
90 render_semaphore: Arc::new(Semaphore::new(MAX_PARALLEL_RENDER_LIMIT)),
91 })
92 }
93
94 #[allow(clippy::field_reassign_with_default)]
97 pub(crate) fn render_board(&self, board: tic_tac_toe::Board) -> anyhow::Result<Vec<u8>> {
98 const PIECE_WIDTH: u16 = 4;
99
100 let draw_start = Instant::now();
101 let mut pixmap = self.background_pixmap.as_ref().as_ref().to_owned();
102
103 let mut paint = Paint::default();
104 let mut stroke = Stroke::default();
105 paint.anti_alias = true;
106 stroke.width = f32::from(PIECE_WIDTH);
107
108 for (i, team) in board.iter() {
109 let transform = Transform::from_translate(
110 f32::from((u16::from(i) % 3) * SQUARE_SIZE),
111 f32::from((u16::from(i) / 3) * SQUARE_SIZE),
112 );
113
114 if let Some(team) = team {
115 paint.set_color_rgba8(0, 0, 0, 255);
116 let path = match team {
117 tic_tac_toe::Team::X => {
118 let mut path_builder = PathBuilder::new();
119 path_builder.move_to((PIECE_WIDTH / 2).into(), (PIECE_WIDTH / 2).into());
120 path_builder.line_to(
121 SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
122 SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
123 );
124 path_builder.move_to(
125 (PIECE_WIDTH / 2).into(),
126 SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
127 );
128 path_builder.line_to(
129 SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
130 f32::from(PIECE_WIDTH / 2),
131 );
132 path_builder.finish()
133 }
134 tic_tac_toe::Team::O => PathBuilder::from_circle(
135 HALF_SQUARE_SIZE_F32,
136 HALF_SQUARE_SIZE_F32,
137 HALF_SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
138 ),
139 };
140 let path =
141 path.with_context(|| format!("failed to build path for team '{:?}'", team))?;
142
143 pixmap.stroke_path(&path, &paint, &stroke, transform, None);
144 } else {
145 paint.set_color_rgba8(255, 255, 255, 255);
146 let path = &self.number_paths[usize::from(i) + 1];
147 let bounds = path.bounds();
148
149 let ratio = f32::from(SQUARE_SIZE / 2) / bounds.height().max(bounds.width());
150 let transform = transform.pre_scale(ratio, ratio).post_translate(
151 (SQUARE_SIZE_F32 / 2.0) - (ratio * bounds.width() / 2.0),
152 (SQUARE_SIZE_F32 / 2.0) - (ratio * bounds.height() / 2.0),
153 );
154
155 pixmap.fill_path(path, &paint, Default::default(), transform, None);
156 }
157 }
158
159 if let Some(winner_info) = board.get_winner_info() {
161 draw_winning_line(&mut pixmap, stroke, paint, winner_info)
162 .context("failed to draw winning line")?;
163 }
164
165 let draw_end = Instant::now();
166 info!("board draw time: {:?}", draw_end - draw_start);
167
168 let encode_start = Instant::now();
169 let img = pixmap.encode_png().context("failed to encode board")?;
170 let encode_end = Instant::now();
171
172 info!("board png encode time: {:?}", encode_end - encode_start);
173
174 Ok(img)
175 }
176
177 pub(crate) async fn render_board_async(
179 &self,
180 board: tic_tac_toe::Board,
181 ) -> anyhow::Result<Vec<u8>> {
182 let _permit = self.render_semaphore.acquire().await?;
184 let self_clone = self.clone();
185 tokio::task::spawn_blocking(move || self_clone.render_board(board)).await?
186 }
187}
188
189fn draw_winning_line(
191 pixmap: &mut Pixmap,
192 mut stroke: Stroke,
193 mut paint: Paint<'_>,
194 winner_info: tic_tac_toe::WinnerInfo,
195) -> anyhow::Result<()> {
196 stroke.width = 10.0;
197 paint.set_color_rgba8(48, 48, 48, 255);
198
199 let start_index = winner_info.start_tile_index();
200 let start = usize::from(start_index);
201 let mut start_x = ((start % 3) * SQUARE_SIZE_USIZE + (SQUARE_SIZE_USIZE / 2)) as f32;
202 let mut start_y = ((start / 3) * SQUARE_SIZE_USIZE + (SQUARE_SIZE_USIZE / 2)) as f32;
203
204 let end_index = winner_info.end_tile_index();
205 let end = usize::from(end_index);
206 let mut end_x = ((end % 3) * SQUARE_SIZE_USIZE + (SQUARE_SIZE_USIZE / 2)) as f32;
207 let mut end_y = ((end / 3) * SQUARE_SIZE_USIZE + (SQUARE_SIZE_USIZE / 2)) as f32;
208
209 match winner_info.win_type {
210 tic_tac_toe::WinType::Horizontal => {
211 start_x -= SQUARE_SIZE_F32 / 4.0;
212 end_x += SQUARE_SIZE_F32 / 4.0;
213 }
214 tic_tac_toe::WinType::Vertical => {
215 start_y -= SQUARE_SIZE_F32 / 4.0;
216 end_y += SQUARE_SIZE_F32 / 4.0;
217 }
218 tic_tac_toe::WinType::Diagonal => {
219 start_x -= SQUARE_SIZE_F32 / 4.0;
220 start_y -= SQUARE_SIZE_F32 / 4.0;
221 end_x += SQUARE_SIZE_F32 / 4.0;
222 end_y += SQUARE_SIZE_F32 / 4.0;
223 }
224 tic_tac_toe::WinType::AntiDiagonal => {
225 start_x += SQUARE_SIZE_F32 / 4.0;
226 start_y -= SQUARE_SIZE_F32 / 4.0;
227 end_x -= SQUARE_SIZE_F32 / 4.0;
228 end_y += SQUARE_SIZE_F32 / 4.0;
229 }
230 }
231
232 let mut path_builder = PathBuilder::new();
233 path_builder.move_to(start_x, start_y);
234 path_builder.line_to(end_x, end_y);
235 let path = path_builder
236 .finish()
237 .context("failed to draw winning line")?;
238
239 pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
240
241 Ok(())
242}
243
244#[derive(Debug)]
246pub(crate) struct SkiaBuilder(PathBuilder);
247
248impl SkiaBuilder {
249 pub(crate) fn new() -> Self {
251 Self(Default::default())
252 }
253
254 pub(crate) fn into_path(self) -> Option<Path> {
256 let mut path = self.0.finish()?;
257
258 let bounds = path.bounds();
260 let transform = Transform::from_scale(1.0, -1.0)
261 .post_translate(-bounds.x(), bounds.y() + bounds.height());
262 path = path.transform(transform)?;
263
264 Some(path)
265 }
266}
267
268impl Default for SkiaBuilder {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274impl OutlineBuilder for SkiaBuilder {
275 fn move_to(&mut self, x: f32, y: f32) {
276 self.0.move_to(x, y);
277 }
278
279 fn line_to(&mut self, x: f32, y: f32) {
280 self.0.line_to(x, y);
281 }
282
283 fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
284 self.0.quad_to(x1, y1, x, y);
285 }
286
287 fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
288 self.0.cubic_to(x1, y1, x2, y2, x, y);
290 }
291
292 fn close(&mut self) {
293 self.0.close();
294 }
295}
296
297#[cfg(test)]
298mod test {
299 use super::*;
300 use tic_tac_toe::Team;
301
302 #[test]
303 fn render_board() {
304 let renderer = Renderer::new().expect("failed to make renderer");
305 let board = tic_tac_toe::Board::new()
306 .set(0, Some(Team::X))
307 .set(4, Some(Team::X))
308 .set(8, Some(Team::X));
309 let img = renderer.render_board(board).expect("failed to render");
310 std::fs::write("ttt-render-test.png", img).expect("failed to save");
311 }
312}