Added content
This commit is contained in:
parent
b634575791
commit
3150f64bd1
32
lib/content/Cargo.toml
Normal file
32
lib/content/Cargo.toml
Normal file
@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "galactica-content"
|
||||
description = "Galactica's game content parser"
|
||||
categories = { workspace = true }
|
||||
keywords = { workspace = true }
|
||||
version = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
homepage = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
license = { workspace = true }
|
||||
documentation = { workspace = true }
|
||||
readme = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
galactica-util = { workspace = true }
|
||||
galactica-packer = { workspace = true }
|
||||
|
||||
nalgebra = { workspace = true }
|
||||
rhai = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
smartstring = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
lazy_static = { workspace = true }
|
||||
rapier2d = { workspace = true }
|
39
lib/content/src/error.rs
Normal file
39
lib/content/src/error.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use thiserror::Error;
|
||||
use tracing::error;
|
||||
|
||||
pub type ContentLoadResult<T> = Result<T, ContentLoadError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ContentLoadError {
|
||||
/// A generic I/O error
|
||||
#[error("I/O error: {error}")]
|
||||
IoError {
|
||||
#[from]
|
||||
error: std::io::Error,
|
||||
},
|
||||
|
||||
/// Toml deserialization error
|
||||
#[error("TOML deserialization error: {error}")]
|
||||
TomlError {
|
||||
#[from]
|
||||
error: toml::de::Error,
|
||||
},
|
||||
|
||||
#[error("Content dir has multiple config tables")]
|
||||
MultipleConfigTables,
|
||||
|
||||
#[error("Content dir does not have a config table")]
|
||||
MissingConfigTable,
|
||||
|
||||
#[error("Content dir has duplicate key `{key}`")]
|
||||
DuplicateKey { key: String },
|
||||
|
||||
#[error("{msg}")]
|
||||
Generic { msg: String },
|
||||
|
||||
#[error("error while compiling rhai script: {error}")]
|
||||
RhaiCompileError {
|
||||
#[from]
|
||||
error: Box<rhai::EvalAltResult>,
|
||||
},
|
||||
}
|
361
lib/content/src/lib.rs
Normal file
361
lib/content/src/lib.rs
Normal file
@ -0,0 +1,361 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
//! This subcrate is responsible for loading, parsing, validating game content.
|
||||
mod error;
|
||||
mod part;
|
||||
mod spriteautomaton;
|
||||
mod util;
|
||||
|
||||
use error::ContentLoadResult;
|
||||
use galactica_packer::{SpriteAtlas, SpriteAtlasImage};
|
||||
use rhai::ImmutableString;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use smartstring::{LazyCompact, SmartString};
|
||||
use std::{
|
||||
borrow::Borrow,
|
||||
collections::HashMap,
|
||||
fmt::Display,
|
||||
fs::File,
|
||||
io::Read,
|
||||
num::NonZeroU32,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use toml;
|
||||
use tracing::{debug, info, warn};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub use part::*;
|
||||
pub use spriteautomaton::*;
|
||||
|
||||
mod syntax {
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashMap, fmt::Display, hash::Hash};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
config,
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
part::{effect, faction, outfit, ship, sprite, system},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Root {
|
||||
pub ship: Option<HashMap<String, ship::syntax::Ship>>,
|
||||
pub system: Option<HashMap<String, system::syntax::System>>,
|
||||
pub outfit: Option<HashMap<String, outfit::syntax::Outfit>>,
|
||||
pub sprite: Option<HashMap<String, sprite::syntax::Sprite>>,
|
||||
pub faction: Option<HashMap<String, faction::syntax::Faction>>,
|
||||
pub effect: Option<HashMap<String, effect::syntax::Effect>>,
|
||||
pub config: Option<config::syntax::Config>,
|
||||
}
|
||||
|
||||
fn merge_hashmap<K, V>(
|
||||
to: &mut Option<HashMap<K, V>>,
|
||||
mut from: Option<HashMap<K, V>>,
|
||||
) -> ContentLoadResult<()>
|
||||
where
|
||||
K: Hash + Eq + Display,
|
||||
{
|
||||
if to.is_none() {
|
||||
*to = from.take();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(from_inner) = from {
|
||||
let to_inner = to.as_mut().unwrap();
|
||||
for (k, v) in from_inner {
|
||||
if to_inner.contains_key(&k) {
|
||||
return Err(ContentLoadError::DuplicateKey { key: k.to_string() });
|
||||
} else {
|
||||
to_inner.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
impl Root {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ship: None,
|
||||
system: None,
|
||||
outfit: None,
|
||||
sprite: None,
|
||||
faction: None,
|
||||
effect: None,
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: Root) -> ContentLoadResult<()> {
|
||||
debug!(message = "Merging ships");
|
||||
merge_hashmap(&mut self.ship, other.ship)?;
|
||||
debug!(message = "Merging systems");
|
||||
merge_hashmap(&mut self.system, other.system)?;
|
||||
debug!(message = "Merging outfits");
|
||||
merge_hashmap(&mut self.outfit, other.outfit)?;
|
||||
debug!(message = "Merging sprites");
|
||||
merge_hashmap(&mut self.sprite, other.sprite)?;
|
||||
debug!(message = "Merging factions");
|
||||
merge_hashmap(&mut self.faction, other.faction)?;
|
||||
debug!(message = "Merging effects");
|
||||
merge_hashmap(&mut self.effect, other.effect)?;
|
||||
|
||||
if self.config.is_some() {
|
||||
if other.config.is_some() {
|
||||
return Err(ContentLoadError::MultipleConfigTables);
|
||||
}
|
||||
} else {
|
||||
self.config = other.config;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait Build {
|
||||
type InputSyntaxType;
|
||||
|
||||
/// Build a processed System struct from raw serde data
|
||||
fn build(
|
||||
root: Self::InputSyntaxType,
|
||||
build_context: &mut ContentBuildContext,
|
||||
content: &mut Content,
|
||||
) -> ContentLoadResult<()>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
/// The type we use to index content objects
|
||||
/// These are only unique WITHIN each "type" of object!
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
pub struct ContentIndex(Arc<SmartString<LazyCompact>>);
|
||||
|
||||
impl From<ImmutableString> for ContentIndex {
|
||||
fn from(value: ImmutableString) -> Self {
|
||||
Self(Arc::new(value.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ContentIndex> for ImmutableString {
|
||||
fn from(value: ContentIndex) -> Self {
|
||||
ImmutableString::from(value.0.as_ref().clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ContentIndex> for ImmutableString {
|
||||
fn from(value: &ContentIndex) -> Self {
|
||||
let x: &SmartString<LazyCompact> = value.0.borrow();
|
||||
ImmutableString::from(x.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentIndex {
|
||||
/// Make a new ContentIndex from a strign
|
||||
pub fn new(s: &str) -> Self {
|
||||
Self(SmartString::from(s).into())
|
||||
}
|
||||
|
||||
/// Get a &str to this index's content
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'d> Deserialize<'d> for ContentIndex {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'d>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(Self::new(&s))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ContentIndex {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores temporary data while building context objects
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ContentBuildContext {
|
||||
pub effect_index: HashMap<ContentIndex, Arc<Effect>>,
|
||||
}
|
||||
|
||||
impl ContentBuildContext {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
effect_index: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents static game content
|
||||
#[derive(Debug)]
|
||||
pub struct Content {
|
||||
/// Keeps track of which images are in which texture
|
||||
sprite_atlas: SpriteAtlas,
|
||||
|
||||
/// Sprites defined in this game
|
||||
pub sprites: HashMap<ContentIndex, Arc<Sprite>>,
|
||||
|
||||
/// Outfits defined in this game
|
||||
pub outfits: HashMap<ContentIndex, Arc<Outfit>>,
|
||||
|
||||
/// Ships defined in this game
|
||||
pub ships: HashMap<ContentIndex, Arc<Ship>>,
|
||||
|
||||
/// Systems defined in this game
|
||||
pub systems: HashMap<ContentIndex, Arc<System>>,
|
||||
|
||||
/// Factions defined in this game
|
||||
pub factions: HashMap<ContentIndex, Arc<Faction>>,
|
||||
|
||||
/// Effects defined in this game
|
||||
pub effects: HashMap<ContentIndex, Arc<Effect>>,
|
||||
|
||||
/// Game configuration
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
// Loading methods
|
||||
impl Content {
|
||||
fn try_parse(path: &Path) -> ContentLoadResult<syntax::Root> {
|
||||
let mut file_string = String::new();
|
||||
let _ = File::open(path)?.read_to_string(&mut file_string);
|
||||
let file_string = file_string.trim();
|
||||
return Ok(toml::from_str(&file_string)?);
|
||||
}
|
||||
|
||||
/// Load content from a directory.
|
||||
pub fn load_dir(
|
||||
content_root: PathBuf,
|
||||
asset_root: PathBuf,
|
||||
atlas_index: PathBuf,
|
||||
) -> ContentLoadResult<Self> {
|
||||
let mut root = syntax::Root::new();
|
||||
|
||||
for e in WalkDir::new(&content_root)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
if e.metadata().unwrap().is_file() {
|
||||
// TODO: better warnings
|
||||
match e.path().extension() {
|
||||
Some(t) => {
|
||||
if t.to_str() != Some("toml") {
|
||||
warn!("{e:#?} is not a toml file, skipping");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warn!("{e:#?} is not a toml file, skipping");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let path = e.path();
|
||||
info!(message = "Loading content file", ?path);
|
||||
let this_root = Self::try_parse(path)?;
|
||||
debug!(message = "Merging content", ?path);
|
||||
root.merge(this_root)?;
|
||||
}
|
||||
}
|
||||
|
||||
let atlas: SpriteAtlas = {
|
||||
let mut file_string = String::new();
|
||||
let _ = File::open(atlas_index)?.read_to_string(&mut file_string);
|
||||
let file_string = file_string.trim();
|
||||
toml::from_str(&file_string)?
|
||||
};
|
||||
|
||||
let mut build_context = ContentBuildContext::new();
|
||||
|
||||
let mut content = Self {
|
||||
config: {
|
||||
if let Some(c) = root.config {
|
||||
debug!(message = "Parsing config table");
|
||||
c.build(&asset_root, &content_root, &atlas)?
|
||||
} else {
|
||||
return Err(error::ContentLoadError::MissingConfigTable);
|
||||
}
|
||||
},
|
||||
|
||||
sprite_atlas: atlas,
|
||||
systems: HashMap::new(),
|
||||
ships: HashMap::new(),
|
||||
outfits: HashMap::new(),
|
||||
sprites: HashMap::new(),
|
||||
factions: HashMap::new(),
|
||||
effects: HashMap::new(),
|
||||
};
|
||||
|
||||
// TODO: enforce sprite and image limits
|
||||
|
||||
// Order matters. Some content types require another to be fully initialized
|
||||
if root.sprite.is_some() {
|
||||
part::sprite::Sprite::build(
|
||||
root.sprite.take().unwrap(),
|
||||
&mut build_context,
|
||||
&mut content,
|
||||
)?;
|
||||
}
|
||||
|
||||
if root.effect.is_some() {
|
||||
part::effect::Effect::build(
|
||||
root.effect.take().unwrap(),
|
||||
&mut build_context,
|
||||
&mut content,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Order below this line does not matter
|
||||
if root.ship.is_some() {
|
||||
part::ship::Ship::build(root.ship.take().unwrap(), &mut build_context, &mut content)?;
|
||||
}
|
||||
if root.outfit.is_some() {
|
||||
part::outfit::Outfit::build(
|
||||
root.outfit.take().unwrap(),
|
||||
&mut build_context,
|
||||
&mut content,
|
||||
)?;
|
||||
}
|
||||
if root.system.is_some() {
|
||||
part::system::System::build(
|
||||
root.system.take().unwrap(),
|
||||
&mut build_context,
|
||||
&mut content,
|
||||
)?;
|
||||
}
|
||||
if root.faction.is_some() {
|
||||
part::faction::Faction::build(
|
||||
root.faction.take().unwrap(),
|
||||
&mut build_context,
|
||||
&mut content,
|
||||
)?;
|
||||
}
|
||||
|
||||
return Ok(content);
|
||||
}
|
||||
}
|
||||
|
||||
// Access methods
|
||||
impl Content {
|
||||
/// Get the list of atlas files we may use
|
||||
pub fn atlas_files(&self) -> &Vec<String> {
|
||||
return &self.sprite_atlas.atlas_list;
|
||||
}
|
||||
|
||||
/// Get sprite atlas metadata
|
||||
pub fn get_atlas(&self) -> &SpriteAtlas {
|
||||
return &self.sprite_atlas;
|
||||
}
|
||||
|
||||
/// Get a texture by its index
|
||||
pub fn get_image(&self, idx: NonZeroU32) -> &SpriteAtlasImage {
|
||||
&self.sprite_atlas.get_by_idx(idx)
|
||||
}
|
||||
}
|
203
lib/content/src/part/config.rs
Normal file
203
lib/content/src/part/config.rs
Normal file
@ -0,0 +1,203 @@
|
||||
use rhai::AST;
|
||||
use std::{collections::HashMap, num::NonZeroU32, path::PathBuf};
|
||||
|
||||
pub(crate) mod syntax {
|
||||
use galactica_packer::SpriteAtlas;
|
||||
use rhai::{Engine, OptimizationLevel};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::error::{ContentLoadError, ContentLoadResult};
|
||||
|
||||
// Raw serde syntax structs.
|
||||
// These are never seen by code outside this crate.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub fonts: Fonts,
|
||||
pub sprite_root: PathBuf,
|
||||
pub starfield: Starfield,
|
||||
pub zoom_min: f32,
|
||||
pub zoom_max: f32,
|
||||
pub ui_scale: f32,
|
||||
pub ui_scene: HashMap<String, PathBuf>,
|
||||
pub start_ui_scene: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
// TODO: clean up build trait
|
||||
pub fn build(
|
||||
self,
|
||||
asset_root: &Path,
|
||||
content_root: &Path,
|
||||
atlas: &SpriteAtlas,
|
||||
) -> ContentLoadResult<super::Config> {
|
||||
for i in &self.fonts.files {
|
||||
if !asset_root.join(i).exists() {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("font file `{i:?}` doesn't exist"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let starfield_density = 0.01;
|
||||
let starfield_size = self.starfield.max_dist * self.zoom_max;
|
||||
let starfield_count = (starfield_size * starfield_density) as i32;
|
||||
|
||||
// 12, because that should be enough to tile any screen.
|
||||
// Starfield squares are tiled to cover the viewport, adapting to any screen ratio.
|
||||
// An insufficient limit will result in some tiles not being drawn
|
||||
let starfield_instance_limit = 12 * starfield_count as u64;
|
||||
|
||||
let starfield_texture = match atlas.get_idx_by_path(&self.starfield.texture) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!(
|
||||
"starfield texture `{:?}` doesn't exist",
|
||||
self.starfield.texture
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut engine = Engine::new_raw();
|
||||
engine.set_optimization_level(OptimizationLevel::Full);
|
||||
engine.set_max_expr_depths(0, 0);
|
||||
let mut ui_scenes = HashMap::new();
|
||||
for (n, p) in self.ui_scene {
|
||||
info!(message = "Loading scene script", script = n);
|
||||
ui_scenes.insert(n.clone(), engine.compile_file(content_root.join(p))?);
|
||||
}
|
||||
|
||||
if !ui_scenes.contains_key(&self.start_ui_scene) {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("starting ui scene `{}` doesn't exist", self.start_ui_scene),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(super::Config {
|
||||
sprite_root: asset_root.join(self.sprite_root),
|
||||
font_files: self
|
||||
.fonts
|
||||
.files
|
||||
.iter()
|
||||
.map(|i| asset_root.join(i))
|
||||
.collect(),
|
||||
|
||||
font_sans: self.fonts.sans,
|
||||
font_serif: self.fonts.serif,
|
||||
font_mono: self.fonts.mono,
|
||||
//font_cursive: self.fonts.cursive,
|
||||
//font_fantasy: self.fonts.fantasy,
|
||||
starfield_max_dist: self.starfield.max_dist,
|
||||
starfield_min_dist: self.starfield.min_dist,
|
||||
starfield_max_size: self.starfield.max_size,
|
||||
starfield_min_size: self.starfield.min_size,
|
||||
starfield_texture,
|
||||
starfield_count,
|
||||
starfield_density,
|
||||
starfield_size,
|
||||
starfield_instance_limit,
|
||||
zoom_max: self.zoom_max,
|
||||
zoom_min: self.zoom_min,
|
||||
ui_scale: self.ui_scale,
|
||||
ui_scenes,
|
||||
start_ui_scene: self.start_ui_scene,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Fonts {
|
||||
pub files: Vec<PathBuf>,
|
||||
pub sans: String,
|
||||
pub serif: String,
|
||||
pub mono: String,
|
||||
//pub cursive: String,
|
||||
//pub fantasy: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Starfield {
|
||||
pub min_size: f32,
|
||||
pub max_size: f32,
|
||||
pub min_dist: f32,
|
||||
pub max_dist: f32,
|
||||
pub texture: PathBuf,
|
||||
}
|
||||
}
|
||||
|
||||
/// Content configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// The directory where all images are stored.
|
||||
/// Image paths are always interpreted relative to this path.
|
||||
/// This is a subdirectory of the asset root.
|
||||
pub sprite_root: PathBuf,
|
||||
|
||||
/// List of font files to load
|
||||
pub font_files: Vec<PathBuf>,
|
||||
|
||||
/// Sans Serif font family name
|
||||
pub font_sans: String,
|
||||
|
||||
/// Serif font family name
|
||||
pub font_serif: String,
|
||||
|
||||
/// Monospace font family name
|
||||
pub font_mono: String,
|
||||
//pub font_cursive: String,
|
||||
//pub font_fantasy: String,
|
||||
/// Min size of starfield sprite, in game units
|
||||
pub starfield_min_size: f32,
|
||||
|
||||
/// Max size of starfield sprite, in game units
|
||||
pub starfield_max_size: f32,
|
||||
|
||||
/// Minimum z-distance of starfield star, in game units
|
||||
pub starfield_min_dist: f32,
|
||||
|
||||
/// Maximum z-distance of starfield star, in game units
|
||||
pub starfield_max_dist: f32,
|
||||
|
||||
/// Index of starfield texture
|
||||
pub starfield_texture: NonZeroU32,
|
||||
|
||||
/// Size of a square starfield tile, in game units.
|
||||
/// A tile of size STARFIELD_Z_MAX * screen-size-in-game-units
|
||||
/// will completely cover a (square) screen.
|
||||
/// This should be big enough to cover the height of the screen at max zoom.
|
||||
pub starfield_size: f32,
|
||||
|
||||
/// Average number of stars per game unit
|
||||
pub starfield_density: f32,
|
||||
|
||||
/// Number of stars in one starfield tile
|
||||
/// Must be positive
|
||||
pub starfield_count: i32,
|
||||
|
||||
// TODO: this shouldn't be here, it depends on graphics implementation
|
||||
/// The maximum number of starfield sprites we can create
|
||||
pub starfield_instance_limit: u64,
|
||||
|
||||
/// Minimum zoom, in game units
|
||||
pub zoom_min: f32,
|
||||
|
||||
/// Maximum zoom,in game units
|
||||
pub zoom_max: f32,
|
||||
|
||||
/// Ui scale factor
|
||||
pub ui_scale: f32,
|
||||
|
||||
/// Ui scene scripts
|
||||
pub ui_scenes: HashMap<String, AST>,
|
||||
|
||||
/// The UI scene we start in.
|
||||
/// This is guaranteed to be a key in ui_scenes.
|
||||
pub start_ui_scene: String,
|
||||
}
|
270
lib/content/src/part/effect.rs
Normal file
270
lib/content/src/part/effect.rs
Normal file
@ -0,0 +1,270 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::{error::ContentLoadResult, Content, ContentBuildContext, ContentIndex, Sprite};
|
||||
|
||||
pub(crate) mod syntax {
|
||||
use std::sync::Arc;
|
||||
|
||||
use galactica_util::to_radians;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
Content, ContentBuildContext, ContentIndex, StartEdge,
|
||||
};
|
||||
// Raw serde syntax structs.
|
||||
// These are never seen by code outside this crate.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Effect {
|
||||
pub sprite: ContentIndex,
|
||||
pub size: f32,
|
||||
pub size_rng: Option<f32>,
|
||||
pub lifetime: TextOrFloat,
|
||||
pub lifetime_rng: Option<f32>,
|
||||
pub angle: Option<f32>,
|
||||
pub angle_rng: Option<f32>,
|
||||
pub angvel: Option<f32>,
|
||||
pub angvel_rng: Option<f32>,
|
||||
|
||||
pub fade: Option<f32>,
|
||||
pub fade_rng: Option<f32>,
|
||||
|
||||
pub velocity: EffectVelocity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum EffectVelocity {
|
||||
Sticky {
|
||||
sticky: String,
|
||||
},
|
||||
Explicit {
|
||||
scale_parent: Option<f32>,
|
||||
scale_parent_rng: Option<f32>,
|
||||
scale_target: Option<f32>,
|
||||
scale_target_rng: Option<f32>,
|
||||
direction_rng: Option<f32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum TextOrFloat {
|
||||
Text(String),
|
||||
Float(f32),
|
||||
}
|
||||
|
||||
// We implement building here instead of in super::Effect because
|
||||
// effects may be defined inline (see EffectReference).
|
||||
impl Effect {
|
||||
pub fn add_to(
|
||||
self,
|
||||
_build_context: &mut ContentBuildContext,
|
||||
content: &mut Content,
|
||||
name: &str,
|
||||
) -> ContentLoadResult<Arc<super::Effect>> {
|
||||
let sprite = match content.sprites.get(&self.sprite) {
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("sprite `{}` doesn't exist", self.sprite),
|
||||
});
|
||||
}
|
||||
Some(t) => t.clone(),
|
||||
};
|
||||
|
||||
let lifetime = match self.lifetime {
|
||||
TextOrFloat::Float(f) => f,
|
||||
TextOrFloat::Text(s) => {
|
||||
if s == "inherit" {
|
||||
// Match lifetime of first section of sprite
|
||||
match &sprite.start_at {
|
||||
StartEdge::Top { section } | StartEdge::Bot { section } => {
|
||||
section.frame_duration * section.frames.len() as f32
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: "bad effect lifetime, must be float or \"inherit\"".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let velocity = match self.velocity {
|
||||
EffectVelocity::Explicit {
|
||||
scale_parent,
|
||||
scale_parent_rng,
|
||||
scale_target,
|
||||
scale_target_rng,
|
||||
direction_rng,
|
||||
} => super::EffectVelocity::Explicit {
|
||||
scale_parent: scale_parent.unwrap_or(0.0),
|
||||
scale_parent_rng: scale_parent_rng.unwrap_or(0.0),
|
||||
scale_target: scale_target.unwrap_or(0.0),
|
||||
scale_target_rng: scale_target_rng.unwrap_or(0.0),
|
||||
direction_rng: direction_rng.unwrap_or(0.0) / 2.0,
|
||||
},
|
||||
EffectVelocity::Sticky { sticky } => {
|
||||
if sticky == "parent" {
|
||||
super::EffectVelocity::StickyParent
|
||||
} else if sticky == "target" {
|
||||
super::EffectVelocity::StickyTarget
|
||||
} else {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("bad sticky specification `{}`", sticky),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let e = Arc::new(super::Effect {
|
||||
name: name.to_string(),
|
||||
sprite,
|
||||
velocity,
|
||||
size: self.size,
|
||||
size_rng: self.size_rng.unwrap_or(0.0),
|
||||
lifetime,
|
||||
lifetime_rng: self.lifetime_rng.unwrap_or(0.0),
|
||||
angle: to_radians(self.angle.unwrap_or(0.0) / 2.0),
|
||||
angle_rng: to_radians(self.angle_rng.unwrap_or(0.0) / 2.0),
|
||||
angvel: to_radians(self.angvel.unwrap_or(0.0)),
|
||||
angvel_rng: to_radians(self.angvel_rng.unwrap_or(0.0)),
|
||||
fade: self.fade.unwrap_or(0.0),
|
||||
fade_rng: self.fade_rng.unwrap_or(0.0),
|
||||
});
|
||||
|
||||
content
|
||||
.effects
|
||||
.insert(ContentIndex::new(&e.name), e.clone());
|
||||
|
||||
return Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
// This isn't used here, but is pulled in by other content items.
|
||||
/// A reference to an effect by name, or an inline definition.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum EffectReference {
|
||||
Label(ContentIndex),
|
||||
Effect(Effect),
|
||||
}
|
||||
|
||||
impl EffectReference {
|
||||
pub fn resolve(
|
||||
self,
|
||||
build_context: &mut ContentBuildContext,
|
||||
content: &mut Content,
|
||||
name: &str,
|
||||
) -> ContentLoadResult<Arc<super::Effect>> {
|
||||
// We do not insert anything into build_context here,
|
||||
// since inline effects cannot be referenced by name.
|
||||
Ok(match self {
|
||||
Self::Effect(e) => e.add_to(build_context, content, name)?,
|
||||
Self::Label(l) => match build_context.effect_index.get(&l) {
|
||||
Some(h) => h.clone(),
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("no effect named `{}`", l),
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect a projectile will spawn when it hits something
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Effect {
|
||||
/// The sprite to use for this effect.
|
||||
pub sprite: Arc<Sprite>,
|
||||
|
||||
/// This effect's name
|
||||
pub name: String,
|
||||
|
||||
/// The height of this effect, in game units.
|
||||
pub size: f32,
|
||||
|
||||
/// Random size variation
|
||||
pub size_rng: f32,
|
||||
|
||||
/// How many seconds this effect should live
|
||||
pub lifetime: f32,
|
||||
|
||||
/// Random lifetime variation
|
||||
pub lifetime_rng: f32,
|
||||
|
||||
/// The angle this effect points once spawned, in radians
|
||||
pub angle: f32,
|
||||
|
||||
/// Random angle variation, in radians
|
||||
pub angle_rng: f32,
|
||||
|
||||
/// How fast this effect spins, in radians/sec
|
||||
pub angvel: f32,
|
||||
|
||||
/// Random angvel variation
|
||||
pub angvel_rng: f32,
|
||||
|
||||
/// Fade this effect out over this many seconds as it ends
|
||||
pub fade: f32,
|
||||
|
||||
/// Random fade variation
|
||||
pub fade_rng: f32,
|
||||
|
||||
/// How to compute this effect's velocity
|
||||
pub velocity: EffectVelocity,
|
||||
}
|
||||
|
||||
/// How to compute an effect's velocity
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EffectVelocity {
|
||||
/// Stick to parent
|
||||
StickyParent,
|
||||
|
||||
/// Stick to target.
|
||||
/// Zero velocity if no target is given.
|
||||
StickyTarget,
|
||||
|
||||
/// Compute velocity from parent and target
|
||||
Explicit {
|
||||
/// How much of the parent's velocity to inherit
|
||||
scale_parent: f32,
|
||||
|
||||
/// Random variation of scale_parent
|
||||
scale_parent_rng: f32,
|
||||
|
||||
/// How much of the target's velocity to keep
|
||||
scale_target: f32,
|
||||
|
||||
/// Random variation of scale_target
|
||||
scale_target_rng: f32,
|
||||
|
||||
/// Random variation of travel direction
|
||||
direction_rng: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl crate::Build for Effect {
|
||||
type InputSyntaxType = HashMap<String, syntax::Effect>;
|
||||
|
||||
fn build(
|
||||
effects: Self::InputSyntaxType,
|
||||
build_context: &mut ContentBuildContext,
|
||||
content: &mut Content,
|
||||
) -> ContentLoadResult<()> {
|
||||
for (effect_name, effect) in effects {
|
||||
info!(message = "Evaluating effect", effect = effect_name);
|
||||
let h = effect.add_to(build_context, content, &effect_name)?;
|
||||
build_context
|
||||
.effect_index
|
||||
.insert(ContentIndex::new(&effect_name), h);
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
125
lib/content/src/part/faction.rs
Normal file
125
lib/content/src/part/faction.rs
Normal file
@ -0,0 +1,125 @@
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
Content, ContentBuildContext, ContentIndex,
|
||||
};
|
||||
|
||||
pub(crate) mod syntax {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::ContentIndex;
|
||||
// Raw serde syntax structs.
|
||||
// These are never seen by code outside this crate.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Faction {
|
||||
pub display_name: String,
|
||||
pub color: [f32; 3],
|
||||
pub relationship: HashMap<ContentIndex, super::Relationship>,
|
||||
}
|
||||
}
|
||||
|
||||
/// How two factions should interact with each other.
|
||||
/// Relationships are directional: the relationship of
|
||||
/// `a` to `b` may not equal the relationship of `b` to `a`.
|
||||
///
|
||||
/// Relationships dictate how a ship of THIS faction
|
||||
/// will interact with a ship of the OTHER faction.
|
||||
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||
pub enum Relationship {
|
||||
/// Attack this faction
|
||||
#[serde(rename = "hostile")]
|
||||
Hostile,
|
||||
|
||||
/// Ignore this faction
|
||||
#[serde(rename = "neutral")]
|
||||
Neutral,
|
||||
|
||||
/// Protect this faction
|
||||
#[serde(rename = "friend")]
|
||||
Friend,
|
||||
}
|
||||
|
||||
/// Represents a game faction
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Faction {
|
||||
/// The name of this faction
|
||||
pub index: ContentIndex,
|
||||
|
||||
/// The pretty name of this faction
|
||||
pub display_name: String,
|
||||
|
||||
/// This faction's color.
|
||||
/// Format is RGB, with each color between 0 and 1.
|
||||
pub color: [f32; 3],
|
||||
|
||||
/// Relationships between this faction and other factions
|
||||
/// This is guaranteed to contain an entry for ALL factions.
|
||||
pub relationships: HashMap<ContentIndex, Relationship>,
|
||||
}
|
||||
|
||||
impl crate::Build for Faction {
|
||||
type InputSyntaxType = HashMap<String, syntax::Faction>;
|
||||
|
||||
fn build(
|
||||
factions: Self::InputSyntaxType,
|
||||
_build_context: &mut ContentBuildContext,
|
||||
content: &mut Content,
|
||||
) -> ContentLoadResult<()> {
|
||||
for (faction_name, faction) in &factions {
|
||||
// Compute relationships
|
||||
let mut relationships = HashMap::new();
|
||||
for (other_name, other_faction) in &factions {
|
||||
if let Some(r) = faction.relationship.get(&ContentIndex::new(other_name)) {
|
||||
relationships.insert(ContentIndex::new(other_name), *r);
|
||||
} else {
|
||||
// Default relationship, if not specified
|
||||
|
||||
// Look at reverse direction...
|
||||
let other = other_faction
|
||||
.relationship
|
||||
.get(&ContentIndex::new(&faction_name));
|
||||
relationships.insert(
|
||||
ContentIndex::new(other_name),
|
||||
// ... and pick a relationship based on that.
|
||||
match other {
|
||||
Some(Relationship::Hostile) => Relationship::Hostile {},
|
||||
_ => Relationship::Neutral {},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if faction.color[0] > 1.0
|
||||
|| faction.color[0] < 0.0
|
||||
|| faction.color[1] > 1.0
|
||||
|| faction.color[1] < 0.0
|
||||
|| faction.color[2] > 1.0
|
||||
|| faction.color[2] < 0.0
|
||||
{
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!(
|
||||
"Invalid color for faction `{}`. Value out of range.",
|
||||
faction_name
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
content.factions.insert(
|
||||
ContentIndex::new(faction_name),
|
||||
Arc::new(Self {
|
||||
index: ContentIndex::new(faction_name),
|
||||
display_name: faction.display_name.to_owned(),
|
||||
relationships,
|
||||
color: faction.color,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
22
lib/content/src/part/mod.rs
Normal file
22
lib/content/src/part/mod.rs
Normal file
@ -0,0 +1,22 @@
|
||||
//! Content parts
|
||||
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod effect;
|
||||
pub(crate) mod faction;
|
||||
pub(crate) mod outfit;
|
||||
pub(crate) mod outfitspace;
|
||||
pub(crate) mod ship;
|
||||
pub(crate) mod sprite;
|
||||
pub(crate) mod system;
|
||||
|
||||
pub use config::Config;
|
||||
pub use effect::*;
|
||||
pub use faction::{Faction, Relationship};
|
||||
pub use outfit::*;
|
||||
pub use outfitspace::OutfitSpace;
|
||||
pub use ship::{
|
||||
CollapseEffectSpawner, CollapseEvent, EffectCollapseEvent, EnginePoint, GunPoint, Ship,
|
||||
ShipCollapse,
|
||||
};
|
||||
pub use sprite::*;
|
||||
pub use system::{System, SystemObject};
|
440
lib/content/src/part/outfit.rs
Normal file
440
lib/content/src/part/outfit.rs
Normal file
@ -0,0 +1,440 @@
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
resolve_edge_as_edge, Content, ContentBuildContext, ContentIndex, Effect, OutfitSpace,
|
||||
SectionEdge, Sprite,
|
||||
};
|
||||
|
||||
pub(crate) mod syntax {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
effect,
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
part::outfitspace,
|
||||
sprite::syntax::SectionEdge,
|
||||
ContentBuildContext, ContentIndex,
|
||||
};
|
||||
use galactica_util::to_radians;
|
||||
use serde::Deserialize;
|
||||
// Raw serde syntax structs.
|
||||
// These are never seen by code outside this crate.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Outfit {
|
||||
pub thumbnail: ContentIndex,
|
||||
pub name: String,
|
||||
pub desc: String,
|
||||
pub cost: u32,
|
||||
pub engine: Option<Engine>,
|
||||
pub steering: Option<Steering>,
|
||||
pub space: outfitspace::syntax::OutfitSpace,
|
||||
pub shield: Option<Shield>,
|
||||
pub gun: Option<Gun>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Shield {
|
||||
pub strength: Option<f32>,
|
||||
pub generation: Option<f32>,
|
||||
pub delay: Option<f32>,
|
||||
// more stats: permiability, shield armor, ramp, etc
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Engine {
|
||||
pub thrust: f32,
|
||||
pub flare: EngineFlare,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EngineFlare {
|
||||
pub sprite: ContentIndex,
|
||||
pub on_start: Option<SectionEdge>,
|
||||
pub on_stop: Option<SectionEdge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Steering {
|
||||
pub power: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Gun {
|
||||
pub projectile: Projectile,
|
||||
pub rate: f32,
|
||||
pub rate_rng: Option<f32>,
|
||||
}
|
||||
|
||||
impl Gun {
|
||||
pub fn build(
|
||||
self,
|
||||
build_context: &mut ContentBuildContext,
|
||||
content: &mut crate::Content,
|
||||
) -> ContentLoadResult<super::Gun> {
|
||||
let projectile_sprite = match content
|
||||
.sprites
|
||||
.get(&ContentIndex::new(&self.projectile.sprite))
|
||||
{
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!(
|
||||
"projectile sprite `{}` doesn't exist",
|
||||
self.projectile.sprite,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Some(t) => t.clone(),
|
||||
};
|
||||
|
||||
let impact_effect = match self.projectile.impact_effect {
|
||||
Some(e) => Some(e.resolve(build_context, content, "")?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let expire_effect = match self.projectile.expire_effect {
|
||||
Some(e) => Some(e.resolve(build_context, content, "")?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
return Ok(super::Gun {
|
||||
rate: self.rate,
|
||||
rate_rng: self.rate_rng.unwrap_or(0.0),
|
||||
projectile: Arc::new(super::Projectile {
|
||||
force: self.projectile.force,
|
||||
sprite: projectile_sprite,
|
||||
size: self.projectile.size,
|
||||
size_rng: self.projectile.size_rng,
|
||||
speed: self.projectile.speed,
|
||||
speed_rng: self.projectile.speed_rng,
|
||||
lifetime: self.projectile.lifetime,
|
||||
lifetime_rng: self.projectile.lifetime_rng,
|
||||
damage: self.projectile.damage,
|
||||
|
||||
// Divide by 2, so the angle matches the angle of the fire cone.
|
||||
// This should ALWAYS be done in the content parser.
|
||||
angle_rng: to_radians(self.projectile.angle_rng / 2.0).into(),
|
||||
impact_effect,
|
||||
expire_effect,
|
||||
collider: self.projectile.collider,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Projectile {
|
||||
pub sprite: String,
|
||||
pub size: f32,
|
||||
pub size_rng: f32,
|
||||
pub speed: f32,
|
||||
pub speed_rng: f32,
|
||||
pub lifetime: f32,
|
||||
pub lifetime_rng: f32,
|
||||
pub damage: f32,
|
||||
pub angle_rng: f32,
|
||||
pub impact_effect: Option<effect::syntax::EffectReference>,
|
||||
pub expire_effect: Option<effect::syntax::EffectReference>,
|
||||
pub collider: super::ProjectileCollider,
|
||||
pub force: f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an outfit that may be attached to a ship.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Outfit {
|
||||
/// This outfit's thumbnail
|
||||
pub thumbnail: Arc<Sprite>,
|
||||
|
||||
/// The cost of this outfit, in credits
|
||||
pub cost: u32,
|
||||
|
||||
/// How much space this outfit requires
|
||||
pub space: OutfitSpace,
|
||||
|
||||
/// The name of this outfit
|
||||
pub display_name: String,
|
||||
|
||||
/// The description of this outfit
|
||||
pub desc: String,
|
||||
|
||||
/// Thie outfit's index
|
||||
pub index: ContentIndex,
|
||||
|
||||
/// The engine flare sprite this outfit creates.
|
||||
/// Its location and size is determined by a ship's
|
||||
/// engine points.
|
||||
pub engine_flare_sprite: Option<Arc<Sprite>>,
|
||||
|
||||
/// Jump to this edge when engines turn on
|
||||
pub engine_flare_on_start: Option<SectionEdge>,
|
||||
|
||||
/// Jump to this edge when engines turn off
|
||||
pub engine_flare_on_stop: Option<SectionEdge>,
|
||||
|
||||
/// This outfit's gun stats.
|
||||
/// If this is some, this outfit requires a gun point.
|
||||
pub gun: Option<Gun>,
|
||||
|
||||
/// The stats this outfit provides
|
||||
pub stats: OutfitStats,
|
||||
}
|
||||
|
||||
/// Outfit statistics
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutfitStats {
|
||||
/// How much engine thrust this outfit produces
|
||||
pub engine_thrust: f32,
|
||||
|
||||
/// How much steering power this outfit provids
|
||||
pub steer_power: f32,
|
||||
|
||||
/// Shield hit points
|
||||
pub shield_strength: f32,
|
||||
|
||||
/// Shield regeneration rate, per second
|
||||
pub shield_generation: f32,
|
||||
|
||||
/// Wait this many seconds after taking damage before regenerating shields
|
||||
pub shield_delay: f32,
|
||||
}
|
||||
|
||||
impl OutfitStats {
|
||||
/// Create a new `OutfitStats`, with all values set to zero.
|
||||
pub fn zero() -> Self {
|
||||
Self {
|
||||
engine_thrust: 0.0,
|
||||
steer_power: 0.0,
|
||||
shield_strength: 0.0,
|
||||
shield_generation: 0.0,
|
||||
shield_delay: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add all the stats in `other` to the stats in `self`.
|
||||
/// Sheld delay is not affected.
|
||||
pub fn add(&mut self, other: &Self) {
|
||||
self.engine_thrust += other.engine_thrust;
|
||||
self.steer_power += other.steer_power;
|
||||
self.shield_strength += other.shield_strength;
|
||||
self.shield_generation += other.shield_generation;
|
||||
}
|
||||
|
||||
/// Subtract all the stats in `other` from the stats in `self`.
|
||||
/// Sheld delay is not affected.
|
||||
pub fn subtract(&mut self, other: &Self) {
|
||||
self.engine_thrust -= other.engine_thrust;
|
||||
self.steer_power -= other.steer_power;
|
||||
self.shield_strength -= other.shield_strength;
|
||||
self.shield_generation -= other.shield_generation;
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines a projectile's collider
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub enum ProjectileCollider {
|
||||
/// A ball collider
|
||||
#[serde(rename = "ball")]
|
||||
Ball(BallCollider),
|
||||
}
|
||||
|
||||
/// A simple ball-shaped collider, centered at the object's position
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct BallCollider {
|
||||
/// The radius of this ball
|
||||
pub radius: f32,
|
||||
}
|
||||
|
||||
/// Represents gun stats of an outfit.
|
||||
/// If an outfit has this value, it requires a gun point.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Gun {
|
||||
/// The projectile this gun produces
|
||||
pub projectile: Arc<Projectile>,
|
||||
|
||||
/// Average delay between projectiles, in seconds.
|
||||
pub rate: f32,
|
||||
|
||||
/// Random variation of projectile delay, in seconds.
|
||||
/// Each shot waits (rate += rate_rng).
|
||||
pub rate_rng: f32,
|
||||
}
|
||||
|
||||
/// Represents a projectile that a [`Gun`] produces.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Projectile {
|
||||
/// The projectile sprite
|
||||
pub sprite: Arc<Sprite>,
|
||||
|
||||
/// The average size of this projectile
|
||||
/// (height in game units)
|
||||
pub size: f32,
|
||||
/// Random size variation
|
||||
pub size_rng: f32,
|
||||
|
||||
/// The speed of this projectile, in game units / second
|
||||
pub speed: f32,
|
||||
/// Random speed variation
|
||||
pub speed_rng: f32,
|
||||
|
||||
/// The lifespan of this projectile.
|
||||
/// It will vanish if it lives this long without hitting anything.
|
||||
pub lifetime: f32,
|
||||
/// Random lifetime variation
|
||||
pub lifetime_rng: f32,
|
||||
|
||||
/// The damage this projectile does
|
||||
pub damage: f32,
|
||||
|
||||
/// The force this projectile applies
|
||||
pub force: f32,
|
||||
|
||||
/// The angle variation of this projectile, in radians
|
||||
pub angle_rng: f32,
|
||||
|
||||
/// The effect this projectile will spawn when it hits something
|
||||
pub impact_effect: Option<Arc<Effect>>,
|
||||
|
||||
/// The effect this projectile will spawn when it expires
|
||||
pub expire_effect: Option<Arc<Effect>>,
|
||||
|
||||
/// Collider parameters for this projectile
|
||||
pub collider: ProjectileCollider,
|
||||
}
|
||||
|
||||
impl crate::Build for Outfit {
|
||||
type InputSyntaxType = HashMap<String, syntax::Outfit>;
|
||||
|
||||
fn build(
|
||||
outfits: Self::InputSyntaxType,
|
||||
build_context: &mut ContentBuildContext,
|
||||
content: &mut Content,
|
||||
) -> ContentLoadResult<()> {
|
||||
for (outfit_name, outfit) in outfits {
|
||||
info!(message = "Building outfit", outfit = ?outfit_name);
|
||||
let gun = match outfit.gun {
|
||||
None => None,
|
||||
Some(g) => Some(g.build(build_context, content)?),
|
||||
};
|
||||
|
||||
let thumbnail = match content.sprites.get(&outfit.thumbnail) {
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("thumbnail sprite `{}` doesn't exist", outfit.thumbnail),
|
||||
});
|
||||
}
|
||||
Some(t) => t.clone(),
|
||||
};
|
||||
|
||||
let mut o = Self {
|
||||
index: ContentIndex::new(&outfit_name),
|
||||
display_name: outfit.name,
|
||||
cost: outfit.cost,
|
||||
thumbnail,
|
||||
gun,
|
||||
desc: outfit.desc,
|
||||
engine_flare_sprite: None,
|
||||
engine_flare_on_start: None,
|
||||
engine_flare_on_stop: None,
|
||||
space: OutfitSpace::from(outfit.space),
|
||||
stats: OutfitStats::zero(),
|
||||
};
|
||||
|
||||
// Engine stats
|
||||
if let Some(engine) = outfit.engine {
|
||||
let sprite = match content.sprites.get(&engine.flare.sprite) {
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("flare sprite `{}` doesn't exist", engine.flare.sprite),
|
||||
});
|
||||
}
|
||||
Some(t) => t.clone(),
|
||||
};
|
||||
o.stats.engine_thrust = engine.thrust;
|
||||
o.engine_flare_sprite = Some(sprite.clone());
|
||||
|
||||
// Flare animation will traverse this edge when the player presses the thrust key
|
||||
// This leads from the idle animation to the transition animation
|
||||
o.engine_flare_on_start = {
|
||||
let x = engine.flare.on_start;
|
||||
if x.is_none() {
|
||||
None
|
||||
} else {
|
||||
let x = x.unwrap();
|
||||
let mut e = resolve_edge_as_edge(&sprite.sections, &x.val, 0.0)?;
|
||||
match &mut e {
|
||||
// Inherit duration from transition sequence
|
||||
SectionEdge::Top {
|
||||
section,
|
||||
ref mut duration,
|
||||
}
|
||||
| SectionEdge::Bot {
|
||||
section,
|
||||
ref mut duration,
|
||||
} => {
|
||||
*duration = section.frame_duration;
|
||||
}
|
||||
_ => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("bad edge `{}`: must be `top` or `bot`", x.val),
|
||||
});
|
||||
}
|
||||
};
|
||||
Some(e)
|
||||
}
|
||||
};
|
||||
|
||||
// Flare animation will traverse this edge when the player releases the thrust key
|
||||
// This leads from the idle animation to the transition animation
|
||||
o.engine_flare_on_stop = {
|
||||
let x = engine.flare.on_stop;
|
||||
if x.is_none() {
|
||||
None
|
||||
} else {
|
||||
let x = x.unwrap();
|
||||
let mut e = resolve_edge_as_edge(&sprite.sections, &x.val, 0.0)?;
|
||||
match &mut e {
|
||||
// Inherit duration from transition sequence
|
||||
SectionEdge::Top {
|
||||
section,
|
||||
ref mut duration,
|
||||
}
|
||||
| SectionEdge::Bot {
|
||||
section,
|
||||
ref mut duration,
|
||||
} => {
|
||||
*duration = section.frame_duration;
|
||||
}
|
||||
_ => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("bad edge `{}`: must be `top` or `bot`", x.val),
|
||||
});
|
||||
}
|
||||
};
|
||||
Some(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Steering stats
|
||||
if let Some(steer) = outfit.steering {
|
||||
o.stats.steer_power = steer.power;
|
||||
}
|
||||
|
||||
// Shield stats
|
||||
if let Some(shield) = outfit.shield {
|
||||
o.stats.shield_delay = shield.delay.unwrap_or(0.0);
|
||||
o.stats.shield_generation = shield.generation.unwrap_or(0.0);
|
||||
o.stats.shield_strength = shield.strength.unwrap_or(0.0);
|
||||
}
|
||||
|
||||
content.outfits.insert(o.index.clone(), Arc::new(o));
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
109
lib/content/src/part/outfitspace.rs
Normal file
109
lib/content/src/part/outfitspace.rs
Normal file
@ -0,0 +1,109 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ops::{Add, AddAssign, Sub, SubAssign},
|
||||
};
|
||||
|
||||
pub(crate) mod syntax {
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OutfitSpace {
|
||||
pub outfit: Option<u32>,
|
||||
pub weapon: Option<u32>,
|
||||
pub engine: Option<u32>,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: user-defined space values
|
||||
|
||||
/// Represents outfit space, either that available in a ship
|
||||
/// or that used by an outfit.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OutfitSpace {
|
||||
/// Total available outfit space.
|
||||
/// This should be greater than weapon and engine.
|
||||
pub outfit: u32,
|
||||
|
||||
/// Space for weapons
|
||||
pub weapon: u32,
|
||||
|
||||
/// Space for engine
|
||||
pub engine: u32,
|
||||
}
|
||||
|
||||
impl OutfitSpace {
|
||||
/// Make a new, zeroed OutfitSpace
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
outfit: 0,
|
||||
weapon: 0,
|
||||
engine: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this outfit contain `smaller`?
|
||||
pub fn can_contain(&self, smaller: &Self) -> bool {
|
||||
self.outfit >= smaller.outfit
|
||||
&& self.weapon >= smaller.weapon
|
||||
&& self.engine >= smaller.engine
|
||||
}
|
||||
|
||||
/// Return a map of "space name" -> number
|
||||
pub fn to_hashmap(&self) -> HashMap<String, u32> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("outfit".to_string(), self.outfit);
|
||||
m.insert("weapon".to_string(), self.weapon);
|
||||
m.insert("engine".to_string(), self.engine);
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for OutfitSpace {
|
||||
type Output = Self;
|
||||
|
||||
fn sub(self, rhs: Self) -> Self::Output {
|
||||
OutfitSpace {
|
||||
outfit: self.outfit - rhs.outfit,
|
||||
weapon: self.weapon - rhs.weapon,
|
||||
engine: self.engine - rhs.engine,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign for OutfitSpace {
|
||||
fn sub_assign(&mut self, rhs: Self) {
|
||||
self.outfit -= rhs.outfit;
|
||||
self.weapon -= rhs.weapon;
|
||||
self.engine -= rhs.engine;
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for OutfitSpace {
|
||||
type Output = Self;
|
||||
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
OutfitSpace {
|
||||
outfit: self.outfit + rhs.outfit,
|
||||
weapon: self.weapon + rhs.weapon,
|
||||
engine: self.engine + rhs.engine,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign for OutfitSpace {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
self.outfit += rhs.outfit;
|
||||
self.weapon += rhs.weapon;
|
||||
self.engine += rhs.engine;
|
||||
}
|
||||
}
|
||||
|
||||
impl From<syntax::OutfitSpace> for OutfitSpace {
|
||||
fn from(value: syntax::OutfitSpace) -> Self {
|
||||
Self {
|
||||
outfit: value.outfit.unwrap_or(0),
|
||||
engine: value.engine.unwrap_or(0),
|
||||
weapon: value.weapon.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
}
|
463
lib/content/src/part/ship.rs
Normal file
463
lib/content/src/part/ship.rs
Normal file
@ -0,0 +1,463 @@
|
||||
use galactica_util::to_radians;
|
||||
use nalgebra::{Point2, Rotation2, Vector2};
|
||||
use rapier2d::geometry::{Collider, ColliderBuilder};
|
||||
use std::{collections::HashMap, fmt::Debug, hash::Hash, sync::Arc};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
Content, ContentBuildContext, ContentIndex, Effect, OutfitSpace, Sprite,
|
||||
};
|
||||
|
||||
pub(crate) mod syntax {
|
||||
use crate::{
|
||||
part::{effect::syntax::EffectReference, outfitspace},
|
||||
ContentIndex,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
// Raw serde syntax structs.
|
||||
// These are never seen by code outside this crate.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Ship {
|
||||
pub name: String,
|
||||
pub sprite: ContentIndex,
|
||||
pub thumbnail: ContentIndex,
|
||||
pub size: f32,
|
||||
pub engines: Vec<Engine>,
|
||||
pub guns: Vec<Gun>,
|
||||
pub hull: f32,
|
||||
pub mass: f32,
|
||||
pub collision: Vec<[f32; 2]>,
|
||||
pub angular_drag: f32,
|
||||
pub linear_drag: f32,
|
||||
pub space: outfitspace::syntax::OutfitSpace,
|
||||
pub collapse: Option<Collapse>,
|
||||
pub damage: Option<Damage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Engine {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub size: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Gun {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Damage {
|
||||
pub hull: f32,
|
||||
pub effects: Vec<DamageEffectSpawner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DamageEffectSpawner {
|
||||
pub effect: EffectReference,
|
||||
pub frequency: f32,
|
||||
pub pos: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
// TODO:
|
||||
// plural or not? document!
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Collapse {
|
||||
pub length: f32,
|
||||
pub effects: Vec<CollapseEffectSpawner>,
|
||||
pub event: Vec<CollapseEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CollapseEffectSpawner {
|
||||
pub effect: EffectReference,
|
||||
pub count: f32,
|
||||
pub pos: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CollapseEvent {
|
||||
Effect(EffectCollapseEvent),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectCollapseEvent {
|
||||
pub time: f32,
|
||||
pub effects: Vec<CollapseEffectSpawner>,
|
||||
}
|
||||
}
|
||||
|
||||
// Processed data structs.
|
||||
// These are exported.
|
||||
|
||||
/// Represents a ship chassis.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ship {
|
||||
/// This ship's display name
|
||||
pub display_name: String,
|
||||
|
||||
/// This object's index
|
||||
pub index: ContentIndex,
|
||||
|
||||
/// This ship's sprite
|
||||
pub sprite: Arc<Sprite>,
|
||||
|
||||
/// This ship's thumbnail
|
||||
pub thumbnail: Arc<Sprite>,
|
||||
|
||||
/// The size of this ship.
|
||||
/// Measured as unrotated height,
|
||||
/// in terms of game units.
|
||||
pub size: f32,
|
||||
|
||||
/// The mass of this ship
|
||||
pub mass: f32,
|
||||
|
||||
/// Engine points on this ship.
|
||||
/// This is where engine flares are drawn.
|
||||
pub engines: Vec<EnginePoint>,
|
||||
|
||||
/// Gun points on this ship.
|
||||
/// A gun outfit can be mounted on each.
|
||||
pub guns: Vec<GunPoint>,
|
||||
|
||||
/// This ship's hull strength
|
||||
pub hull: f32,
|
||||
|
||||
/// Collision shape for this ship
|
||||
pub collider: CollisionDebugWrapper,
|
||||
|
||||
/// Reduction in angular velocity over time
|
||||
pub angular_drag: f32,
|
||||
|
||||
/// Reduction in velocity over time
|
||||
pub linear_drag: f32,
|
||||
|
||||
/// Outfit space in this ship
|
||||
pub space: OutfitSpace,
|
||||
|
||||
/// Ship collapse sequence
|
||||
pub collapse: ShipCollapse,
|
||||
|
||||
/// Damaged ship effects
|
||||
pub damage: ShipDamage,
|
||||
}
|
||||
|
||||
/// Hack to give `Collider` a fake debug method.
|
||||
/// Pretend this is transparent, get the collider with .0.
|
||||
#[derive(Clone)]
|
||||
pub struct CollisionDebugWrapper(pub Collider);
|
||||
|
||||
impl Debug for CollisionDebugWrapper {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
"CollisionDebugWrapper".fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// An engine point on a ship.
|
||||
/// This is where flares are drawn.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnginePoint {
|
||||
/// This engine point's position, in game units,
|
||||
/// relative to the ship's center.
|
||||
pub pos: Vector2<f32>,
|
||||
|
||||
/// The size of the flare that should be drawn
|
||||
/// at this point, measured as height in game units.
|
||||
pub size: f32,
|
||||
}
|
||||
|
||||
/// A gun point on a ship.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GunPoint {
|
||||
/// This gun point's index in this ship
|
||||
pub idx: u32,
|
||||
|
||||
/// This gun point's position, in game units,
|
||||
/// relative to the ship's center.
|
||||
pub pos: Vector2<f32>,
|
||||
}
|
||||
|
||||
impl Eq for GunPoint {}
|
||||
impl PartialEq for GunPoint {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.idx == other.idx
|
||||
}
|
||||
}
|
||||
|
||||
// We use a hashmap of these in OutfitSet
|
||||
impl Hash for GunPoint {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.idx.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for a ship's collapse sequence
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShipCollapse {
|
||||
/// Collapse sequence length, in seconds
|
||||
pub length: f32,
|
||||
|
||||
/// Effects to create during collapse
|
||||
pub effects: Vec<CollapseEffectSpawner>,
|
||||
|
||||
/// Scripted events during ship collapse
|
||||
pub events: Vec<CollapseEvent>,
|
||||
}
|
||||
|
||||
/// Parameters for damaged ship effects
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShipDamage {
|
||||
/// Show damaged ship effects if hull is below this value
|
||||
pub hull: f32,
|
||||
|
||||
/// Effects to create during collapse
|
||||
pub effects: Vec<DamageEffectSpawner>,
|
||||
}
|
||||
|
||||
/// An effect shown when a ship is damaged
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DamageEffectSpawner {
|
||||
/// The effect to create
|
||||
pub effect: Arc<Effect>,
|
||||
|
||||
/// How often to create this effect
|
||||
pub frequency: f32,
|
||||
|
||||
/// Where to create is effect.
|
||||
/// Position is random if None.
|
||||
pub pos: Option<Point2<f32>>,
|
||||
}
|
||||
|
||||
/// An effect shown during a ship collapse sequence
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CollapseEffectSpawner {
|
||||
/// The effect to create
|
||||
pub effect: Arc<Effect>,
|
||||
|
||||
/// How many effects to create
|
||||
pub count: f32,
|
||||
|
||||
/// Where to create this effect.
|
||||
/// Position is random if None.
|
||||
pub pos: Option<Point2<f32>>,
|
||||
}
|
||||
|
||||
/// A scripted event during a ship collapse sequence
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CollapseEvent {
|
||||
/// A scripted effect during a ship collapse sequence
|
||||
Effect(EffectCollapseEvent),
|
||||
}
|
||||
|
||||
/// A scripted effect during a ship collapse sequence
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectCollapseEvent {
|
||||
/// When to trigger this event
|
||||
pub time: f32,
|
||||
|
||||
/// The effect to create
|
||||
pub effects: Vec<CollapseEffectSpawner>,
|
||||
}
|
||||
|
||||
impl crate::Build for Ship {
|
||||
type InputSyntaxType = HashMap<String, syntax::Ship>;
|
||||
|
||||
fn build(
|
||||
ship: Self::InputSyntaxType,
|
||||
build_context: &mut ContentBuildContext,
|
||||
ct: &mut Content,
|
||||
) -> ContentLoadResult<()> {
|
||||
for (ship_name, ship) in ship {
|
||||
info!(message = "Building ship", ship = ?ship_name);
|
||||
|
||||
let sprite = match ct.sprites.get(&ship.sprite) {
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("Sprite `{}` doesn't exist", ship.sprite),
|
||||
});
|
||||
}
|
||||
|
||||
Some(t) => t.clone(),
|
||||
};
|
||||
|
||||
let thumbnail = match ct.sprites.get(&ship.thumbnail) {
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("Thumbnail sprite `{}` doesn't exist", ship.thumbnail),
|
||||
});
|
||||
}
|
||||
|
||||
Some(t) => t.clone(),
|
||||
};
|
||||
|
||||
let size = ship.size;
|
||||
|
||||
let collapse = {
|
||||
if let Some(c) = ship.collapse {
|
||||
let mut effects = Vec::new();
|
||||
for e in c.effects {
|
||||
effects.push(CollapseEffectSpawner {
|
||||
effect: e.effect.resolve(build_context, ct, "")?,
|
||||
count: e.count,
|
||||
pos: e.pos.map(|p| {
|
||||
Point2::new(p[0] * (size / 2.0) * sprite.aspect, p[1] * size / 2.0)
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
for e in c.event {
|
||||
match e {
|
||||
syntax::CollapseEvent::Effect(e) => {
|
||||
let mut effects = Vec::new();
|
||||
for g in e.effects {
|
||||
effects.push(CollapseEffectSpawner {
|
||||
effect: g.effect.resolve(build_context, ct, "")?,
|
||||
count: g.count,
|
||||
pos: g.pos.map(|p| {
|
||||
Point2::new(
|
||||
p[0] * (size / 2.0) * sprite.aspect,
|
||||
p[1] * size / 2.0,
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
events.push(CollapseEvent::Effect(EffectCollapseEvent {
|
||||
time: e.time,
|
||||
effects,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ShipCollapse {
|
||||
length: c.length,
|
||||
effects,
|
||||
events,
|
||||
}
|
||||
} else {
|
||||
// Default collapse sequence
|
||||
ShipCollapse {
|
||||
length: 0.0,
|
||||
effects: vec![],
|
||||
events: vec![],
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let damage = {
|
||||
if let Some(c) = ship.damage {
|
||||
let mut effects = Vec::new();
|
||||
for e in c.effects {
|
||||
effects.push(DamageEffectSpawner {
|
||||
effect: e.effect.resolve(build_context, ct, "")?,
|
||||
frequency: e.frequency,
|
||||
pos: e.pos.map(|p| {
|
||||
Point2::new(p[0] * (size / 2.0) * sprite.aspect, p[1] * size / 2.0)
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
ShipDamage {
|
||||
hull: c.hull,
|
||||
effects: effects,
|
||||
}
|
||||
} else {
|
||||
// Default damage effects
|
||||
ShipDamage {
|
||||
hull: 0.0,
|
||||
effects: vec![],
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: document this
|
||||
let mut guns = Vec::new();
|
||||
for g in ship.guns {
|
||||
guns.push(GunPoint {
|
||||
idx: guns.len() as u32,
|
||||
|
||||
// Angle adjustment, since sprites point north
|
||||
// and 0 degrees is east in the game
|
||||
pos: Rotation2::new(to_radians(-90.0))
|
||||
* Vector2::new(g.x * size * sprite.aspect / 2.0, g.y * size / 2.0),
|
||||
})
|
||||
}
|
||||
|
||||
// Build rapier2d collider
|
||||
let collider = {
|
||||
let indices: Vec<[u32; 2]> = (0..ship.collision.len())
|
||||
.map(|x| {
|
||||
// Auto-generate mesh lines:
|
||||
// [ [0, 1], [1, 2], ..., [n, 0] ]
|
||||
let next = if x == ship.collision.len() - 1 {
|
||||
0
|
||||
} else {
|
||||
x + 1
|
||||
};
|
||||
[x as u32, next as u32]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let points: Vec<Point2<f32>> = ship
|
||||
.collision
|
||||
.iter()
|
||||
.map(|x| {
|
||||
// Angle adjustment: rotate collider to match sprite
|
||||
// (Sprites (and their colliders) point north, but 0 is east in the game world)
|
||||
// We apply this pointwise so that local points inside the collider work as we expect.
|
||||
//
|
||||
// If we don't, rapier2 will compute local points pre-rotation,
|
||||
// which will break effect placement on top of ships (i.e, collapse effects)
|
||||
Rotation2::new(to_radians(-90.0))
|
||||
* Point2::new(x[0] * (size / 2.0) * sprite.aspect, x[1] * size / 2.0)
|
||||
})
|
||||
.collect();
|
||||
|
||||
ColliderBuilder::convex_decomposition(&points[..], &indices[..])
|
||||
.mass(ship.mass)
|
||||
.build()
|
||||
};
|
||||
|
||||
ct.ships.insert(
|
||||
ContentIndex::new(&ship_name),
|
||||
Arc::new(Self {
|
||||
sprite: sprite.clone(),
|
||||
thumbnail,
|
||||
collapse,
|
||||
damage,
|
||||
index: ContentIndex::new(&ship_name),
|
||||
display_name: ship.name,
|
||||
mass: ship.mass,
|
||||
space: OutfitSpace::from(ship.space),
|
||||
angular_drag: ship.angular_drag,
|
||||
linear_drag: ship.linear_drag,
|
||||
size,
|
||||
hull: ship.hull,
|
||||
|
||||
engines: ship
|
||||
.engines
|
||||
.iter()
|
||||
.map(|e| EnginePoint {
|
||||
pos: Vector2::new(e.x * size * sprite.aspect / 2.0, e.y * size / 2.0),
|
||||
size: e.size,
|
||||
})
|
||||
.collect(),
|
||||
|
||||
guns,
|
||||
|
||||
collider: CollisionDebugWrapper(collider),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
505
lib/content/src/part/sprite.rs
Normal file
505
lib/content/src/part/sprite.rs
Normal file
@ -0,0 +1,505 @@
|
||||
use lazy_static::lazy_static;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::{
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
Content, ContentBuildContext, ContentIndex,
|
||||
};
|
||||
|
||||
pub(crate) mod syntax {
|
||||
use crate::{
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
Content, ContentIndex,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
// Raw serde syntax structs.
|
||||
// These are never seen by code outside this crate.
|
||||
|
||||
/// Convenience variants of sprite definitions
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Sprite {
|
||||
Static(StaticSprite),
|
||||
OneSection(SpriteSection),
|
||||
Complete(CompleteSprite),
|
||||
}
|
||||
|
||||
/// Two ways to specify animation length
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub enum TimingVariant {
|
||||
/// The duration of this whole section
|
||||
#[serde(rename = "duration")]
|
||||
Duration(f32),
|
||||
|
||||
/// The fps of this section
|
||||
#[serde(rename = "fps")]
|
||||
Fps(f32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Timing {
|
||||
#[serde(flatten)]
|
||||
pub variant: TimingVariant,
|
||||
}
|
||||
|
||||
/// An unanimated sprite
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StaticSprite {
|
||||
pub file: PathBuf,
|
||||
}
|
||||
|
||||
/// The proper, full sprite definition
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CompleteSprite {
|
||||
pub section: HashMap<ContentIndex, SpriteSection>,
|
||||
pub start_at: SectionEdge,
|
||||
}
|
||||
|
||||
/// A single animation section
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpriteSection {
|
||||
pub frames: Vec<PathBuf>,
|
||||
pub timing: Timing,
|
||||
pub top: Option<SectionEdge>,
|
||||
pub bot: Option<SectionEdge>,
|
||||
}
|
||||
|
||||
impl SpriteSection {
|
||||
pub fn resolve_edges(
|
||||
&self,
|
||||
sections: &HashMap<ContentIndex, Arc<super::SpriteSection>>,
|
||||
sec: &mut super::SpriteSection,
|
||||
) -> ContentLoadResult<()> {
|
||||
let edge_top = match &self.top {
|
||||
Some(x) => super::resolve_edge_as_edge(sections, &x.val, sec.frame_duration)?,
|
||||
None => super::SectionEdge::Stop,
|
||||
};
|
||||
|
||||
let edge_bot = match &self.bot {
|
||||
Some(x) => super::resolve_edge_as_edge(sections, &x.val, sec.frame_duration)?,
|
||||
None => super::SectionEdge::Stop,
|
||||
};
|
||||
|
||||
sec.edge_bot = edge_bot;
|
||||
sec.edge_top = edge_top;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
pub fn add_to(
|
||||
&self,
|
||||
ct: &mut Content,
|
||||
) -> ContentLoadResult<((u32, u32), super::SpriteSection)> {
|
||||
// Make sure all frames have the same size and add them
|
||||
// to the frame vector
|
||||
let mut dim = None;
|
||||
let mut frames: Vec<u32> = Vec::new();
|
||||
for f in &self.frames {
|
||||
let idx = match ct.sprite_atlas.get_idx_by_path(f) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("file `{}` isn't in the sprite atlas", f.display()),
|
||||
});
|
||||
}
|
||||
};
|
||||
let img = &ct.sprite_atlas.get_by_idx(idx);
|
||||
|
||||
match dim {
|
||||
None => dim = Some(img.true_size),
|
||||
Some(e) => {
|
||||
if img.true_size != e {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: "failed to load section frames because frames have different sizes".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frames.push(img.idx.into());
|
||||
}
|
||||
|
||||
if frames.len() == 0 {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: "sprite sections must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let dim = dim.unwrap();
|
||||
|
||||
let frame_duration = match self.timing.variant {
|
||||
TimingVariant::Duration(d) => d / self.frames.len() as f32,
|
||||
TimingVariant::Fps(f) => 1.0 / f,
|
||||
};
|
||||
|
||||
if frame_duration <= 0.0 {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: "frame duration must be positive (and therefore nonzero)".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok((
|
||||
dim,
|
||||
super::SpriteSection {
|
||||
frames,
|
||||
frame_duration,
|
||||
// These are changed later, after all sections are built
|
||||
edge_top: super::SectionEdge::Stop,
|
||||
edge_bot: super::SectionEdge::Stop,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// A link between two animation sections
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SectionEdge {
|
||||
pub val: String,
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge between two animation sections
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SectionEdge {
|
||||
/// Stop at the last frame of this section
|
||||
Stop,
|
||||
|
||||
/// Play the given section from the bottm
|
||||
Bot {
|
||||
/// The section to play
|
||||
section: Arc<SpriteSection>,
|
||||
|
||||
/// The length of this edge, in seconds
|
||||
duration: f32,
|
||||
},
|
||||
|
||||
/// Play the given section from the top
|
||||
Top {
|
||||
/// The section to play
|
||||
section: Arc<SpriteSection>,
|
||||
|
||||
/// The length of this edge, in seconds
|
||||
duration: f32,
|
||||
},
|
||||
|
||||
/// Replay this section in the opposite direction
|
||||
Reverse {
|
||||
/// The length of this edge, in seconds
|
||||
duration: f32,
|
||||
},
|
||||
|
||||
/// Restart this section from the opposite end
|
||||
Repeat {
|
||||
/// The length of this edge, in seconds
|
||||
duration: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where to start an animation
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StartEdge {
|
||||
/// Play the given section from the bottm
|
||||
Bot {
|
||||
/// The section to play
|
||||
section: Arc<SpriteSection>,
|
||||
},
|
||||
|
||||
/// Play the given section from the top
|
||||
Top {
|
||||
/// The section to play
|
||||
section: Arc<SpriteSection>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Into<SectionEdge> for StartEdge {
|
||||
fn into(self) -> SectionEdge {
|
||||
match self {
|
||||
Self::Bot { section } => SectionEdge::Bot {
|
||||
section,
|
||||
duration: 0.0,
|
||||
},
|
||||
Self::Top { section } => SectionEdge::Top {
|
||||
section,
|
||||
duration: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a sprite that may be used in the game.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sprite {
|
||||
/// This object's index
|
||||
pub index: ContentIndex,
|
||||
|
||||
/// Where this sprite starts playing
|
||||
pub start_at: StartEdge,
|
||||
|
||||
/// This sprite's animation sections
|
||||
pub sections: HashMap<ContentIndex, Arc<SpriteSection>>,
|
||||
|
||||
/// Aspect ratio of this sprite (width / height)
|
||||
pub aspect: f32,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// The universal "hidden" sprite section, available in all sprites.
|
||||
/// A SpriteAutomaton in this section will not be drawn.
|
||||
pub static ref HIDDEN_SPRITE_SECTION: Arc<SpriteSection> = Arc::new(SpriteSection {
|
||||
frames: vec![0],
|
||||
frame_duration: 0.0,
|
||||
edge_bot: SectionEdge::Stop,
|
||||
edge_top: SectionEdge::Stop,
|
||||
});
|
||||
}
|
||||
|
||||
impl Sprite {
|
||||
/// Get the index of the texture of this sprite's first frame
|
||||
pub fn get_first_frame(&self) -> u32 {
|
||||
match &self.start_at {
|
||||
StartEdge::Bot { section } => *section.frames.last().unwrap(),
|
||||
StartEdge::Top { section } => *section.frames.first().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get this sprite's starting section
|
||||
pub fn get_start_section(&self) -> Arc<SpriteSection> {
|
||||
match &self.start_at {
|
||||
StartEdge::Bot { section } => section.clone(),
|
||||
StartEdge::Top { section } => section.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate this sprite's sections
|
||||
pub fn iter_sections(&self) -> impl Iterator<Item = &Arc<SpriteSection>> {
|
||||
self.sections.values()
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of a sprite's animation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpriteSection {
|
||||
/// The texture index of each frame in this animation section.
|
||||
/// unanimated sections have one frame.
|
||||
pub frames: Vec<u32>,
|
||||
|
||||
/// The speed of this sprite's animation.
|
||||
/// This must always be positive (and therefore, nonzero)
|
||||
pub frame_duration: f32,
|
||||
|
||||
/// What to do when we reach the top of this section
|
||||
pub edge_top: SectionEdge,
|
||||
|
||||
/// What to do when we reach the bottom of this section
|
||||
pub edge_bot: SectionEdge,
|
||||
}
|
||||
|
||||
/// Resolve an edge specification string as a StartEdge
|
||||
pub fn resolve_edge_as_start(
|
||||
sections: &HashMap<ContentIndex, Arc<SpriteSection>>,
|
||||
edge_string: &str,
|
||||
) -> ContentLoadResult<super::StartEdge> {
|
||||
debug!(message = "Resolving start edge");
|
||||
let e = resolve_edge_as_edge(sections, edge_string, 0.0)?;
|
||||
match e {
|
||||
super::SectionEdge::Bot { section, .. } => Ok(super::StartEdge::Bot { section }),
|
||||
super::SectionEdge::Top { section, .. } => Ok(super::StartEdge::Top { section }),
|
||||
_ => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("bad section start specification `{}`", edge_string),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an edge specifiation string as a SectionEdge
|
||||
pub fn resolve_edge_as_edge(
|
||||
sections: &HashMap<ContentIndex, Arc<SpriteSection>>,
|
||||
edge_string: &str,
|
||||
duration: f32,
|
||||
) -> ContentLoadResult<super::SectionEdge> {
|
||||
if edge_string == "hidden" {
|
||||
return Ok(super::SectionEdge::Top {
|
||||
section: crate::HIDDEN_SPRITE_SECTION.clone(),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
if edge_string == "stop" {
|
||||
return Ok(super::SectionEdge::Stop);
|
||||
}
|
||||
|
||||
if edge_string == "reverse" {
|
||||
return Ok(super::SectionEdge::Reverse { duration });
|
||||
}
|
||||
|
||||
if edge_string == "repeat" {
|
||||
return Ok(super::SectionEdge::Repeat { duration });
|
||||
}
|
||||
|
||||
let (section_name, start_point) = match edge_string.split_once(":") {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("bad section edge specification `{}`", edge_string),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let section = match sections.get(&ContentIndex::new(section_name)) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("section `{}` doesn't exist", section_name),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
match start_point {
|
||||
"top" => Ok(super::SectionEdge::Top {
|
||||
section: section.clone(),
|
||||
duration,
|
||||
}),
|
||||
"bot" => Ok(super::SectionEdge::Bot {
|
||||
section: section.clone(),
|
||||
duration,
|
||||
}),
|
||||
_ => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("invalid target `{}`", start_point),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Build for Sprite {
|
||||
type InputSyntaxType = HashMap<String, syntax::Sprite>;
|
||||
|
||||
fn build(
|
||||
sprites: Self::InputSyntaxType,
|
||||
_build_context: &mut ContentBuildContext,
|
||||
ct: &mut Content,
|
||||
) -> ContentLoadResult<()> {
|
||||
for (sprite_name, t) in sprites {
|
||||
info!(message = "Parsing sprite", sprite = sprite_name);
|
||||
|
||||
match t {
|
||||
syntax::Sprite::Static(t) => {
|
||||
let idx = match ct.sprite_atlas.get_idx_by_path(&t.file) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!(
|
||||
"file `{}` isn't in the sprite atlas, cannot proceed",
|
||||
t.file.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
let img = &ct.sprite_atlas.get_by_idx(idx);
|
||||
let aspect = img.w / img.h;
|
||||
|
||||
let section = Arc::new(SpriteSection {
|
||||
frames: vec![img.idx.into()],
|
||||
// We implement unanimated sprites with a very fast framerate
|
||||
// and STOP endpoints.
|
||||
frame_duration: 0.01,
|
||||
edge_top: SectionEdge::Stop,
|
||||
edge_bot: SectionEdge::Stop,
|
||||
});
|
||||
|
||||
let sprite = Arc::new(Self {
|
||||
index: ContentIndex::new(&sprite_name),
|
||||
start_at: StartEdge::Top {
|
||||
section: section.clone(),
|
||||
},
|
||||
sections: {
|
||||
let mut h = HashMap::new();
|
||||
h.insert(ContentIndex::new("anim"), section);
|
||||
h
|
||||
},
|
||||
aspect,
|
||||
});
|
||||
ct.sprites.insert(sprite.index.clone(), sprite);
|
||||
}
|
||||
syntax::Sprite::OneSection(s) => {
|
||||
let (dim, section) = s.add_to(ct)?;
|
||||
let aspect = dim.0 as f32 / dim.1 as f32;
|
||||
|
||||
let section = Arc::new(section);
|
||||
|
||||
let sprite = Arc::new(Self {
|
||||
index: ContentIndex::new(&sprite_name),
|
||||
start_at: StartEdge::Top {
|
||||
section: section.clone(),
|
||||
},
|
||||
sections: {
|
||||
let mut h = HashMap::new();
|
||||
h.insert(ContentIndex::new("anim"), section.clone());
|
||||
h
|
||||
},
|
||||
aspect,
|
||||
});
|
||||
ct.sprites.insert(sprite.index.clone(), sprite);
|
||||
}
|
||||
syntax::Sprite::Complete(s) => {
|
||||
let mut sections = HashMap::new();
|
||||
|
||||
let mut dim = None;
|
||||
|
||||
for (name, sec) in &s.section {
|
||||
info!(
|
||||
message = "Parsing sprite section",
|
||||
sprite = sprite_name,
|
||||
section = ?name
|
||||
);
|
||||
|
||||
let (d, s) = sec.add_to(ct)?;
|
||||
|
||||
// Make sure all dimensions are the same
|
||||
if dim.is_none() {
|
||||
dim = Some(d);
|
||||
} else if dim.unwrap() != d {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!(
|
||||
"could not load sprite, image sizes in section `{}` are different",
|
||||
name
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
sections.insert(name.clone(), Arc::new(s));
|
||||
}
|
||||
|
||||
// We need to clone this here, thanks to self-reference.
|
||||
let tmp_sections = sections.clone();
|
||||
|
||||
for (name, sec) in &s.section {
|
||||
let parsed_sec = sections.get_mut(name).unwrap();
|
||||
let parsed_sec = Arc::make_mut(parsed_sec);
|
||||
sec.resolve_edges(&tmp_sections, parsed_sec)?;
|
||||
}
|
||||
|
||||
let dim = dim.unwrap();
|
||||
let aspect = dim.0 as f32 / dim.1 as f32;
|
||||
|
||||
let start_at = resolve_edge_as_start(§ions, &s.start_at.val)?;
|
||||
|
||||
let sprite = Arc::new(Self {
|
||||
index: ContentIndex::new(&sprite_name),
|
||||
start_at,
|
||||
sections,
|
||||
aspect,
|
||||
});
|
||||
ct.sprites.insert(sprite.index.clone(), sprite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
317
lib/content/src/part/system.rs
Normal file
317
lib/content/src/part/system.rs
Normal file
@ -0,0 +1,317 @@
|
||||
use galactica_util::to_radians;
|
||||
use nalgebra::{Point2, Point3};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
error::{ContentLoadError, ContentLoadResult},
|
||||
util::Polar,
|
||||
Content, ContentBuildContext, ContentIndex, Outfit, Sprite,
|
||||
};
|
||||
|
||||
pub(crate) mod syntax {
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ContentIndex;
|
||||
// Raw serde syntax structs.
|
||||
// These are never seen by code outside this crate.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct System {
|
||||
pub name: String,
|
||||
pub object: HashMap<ContentIndex, Object>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Object {
|
||||
pub sprite: ContentIndex,
|
||||
pub position: Position,
|
||||
pub size: f32,
|
||||
pub radius: Option<f32>,
|
||||
pub angle: Option<f32>,
|
||||
pub landable: Option<Landable>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Landable {
|
||||
pub name: String,
|
||||
pub desc: String,
|
||||
pub image: ContentIndex,
|
||||
pub outfitter: Vec<ContentIndex>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Position {
|
||||
Polar(PolarCoords),
|
||||
Cartesian(CoordinatesThree),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PolarCoords {
|
||||
pub center: CoordinatesTwo,
|
||||
pub radius: f32,
|
||||
pub angle: f32,
|
||||
pub z: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CoordinatesTwo {
|
||||
Label(ContentIndex),
|
||||
Coords([f32; 2]),
|
||||
}
|
||||
|
||||
impl ToString for CoordinatesTwo {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
Self::Label(s) => s.to_string(),
|
||||
Self::Coords(v) => format!("{:?}", v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CoordinatesThree {
|
||||
Label(ContentIndex),
|
||||
Coords([f32; 3]),
|
||||
}
|
||||
|
||||
impl ToString for CoordinatesThree {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
Self::Label(s) => s.to_string(),
|
||||
Self::Coords(v) => format!("{:?}", v),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Processed data structs.
|
||||
// These are exported.
|
||||
|
||||
/// Represents a star system
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct System {
|
||||
/// This object's name
|
||||
pub display_name: String,
|
||||
|
||||
/// This object's index
|
||||
pub index: ContentIndex,
|
||||
|
||||
/// Objects in this system
|
||||
pub objects: HashMap<ContentIndex, Arc<SystemObject>>,
|
||||
}
|
||||
|
||||
/// Represents an orbiting body in a star system
|
||||
/// (A star, planet, moon, satellite, etc)
|
||||
/// These may be landable and may be decorative.
|
||||
/// System objects to not interact with the physics engine.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemObject {
|
||||
/// This object's index
|
||||
pub index: ContentIndex,
|
||||
|
||||
/// This object's sprite
|
||||
pub sprite: Arc<Sprite>,
|
||||
|
||||
/// This object's size.
|
||||
/// Measured as height in game units.
|
||||
/// This value is scaled for distance
|
||||
/// (i.e, the z-value of position)
|
||||
pub size: f32,
|
||||
|
||||
/// This object's position, in game coordinates,
|
||||
/// relative to the system's center (0, 0).
|
||||
pub pos: Point3<f32>,
|
||||
|
||||
/// This object's sprite's angle, in radians
|
||||
pub angle: f32,
|
||||
|
||||
/// If true, ships may land on this object
|
||||
pub landable: Option<LandableSystemObject>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LandableSystemObject {
|
||||
/// This object's name
|
||||
pub display_name: String,
|
||||
|
||||
/// The description of this object
|
||||
pub desc: String,
|
||||
|
||||
/// This object's image
|
||||
pub image: Arc<Sprite>,
|
||||
|
||||
/// The outfits we can buy here
|
||||
/// If this is empty, this landable has no outfitter.
|
||||
pub outfitter: Vec<Arc<Outfit>>,
|
||||
}
|
||||
|
||||
/// Helper function for resolve_position, never called on its own.
|
||||
fn resolve_coordinates(
|
||||
objects: &HashMap<ContentIndex, syntax::Object>,
|
||||
cor: &syntax::CoordinatesThree,
|
||||
mut cycle_detector: HashSet<ContentIndex>,
|
||||
) -> ContentLoadResult<Point3<f32>> {
|
||||
match cor {
|
||||
syntax::CoordinatesThree::Coords(c) => Ok((*c).into()),
|
||||
syntax::CoordinatesThree::Label(l) => {
|
||||
if cycle_detector.contains(l) {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!(
|
||||
"Found coordinate cycle: `{}`",
|
||||
cycle_detector.iter().fold(String::new(), |sum, a| {
|
||||
if sum.is_empty() {
|
||||
a.to_string()
|
||||
} else {
|
||||
format!("{sum} -> {a}")
|
||||
}
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
cycle_detector.insert(l.clone());
|
||||
|
||||
let p = match objects.get(l) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("Could not resolve coordinate label `{l}`"),
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(resolve_position(&objects, &p, cycle_detector)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given an object, resolve its position as a Point3.
|
||||
fn resolve_position(
|
||||
objects: &HashMap<ContentIndex, syntax::Object>,
|
||||
obj: &syntax::Object,
|
||||
cycle_detector: HashSet<ContentIndex>,
|
||||
) -> ContentLoadResult<Point3<f32>> {
|
||||
match &obj.position {
|
||||
syntax::Position::Cartesian(c) => Ok(resolve_coordinates(objects, &c, cycle_detector)?),
|
||||
syntax::Position::Polar(p) => {
|
||||
let three = match &p.center {
|
||||
syntax::CoordinatesTwo::Label(s) => syntax::CoordinatesThree::Label(s.clone()),
|
||||
syntax::CoordinatesTwo::Coords(v) => {
|
||||
syntax::CoordinatesThree::Coords([v[0], v[1], f32::NAN])
|
||||
}
|
||||
};
|
||||
let r = resolve_coordinates(&objects, &three, cycle_detector)?;
|
||||
let plane = Polar {
|
||||
center: Point2::new(r.x, r.y),
|
||||
radius: p.radius,
|
||||
angle: to_radians(p.angle),
|
||||
}
|
||||
.to_cartesian();
|
||||
Ok(Point3::new(plane.x, plane.y, p.z))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Build for System {
|
||||
type InputSyntaxType = HashMap<String, syntax::System>;
|
||||
|
||||
fn build(
|
||||
system: Self::InputSyntaxType,
|
||||
_build_context: &mut ContentBuildContext,
|
||||
content: &mut Content,
|
||||
) -> ContentLoadResult<()> {
|
||||
for (system_name, system) in system {
|
||||
info!(message = "Parsing system", system = system_name);
|
||||
|
||||
let mut objects = HashMap::new();
|
||||
|
||||
for (index, object) in &system.object {
|
||||
info!(
|
||||
message = "Parsing system object",
|
||||
system = system_name,
|
||||
object_idx = ?index
|
||||
);
|
||||
|
||||
let mut cycle_detector = HashSet::new();
|
||||
cycle_detector.insert(index.clone());
|
||||
|
||||
let sprite = match content.sprites.get(&object.sprite) {
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("sprite `{}` doesn't exist", object.sprite),
|
||||
});
|
||||
}
|
||||
|
||||
Some(t) => t.clone(),
|
||||
};
|
||||
|
||||
let landable = 'landable: {
|
||||
if object.landable.is_none() {
|
||||
break 'landable None;
|
||||
}
|
||||
let l = object.landable.as_ref().unwrap();
|
||||
|
||||
let image = match content.sprites.get(&l.image) {
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("sprite `{}` doesn't exist", object.sprite),
|
||||
});
|
||||
}
|
||||
|
||||
Some(t) => t.clone(),
|
||||
};
|
||||
|
||||
let mut outfitter = Vec::new();
|
||||
for o in &l.outfitter {
|
||||
match content.outfits.get(&o) {
|
||||
Some(x) => outfitter.push(x.clone()),
|
||||
None => {
|
||||
return Err(ContentLoadError::Generic {
|
||||
msg: format!("outfit `{o}` doesn't exist"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break 'landable Some(LandableSystemObject {
|
||||
image,
|
||||
outfitter,
|
||||
display_name: l.name.clone(),
|
||||
// TODO: better linebreaks, handle double spaces
|
||||
// Tabs
|
||||
desc: l.desc.replace("\n", " ").replace("<br>", "\n"),
|
||||
});
|
||||
};
|
||||
|
||||
objects.insert(
|
||||
index.clone(),
|
||||
Arc::new(SystemObject {
|
||||
index: index.clone(),
|
||||
sprite,
|
||||
pos: resolve_position(&system.object, &object, cycle_detector)?,
|
||||
size: object.size,
|
||||
angle: to_radians(object.angle.unwrap_or(0.0)),
|
||||
landable,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
content.systems.insert(
|
||||
ContentIndex::new(&system_name),
|
||||
Arc::new(Self {
|
||||
index: ContentIndex::new(&system_name),
|
||||
display_name: system.name,
|
||||
objects,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
308
lib/content/src/spriteautomaton.rs
Normal file
308
lib/content/src/spriteautomaton.rs
Normal file
@ -0,0 +1,308 @@
|
||||
use crate::{SectionEdge, Sprite, SpriteSection, StartEdge};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A single frame's state
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnimationState {
|
||||
/// The index of the texture we're fading from
|
||||
pub texture_a: u32,
|
||||
|
||||
/// The index of the texture we're fading to
|
||||
pub texture_b: u32,
|
||||
|
||||
/// Between 0.0 and 1.0, denoting how far we are between
|
||||
/// texture_a and texture_b
|
||||
/// 0.0 means fully show texture_a;
|
||||
/// 1.0 means fully show texture_b.
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
impl AnimationState {
|
||||
/// Convenience method.
|
||||
/// Get texture index as an array
|
||||
pub fn texture_index(&self) -> [u32; 2] {
|
||||
[self.texture_a, self.texture_b]
|
||||
}
|
||||
}
|
||||
|
||||
/// What direction are we playing our animation in?
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum AnimDirection {
|
||||
/// Top to bottom, with increasing frame indices
|
||||
/// (normal)
|
||||
Up,
|
||||
|
||||
/// Bottom to top, with decreasing frame indices
|
||||
/// (reverse)
|
||||
Down,
|
||||
|
||||
/// Stopped on one frame
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Manages a single sprite's animation state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpriteAutomaton {
|
||||
/// The sprite we're animating
|
||||
sprite: Arc<Sprite>,
|
||||
|
||||
/// Which animation section we're on
|
||||
/// This MUST be a section from this Automaton's sprite
|
||||
current_section: Arc<SpriteSection>,
|
||||
|
||||
/// Which frame we're on
|
||||
current_frame: usize,
|
||||
|
||||
/// Where we are between frames.
|
||||
current_edge_progress: f32,
|
||||
|
||||
/// The length of the current edge we're on, in seconds
|
||||
current_edge_duration: f32,
|
||||
|
||||
/// In what direction are we playing the current section?
|
||||
current_direction: AnimDirection,
|
||||
|
||||
/// The texture we're fading from
|
||||
/// (if we're moving downwards)
|
||||
last_texture: u32,
|
||||
|
||||
/// The texture we're fading to
|
||||
/// (if we're moving downwards)
|
||||
next_texture: u32,
|
||||
|
||||
/// If this is some, take this edge next
|
||||
next_edge_override: Option<SectionEdge>,
|
||||
}
|
||||
|
||||
impl SpriteAutomaton {
|
||||
/// Create a new AnimAutomaton
|
||||
pub fn new(sprite: Arc<Sprite>) -> Self {
|
||||
let (current_section, texture, current_direction) = {
|
||||
match &sprite.start_at {
|
||||
StartEdge::Top { section } => (
|
||||
section,
|
||||
*section.frames.first().unwrap(),
|
||||
AnimDirection::Down,
|
||||
),
|
||||
StartEdge::Bot { section } => {
|
||||
(section, *section.frames.last().unwrap(), AnimDirection::Up)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
sprite: sprite.clone(),
|
||||
current_frame: 0,
|
||||
|
||||
current_edge_progress: match current_direction {
|
||||
AnimDirection::Down => 0.0,
|
||||
AnimDirection::Up => current_section.frame_duration,
|
||||
AnimDirection::Stop => unreachable!("how'd you get here?"),
|
||||
},
|
||||
|
||||
current_edge_duration: current_section.frame_duration,
|
||||
next_edge_override: None,
|
||||
|
||||
current_direction,
|
||||
current_section: current_section.clone(),
|
||||
last_texture: texture,
|
||||
next_texture: texture,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the next section transition
|
||||
pub fn next_edge(&mut self, s: SectionEdge) {
|
||||
self.next_edge_override = Some(s);
|
||||
}
|
||||
|
||||
/// Force a transition to the given section right now
|
||||
pub fn jump_to(&mut self, start: &SectionEdge) {
|
||||
self.take_edge(start);
|
||||
}
|
||||
|
||||
fn take_edge(&mut self, e: &SectionEdge) {
|
||||
let last = match self.current_direction {
|
||||
AnimDirection::Stop => self.next_texture,
|
||||
AnimDirection::Down => self.next_texture,
|
||||
AnimDirection::Up => self.last_texture,
|
||||
};
|
||||
|
||||
match e {
|
||||
SectionEdge::Stop => {
|
||||
match self.current_direction {
|
||||
AnimDirection::Stop => {}
|
||||
AnimDirection::Up => {
|
||||
self.current_frame = 0;
|
||||
}
|
||||
AnimDirection::Down => {
|
||||
self.current_frame = self.current_section.frames.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
self.current_edge_duration = 1.0;
|
||||
self.current_direction = AnimDirection::Stop;
|
||||
}
|
||||
SectionEdge::Top { section, duration } => {
|
||||
self.current_section = section.clone();
|
||||
self.current_edge_duration = *duration;
|
||||
self.current_frame = 0;
|
||||
self.current_direction = AnimDirection::Down;
|
||||
}
|
||||
SectionEdge::Bot { section, duration } => {
|
||||
self.current_section = section.clone();
|
||||
self.current_frame = section.frames.len() - 1;
|
||||
self.current_edge_duration = *duration;
|
||||
self.current_direction = AnimDirection::Up;
|
||||
}
|
||||
SectionEdge::Repeat { duration } => {
|
||||
match self.current_direction {
|
||||
AnimDirection::Stop => {}
|
||||
AnimDirection::Up => {
|
||||
self.current_frame = self.current_section.frames.len() - 1;
|
||||
}
|
||||
AnimDirection::Down => {
|
||||
self.current_frame = 0;
|
||||
}
|
||||
}
|
||||
self.current_edge_duration = *duration;
|
||||
}
|
||||
SectionEdge::Reverse { duration } => {
|
||||
match self.current_direction {
|
||||
AnimDirection::Stop => {}
|
||||
AnimDirection::Up => {
|
||||
// Jump to SECOND frame, since we've already shown the
|
||||
// first during the fade transition
|
||||
self.current_frame = {
|
||||
if self.current_section.frames.len() == 1 {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
};
|
||||
self.current_direction = AnimDirection::Down;
|
||||
}
|
||||
AnimDirection::Down => {
|
||||
self.current_frame = {
|
||||
if self.current_section.frames.len() == 1 {
|
||||
0
|
||||
} else {
|
||||
self.current_section.frames.len() - 2
|
||||
}
|
||||
};
|
||||
self.current_direction = AnimDirection::Up;
|
||||
}
|
||||
}
|
||||
self.current_edge_duration = *duration;
|
||||
}
|
||||
}
|
||||
|
||||
match self.current_direction {
|
||||
AnimDirection::Stop => {
|
||||
self.next_texture = self.current_section.frames[self.current_frame];
|
||||
self.last_texture = self.current_section.frames[self.current_frame];
|
||||
}
|
||||
AnimDirection::Down => {
|
||||
self.last_texture = last;
|
||||
self.next_texture = self.current_section.frames[self.current_frame];
|
||||
self.current_edge_progress = 0.0;
|
||||
}
|
||||
AnimDirection::Up => {
|
||||
self.next_texture = last;
|
||||
self.last_texture = self.current_section.frames[self.current_frame];
|
||||
self.current_edge_progress = self.current_edge_duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Step this animation by `t` seconds
|
||||
pub fn step(&mut self, t: f32) {
|
||||
// Current_fade and current_frame keep track of where we are in the current section.
|
||||
// current_frame indexes this section frames. When it exceeds the number of frames
|
||||
// or falls below zero (when moving in reverse), we switch to the next section.
|
||||
//
|
||||
// current_fade keeps track of our state between frames. It is zero once a frame starts,
|
||||
// and we switch to the next frame when it hits 1.0. If we are stepping foward, it increases,
|
||||
// and if we are stepping backwards, it decreases.
|
||||
|
||||
// Note that frame_duration may be zero!
|
||||
// This is only possible in the hidden texture, since
|
||||
// user-provided sections are always checked to be positive.
|
||||
assert!(self.current_section.frame_duration >= 0.0);
|
||||
|
||||
match self.current_direction {
|
||||
AnimDirection::Stop => {
|
||||
// Edge case: we're stopped and got a request to transition.
|
||||
// we should transition right away.
|
||||
|
||||
if self.next_edge_override.is_some() {
|
||||
let e = self.next_edge_override.take().unwrap();
|
||||
self.take_edge(&e);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
AnimDirection::Down => {
|
||||
self.current_edge_progress += t;
|
||||
|
||||
// We're stepping foward and finished this frame
|
||||
if self.current_edge_progress > self.current_edge_duration {
|
||||
if self.current_frame < self.current_section.frames.len() - 1 {
|
||||
self.current_frame += 1;
|
||||
self.last_texture = self.next_texture;
|
||||
self.next_texture = self.current_section.frames[self.current_frame];
|
||||
self.current_edge_progress = 0.0;
|
||||
self.current_edge_duration = self.current_section.frame_duration;
|
||||
} else {
|
||||
let e = {
|
||||
if self.next_edge_override.is_some() {
|
||||
self.next_edge_override.take().unwrap()
|
||||
} else {
|
||||
self.current_section.edge_bot.clone()
|
||||
}
|
||||
};
|
||||
self.take_edge(&e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimDirection::Up => {
|
||||
self.current_edge_progress -= t;
|
||||
|
||||
// We're stepping backward and finished this frame
|
||||
if self.current_edge_progress < 0.0 {
|
||||
if self.current_frame > 0 {
|
||||
self.current_frame -= 1;
|
||||
self.next_texture = self.last_texture;
|
||||
self.last_texture = self.current_section.frames[self.current_frame];
|
||||
self.current_edge_progress = self.current_section.frame_duration;
|
||||
self.current_edge_duration = self.current_section.frame_duration;
|
||||
} else {
|
||||
let e = {
|
||||
if self.next_edge_override.is_some() {
|
||||
self.next_edge_override.take().unwrap()
|
||||
} else {
|
||||
self.current_section.edge_top.clone()
|
||||
}
|
||||
};
|
||||
self.take_edge(&e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current frame of this animation
|
||||
pub fn get_texture_idx(&self) -> AnimationState {
|
||||
return AnimationState {
|
||||
texture_a: self.last_texture,
|
||||
texture_b: self.next_texture,
|
||||
fade: self.current_edge_progress / self.current_edge_duration,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the sprite this automaton is using
|
||||
pub fn get_sprite(&self) -> Arc<Sprite> {
|
||||
self.sprite.clone()
|
||||
}
|
||||
}
|
24
lib/content/src/util.rs
Normal file
24
lib/content/src/util.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use nalgebra::{Point2, Vector2};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Polar {
|
||||
/// The center of this polar coordinate
|
||||
pub center: Point2<f32>,
|
||||
|
||||
/// The radius of this polar coordinate
|
||||
pub radius: f32,
|
||||
|
||||
/// In radians
|
||||
pub angle: f32,
|
||||
}
|
||||
|
||||
impl Polar {
|
||||
pub fn to_cartesian(self) -> Point2<f32> {
|
||||
let v = Vector2::new(
|
||||
self.radius * self.angle.sin(),
|
||||
self.radius * self.angle.cos(),
|
||||
);
|
||||
|
||||
return self.center + v;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user