import type { Graphics } from 'pixi.js'; import { OUTLINE_COLOR } from '../config'; /** Everything a painter needs to draw one frame. Local space: facing right, body radius `r`, origin at the center. */ export interface Pose { /** Seconds since the view was created. */ time: number; /** Walk cycle phase (radians). */ walk: number; moving: boolean; grounded: boolean; attack: boolean; special: boolean; stunned: boolean; /** Unit look direction in local space. */ lookX: number; lookY: number; /** 0 at base size, 1 at max size. */ fat: number; /** Player color. */ color: number; /** Outline width in local units. */ ink: number; } export type Painter = (g: Graphics, r: number, pose: Pose) => void; const INK = OUTLINE_COLOR; function ellipsePoly(cx: number, cy: number, rx: number, ry: number, angle: number, steps = 20): number[] { const pts: number[] = []; const c = Math.cos(angle); const s = Math.sin(angle); for (let i = 0; i < steps; i++) { const a = (i / steps) * Math.PI * 2; const x = Math.cos(a) * rx; const y = Math.sin(a) * ry; pts.push(cx + x * c - y * s, cy + x * s + y * c); } return pts; } function eyes(g: Graphics, pose: Pose, spots: [number, number][], size: number): void { for (const [ex, ey] of spots) { if (pose.stunned) { const d = size * 0.6; 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' }); continue; } g.ellipse(ex, ey, size, size * 1.2).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK }); const px = ex + pose.lookX * size * 0.4; const py = ey + pose.lookY * size * 0.45; g.circle(px, py, size * 0.5).fill(INK); g.circle(px - size * 0.18, py - size * 0.2, size * 0.17).fill(0xffffff); } } function foot(g: Graphics, x: number, y: number, r: number, color: number, ink: number): void { g.ellipse(x, y, r * 0.24, r * 0.13).fill(color).stroke({ width: ink, color: INK }); } function stars(g: Graphics, r: number, pose: Pose): void { if (!pose.stunned) return; for (let i = 0; i < 3; i++) { const a = pose.time * 6 + (i * Math.PI * 2) / 3; const cx = Math.cos(a) * r * 0.8; const cy = -r * 1.25 + Math.sin(a) * r * 0.2; const pts: number[] = []; for (let k = 0; k < 10; k++) { const rr = k % 2 === 0 ? r * 0.2 : r * 0.08; const aa = (k / 10) * Math.PI * 2 - Math.PI / 2; pts.push(cx + Math.cos(aa) * rr, cy + Math.sin(aa) * rr); } g.poly(pts).fill(0xffe14d).stroke({ width: pose.ink * 0.6, color: INK }); } } /** Castor Glouton: brown, big teeth, paddle tail, cheeks that swell as he eats. */ export const paintCastor: Painter = (g, r, pose) => { const fur = 0xa0652d; const dark = 0x6b4226; const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0; const lift = pose.moving && pose.grounded ? Math.abs(Math.cos(pose.walk)) * 0.12 * r : 0; const chomp = pose.special && Math.sin(pose.time * 28) > 0; const mouthOpen = pose.attack || chomp; // Tail (behind), wagging. const wag = pose.moving ? Math.sin(pose.walk * 0.5) * 0.15 : Math.sin(pose.time * 2) * 0.06; 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 }); for (let i = -1; i <= 1; i++) { const ox = -0.95 * r + i * 0.22 * r; 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 }); } foot(g, -0.38 * r - step * 0.18 * r, 0.93 * r - (step < 0 ? lift : 0), r, dark, pose.ink); // Body + belly. g.circle(0, 0, r).fill(fur).stroke({ width: pose.ink, color: INK }); g.ellipse(0.22 * r, 0.32 * r, 0.52 * r, 0.5 * r).fill(0xe0b07a); // Fur tufts. 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 }); // Ears. for (const [ex, ey, er] of [ [-0.32, -0.86, 0.21], [0.22, -0.94, 0.18], ]) { g.circle(ex * r, ey * r, er * r).fill(fur).stroke({ width: pose.ink, color: INK }); g.circle(ex * r, ey * r, er * r * 0.5).fill(0xf2a5a5); } // Cheeks swell with food. const cheek = 0.2 + pose.fat * 0.22 + (pose.special ? 0.04 : 0); g.circle(0.5 * r, 0.22 * r, cheek * r).fill(0xb8763b).stroke({ width: pose.ink * 0.7, color: INK }); g.circle(0.44 * r, 0.16 * r, cheek * r * 0.35).fill({ color: 0xff8fa3, alpha: 0.6 }); eyes( g, pose, [ [0.3 * r, -0.34 * r], [0.64 * r, -0.3 * r], ], 0.17 * r, ); // Mouth and the famous teeth. const jaw = mouthOpen ? 0.22 * r : 0; 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 }); for (const tx of [0.66, 0.8]) { 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 }); } // Nose and whiskers. g.ellipse(0.96 * r, -0.06 * r, 0.14 * r, 0.1 * r).fill(INK); g.circle(0.93 * r, -0.09 * r, 0.04 * r).fill(0xffffff); g.moveTo(0.98 * r, 0.02 * r) .lineTo(1.32 * r, -0.08 * r) .moveTo(0.98 * r, 0.06 * r) .lineTo(1.32 * r, 0.1 * r) .stroke({ width: pose.ink * 0.4, color: INK }); foot(g, 0.36 * r + step * 0.18 * r, 0.93 * r - (step > 0 ? lift : 0), r, dark, pose.ink); stars(g, r, pose); }; /** The Tester: a squishy jelly bean with a sprout on its head. */ export const paintGeneric: Painter = (g, r, pose) => { const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0; const dark = darken(pose.color, 0.7); foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, dark, pose.ink); // Sprout. const sway = Math.sin(pose.time * 3) * 0.1 * r; g.moveTo(0, -r).quadraticCurveTo(0.05 * r, -1.25 * r, sway, -1.4 * r).stroke({ width: pose.ink, color: INK }); 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 }); g.circle(0, 0, r).fill(pose.color).stroke({ width: pose.ink, color: INK }); g.ellipse(-0.35 * r, -0.45 * r, 0.25 * r, 0.15 * r).fill({ color: 0xffffff, alpha: 0.55 }); g.circle(0.55 * r, 0.25 * r, 0.14 * r).fill({ color: 0xff3b6b, alpha: 0.35 }); eyes( g, pose, [ [0.12 * r, -0.2 * r], [0.5 * r, -0.18 * r], ], 0.2 * r, ); 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 }); 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' }); foot(g, 0.35 * r + step * 0.18 * r, 0.93 * r, r, dark, pose.ink); stars(g, r, pose); }; function darken(color: number, k: number): number { return (Math.round((color >> 16) * k) << 16) | (Math.round(((color >> 8) & 0xff) * k) << 8) | Math.round((color & 0xff) * k); } export const PAINTERS: Record = { castor: paintCastor, generic: paintGeneric, };