feat: add server and frontend initial - wood chopping

This commit is contained in:
2026-09-05 16:37:45 +05:00
parent c79681dc59
commit f6c338e1b0
28 changed files with 1857 additions and 195 deletions
+148 -29
View File
@@ -1,58 +1,177 @@
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;
interface Chip {
g: Graphics;
vx: number;
vy: number;
born: number;
}
/**
* Заглушка M0: человечек из примитивов. С M1 сюда придут спрайты из паков —
* состав (тело + ник над головой) сохранится.
* Аватар M1: процедурный человечек (код вместо спрайтов — ноль лицензионных
* рисков). С M2 сюда придут текстуры из паков; состав (тело + ник + прогресс)
* сохранится. Прогресс рубки клиент считает сам из actionStart/actionDur —
* серверу не нужно слать его каждый тик.
*/
export class AvatarView {
readonly container = new Container();
private readonly rig = new Container();
private readonly legL: Graphics;
private readonly legR: Graphics;
private readonly tool: Graphics;
private readonly label: Text;
private targetX: number;
private targetY: number;
private readonly bar: Graphics;
private chips: Chip[] = [];
private face: 1 | -1 = 1;
private lastChopPhase = 0;
private prevNow = 0;
private lastLabelText = '';
constructor(av: AvatarState) {
const shadow = new Graphics().ellipse(0, 0, 9, 3.5).fill({ color: 0x000000, alpha: 0.3 });
const body = new Graphics()
.roundRect(-7, -17, 14, 17, 5)
.fill(av.color)
.circle(0, -23, 7.5)
.fill(0xffe3c0);
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.tool = new Graphics()
.rect(-1, -12, 2, 12)
.fill(0x8a6238)
.poly([-1, -12, 7, -12, 7, -7, -1, -8])
.fill(0xb8c4d0);
this.tool.position.set(6, -14);
this.label = new Text({
text: av.name,
text: '',
style: {
fontFamily: 'monospace',
fontSize: 13,
fontSize: 11,
fontWeight: 'bold',
fill: av.color,
stroke: { color: 0x10131a, width: 3 },
},
});
this.label.anchor.set(0.5, 1);
this.label.y = -36;
this.label.y = -42;
this.bar = new Graphics();
this.rig.addChild(shadow, body);
this.container.addChild(this.rig, this.label);
this.rig.addChild(shadow, this.legL, this.legR, body, head, this.tool);
this.container.addChild(this.rig, this.bar, this.label);
this.targetX = av.x;
this.targetY = av.y;
this.container.position.set(av.x, av.y);
this.apply(av);
this.applyLabel(av);
this.tool.visible = false;
}
apply(av: AvatarState): void {
this.targetX = av.x;
this.targetY = av.y;
this.rig.scale.x = av.face === 'left' ? -1 : 1;
if (this.label.text !== av.name) this.label.text = av.name;
if (this.label.style.fill !== av.color) this.label.style.fill = av.color;
applyLabel(av: AvatarState): void {
const text = `${av.name} [${av.level}]`;
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;
}
}
/** Плавное дотягивание до последнего снапшота — сглаживает дискретность тиков. */
tick(dtMs: number): void {
const k = 1 - Math.exp(-dtMs / 120);
this.container.x += (this.targetX - this.container.x) * k;
this.container.y += (this.targetY - this.container.y) * k;
/** 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;
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.tool.visible = false;
this.bar.visible = false;
} else if (av.action === 'chop' && 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.tool.visible = true;
this.tool.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);
if (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.tool.visible = false;
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 });
}
}