import { ColorMatrixFilter, Container, Graphics, Text } from 'pixi.js'; import { Anim } from '../../shared/characters'; import type { PlayerInfo, PlayerState } from '../../shared/protocol'; import { PAINTERS, paintGeneric, type Painter, type Pose } from '../characters/painters'; import { paintItem } from './RoundViews'; import { INVISIBLE_FADE, INVISIBLE_SELF_ALPHA, OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, RUN_LEAN, SQUASH_RECOVERY, VARIANT_FILTER_RESOLUTION, VARIANT_HUES, } from '../config'; /** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */ const FAT_RANGE = 26; export interface PlayerEvents { landed: boolean; landingSpeed: number; jumped: boolean; stunnedNow: boolean; } /** A character on screen: painter-drawn body with squash & stretch, walk cycle, and a name tag. */ export class PlayerView { readonly container = new Container(); private readonly body = new Graphics(); /** Soft contact shadow on the ground. */ private readonly shadow = new Graphics(); /** Carried item (candle) held up next to the character. */ private readonly held = new Graphics(); private readonly name: Text; private readonly paint: Painter; private readonly color: number; private time = 0; private walk = 0; private squash = 0; private wasGrounded = true; private wasStunned = false; private lastVy = 0; constructor(readonly info: PlayerInfo) { this.paint = PAINTERS[info.characterId] ?? paintGeneric; this.color = PLAYER_COLORS[(info.id - 1) % PLAYER_COLORS.length]; this.name = new Text({ text: info.name, style: { fontFamily: 'Comic Sans MS', fontSize: 15, fill: this.color, fontWeight: 'bold', stroke: { color: OUTLINE_COLOR, width: 4 } }, }); this.name.anchor.set(0.5, 1); this.held.scale.set(0.8); this.container.addChild(this.shadow, this.body, this.held, this.name); // Several players on the same character: the others get their colors shifted. if (info.variant > 0) { const filter = new ColorMatrixFilter({ resolution: VARIANT_FILTER_RESOLUTION, antialias: 'on' }); filter.hue(VARIANT_HUES[(info.variant - 1) % VARIANT_HUES.length], false); this.body.filters = [filter]; } } /** Draws a static pose (menu portraits). */ drawIdle(): void { this.draw(this.info.radius, 1, 0.6, -0.2, { moving: false, grounded: true, attack: false, special: false, stunned: false, busy: false, underground: false }); this.shadow.clear(); } /** `local`: this is the player at this keyboard (who still sees themself when invisible). */ update(state: PlayerState, aimX: number, aimY: number, dt: number, local = false): PlayerEvents { this.time += dt; const size = state.size; this.container.position.set(state.x, state.y); // In the custard, waiting for the spoon. this.container.visible = (state.anim & Anim.Dead) === 0; // Invisible (Léon): gone for the others, a ghost for himself; fades in and out. const alpha = state.anim & Anim.Invisible ? (local ? INVISIBLE_SELF_ALPHA : 0) : 1; this.container.alpha += (alpha - this.container.alpha) * Math.min(1, INVISIBLE_FADE * dt); this.held.clear(); if (state.item) { paintItem(this.held, state.item, false, this.time); this.held.position.set(-state.facing * size * 0.95, -size * 0.35); this.held.rotation = -state.facing * 0.4; } // Landing squashes, jumping stretches. const events: PlayerEvents = { landed: false, landingSpeed: 0, jumped: false, stunnedNow: false }; if (state.grounded && !this.wasGrounded) { this.squash = Math.min(0.45, Math.abs(this.lastVy) / 1600); events.landed = true; events.landingSpeed = Math.abs(this.lastVy); } else if (!state.grounded && this.wasGrounded && state.vy < -100) { this.squash = -0.3; events.jumped = true; } this.wasGrounded = state.grounded; this.lastVy = state.vy; const stunned = (state.anim & Anim.Stunned) !== 0; events.stunnedNow = stunned && !this.wasStunned; this.wasStunned = stunned; this.squash += (0 - this.squash) * Math.min(1, SQUASH_RECOVERY * dt); const moving = Math.abs(state.vx) > 20; this.walk += dt * (moving ? Math.abs(state.vx) / 18 : 0); // Look direction in local (facing-right) space. const dx = (aimX - state.x) * state.facing; const dy = aimY - state.y; const len = Math.hypot(dx, dy) || 1; // Leans into the run. this.body.rotation = Math.max(-RUN_LEAN, Math.min(RUN_LEAN, (state.vx / 600) * RUN_LEAN)); this.shadow.clear(); if (state.grounded) this.shadow.ellipse(0, size * 0.98, size * 0.85, size * 0.18).fill({ color: OUTLINE_COLOR, alpha: 0.18 }); this.draw(size, state.facing, dx / len, dy / len, { moving, grounded: state.grounded, attack: (state.anim & Anim.Attack) !== 0, special: (state.anim & Anim.Special) !== 0, stunned, busy: (state.anim & Anim.Busy) !== 0, underground: (state.anim & Anim.Underground) !== 0, }); // Drenched: dripping wet. if (state.anim & Anim.Soaked) { for (let i = 0; i < 3; i++) { const t = (this.time * 1.6 + i / 3) % 1; const x = (i - 1) * size * 0.55; 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) }); } } return events; } private draw( size: number, facing: number, lookX: number, lookY: number, flags: Pick, ): void { const base = this.info.radius; const scale = size / base; const sx = 1 + this.squash; const sy = 1 - this.squash; // Wobble while eating. const wobble = flags.special ? 1 + Math.sin(this.time * 28) * 0.04 : 1; this.body.scale.set(facing * scale * sx * wobble, scale * sy / wobble); // Keep the feet on the ground while squashed. this.body.y = size * (1 - sy); this.body.clear(); this.paint(this.body, base, { time: this.time, walk: this.walk, ...flags, lookX, lookY, fat: Math.min(1, Math.max(0, (size - base) / FAT_RANGE)), color: this.color, ink: OUTLINE_WIDTH / scale, }); this.name.y = -size * 1.15 - 10; } destroy(): void { this.container.destroy({ children: true }); } }