PageSourceSearch

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

js the-torchbearer.netlify.app collected 2026-10-03 11:30:22 UTC 20,477 bytes, 568 lines download raw bytes

1/* The Torchbearer — the Aspirant.
2 *
3 * A crownless aspirant descending the Shadow King's crypt. Owns movement, aim,
4 * animation state, and the whole attack model:
5 *
6 *   tap attack   -> 3-step combo chain; step 3 is a weapon-specific finisher
7 *   hold attack  -> charged heavy (weapon-specific: spin / impale / quake)
8 *   dash         -> i-frames; a hit inside the first 200ms becomes a PARRY
9 *
10 * HP lives in SK.run; this class never touches it. Attacks are handed back to
11 * DungeonScene as plain descriptors so all damage resolution stays in one
12 * place.
13 */
14window.SK = window.SK || {};
15
16SK.PARRY_WINDOW = 200;      // ms after dash start where a hit becomes a parry
17SK.PARRY_BUFF_MS = 2000;    // how long the free counter stays available
18
19SK.Player = class Player {
20  constructor(scene, x, y) {
21    this.scene = scene;
22
23    // You look like the calling you chose: knight in plate, ranger as an elf,
24    // magister as a wizard. Falls back to the knight if a class has no art.
25    const cls = (scene.run && scene.run.classId) || SK.DEFAULT_CLASS || 'knight';
26    this.animBase = scene.anims.exists('hero-' + cls + '-idle')
27      ? 'hero-' + cls : 'hero';
28    const tex = scene.textures.exists('hero-' + cls + '-i0')
29      ? 'hero-' + cls + '-i0' : 'hero';
30
31    this.sprite = scene.physics.add.sprite(x, y, tex);
32    this.sprite.setDepth(100);
33    this.sprite.body.setCircle(16, this.sprite.width / 2 - 16,
34      this.sprite.height / 2 - 16);
35    this.sprite.setCollideWorldBounds(false);
36
37    this.shadow = scene.add.image(x, y, 'shadow').setDepth(96);
38
39    // Warm glow under the hero so it reads against dark stone.
40    this.glow = scene.add.image(x, y, 'glow-dot')
41      .setDepth(99).setTint(0xff9e2c).setAlpha(0.45).setScale(3);
42    this.glow.setBlendMode(Phaser.BlendModes.ADD);
43
44    // Charge tell: a bright core that swells as the swing winds up.
45    this.chargeGlow = scene.add.image(x, y, 'glow-dot')
46      .setDepth(101).setAlpha(0).setScale(1);
47    this.chargeGlow.setBlendMode(Phaser.BlendModes.ADD);
48
49    // The arm you are actually carrying. Until now the weapon existed only as
50    // a stat block and an icon in the corner, so every calling looked
51    // identically unarmed. It is held to the aiming side, swings through the
52    // arc on attack, and is drawn behind the hero when aiming away.
53    this.weaponSprite = scene.add.image(x, y, 'wpn-sword')
54      .setDepth(101).setOrigin(0.5, 0.88).setScale(0.78);
55    this.weaponAngle = 0;
56    this.swingT = 1;          // 0..1 through the current swing, 1 = at rest
57    this.swingDir = 1;
58    this._syncWeaponTexture();
59
60    this.aimAngle = 0;
61    this.lastMove = { x: 1, y: 0 };
62    this.facing = 'down';
63
64    this.lastAttackAt = -9999;
65    this.lastDashAt = -9999;
66    this.dashUntil = 0;
67    this.dashStartedAt = -9999;
68    this.hurtUntil = 0;
69    this.iframesUntil = 0;
70    this.dashVec = { x: 0, y: 0 };
71
72    // Combo + charge state.
73    this.comboStep = 0;
74    this.comboExpiry = 0;
75    this.holdStart = 0;
76    this.holding = false;
77
78    // Parry reward: the next attack is free, doubled, and staggers.
79    this.parryUntil = 0;
80
81    this.keys = scene.input.keyboard.addKeys({
82      up: 'W', down: 'S', left: 'A', right: 'D',
83      up2: 'UP', down2: 'DOWN', left2: 'LEFT', right2: 'RIGHT',
84      dash: 'SHIFT', dash2: 'K'
85    });
86  }
87
88  get x() { return this.sprite.x; }
89  get y() { return this.sprite.y; }
90
91  mods() { return this.scene.run.mods; }
92  weapon() { return SK.currentWeapon(this.scene.run); }
93
94  /* ---- per-frame ------------------------------------------------------ */
95
96  update(now) {
97    const P = SK.PLAYER;
98    const body = this.sprite.body;
99    const klass = SK.currentClass(this.scene.run);
100
101    // The combo lapses if you wait too long between swings.
102    if (this.comboStep > 0 && now > this.comboExpiry) this.comboStep = 0;
103
104    // Held-weapon swing clock. Runs off the weapon's own cooldown so a dagger
105    // visibly flurries where an axe visibly heaves.
106    if (this.swingT < 1) {
107      const dur = Math.max(90, this._cooldown() * 0.75);
108      this.swingT = Math.min(1, this.swingT + (this.scene.game.loop.delta || 16) / dur);
109    }
110
111    if (now < this.dashUntil) {
112      body.setVelocity(this.dashVec.x * P.dashSpeed, this.dashVec.y * P.dashSpeed);
113      this._ghost();
114      this._syncAttachments(now);
115      return;
116    }
117
118    let mx = 0, my = 0;
119    if (this.keys.left.isDown || this.keys.left2.isDown) mx -= 1;
120    if (this.keys.right.isDown || this.keys.right2.isDown) mx += 1;
121    if (this.keys.up.isDown || this.keys.up2.isDown) my -= 1;
122    if (this.keys.down.isDown || this.keys.down2.isDown) my += 1;
123
124    let speed = (klass.speed || P.speed) *
125      (this.scene.roomMods ? this.scene.roomMods.speedMult : 1);
126    if (this.isCharging(now)) speed *= SK.CHARGE_MOVE_MULT;
127
128    if (mx || my) {
129      const len = Math.hypot(mx, my);
130      body.setVelocity((mx / len) * speed, (my / len) * speed);
131      this.lastMove.x = mx / len;
132      this.lastMove.y = my / len;
133    } else {
134      body.setVelocity(0, 0);
135    }
136
137    // Aim follows the mouse when there is one, else the travel direction.
138    const p = this.scene.input.activePointer;
139    if (p && p.active !== false && (p.x || p.y)) {
140      const wp = p.positionToCamera(this.scene.cameras.main);
141      this.aimAngle = Phaser.Math.Angle.Between(this.x, this.y, wp.x, wp.y);
142    } else {
143      this.aimAngle = Math.atan2(this.lastMove.y, this.lastMove.x);
144    }
145
146    const ax = Math.cos(this.aimAngle);
147    const ay = Math.sin(this.aimAngle);
148    this.facing = (Math.abs(ay) > Math.abs(ax) * 1.2)
149      ? (ay > 0 ? 'down' : 'up') : 'side';
150    this.sprite.setFlipX(this.facing === 'side' && ax < 0);
151
152    // Real 4-frame cycles now, so drive the animation rather than stamping a
153    // texture every frame — setTexture here would restart the cycle forever
154    // and the hero would freeze on frame 0.
155    const want = this.animBase + (mx || my ? '-walk' : '-idle');
156    const cur = this.sprite.anims.currentAnim;
157    if (!cur || cur.key !== want) {
158      if (this.scene.anims.exists(want)) this.sprite.play(want, true);
159    }
160
161    this.sprite.setAlpha(now < this.hurtUntil
162      ? (Math.floor(now / 60) % 2 ? 0.35 : 0.9) : 1);
163
164    this._syncAttachments(now);
165  }
166
167  /* Hold the weapon in a hand and swing it from there.
168   *
169   * The first version orbited the weapon around the body's centre, which is
170   * why it read as floating rather than gripped. A wielded arm has to do three
171   * things: sit at a HAND (offset to the character's side, not the middle),
172   * pivot about its GRIP (origin near the handle), and keep a believable rest
173   * pose when nothing is happening.
174   */
175  _syncWeapon(now) {
176    const w = this.weaponSprite;
177    if (!w || !w.active) return;
178    this._syncWeaponTexture();
179
180    const aim = this.aimAngle;
181    const facingLeft = Math.cos(aim) < 0;
182    const side = facingLeft ? -1 : 1;
183    const weap = this.weapon();
184    const ranged = !!weap.ranged;
185
186    // The hand: out to the aiming side of the torso and a little down, so the
187    // weapon hangs off the body instead of sprouting from its navel.
188    const perp = aim + Math.PI / 2;
189    let handX = this.x + Math.cos(aim) * 5 - Math.cos(perp) * 9 * side;
190    let handY = this.y + Math.sin(aim) * 5 - Math.sin(perp) * 9 * side + 5;
191
192    // Walking bob, so the arm moves with the body rather than sliding along.
193    const moving = this.sprite.body &&
194      (this.sprite.body.velocity.x || this.sprite.body.velocity.y);
195    if (moving) handY += Math.sin(now / 90) * 1.6;
196
197    // Rest pose per weapon family.
198    //   melee  — carried out and angled back, blade low
199    //   bow    — held across the body, limbs perpendicular to the shot
200    //   staff  — carried upright, the gem above the hand
201    let ang;
202    if (weap.kind === 'staff' || weap.kind === 'froststaff') {
203      ang = -Math.PI / 2 + 0.12 * side;
204    } else if (ranged) {
205      ang = aim + Math.PI / 2;
206    } else {
207      ang = aim + 0.75 * side;
208    }
209
210    if (this.swingT < 1) {
211      const ease = 1 - Math.pow(1 - this.swingT, 3);
212      if (weap.kind === 'staff' || weap.kind === 'froststaff') {
213        // Thrust the staff forward to cast, then settle back upright.
214        const cast = aim + Math.PI / 2;
215        ang = Phaser.Math.Angle.RotateTo(-Math.PI / 2 + 0.12 * side, cast,
216          (1 - ease) * 1.2);
217        handX += Math.cos(aim) * (1 - ease) * 10;
218        handY += Math.sin(aim) * (1 - ease) * 10;
219      } else if (ranged) {
220        // Recoil straight back down the firing line.
221        ang = aim + Math.PI / 2 + (1 - ease) * 0.45 * this.swingDir;
222        handX -= Math.cos(aim) * (1 - ease) * 7;
223        handY -= Math.sin(aim) * (1 - ease) * 7;
224      } else {
225        // Sweep the blade through the weapon's own arc, and let the hand lead
226        // into the blow so the whole body commits to it.
227        const arc = weap.arc || 1.9;
228        ang = aim - this.swingDir * arc * 0.55 + this.swingDir * arc * 1.1 * ease;
229        const lead = Math.sin(Math.PI * ease) * 9;
230        handX += Math.cos(aim) * lead;
231        handY += Math.sin(aim) * lead;
232      }
233    }
234
235    w.setPosition(handX, handY);
236    w.setRotation(ang + Math.PI / 2);
237    w.setFlipX(facingLeft);
238
239    // Behind the hero when the arm is on the far side of the body, in front
240    // otherwise — the cheapest way to make a flat sprite look like it has an arm.
241    //
242    // This MUST be relative to the hero's own depth: the scene Y-sorts, so the
243    // hero sits at ~100 + y*0.1 (121 and climbing), and a hard-coded 101 here
244    // put the weapon permanently behind him — attached, visible, and never
245    // once drawn on top.
246    const base = this.sprite.depth;
247    const behind = this.facing === 'up' && this.swingT >= 1;
248    w.setDepth(base + (behind ? -2 : 2));
249    w.setAlpha(this.sprite.alpha);
250    w.setVisible(this.sprite.visible);
251  }
252
253  /* Begin a visible swing. sweep is +1/-1 from the combo step, so the second
254   * blow of a chain comes back the other way. */
255  _startSwing(sweep, w) {
256    this.swingT = 0;
257    this.swingDir = sweep === 0 ? 1 : (sweep > 0 ? 1 : -1);
258  }
259
260  _syncWeaponTexture() {
261    const id = (this.scene.run && this.scene.run.weaponId) || 'sword';
262    if (this._weaponTexId === id) return;
263    const key = 'wpn-' + id;
264    if (this.scene.textures.exists(key)) {
265      this.weaponSprite.setTexture(key);
266      this._weaponTexId = id;
267    }
268  }
269
270  _syncAttachments(now) {
271    this.glow.setPosition(this.x, this.y + 8);
272    this.shadow.setPosition(this.x, this.y + this.sprite.displayHeight / 2 - 4);
273    this._syncWeapon(now);
274
275    if (this.isCharging(now)) {
276      const t = this.chargeProgress(now);
277      const w = this.weapon();
278      this.chargeGlow.setPosition(
279        this.x + Math.cos(this.aimAngle) * 26,
280        this.y + Math.sin(this.aimAngle) * 26
281      );
282      this.chargeGlow.setTint(t >= 1 ? 0xffffff : w.glow);
283      this.chargeGlow.setAlpha(0.35 + t * 0.6);
284      this.chargeGlow.setScale(
285        0.8 + t * 2.4 + (t >= 1 ? Math.sin(now / 60) * 0.3 : 0)
286      );
287    } else {
288      this.chargeGlow.setAlpha(0);
289    }
290  }
291
292  _ghost() {
293    if (Math.random() < 0.5) return;
294    const g = this.scene.add.image(this.x, this.y, this.sprite.texture.key)
295      .setDepth(98).setTintFill(0xe8dcc2).setAlpha(0.45)
296      .setFlipX(this.sprite.flipX);
297    this.scene.tweens.add({
298      targets: g, alpha: 0, duration: 220,
299      onComplete: () => g.destroy()
300    });
301  }
302
303  /* ---- attack input --------------------------------------------------- */
304
305  pressAttack(now) {
306    this.holding = true;
307    this.holdStart = now;
308  }
309
310  /* Returns an attack descriptor, or null if still on cooldown. */
311  releaseAttack(now) {
312    const held = this.holding ? now - this.holdStart : 0;
313    this.holding = false;
314    return (held >= SK.CHARGE_MS)
315      ? this._chargedAttack(now)
316      : this._comboAttack(now);
317  }
318
319  isCharging(now) {
320    return this.holding && (now - this.holdStart) >= SK.CHARGE_MS * 0.35;
321  }
322
323  chargeProgress(now) {
324    if (!this.holding) return 0;
325    return Phaser.Math.Clamp((now - this.holdStart) / SK.CHARGE_MS, 0, 1);
326  }
327
328  _cooldown() {
329    const w = this.weapon();
330    // Power level is folded in by SK.weaponCooldown, which also applies the
331    // shard atkSpeedMult, so this only adds the room modifier on top.
332    const base = SK.weaponCooldown
333      ? SK.weaponCooldown(this.scene.run, w)
334      : w.cooldown * this.mods().atkSpeedMult;
335    return base * (this.scene.roomMods ? this.scene.roomMods.atkSpeedMult : 1);
336  }
337
338  _baseDamage() {
339    const w = this.weapon();
340    let d = SK.weaponDamage(this.scene.run, w) + this.mods().dmgBonus +
341      (this.scene.roomMods ? this.scene.roomMods.dmgBonus : 0);
342    const rage = this.scene.run.buffs && this.scene.run.buffs.rage;
343    if (rage) d = Math.max(1, Math.round(d * rage.mult));
344    return d;
345  }
346
347  _comboAttack(now) {
348    const parry = now < this.parryUntil;
349    // A parry counter ignores cooldown entirely — that is the reward.
350    if (!parry && now - this.lastAttackAt < this._cooldown()) return null;
351    this.lastAttackAt = now;
352
353    const w = this.weapon();
354    const step = w.combo[Math.min(this.comboStep, w.combo.length - 1)];
355
356    this.comboStep = (this.comboStep + 1) % w.combo.length;
357    this.comboExpiry = now + SK.COMBO_WINDOW_MS;
358
359    const range = w.range * step.rangeMult + this.mods().rangeBonus;
360    const arc = w.arc * step.arcMult * this.mods().arcMult;
361    let dmg = Math.round(this._baseDamage() * step.dmgMult);
362
363    if (parry) {
364      dmg *= 2;
365      this.parryUntil = 0;
366    }
367
368    // Lunge into the swing — attacks should move you.
369    this.sprite.body.velocity.x += Math.cos(this.aimAngle) * step.lunge;
370    this.sprite.body.velocity.y += Math.sin(this.aimAngle) * step.lunge;
371
372    if (w.ranged) {
373      // Loose the shot(s) and recoil slightly; no arc is drawn.
374      const shots = step.shots || 1;
375      const spread = step.spread || 0;
376      for (let i = 0; i < shots; i++) {
377        const off = shots === 1 ? 0 : (i - (shots - 1) / 2) * spread;
378        this.scene.spawnPlayerBolt(this.x, this.y, this.aimAngle + off, {
379          speed: w.boltSpeed, dmg: dmg, weapon: w, range: range,
380          knock: step.knock || 120, burst: w.burst || 0,
381          silent: i > 0,          // one report per volley, not one per arrow
382          // Weapon-level pierce, not just the charged shot: the crossbow is
383          // supposed to punch through on every bolt, which is the whole
384          // reason to carry it over the faster shortbow.
385          pierce: !!w.pierce,
386          finisher: !!step.finisher, parry: parry
387        });
388      }
389      this.sprite.body.velocity.x -= Math.cos(this.aimAngle) * 40;
390      this.sprite.body.velocity.y -= Math.sin(this.aimAngle) * 40;
391      return {
392        ranged: true, x: this.x, y: this.y, angle: this.aimAngle,
393        range: 0, arc: 0, dmg: 0, knock: 0,
394        finisher: !!step.finisher, stagger: false, charged: false,
395        parry: parry, kick: step.kick,
396        label: parry ? 'RIPOSTE' : null, weapon: w
397      };
398    }
399
400    this._drawSwing(range, arc, w, step.sweep, false);
401    this._startSwing(step.sweep, w);
402
403    return {
404      x: this.x, y: this.y, angle: this.aimAngle,
405      range: range, arc: arc, dmg: dmg,
406      knock: step.knock || 120,
407      finisher: !!step.finisher,
408      stagger: !!step.finisher || parry,
409      slam: !!step.slam,
410      charged: false, parry: parry,
411      kick: step.kick, label: parry ? 'RIPOSTE' : null,
412      weapon: w
413    };
414  }
415
416  _chargedAttack(now) {
417    const w = this.weapon();
418    const c = w.charge;
419    this.lastAttackAt = now;
420    this.comboStep = 0;
421
422    let dmg = this._baseDamage() + c.damage;
423    if (now < this.parryUntil) { dmg *= 2; this.parryUntil = 0; }
424
425    // Ranged charges are single heavy projectiles.
426    if (c.type === 'pierce') {
427      this.scene.spawnPlayerBolt(this.x, this.y, this.aimAngle, {
428        speed: c.boltSpeed || 800, dmg: dmg, weapon: w, range: c.range,
429        knock: c.knock, pierce: true, big: true, finisher: true, charged: true
430      });
431      return {
432        ranged: true, x: this.x, y: this.y, angle: this.aimAngle,
433        range: 0, arc: 0, dmg: 0, knock: 0, finisher: true, stagger: false,
434        charged: true, chargeType: c.type, kick: c.shake,
435        label: c.label, weapon: w
436      };
437    }
438
439    // Starfall: a ring of bolts in every direction.
440    if (c.type === 'nova') {
441      for (let i = 0; i < 12; i++) {
442        this.scene.spawnPlayerBolt(this.x, this.y, (i / 12) * Math.PI * 2, {
443          speed: 460, dmg: dmg, weapon: w, range: c.range,
444          knock: c.knock, burst: w.burst || 40, finisher: true, charged: true
445        });
446      }
447      return {
448        ranged: true, x: this.x, y: this.y, angle: this.aimAngle,
449        range: 0, arc: 0, dmg: 0, knock: 0, finisher: true, stagger: false,
450        charged: true, chargeType: c.type, kick: c.shake,
451        label: c.label, weapon: w
452      };
453    }
454
455    // The impale charge hurls you forward through the line you just cut.
456    if (c.type === 'impale') {
457      this.dashVec.x = Math.cos(this.aimAngle);
458      this.dashVec.y = Math.sin(this.aimAngle);
459      this.dashUntil = now + 220;
460      this.iframesUntil = now + 260;
461    }
462
463    this._drawSwing(c.range, c.arc, w, 1, true);
464
465    return {
466      x: this.x, y: this.y, angle: this.aimAngle,
467      range: c.range, arc: c.arc, dmg: dmg,
468      knock: c.knock, finisher: true, stagger: true,
469      slam: c.type === 'quake', pierce: c.type === 'impale',
470      charged: true, chargeType: c.type,
471      kick: c.shake, label: c.label, weapon: w
472    };
473  }
474
475  /* Weapon-shaped swing: a wedge for blades, a spike for the spear. */
476  _drawSwing(range, arc, w, sweep, charged) {
477    const g = this.scene.add.graphics().setDepth(300);
478    const a0 = this.aimAngle - arc / 2;
479    const a1 = this.aimAngle + arc / 2;
480
481    if (w.kind === 'spear') {
482      const tipX = this.x + Math.cos(this.aimAngle) * range;
483      const tipY = this.y + Math.sin(this.aimAngle) * range;
484      const perp = this.aimAngle + Math.PI / 2;
485      const halfW = charged ? 16 : 10;
486      g.fillStyle(w.color, charged ? 0.6 : 0.4);
487      g.fillTriangle(
488        this.x + Math.cos(perp) * halfW, this.y + Math.sin(perp) * halfW,
489        this.x - Math.cos(perp) * halfW, this.y - Math.sin(perp) * halfW,
490        tipX, tipY
491      );
492      g.lineStyle(3, w.glow, 0.9);
493      g.beginPath();
494      g.moveTo(this.x, this.y);
495      g.lineTo(tipX, tipY);
496      g.strokePath();
497    } else {
498      g.fillStyle(w.color, charged ? 0.5 : 0.32);
499      g.slice(this.x, this.y, range, a0, a1, false);
500      g.fillPath();
501      g.lineStyle(charged ? 5 : 3, w.glow, 0.95);
502      g.beginPath();
503      g.arc(this.x, this.y, range - 4, a0, a1, false);
504      g.strokePath();
505    }
506
507    this.scene.tweens.add({
508      targets: g,
509      alpha: 0,
510      duration: charged ? 220 : 130,
511      onComplete: () => g.destroy()
512    });
513  }
514
515  /* ---- dash / damage --------------------------------------------------- */
516
517  tryDash(now) {
518    const m = this.mods();
519    if (now - this.lastDashAt <
520        SK.PLAYER.dashCooldown * (m.dashCooldownMult || 1)) return false;
521    this.lastDashAt = now;
522    this.dashStartedAt = now;
523
524    let dx = this.lastMove.x, dy = this.lastMove.y;
525    const body = this.sprite.body;
526    if (Math.abs(body.velocity.x) + Math.abs(body.velocity.y) > 10) {
527      const len = Math.hypot(body.velocity.x, body.velocity.y);
528      dx = body.velocity.x / len;
529      dy = body.velocity.y / len;
530    }
531    this.dashVec.x = dx;
532    this.dashVec.y = dy;
533
534    this.dashUntil = now + SK.PLAYER.dashMs * m.dashRangeMult;
535    this.iframesUntil = now + SK.PLAYER.dashIframes + m.dashIframeBonus;
536    return true;
537  }
538
539  isDashing(now) { return now < this.dashUntil; }
540
541  /* True only in the opening frames of a dash — what a parry rewards. */
542  inParryWindow(now) {
543    return (now - this.dashStartedAt) <= SK.PARRY_WINDOW;
544  }
545
546  grantParry(now) {
547    this.parryUntil = now + SK.PARRY_BUFF_MS;
548  }
549
550  invulnerable(now) {
551    return now < this.hurtUntil || now < this.iframesUntil;
552  }
553
554  hurt(now, fromX, fromY) {
555    this.hurtUntil = now + SK.PLAYER.hurtIframes;
556    this.comboStep = 0;
557    const ang = Phaser.Math.Angle.Between(fromX, fromY, this.x, this.y);
558    this.sprite.body.setVelocity(Math.cos(ang) * 320, Math.sin(ang) * 320);
559  }
560
561  destroy() {
562    [this.sprite, this.glow, this.shadow, this.chargeGlow,
563     this.weaponSprite].forEach(o => {
564      if (o && o.active) o.destroy();
565    });
566    this.sprite = null;
567  }
568};

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.