PlayerView.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { Container, Graphics, Text } from 'pixi.js';
  2. import { Anim } from '../../shared/characters';
  3. import type { PlayerInfo, PlayerState } from '../../shared/protocol';
  4. import { PAINTERS, paintGeneric, type Painter, type Pose } from '../characters/painters';
  5. import { paintItem } from './RoundViews';
  6. import { OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, RUN_LEAN, SQUASH_RECOVERY } from '../config';
  7. /** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */
  8. const FAT_RANGE = 26;
  9. export interface PlayerEvents {
  10. landed: boolean;
  11. landingSpeed: number;
  12. stunnedNow: boolean;
  13. }
  14. /** A character on screen: painter-drawn body with squash & stretch, walk cycle, and a name tag. */
  15. export class PlayerView {
  16. readonly container = new Container();
  17. private readonly body = new Graphics();
  18. /** Soft contact shadow on the ground. */
  19. private readonly shadow = new Graphics();
  20. /** Carried item (candle) held up next to the character. */
  21. private readonly held = new Graphics();
  22. private readonly name: Text;
  23. private readonly paint: Painter;
  24. private readonly color: number;
  25. private time = 0;
  26. private walk = 0;
  27. private squash = 0;
  28. private wasGrounded = true;
  29. private wasStunned = false;
  30. private lastVy = 0;
  31. constructor(readonly info: PlayerInfo) {
  32. this.paint = PAINTERS[info.characterId] ?? paintGeneric;
  33. this.color = PLAYER_COLORS[(info.id - 1) % PLAYER_COLORS.length];
  34. this.name = new Text({
  35. text: info.name,
  36. style: { fontFamily: 'Comic Sans MS', fontSize: 15, fill: this.color, fontWeight: 'bold', stroke: { color: OUTLINE_COLOR, width: 4 } },
  37. });
  38. this.name.anchor.set(0.5, 1);
  39. this.held.scale.set(0.8);
  40. this.container.addChild(this.shadow, this.body, this.held, this.name);
  41. }
  42. /** Draws a static pose (menu portraits). */
  43. drawIdle(): void {
  44. this.draw(this.info.radius, 1, 0.6, -0.2, { moving: false, grounded: true, attack: false, special: false, stunned: false, busy: false, underground: false });
  45. this.shadow.clear();
  46. }
  47. update(state: PlayerState, aimX: number, aimY: number, dt: number): PlayerEvents {
  48. this.time += dt;
  49. const size = state.size;
  50. this.container.position.set(state.x, state.y);
  51. // In the custard, waiting for the spoon.
  52. this.container.visible = (state.anim & Anim.Dead) === 0;
  53. this.held.clear();
  54. if (state.item) {
  55. paintItem(this.held, state.item, false, this.time);
  56. this.held.position.set(-state.facing * size * 0.95, -size * 0.35);
  57. this.held.rotation = -state.facing * 0.4;
  58. }
  59. // Landing squashes, jumping stretches.
  60. const events: PlayerEvents = { landed: false, landingSpeed: 0, stunnedNow: false };
  61. if (state.grounded && !this.wasGrounded) {
  62. this.squash = Math.min(0.45, Math.abs(this.lastVy) / 1600);
  63. events.landed = true;
  64. events.landingSpeed = Math.abs(this.lastVy);
  65. } else if (!state.grounded && this.wasGrounded && state.vy < -100) this.squash = -0.3;
  66. this.wasGrounded = state.grounded;
  67. this.lastVy = state.vy;
  68. const stunned = (state.anim & Anim.Stunned) !== 0;
  69. events.stunnedNow = stunned && !this.wasStunned;
  70. this.wasStunned = stunned;
  71. this.squash += (0 - this.squash) * Math.min(1, SQUASH_RECOVERY * dt);
  72. const moving = Math.abs(state.vx) > 20;
  73. this.walk += dt * (moving ? Math.abs(state.vx) / 18 : 0);
  74. // Look direction in local (facing-right) space.
  75. const dx = (aimX - state.x) * state.facing;
  76. const dy = aimY - state.y;
  77. const len = Math.hypot(dx, dy) || 1;
  78. // Leans into the run.
  79. this.body.rotation = Math.max(-RUN_LEAN, Math.min(RUN_LEAN, (state.vx / 600) * RUN_LEAN));
  80. this.shadow.clear();
  81. if (state.grounded) this.shadow.ellipse(0, size * 0.98, size * 0.85, size * 0.18).fill({ color: OUTLINE_COLOR, alpha: 0.18 });
  82. this.draw(size, state.facing, dx / len, dy / len, {
  83. moving,
  84. grounded: state.grounded,
  85. attack: (state.anim & Anim.Attack) !== 0,
  86. special: (state.anim & Anim.Special) !== 0,
  87. stunned,
  88. busy: (state.anim & Anim.Busy) !== 0,
  89. underground: (state.anim & Anim.Underground) !== 0,
  90. });
  91. return events;
  92. }
  93. private draw(
  94. size: number,
  95. facing: number,
  96. lookX: number,
  97. lookY: number,
  98. flags: Pick<Pose, 'moving' | 'grounded' | 'attack' | 'special' | 'stunned' | 'busy' | 'underground'>,
  99. ): void {
  100. const base = this.info.radius;
  101. const scale = size / base;
  102. const sx = 1 + this.squash;
  103. const sy = 1 - this.squash;
  104. // Wobble while eating.
  105. const wobble = flags.special ? 1 + Math.sin(this.time * 28) * 0.04 : 1;
  106. this.body.scale.set(facing * scale * sx * wobble, scale * sy / wobble);
  107. // Keep the feet on the ground while squashed.
  108. this.body.y = size * (1 - sy);
  109. this.body.clear();
  110. this.paint(this.body, base, {
  111. time: this.time,
  112. walk: this.walk,
  113. ...flags,
  114. lookX,
  115. lookY,
  116. fat: Math.min(1, Math.max(0, (size - base) / FAT_RANGE)),
  117. color: this.color,
  118. ink: OUTLINE_WIDTH / scale,
  119. });
  120. this.name.y = -size * 1.15 - 10;
  121. }
  122. destroy(): void {
  123. this.container.destroy({ children: true });
  124. }
  125. }