jugar_render/
lib.rs

1//! # jugar-render
2//!
3//! Rendering system for Jugar with responsive camera and resolution-independent canvas.
4
5#![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/// Rendering errors
15#[derive(Error, Debug, Clone, PartialEq, Eq)]
16pub enum RenderError {
17    /// Invalid viewport dimensions
18    #[error("Invalid viewport: {width}x{height}")]
19    InvalidViewport {
20        /// Width
21        width: u32,
22        /// Height
23        height: u32,
24    },
25}
26
27/// Result type for render operations
28pub type Result<T> = core::result::Result<T, RenderError>;
29
30/// Aspect ratio presets
31#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
32pub enum AspectRatio {
33    /// Mobile portrait (9:16)
34    MobilePortrait,
35    /// Mobile landscape (16:9)
36    MobileLandscape,
37    /// Desktop standard (16:9)
38    #[default]
39    Standard,
40    /// Ultrawide (21:9)
41    Ultrawide,
42    /// Super ultrawide (32:9) - 49" monitors
43    SuperUltrawide,
44    /// Custom aspect ratio
45    Custom(f32, f32),
46}
47
48impl AspectRatio {
49    /// Returns the aspect ratio as a float (width/height)
50    #[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    /// Detects aspect ratio from dimensions
62    #[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/// Viewport representing the rendering area
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct Viewport {
82    /// Width in pixels
83    pub width: u32,
84    /// Height in pixels
85    pub height: u32,
86    /// Safe area for gameplay (16:9 within any aspect ratio)
87    pub safe_area: Rect,
88    /// Detected aspect ratio
89    pub aspect_ratio: AspectRatio,
90}
91
92impl Viewport {
93    /// Creates a new viewport
94    #[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    /// Resizes the viewport
107    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    /// Converts screen coordinates to world coordinates
115    #[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, // Y is flipped
122        )
123    }
124
125    /// Converts world coordinates to screen coordinates
126    #[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    /// Checks if a world position is visible
136    #[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
152/// Calculates the 16:9 safe area within any viewport
153fn 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        // Wider than 16:9 - pillarbox
159        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        // Taller than 16:9 - letterbox
164        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/// Render command for batched rendering
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub enum RenderCommand {
173    /// Clear the screen with a color
174    Clear {
175        /// RGBA color
176        color: [f32; 4],
177    },
178    /// Draw a sprite
179    DrawSprite {
180        /// Texture ID
181        texture_id: u32,
182        /// Position
183        position: Position,
184        /// Size
185        size: Vec2,
186        /// Source rectangle (for sprite sheets)
187        source: Option<Rect>,
188        /// Tint color
189        color: [f32; 4],
190    },
191    /// Draw a rectangle
192    DrawRect {
193        /// Rectangle bounds
194        rect: Rect,
195        /// Fill color
196        color: [f32; 4],
197    },
198}
199
200/// Render queue for batched rendering
201#[derive(Debug, Default)]
202pub struct RenderQueue {
203    commands: Vec<RenderCommand>,
204}
205
206impl RenderQueue {
207    /// Creates a new render queue
208    #[must_use]
209    pub fn new() -> Self {
210        Self::default()
211    }
212
213    /// Clears the queue
214    pub fn clear(&mut self) {
215        self.commands.clear();
216    }
217
218    /// Adds a command to the queue
219    pub fn push(&mut self, cmd: RenderCommand) {
220        self.commands.push(cmd);
221    }
222
223    /// Returns the commands
224    #[must_use]
225    pub fn commands(&self) -> &[RenderCommand] {
226        &self.commands
227    }
228
229    /// Returns the number of commands
230    #[must_use]
231    pub fn len(&self) -> usize {
232        self.commands.len()
233    }
234
235    /// Checks if the queue is empty
236    #[must_use]
237    pub fn is_empty(&self) -> bool {
238        self.commands.is_empty()
239    }
240}
241
242/// Calculates UI element position based on anchor and viewport
243#[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    // Calculate base position from anchor
256    let base_x = vw * ax;
257    let base_y = vh * ay;
258
259    // Apply scaling based on mode
260    let scale = match scale_mode {
261        ScaleMode::Adaptive => vh.min(vw) / 1080.0, // Scale based on shortest dimension
262        ScaleMode::PixelPerfect | ScaleMode::Fixed => 1.0,
263    };
264
265    // Calculate final position (centered on anchor point)
266    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        // 16:9 should have full safe area
305        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        // 32:9 should have pillarboxed 16:9 safe area
313        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        // Should be centered
383        assert!((pos.x - (960.0 - 50.0)).abs() < 1.0);
384        assert!((pos.y - (540.0 - 25.0)).abs() < 1.0);
385    }
386}