Log inGet started
World

Fencing with Fences

Branchmain
Branchesmain
Commitc0197a6317

README.md

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 far fxSeq has advanced since the first poll
  • taken — how many events it has drained off fxQueue
  • missed — 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.

primitivefor
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.