astara / src /torch.js
system's picture
system HF Staff
d7dab35: Her own drawings for the sword, the fall and the torch
75ec82b verified
Raw History Blame Contribute Delete
9.18 kB
// The torch light. Shared by the demo and the level, because it is the one
// thing in the project that neither of them is allowed to have its own version
// of.
//
// Owns the raise ramp, the flicker, where the light sits relative to his hand,
// and the coverage test that decides whether something has been seen.
import { TORCH, REVEAL_T, TONE } from './tuning.js';
const smoothstep = (t) => t * t * (3 - 2 * t);
const lerp = (a, b, t) => a + (b - a) * t;
const RAD = Math.PI / 180;
/**
* Where the two bones of the torch arm are, at a given raise.
*
* ONE PLACE, because two would drift. The renderer draws the arm from this and
* the torch hangs the flame and the light off the same answer, so the flame is
* in the hand by construction rather than by a pair of numbers that agree
* until somebody edits one.
*
* Returns offsets from the clip pivot in game pixels, +x being the way he
* faces and -y up. `arm` is the manifest's torchArm block; null gives null and
* every caller falls back to the static handPivot.
*
* @returns {{shoulder:number[], elbow:number[], hand:number[]}|null}
*/
export function armPose(arm, eased) {
if (!arm) return null;
const a1 = lerp(arm.down[0], arm.up[0], eased) * RAD;
const a2 = lerp(arm.down[1], arm.up[1], eased) * RAD;
const [sx, sy] = arm.shoulder;
// The manifest's angles have y UP, the canvas has y down, so the sines flip.
const ex = sx + arm.upper * Math.cos(a1);
const ey = sy - arm.upper * Math.sin(a1);
const wx = ex + arm.fore * Math.cos(a2);
const wy = ey - arm.fore * Math.sin(a2);
// The fist sits on past the wrist, along the forearm.
const fx = wx + arm.wristToFist * Math.cos(a2);
const fy = wy - arm.wristToFist * Math.sin(a2);
// The shaft is NOT the forearm carried on: the wrist cocks back, and that
// angle is most of what stops a held torch reading as a stick on the end of
// an arm. Upright at his side, leaning forward when it is out.
const ta = lerp(arm.torchAngle[0], arm.torchAngle[1], eased) * RAD;
const ux = Math.cos(ta);
const uy = -Math.sin(ta); // one step up the shaft, in canvas axes
const bx = fx - arm.buttBelowFist * ux;
const by = fy - arm.buttBelowFist * uy;
const headUp = arm.shaftLen + arm.bindLen;
return {
shoulder: [sx, sy],
elbow: [ex, ey],
wrist: [wx, wy],
fist: [fx, fy],
// The butt of the shaft, back down the shaft from his fist, so the brand
// runs up THROUGH his hand instead of starting at it.
butt: [bx, by],
// The foot of the fire, on top of the bound head: the fire sprite's pivot.
head: [bx + headUp * ux, by + headUp * uy],
// And where the light comes from, which is up inside the fire. STRAIGHT UP
// from the head, not on up the shaft: fire rises, so the flame stands
// vertical however the brand is angled, and its middle is above the head
// rather than along the stick.
glow: [bx + headUp * ux, by + headUp * uy - arm.fireCentre],
up: [ux, uy],
torchDeg: ta / RAD,
};
}
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
// Smooth 1D value noise. A literal 12Hz square wave would read as a strobe;
// this keeps the stated rate but moves like a flame.
function hash(n) {
const s = Math.sin(n * 127.1) * 43758.5453123;
return s - Math.floor(s);
}
function valueNoise(t) {
const i = Math.floor(t);
const f = t - i;
const u = f * f * (3 - 2 * f);
return hash(i) * (1 - u) + hash(i + 1) * u;
}
export function createTorch() {
return {
raise: 0, prevRaise: 0,
torchDeg: 90, prevTorchDeg: 90,
// The eased raise, published so the renderer draws the arm at exactly the
// value the light was computed from rather than easing a second time.
eased: 0, prevEased: 0,
radius: 0, prevRadius: 0,
cx: 0, cy: 0, prevCx: 0, prevCy: 0,
handX: 0, handY: 0, prevHandX: 0, prevHandY: 0,
reset() {
this.raise = this.prevRaise = 0;
this.eased = this.prevEased = 0;
this.radius = this.prevRadius = 0;
},
/** Copy this frame's values into the previous ones, for render interpolation. */
snapshot() {
this.prevRaise = this.raise;
this.prevEased = this.eased;
this.prevRadius = this.radius;
this.prevCx = this.cx;
this.prevCy = this.cy;
this.prevHandX = this.handX;
this.prevHandY = this.handY;
this.prevTorchDeg = this.torchDeg;
},
/**
* @param {boolean} wantUp is the button held
* @param {number} clock seconds since boot, for the flicker
* @param {number} anchorX the prince's world x
* @param {number} groundY the ground line
* @param {number} facing +1 right, -1 left
* @param {number} handDX hand offset from the pivot, from the manifest
* @param {number} handDY
* @param {object} arm the manifest's torchArm block, or null
* @param {number} fireCentre how far up the fire its light sits, for a pose
* that holds the torch in the drawing; or null
* @param {number[][]} hands where the fire stands in each frame of that
* pose, as offsets from its pivot; or null
*/
update(dt, wantUp, clock, anchorX, groundY, facing, handDX, handDY, arm = null, fireCentre = null, hands = null) {
const rate = wantUp ? 1000 / TORCH.RAISE_MS : -1000 / TORCH.LOWER_MS;
this.raise = clamp01(this.raise + rate * dt);
const eased = smoothstep(this.raise);
this.eased = eased;
if (eased > 0) {
const t = clock * TORCH.FLICKER_HZ;
// Two octaves so it never settles into a visible rhythm.
const n = (valueNoise(t) * 2 - 1) * 0.7 + (valueNoise(t * 2.27 + 31.7) * 2 - 1) * 0.3;
// A SLOW WANDER ON TOP, and it is off until somebody judges it.
//
// 4% at 12Hz is a clean circle with a tremble in it: it buzzes rather
// than breathes, and at a glance it reads as a spotlight rather than
// as fire. A third of that frequency, at three times the depth, is the
// part of firelight you actually notice — the pool swelling and
// dropping over a second or two while the edge shivers.
const slow = TONE.TORCH_LIFE > 0
? (valueNoise(t * 0.28 + 7.3) * 2 - 1) * TORCH.FLICKER_AMOUNT * 3 * TONE.TORCH_LIFE
: 0;
this.radius = TORCH.RADIUS * eased * (1 + n * TORCH.FLICKER_AMOUNT + slow);
} else {
this.radius = 0;
}
// The hand rides the arm when there is one, so the flame goes up with it.
const pose = armPose(arm, eased);
// OR RIDES HER FRAMES. The raise is her arm coming up over four frames,
// picked by how far up it is exactly as the pose's frame is, so the fire
// stays on the torch in every one of them.
if (!pose && hands && hands.length) {
const k = Math.min(hands.length - 1, Math.floor(eased * (hands.length - 1) + 1e-6));
handDX = hands[k][0];
handDY = hands[k][1];
}
// handX/handY is where the FIRE sprite's pivot goes: the top of the
// bound head, which is where a flame sits.
this.handX = anchorX + facing * (pose ? pose.head[0] : handDX);
this.handY = groundY + (pose ? pose.head[1] : handDY);
this.torchDeg = pose ? pose.torchDeg : 90;
// THE POOL COMES OUT OF THE FIRE.
//
// For most of this project it hung off a fixed offset from him, which
// was invisible while his hand never moved -- and the moment the arm
// went out it was plainly a pool of light centred on nothing. It is on
// the middle of the flame now, measured off the sprite: the pack's
// centroid sits 40% up its box and wanders less than 6px over the whole
// loop.
//
// HER DRAWING HOLDS IT UP, and the fire sits where her flame was: the
// same deal as the arm, with no arm. The light comes out of the middle of
// that fire, straight above its foot.
//
// With neither there is nothing to hang it on, so it falls back.
if (pose) {
this.cx = anchorX + facing * pose.glow[0];
this.cy = groundY + pose.glow[1];
} else if (fireCentre != null) {
this.cx = this.handX;
this.cy = this.handY - fireCentre;
} else {
this.cx = anchorX + facing * TORCH.LIGHT_AHEAD;
this.cy = groundY - TORCH.LIGHT_LIFT;
}
return eased;
},
/**
* Has this point been properly lit? True inside the radius at which the
* mask is still at least 50% opaque.
*
* REVEAL_T is solved from the gradient's own stop table at boot, so it
* tracks the falloff instead of being a number somebody wrote down. See
* tuning.js.
*
* Testing the centre of a figure's box rather than integrating its area
* fires slightly early — 21px on the current falloff, about 0.38s of
* walking. REVEAL_MS of dwell absorbs that completely.
*/
covers(x, y) {
if (this.radius <= 0) return false;
const r = REVEAL_T * this.radius;
const dx = x - this.cx;
const dy = y - this.cy;
return dx * dx + dy * dy < r * r;
},
};
}