Skip to content

Latest commit

 

History

History
285 lines (227 loc) · 11.6 KB

visuals.md

File metadata and controls

285 lines (227 loc) · 11.6 KB

Animation, particles & sound

The three things that make a scene feel alive, driven from a script.

Part of the scripting guide · every call, as a reference

Contents


9. Animation: node:animator()

Any node with an Animation Controller component (or a rigged model with embedded clips) exposes an animation handle. See docs/animation.md for the full system (controllers, layers, events, the stepped retro look).

local anim
function start(node)
  anim = node:animator()
end

function update(node, dt)
  local speed = math.sqrt(node.vx^2 + node.vz^2)
  if not node.grounded then anim:play("Jump")
  elseif speed > 6     then anim:play("Run")
  elseif speed > 0.5   then anim:play("Walk")
  else                      anim:play("Idle") end

  if input.pressed("j") then anim:restart("Slash") end -- one-shot attack layer
end

-- called by a ⚑ event key placed on a clip's timeline:
function onSlashHit(node) log("hit frame!") end
Call What it does
anim:play(state [, fade [, layer]]) transition (controller decides the fade; safe every frame)
anim:restart(state [, fade [, layer]]) force re-entry (re-trigger a one-shot)
anim:crossfade(state, fade [, layer]) transition with an explicit fade
anim:stop([layer [, fade]]) stop a layer (all if omitted)
anim:setSpeed(x [, layer]) speed multiplier: the whole animator's, or one layer's
anim:setParam(name, x) the number a blend state mixes its clips by
anim:setLayerWeight(layer, w) blend a layer over the ones below (0..1)
anim:seek(t [, layer]) jump the playhead
anim:state([layer]) / anim:time([layer]) what's showing / seconds in (anim:current is an alias of anim:state)
anim:finished([layer]) a one-shot reached its end
anim:isPlaying([state]) is a state (or anything) playing
anim:clips() / anim:layers() available state / layer names
anim:duration(clip) / anim:events(clip) the clip as authored — length in seconds, and its event list
anim:setBoneRot / addBoneRot / setBonePos / addBonePos change one bone after the clips, for this frame
anim:reach(tip, target, {pole=}) / anim:lookAt(bones, target) two-bone IK and look-at, for this frame
anim:boneWorld(bone) where a bone is: position, yaw, pitch, roll

Bone writes and IK, with a foot-planting recipe, are in docs/animation.md.

anim:duration / anim:events read the asset, not playback, so they answer in start(), before anything has played a frame. events returns { {t = seconds, func = "onHitboxStart"}, ... } ascending by t — an unknown clip is nil, a clip with no events is an empty list.

They exist so an animator can author timing by eye while the game still runs on integer frames. Drop an event on the frame where the strike connects, then bake it once at load:

local dur = anim:duration(clipName)
for _, e in ipairs(anim:events(clipName) or {}) do
  if e.func == "onHitboxStart" then
    move.startup = math.floor(e.t / dur * move.frames + 0.5)
  end
end

Don't let events drive frame-exact gameplay directly: they fire off float playback time, stepped playback (sample_fps) quantises them to its grid, clip time and gameplay frame disagree mid-crossfade, and a prediction replay deliberately doesn't re-fire them. Every machine loads the same .anim.ron, so a baked number is identical everywhere and constant thereafter.

Conditional expressions. Lua's and/or chain is an inline if/else — handy for mapping states to values without an if-ladder:

local speed = anim:isPlaying("Running") and 2
           or anim:isPlaying("Walking") and 1
           or 0

The one gotcha: put the condition first. a and b yields b only when a is truthy, so 2 and anim:isPlaying("Running") gives you the boolean, not the 2. (And this only picks non-false values — cond and false or x always lands on x.) Method names are camelCase: anim:IsPlaying is an error, and the animator will suggest the spelling it thinks you meant.

Events → functions. Put a ⚑ event on a clip in the ✎ Animating tab and name a function; when the playhead crosses it during Play, that function is called (with the node) on every script attached to the controller's node that defines it.


10. Particles: node:particles()

Any node with a Particle System component exposes a particle handle, so scripts can fire and stop effects on cue — muzzle flashes, footstep dust, thruster plumes, pickups. See docs/subsystems/particles-vfx.md for authoring effects on the ✱ Particles timeline.

function update(node, dt)
  local p = node:particles()

  -- one-shot burst on each shot (re-fires even mid-play):
  if input.clicked(0) then p:restart() end

  -- a continuous effect that follows a condition:
  local jet = find("Thruster"):particles()
  if input.key("w") then jet:play() else jet:stop() end

  if p:isPlaying() then log("smoke: " .. p:alive() .. " alive") end
end
Call What it does
p:play() start emitting if idle (spawns a fresh instance); no-op if already playing
p:stop() stop + despawn — the live particles vanish
p:restart() re-spawn from t=0 (re-fire a one-shot burst)
p:setIntensity(i) live emission scale 0..~2 — throttle a plume without touching the asset
p:setBeamEnd(x, y, z) aim every Beam track's endpoint at a WORLD point (converted to effect-local, so the beam tracks the target as the node moves)
p:isPlaying() is an instance emitting/ageing right now
p:alive() live particle count across the effect's tracks
p:asset() the effect asset key this node references, or nil

