1#![forbid(unsafe_code)]
6#![warn(missing_docs)]
7
8use glam::Vec2;
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12use jugar_core::{Anchor, Camera, Position, Rect, ScaleMode};
13
14#[derive(Error, Debug, Clone, PartialEq, Eq)]
16pub enum RenderError {
17 #[error("Invalid viewport: {width}x{height}")]
19 InvalidViewport {
20 width: u32,
22 height: u32,
24 },
25}
26
27pub type Result<T> = core::result::Result<T, RenderError>;
29
30#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
32pub enum AspectRatio {
33 MobilePortrait,
35 MobileLandscape,
37 #[default]
39 Standard,
40 Ultrawide,
42 SuperUltrawide,
44 Custom(f32, f32),
46}
47
48impl AspectRatio {
49 #[must_use]
51 pub fn ratio(self) -> f32 {
52 match self {
53 Self::MobilePortrait => 9.0 / 16.0,
54 Self::MobileLandscape | Self::Standard => 16.0 / 9.0,
55 Self::Ultrawide => 21.0 / 9.0,
56 Self::SuperUltrawide => 32.0 / 9.0,
57 Self::Custom(w, h) => w / h,
58 }
59 }
60
61 #[must_use]
63 pub fn from_dimensions(width: u32, height: u32) -> Self {
64 let ratio = width as f32 / height as f32;
65 if (ratio - 9.0 / 16.0).abs() < 0.1 {
66 Self::MobilePortrait
67 } else if (ratio - 16.0 / 9.0).abs() < 0.1 {
68 Self::Standard
69 } else if (ratio - 21.0 / 9.0).abs() < 0.1 {
70 Self::Ultrawide
71 } else if (ratio - 32.0 / 9.0).abs() < 0.1 {
72 Self::SuperUltrawide
73 } else {
74 Self::Custom(width as f32, height as f32)
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct Viewport {
82 pub width: u32,
84 pub height: u32,
86 pub safe_area: Rect,
88 pub aspect_ratio: AspectRatio,
90}
91
92impl Viewport {
93 #[must_use]
95 pub fn new(width: u32, height: u32) -> Self {
96 let aspect_ratio = AspectRatio::from_dimensions(width, height);
97 let safe_area = calculate_safe_area(width, height);
98 Self {
99 width,
100 height,
101 safe_area,
102 aspect_ratio,
103 }
104 }
105
106 pub fn resize(&mut self, width: u32, height: u32) {
108 self.width = width;
109 self.height = height;
110 self.aspect_ratio = AspectRatio::from_dimensions(width, height);
111 self.safe_area = calculate_safe_area(width, height);
112 }
113
114 #[must_use]
116 pub fn screen_to_world(&self, screen_pos: Vec2, camera: &Camera) -> Vec2 {
117 let center = Vec2::new(self.width as f32 / 2.0, self.height as f32 / 2.0);
118 let offset = screen_pos - center;
119 Vec2::new(
120 camera.position.x + offset.x / camera.zoom,
121 camera.position.y - offset.y / camera.zoom, )
123 }
124
125 #[must_use]
127 pub fn world_to_screen(&self, world_pos: Vec2, camera: &Camera) -> Vec2 {
128 let center = Vec2::new(self.width as f32 / 2.0, self.height as f32 / 2.0);
129 Vec2::new(
130 (world_pos.x - camera.position.x).mul_add(camera.zoom, center.x),
131 (world_pos.y - camera.position.y).mul_add(-camera.zoom, center.y),
132 )
133 }
134
135 #[must_use]
137 pub fn is_visible(&self, world_pos: Vec2, camera: &Camera) -> bool {
138 let screen_pos = self.world_to_screen(world_pos, camera);
139 screen_pos.x >= 0.0
140 && screen_pos.x <= self.width as f32
141 && screen_pos.y >= 0.0
142 && screen_pos.y <= self.height as f32
143 }
144}
145
146impl Default for Viewport {
147 fn default() -> Self {
148 Self::new(1920, 1080)
149 }
150}
151
152fn calculate_safe_area(width: u32, height: u32) -> Rect {
154 let target_ratio = 16.0 / 9.0;
155 let current_ratio = width as f32 / height as f32;
156
157 if current_ratio > target_ratio {
158 let safe_width = height as f32 * target_ratio;
160 let offset = (width as f32 - safe_width) / 2.0;
161 Rect::new(offset, 0.0, safe_width, height as f32)
162 } else {
163 let safe_height = width as f32 / target_ratio;
165 let offset = (height as f32 - safe_height) / 2.0;
166 Rect::new(0.0, offset, width as f32, safe_height)
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub enum RenderCommand {
173 Clear {
175 color: [f32; 4],
177 },
178 DrawSprite {
180 texture_id: u32,
182 position: Position,
184 size: Vec2,
186 source: Option<Rect>,
188 color: [f32; 4],
190 },
191 DrawRect {
193 rect: Rect,
195 color: [f32; 4],
197 },
198}
199
200#[derive(Debug, Default)]
202pub struct RenderQueue {
203 commands: Vec<RenderCommand>,
204}
205
206impl RenderQueue {
207 #[must_use]
209 pub fn new() -> Self {
210 Self::default()
211 }
212
213 pub fn clear(&mut self) {
215 self.commands.clear();
216 }
217
218 pub fn push(&mut self, cmd: RenderCommand) {
220 self.commands.push(cmd);
221 }
222
223 #[must_use]
225 pub fn commands(&self) -> &[RenderCommand] {
226 &self.commands
227 }
228
229 #[must_use]
231 pub fn len(&self) -> usize {
232 self.commands.len()
233 }
234
235 #[must_use]
237 pub fn is_empty(&self) -> bool {
238 self.commands.is_empty()
239 }
240}
241
242#[must_use]
244pub fn calculate_anchored_position(
245 anchor: Anchor,
246 offset: Vec2,
247 element_size: Vec2,
248 viewport: &Viewport,
249 scale_mode: ScaleMode,
250) -> Vec2 {
251 let (ax, ay) = anchor.normalized();
252 let vw = viewport.width as f32;
253 let vh = viewport.height as f32;
254
255 let base_x = vw * ax;
257 let base_y = vh * ay;
258
259 let scale = match scale_mode {
261 ScaleMode::Adaptive => vh.min(vw) / 1080.0, ScaleMode::PixelPerfect | ScaleMode::Fixed => 1.0,
263 };
264
265 Vec2::new(
267 (element_size.x * scale).mul_add(-ax, offset.x.mul_add(scale, base_x)),
268 (element_size.y * scale).mul_add(-ay, offset.y.mul_add(scale, base_y)),
269 )
270}
271
272#[cfg(test)]
273#[allow(clippy::unwrap_used, clippy::expect_used)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_aspect_ratio_standard() {
279 let ratio = AspectRatio::Standard;
280 assert!((ratio.ratio() - 16.0 / 9.0).abs() < 0.01);
281 }
282
283 #[test]
284 fn test_aspect_ratio_ultrawide() {
285 let ratio = AspectRatio::SuperUltrawide;
286 assert!((ratio.ratio() - 32.0 / 9.0).abs() < 0.01);
287 }
288
289 #[test]
290 fn test_aspect_ratio_detection() {
291 assert!(matches!(
292 AspectRatio::from_dimensions(1920, 1080),
293 AspectRatio::Standard
294 ));
295 assert!(matches!(
296 AspectRatio::from_dimensions(5120, 1440),
297 AspectRatio::SuperUltrawide
298 ));
299 }
300
301 #[test]
302 fn test_viewport_safe_area_standard() {
303 let viewport = Viewport::new(1920, 1080);
304 assert!((viewport.safe_area.width - 1920.0).abs() < 1.0);
306 assert!((viewport.safe_area.height - 1080.0).abs() < 1.0);
307 }
308
309 #[test]
310 fn test_viewport_safe_area_ultrawide() {
311 let viewport = Viewport::new(5120, 1440);
312 let expected_width = 1440.0 * 16.0 / 9.0;
314 assert!((viewport.safe_area.width - expected_width).abs() < 1.0);
315 }
316
317 #[test]
318 fn test_screen_to_world_center() {
319 let viewport = Viewport::new(800, 600);
320 let camera = Camera::new();
321
322 let center = Vec2::new(400.0, 300.0);
323 let world = viewport.screen_to_world(center, &camera);
324
325 assert!(world.x.abs() < 0.1);
326 assert!(world.y.abs() < 0.1);
327 }
328
329 #[test]
330 fn test_world_to_screen_roundtrip() {
331 let viewport = Viewport::new(800, 600);
332 let camera = Camera::new();
333
334 let world_pos = Vec2::new(100.0, 50.0);
335 let screen = viewport.world_to_screen(world_pos, &camera);
336 let back = viewport.screen_to_world(screen, &camera);
337
338 assert!((back.x - world_pos.x).abs() < 0.1);
339 assert!((back.y - world_pos.y).abs() < 0.1);
340 }
341
342 #[test]
343 fn test_render_queue() {
344 let mut queue = RenderQueue::new();
345 assert!(queue.is_empty());
346
347 queue.push(RenderCommand::Clear {
348 color: [0.0, 0.0, 0.0, 1.0],
349 });
350 assert_eq!(queue.len(), 1);
351
352 queue.clear();
353 assert!(queue.is_empty());
354 }
355
356 #[test]
357 fn test_anchored_position_top_left() {
358 let viewport = Viewport::new(1920, 1080);
359 let pos = calculate_anchored_position(
360 Anchor::TopLeft,
361 Vec2::new(10.0, 10.0),
362 Vec2::new(100.0, 50.0),
363 &viewport,
364 ScaleMode::Fixed,
365 );
366
367 assert!((pos.x - 10.0).abs() < 1.0);
368 assert!((pos.y - 10.0).abs() < 1.0);
369 }
370
371 #[test]
372 fn test_anchored_position_center() {
373 let viewport = Viewport::new(1920, 1080);
374 let pos = calculate_anchored_position(
375 Anchor::Center,
376 Vec2::ZERO,
377 Vec2::new(100.0, 50.0),
378 &viewport,
379 ScaleMode::Fixed,
380 );
381
382 assert!((pos.x - (960.0 - 50.0)).abs() < 1.0);
384 assert!((pos.y - (540.0 - 25.0)).abs() < 1.0);
385 }
386}