import { Container, Graphics, Text } from 'pixi.js'; import type { AvatarState } from '@idle/shared'; const SKIN = 0xffe3c0; const LEG_COLOR = 0x3b4657; const CHIP_COLOR = 0xc79b5b; const BAR_BG = 0x10131a; const BAR_FG = 0x9be870; const HANDLE = 0x8a6238; const STEEL = 0xb8c4d0; const RUSTY = 0x9a6a3f; const AURA = 0xffb347; interface Chip { g: Graphics; vx: number; vy: number; born: number; } function makeTool(tool: string): Graphics { if (tool === 'log') { // бревно в руках — для подкинутого дрова у костра return new Graphics() .rect(-6, -3, 12, 5) .fill(0x7a5230) .circle(-6, -0.5, 2.2) .fill(0x9a7448) .circle(6, -0.5, 2.2) .fill(0x9a7448); } const g = new Graphics().rect(-1, -12, 2, 12).fill(HANDLE); if (tool === 'axe_rusty' || tool === 'axe_iron') { g.poly([-1, -12, 7, -12, 7, -7, -1, -8]).fill(tool === 'axe_iron' ? STEEL : RUSTY); } else if (tool === 'pick_rusty' || tool === 'pick_iron') { const head = tool === 'pick_iron' ? STEEL : RUSTY; g.poly([-1, -11, 8, -15, 8, -12, -1, -8]).fill(head); g.poly([-1, -11, -8, -15, -8, -12, -1, -8]).fill(head); } else if (tool === 'hammer') { g.rect(-4, -15, 8, 4).fill(0x55606c); } else if (tool === 'spoon') { g.ellipse(0, -13, 2.5, 3.5).fill(RUSTY); } else if (tool === 'rod') { g.rect(-0.5, -20, 1.5, 20).fill(0x7d5a38); g.circle(0.25, -20, 1.5).fill(STEEL); } return g; } /** * Аватар M3: процедурный человечек с инструментом по действию (топор/кирка * тирами, молот, ложка, удочка), аурой баффа отдыха и прогресс-баром цикла. * Прогресс клиент считает сам из actionStart/actionDur. */ export class AvatarView { readonly container = new Container(); private readonly rig = new Container(); private readonly legL: Graphics; private readonly legR: Graphics; private readonly toolHolder = new Container(); private readonly tools = new Map(); private readonly aura = new Graphics(); private readonly label: Text; private readonly bar: Graphics; private chips: Chip[] = []; private face: 1 | -1 = 1; private lastChopPhase = 0; private prevNow = 0; private lastLabelText = ''; private lastTool = ''; constructor(av: AvatarState) { const shadow = new Graphics().ellipse(0, 1, 9, 3.5).fill({ color: 0x000000, alpha: 0.3 }); this.legL = new Graphics().roundRect(-2, -1, 4, 7, 2).fill(LEG_COLOR); this.legR = new Graphics().roundRect(-2, -1, 4, 7, 2).fill(LEG_COLOR); this.legL.position.set(-3, -6); this.legR.position.set(3, -6); const body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color); const head = new Graphics().circle(0, -23, 6.5).fill(SKIN); this.toolHolder.position.set(6, -14); for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer', 'spoon', 'rod', 'log']) { const g = makeTool(tool); g.visible = false; this.tools.set(tool, g); this.toolHolder.addChild(g); } this.label = new Text({ text: '', style: { fontFamily: 'monospace', fontSize: 11, fontWeight: 'bold', fill: av.color, stroke: { color: 0x10131a, width: 3 }, }, }); this.label.anchor.set(0.5, 1); this.label.y = -42; this.bar = new Graphics(); this.aura.ellipse(0, 0, 15, 6.5).fill({ color: AURA, alpha: 0.3 }); this.aura.visible = false; this.rig.addChild(shadow, this.legL, this.legR, body, head, this.toolHolder); this.container.addChild(this.aura, this.rig, this.bar, this.label); this.container.position.set(av.x, av.y); this.applyLabel(av); } applyLabel(av: AvatarState): void { const maxLevel = Math.max(1, ...Object.values(av.skills).map((s) => s.level)); const text = `${av.name} [${maxLevel}]`; if (text !== this.lastLabelText) { this.lastLabelText = text; this.label.text = text; this.label.style.fill = av.color; } else if (this.label.style.fill !== av.color) { this.label.style.fill = av.color; } } /** now — выровненный по серверу epoch ms. */ tick(av: AvatarState, now: number): void { this.applyLabel(av); const dt = Math.max(0, Math.min(now - this.prevNow, 100)); this.prevNow = now; // позиция: декларативный твин из протокола let x = av.x; let y = av.y; if (av.moving) { const dx = av.tx - av.x; const dy = av.ty - av.y; const d = Math.hypot(dx, dy); const k = Math.min(((now - av.moveStart) / 1000) * av.speed, d); if (d > 0) { x += (dx / d) * k; y += (dy / d) * k; } this.face = dx >= 0 ? 1 : -1; } this.container.position.set(x, y); this.rig.scale.x = this.face; if (av.tool !== this.lastTool) { this.lastTool = av.tool; for (const [id, g] of this.tools) g.visible = id === av.tool; } const working = av.action === 'chop' || av.action === 'mine' || av.action === 'smith' || av.action === 'cook' || av.action === 'fish'; this.toolHolder.visible = working && av.tool !== 'none'; this.aura.visible = av.restStacks > 0; if (this.aura.visible) { this.aura.alpha = 0.5 + av.restStacks * 0.2 + Math.sin(now / 300) * 0.08; } const t = now / 1000; if (av.moving) { const swing = Math.sin(t * 12); this.legL.rotation = swing * 0.5; this.legR.rotation = -swing * 0.5; this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5; this.toolHolder.rotation = 0; this.bar.visible = false; } else if (av.action === 'rest') { // отдых: спокойное дыхание this.rig.y = Math.sin(t * 1.5) * 0.6; this.legL.rotation = 0; this.legR.rotation = 0; this.toolHolder.rotation = 0; this.bar.visible = false; this.lastChopPhase = 0; } else if (working && av.actionStart !== null && av.actionDur) { const p = ((((now - av.actionStart) / av.actionDur) % 1) + 1) % 1; const swing = Math.sin((Math.min(p, 0.35) / 0.35) * Math.PI); this.toolHolder.rotation = -1.0 + swing * 1.7; this.rig.y = -swing * 1.5; this.legL.rotation = 0; this.legR.rotation = 0; this.bar.visible = true; this.bar.clear(); this.bar.roundRect(-12, -36, 24, 3.5, 1.5).fill({ color: BAR_BG, alpha: 0.6 }); this.bar.roundRect(-12, -36, 24 * p, 3.5, 1.5).fill(BAR_FG); const chipsy = av.action === 'chop' || av.action === 'mine'; if (chipsy && this.lastChopPhase < 0.33 && p >= 0.33) this.spawnChips(); this.lastChopPhase = p < this.lastChopPhase ? 0 : p; } else { // отдых в сторону: лёгкое «дыхание» this.rig.y = Math.sin(t * 2) * 0.8; this.legL.rotation = 0; this.legR.rotation = 0; this.toolHolder.rotation = 0; this.bar.visible = false; this.lastChopPhase = 0; } this.tickChips(now, dt); } private spawnChips(): void { for (let i = 0; i < 4; i++) { const g = new Graphics().rect(-1.5, -1.5, 3, 3).fill(CHIP_COLOR); g.position.set(this.face * 9, -8); this.container.addChild(g); this.chips.push({ g, vx: this.face * (30 + Math.random() * 40), vy: -(40 + Math.random() * 50), born: this.prevNow, }); } } private tickChips(now: number, dt: number): void { if (this.chips.length === 0) return; const s = dt / 1000; this.chips = this.chips.filter((c) => { const age = now - c.born; if (age > 500) { c.g.destroy(); return false; } c.vy += 260 * s; c.g.x += c.vx * s; c.g.y += c.vy * s; c.g.alpha = Math.max(0, 1 - age / 500); return true; }); } destroy(): void { this.container.destroy({ children: true }); } }