feat: add server and frontend initial - wood chopping
This commit is contained in:
+148
-29
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './avatar';
|
||||
export * from './tree';
|
||||
export * from './world';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Container, Graphics } from 'pixi.js';
|
||||
import type { NodeState } from '@idle/shared';
|
||||
|
||||
const TRUNK = 0x7a5230;
|
||||
const STUMP = 0x9a7448;
|
||||
const LEAF_A = 0x3f8f4f;
|
||||
const LEAF_B = 0x4da25d;
|
||||
const LEAF_C = 0x2f7340;
|
||||
|
||||
/** Дерево-заглушка M1: крона уменьшается с hp, сваленное — пень. */
|
||||
export class TreeView {
|
||||
readonly container = new Container();
|
||||
private readonly trunk = new Graphics().roundRect(-3, -12, 6, 13, 2).fill(TRUNK);
|
||||
private readonly canopy = new Graphics();
|
||||
private lastHp = -1;
|
||||
private lastDown = false;
|
||||
|
||||
constructor(private st: NodeState) {
|
||||
this.container.addChild(this.trunk, this.canopy);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
apply(st: NodeState): void {
|
||||
this.st = st;
|
||||
this.container.zIndex = st.y;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private redraw(): void {
|
||||
const down = this.st.respawnAt !== null;
|
||||
if (down === this.lastDown && this.lastHp === this.st.hp) return;
|
||||
this.lastDown = down;
|
||||
this.lastHp = this.st.hp;
|
||||
this.canopy.clear();
|
||||
if (down) {
|
||||
this.canopy.roundRect(-4, -4, 8, 5, 2).fill(STUMP);
|
||||
return;
|
||||
}
|
||||
const k = this.st.hp / this.st.maxHp;
|
||||
const r = 9 + 11 * k;
|
||||
this.canopy.circle(0, -20, r).fill(LEAF_A);
|
||||
this.canopy.circle(-r * 0.55, -16, r * 0.7).fill(LEAF_B);
|
||||
this.canopy.circle(r * 0.55, -16, r * 0.7).fill(LEAF_C);
|
||||
}
|
||||
}
|
||||
+168
-23
@@ -1,6 +1,16 @@
|
||||
import { Application, Container } from 'pixi.js';
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
import { Application, Container, Graphics } from 'pixi.js';
|
||||
import type {
|
||||
AvatarState,
|
||||
CameraState,
|
||||
DeltaMsg,
|
||||
GameEvent,
|
||||
NodeState,
|
||||
SnapshotMsg,
|
||||
ViewerStat,
|
||||
WelcomeMsg,
|
||||
} from '@idle/shared';
|
||||
import { AvatarView } from './avatar';
|
||||
import { TreeView } from './tree';
|
||||
|
||||
export type WorldStatus = 'connecting' | 'online' | 'reconnecting';
|
||||
|
||||
@@ -9,14 +19,55 @@ export interface MountWorldOptions {
|
||||
backgroundAlpha?: number;
|
||||
background?: number;
|
||||
onStatus?: (status: WorldStatus) => void;
|
||||
onEvent?: (e: GameEvent) => void;
|
||||
onStats?: (s: ViewerStat[]) => void;
|
||||
}
|
||||
|
||||
export interface WorldHandle {
|
||||
applySnapshot(avatars: AvatarState[]): void;
|
||||
setWelcome(w: WelcomeMsg): void;
|
||||
applySnapshot(snap: SnapshotMsg): void;
|
||||
applyDelta(d: DeltaMsg): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** Инициализация pixi-канваса в хосте; состояние мира придёт через applySnapshot. */
|
||||
const GROUND = 0x4f8f56;
|
||||
const GROUND_PATCH = 0x46824d;
|
||||
const GROUND_DOT = 0x5aa061;
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function drawGround(w: number, h: number, spawn: { x: number; y: number }): Graphics {
|
||||
const g = new Graphics();
|
||||
g.rect(0, 0, w, h).fill(GROUND);
|
||||
const rng = mulberry32(42);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
g.ellipse(rng() * w, rng() * h, 15 + rng() * 45, 10 + rng() * 30).fill({
|
||||
color: GROUND_PATCH,
|
||||
alpha: 0.5,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 90; i++) {
|
||||
g.circle(rng() * w, rng() * h, 1 + rng() * 1.5).fill({ color: GROUND_DOT, alpha: 0.7 });
|
||||
}
|
||||
// полянка спавна
|
||||
g.ellipse(spawn.x, spawn.y, 46, 26).fill({ color: 0x6b7280, alpha: 0.35 });
|
||||
g.ellipse(spawn.x, spawn.y, 34, 18).fill({ color: 0x8a919c, alpha: 0.35 });
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мир на канвасе: фон, ноды, аватары и серверная камера-видоискатель.
|
||||
* Состояние приходит из протокола; движение и прогресс дорисовываются локально.
|
||||
*/
|
||||
export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise<WorldHandle> {
|
||||
opts.onStatus?.('connecting');
|
||||
|
||||
@@ -30,33 +81,127 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
||||
host.appendChild(app.canvas);
|
||||
app.canvas.style.display = 'block';
|
||||
|
||||
const world = new Container();
|
||||
app.stage.addChild(world);
|
||||
const worldLayer = new Container();
|
||||
const entityLayer = new Container();
|
||||
entityLayer.sortableChildren = true;
|
||||
worldLayer.addChild(entityLayer);
|
||||
app.stage.addChild(worldLayer);
|
||||
|
||||
const views = new Map<string, AvatarView>();
|
||||
let groundW = 0;
|
||||
|
||||
let clockOffset = 0; // serverNow - Date.now()
|
||||
let camTarget: CameraState | null = null;
|
||||
const camCur = { x: 0, y: 0, w: 900, h: 560 };
|
||||
|
||||
const avatars = new Map<string, AvatarState>();
|
||||
const avatarViews = new Map<string, AvatarView>();
|
||||
const nodes = new Map<string, NodeState>();
|
||||
const treeViews = new Map<string, TreeView>();
|
||||
|
||||
const serverNow = (): number => Date.now() + clockOffset;
|
||||
|
||||
function syncAvatar(av: AvatarState): void {
|
||||
avatars.set(av.id, av);
|
||||
let view = avatarViews.get(av.id);
|
||||
if (!view) {
|
||||
view = new AvatarView(av);
|
||||
avatarViews.set(av.id, view);
|
||||
entityLayer.addChild(view.container);
|
||||
}
|
||||
view.container.zIndex = av.y;
|
||||
}
|
||||
|
||||
function syncNode(st: NodeState): void {
|
||||
nodes.set(st.id, st);
|
||||
let view = treeViews.get(st.id);
|
||||
if (!view) {
|
||||
view = new TreeView(st);
|
||||
treeViews.set(st.id, view);
|
||||
entityLayer.addChild(view.container);
|
||||
}
|
||||
view.apply(st);
|
||||
}
|
||||
|
||||
function pruneEntities(): void {
|
||||
for (const [id, view] of avatarViews) {
|
||||
if (!avatars.has(id)) {
|
||||
view.destroy();
|
||||
avatarViews.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, view] of treeViews) {
|
||||
if (!nodes.has(id)) {
|
||||
view.container.destroy();
|
||||
treeViews.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeCamera(c: CameraState): void {
|
||||
camTarget = c;
|
||||
// при первом кадре прыгаем сразу, дальше — плавно в тикере
|
||||
if (camCur.w !== c.w || camCur.h !== c.h) {
|
||||
camCur.x = c.x;
|
||||
camCur.y = c.y;
|
||||
camCur.w = c.w;
|
||||
camCur.h = c.h;
|
||||
}
|
||||
}
|
||||
|
||||
app.ticker.add((ticker) => {
|
||||
for (const view of views.values()) view.tick(ticker.deltaMS);
|
||||
const now = serverNow();
|
||||
for (const [id, view] of avatarViews) {
|
||||
const av = avatars.get(id);
|
||||
if (av) {
|
||||
view.tick(av, now);
|
||||
view.container.zIndex = av.y;
|
||||
}
|
||||
}
|
||||
entityLayer.sortChildren();
|
||||
|
||||
if (camTarget) {
|
||||
const k = 1 - Math.exp(-ticker.deltaMS / 400);
|
||||
camCur.x += (camTarget.x - camCur.x) * k;
|
||||
camCur.y += (camTarget.y - camCur.y) * k;
|
||||
}
|
||||
const vw = app.renderer.width;
|
||||
const vh = app.renderer.height;
|
||||
const scale = Math.max(vw / camCur.w, vh / camCur.h);
|
||||
worldLayer.scale.set(scale);
|
||||
worldLayer.position.set(
|
||||
vw / 2 - (camCur.x + camCur.w / 2) * scale,
|
||||
vh / 2 - (camCur.y + camCur.h / 2) * scale,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
applySnapshot(avatars) {
|
||||
for (const av of avatars) {
|
||||
let view = views.get(av.id);
|
||||
if (!view) {
|
||||
view = new AvatarView(av);
|
||||
views.set(av.id, view);
|
||||
world.addChild(view.container);
|
||||
}
|
||||
view.apply(av);
|
||||
}
|
||||
for (const [id, view] of views) {
|
||||
if (!avatars.some((av) => av.id === id)) {
|
||||
view.container.destroy();
|
||||
views.delete(id);
|
||||
}
|
||||
setWelcome(w) {
|
||||
clockOffset = w.serverNow - Date.now();
|
||||
if (groundW !== w.world.w) {
|
||||
groundW = w.world.w;
|
||||
const ground = drawGround(w.world.w, w.world.h, w.world.spawn);
|
||||
ground.zIndex = -1000;
|
||||
entityLayer.addChild(ground);
|
||||
}
|
||||
},
|
||||
applySnapshot(snap) {
|
||||
clockOffset = snap.serverNow - Date.now();
|
||||
mergeCamera(snap.camera);
|
||||
avatars.clear();
|
||||
nodes.clear();
|
||||
for (const av of snap.avatars) syncAvatar(av);
|
||||
for (const st of snap.nodes) syncNode(st);
|
||||
pruneEntities();
|
||||
opts.onStats?.(snap.stats);
|
||||
},
|
||||
applyDelta(d) {
|
||||
clockOffset = d.serverNow - Date.now();
|
||||
if (d.camera) mergeCamera(d.camera);
|
||||
if (d.avatars) for (const av of d.avatars) syncAvatar(av);
|
||||
if (d.nodes) for (const st of d.nodes) syncNode(st);
|
||||
for (const e of d.events ?? []) opts.onEvent?.(e);
|
||||
if (d.stats) opts.onStats?.(d.stats);
|
||||
},
|
||||
destroy() {
|
||||
void app.destroy(true, { children: true });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user