Use a signal because health change is an event and more listeners may appear later. The player does not need a HUD reference.
damage method -> PlayerState.health -> player_health_changed(health)
-> HUD UiLabel
-> optional audio/achievement/analytics listeners
#[State]
struct PlayerState {
#[default = 100]
health: i32,
}
lifecycle!({});
methods!({
// pub so attackers can dispatch it via call_method!.
pub fn take_damage(&self, ctx: &mut ScriptContext<'_, API>, amount: i32) -> bool {
let health = with_state_mut!(ctx.run, PlayerState, ctx.id, |state| {
state.health = (state.health - amount.max(0)).max(0);
state.health
}).unwrap_or(0);
signal_emit!(
ctx.run,
signal!("player_health_changed"),
params![health]
);
health > 0
}
});lifecycle!({
fn on_all_init(&self, ctx: &mut ScriptContext<'_, API>) {
signal_connect!(
ctx.run,
ctx.id,
signal!("player_health_changed"),
func!("on_health_changed")
);
}
});
methods!({
// pub because signal dispatch uses the same glue as call_method!.
pub fn on_health_changed(&self, ctx: &mut ScriptContext<'_, API>, health: i32) {
with_node_mut!(ctx.run, UiLabel, ctx.id, |label| {
label.text = format!("Health: {health}").into();
});
}
});Audio, achievements, or analytics scripts may connect to the same signal without changing the player script.
No listeners is valid. A receiver removed later simply stops reacting. Use a method instead if the player must target one receiver and consume its reply. Do not inject every possible listener into player state.