mod audio;
#[cfg(target_os = "android")]
pub mod android;
mod drawable;
mod glyph_cache;
pub mod graphics;
mod image;
mod image_raw;
pub mod model;
mod input;
mod key;
mod mouse;
mod packer;
mod platform;
mod platform_events;
mod pt;
mod shader_opts;
mod text;
mod texture;
mod touch;
mod window;
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::rc::Rc;
use std::time::Duration;
#[cfg(not(target_os = "android"))]
use winit::event_loop::EventLoop;
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
use console_error_panic_hook;
use crate::graphics::Graphics;
use crate::drawable::{DrawCommand, DrawCommand3D};
pub use drawable::{DrawOption, DrawOption3D};
pub use image::{Bounds, Image};
pub use model::Model;
pub use input::InputManager;
pub use key::Key;
pub use mouse::MouseButton;
pub use pt::Pt;
pub use shader_opts::ShaderOpts;
pub use text::Text;
pub use touch::{TouchInfo, TouchPhase};
#[cfg(target_os = "android")]
pub use android_activity::AndroidApp;
pub use platform_events::PlatformEvent;
#[derive(Debug, Clone)]
pub struct SoundOptions {
pub volume: f32,
pub fade_in: Duration,
pub fade_out: Option<Duration>,
pub start_paused: bool,
}
impl Default for SoundOptions {
fn default() -> Self {
Self {
volume: 1.0,
fade_in: Duration::ZERO,
fade_out: None,
start_paused: false,
}
}
}
#[derive(Debug, Clone)]
pub struct WindowConfig {
pub title: String,
pub width: Pt,
pub height: Pt,
pub resizable: bool,
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
pub canvas_id: Option<String>,
pub transparent: bool,
}
impl Default for WindowConfig {
fn default() -> Self {
Self {
title: "spot".to_string(),
width: Pt(800.0),
height: Pt(600.0),
resizable: true,
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
canvas_id: None,
transparent: false,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct DrawState {
pub position: [Pt; 2],
pub clip: Option<[Pt; 4]>,
pub shader_id: Option<u32>,
pub shader_opts: Option<ShaderOpts>,
pub layer: i32,
}
impl Default for DrawState {
fn default() -> Self {
Self {
position: [Pt(0.0), Pt(0.0)],
clip: None,
shader_id: None,
shader_opts: None,
layer: 0,
}
}
}
#[derive(Debug)]
pub struct Context {
draw_list: Vec<DrawCommand>,
draw_list_3d: Vec<DrawCommand3D>,
input: InputManager,
scale_factor: f64,
window_logical_size: (Pt, Pt),
resources: ResourceMap,
state_stack: Vec<DrawState>,
current_state: DrawState,
last_image_opts: HashMap<u32, LastImageDrawInfo>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LastImageDrawInfo {
pub(crate) opts: DrawOption,
}
#[derive(Default)]
struct ResourceMap {
inner: HashMap<TypeId, Rc<dyn Any>>,
}
impl std::fmt::Debug for ResourceMap {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResourceMap")
.field("len", &self.inner.len())
.finish()
}
}
impl Context {
pub fn new() -> Self {
Self {
draw_list: Vec::new(),
draw_list_3d: Vec::new(),
input: InputManager::new(),
scale_factor: 1.0,
window_logical_size: (Pt(0.0), Pt(0.0)),
resources: ResourceMap::default(),
state_stack: Vec::new(),
current_state: DrawState::default(),
last_image_opts: HashMap::new(),
}
}
pub fn set_window_logical_size(&mut self, width: Pt, height: Pt) {
let w = Pt(width.0.max(0.0));
let h = Pt(height.0.max(0.0));
self.window_logical_size = (w, h);
}
pub fn window_logical_size(&self) -> (Pt, Pt) {
self.window_logical_size
}
pub fn vw(&self, percent: f32) -> Pt {
let (w, _) = self.window_logical_size;
let p = if percent.is_finite() { percent } else { 0.0 };
Pt::from((w.as_f32() * (p / 100.0)) as f32)
}
pub fn vh(&self, percent: f32) -> Pt {
let (_, h) = self.window_logical_size;
let p = if percent.is_finite() { percent } else { 0.0 };
Pt::from((h.as_f32() * (p / 100.0)) as f32)
}
pub fn insert_resource<T: Any>(&mut self, value: Rc<T>) {
self.resources
.inner
.insert(TypeId::of::<T>(), value as Rc<dyn Any>);
}
pub fn get_resource<T: Any>(&self) -> Option<Rc<T>> {
self.resources
.inner
.get(&TypeId::of::<T>())
.cloned()
.and_then(|v| Rc::downcast::<T>(v).ok())
}
pub fn remove_resource<T: Any>(&mut self) -> Option<Rc<T>> {
self.resources
.inner
.remove(&TypeId::of::<T>())
.and_then(|v| Rc::downcast::<T>(v).ok())
}
pub fn set_ambient_light(&mut self, color: [f32; 4]) {
crate::with_graphics(|g| {
g.scene_globals.ambient_color = color;
});
}
pub fn set_light(&mut self, index: usize, position: [f32; 4], color: [f32; 4]) {
crate::with_graphics(|g| {
if index < 4 {
g.scene_globals.lights[index] = crate::graphics::Light {
position,
color,
};
}
});
}
pub fn set_camera_pos(&mut self, pos: [f32; 3]) {
crate::with_graphics(|g| {
g.scene_globals.camera_pos = [pos[0], pos[1], pos[2], 1.0];
});
}
pub(crate) fn insert_resource_dyn(&mut self, type_id: TypeId, value: Rc<dyn Any>) {
self.resources.inner.insert(type_id, value);
}
pub(crate) fn remove_resource_dyn(&mut self, type_id: TypeId) {
self.resources.inner.remove(&type_id);
}
pub fn create_skin(&self, bones: Vec<crate::graphics::Bone>, matrices: Vec<[[f32; 4]; 4]>) -> u32 {
crate::with_graphics(|g| g.create_skin(bones, matrices)).unwrap_or(0)
}
pub fn update_bone_matrices(&self, skin_id: u32, matrices: &[[[f32; 4]; 4]]) {
crate::with_graphics(|g| g.update_bone_matrices(skin_id, matrices));
}
pub(crate) fn begin_frame(&mut self) {
self.draw_list.clear();
self.draw_list_3d.clear();
self.state_stack.clear();
self.current_state = DrawState::default();
self.last_image_opts.clear();
}
pub fn input(&self) -> &InputManager {
&self.input
}
pub fn input_mut(&mut self) -> &mut InputManager {
&mut self.input
}
pub(crate) fn set_scale_factor(&mut self, scale_factor: f64) {
self.scale_factor = scale_factor;
}
pub fn scale_factor(&self) -> f64 {
self.scale_factor
}
pub(crate) fn push(&mut self, mut drawable: DrawCommand) {
match &mut drawable {
DrawCommand::Image(_id, opts, shader_id, shader_opts, _) => {
*opts = opts.apply_state(&self.current_state);
*opts = opts.with_layer(opts.layer() + self.current_state.layer);
if *shader_id == 0 {
if let Some(parent_shader_id) = self.current_state.shader_id {
*shader_id = parent_shader_id;
if let Some(parent_shader_opts) = self.current_state.shader_opts {
*shader_opts = parent_shader_opts;
}
}
}
}
DrawCommand::Text(_, opts) => {
*opts = opts.apply_state(&self.current_state);
*opts = opts.with_layer(opts.layer() + self.current_state.layer);
}
}
if let DrawCommand::Image(id, opts, _, _, size) = &drawable {
let pos = opts.position();
let scale = opts.scale();
let rot = opts.rotation();
let w = size[0].as_f32() * scale[0];
let h = size[1].as_f32() * scale[1];
let (vw, vh) = self.window_logical_size;
let screen_w = vw.as_f32();
let screen_h = vh.as_f32();
let is_visible = if rot == 0.0 {
let x0 = pos[0].as_f32();
let y0 = pos[1].as_f32();
let x1 = x0 + w;
let y1 = y0 + h;
let min_x = x0.min(x1);
let max_x = x0.max(x1);
let min_y = y0.min(y1);
let max_y = y0.max(y1);
!(max_x < 0.0 || min_x > screen_w || max_y < 0.0 || min_y > screen_h)
} else {
let c = rot.cos();
let s = rot.sin();
let x2 = w * c;
let y2 = w * s;
let x3 = -h * s;
let y3 = h * c;
let x4 = x2 + x3;
let y4 = y2 + y3;
let min_x = 0.0f32.min(x2).min(x3).min(x4);
let max_x = 0.0f32.max(x2).max(x3).max(x4);
let min_y = 0.0f32.min(y2).min(y3).min(y4);
let max_y = 0.0f32.max(y2).max(y3).max(y4);
!(pos[0].as_f32() + max_x < 0.0
|| pos[0].as_f32() + min_x > screen_w
|| pos[1].as_f32() + max_y < 0.0
|| pos[1].as_f32() + min_y > screen_h)
};
if !is_visible {
if std::env::var("SPOT_DEBUG_CULL").is_ok() {
eprintln!(
"[spot][cull] image id={} at {:?} (size {:?}) is culled (screen: {:?})",
id,
pos,
[w, h],
self.window_logical_size
);
}
return;
}
self.last_image_opts
.insert(*id, LastImageDrawInfo { opts: *opts });
}
if std::env::var("SPOT_DEBUG_DRAW").is_ok() {
match &drawable {
DrawCommand::Image(id, opts, shader_id, _shader_opts, _) => {
eprintln!(
"[spot][debug] draw image id={} shader_id={} pos={:?} clip={:?}",
id,
shader_id,
opts.position(),
opts.get_clip()
);
}
DrawCommand::Text(_text, opts) => {
eprintln!(
"[spot][debug] draw text pos={:?} clip={:?}",
opts.position(),
opts.get_clip()
);
}
}
}
self.draw_list.push(drawable);
}
pub(crate) fn push_3d(&mut self, drawable: DrawCommand3D) {
self.draw_list_3d.push(drawable);
}
fn current_draw_state(&self) -> DrawState {
self.current_state
}
pub(crate) fn last_image_draw_info(&self, image_id: u32) -> Option<LastImageDrawInfo> {
self.last_image_opts.get(&image_id).copied()
}
fn push_state(&mut self, state: DrawState) {
self.state_stack.push(self.current_state);
self.current_state.position[0] += state.position[0];
self.current_state.position[1] += state.position[1];
self.current_state.layer += state.layer;
if let Some(new_clip_abs) = state.clip {
let merged_clip = if let Some(old_clip_abs) = self.current_state.clip {
let x = old_clip_abs[0].as_f32().max(new_clip_abs[0].as_f32());
let y = old_clip_abs[1].as_f32().max(new_clip_abs[1].as_f32());
let right = (old_clip_abs[0].as_f32() + old_clip_abs[2].as_f32())
.min(new_clip_abs[0].as_f32() + new_clip_abs[2].as_f32());
let bottom = (old_clip_abs[1].as_f32() + old_clip_abs[3].as_f32())
.min(new_clip_abs[1].as_f32() + new_clip_abs[3].as_f32());
let w = (right - x).max(0.0);
let h = (bottom - y).max(0.0);
Some([Pt::from(x), Pt::from(y), Pt::from(w), Pt::from(h)])
} else {
Some(new_clip_abs)
};
self.current_state.clip = merged_clip;
}
if let Some(sid) = state.shader_id {
self.current_state.shader_id = Some(sid);
}
if let Some(sopts) = state.shader_opts {
self.current_state.shader_opts = Some(sopts);
}
}
fn pop_state(&mut self) {
if let Some(prev_state) = self.state_stack.pop() {
self.current_state = prev_state;
}
}
pub(crate) fn draw_list(&self) -> &[DrawCommand] {
&self.draw_list
}
}
pub fn key_down(context: &Context, key: Key) -> bool {
context.input().key_down(key)
}
pub fn key_pressed(context: &Context, key: Key) -> bool {
context.input().key_pressed(key)
}
pub fn key_released(context: &Context, key: Key) -> bool {
context.input().key_released(key)
}
pub fn mouse_button_down(context: &Context, button: MouseButton) -> bool {
context.input().mouse_down(button)
}
pub fn mouse_button_pressed(context: &Context, button: MouseButton) -> bool {
context.input().mouse_pressed(button)
}
pub fn mouse_button_released(context: &Context, button: MouseButton) -> bool {
context.input().mouse_released(button)
}
pub fn mouse_button_pressed_position(context: &Context, button: MouseButton) -> Option<(Pt, Pt)> {
if mouse_button_pressed(context, button) {
cursor_position(context)
} else {
None
}
}
pub fn window_size(context: &Context) -> (Pt, Pt) {
context.window_logical_size()
}
pub fn cursor_position(context: &Context) -> Option<(Pt, Pt)> {
context.input().cursor_position()
}
pub fn text_input_enabled(context: &Context) -> bool {
context.input().text_input_enabled()
}
pub fn set_text_input_enabled(context: &mut Context, enabled: bool) {
context.input_mut().set_text_input_enabled(enabled);
}
pub fn text_input(context: &Context) -> &str {
context.input().text_input()
}
pub fn get_input(context: &Context) -> &str {
context.input().text_input()
}
pub fn touches(context: &Context) -> &[TouchInfo] {
context.input().touches()
}
#[cfg(feature = "sensors")]
pub fn gyroscope(context: &Context) -> Option<[f32; 3]> {
context.input().gyroscope()
}
#[cfg(feature = "sensors")]
pub fn accelerometer(context: &Context) -> Option<[f32; 3]> {
context.input().accelerometer()
}
#[cfg(feature = "sensors")]
pub fn magnetometer(context: &Context) -> Option<[f32; 3]> {
context.input().magnetometer()
}
#[cfg(feature = "sensors")]
pub fn rotation(context: &Context) -> Option<[f32; 4]> {
context.input().rotation()
}
#[cfg(feature = "sensors")]
pub fn step_count(context: &Context) -> Option<f32> {
context.input().step_count()
}
#[cfg(feature = "sensors")]
pub fn step_detected(context: &Context) -> bool {
context.input().step_detected()
}
pub fn poll_platform_events(_context: &Context) -> Vec<PlatformEvent> {
platform_events::poll_events()
}
pub fn push_platform_event(event: PlatformEvent) {
platform_events::push_event(event);
}
pub fn touch_down(context: &Context) -> bool {
!context.input().touches().is_empty()
}
pub fn ime_preedit(context: &Context) -> Option<&str> {
context.input().ime_preedit()
}
pub fn register_image_shader(wgsl_source: &str) -> u32 {
with_graphics(|g| g.register_image_shader(wgsl_source)).unwrap_or(0)
}
pub fn register_model_shader(wgsl_source: &str) -> u32 {
with_graphics(|g| g.register_model_shader(wgsl_source)).unwrap_or(0)
}
pub fn register_font(font_data: Vec<u8>) -> u32 {
with_graphics(|g| g.register_font(font_data)).unwrap_or(0)
}
pub fn get_registered_font(font_id: u32) -> Option<Vec<u8>> {
with_graphics(|g| g.get_font(font_id).cloned()).flatten()
}
pub fn unregister_font(font_id: u32) {
with_graphics(|g| g.unregister_font(font_id));
}
pub fn compress_assets() {
with_graphics(|g| {
let _ = g.compress_assets();
});
}
pub fn load_asset(path: &str) -> anyhow::Result<Vec<u8>> {
#[cfg(target_os = "android")]
{
use std::ffi::CString;
if let Some(app) = crate::android::get_app() {
let mut normalized_path = path;
if normalized_path.starts_with("./") {
normalized_path = &normalized_path[2..];
}
if normalized_path.starts_with("assets/") {
normalized_path = &normalized_path[7..];
}
let asset_path = CString::new(normalized_path)?;
let mut asset = app
.asset_manager()
.open(&asset_path)
.ok_or_else(|| anyhow::anyhow!("Failed to open asset: {}", normalized_path))?;
return Ok(asset.buffer()?.to_vec());
}
}
Ok(std::fs::read(path)?)
}
pub fn set_background_transparent(context: &Context, transparent: bool) {
let _ = context; with_graphics(|g| g.set_transparent(transparent));
}
pub fn is_background_transparent(context: &Context) -> bool {
let _ = context;
with_graphics(|g| g.transparent()).unwrap_or(false)
}
pub fn register_sound(bytes: Vec<u8>) -> u32 {
platform::with_audio(|a| a.register_sound(bytes)).unwrap_or(0)
}
pub fn play_sound(sound_id: u32, options: SoundOptions) -> Option<u64> {
let opts = audio::PlayOptions {
volume: options.volume,
fade_in: options.fade_in,
fade_out: options.fade_out,
start_paused: options.start_paused,
};
platform::with_audio(|a| a.play_registered_sound_with_options(sound_id, opts)).flatten()
}
pub fn play_sound_simple(sound_id: u32) -> Option<u64> {
platform::with_audio(|a| {
a.play_registered_sound_with_options(sound_id, audio::PlayOptions::default())
})
.flatten()
}
pub fn pause_sound(play_id: u64) {
platform::with_audio(|a| a.pause_play_id(play_id));
}
pub fn resume_sound(play_id: u64) {
platform::with_audio(|a| a.resume_play_id(play_id));
}
pub fn stop_sound(play_id: u64) {
platform::with_audio(|a| a.stop_play_id(play_id));
}
pub fn fade_in_sound(play_id: u64, duration: Duration) {
platform::with_audio(|a| a.fade_in_play_id(play_id, duration));
}
pub fn fade_out_sound(play_id: u64, duration: Duration) {
platform::with_audio(|a| a.fade_out_play_id(play_id, duration));
}
pub fn set_sound_volume(play_id: u64, volume: f32) {
platform::with_audio(|a| a.set_volume_play_id(play_id, volume));
}
pub fn is_sound_playing(play_id: u64) -> bool {
platform::with_audio(|a| a.is_playing_play_id(play_id)).unwrap_or(false)
}
pub fn unregister_sound(sound_id: u32) {
platform::with_audio(|a| a.unregister_sound(sound_id));
}
pub fn play_sine(freq: f32, volume: f32) -> Option<u64> {
platform::with_audio(|a| a.play_sine(freq, volume)).flatten()
}
type SceneFactory = Box<dyn Fn(&mut Context) -> Box<dyn Spot> + Send + Sync>;
pub(crate) struct ScenePayload {
pub(crate) type_id: TypeId,
pub(crate) value: Rc<dyn Any>,
}
pub(crate) struct ScenePayloadTypeId(pub(crate) TypeId);
pub(crate) struct SceneSwitchRequest {
pub(crate) factory: SceneFactory,
pub(crate) payload: Option<ScenePayload>,
}
use std::cell::RefCell;
thread_local! {
static SCENE_SWITCH_REQUEST: RefCell<Option<SceneSwitchRequest>> = const { RefCell::new(None) };
static QUIT_REQUEST: RefCell<bool> = const { RefCell::new(false) };
}
pub fn with_graphics<R>(f: impl FnOnce(&mut Graphics) -> R) -> Option<R> {
platform::with_graphics(f)
}
fn request_scene_switch<F>(factory: F)
where
F: Fn(&mut Context) -> Box<dyn Spot> + Send + Sync + 'static,
{
SCENE_SWITCH_REQUEST.with(|request| {
*request.borrow_mut() = Some(SceneSwitchRequest {
factory: Box::new(factory),
payload: None,
});
});
}
fn request_scene_switch_with<F>(factory: F, payload: ScenePayload)
where
F: Fn(&mut Context) -> Box<dyn Spot> + Send + Sync + 'static,
{
SCENE_SWITCH_REQUEST.with(|request| {
*request.borrow_mut() = Some(SceneSwitchRequest {
factory: Box::new(factory),
payload: Some(payload),
});
});
}
pub(crate) fn take_scene_switch_request() -> Option<SceneSwitchRequest> {
SCENE_SWITCH_REQUEST.with(|request| request.borrow_mut().take())
}
pub fn quit() {
QUIT_REQUEST.with(|request| *request.borrow_mut() = true);
}
pub(crate) fn take_quit_request() -> bool {
QUIT_REQUEST.with(|request| request.replace(false))
}
#[cfg(not(target_os = "android"))]
pub fn run<T: Spot + 'static>(window: WindowConfig) {
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
{
console_error_panic_hook::set_once();
}
let event_loop = EventLoop::new().expect("failed to create winit EventLoop");
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
let mut app = window::App::new_wasm::<T>(window.clone(), window.canvas_id.clone());
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
let mut app = window::App::new::<T>(window);
event_loop.run_app(&mut app).expect("event loop error");
}
#[cfg(target_os = "android")]
pub fn run<T: Spot + 'static>(
window: WindowConfig,
app: AndroidApp,
) {
let mut app_impl = window::App::new::<T>(window);
app_impl.run(app);
}
pub fn switch_scene<T: Spot + 'static>() {
request_scene_switch(|ctx| Box::new(T::initialize(ctx)));
}
pub fn switch_scene_with<T: Spot + 'static, P: Any>(payload: P) {
request_scene_switch_with(
|ctx| Box::new(T::initialize(ctx)),
ScenePayload {
type_id: TypeId::of::<P>(),
value: Rc::new(payload),
},
);
}
pub trait Spot {
fn initialize(context: &mut Context) -> Self
where
Self: Sized;
fn draw(&mut self, context: &mut Context);
fn update(&mut self, context: &mut Context, dt: Duration);
fn resumed(&mut self, _context: &mut Context) {}
fn suspended(&mut self, _context: &mut Context) {}
fn remove(&self);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_image_culling_flip() {
let mut context = Context::new();
context.set_window_logical_size(Pt::from(800.0), Pt::from(600.0));
let img_id = 1u32;
let img_size = [Pt::from(100.0), Pt::from(100.0)];
let opts = DrawOption::default().with_position([Pt::from(100.0), Pt::from(100.0)]);
context.push(DrawCommand::Image(
img_id,
opts,
0,
ShaderOpts::default(),
img_size,
));
assert_eq!(context.draw_list.len(), 1, "Normal image should be visible");
context.draw_list.clear();
let opts = DrawOption::default()
.with_position([Pt::from(100.0), Pt::from(100.0)])
.with_scale([-1.0, 1.0]);
context.push(DrawCommand::Image(
img_id,
opts,
0,
ShaderOpts::default(),
img_size,
));
assert_eq!(
context.draw_list.len(),
1,
"Flipped H image at 100 should be visible (covers 0-100)"
);
context.draw_list.clear();
let opts = DrawOption::default()
.with_position([Pt::from(-0.1), Pt::from(100.0)])
.with_scale([-1.0, 1.0]);
context.push(DrawCommand::Image(
img_id,
opts,
0,
ShaderOpts::default(),
img_size,
));
assert_eq!(
context.draw_list.len(),
0,
"Flipped H image at -0.1 should be culled (covers -100 to -0.1)"
);
context.draw_list.clear();
let opts = DrawOption::default()
.with_position([Pt::from(100.0), Pt::from(100.0)])
.with_scale([1.0, -1.0]);
context.push(DrawCommand::Image(
img_id,
opts,
0,
ShaderOpts::default(),
img_size,
));
assert_eq!(
context.draw_list.len(),
1,
"Flipped V image at 100 should be visible (covers 0-100 in Y)"
);
context.draw_list.clear();
let opts = DrawOption::default()
.with_position([Pt::from(100.0), Pt::from(100.0)])
.with_scale([-1.0, -1.0]);
context.push(DrawCommand::Image(
img_id,
opts,
0,
ShaderOpts::default(),
img_size,
));
assert_eq!(
context.draw_list.len(),
1,
"Both-flipped image at 100,100 should be visible"
);
context.draw_list.clear();
}
}