PageSourceSearch

https://the-torchbearer.netlify.app/src/systems/juice.js?v=27Jul-TB8

js the-torchbearer.netlify.app collected 2026-10-03 11:29:57 UTC 12,754 bytes, 344 lines download raw bytes

1/* The Torchbearer — the juice toolkit.
2 *
3 * Everything that makes a hit FEEL like a hit: hitstop freezes, floating
4 * score popups, shockwave rings, burst particles. All procedural, all cheap.
5 *
6 * Hitstop note: gameplay runs on delta-accumulated physics, so a true freeze
7 * is done by pausing physics + tweens for a few tens of ms of real time.
8 * Scenes must check SK.juice.isFrozen(scene) nowhere — the freeze is fully
9 * self-contained.
10 */
11window.SK = window.SK || {};
12
13SK.juice = {
14  /* A coaching line: a framed strip low on the screen that slides in, holds,
15   * and leaves. Deliberately not a modal — the game keeps running while you
16   * read it, because stopping play to teach play is how tutorials get skipped. */
17  coach: function (scene, text) {
18    var w = scene.scale.width, h = scene.scale.height;
19    var y = h - 132;
20    var c = scene.add.container(w / 2, y + 16).setDepth(1200).setScrollFactor(0);
21    var t = scene.add.text(0, 0, text, {
22      fontFamily: SK.FONT, fontSize: '15px', color: SK.CSS.bone
23    }).setOrigin(0.5);
24    var pw = t.width + 44, ph = 40;
25    var g = scene.add.graphics();
26    g.fillStyle(0x12100c, 0.9);
27    g.fillRoundedRect(-pw / 2, -ph / 2, pw, ph, 6);
28    g.lineStyle(1, SK.COLORS.gold, 0.85);
29    g.strokeRoundedRect(-pw / 2, -ph / 2, pw, ph, 6);
30    g.fillStyle(SK.COLORS.gold, 1);
31    g.fillRect(-pw / 2 + 1, -ph / 2 + 1, 3, ph - 2);
32    c.add([g, t]);
33    c.setAlpha(0);
34    scene.tweens.add({ targets: c, alpha: 1, y: y, duration: 260, ease: 'Quad.easeOut' });
35    scene.tweens.add({
36      targets: c, alpha: 0, y: y - 14, delay: 3600, duration: 340,
37      onComplete: function () { c.destroy(true); }
38    });
39    return c;
40  },
41
42
43  /* ---- time control -----------------------------------------------------
44   * Hard rules, learned the hard way:
45   *   1. A freeze is ALWAYS bounded (MAX_FREEZE). Extensions can never push
46   *      it past that, so the world can never stay stopped.
47   *   2. thaw() and unslow() each restore EVERY field either one can touch —
48   *      pause state, clock scale, tween scale and physics timeScale — so no
49   *      interleaving of the two can leave a residue behind.
50   *   3. Release timers re-arm rather than bail out when a deadline moves.
51   */
52
53  MAX_FREEZE: 140,     // ms; nothing may stop the world longer than this
54  MAX_SLOWMO: 1200,
55
56  hitstop: function (scene, ms) {
57    var now = performance.now();
58
59    if (scene._hitstopUntil && now < scene._hitstopUntil) {
60      // Extend, but never beyond the absolute cap measured from the START of
61      // the freeze. Continuous hits used to push the deadline forever.
62      var cap = (scene._hitstopStart || now) + SK.juice.MAX_FREEZE;
63      scene._hitstopUntil = Math.min(Math.max(scene._hitstopUntil, now + ms), cap);
64      return;
65    }
66
67    scene._hitstopStart = now;
68    scene._hitstopUntil = now + Math.min(ms, SK.juice.MAX_FREEZE);
69
70    if (scene.physics && scene.physics.world) scene.physics.world.pause();
71    scene.tweens.timeScale = 0.05;
72    scene.time.timeScale = 0.05;
73
74    var release = function () {
75      if (!scene.sys || !scene.sys.isActive()) { SK.juice.thaw(scene); return; }
76      var left = scene._hitstopUntil - performance.now();
77      if (left > 1) { setTimeout(release, left); return; }
78      SK.juice.thaw(scene);
79    };
80    setTimeout(release, Math.min(ms, SK.juice.MAX_FREEZE));
81  },
82
83  /* Brief slow-motion — parries, boss deaths, final blows. */
84  slowmo: function (scene, scale, ms) {
85    if (!scene.physics || !scene.physics.world) return;
86    var now = performance.now();
87    ms = Math.min(ms, SK.juice.MAX_SLOWMO);
88
89    if (scene._slowmoUntil && now < scene._slowmoUntil) {
90      var cap = (scene._slowmoStart || now) + SK.juice.MAX_SLOWMO;
91      scene._slowmoUntil = Math.min(Math.max(scene._slowmoUntil, now + ms), cap);
92      return;
93    }
94
95    scene._slowmoStart = now;
96    scene._slowmoUntil = now + ms;
97
98    scene.physics.world.timeScale = 1 / scale;   // arcade: >1 slows the sim
99    scene.tweens.timeScale = scale;
100    scene.time.timeScale = scale;
101
102    var release = function () {
103      if (!scene.sys || !scene.sys.isActive()) { SK.juice.unslow(scene); return; }
104      var left = scene._slowmoUntil - performance.now();
105      if (left > 1) { setTimeout(release, left); return; }
106      SK.juice.unslow(scene);
107    };
108    setTimeout(release, ms);
109  },
110
111  /* Restore everything. Both of these reset ALL four fields on purpose. */
112  thaw: function (scene) {
113    scene._hitstopUntil = 0;
114    scene._hitstopStart = 0;
115    if (scene.physics && scene.physics.world) {
116      if (scene.physics.world.isPaused) scene.physics.world.resume();
117      if (!scene._slowmoUntil) scene.physics.world.timeScale = 1;
118    }
119    if (scene.tweens) scene.tweens.timeScale = scene._slowmoUntil ? scene.tweens.timeScale : 1;
120    if (scene.time) scene.time.timeScale = scene._slowmoUntil ? scene.time.timeScale : 1;
121  },
122
123  unslow: function (scene) {
124    scene._slowmoUntil = 0;
125    scene._slowmoStart = 0;
126    if (scene.physics && scene.physics.world) {
127      scene.physics.world.timeScale = 1;
128      if (scene.physics.world.isPaused && !scene._hitstopUntil) {
129        scene.physics.world.resume();
130      }
131    }
132    if (scene.tweens) scene.tweens.timeScale = 1;
133    if (scene.time) scene.time.timeScale = 1;
134  },
135
136  /* Belt-and-braces: called every frame. A stopped or slowed world that has
137   * outlived its deadline is unrecoverable for the player, so force it back. */
138  policeTime: function (scene) {
139    var now = performance.now();
140    if (scene._hitstopUntil && now > scene._hitstopUntil + 80) SK.juice.thaw(scene);
141    if (scene._slowmoUntil && now > scene._slowmoUntil + 150) SK.juice.unslow(scene);
142
143    var w = scene.physics && scene.physics.world;
144    if (!w) return;
145    if (w.isPaused && !scene._hitstopUntil) w.resume();
146    if (w.timeScale !== 1 && !scene._slowmoUntil) w.timeScale = 1;
147    if (scene.time.timeScale !== 1 && !scene._hitstopUntil && !scene._slowmoUntil) {
148      scene.time.timeScale = 1;
149      scene.tweens.timeScale = 1;
150    }
151  },
152
153  /* Floating text: "+30", "PERFECT", "FEVER". */
154  popup: function (scene, x, y, text, color, size) {
155    // Numeric feedback is a preference; labels like PERFECT always show.
156    if (SK.settings && SK.settings.damageNumbers === false &&
157        /^[-+]?\d/.test(String(text))) {
158      return null;
159    }
160    var t = scene.add.text(x, y, text, {
161      fontFamily: SK.FONT,
162      fontSize: (size || 16) + 'px',
163      color: color || '#ffffff',
164      stroke: '#120e0a',
165      strokeThickness: 3
166    }).setOrigin(0.5).setDepth(850);
167
168    scene.tweens.add({
169      targets: t,
170      y: y - 44,
171      alpha: { from: 1, to: 0 },
172      scale: { from: 1, to: 1.15 },
173      duration: 700,
174      ease: 'Quad.easeOut',
175      onComplete: function () { t.destroy(); }
176    });
177    return t;
178  },
179
180  /* Expanding ring — kills, explosions, door opens. */
181  shockwave: function (scene, x, y, color, maxR) {
182    var g = scene.add.graphics().setDepth(450);
183    var state = { r: 10, a: 0.9 };
184    scene.tweens.add({
185      targets: state,
186      r: maxR || 120,
187      a: 0,
188      duration: 380,
189      ease: 'Quad.easeOut',
190      onUpdate: function () {
191        g.clear();
192        g.lineStyle(4, color, state.a);
193        g.strokeCircle(x, y, state.r);
194        g.lineStyle(10, color, state.a * 0.25);
195        g.strokeCircle(x, y, state.r);
196      },
197      onComplete: function () { g.destroy(); }
198    });
199  },
200
201  /* Directional burst of squares — enemy deaths, slash impacts. */
202  burst: function (scene, x, y, colors, count, force) {
203    var n = count || 14;
204    var f = force || 180;
205    for (var i = 0; i < n; i++) {
206      var size = Phaser.Math.Between(3, 8);
207      var rect = scene.add.rectangle(
208        x, y, size, size, colors[i % colors.length]
209      ).setDepth(440);
210      rect.setAngle(Phaser.Math.Between(0, 360));
211      var ang = Math.random() * Math.PI * 2;
212      var dist = f * (0.4 + Math.random() * 0.8);
213      scene.tweens.add({
214        targets: rect,
215        x: x + Math.cos(ang) * dist,
216        y: y + Math.sin(ang) * dist,
217        angle: rect.angle + Phaser.Math.Between(-260, 260),
218        alpha: 0,
219        scale: 0.3,
220        duration: Phaser.Math.Between(300, 520),
221        ease: 'Quad.easeOut',
222        onComplete: function (tw, targets) { targets[0].destroy(); }
223      });
224    }
225  },
226
227  /* Ground indicator that fills over an attack's wind-up. The contract for
228   * this game: nothing may damage the player without one of these first.
229   *   shape 'arc'    - a wedge (melee swings)
230   *   shape 'line'   - a lane (charges, beams)
231   *   shape 'circle' - a radius (slams, rings)
232   * Returns a handle with .cancel() so a stagger can abort the tell. */
233  telegraph: function (scene, opts) {
234    /* Two layers on purpose.
235     *
236     * `g` is the floor stain at depth 6: under the actors, and therefore under
237     * the lightmap at depth 800, so torchlight dims it along with the stone.
238     * That looks right but it is why the wind-up used to be almost invisible in
239     * an unlit corner — a warning you cannot see is not a warning.
240     *
241     * `edge` is the same shape drawn as a bright outline ABOVE the lightmap, so
242     * the tell survives any lighting. It is the only part of the world that is
243     * deliberately exempt from the dungeon's own darkness. */
244    var g = scene.add.graphics().setDepth(6);
245    var edge = scene.add.graphics().setDepth(810);
246    var state = { p: 0 };
247    var color = opts.color || 0xff4a3d;
248    var alive = true;
249
250    var draw = function () {
251      if (!alive) return;
252      g.clear();
253      edge.clear();
254      var t = state.p;
255      // Outline appears immediately; the fill sweeps in as time runs out.
256      g.lineStyle(2, color, 0.35 + t * 0.4);
257      g.fillStyle(color, 0.22 + t * 0.38);
258      // The readable layer: thickens and brightens as the blow approaches, and
259      // pulses once it is nearly out so the release is impossible to miss.
260      var urgent = t > 0.72 ? (Math.sin(t * 60) * 0.25 + 0.75) : 1;
261      edge.lineStyle(t > 0.72 ? 4 : 3, color, (0.55 + t * 0.45) * urgent);
262
263      var x = opts.follow ? opts.follow.x : opts.x;
264      var y = opts.follow ? opts.follow.y : opts.y;
265
266      if (opts.shape === 'line') {
267        var a = opts.angle, half = (opts.width || 40) / 2;
268        var px = Math.cos(a + Math.PI / 2) * half;
269        var py = Math.sin(a + Math.PI / 2) * half;
270        var ex = x + Math.cos(a) * opts.length;
271        var ey = y + Math.sin(a) * opts.length;
272        g.beginPath();
273        g.moveTo(x + px, y + py);
274        g.lineTo(ex + px, ey + py);
275        g.lineTo(ex - px, ey - py);
276        g.lineTo(x - px, y - py);
277        g.closePath();
278        g.fillPath();
279        g.strokePath();
280        edge.beginPath();
281        edge.moveTo(x + px, y + py);
282        edge.lineTo(ex + px, ey + py);
283        edge.lineTo(ex - px, ey - py);
284        edge.lineTo(x - px, y - py);
285        edge.closePath();
286        edge.strokePath();
287      } else if (opts.shape === 'circle') {
288        g.fillCircle(x, y, opts.radius * (0.35 + t * 0.65));
289        g.strokeCircle(x, y, opts.radius);
290        edge.strokeCircle(x, y, opts.radius);
291      } else {
292        // The arc was the ONLY shape that never stroked its outline, which is
293        // why the commonest tell in the game — the overhead slash — was the
294        // hardest one to read.
295        var arc = opts.arc || Math.PI / 2;
296        g.slice(x, y, opts.radius, opts.angle - arc / 2, opts.angle + arc / 2, false);
297        g.fillPath();
298        edge.slice(x, y, opts.radius,
299          opts.angle - arc / 2, opts.angle + arc / 2, false);
300        edge.strokePath();
301      }
302    };
303
304    var tw = scene.tweens.add({
305      targets: state, p: 1, duration: opts.ms || 500,
306      onUpdate: draw,
307      onComplete: function () { alive = false; g.destroy(); edge.destroy(); }
308    });
309    draw();
310
311    return {
312      cancel: function () {
313        if (!alive) return;
314        alive = false;
315        tw.stop();
316        g.destroy();
317        edge.destroy();
318      }
319    };
320  },
321
322  /* One camera kick in a direction — subtler than a shake for slashes.
323   * Works on a static (room-locked) camera by nudging scroll and restoring. */
324  kick: function (scene, angle, px) {
325    var cam = scene.cameras.main;
326    var bx = cam.scrollX;
327    var by = cam.scrollY;
328    cam.setScroll(bx + Math.cos(angle) * px, by + Math.sin(angle) * px);
329    var state = { t: 0 };
330    scene.tweens.add({
331      targets: state,
332      t: 1,
333      duration: 110,
334      ease: 'Quad.easeOut',
335      onUpdate: function () {
336        cam.setScroll(
337          bx + Math.cos(angle) * px * (1 - state.t),
338          by + Math.sin(angle) * px * (1 - state.t)
339        );
340      },
341      onComplete: function () { cam.setScroll(bx, by); }
342    });
343  }
344};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.