235 lines
7.9 KiB
TypeScript
235 lines
7.9 KiB
TypeScript
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<string, Graphics>();
|
|
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 });
|
|
}
|
|
}
|