fx
Everything between the rules firing and the player feeling it, on the module side. `DuelFx` is the component that uses it; `PisteCamera` uses the curves.
fx
Everything between the rules firing and the player feeling it, on the module
side. DuelFx is the component that uses it; PisteCamera uses the curves.
local Fx = require("fx")
The event source
local source = Fx.source()
for _, ev in ipairs(source:poll(match, playerFencer, rivalFencer, dt)) do ... end
poll drains DuelMatch.fxQueue — every event of a frame, in the order the
umpire wrote it. The umpire empties that queue at the top of its own update and
fills it as the frame resolves, so any reader that runs after it sees the whole
frame. source.path reads queue once that read is live.
Beside the queue, the same vocabulary is derived from the umpire's frozen
public fields. That derivation is not a fallback that will be retired: the
two measure faults never reach the bus at all. strikeFrom writes an
exchange record for a SHORT or a CROWDED and returns without emitting, so
short:* and crowded:* exist only if someone derives them. Everything else
the derivation produces comes from a state edge in the same frame as the queued
event, so the two collapse together and nothing double-fires.
crowded:player / crowded:rival are in Fx.DERIVED rather than Fx.EVENTS:
they are read off a frozen public field, and the umpire's own vocabulary is
unchanged.
Losslessness, in the umpire's own units
fxSeq counts every emit() the umpire has ever made. The source keeps:
produced— how farfxSeqhas advanced since the first polltaken— how many events it has drained offfxQueuemissed— the difference, which is exactly the events that never arrived
recordsMissed is a separate count: exchange records overwritten inside
one frame. Those exchanges' events still arrive on the queue; only the detail
behind them is gone, so the reaction falls back to its default weight.
The strike read
source.strike holds the last parsed verdict —
{ who, action, riposte, verdict, defence, phase } — from a record like
player thrust RIPOSTE vs rival parry/active -> touch. That is how the
reaction layer knows a lunge landed rather than a thrust.
source.fault holds the last { who, action, kind, gap, reach }.
The tell watcher
local tells = Fx.tells()
for _, tell in ipairs(tells:poll(pf, rf)) do ... end -- "player:lunge"
A commitment is fencer state, not a match event: it is the read the whole game is about and it lasts a fifth of a second.
Curves
Fx.approach(v, target, rate, dt), Fx.outCubic(t), Fx.snap(t) — shared by
DuelFx and PisteCamera so a recoil and a camera kick fall off on the same
shape.
The synthesiser
Every sound in the assize is a sine, a noise burst, a formant resonance and an envelope. Nothing is imported.
| primitive | for |
|---|---|
Fx.sine(buf, o) | partials, gliding, optionally FM — bells, thuds, birdsong |
Fx.noise(buf, o) | a band-shaped burst whose band may sweep — gravel, air, timber |
Fx.rumble(buf, o) | lowpassed noise — soil, breath, distance |
Fx.voice(buf, o) | a glottal pulse train through resonant formants — the umpire's calls and the fencers' efforts. breath trades the pulse for air, which is the difference between a shout and a grunt. |
Fx.bed(buf, o) | a continuous lowpassed bed whose loudness breathes on a slow LFO |
Fx.finish(buf, peak) | soft clip, peak-normalise, and 4 ms fades at both ends |
Vowels are three { centre, bandwidth, amplitude } formants; ah, ay, aw,
oy, uh, ee, oo, oh and er are enough for the whole rite.
The umpire's throat
The assize needs a dozen calls and not three: an umpire with four lines says
each of them eight times in a bout, and a rite that repeats is a metronome. So
a call is written as a list of syllables rather than as a stack of primitives —
a vowel, a length, and where the pitch starts and ends on it. edge puts a
consonant's noise in front of a syllable and stop puts one behind it, which
is the whole difference between a call that lands on a vowel and one that stops
dead on a T.
VOICES.umpire_hold = function()
return call({
seed = 9400,
syls = {
{ v = "oh", dur = 0.30, f = 178, f2 = 118, amp = 0.86,
glide = 0.7, edge = 0.26, edgeF = 3000, stop = 0.20 },
},
})
end
Fifteen calls are written that way: umpire_salute, umpire_engarde,
umpire_ready and umpire_play for opening and restarting; umpire_come for
a ground with no ceremony; umpire_hold and umpire_arret beside halt;
umpire_touche, umpire_award_for and umpire_award_against beside point;
umpire_score when the touches are level; umpire_last when one post ends the
assize; umpire_verdict to read it; and breath_hard / breath_soft, which
are the same throat with the pulse traded for air — the man behind the mask,
audible for the two seconds nobody is fencing.
DuelFx decides which of them is heard, from the beat DuelMatch.ceremonyPhase
publishes and the state of the bout.
Fx.bake() -- render every clip into /source/audio/
Fx.bake({ "umpire_halt" }) -- or just one
Fx.clipNames() -- what it can render
Baking is deterministic — the noise generator is a seeded LCG, so a re-bake
produces the same bytes and overwrite = true keeps each clip's guid.
Peak normalisation is a trap for a bed. A bed whose birds set the peak has
its wind crushed to silence, and audio.info reports nothing that would show
it. Decode the clip back and measure RMS in windows:
local pcm, sr = audio.decode(ref:getBytes()) -- little-endian f32, interleaved
hedge_wall was rewritten twice on that measurement: its wind bed read 0.021
RMS against a 0.055 pigeon until the birds came down and the bed came up.
Scoped to this part · feeds back into the world's score.