feat: setup project
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@idle/render",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@idle/shared": "workspace:*",
|
||||
"pixi.js": "^8.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Container, Graphics, Text } from 'pixi.js';
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
|
||||
/**
|
||||
* Заглушка M0: человечек из примитивов. С M1 сюда придут спрайты из паков —
|
||||
* состав (тело + ник над головой) сохранится.
|
||||
*/
|
||||
export class AvatarView {
|
||||
readonly container = new Container();
|
||||
private readonly rig = new Container();
|
||||
private readonly label: Text;
|
||||
private targetX: number;
|
||||
private targetY: number;
|
||||
|
||||
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);
|
||||
this.label = new Text({
|
||||
text: av.name,
|
||||
style: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
fill: av.color,
|
||||
stroke: { color: 0x10131a, width: 3 },
|
||||
},
|
||||
});
|
||||
this.label.anchor.set(0.5, 1);
|
||||
this.label.y = -36;
|
||||
|
||||
this.rig.addChild(shadow, body);
|
||||
this.container.addChild(this.rig, this.label);
|
||||
|
||||
this.targetX = av.x;
|
||||
this.targetY = av.y;
|
||||
this.container.position.set(av.x, av.y);
|
||||
this.apply(av);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Плавное дотягивание до последнего снапшота — сглаживает дискретность тиков. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './avatar';
|
||||
export * from './world';
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Application, Container } from 'pixi.js';
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
import { AvatarView } from './avatar';
|
||||
|
||||
export type WorldStatus = 'connecting' | 'online' | 'reconnecting';
|
||||
|
||||
export interface MountWorldOptions {
|
||||
/** 0 — прозрачный фон (overlay для OBS). */
|
||||
backgroundAlpha?: number;
|
||||
background?: number;
|
||||
onStatus?: (status: WorldStatus) => void;
|
||||
}
|
||||
|
||||
export interface WorldHandle {
|
||||
applySnapshot(avatars: AvatarState[]): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** Инициализация pixi-канваса в хосте; состояние мира придёт через applySnapshot. */
|
||||
export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise<WorldHandle> {
|
||||
opts.onStatus?.('connecting');
|
||||
|
||||
const app = new Application();
|
||||
await app.init({
|
||||
backgroundAlpha: opts.backgroundAlpha ?? 1,
|
||||
background: opts.background ?? 0x151a21,
|
||||
resizeTo: host,
|
||||
antialias: true,
|
||||
});
|
||||
host.appendChild(app.canvas);
|
||||
app.canvas.style.display = 'block';
|
||||
|
||||
const world = new Container();
|
||||
app.stage.addChild(world);
|
||||
|
||||
const views = new Map<string, AvatarView>();
|
||||
|
||||
app.ticker.add((ticker) => {
|
||||
for (const view of views.values()) view.tick(ticker.deltaMS);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
void app.destroy(true, { children: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user