From 33c20d47a094bdf94dcf7ddee7014c79dd26266d Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Thu, 16 Jul 2026 10:14:00 -0700 Subject: [PATCH] Add two ways to register action handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both remove the add_systems(.., run_if(on_action(..))) boilerplate: - `#[action_handler(path)]` derive attribute colocates a handler with its action variant and wires it for its kind — a gated plain system for instant/toggle, a typed pipe (axis_value/scalar_value) plus gate for axis/scalar. The derive generates an `impl GameActionHandlers` (empty when no variant declares one), which ActionsPlugin calls on build. - `App::on_action(schedule, action, system)` (AppActionExt) — the explicit, no-macro form that keeps registration visible in main. ActionsPlugin's Plugin impl now requires T: GameActionHandlers (satisfied automatically by the derive). Co-Authored-By: Claude Opus 4.8 --- engine/macros/src/lib.rs | 179 ++++++++++++++++++++++++++++++--------- engine/src/actions.rs | 68 ++++++++++++++- 2 files changed, 204 insertions(+), 43 deletions(-) diff --git a/engine/macros/src/lib.rs b/engine/macros/src/lib.rs index d59a13b..eab327b 100644 --- a/engine/macros/src/lib.rs +++ b/engine/macros/src/lib.rs @@ -1,31 +1,37 @@ //! Proc-macros for the `engine` crate. //! -//! Currently provides [`GameAction`](macro@GameAction), a derive that annotates -//! each enum variant with its [`ActionKind`] via `#[action_kind(...)]`. +//! Provides [`GameAction`](macro@GameAction), a derive that annotates each enum +//! variant with its [`ActionKind`] via `#[action_kind(...)]` and, optionally, a +//! system to run for it via `#[action_handler(...)]`. use proc_macro::TokenStream; use proc_macro_crate::{crate_name, FoundCrate}; use quote::{format_ident, quote}; -use syn::{spanned::Spanned, Data, DeriveInput, Fields, Ident}; +use syn::{spanned::Spanned, Data, DeriveInput, Fields, Ident, Path}; -/// Derives `engine::GameAction` for a fieldless enum. +/// Derives `engine::GameAction` (and `engine::GameActionHandlers`) for a +/// fieldless enum. /// /// Each variant must be annotated with `#[action_kind(...)]` — one of `instant`, -/// `toggle`, `axis`, or `scalar`: +/// `toggle`, `axis`, or `scalar` — and may carry any number of +/// `#[action_handler(path::to::system)]` attributes: /// /// ```ignore /// #[derive(Copy, Clone, Eq, PartialEq, Hash, GameAction)] /// enum Action { -/// #[action_kind(instant)] +/// #[action_kind(instant)] #[action_handler(jump)] /// Jump, -/// #[action_kind(toggle)] -/// Grid, +/// #[action_kind(axis)] #[action_handler(move_player)] // fn(In, ..) +/// Move, /// } /// ``` /// -/// This expands to an `impl GameAction for Action` whose `kind` matches each -/// variant to its declared [`ActionKind`]. -#[proc_macro_derive(GameAction, attributes(action_kind))] +/// This expands to an `impl GameAction` whose `kind` maps each variant to its +/// declared [`ActionKind`], plus an `impl GameActionHandlers` whose `add_handlers` +/// registers each handler wired for its kind — a gated plain system for +/// `instant`/`toggle`, a typed pipe (`axis_value`/`scalar_value`) plus gate for +/// `axis`/`scalar`. `ActionsPlugin` calls `add_handlers` for you. +#[proc_macro_derive(GameAction, attributes(action_kind, action_handler))] pub fn derive_game_action(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); expand(input) @@ -33,6 +39,26 @@ pub fn derive_game_action(input: TokenStream) -> TokenStream { .into() } +#[derive(Clone, Copy)] +enum Kind { + Instant, + Toggle, + Axis, + Scalar, +} + +impl Kind { + /// The `ActionKind` expression this maps to. + fn to_tokens(self, engine: &proc_macro2::TokenStream) -> proc_macro2::TokenStream { + match self { + Kind::Instant => quote!(#engine::ActionKind::Instant), + Kind::Toggle => quote!(#engine::ActionKind::Toggle), + Kind::Axis => quote!(#engine::ActionKind::Axis), + Kind::Scalar => quote!(#engine::ActionKind::Scalar), + } + } +} + fn expand(input: DeriveInput) -> syn::Result { let Data::Enum(data) = &input.data else { return Err(syn::Error::new( @@ -45,40 +71,93 @@ fn expand(input: DeriveInput) -> syn::Result { let ident = &input.ident; let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); - let arms = data - .variants - .iter() - .map(|variant| { - if !matches!(variant.fields, Fields::Unit) { - return Err(syn::Error::new( - variant.fields.span(), - "`GameAction` variants must be fieldless (unit variants)", - )); + let mut kind_arms = Vec::new(); + let mut handler_regs = Vec::new(); + + for variant in &data.variants { + if !matches!(variant.fields, Fields::Unit) { + return Err(syn::Error::new( + variant.fields.span(), + "`GameAction` variants must be fieldless (unit variants)", + )); + } + let variant_ident = &variant.ident; + let kind = parse_kind(variant)?; + let kind_tokens = kind.to_tokens(&engine); + kind_arms.push(quote!(Self::#variant_ident => #kind_tokens)); + + for handler in parse_handlers(variant)? { + handler_regs.push(gen_handler(&engine, variant_ident, kind, &handler)); + } + } + + // Keep the generated `add_handlers` warning-clean whether or not there are + // any handlers: no unused `app` param and no unused trait imports. + let handlers_body = if handler_regs.is_empty() { + quote! { + fn add_handlers(_app: &mut #engine::App) {} + } + } else { + quote! { + fn add_handlers(app: &mut #engine::App) { + // Bring the builder methods into scope without polluting names. + #[allow(unused_imports)] + use #engine::IntoScheduleConfigs as _; + #[allow(unused_imports)] + use #engine::IntoSystem as _; + #(#handler_regs)* } - let variant_ident = &variant.ident; - let kind = parse_kind(variant, &engine)?; - Ok(quote!(Self::#variant_ident => #kind)) - }) - .collect::>>()?; + } + }; Ok(quote! { impl #impl_generics #engine::GameAction for #ident #ty_generics #where_clause { fn kind(self) -> #engine::ActionKind { match self { - #(#arms,)* + #(#kind_arms,)* } } } + + impl #impl_generics #engine::GameActionHandlers for #ident #ty_generics #where_clause { + #handlers_body + } }) } -/// Parses the single required `#[action_kind(instant|toggle)]` attribute on a -/// variant into the corresponding `ActionKind` expression. -fn parse_kind( - variant: &syn::Variant, +/// Emits one `add_systems` call for a variant's handler, wired for its kind. +fn gen_handler( engine: &proc_macro2::TokenStream, -) -> syn::Result { - let mut kind: Option = None; + variant_ident: &Ident, + kind: Kind, + handler: &Path, +) -> proc_macro2::TokenStream { + let action = quote!(Self::#variant_ident); + match kind { + // No payload: gate a plain system on the action being active. + Kind::Instant | Kind::Toggle => quote! { + app.add_systems(#engine::Update, #handler.run_if(#engine::on_action(#action))); + }, + // Vec2 payload: pipe it in, gated so it runs while the axis is active. + Kind::Axis => quote! { + app.add_systems( + #engine::Update, + #engine::axis_value(#action).pipe(#handler).run_if(#engine::on_action(#action)), + ); + }, + // f32 payload: pipe it in, gated so it runs when the slider changes. + Kind::Scalar => quote! { + app.add_systems( + #engine::Update, + #engine::scalar_value(#action).pipe(#handler).run_if(#engine::on_action(#action)), + ); + }, + } +} + +/// Parses the single required `#[action_kind(...)]` attribute on a variant. +fn parse_kind(variant: &syn::Variant) -> syn::Result { + let mut kind: Option = None; for attr in &variant.attrs { if !attr.path().is_ident("action_kind") { @@ -90,20 +169,20 @@ fn parse_kind( "duplicate `#[action_kind(...)]` attribute", )); } - let variant_name: Ident = attr.parse_args().map_err(|_| { + let name: Ident = attr.parse_args().map_err(|_| { syn::Error::new( attr.span(), - "expected `#[action_kind(instant)]` or `#[action_kind(toggle)]`", + "expected `#[action_kind(instant|toggle|axis|scalar)]`", ) })?; - kind = Some(match variant_name.to_string().as_str() { - "instant" => quote!(#engine::ActionKind::Instant), - "toggle" => quote!(#engine::ActionKind::Toggle), - "axis" => quote!(#engine::ActionKind::Axis), - "scalar" => quote!(#engine::ActionKind::Scalar), + kind = Some(match name.to_string().as_str() { + "instant" => Kind::Instant, + "toggle" => Kind::Toggle, + "axis" => Kind::Axis, + "scalar" => Kind::Scalar, other => { return Err(syn::Error::new( - variant_name.span(), + name.span(), format!( "unknown action kind `{other}`, expected `instant`, `toggle`, `axis`, or `scalar`" ), @@ -116,13 +195,31 @@ fn parse_kind( syn::Error::new( variant.ident.span(), format!( - "variant `{}` needs `#[action_kind(instant)]` or `#[action_kind(toggle)]`", + "variant `{}` needs `#[action_kind(instant|toggle|axis|scalar)]`", variant.ident ), ) }) } +/// Parses every `#[action_handler(path)]` attribute on a variant (zero or more). +fn parse_handlers(variant: &syn::Variant) -> syn::Result> { + let mut handlers = Vec::new(); + for attr in &variant.attrs { + if !attr.path().is_ident("action_handler") { + continue; + } + let path: Path = attr.parse_args().map_err(|_| { + syn::Error::new( + attr.span(), + "expected `#[action_handler(path::to::system)]`", + ) + })?; + handlers.push(path); + } + Ok(handlers) +} + /// Resolves the path to the `engine` crate so the derive works whether it is /// invoked from a game, from `engine`'s own tests, or under a renamed dependency. fn engine_path() -> proc_macro2::TokenStream { diff --git a/engine/src/actions.rs b/engine/src/actions.rs index 2787c4e..592abd4 100644 --- a/engine/src/actions.rs +++ b/engine/src/actions.rs @@ -93,11 +93,24 @@ //! They compose — `action_value(a).pipe(sys).run_if(on_action(a))` delivers the //! payload *and* skips the system when the action is inactive — and neither is //! mandatory: any system may read [`ActionState`] directly. +//! +//! ## Registering handlers +//! +//! Two conveniences remove the `add_systems(.., run_if(on_action(..)))` boilerplate; +//! use whichever you prefer (or neither — plain `add_systems` always works): +//! +//! * **`#[action_handler(path)]`** on a variant (see the [`GameAction`](macro@GameAction) +//! derive) — colocates the handler with the action and wires it *for its kind* +//! automatically: a gated plain system for instant/toggle, a typed pipe for +//! axis/scalar. [`ActionsPlugin`] registers them when you add it. +//! * **[`AppActionExt::on_action`]** — `app.on_action(Update, Action::Jump, jump)`, +//! the explicit, no-macro form that keeps registration visible in `main`. use core::hash::Hash; use core::marker::PhantomData; use bevy::{ + ecs::{schedule::ScheduleLabel, system::ScheduleSystem}, platform::collections::{HashMap, HashSet}, prelude::*, ui::Checked, @@ -635,8 +648,58 @@ fn sync_checkboxes( } } +/// Registers a variant's `#[action_handler(..)]` systems onto the [`App`]. +/// +/// Implemented automatically by `#[derive(GameAction)]` — an empty impl when no +/// variant declares a handler — and called by [`ActionsPlugin`] in +/// [`build`](Plugin::build), so annotated handlers register themselves when you +/// add the plugin. You rarely name this trait directly; the derive owns it. +pub trait GameActionHandlers: GameAction { + /// Adds each `#[action_handler(..)]` system, wired for its kind: a gated plain + /// system for [`Instant`](ActionKind::Instant)/[`Toggle`](ActionKind::Toggle), + /// a typed pipe (`axis_value`/`scalar_value`) plus gate for + /// [`Axis`](ActionKind::Axis)/[`Scalar`](ActionKind::Scalar). + fn add_handlers(app: &mut App); +} + +/// [`App`] extension for wiring a system to an action without the +/// `run_if(on_action(..))` boilerplate. +/// +/// The un-macro'd counterpart to `#[action_handler(..)]`: fully explicit, keeps +/// registration visible in `main`, and needs no derive. +pub trait AppActionExt { + /// Adds `systems` to `schedule`, gated on `action` being active — shorthand + /// for `add_systems(schedule, systems.run_if(on_action(action)))`. + /// + /// ```rust,ignore + /// app.on_action(Update, Action::Jump, jump) + /// .on_action(Update, Action::Grid, draw_grid); + /// ``` + /// + /// This is the gated form (the system takes no `In`). For the value kinds, + /// pipe explicitly: `add_systems(schedule, axis_value(a).pipe(sys))`. + fn on_action( + &mut self, + schedule: impl ScheduleLabel, + action: T, + systems: impl IntoScheduleConfigs, + ) -> &mut Self; +} + +impl AppActionExt for App { + fn on_action( + &mut self, + schedule: impl ScheduleLabel, + action: T, + systems: impl IntoScheduleConfigs, + ) -> &mut Self { + self.add_systems(schedule, systems.run_if(on_action(action))) + } +} + /// Registers everything needed for action `T`: the [`ActionState`] and -/// [`InputMap`] resources, the hardware/UI fan-in, and checkbox syncing. +/// [`InputMap`] resources, the hardware/UI fan-in, checkbox syncing, and any +/// `#[action_handler(..)]` systems (via [`GameActionHandlers`]). /// /// Build it with [`new`](Self::new) and [`bind`](Self::bind); one instance per /// action type. @@ -668,7 +731,7 @@ impl ActionsPlugin { } } -impl Plugin for ActionsPlugin { +impl Plugin for ActionsPlugin { fn build(&self, app: &mut App) { app.init_resource::>() .insert_resource(self.map.clone()) @@ -678,5 +741,6 @@ impl Plugin for ActionsPlugin { .add_observer(button_to_action::) .add_observer(checkbox_to_action::) .add_observer(slider_to_action::); + T::add_handlers(app); } }