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.