Add two ways to register action handlers

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 <noreply@anthropic.com>
main
Elijah Voigt 6 days ago
parent 40b3f2adfc
commit 33c20d47a0

@ -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<Vec2>, ..)
/// 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<proc_macro2::TokenStream> {
let Data::Enum(data) = &input.data else {
return Err(syn::Error::new(
@ -45,10 +71,10 @@ fn expand(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let ident = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let arms = data
.variants
.iter()
.map(|variant| {
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(),
@ -56,29 +82,82 @@ fn expand(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
));
}
let variant_ident = &variant.ident;
let kind = parse_kind(variant, &engine)?;
Ok(quote!(Self::#variant_ident => #kind))
})
.collect::<syn::Result<Vec<_>>>()?;
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)*
}
}
};
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<proc_macro2::TokenStream> {
let mut kind: Option<proc_macro2::TokenStream> = 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<Kind> {
let mut kind: Option<Kind> = 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<Vec<Path>> {
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 {

@ -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<T: GameAction>(
}
}
/// 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<T: GameAction, M>(
&mut self,
schedule: impl ScheduleLabel,
action: T,
systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
) -> &mut Self;
}
impl AppActionExt for App {
fn on_action<T: GameAction, M>(
&mut self,
schedule: impl ScheduleLabel,
action: T,
systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
) -> &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<T: GameAction> ActionsPlugin<T> {
}
}
impl<T: GameAction> Plugin for ActionsPlugin<T> {
impl<T: GameActionHandlers> Plugin for ActionsPlugin<T> {
fn build(&self, app: &mut App) {
app.init_resource::<ActionState<T>>()
.insert_resource(self.map.clone())
@ -678,5 +741,6 @@ impl<T: GameAction> Plugin for ActionsPlugin<T> {
.add_observer(button_to_action::<T>)
.add_observer(checkbox_to_action::<T>)
.add_observer(slider_to_action::<T>);
T::add_handlers(app);
}
}

Loading…
Cancel
Save