Skip to main content

spottedcat/
lib.rs

1//! # spottedcat
2//!
3//! Spottedcat is a lightweight cross-platform 2D/3D game engine built with Rust and wgpu.
4//! It provides a simple API for rendering, input, audio, text, and scene management across desktop, web, iOS, and Android.
5//!
6//! The engine uses a decoupled main loop with a fixed-step update (UPS) for logic and a variable refresh rate (FPS) for rendering,
7//! combined with built-in state interpolation for smooth visuals.
8//!
9//! ## Basic Example
10//!
11//! ```rust,no_run
12//! use spottedcat::{Context, Spot, Image, DrawOption, Pt, WindowConfig};
13//! use std::time::Duration;
14//!
15//! struct MyApp {
16//!     image: Image,
17//! }
18//!
19//! impl Spot for MyApp {
20//!     fn initialize(ctx: &mut Context) -> Self {
21//!         // Create an image from raw RGBA8 data
22//!         let rgba = vec![255u8; 64 * 64 * 4]; // Red square
23//!         let image = Image::new(ctx, Pt::from(64.0), Pt::from(64.0), &rgba)
24//!             .expect("Failed to create image");
25//!         Self { image }
26//!     }
27//!
28//!     fn update(&mut self, _ctx: &mut Context, _dt: Duration) {
29//!         // Handle logic here
30//!     }
31//!
32//!     fn draw(&mut self, ctx: &mut Context, screen: Image) {
33//!         let (w, h) = spottedcat::window_size(ctx);
34//!         
35//!         // Draw image at center
36//!         let opts = DrawOption::default()
37//!             .with_position([w / 2.0, h / 2.0])
38//!             .with_scale([2.0, 2.0]);
39//!             
40//!         screen.draw(ctx, &self.image, opts);
41//!     }
42//!
43//!     fn remove(&mut self, _ctx: &mut Context) {}
44//! }
45//!
46//! fn main() {
47//!     spottedcat::run::<MyApp>(WindowConfig {
48//!         title: "SpottedCat Example".to_string(),
49//!         ..Default::default()
50//!     });
51//! }
52//! ```
53
54#[cfg(target_os = "android")]
55pub mod android;
56mod assets;
57mod audio;
58mod context;
59mod context_3d;
60mod controls;
61mod drawable;
62mod drawable_3d;
63#[cfg(feature = "effects")]
64mod fog;
65mod glyph_cache;
66mod graphics;
67pub mod image;
68mod image_raw;
69mod input;
70mod key;
71mod launch;
72pub mod math;
73#[cfg(feature = "model-3d")]
74pub mod model;
75mod mouse;
76mod platform;
77mod platform_events;
78mod pt;
79mod scenes;
80mod shader_opts;
81mod sound;
82mod splash;
83pub mod text;
84
85mod touch;
86#[cfg(any(feature = "utils", feature = "model-3d", feature = "gltf"))]
87pub mod utils;
88mod window;
89
90#[cfg(target_os = "android")]
91pub use android_activity::AndroidApp;
92pub use assets::*;
93pub use context::Context;
94pub use controls::*;
95pub use drawable::{DrawOption, Drawable};
96#[cfg(feature = "model-3d")]
97pub use drawable_3d::DrawOption3D;
98#[cfg(feature = "effects")]
99pub use fog::{FogBackgroundSettings, FogSamplingSettings, FogSettings};
100
101pub use image::{Bounds, Image};
102pub use input::InputManager;
103pub use key::Key;
104pub use launch::{WindowConfig, run};
105#[cfg(feature = "model-3d")]
106pub use model::Model;
107pub use mouse::MouseButton;
108pub use platform_events::PlatformEvent;
109pub use pt::Pt;
110pub use scenes::{Spot, quit, switch_scene, switch_scene_with};
111pub use shader_opts::ShaderOpts;
112pub use sound::*;
113pub use splash::OneShotSplash;
114pub use text::Text;
115pub use graphics::texture::Texture;
116pub use touch::{TouchInfo, TouchPhase};
117
118// --- Functional API ---
119
120/// Registers a TTF/OTF font for text rendering and returns a unique font ID.
121pub fn register_font(ctx: &mut Context, font_data: Vec<u8>) -> u32 {
122    ctx.register_font(font_data)
123}
124
125/// Registers a custom WGSL fragment shader extension for image rendering.
126///
127/// The shader should define a `user_fs_hook()` function to modify the output color.
128pub fn register_shader(ctx: &mut Context, user_functions: &str) -> u32 {
129    ctx.register_image_shader(user_functions)
130}
131
132/// Creates a logical point value ([`Pt`][crate::Pt]) from a scalar.
133pub fn pt(x: f32) -> Pt {
134    Pt::from(x)
135}
136
137pub fn unregister_font(ctx: &mut Context, font_id: u32) {
138    assets::unregister_font(ctx, font_id);
139}
140
141/// Registers a sound from raw bytes and returns a unique sound ID.
142pub fn register_sound(ctx: &mut Context, bytes: Vec<u8>) -> Option<u32> {
143    sound::register_sound(ctx, bytes)
144}
145
146/// Unregisters a sound and frees its resources.
147pub fn unregister_sound(ctx: &mut Context, sound_id: u32) {
148    sound::unregister_sound(ctx, sound_id)
149}
150
151/// Forces pending asset compression work to run immediately.
152
153
154#[cfg(feature = "model-3d")]
155/// Sets camera eye, target and up vectors in one call.
156pub fn set_camera(ctx: &mut Context, eye: [f32; 3], target: [f32; 3], up: [f32; 3]) {
157    ctx.set_camera(eye, target, up);
158}
159
160#[cfg(feature = "model-3d")]
161/// Returns current camera eye position.
162pub fn camera_position(ctx: &Context) -> [f32; 3] {
163    ctx.camera_position()
164}
165
166#[cfg(feature = "model-3d")]
167/// Sets camera eye position.
168pub fn set_camera_pos(ctx: &mut Context, pos: [f32; 3]) {
169    ctx.set_camera_pos(pos);
170}
171
172#[cfg(feature = "model-3d")]
173/// Sets camera target vector.
174pub fn set_camera_target(ctx: &mut Context, x: f32, y: f32, z: f32) {
175    ctx.set_camera_target(x, y, z);
176}
177
178#[cfg(feature = "model-3d")]
179/// Sets camera up vector.
180pub fn set_camera_up(ctx: &mut Context, x: f32, y: f32, z: f32) {
181    ctx.set_camera_up(x, y, z);
182}
183
184#[cfg(feature = "model-3d")]
185/// Sets camera vertical field of view in degrees.
186pub fn set_camera_fovy(ctx: &mut Context, fovy_degrees: f32) {
187    ctx.set_camera_fovy(fovy_degrees);
188}
189
190#[cfg(feature = "model-3d")]
191/// Sets ambient light color.
192pub fn set_ambient(ctx: &mut Context, color: [f32; 4]) {
193    ctx.set_ambient(color);
194}
195
196#[cfg(feature = "model-3d")]
197/// Sets a PBR light (up to 4 lights).
198pub fn set_light(ctx: &mut Context, index: usize, position: [f32; 4], color: [f32; 4]) {
199    ctx.set_light(index, position, color);
200}
201
202#[cfg(all(feature = "model-3d", feature = "effects"))]
203/// Sets global fog settings.
204pub fn set_fog(ctx: &mut Context, settings: FogSettings) {
205    ctx.set_fog(settings);
206}
207
208#[cfg(all(feature = "model-3d", feature = "effects"))]
209/// Resets global fog to the default disabled state.
210pub fn clear_fog(ctx: &mut Context) {
211    ctx.clear_fog();
212}
213
214#[cfg(feature = "model-3d")]
215/// Sets the ambient light color for the active 3D scene.
216pub fn set_ambient_light(ctx: &mut Context, color: [f32; 4]) {
217    ctx.set_ambient_light(color);
218}
219
220/// Sets the window's logical size.
221pub fn set_window_size(ctx: &mut Context, width: Pt, height: Pt) {
222    ctx.set_window_logical_size(width, height);
223}
224
225/// Returns the window's logical size as a tuple of `(width, height)`.
226pub fn window_size(ctx: &Context) -> (Pt, Pt) {
227    ctx.window_logical_size()
228}
229
230/// Inserts or replaces a resource of type T in the context.
231pub fn insert_resource<T: std::any::Any>(ctx: &mut Context, value: std::rc::Rc<T>) {
232    ctx.insert_resource(value)
233}
234
235/// Returns a resource of type T from the context, if it exists.
236pub fn get_resource<T: std::any::Any>(ctx: &Context) -> Option<std::rc::Rc<T>> {
237    ctx.get_resource::<T>()
238}
239
240/// Removes and returns a resource of type T from the context, if it exists.
241pub fn take_resource<T: std::any::Any>(ctx: &mut Context) -> Option<std::rc::Rc<T>> {
242    ctx.take_resource::<T>()
243}
244
245/// Returns the window's scale factor (DPI).
246pub fn scale_factor(ctx: &Context) -> f64 {
247    ctx.scale_factor()
248}
249
250/// Returns a percentage of the window width as Pt.
251pub fn vw(ctx: &Context, percent: f32) -> Pt {
252    ctx.vw(percent)
253}
254
255/// Returns a percentage of the window height as Pt.
256pub fn vh(ctx: &Context, percent: f32) -> Pt {
257    ctx.vh(percent)
258}
259
260/// Returns true if the specified key is currently held down.
261pub fn key_down(ctx: &Context, key: Key) -> bool {
262    ctx.input().key_down(key)
263}
264
265/// Returns true if the specified key was just pressed this frame.
266pub fn key_pressed(ctx: &Context, key: Key) -> bool {
267    ctx.input().key_pressed(key)
268}
269
270/// Returns true if the specified mouse button is currently held down.
271pub fn mouse_down(ctx: &Context, btn: MouseButton) -> bool {
272    ctx.input().mouse_down(btn)
273}
274
275/// Returns true if the specified mouse button was just pressed this frame.
276pub fn mouse_pressed(ctx: &Context, btn: MouseButton) -> bool {
277    ctx.input().mouse_pressed(btn)
278}
279
280/// Returns the current mouse position in logical coordinates.
281pub fn mouse_pos(ctx: &Context) -> Option<(Pt, Pt)> {
282    ctx.input().cursor_position()
283}
284
285/// Alias for [`mouse_pos`].
286pub fn cursor_position(ctx: &Context) -> Option<(Pt, Pt)> {
287    mouse_pos(ctx)
288}
289
290/// Returns a slice of active touch points.
291pub fn touches(ctx: &Context) -> &[TouchInfo] {
292    ctx.input().touches()
293}
294
295/// Requests a window title update.
296pub fn set_window_title(ctx: &mut Context, title: impl Into<String>) {
297    ctx.set_window_title(title);
298}
299
300/// Requests cursor visibility update.
301pub fn set_cursor_visible(ctx: &mut Context, visible: bool) {
302    ctx.set_cursor_visible(visible);
303}
304
305/// Requests fullscreen toggle.
306pub fn set_fullscreen(ctx: &mut Context, enabled: bool) {
307    ctx.set_fullscreen(enabled);
308}
309
310/// Scene switch helper that keeps the ctx-first API shape.
311pub fn switch_scene_ctx<T: Spot + 'static>(_ctx: &mut Context) {
312    switch_scene::<T>();
313}
314
315/// Scene switch with payload helper that keeps the ctx-first API shape.
316pub fn switch_scene_with_ctx<T: Spot + 'static, P: std::any::Any>(_ctx: &mut Context, payload: P) {
317    switch_scene_with::<T, P>(payload);
318}
319
320/// Quit helper that keeps the ctx-first API shape.
321pub fn quit_ctx(_ctx: &mut Context) {
322    quit();
323}
324
325// --- Utilities & Time ---
326
327/// Returns the time elapsed since the last frame.
328pub fn delta_time(ctx: &Context) -> std::time::Duration {
329    ctx.delta_time()
330}
331
332/// Returns the time elapsed since the last frame in seconds.
333///
334/// This is a convenience for `delta_time(ctx).as_secs_f32()`.
335pub fn dt(ctx: &Context) -> f32 {
336    ctx.delta_time().as_secs_f32()
337}
338
339/// Returns total elapsed time since engine start.
340pub fn total_elapsed(ctx: &Context) -> std::time::Duration {
341    ctx.total_elapsed()
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::drawable::{DrawCommand, ImageCommand};
348
349    #[test]
350    fn test_image_culling_flip() {
351        let mut ctx = Context::new();
352        ctx.set_window_logical_size(Pt::from(800.0), Pt::from(600.0));
353
354        let img_id = 1u32;
355        let img_size = [Pt::from(100.0), Pt::from(100.0)];
356
357        let opts = DrawOption::default().with_position([Pt::from(100.0), Pt::from(100.0)]);
358        ctx.push(DrawCommand::Image(Box::new(ImageCommand {
359            id: img_id,
360            target_texture_id: 0,
361            opts,
362            shader_id: 0,
363            shader_opts: ShaderOpts::default(),
364            size: img_size,
365        })));
366        assert_eq!(
367            ctx.runtime.draw_list.len(),
368            1,
369            "Normal image should be visible"
370        );
371        ctx.runtime.draw_list.clear();
372
373        let opts = DrawOption::default()
374            .with_position([Pt::from(100.0), Pt::from(100.0)])
375            .with_scale([-1.0, 1.0]);
376        ctx.push(DrawCommand::Image(Box::new(ImageCommand {
377            id: img_id,
378            target_texture_id: 0,
379            opts,
380            shader_id: 0,
381            shader_opts: ShaderOpts::default(),
382            size: img_size,
383        })));
384        assert_eq!(
385            ctx.runtime.draw_list.len(),
386            1,
387            "Flipped H image at 100 should be visible (covers 0-100)"
388        );
389        ctx.runtime.draw_list.clear();
390
391        let opts = DrawOption::default()
392            .with_position([Pt::from(-0.1), Pt::from(100.0)])
393            .with_scale([-1.0, 1.0]);
394        ctx.push(DrawCommand::Image(Box::new(ImageCommand {
395            id: img_id,
396            target_texture_id: 0,
397            opts,
398            shader_id: 0,
399            shader_opts: ShaderOpts::default(),
400            size: img_size,
401        })));
402        assert_eq!(
403            ctx.runtime.draw_list.len(),
404            0,
405            "Flipped H image at -0.1 should be culled (covers -100 to -0.1)"
406        );
407        ctx.runtime.draw_list.clear();
408
409        let opts = DrawOption::default()
410            .with_position([Pt::from(100.0), Pt::from(100.0)])
411            .with_scale([1.0, -1.0]);
412        ctx.push(DrawCommand::Image(Box::new(ImageCommand {
413            id: img_id,
414            target_texture_id: 0,
415            opts,
416            shader_id: 0,
417            shader_opts: ShaderOpts::default(),
418            size: img_size,
419        })));
420        assert_eq!(
421            ctx.runtime.draw_list.len(),
422            1,
423            "Flipped V image at 100 should be visible (covers 0-100 in Y)"
424        );
425        ctx.runtime.draw_list.clear();
426
427        let opts = DrawOption::default()
428            .with_position([Pt::from(100.0), Pt::from(100.0)])
429            .with_scale([-1.0, -1.0]);
430        ctx.push(DrawCommand::Image(Box::new(ImageCommand {
431            id: img_id,
432            target_texture_id: 0,
433            opts,
434            shader_id: 0,
435            shader_opts: ShaderOpts::default(),
436            size: img_size,
437        })));
438        assert_eq!(
439            ctx.runtime.draw_list.len(),
440            1,
441            "Both-flipped image at 100,100 should be visible"
442        );
443        ctx.runtime.draw_list.clear();
444    }
445
446    #[test]
447    fn test_render_target_registration() {
448        let mut ctx = Context::new();
449        let texture = Texture::new_render_target(&mut ctx, Pt::from(100.0), Pt::from(200.0));
450        let image = texture.view();
451
452        assert_eq!(texture.width(), Pt::from(100.0));
453        assert_eq!(texture.height(), Pt::from(200.0));
454        assert_eq!(image.width(), Pt::from(100.0));
455        assert_eq!(image.height(), Pt::from(200.0));
456
457        let texture_entry = ctx
458            .registry
459            .textures
460            .get(texture.id() as usize)
461            .unwrap()
462            .as_ref()
463            .unwrap();
464        assert!(texture_entry.is_render_target());
465        assert_eq!(texture_entry.pixel_width, 100);
466        assert_eq!(texture_entry.pixel_height, 200);
467    }
468}