File size: 9,180 Bytes
71eae78
 
 
 
 
 
 
d1e0284
71eae78
 
9f89772
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3647d73
 
 
 
 
 
 
 
 
 
 
 
 
 
9f89772
 
 
3647d73
 
 
 
 
 
 
 
 
 
 
 
 
 
9f89772
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3647d73
9f89772
 
 
71eae78
 
 
 
 
 
9f89772
71eae78
 
 
 
 
 
9f89772
71eae78
 
 
 
 
3647d73
71eae78
 
 
 
 
 
 
 
 
 
9f89772
75ec82b
 
 
 
71eae78
75ec82b
71eae78
 
 
 
9f89772
71eae78
 
 
 
d1e0284
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
9f89772
 
75ec82b
 
 
 
 
 
 
 
3647d73
 
 
 
 
9f89772
3647d73
9f89772
3647d73
 
 
 
 
 
 
75ec82b
 
 
 
 
3647d73
 
 
75ec82b
 
 
3647d73
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
// 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;
    },
  };
}