mod loader;
pub use loader::*;
pub mod front_matter;
pub mod pico8;
use crate::{
pico8::{Pico8Asset, Pico8Handle, canvas::N9Canvas},
run::RunState,
};
use bevy::asset::{AssetLoadFailedEvent, AssetPath};
use bevy::prelude::*;
use bevy::window::{PresentMode, PrimaryWindow, Window, WindowResizeConstraints, WindowResolution};
#[cfg(feature = "scripting")]
use bevy_mod_scripting::{
asset::ScriptAsset,
core::{event::Recipients, script::ScriptComponent},
};
use merge2::Merge;
use serde::{Deserialize, Serialize};
#[cfg(feature = "gameboy")]
pub mod gameboy;
pub const DEFAULT_CANVAS_SIZE: UVec2 = UVec2::splat(128);
pub const DEFAULT_SCREEN_SIZE: UVec2 = UVec2::splat(512);
pub const DEFAULT_DECORATIONS: bool = true;
pub(crate) fn plugin(app: &mut App) {
app.add_systems(
Update,
(
update_asset,
warn_load_failed::<crate::pico8::SpriteSheet>,
warn_load_failed::<crate::pico8::Pico8Asset>,
),
)
.add_plugins(loader::plugin);
app.add_plugins(pico8::plugin);
#[cfg(feature = "gameboy")]
app.add_plugins(gameboy::plugin);
app.init_resource::<KeyBindings>().init_asset::<Config>();
}
pub fn headless_config_load_plugin(app: &mut App) {
app.init_asset::<Config>()
.init_asset::<crate::pico8::Pico8Asset>()
.init_asset::<crate::pico8::SpriteSheet>()
.init_asset::<bevy::text::Font>()
.add_plugins(loader::plugin);
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, Merge, PartialEq, Reflect, Asset)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
pub name: Option<String>,
pub frames_per_second: Option<u8>,
pub description: Option<String>,
pub template: Option<String>,
pub author: Option<String>,
pub license: Option<String>,
pub screen: Option<Screen>,
pub defaults: Option<Defaults>,
pub bit_depth: Option<u8>,
#[serde(default, rename = "palette")]
pub palettes: Vec<Palette>,
#[serde(default, rename = "font")]
pub fonts: Vec<Font>,
#[serde(default, rename = "sprite-sheet")]
pub sprite_sheets: Vec<SpriteSheet>,
#[serde(default)]
#[cfg(feature = "scripting")]
pub scripts: Vec<String>,
#[serde(default, rename = "audio-bank")]
pub audio_banks: Vec<AudioBank>,
#[serde(default, rename = "map")]
pub maps: Vec<SpriteMap>,
#[serde(default, rename = "mesh")]
pub meshes: Vec<Mesh>,
pub key_bindings: Option<KeyBindings>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, Merge, PartialEq, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
pub struct Defaults {
pub initial_palette: Option<usize>,
pub initial_pen_color: Option<usize>,
pub initial_transparent_color: Option<usize>,
pub clear_color: Option<usize>,
pub font_size: Option<f32>,
pub time_to_live: Option<u8>,
pub canvas_bit_depth: Option<u8>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, Merge, PartialEq, Resource, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
pub struct KeyBindings {
pub players: Vec<PlayerKeyBindings>,
}
impl KeyBindings {
pub fn pico8() -> Self {
use bevy::prelude::KeyCode::*;
Self {
players: vec![
PlayerKeyBindings {
left: vec![ArrowLeft],
right: vec![ArrowRight],
up: vec![ArrowUp],
down: vec![ArrowDown],
o: vec![KeyZ, KeyC, KeyN, NumpadSubtract],
x: vec![KeyX, KeyV, KeyM, Numpad8],
},
PlayerKeyBindings {
left: vec![KeyS],
right: vec![KeyF],
up: vec![KeyE],
down: vec![KeyD],
o: vec![ShiftLeft, Tab],
x: vec![KeyA, KeyQ],
},
],
}
}
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, Merge, PartialEq, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
pub struct PlayerKeyBindings {
#[serde(default)]
pub left: Vec<KeyCode>,
#[serde(default)]
pub right: Vec<KeyCode>,
#[serde(default)]
pub up: Vec<KeyCode>,
#[serde(default)]
pub down: Vec<KeyCode>,
#[serde(default)]
pub o: Vec<KeyCode>,
#[serde(default)]
pub x: Vec<KeyCode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Reflect)]
#[serde(untagged)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
pub enum AudioBank {
Paths { paths: Vec<String> },
Path { path: String },
}
impl AudioBank {
pub fn paths(&self) -> impl Iterator<Item = &str> {
let (a, b) = match self {
AudioBank::Paths { paths: v } => (Some(v), None),
AudioBank::Path { path: s } => (None, Some(s.as_str())),
};
a.into_iter().flatten().map(|x| x.as_str()).chain(b)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(untagged)]
pub enum ResizeConstraints {
MatchScreen { match_screen: bool },
Rect { rect: URect },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Merge, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
pub struct Screen {
#[merge(skip)]
pub canvas_size: UVec2,
pub screen_size: Option<UVec2>,
pub resize_constraints: Option<ResizeConstraints>,
pub decorations: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, Merge, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
pub struct SpriteSheet {
pub path: String,
pub sprite_size: Option<UVec2>,
pub sprite_counts: Option<UVec2>,
pub padding: Option<UVec2>,
pub offset: Option<UVec2>,
pub index_color: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Reflect)]
pub struct SpriteMap {
path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(untagged)]
pub enum Font {
Default { default: bool },
Path {
path: String,
height: Option<f32>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Merge, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
pub struct Palette {
pub path: String,
pub row: Option<u32>,
pub column: Option<u32>,
pub extract_index: Option<bool>,
}
impl Palette {
#[allow(clippy::wrong_self_convention)]
fn into_settings(&self) -> Option<crate::pico8::PaletteSettings> {
use crate::pico8::PaletteSettings;
if self.extract_index.unwrap_or(false) {
Some(PaletteSettings::FromIndex)
} else if let Some(row) = self.row {
Some(PaletteSettings::FromRow(row))
} else if let Some(column) = self.column {
Some(PaletteSettings::FromColumn(column))
} else {
Some(PaletteSettings::FromImage)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Reflect)]
#[serde(deny_unknown_fields)]
#[serde(untagged)]
pub enum Mesh {
Path { path: String },
Cuboid { cuboid: [f32; 3] },
}
fn warn_load_failed<A>(mut reader: MessageReader<AssetLoadFailedEvent<A>>)
where
A: Asset,
{
for e in reader.read() {
warn!(
"{} failed to load at path '{}': {}",
std::any::type_name::<A>(),
e.path,
e.error
);
}
}
fn apply_config_to_world_and_window(
config: &Config,
commands: &mut Commands,
primary_windows: &mut Query<&mut Window, With<PrimaryWindow>>,
defaults: Option<&crate::pico8::Defaults>,
) {
config.was_plugin_build(commands);
let window_spec = config.to_window();
if let Some(mut window) = primary_windows.iter_mut().next() {
trace!("Updating window");
window.title = window_spec.title.clone();
debug!("prior window resolution {:?}", &window.resolution);
debug!("new window resolution {:?}", &window_spec.resolution);
window.resolution.set(
window_spec.resolution.physical_width() as f32,
window_spec.resolution.physical_height() as f32,
);
window.resize_constraints = window_spec.resize_constraints;
window.decorations = window_spec.decorations;
} else {
trace!("Spawning window");
debug!("spawn with window resolution {:?}", &window_spec.resolution);
commands.spawn((window_spec, PrimaryWindow));
if let Some(defaults) = defaults {
commands.insert_resource(crate::pico8::Pico8State::from(defaults));
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn update_asset
(
mut reader: MessageReader<AssetEvent<crate::pico8::Pico8Asset>>,
assets: Res<Assets<crate::pico8::Pico8Asset>>,
configs: Res<Assets<Config>>,
mut next_state: ResMut<NextState<RunState>>,
mut pico8_handle: Option<ResMut<Pico8Handle>>,
mut commands: Commands,
mut primary_windows: Query<&mut Window, With<PrimaryWindow>>,
#[cfg(feature = "scripting")] _scripts: ResMut<Assets<ScriptAsset>>,
) {
for e in reader.read() {
info!("update asset event {e:?}");
match e {
AssetEvent::LoadedWithDependencies { id } => {
if let Some(pico8_handle) = &mut pico8_handle {
if let Some(pico8_asset) = assets.get(*id) {
if pico8_handle.handle.id() != *id {
warn!("Script loaded but does not match Pico8Handle.");
continue;
}
#[cfg(feature = "scripting")]
{
if !pico8_asset.scripts.is_empty() && pico8_handle.main_script.is_none()
{
let entity = commands
.spawn((
Name::new("scripts"),
ScriptComponent(pico8_asset.scripts.clone()),
))
.id();
info!("Add scripts to entity {}", &entity);
pico8_handle.main_script = Some(Recipients::AllContexts);
}
}
if let Some(config) = configs.get(&pico8_asset.config) {
let defaults = config.defaults
.as_ref()
.map(crate::pico8::Defaults::from_config);
apply_config_to_world_and_window(
config,
&mut commands,
&mut primary_windows,
defaults.as_ref()
);
if let Some(defaults) = defaults {
commands.insert_resource(defaults);
}
}
info!("Goto Loaded state");
next_state.set(RunState::Loaded);
} else {
debug!("Pico8Asset not available for loaded {:?}.", id);
}
} else {
warn!("Script loaded but no Pico8Handle is loaded.");
}
}
#[cfg(feature = "watcher")]
AssetEvent::Modified { id } => {
if let Some(pico8_handle) = &pico8_handle {
if pico8_handle.handle.id() != *id {
continue;
}
if let Some(pico8_asset) = assets.get(*id)
&& let Some(config) = configs.get(&pico8_asset.config)
{
info!("Config changed, re-applying to window and resources");
apply_config_to_world_and_window(
config,
&mut commands,
&mut primary_windows,
None,
);
commands.insert_resource(crate::pico8::DespawnClearablesOnNextClear(true));
}
}
}
_ => {}
}
}
}
pub fn load_and_insert_pico8(
path: impl Into<AssetPath<'static>>,
) -> impl Fn(Res<AssetServer>, Commands) {
let path = path.into();
move |asset_server: Res<AssetServer>, mut commands: Commands| {
let pico8_asset: Handle<Pico8Asset> = asset_server.load::<Pico8Asset>(&path);
commands.insert_resource(Pico8Handle::from(pico8_asset));
}
}
pub fn run_pico8_when_loaded(
state: Res<State<RunState>>,
mut next_state: ResMut<NextState<RunState>>,
) {
match **state {
RunState::Loaded => {
info!("Goto Init state.");
next_state.set(RunState::Init);
}
RunState::Init => {
info!("Goto Run state.");
next_state.set(RunState::Run);
}
_ => (),
}
}
pub fn pause_pico8_when_loaded(
state: Res<State<RunState>>,
mut next_state: ResMut<NextState<RunState>>,
) {
match **state {
RunState::Loaded => {
next_state.set(RunState::Init);
}
RunState::Init => {
next_state.set(RunState::Pause);
}
_ => (),
}
}
impl std::str::FromStr for Config {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(toml::from_str::<Config>(s)?)
}
}
impl Config {
pub fn pico8() -> Self {
Config {
frames_per_second: Some(30),
screen: Some(Screen {
canvas_size: UVec2::splat(128),
screen_size: Some(UVec2::splat(512)),
decorations: Some(true),
resize_constraints: None,
}),
palettes: vec![Palette {
path: crate::config::pico8::PALETTE.into(),
row: None,
column: None,
extract_index: None,
}],
fonts: vec![Font::Path {
path: pico8::FONT.into(),
height: None,
}],
defaults: Some(Defaults {
font_size: Some(5.0),
initial_pen_color: Some(6),
clear_color: Some(0),
initial_transparent_color: Some(0),
time_to_live: Some(1),
..default()
}),
key_bindings: Some(KeyBindings::pico8()),
..default()
}
}
#[cfg(feature = "gameboy")]
pub fn gameboy() -> Self {
Config {
frames_per_second: Some(60),
screen: Some(Screen {
canvas_size: UVec2::new(160, 144),
screen_size: Some(4 * UVec2::new(160, 144)),
decorations: Some(true),
resize_constraints: None,
}),
palettes: vec![Palette {
path: gameboy::PALETTES.into(),
row: Some(15),
column: None,
extract_index: None,
}],
fonts: vec![Font::Path {
path: gameboy::FONT.into(),
height: None,
}],
defaults: Some(Defaults {
font_size: Some(5.0),
initial_pen_color: Some(1),
clear_color: Some(3),
initial_transparent_color: None,
time_to_live: Some(1),
..default()
}),
key_bindings: Some(KeyBindings::pico8()),
..default()
}
}
pub fn inject_template(&mut self, template_name: Option<&str>) -> Result<(), ConfigError> {
if let Some(template_name) = template_name.or(self.template.as_deref()) {
let mut template = match template_name {
#[cfg(feature = "gameboy")]
"gameboy" => Config::gameboy(),
"pico8" => Config::pico8(),
x => {
return Err(ConfigError::InvalidTemplate(x.to_string()));
}
};
self.merge(&mut template)
}
Ok(())
}
pub fn with_default_font(mut self) -> Self {
if self.fonts.is_empty() {
self.fonts.push(Font::Default { default: true });
}
self
}
pub fn to_window(&self) -> Window {
let screen_size = self
.screen
.as_ref()
.and_then(|s| s.screen_size)
.unwrap_or(DEFAULT_SCREEN_SIZE);
let decorations = self
.screen
.as_ref()
.and_then(|s| s.decorations)
.unwrap_or(DEFAULT_DECORATIONS);
let resize_constraints = self
.screen
.as_ref()
.and_then(|s| s.resize_constraints.clone())
.unwrap_or(ResizeConstraints::MatchScreen {
match_screen: false,
});
let resolution = WindowResolution::new(screen_size.x, screen_size.y);
let resize_constraints = match resize_constraints {
ResizeConstraints::MatchScreen { match_screen: true } => WindowResizeConstraints {
min_width: resolution.width(),
max_width: resolution.width(),
min_height: resolution.height(),
max_height: resolution.height(),
},
ResizeConstraints::MatchScreen {
match_screen: false,
} => WindowResizeConstraints::default(),
ResizeConstraints::Rect { rect } => WindowResizeConstraints {
min_width: rect.min.x as f32,
max_width: rect.max.x as f32,
min_height: rect.min.y as f32,
max_height: rect.max.y as f32,
},
};
Window {
title: self.name.as_deref().unwrap_or("Nano-9").into(),
present_mode: PresentMode::AutoVsync,
resize_constraints,
decorations,
resolution,
..default()
}
}
pub(crate) fn was_plugin_build(&self, commands: &mut Commands) {
commands.insert_resource(
self.defaults
.as_ref()
.map(crate::pico8::Defaults::from_config)
.unwrap_or_default(),
);
commands.insert_resource(self.key_bindings.clone().unwrap_or_default());
let canvas_size: UVec2 = self
.screen
.as_ref()
.map(|s| s.canvas_size)
.unwrap_or(DEFAULT_CANVAS_SIZE);
commands.insert_resource(N9Canvas {
size: canvas_size,
..default()
});
if let Some(fps) = self.frames_per_second {
info!("Set FPS {}", &fps);
#[cfg(feature = "framepace")]
{
let limiter = bevy_framepace::Limiter::from_framerate(fps as f64);
commands.insert_resource(
bevy_framepace::FramepaceSettings::default().with_limiter(limiter),
);
}
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[cfg(feature = "level")]
use std::path::PathBuf;
#[test]
fn test_config_0() {
let config: Config = toml::from_str(
r#"
sprite-sheet = []
"#,
)
.unwrap();
assert_eq!(config.sprite_sheets.len(), 0);
assert!(config.screen.is_none());
}
#[test]
fn test_config_1() {
let config: Config = toml::from_str(
r#"
[[sprite-sheet]]
path = "sprites.png"
sprite-size = [8, 8]
"#,
)
.unwrap();
assert_eq!(config.sprite_sheets.len(), 1);
assert_eq!(config.sprite_sheets[0].path, "sprites.png");
assert_eq!(config.sprite_sheets[0].sprite_size, Some(UVec2::splat(8)));
}
#[test]
fn test_palete_0() {
let config: Config = toml::from_str(
r#"
[[palette]]
path = "sprites.png"
"#,
)
.unwrap();
assert_eq!(
config.palettes,
vec![Palette {
path: "sprites.png".into(),
row: None,
column: None,
extract_index: None,
}]
);
}
#[test]
fn test_config_2() {
let config: Config = toml::from_str(
r#"
[screen]
canvas-size = [128,128]
[[sprite-sheet]]
path = "sprites.png"
sprite-size = [8, 8]
"#,
)
.unwrap();
assert_eq!(
config.screen.map(|s| s.canvas_size),
Some(UVec2::splat(128))
);
assert_eq!(config.sprite_sheets.len(), 1);
assert_eq!(config.sprite_sheets[0].path, "sprites.png");
assert_eq!(config.sprite_sheets[0].sprite_size, Some(UVec2::splat(8)));
}
#[test]
fn test_config_3() {
let config: Config = toml::from_str(
r#"
[[audio-bank]]
paths = ["blah.p8"]
"#,
)
.unwrap();
assert_eq!(config.audio_banks.len(), 1);
assert_eq!(
config.audio_banks[0].paths().collect::<Vec<_>>(),
vec!["blah.p8"],
);
}
#[test]
fn test_config_4() {
let config: Config = toml::from_str(
r#"
[[audio-bank]]
paths = [
"blah.mp3"
]
"#,
)
.unwrap();
assert_eq!(config.audio_banks.len(), 1);
assert_eq!(
config.audio_banks[0].paths().collect::<Vec<_>>(),
vec!["blah.mp3"],
);
}
#[test]
fn test_config_5() {
let config: Config = toml::from_str(
r#"
[[font]]
path = "blah.tff"
[[font]]
path = "dee.tff"
height = 3.0
[[font]]
default = true
"#,
)
.unwrap();
assert_eq!(config.fonts.len(), 3);
}
#[test]
#[cfg(feature = "level")]
fn test_config_6() {
let config: Config = toml::from_str(
r#"
[[map]]
path = "blah.ldtk"
[[map]]
path = "blah.p8"
"#,
)
.unwrap();
assert_eq!(config.maps.len(), 2);
assert_eq!(config.maps[0].path, PathBuf::from("blah.ldtk"));
}
#[test]
fn test_config_7() {
assert!(
toml::from_str::<Config>(
r#"
frames_per_second = 70
blah = 7
"#,
)
.is_err()
);
}
#[test]
fn test_config_8() {
let config: Config = toml::from_str(
r#"
[screen]
canvas-size = [128, 128]
screen-size = [512, 512]
"#,
)
.unwrap();
assert!(config.screen.is_some());
let screen = config.screen.unwrap();
assert_eq!(screen.canvas_size, UVec2::splat(128));
assert_eq!(screen.screen_size, Some(UVec2::splat(512)));
}
#[test]
fn test_config_9() {
let config: Config = toml::from_str(
r#"
[defaults]
font-size = 5
initial-pen-color = 6
initial-transparent-color = 7
clear-color = 8
"#,
)
.unwrap();
assert!(config.defaults.is_some());
let defaults = config.defaults.unwrap();
assert_eq!(defaults.font_size.unwrap(), 5.0);
assert_eq!(defaults.initial_pen_color.unwrap(), 6);
assert_eq!(defaults.initial_transparent_color.unwrap(), 7);
assert_eq!(defaults.clear_color.unwrap(), 8);
}
#[test]
fn test_inject0() {
let mut a = Config::default();
a.inject_template(Some("pico8")).unwrap();
let mut b = Config::default();
b.merge(&mut Config::pico8());
assert_eq!(a, b);
}
#[test]
fn test_inject1() {
let mut a = Config {
frames_per_second: Some(60),
..default()
};
a.frames_per_second = Some(60);
a.inject_template(Some("pico8")).unwrap();
let mut b = Config {
frames_per_second: Some(60),
..default()
};
b.merge(&mut Config::pico8());
assert_eq!(a, b);
}
#[test]
fn test_image_palette0() {
let config: Config = toml::from_str(
r#"
[[sprite-sheet]]
path = "sprites.png"
index-color = true
"#,
)
.unwrap();
assert_eq!(config.sprite_sheets[0].index_color, Some(true));
}
#[test]
fn test_image_palette1() {
let config: Config = toml::from_str(
r#"
[[sprite-sheet]]
path = "sprites.png"
"#,
)
.unwrap();
assert_eq!(config.sprite_sheets[0].index_color, None);
}
#[test]
fn test_image_palette2() {
let config: Config = toml::from_str(
r#"
[[sprite-sheet]]
path = "sprites.png"
sprite-size = [16,16]
"#,
)
.unwrap();
assert_eq!(config.sprite_sheets[0].path, "sprites.png");
}
#[test]
fn test_mesh0() {
let config: Config = toml::from_str(
r#"
[[mesh]]
path = "teapot.glb"
"#,
)
.unwrap();
assert_eq!(
config.meshes[0],
Mesh::Path {
path: "teapot.glb".into()
}
);
}
#[test]
fn test_mesh1() {
let config: Config = toml::from_str(
r#"
[[mesh]]
cuboid = [0.1, 0.2, 0.3]
"#,
)
.unwrap();
assert_eq!(
config.meshes[0],
Mesh::Cuboid {
cuboid: [0.1, 0.2, 0.3]
}
);
}
#[test]
fn test_unexpected_name() {
assert!(
toml::from_str::<Config>(
r#"
[[mesh]]
cuboid = [0.1, 0.2, 0.3]
bad_name = 1
"#,
)
.is_err()
);
}
}