Handles work cross-node: find("Campfire"):particles():stop(). A node's Play on start flag is also scriptable — node:getcomponent("ParticleSystem").play_on_start = 1.

spawnEffect — fire a one-shot at a world point

For hits, pickups, footstep poofs — effects that aren't tied to a node — spawn one anywhere in the world and forget it. It plays once and despawns itself:

function update(node, dt)
  if input.clicked(0) then
    local h = raycast(node.x, node.y, node.z, fx, fy, fz, 100)
    if h then spawnEffect("vfx/Impact", h.x, h.y, h.z) end
  end
end

spawnEffect(key, x, y, z) — key is the effect asset (project-relative, no .vfx.ron); the position is world space. Author it as a one-shot effect on the ✱ Particles timeline so it ends cleanly. That's the whole loop: design it on the timeline → spawnEffect it from gameplay.


11. Audio: audio.play, node:sound() & the mixer

Playing a sound needs nothing but a clip path — no prefab, no source node, no spawn-then-get-component dance:

audio.play("audio/ding.ogg")                          -- flat 2D (UI, stingers)
audio.play("audio/hit.ogg", h.x, h.y, h.z)            -- 3D at a world point
audio.play("audio/engine.ogg", carNode, {loop = true}) -- follows the node

Sounds default to spatial: they attenuate with distance and pan toward where they are relative to the active camera. Every knob rides in the options table (all optional):

local s = audio.play("audio/roar.ogg", bossNode, {
  volume = 0.8,             -- linear, 1 = as authored
  pitch = 1.1,              -- playback rate (also shifts pitch)
  mode = "Spatial",         -- "Distance" = attenuate only · "Flat" = plain 2D
  falloff = "Inverse",      -- "Linear" · "Exponential"
  minDistance = 2,          -- full volume inside this range
  maxDistance = 50,         -- silent past this range
  track = "SFX",            -- mixer track to route through (default Master)
  endBehavior = "Destroy",  -- "Stop" (default) · "Destroy" · "Loop"
})

audio.play returns a sound handle, live until the sound ends:

Call What it does
s:stop() fade out (a few ms — never clicks) and end
s:pause() / s:resume() freeze / continue playback
s:setVolume(v) / s:setPitch(v) / s:setPan(v) live tweaks
s:setTrack("Music") re-route through another mixer track
s:setPosition(x, y, z) move the emitter (stops following a node)
s:seek(secs) jump the playhead
s:isPlaying() / s:position() playback state
s:isLoading() its clip is still being read (see below)

The first time a clip is played, its file is read and decoded in the background, and the sound starts once that is done: a frame later for a sound effect, longer for a long music file. Until then it already reads as playing, from 0 seconds, and s:isLoading() is true; seeking, pausing, volume and stop() all apply when it starts. When a sound has to start on the frame it is asked for, read it in ahead of time on a menu or a loading screen:

audio.preload({ "audio/hit", "music/level1.ogg" }, function(failed)
  ready = true            -- `failed` lists any clip that could not load
end)

A preloaded clip is kept until it has been played. After that, clips nothing is playing are kept while they fit in 64 MB, and the least recently played goes first. A dropped clip is read again the next time it plays. assets.preload takes sound files too, beside models.

endBehavior = "Destroy" on a node-following sound despawns that node when the sound finishes — spawn a node, hang a sound on it, and it cleans itself up.

The Audio Source component

For authored emitters (ambient loops, music zones, alarm props), add an Audio Source in the Inspector (➕ Add Component): pick the clip, spatial mode, falloff, distances, mixer track, end behavior, and Play on start. Scripts drive it through node:sound():

local alarm = find("Alarm"):sound()
alarm:play()                     -- restart its clip
alarm:setClip("audio/alarm2.ogg")
alarm:pause()  alarm:resume()  alarm:stop()
if alarm:isPlaying() then log(alarm:position()) end

Its tunables mirror live through getcomponent (numbers only, like every component):

field Meaning (Inspector: ♪ Audio Source)
volume linear volume 0..2
pitch playback rate (0.5 = octave down)
pan stereo pan −1..1 (Flat mode)
minDistance / maxDistance the falloff range
playOnStart 1/0 — play when Play starts
mode 0 = Spatial · 1 = Distance · 2 = Flat
falloff 0 = Inverse · 1 = Linear · 2 = Exponential
endBehavior 0 = Stop · 1 = Destroy · 2 = Loop
node:getcomponent("AudioSource").volume = 0.3   -- live while playing

The mixer

Everything audible routes through the 🎧 Mixer tab: named tracks with a fader, pan, mute/solo, an effect chain (parametric EQ with a draggable curve, delay, reverb, chorus, flanger, phaser, pitch shift, compressor, limiter, distortion, utility), and routing — tracks can output into other tracks (e.g. Footsteps → SFX → Master). The graph saves with the project (project.ron); anything that doesn't name a track plays on Master.

Scripts get live control that reverts when Play stops:

audio.track("Music"):setVolume(-12)   -- duck music (fader dB)
audio.track("SFX"):setPan(0.2)
audio.track("Master"):setMuted(true)
audio.stopAll()                       -- silence everything

Clips are plain files under assets/audio/ (.wav, .ogg, .mp3, .flac) — double-click one in the Assets browser to preview it. Clip references are project-relative paths ("audio/hit.ogg"; the extension may be omitted).