use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Shape {
Unit,
Literal,
Name,
Unary,
Binary,
List,
Fields,
}
pub(crate) struct Operand {
pub key: &'static str,
pub query: bool,
}
pub(crate) fn operands(verb: &str) -> &'static [Operand] {
const QUERY: Operand = Operand {
key: "query",
query: true,
};
const fn expr(key: &'static str) -> Operand {
Operand { key, query: false }
}
const NEAREST: &[Operand] = &[QUERY, expr("of")];
const COUNT_WITHIN: &[Operand] = &[QUERY, expr("of"), expr("radius")];
const RAYCAST: &[Operand] = &[QUERY, expr("from"), expr("dir"), expr("distance")];
match verb {
"nearest" => NEAREST,
"count_within" => COUNT_WITHIN,
"raycast" => RAYCAST,
_ => &[],
}
}
pub(crate) struct Entry {
pub verb: &'static str,
pub hint: &'static str,
}
pub(crate) const SOURCES: &[Entry] = &[
Entry {
verb: "start",
hint: "once, when the world starts",
},
Entry {
verb: "tick",
hint: "every frame",
},
Entry {
verb: "timer",
hint: "after an interval, once or repeating",
},
Entry {
verb: "variable",
hint: "whenever a world variable changes",
},
Entry {
verb: "enter",
hint: "on entering a trigger volume",
},
Entry {
verb: "exit",
hint: "on leaving a trigger volume",
},
Entry {
verb: "interact",
hint: "on interacting with an entity",
},
Entry {
verb: "spawned",
hint: "the tick after an entity spawns",
},
];
pub(crate) const NODES: &[Entry] = &[
Entry {
verb: "if",
hint: "run nodes when a condition holds",
},
Entry {
verb: "for_each",
hint: "run nodes per entity a query matched",
},
Entry {
verb: "after",
hint: "run nodes once, after a wait",
},
Entry {
verb: "let",
hint: "bind an expression to a name",
},
Entry {
verb: "set",
hint: "write a world variable",
},
Entry {
verb: "set_local",
hint: "write a per-entity local",
},
Entry {
verb: "set_transform",
hint: "move, rotate, or scale an entity",
},
Entry {
verb: "spawn",
hint: "copy a placement into the world",
},
Entry {
verb: "despawn",
hint: "remove an entity and its children",
},
Entry {
verb: "reparent",
hint: "re-point an entity's parent",
},
Entry {
verb: "show",
hint: "reveal a hidden entity",
},
Entry {
verb: "hide",
hint: "hide an entity without removing it",
},
Entry {
verb: "wake",
hint: "wake an entity's physics body",
},
Entry {
verb: "sound",
hint: "play an audio clip",
},
Entry {
verb: "scene",
hint: "jump to a scene",
},
Entry {
verb: "screen",
hint: "show a screen",
},
Entry {
verb: "story",
hint: "start or continue the story",
},
Entry {
verb: "save",
hint: "persist world variables and once state",
},
];
pub(crate) const EXPRS: &[Entry] = &[
Entry {
verb: "bool",
hint: "a true / false constant",
},
Entry {
verb: "int",
hint: "a whole-number constant",
},
Entry {
verb: "float",
hint: "a decimal constant",
},
Entry {
verb: "vec3",
hint: "a three-component constant",
},
Entry {
verb: "var",
hint: "read a world variable",
},
Entry {
verb: "local",
hint: "read a per-entity local",
},
Entry {
verb: "bind",
hint: "read a bound name",
},
Entry {
verb: "named",
hint: "an entity declared in the world",
},
Entry {
verb: "self",
hint: "the entity this behavior runs for",
},
Entry {
verb: "dt",
hint: "seconds since the previous tick",
},
Entry {
verb: "elapsed",
hint: "seconds since the world started",
},
Entry {
verb: "position",
hint: "an entity's world position",
},
Entry {
verb: "distance",
hint: "distance between two entities",
},
Entry {
verb: "alive",
hint: "whether an entity still exists",
},
Entry {
verb: "first",
hint: "the first entity a query matched",
},
Entry {
verb: "count",
hint: "how many entities a query matched",
},
Entry {
verb: "nearest",
hint: "the entity of a query nearest a point",
},
Entry {
verb: "count_within",
hint: "how many of a query sit within a radius",
},
Entry {
verb: "raycast",
hint: "the entity of a query a ray meets first",
},
Entry {
verb: "add",
hint: "sum",
},
Entry {
verb: "sub",
hint: "difference",
},
Entry {
verb: "mul",
hint: "product",
},
Entry {
verb: "div",
hint: "quotient",
},
Entry {
verb: "normalize",
hint: "a vector rescaled to unit length",
},
Entry {
verb: "eq",
hint: "equal",
},
Entry {
verb: "ne",
hint: "not equal",
},
Entry {
verb: "lt",
hint: "less than",
},
Entry {
verb: "le",
hint: "less than or equal",
},
Entry {
verb: "gt",
hint: "greater than",
},
Entry {
verb: "ge",
hint: "greater than or equal",
},
Entry {
verb: "all",
hint: "every condition holds",
},
Entry {
verb: "any",
hint: "some condition holds",
},
Entry {
verb: "not",
hint: "logical negation",
},
];
pub(crate) const LITERALS: &[Entry] = &[
Entry {
verb: "bool",
hint: "true / false",
},
Entry {
verb: "int",
hint: "a whole number",
},
Entry {
verb: "float",
hint: "a decimal",
},
Entry {
verb: "vec3",
hint: "three components",
},
];
pub(crate) fn known(entries: &[Entry], verb: &str) -> bool {
entries.iter().any(|e| e.verb == verb)
}
pub(crate) fn verb_of(value: &Value) -> &str {
if let Some(word) = value.as_str() {
return word;
}
match value.as_object() {
Some(map) if map.len() == 1 => map.keys().next().map(String::as_str).unwrap_or(""),
_ => "",
}
}
pub(crate) fn shape(verb: &str) -> Shape {
match verb {
"self" | "dt" | "elapsed" => Shape::Unit,
"bool" | "int" | "float" | "vec3" => Shape::Literal,
"var" | "local" | "bind" | "named" | "first" | "count" => Shape::Name,
"nearest" | "count_within" | "raycast" => Shape::Fields,
"position" | "alive" | "normalize" | "not" => Shape::Unary,
"all" | "any" => Shape::List,
_ => Shape::Binary,
}
}
pub(crate) fn single(verb: &str, body: Value) -> Value {
let mut map = serde_json::Map::new();
map.insert(verb.to_string(), body);
Value::Object(map)
}
pub(crate) fn source_default(verb: &str) -> Value {
match verb {
"timer" => json!({"timer": {"interval": 1.0, "repeat": false}}),
"variable" => json!({"variable": ""}),
"enter" | "exit" | "interact" => single(verb, Value::Null),
other => json!(other),
}
}
pub(crate) fn node_default(verb: &str) -> Value {
match verb {
"if" => json!({"if": {"cond": {"bool": true}, "then": [], "else": []}}),
"after" => json!({"after": {"seconds": {"float": 1.0}, "do": []}}),
"for_each" => json!({"for_each": {"query": "", "bind": "each", "do": []}}),
"let" => json!({"let": {"name": "value", "value": {"int": 0}}}),
"set" => json!({"set": {"var": "", "value": {"int": 0}, "add": false}}),
"set_local" => json!({"set_local": {"local": "", "value": {"int": 0}, "add": false}}),
"set_transform" => json!({"set_transform": {
"entity": "self", "position": null, "rotation_deg": null, "scale": null}}),
"spawn" => json!({"spawn": {
"template": null, "position": [0.0, 0.0, 0.0], "rotation_deg": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0], "lifetime": 0.0, "bind": null}}),
"despawn" | "show" | "hide" | "wake" => single(verb, json!({"target": "self"})),
"reparent" => json!({"reparent": {"child": "self", "parent": null}}),
"sound" => json!({"sound": {"clip": null, "kind": "sound", "volume": 1.0}}),
"scene" => json!({"scene": {"scene": null, "transition": "FadeBlack"}}),
"screen" => json!({"screen": {"screen": null}}),
"story" => json!({"story": "start"}),
_ => single("save", Value::Null),
}
}
pub(crate) fn literal_default(verb: &str) -> Value {
single(verb, literal_body(&Value::Null, verb))
}
pub(crate) fn name_body(verb: &str, text: &str) -> Value {
if verb == "named" && text.is_empty() {
Value::Null
} else {
Value::String(text.to_string())
}
}
pub(crate) fn expr_default(verb: &str) -> Value {
match shape(verb) {
Shape::Unit => json!(verb),
Shape::Literal => literal_default(verb),
Shape::Name => single(verb, name_body(verb, "")),
Shape::Unary => single(verb, unary_operand(verb)),
Shape::List => single(verb, json!([])),
Shape::Binary => single(verb, binary_operands(verb)),
Shape::Fields => single(verb, named_operands(verb)),
}
}
fn named_operands(verb: &str) -> Value {
match verb {
"nearest" => json!({"query": "", "of": "self"}),
"count_within" => json!({"query": "", "of": "self", "radius": {"float": 5.0}}),
_ => json!({
"query": "",
"from": "self",
"dir": {"vec3": [0.0, 0.0, -1.0]},
"distance": {"float": 20.0},
}),
}
}
fn unary_operand(verb: &str) -> Value {
match verb {
"position" | "alive" => json!("self"),
"normalize" => json!({"vec3": [0.0, 0.0, 0.0]}),
_ => json!({"bool": true}),
}
}
fn binary_operands(verb: &str) -> Value {
match verb {
"distance" => json!(["self", "self"]),
_ => json!([{"float": 0.0}, {"float": 0.0}]),
}
}
pub(crate) fn swap_expr(current: &Value, verb: &str) -> Value {
let (from, to) = (shape(verb_of(current)), shape(verb));
if from != to || to == Shape::Fields {
return expr_default(verb);
}
let body = body_of(current);
match to {
Shape::Unit => json!(verb),
Shape::Literal => single(verb, literal_body(body.unwrap_or(&Value::Null), verb)),
Shape::Name => single(
verb,
name_body(verb, body.and_then(Value::as_str).unwrap_or("")),
),
_ => single(verb, body.cloned().unwrap_or(Value::Null)),
}
}
pub(crate) fn swap_literal(current: &Value, verb: &str) -> Value {
let body = body_of(current);
single(verb, literal_body(body.unwrap_or(&Value::Null), verb))
}
pub(crate) fn body_of(value: &Value) -> Option<&Value> {
match value.as_object() {
Some(map) if map.len() == 1 => map.values().next(),
_ => None,
}
}
fn literal_body(from: &Value, verb: &str) -> Value {
let number = from.as_f64();
match verb {
"bool" => json!(from.as_bool().unwrap_or(number.is_some_and(|n| n != 0.0))),
"int" => json!(number.unwrap_or(0.0) as i64),
"float" => json!(number.unwrap_or(0.0)),
_ => match from.as_array() {
Some(a) if a.len() == 3 => from.clone(),
_ => json!([0.0, 0.0, 0.0]),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use concinnity_core::components::BehaviorExpr;
use concinnity_core::components::BehaviorNode;
use concinnity_core::components::BehaviorSource;
#[test]
fn every_node_default_parses_as_a_node() {
for entry in NODES {
let v = node_default(entry.verb);
assert_eq!(verb_of(&v), entry.verb);
serde_json::from_value::<BehaviorNode>(v)
.unwrap_or_else(|e| panic!("node `{}` does not parse: {e}", entry.verb));
}
}
#[test]
fn swapping_a_named_operand_expression_starts_fresh() {
let nearest = expr_default("nearest");
let raycast = swap_expr(&nearest, "raycast");
assert_eq!(raycast, expr_default("raycast"));
let distance = expr_default("distance");
assert_eq!(swap_expr(&distance, "nearest"), expr_default("nearest"));
assert_eq!(swap_expr(&raycast, "distance"), expr_default("distance"));
}
#[test]
fn a_named_operand_default_carries_every_operand() {
for verb in ["nearest", "count_within", "raycast"] {
assert_eq!(shape(verb), Shape::Fields, "{verb}");
let body = body_of(&expr_default(verb)).cloned().unwrap_or(Value::Null);
for operand in operands(verb) {
assert!(
body.get(operand.key).is_some(),
"`{verb}` default is missing `{}`: {body}",
operand.key,
);
}
}
}
#[test]
fn every_expr_default_parses_as_an_expression() {
for entry in EXPRS {
let v = expr_default(entry.verb);
assert_eq!(verb_of(&v), entry.verb);
serde_json::from_value::<BehaviorExpr>(v)
.unwrap_or_else(|e| panic!("expression `{}` does not parse: {e}", entry.verb));
}
}
#[test]
fn every_source_default_parses_as_a_source() {
for entry in SOURCES {
let v = source_default(entry.verb);
serde_json::from_value::<BehaviorSource>(v)
.unwrap_or_else(|e| panic!("source `{}` does not parse: {e}", entry.verb));
}
}
#[test]
fn fresh_operands_match_the_operator() {
assert_eq!(
expr_default("position"),
serde_json::json!({"position": "self"})
);
assert_eq!(
expr_default("normalize"),
serde_json::json!({"normalize": {"vec3": [0.0, 0.0, 0.0]}})
);
assert_eq!(
expr_default("distance"),
serde_json::json!({"distance": ["self", "self"]})
);
assert_eq!(
expr_default("not"),
serde_json::json!({"not": {"bool": true}})
);
}
#[test]
fn swapping_within_a_shape_keeps_the_operands() {
let lt = serde_json::json!({"lt": [{"float": 1.0}, {"float": 2.0}]});
assert_eq!(
swap_expr(<, "ge"),
serde_json::json!({"ge": [{"float": 1.0}, {"float": 2.0}]})
);
let var = serde_json::json!({"var": "health"});
assert_eq!(
swap_expr(&var, "local"),
serde_json::json!({"local": "health"})
);
}
#[test]
fn an_empty_name_swapped_to_named_names_nothing() {
let var = serde_json::json!({"var": ""});
assert_eq!(swap_expr(&var, "named"), serde_json::json!({"named": null}));
let named = serde_json::json!({"named": null});
assert_eq!(swap_expr(&named, "var"), serde_json::json!({"var": ""}));
assert_eq!(swap_expr(&named, "named"), named);
}
#[test]
fn swapping_across_shapes_starts_from_the_default() {
let lt = serde_json::json!({"lt": [{"float": 1.0}, {"float": 2.0}]});
assert_eq!(swap_expr(<, "not"), expr_default("not"));
assert_eq!(swap_expr(<, "self"), serde_json::json!("self"));
}
#[test]
fn literals_carry_their_number_across_the_numeric_types() {
let f = serde_json::json!({"float": 2.75});
assert_eq!(swap_literal(&f, "int"), serde_json::json!({"int": 2}));
assert_eq!(swap_literal(&f, "bool"), serde_json::json!({"bool": true}));
let v = serde_json::json!({"vec3": [1.0, 2.0, 3.0]});
assert_eq!(swap_literal(&v, "vec3"), v);
assert_eq!(swap_literal(&v, "float"), serde_json::json!({"float": 0.0}));
}
#[test]
fn verb_of_reads_bare_words_and_single_key_objects() {
assert_eq!(verb_of(&serde_json::json!("self")), "self");
assert_eq!(verb_of(&serde_json::json!({"int": 3})), "int");
assert_eq!(verb_of(&serde_json::json!({"a": 1, "b": 2})), "");
assert_eq!(verb_of(&Value::Null), "");
}
#[test]
fn every_entry_has_a_hint() {
for entries in [SOURCES, NODES, EXPRS, LITERALS] {
for e in entries {
assert!(!e.hint.is_empty(), "`{}` has no hint", e.verb);
}
}
}
}