// 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; }, }; }