PlayerView.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import { ColorMatrixFilter, 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 {
  7. INVISIBLE_FADE,
  8. INVISIBLE_SELF_ALPHA,
  9. OUTLINE_COLOR,
  10. OUTLINE_WIDTH,
  11. PLAYER_COLORS,
  12. RUN_LEAN,
  13. SQUASH_RECOVERY,
  14. VARIANT_FILTER_RESOLUTION,
  15. VARIANT_HUES,
  16. } from '../config';
  17. /** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */
  18. const FAT_RANGE = 26;
  19. export interface PlayerEvents {
  20. landed: boolean;
  21. landingSpeed: number;
  22. jumped: boolean;
  23. stunnedNow: boolean;
  24. }
  25. /** A character on screen: painter-drawn body with squash & stretch, walk cycle, and a name tag. */
  26. export class PlayerView {
  27. readonly container = new Container();
  28. private readonly body = new Graphics();
  29. /** Soft contact shadow on the ground. */
  30. private readonly shadow = new Graphics();
  31. /** Carried item (candle) held up next to the character. */
  32. private readonly held = new Graphics();
  33. private readonly name: Text;
  34. private readonly paint: Painter;
  35. private readonly color: number;
  36. private time = 0;
  37. private walk = 0;
  38. private squash = 0;
  39. private wasGrounded = true;
  40. private wasStunned = false;
  41. private lastVy = 0;
  42. constructor(readonly info: PlayerInfo) {
  43. this.paint = PAINTERS[info.characterId] ?? paintGeneric;
  44. this.color = PLAYER_COLORS[(info.id - 1) % PLAYER_COLORS.length];
  45. this.name = new Text({
  46. text: info.name,
  47. style: { fontFamily: 'Comic Sans MS', fontSize: 15, fill: this.color, fontWeight: 'bold', stroke: { color: OUTLINE_COLOR, width: 4 } },
  48. });
  49. this.name.anchor.set(0.5, 1);
  50. this.held.scale.set(0.8);
  51. this.container.addChild(this.shadow, this.body, this.held, this.name);
  52. // Several players on the same character: the others get their colors shifted.
  53. if (info.variant > 0) {
  54. const filter = new ColorMatrixFilter({ resolution: VARIANT_FILTER_RESOLUTION, antialias: 'on' });
  55. filter.hue(VARIANT_HUES[(info.variant - 1) % VARIANT_HUES.length], false);
  56. this.body.filters = [filter];
  57. }
  58. }
  59. /** Draws a static pose (menu portraits). */
  60. drawIdle(): void {
  61. this.draw(this.info.radius, 1, 0.6, -0.2, { moving: false, grounded: true, attack: false, special: false, stunned: false, busy: false, underground: false });
  62. this.shadow.clear();
  63. }
  64. /** `local`: this is the player at this keyboard (who still sees themself when invisible). */
  65. update(state: PlayerState, aimX: number, aimY: number, dt: number, local = false): PlayerEvents {
  66. this.time += dt;
  67. const size = state.size;
  68. this.container.position.set(state.x, state.y);
  69. // In the custard, waiting for the spoon.
  70. this.container.visible = (state.anim & Anim.Dead) === 0;
  71. // Invisible (Léon): gone for the others, a ghost for himself; fades in and out.
  72. const alpha = state.anim & Anim.Invisible ? (local ? INVISIBLE_SELF_ALPHA : 0) : 1;
  73. this.container.alpha += (alpha - this.container.alpha) * Math.min(1, INVISIBLE_FADE * dt);
  74. this.held.clear();
  75. if (state.item) {
  76. paintItem(this.held, state.item, false, this.time);
  77. this.held.position.set(-state.facing * size * 0.95, -size * 0.35);
  78. this.held.rotation = -state.facing * 0.4;
  79. }
  80. // Landing squashes, jumping stretches.
  81. const events: PlayerEvents = { landed: false, landingSpeed: 0, jumped: false, stunnedNow: false };
  82. if (state.grounded && !this.wasGrounded) {
  83. this.squash = Math.min(0.45, Math.abs(this.lastVy) / 1600);
  84. events.landed = true;
  85. events.landingSpeed = Math.abs(this.lastVy);
  86. } else if (!state.grounded && this.wasGrounded && state.vy < -100) {
  87. this.squash = -0.3;
  88. events.jumped = true;
  89. }
  90. this.wasGrounded = state.grounded;
  91. this.lastVy = state.vy;
  92. const stunned = (state.anim & Anim.Stunned) !== 0;
  93. events.stunnedNow = stunned && !this.wasStunned;
  94. this.wasStunned = stunned;
  95. this.squash += (0 - this.squash) * Math.min(1, SQUASH_RECOVERY * dt);
  96. const moving = Math.abs(state.vx) > 20;
  97. this.walk += dt * (moving ? Math.abs(state.vx) / 18 : 0);
  98. // Look direction in local (facing-right) space.
  99. const dx = (aimX - state.x) * state.facing;
  100. const dy = aimY - state.y;
  101. const len = Math.hypot(dx, dy) || 1;
  102. // Leans into the run.
  103. this.body.rotation = Math.max(-RUN_LEAN, Math.min(RUN_LEAN, (state.vx / 600) * RUN_LEAN));
  104. this.shadow.clear();
  105. if (state.grounded) this.shadow.ellipse(0, size * 0.98, size * 0.85, size * 0.18).fill({ color: OUTLINE_COLOR, alpha: 0.18 });
  106. this.draw(size, state.facing, dx / len, dy / len, {
  107. moving,
  108. grounded: state.grounded,
  109. attack: (state.anim & Anim.Attack) !== 0,
  110. special: (state.anim & Anim.Special) !== 0,
  111. stunned,
  112. busy: (state.anim & Anim.Busy) !== 0,
  113. underground: (state.anim & Anim.Underground) !== 0,
  114. });
  115. // Drenched: dripping wet.
  116. if (state.anim & Anim.Soaked) {
  117. for (let i = 0; i < 3; i++) {
  118. const t = (this.time * 1.6 + i / 3) % 1;
  119. const x = (i - 1) * size * 0.55;
  120. this.shadow.ellipse(x, -size * 0.3 + t * size * 1.2, size * 0.07, size * 0.11).fill({ color: 0x4dc4ff, alpha: 0.85 * (1 - t) });
  121. }
  122. }
  123. return events;
  124. }
  125. private draw(
  126. size: number,
  127. facing: number,
  128. lookX: number,
  129. lookY: number,
  130. flags: Pick<Pose, 'moving' | 'grounded' | 'attack' | 'special' | 'stunned' | 'busy' | 'underground'>,
  131. ): void {
  132. const base = this.info.radius;
  133. const scale = size / base;
  134. const sx = 1 + this.squash;
  135. const sy = 1 - this.squash;
  136. // Wobble while eating.
  137. const wobble = flags.special ? 1 + Math.sin(this.time * 28) * 0.04 : 1;
  138. this.body.scale.set(facing * scale * sx * wobble, scale * sy / wobble);
  139. // Keep the feet on the ground while squashed.
  140. this.body.y = size * (1 - sy);
  141. this.body.clear();
  142. this.paint(this.body, base, {
  143. time: this.time,
  144. walk: this.walk,
  145. ...flags,
  146. lookX,
  147. lookY,
  148. fat: Math.min(1, Math.max(0, (size - base) / FAT_RANGE)),
  149. color: this.color,
  150. ink: OUTLINE_WIDTH / scale,
  151. });
  152. this.name.y = -size * 1.15 - 10;
  153. }
  154. destroy(): void {
  155. this.container.destroy({ children: true });
  156. }
  157. }