painters.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import type { Graphics } from 'pixi.js';
  2. import { OUTLINE_COLOR } from '../config';
  3. /** Everything a painter needs to draw one frame. Local space: facing right, body radius `r`, origin at the center. */
  4. export interface Pose {
  5. /** Seconds since the view was created. */
  6. time: number;
  7. /** Walk cycle phase (radians). */
  8. walk: number;
  9. moving: boolean;
  10. grounded: boolean;
  11. attack: boolean;
  12. special: boolean;
  13. stunned: boolean;
  14. /** Unit look direction in local space. */
  15. lookX: number;
  16. lookY: number;
  17. /** 0 at base size, 1 at max size. */
  18. fat: number;
  19. /** Player color. */
  20. color: number;
  21. /** Outline width in local units. */
  22. ink: number;
  23. }
  24. export type Painter = (g: Graphics, r: number, pose: Pose) => void;
  25. const INK = OUTLINE_COLOR;
  26. function ellipsePoly(cx: number, cy: number, rx: number, ry: number, angle: number, steps = 20): number[] {
  27. const pts: number[] = [];
  28. const c = Math.cos(angle);
  29. const s = Math.sin(angle);
  30. for (let i = 0; i < steps; i++) {
  31. const a = (i / steps) * Math.PI * 2;
  32. const x = Math.cos(a) * rx;
  33. const y = Math.sin(a) * ry;
  34. pts.push(cx + x * c - y * s, cy + x * s + y * c);
  35. }
  36. return pts;
  37. }
  38. function eyes(g: Graphics, pose: Pose, spots: [number, number][], size: number): void {
  39. for (const [ex, ey] of spots) {
  40. if (pose.stunned) {
  41. const d = size * 0.6;
  42. g.moveTo(ex - d, ey - d).lineTo(ex + d, ey + d).moveTo(ex + d, ey - d).lineTo(ex - d, ey + d).stroke({ width: pose.ink, color: INK, cap: 'round' });
  43. continue;
  44. }
  45. g.ellipse(ex, ey, size, size * 1.2).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK });
  46. const px = ex + pose.lookX * size * 0.4;
  47. const py = ey + pose.lookY * size * 0.45;
  48. g.circle(px, py, size * 0.5).fill(INK);
  49. g.circle(px - size * 0.18, py - size * 0.2, size * 0.17).fill(0xffffff);
  50. }
  51. }
  52. function foot(g: Graphics, x: number, y: number, r: number, color: number, ink: number): void {
  53. g.ellipse(x, y, r * 0.24, r * 0.13).fill(color).stroke({ width: ink, color: INK });
  54. }
  55. function stars(g: Graphics, r: number, pose: Pose): void {
  56. if (!pose.stunned) return;
  57. for (let i = 0; i < 3; i++) {
  58. const a = pose.time * 6 + (i * Math.PI * 2) / 3;
  59. const cx = Math.cos(a) * r * 0.8;
  60. const cy = -r * 1.25 + Math.sin(a) * r * 0.2;
  61. const pts: number[] = [];
  62. for (let k = 0; k < 10; k++) {
  63. const rr = k % 2 === 0 ? r * 0.2 : r * 0.08;
  64. const aa = (k / 10) * Math.PI * 2 - Math.PI / 2;
  65. pts.push(cx + Math.cos(aa) * rr, cy + Math.sin(aa) * rr);
  66. }
  67. g.poly(pts).fill(0xffe14d).stroke({ width: pose.ink * 0.6, color: INK });
  68. }
  69. }
  70. /** Castor Glouton: brown, big teeth, paddle tail, cheeks that swell as he eats. */
  71. export const paintCastor: Painter = (g, r, pose) => {
  72. const fur = 0xa0652d;
  73. const dark = 0x6b4226;
  74. const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
  75. const lift = pose.moving && pose.grounded ? Math.abs(Math.cos(pose.walk)) * 0.12 * r : 0;
  76. const chomp = pose.special && Math.sin(pose.time * 28) > 0;
  77. const mouthOpen = pose.attack || chomp;
  78. // Tail (behind), wagging.
  79. const wag = pose.moving ? Math.sin(pose.walk * 0.5) * 0.15 : Math.sin(pose.time * 2) * 0.06;
  80. g.poly(ellipsePoly(-0.95 * r, 0.42 * r, 0.62 * r, 0.24 * r, -0.35 + wag)).fill(dark).stroke({ width: pose.ink, color: INK });
  81. for (let i = -1; i <= 1; i++) {
  82. const ox = -0.95 * r + i * 0.22 * r;
  83. g.moveTo(ox - 0.08 * r, 0.3 * r + i * 0.08 * r).lineTo(ox + 0.1 * r, 0.52 * r + i * 0.08 * r).stroke({ width: pose.ink * 0.5, color: 0x4a2c17 });
  84. }
  85. foot(g, -0.38 * r - step * 0.18 * r, 0.93 * r - (step < 0 ? lift : 0), r, dark, pose.ink);
  86. // Body + belly.
  87. g.circle(0, 0, r).fill(fur).stroke({ width: pose.ink, color: INK });
  88. g.ellipse(0.22 * r, 0.32 * r, 0.52 * r, 0.5 * r).fill(0xe0b07a);
  89. // Fur tufts.
  90. g.moveTo(-0.55 * r, -0.2 * r).lineTo(-0.42 * r, -0.12 * r).moveTo(-0.6 * r, 0.05 * r).lineTo(-0.46 * r, 0.1 * r).stroke({ width: pose.ink * 0.5, color: dark });
  91. // Ears.
  92. for (const [ex, ey, er] of [
  93. [-0.32, -0.86, 0.21],
  94. [0.22, -0.94, 0.18],
  95. ]) {
  96. g.circle(ex * r, ey * r, er * r).fill(fur).stroke({ width: pose.ink, color: INK });
  97. g.circle(ex * r, ey * r, er * r * 0.5).fill(0xf2a5a5);
  98. }
  99. // Cheeks swell with food.
  100. const cheek = 0.2 + pose.fat * 0.22 + (pose.special ? 0.04 : 0);
  101. g.circle(0.5 * r, 0.22 * r, cheek * r).fill(0xb8763b).stroke({ width: pose.ink * 0.7, color: INK });
  102. g.circle(0.44 * r, 0.16 * r, cheek * r * 0.35).fill({ color: 0xff8fa3, alpha: 0.6 });
  103. eyes(
  104. g,
  105. pose,
  106. [
  107. [0.3 * r, -0.34 * r],
  108. [0.64 * r, -0.3 * r],
  109. ],
  110. 0.17 * r,
  111. );
  112. // Mouth and the famous teeth.
  113. const jaw = mouthOpen ? 0.22 * r : 0;
  114. if (mouthOpen) g.ellipse(0.8 * r, 0.24 * r, 0.2 * r, 0.12 * r + jaw * 0.5).fill(0x5a1e1e).stroke({ width: pose.ink * 0.8, color: INK });
  115. for (const tx of [0.66, 0.8]) {
  116. g.roundRect(tx * r, 0.05 * r, 0.13 * r, 0.26 * r, 0.03 * r).fill(0xfffbe8).stroke({ width: pose.ink * 0.7, color: INK });
  117. }
  118. // Nose and whiskers.
  119. g.ellipse(0.96 * r, -0.06 * r, 0.14 * r, 0.1 * r).fill(INK);
  120. g.circle(0.93 * r, -0.09 * r, 0.04 * r).fill(0xffffff);
  121. g.moveTo(0.98 * r, 0.02 * r)
  122. .lineTo(1.32 * r, -0.08 * r)
  123. .moveTo(0.98 * r, 0.06 * r)
  124. .lineTo(1.32 * r, 0.1 * r)
  125. .stroke({ width: pose.ink * 0.4, color: INK });
  126. foot(g, 0.36 * r + step * 0.18 * r, 0.93 * r - (step > 0 ? lift : 0), r, dark, pose.ink);
  127. stars(g, r, pose);
  128. };
  129. /** The Tester: a squishy jelly bean with a sprout on its head. */
  130. export const paintGeneric: Painter = (g, r, pose) => {
  131. const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
  132. const dark = darken(pose.color, 0.7);
  133. foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, dark, pose.ink);
  134. // Sprout.
  135. const sway = Math.sin(pose.time * 3) * 0.1 * r;
  136. g.moveTo(0, -r).quadraticCurveTo(0.05 * r, -1.25 * r, sway, -1.4 * r).stroke({ width: pose.ink, color: INK });
  137. g.poly(ellipsePoly(sway + 0.18 * r, -1.42 * r, 0.2 * r, 0.1 * r, -0.4)).fill(0x7ccf5a).stroke({ width: pose.ink * 0.7, color: INK });
  138. g.circle(0, 0, r).fill(pose.color).stroke({ width: pose.ink, color: INK });
  139. g.ellipse(-0.35 * r, -0.45 * r, 0.25 * r, 0.15 * r).fill({ color: 0xffffff, alpha: 0.55 });
  140. g.circle(0.55 * r, 0.25 * r, 0.14 * r).fill({ color: 0xff3b6b, alpha: 0.35 });
  141. eyes(
  142. g,
  143. pose,
  144. [
  145. [0.12 * r, -0.2 * r],
  146. [0.5 * r, -0.18 * r],
  147. ],
  148. 0.2 * r,
  149. );
  150. if (pose.attack) g.ellipse(0.4 * r, 0.32 * r, 0.16 * r, 0.14 * r).fill(0x5a1e1e).stroke({ width: pose.ink * 0.7, color: INK });
  151. else g.moveTo(0.18 * r, 0.3 * r).quadraticCurveTo(0.38 * r, 0.45 * r, 0.6 * r, 0.28 * r).stroke({ width: pose.ink * 0.8, color: INK, cap: 'round' });
  152. foot(g, 0.35 * r + step * 0.18 * r, 0.93 * r, r, dark, pose.ink);
  153. stars(g, r, pose);
  154. };
  155. function darken(color: number, k: number): number {
  156. return (Math.round((color >> 16) * k) << 16) | (Math.round(((color >> 8) & 0xff) * k) << 8) | Math.round((color & 0xff) * k);
  157. }
  158. export const PAINTERS: Record<string, Painter> = {
  159. castor: paintCastor,
  160. generic: paintGeneric,
  161. };