@yagejs-addons/feel
v0.1.0
Published
Composable game-feel cues for YAGE: visual motion, time, camera, audio, particles, and renderer effects
Maintainers
Readme
@yagejs-addons/feel
Compose the small responses that make an action readable and satisfying, then play them from one named trigger.
The playable example groups the effects
across four scenes. It covers impacts, trails, afterimages, highlights,
springs, visibility, cue composition, scene and target time effects, animation,
callbacks, shockwaves, camera modifiers, glitch, blur, implosion, dissolve,
practical recipes, and a custom effect. Press N or P to move between scenes
with a slide transition.
import { Feel, feelHitStop, feelParallel } from "@yagejs-addons/feel";
import {
feelCameraShake,
feelHitFlash,
feelScalePunch,
feelSquash,
} from "@yagejs-addons/feel/renderer";
enemy.add(
new Feel({
hit: feelParallel(
feelSquash({ target: enemySprite, amount: 0.2 }),
feelHitStop({ duration: 0.05 }),
feelCameraShake({ camera, intensity: 5 }),
feelHitFlash(enemySprite.fx, { color: 0xffffff }),
),
}),
);
enemy.get(Feel).play("hit");Install
npm install @yagejs-addons/feel @yagejs/core
# Install only the peers used by the optional entries you import:
npm install @yagejs/renderer @yagejs/effects
npm install @yagejs/audio
npm install @yagejs/particlesThe root entry imports only @yagejs/core. Renderer, audio, particles, and
recipes have separate entry points.
Compose cues
feelParallel(...nodes)starts every node together.feelSequence(...nodes)starts each node after the previous one finishes.feelDelay(seconds, node?)delays a node or creates an empty wait.feelRepeat(node, times, gap?)repeats a finite node a fixed number of times.feelLoop(node, gap?)repeats a finite node until release.defineFeelEffect(duration, create)adds a game-specific effect.defineFeelState(timing, create)adds an attack, hold, and release state.
Each cue also accepts trigger policy:
const feel = entity.add(
new Feel({
hit: {
effect: feelScalePunch({ target: playerSprite, scale: 1.15 }),
overlap: "restart", // "restart" (default), "ignore", or "allow"
chance: 0.9,
cooldown: 0.05,
intensity: [0.9, 1.1],
},
}),
);
feel.play("hit", { intensity: 1.5, duration: 0.3 });
feel.stop("hit");Chance and intensity ranges use YAGE's scene-scoped random service. Disabling
or destroying Feel stops every active cue and restores active effects. A
finite play-time duration scales the cue's child timings and local update
clock. Intensity is a non-negative multiplier and may exceed 1. release()
lets held states and loops finish their release behavior; stop() cancels
immediately.
FeelPulseTiming describes the finite rise-and-fall curve shared by renderer
pulses:
interface FeelPulseTiming {
duration?: number;
peakAt?: number;
attackEasing?: EasingFunction;
releaseEasing?: EasingFunction;
}Builders validate and capture duration and peakAt when called. Most pulses,
including opacity, recoil, and bounce, peak at 0.25 and use easeOutQuad for
both phases. Hit flash keeps its 0.12-second linear midpoint pulse. A play-time
duration scales the whole finite cue rather than replacing one builder's
timing.
Core effects
The root entry supplies:
feelHitStop,feelSlowMotion, andfeelTargetFreezefeelKeyframeAnimationandfeelCall
Save and load
Cue definitions, cooldown clocks, and active playbacks are runtime-only. Normal
entity setup constructs Feel when the game builds a scene, with no cue in
progress.
Renderer and camera modifiers, temporary filter attachments, time requests, particle-emission requests, live particles, and transient visual entities are also omitted. The saved base transform, camera, and visual properties remain unchanged by these effects.
feelCall, defineFeelEffect, feelKeyframeAnimation, and
feelSpriteAnimation can run user-supplied code. If that code writes to
an explicit game-owned state root, YAGE saves the resulting value normally.
Use renderer modifier handles for custom visual motion that should remain a
runtime effect.
Renderer effects
Import these from @yagejs-addons/feel/renderer:
feelSpriteAnimationfeelPositionPunch,feelPositionSpring,feelRecoil, andfeelBouncefeelRotationPunch,feelRotationSpring, andfeelRotationShakefeelScalePunch,feelScaleSpring,feelScaleShake,feelSquash, andfeelTransformShakefeelCameraShake,feelCameraRotation, andfeelCameraZoomfeelHitFlashandfeelShockwavefeelGlitch, which appears quickly, holds, and refreshes its bands from the cue's seeded random sourcefeelDissolve, which advances one visual from intact to transparentfeelOutline,feelGlow, andfeelColorizefeelOpacityandfeelBlinkfeelFloatingText,feelDamageNumber, andfeelImpactRingfeelFlightLines,feelMotionTrail, andfeelAfterimagefeelEffect, which pulses anyEffectHandlefrom zero to its peak and back
Motion effects target a VisualComponent, such as SpriteComponent. They
add render-only position and rotation offsets and multiply render-only scale.
The entity's Transform, rigid body, collider, and depth-sort position remain
unchanged. Overlapping effects own separate modifiers and remove only their
own values.
const springHit = feelParallel(
feelPositionSpring({ target: enemySprite, offset: { x: -12, y: 0 } }),
feelRotationSpring({ target: enemySprite, radians: 0.15 }),
feelScaleSpring({ target: enemySprite, scale: 1.25 }),
);Spring cues begin at the requested visual displacement and oscillate back to
the live base value. duration sets the settling time, oscillations sets the
number of rebounds, and decay controls how quickly the rebounds weaken.
import { easeOutQuad } from "@yagejs/core";
import { bloom } from "@yagejs/effects";
import { feelEffect } from "@yagejs-addons/feel/renderer";
const bloomPulse = feelEffect(worldLayer.fx, bloom({ bloomScale: 1.5 }), {
duration: 0.25,
peakAt: 0.2,
attackEasing: easeOutQuad,
releaseEasing: easeOutQuad,
});Use feelEffect for a renderer effect that only needs a temporary intensity
pulse. feelGlitch changes its slice pattern during playback. feelDissolve
moves in one direction instead of returning to zero. The renderer package also
supplies zoomBlur, axisBlur, and implosion; static pulses use
feelEffect.
feelHitFlash, feelOpacity, feelRecoil, and feelBounce accept the same
four pulse-timing fields. A custom easing function runs through Feel's callback
error boundary. The function must return a finite number before Feel writes
renderer state.
Recipes
Recipes are ready-made compositions under @yagejs-addons/feel/recipes. Each
recipe returns a normal FeelNode for the existing Feel component. The
separate import distinguishes recipes from basic feelX nodes.
import {
damageImpact,
dashBurst,
enemyDeath,
} from "@yagejs-addons/feel/recipes";
const feel = entity.add(
new Feel({
hurt: damageImpact({ target: enemySprite, value: () => lastDamage }),
dash: dashBurst({
target: playerSprite,
direction: { x: 1, y: 0 },
peakAt: 0.45,
}),
die: enemyDeath({
target: enemySprite,
onComplete: ({ entity }) => entity.destroy(),
}),
}),
);| Recipe | Composition |
| -------------- | ------------------------------------------------------------------- |
| impact | Hit flash, scale punch, visual shake, and an impact ring |
| damageImpact | impact plus a floating damage number |
| dashBurst | Axis stretch, axis blur, and directional flight lines |
| spawnPop | Scale and position springs plus a short glow |
| enemyDeath | Impact flash, ring, shake, glow, and edged dissolve |
| voidCollapse | Inward blur, center-expanding implosion, peak hold, and color shift |
Recipes do not add sound, camera movement, or time changes. enemyDeath
requires onComplete, runs it after the temporary visual handles are removed,
and does not destroy the entity itself. Stopping the cue before completion does
not run the callback. Nested option objects expose each recipe's basic parts.
dashBurst applies its top-level pulse curve to squash and axis blur. Its
duration also controls the flight lines. The defaults are 0.3 seconds, a peak
at 0.3, and easeOutQuad for both phases.
Highlights and combat callouts
Outline, glow, and colorize effects pulse a filter on one visual. Floating text, damage numbers, and impact rings spawn independent world-space visuals, so retriggers can overlap without sharing state. Feel-owned filter pulses are omitted from save snapshots.
const criticalHit = feelParallel(
feelOutline({
target: enemySprite,
color: 0xffd54a,
thickness: 3,
duration: 0.25,
}),
feelGlow({ target: enemySprite, color: 0xff8800, duration: 0.3 }),
feelDamageNumber({
value: () => lastDamage,
critical: () => lastHitWasCritical,
prefix: "-",
layer: "effects",
}),
feelImpactRing({ color: 0xffd54a, layer: "effects" }),
);Flight lines, motion trails, and afterimages
feelFlightLines creates a short directional streak field. feelMotionTrail
samples a live world position and draws a fading line through recent samples.
feelAfterimage leaves tinted copies of a sprite's current frame behind its
rendered pose. All three effects own temporary entities and leave gameplay
transforms unchanged.
const dash = feelParallel(
feelFlightLines({ direction: () => velocity, duration: 0.25 }),
feelMotionTrail({
position: () => player.get(Transform).worldPosition,
duration: "held",
lifetime: 0.18,
}),
feelAfterimage({
target: playerSprite,
count: 5,
interval: 0.05,
tint: 0x1e3a8a,
}),
);A fixed flight-line direction must be finite with a magnitude greater than
1e-6; invalid fixed values throw when feelFlightLines is called. A direction
function is evaluated once when each burst starts. A finite zero or near-zero
result creates no streak entity, but the empty burst still lasts for its
configured duration.
Afterimages accept SpriteComponent and AnimatedSpriteComponent. Each copy
captures the current animation frame, anchor, effective rendered transform,
and opacity. Copies fade independently and are removed on completion or
cancellation.
A held motion trail samples until release, then stays alive for one lifetime
while its last points fade.
feelFloatingText and feelDamageNumber use the cue entity's world
Transform by default. Pass position to spawn elsewhere. Each playback
creates one temporary entity and destroys it when the effect completes or is
cancelled. Active callouts are omitted from save snapshots. Use a custom pool
instead when a game displays very large numbers of callouts every frame.
Audio and particles
import { feelSound } from "@yagejs-addons/feel/audio";
import {
feelParticleBurst,
feelParticleEmit,
} from "@yagejs-addons/feel/particles";
const impact = feelParallel(
feelSound({ alias: "impact", speed: [0.95, 1.05] }),
feelParticleBurst({ emitter: sparks, count: [8, 12] }),
feelParticleEmit({ emitter: smoke, duration: 0.2 }),
);Sounds keep the overall playback active until natural completion, release, or
cancellation. A sound remains a zero-time sequence step. With once: true,
each cue owns one AudioManager.requestOnce request, so releasing the cue does
not stop another request or a playOnce owner. Set particle emission to
duration: "held" to emit until graceful release; existing particles keep
their own lifetimes.
Playback events
The host entity emits FeelStartedEvent, FeelCompletedEvent, and
FeelStoppedEvent. Each payload carries the cue name and its
FeelPlaybackHandle. play() returns null while the component is dormant
or when chance, cooldown, or overlap: "ignore" rejects the trigger.
