Fencing with Fences
README.md
PisteCamera
The fencing broadcast: a locked side-on view straight down the property line.
It lives on the PlayerPrototype's CameraRig beside the Camera component, so
every joining user gets their own.
It frames by extent rather than by separation. Each frame it works out the
exact span it has to cover — both fencers, the boundary post, a margin — and
sits at the nearest distance whose frustum covers it, floored by minDistance
and ceilinged by the widest span the duel's own leash can produce. Framing in
proportion to the gap pulls the camera out whether the frame needs it or not,
and a game whose whole content is two figures and a fifth-of-a-second wind-up
cannot afford figures that small.
It also reacts: DuelFx publishes punch, kick and shake, and this turns
them into a dolly and a wobble. The reaction is applied after the extent solve
and only ever pulls the camera IN, so it can never uncover something the solve
decided had to be covered.
The shot is solved, not guessed
The frame is drawn at a 60° vertical field of view whatever Camera.fov
says — that field is a silent engine no-op, filed as
#8071 — so fov here means
"the angle actually on screen" and every number below is measured against it.
At 60° and 16:9, height / lookHeight / minDistance place, in tangent
space:
| fencer's feet | 84.2 % down the frame |
| crown of the mask | 44.9 % down the frame |
| figure | 39.3 % of frame height |
| empty lawn below the feet | 15.8 % of frame height |
The dial that decides the dead lawn is lookHeight, not height: the margin
under the feet is exactly the part of the frame NOT spent on the world between
the ground and the point the camera aims at. Aiming at the mask rather than at
the chest is what buys it back. height sits at a standing fencer's eye so the
piste reads flat and neither figure is looked down on.
fencerFramePct and lawnBelowPct report those two numbers live, computed the
way the frame is actually projected rather than by the small-angle shortcut —
they agree with worldToScreen to seven decimal places, so a test can assert
the framing without re-deriving the projection.
The contact framing
Side-on is the framing that makes distance legible, and distance is the whole skill, so the broadcast holds it for every frame the two are fencing. It is also the framing in which two men holding fence panels at two metres are one beige mass: the panels lie in the plane of the view, so they stack instead of separating, and the one frame that decides the bout is the one nobody can read.
The umpire already stops the bout on a touch. This takes that stop:
- the station swings
contactYawoff the piste axis, orbiting the focus rather than sliding along it, so the two fencers land at different depths and their panels stop being coplanar; - it cranes
contactLiftand aimscontactAimlower, which drops the near panel's top edge below the contact instead of across it; - it closes to a distance solved from the pair's own span with
contactPadof the fight's margin, floored atcontactCloseofminDistanceand ceilinged atminDistance— so contact is never framed wider than the fight, and the figure can only grow; - it slides
contactBiasof the way toward the fencer who took the touch, who is the subject of the moment; - and it holds
contactHoldseconds past the umpire's own stop, so the beat has a tail rather than a cut.
Nothing above touches the frame the distance is judged in: contactPct is 0
for every frame of state == "fence", and the dials it blends toward are all
stated as offsets or fractions of the shot's own, so re-tuning the shot
re-tunes the contact framing with it.
contactForce holds the framing for a number of seconds on demand. The beat is
about six tenths of a second long and a screenshot cannot be asked for from
outside the engine inside it, so this is how the moment gets photographed.
Public fields
height: number— the camera's world Y.lookHeight: number— the world Y it aims at; the difference fromheightis the whole of the pitch, and this is the dial that sets the dead lawn.minDistance: number— the nearest the broadcast ever sits. This is the number that decides how large a fencer reads when the two are at measure. At 4.60 the extent solve covers a 7.1 m gap before it pulls out at all, which is wider than the fight gets outside a full retreat — so the shot holds still through an exchange instead of breathing on every step.padding: number— world metres held either side of the outermost thing in the shot.fov: number— the vertical field of view the frame is actually drawn at.smoothing: number— how quickly the rig chases its solved pose.reactionRoom: number— the furthest a punch or a kick may pull the camera in, in metres. A metre buys much more of the frame from 4.60 than from 6.60.focusX: number— the smoothed X the rig sits at. Live output.distance: number— the smoothed distance the rig sits at. Live output.fencerFramePct: number— the share of frame height a 2.13 m fencer covers. Live output.lawnBelowPct: number— the share of frame height below their feet. Live output. The frame's dead space, as a number.contactYaw: number— degrees the station swings off the piste axis at contact.contactLift/contactAim: number— metres the camera rises and metres the aim point drops at contact.contactClose/contactPad: number— the contact station's near bound and its margin, each as a fraction ofminDistanceandpadding.contactBias: number— how far the frame slides off the pair's midpoint toward the fencer who took the touch.contactRise/contactFall: number— how fast the swing goes on and comes off, per second.contactHold: number— seconds the framing runs on past the umpire's stop.contactForce: number— seconds of contact framing to hold on demand, counted down inupdate.contactPct: number— 0 side-on, 1 fully in the contact framing. Live output, and the field a test asserts the beat on.contactSide: number— which way the swing went: −1 the struck fencer is west, +1 east. Live output.
Lifecycle
awake()— pushesfovonto the siblingCamera.update(dt)/editorUpdate(dt)— solves and applies the pose. It runs in edit mode too, so the shot can be judged without entering play.
Usage
entity(id).component.add("PisteCamera", {})
entity(id).component.get("PisteCamera").minDistance = 5.2
Notes
- The scene's authored props outrank every default in this file.
scenes/main.scene/scene.jsoncarries aPisteCameraprop block, and a coldlayers.loadre-attaches the component with those numbers — so the shot the world runs after a reload is the one written there, not the one tuned here. Read the live component, not this file, before believing any framing number. - Leave the sibling
Camera'sbehaviorfield unset. A camera-controller component named there is re-instantiated on every scene load and fights this one for the rig's transform. - The fencers and the director are held by id and re-scanned only when a
cached id stops resolving. Asking
entity.findAll()once a frame is a walk of the whole arena, which is a few hundred parts: it cost 4.2 ms a frame and made this the second most expensive script in the world. It now costs 0.21.