diff --git a/engine/macros/src/lib.rs b/engine/macros/src/lib.rs index eab327b..781fdbc 100644 --- a/engine/macros/src/lib.rs +++ b/engine/macros/src/lib.rs @@ -63,7 +63,11 @@ fn expand(input: DeriveInput) -> syn::Result { let Data::Enum(data) = &input.data else { return Err(syn::Error::new( input.ident.span(), - "`GameAction` can only be derived for enums", + format!( + "`GameAction` can only be derived for enums, but `{}` is not one.\n\ + An action set is a fixed list of variants, e.g. `enum {} {{ #[action_kind(instant)] Jump }}`.", + input.ident, input.ident + ), )); }; @@ -78,7 +82,11 @@ fn expand(input: DeriveInput) -> syn::Result { if !matches!(variant.fields, Fields::Unit) { return Err(syn::Error::new( variant.fields.span(), - "`GameAction` variants must be fieldless (unit variants)", + format!( + "action variant `{0}` must be a unit variant (no fields).\n\ + Actions are plain identifiers used as map keys — write `{0}`, not `{0}(..)` or `{0} {{ .. }}`.", + variant.ident + ), )); } let variant_ident = &variant.ident; @@ -166,13 +174,20 @@ fn parse_kind(variant: &syn::Variant) -> syn::Result { if kind.is_some() { return Err(syn::Error::new( attr.span(), - "duplicate `#[action_kind(...)]` attribute", + format!( + "action variant `{}` has more than one `#[action_kind(..)]`; each variant declares exactly one kind", + variant.ident + ), )); } let name: Ident = attr.parse_args().map_err(|_| { syn::Error::new( attr.span(), - "expected `#[action_kind(instant|toggle|axis|scalar)]`", + format!( + "malformed `#[action_kind(..)]` on `{}`: expected a single kind — one of \ + `instant`, `toggle`, `axis`, or `scalar`, e.g. `#[action_kind(instant)]`", + variant.ident + ), ) })?; kind = Some(match name.to_string().as_str() { @@ -184,7 +199,8 @@ fn parse_kind(variant: &syn::Variant) -> syn::Result { return Err(syn::Error::new( name.span(), format!( - "unknown action kind `{other}`, expected `instant`, `toggle`, `axis`, or `scalar`" + "unknown action kind `{other}` on `{}`; expected `instant`, `toggle`, `axis`, or `scalar`", + variant.ident ), )) } @@ -195,7 +211,8 @@ fn parse_kind(variant: &syn::Variant) -> syn::Result { syn::Error::new( variant.ident.span(), format!( - "variant `{}` needs `#[action_kind(instant|toggle|axis|scalar)]`", + "action variant `{0}` is missing its kind; add one of \ + `#[action_kind(instant|toggle|axis|scalar)]`, e.g. `#[action_kind(instant)] {0}`", variant.ident ), ) @@ -212,7 +229,11 @@ fn parse_handlers(variant: &syn::Variant) -> syn::Result> { let path: Path = attr.parse_args().map_err(|_| { syn::Error::new( attr.span(), - "expected `#[action_handler(path::to::system)]`", + format!( + "malformed `#[action_handler(..)]` on `{}`: expected a path to a system \ + function, e.g. `#[action_handler(jump)]` or `#[action_handler(sim::step)]`", + variant.ident + ), ) })?; handlers.push(path); diff --git a/engine/src/actions.rs b/engine/src/actions.rs index 592abd4..c3abdc2 100644 --- a/engine/src/actions.rs +++ b/engine/src/actions.rs @@ -427,8 +427,18 @@ impl ActionState { /// ([`Toggle`](ActionKind::Toggle)), or `Slider` ([`Scalar`](ActionKind::Scalar)) — /// the engine wires the widget's events into [`ActionState`] and keeps checkboxes /// visually in sync. +/// +/// To spawn it inside a `bsn!` scene, the action type must be `Default` (Bevy's +/// template system blanket-requires `Clone + Default` for any component in a +/// scene), and the entry needs a turbofish so the generic can be inferred in +/// template position: `ActionSource::(MyAction::Jump)`. `Default` is +/// derived here conditionally, so it is available exactly when the action is. +/// +/// Note the `Default` bound lives only on this derived `Default` impl — the +/// `Component` impl stays generic over every [`GameAction`], so the fan-in +/// systems work for actions that are not `Default`. #[derive(Component, Copy, Clone, Debug, Default)] -pub struct ActionSource(pub T); +pub struct ActionSource(pub T); /// The two symmetrical entry points below let any system consume any action: /// @@ -744,3 +754,50 @@ impl Plugin for ActionsPlugin { T::add_handlers(app); } } + +#[cfg(test)] +mod tests { + use super::*; + + // Exercises the `#[derive(GameAction)]` codegen: every kind, plus an + // `#[action_handler(..)]` per kind wired for its expected signature. If the + // generated `impl GameAction` / `impl GameActionHandlers` (with its + // kind-specific `add_systems`/pipe wiring) fails to type-check, this won't + // compile. + #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)] + enum TestAction { + #[action_kind(instant)] + #[action_handler(on_jump)] + Jump, + #[action_kind(toggle)] + #[action_handler(on_grid)] + Grid, + #[action_kind(axis)] + #[action_handler(on_move)] + Move, + #[action_kind(scalar)] + #[action_handler(on_volume)] + Volume, + } + + fn on_jump() {} + fn on_grid() {} + fn on_move(_dir: In) {} + fn on_volume(_level: In) {} + + #[test] + fn derive_and_handlers_compile() { + assert_eq!(TestAction::Jump.kind(), ActionKind::Instant); + assert_eq!(TestAction::Move.kind(), ActionKind::Axis); + assert_eq!(TestAction::Volume.kind(), ActionKind::Scalar); + + // Registers the generated handlers, exercising the piped/gated wiring + // and the `AppActionExt::on_action` path shape. + let mut app = App::new(); + app.add_plugins( + ActionsPlugin::::new() + .bind(TestAction::Jump, [Binding::Key(KeyCode::Space)]), + ); + app.on_action(Update, TestAction::Jump, on_jump); + } +}