Spaces:
Running
Running
Download src/torch.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 9.18 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/torch.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/torch.js
-
curl -L -o torch.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/torch.js
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; | |
| }, | |
| }; | |
| } | |