import { 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 { OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, RUN_LEAN, SQUASH_RECOVERY } 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; 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); } /** 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(); } update(state: PlayerState, aimX: number, aimY: number, dt: number): 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; 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, 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; 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, }); 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 }); } }