feat: add ore and blacksmith

This commit is contained in:
2026-09-05 17:24:44 +05:00
parent f6c338e1b0
commit 6273f2849c
15 changed files with 1333 additions and 334 deletions
+44 -21
View File
@@ -6,6 +6,9 @@ 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;
interface Chip {
g: Graphics;
@@ -14,18 +17,32 @@ interface Chip {
born: number;
}
function makeTool(tool: string): Graphics {
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);
}
return g;
}
/**
* Аватар M1: процедурный человечек (код вместо спрайтов — ноль лицензионных
* рисков). С M2 сюда придут текстуры из паков; состав (тело + ник + прогресс)
* сохранится. Прогресс рубки клиент считает сам из actionStart/actionDur
* серверу не нужно слать его каждый тик.
* Аватар M2: процедурный человечек, в руках — текущий инструмент
* (axe/pick тирами или молот у наковальни). Прогресс цикла клиент считает сам
* из 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 toolHolder = new Container();
private readonly tools = new Map<string, Graphics>();
private readonly label: Text;
private readonly bar: Graphics;
private chips: Chip[] = [];
@@ -33,6 +50,7 @@ export class AvatarView {
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 });
@@ -42,13 +60,13 @@ export class AvatarView {
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.toolHolder.position.set(6, -14);
for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer']) {
const g = makeTool(tool);
g.visible = false;
this.tools.set(tool, g);
this.toolHolder.addChild(g);
}
this.label = new Text({
text: '',
style: {
@@ -63,16 +81,16 @@ export class AvatarView {
this.label.y = -42;
this.bar = new Graphics();
this.rig.addChild(shadow, this.legL, this.legR, body, head, this.tool);
this.rig.addChild(shadow, this.legL, this.legR, body, head, this.toolHolder);
this.container.addChild(this.rig, this.bar, this.label);
this.container.position.set(av.x, av.y);
this.applyLabel(av);
this.tool.visible = false;
}
applyLabel(av: AvatarState): void {
const text = `${av.name} [${av.level}]`;
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;
@@ -105,19 +123,24 @@ export class AvatarView {
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';
this.toolHolder.visible = working && av.tool !== 'none';
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;
} 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.tool.visible = true;
this.tool.rotation = -1.0 + swing * 1.7;
this.toolHolder.rotation = -1.0 + swing * 1.7;
this.rig.y = -swing * 1.5;
this.legL.rotation = 0;
this.legR.rotation = 0;
@@ -132,7 +155,7 @@ export class AvatarView {
this.rig.y = Math.sin(t * 2) * 0.8;
this.legL.rotation = 0;
this.legR.rotation = 0;
this.tool.visible = false;
this.toolHolder.rotation = 0;
this.bar.visible = false;
this.lastChopPhase = 0;
}
+1
View File
@@ -1,3 +1,4 @@
export * from './avatar';
export * from './rock';
export * from './tree';
export * from './world';
+48
View File
@@ -0,0 +1,48 @@
import { Container, Graphics } from 'pixi.js';
import type { NodeState } from '@idle/shared';
const BODY = 0x8a8f98;
const BODY_DARK = 0x777c86;
const SPECKLE: Record<string, number> = { copper: 0xc77b3f, iron: 0x5a6478 };
/** Жила руды: вкрапления по типу руды, размер тает с hp, выработанная — обломки. */
export class RockView {
readonly container = new Container();
private readonly body = new Graphics();
private lastHp = -1;
private lastDown = false;
constructor(private st: NodeState) {
this.container.addChild(this.body);
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.body.clear();
if (down) {
this.body.ellipse(0, 0, 10, 5).fill(BODY_DARK);
this.body.ellipse(7, 2, 5, 3).fill(BODY);
this.body.ellipse(-8, 3, 4, 2.5).fill(BODY);
return;
}
const k = this.st.hp / this.st.maxHp;
const r = 13 + 7 * k;
this.body.ellipse(0, -r * 0.45, r, r * 0.8).fill(BODY);
this.body.ellipse(-r * 0.4, -r * 0.25, r * 0.5, r * 0.4).fill(BODY_DARK);
const spec = SPECKLE[this.st.variant ?? 'copper'] ?? 0xc77b3f;
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2;
this.body.circle(Math.cos(a) * r * 0.45, -r * 0.45 + Math.sin(a) * r * 0.3, 1.6).fill(spec);
}
}
}
+88 -28
View File
@@ -1,4 +1,4 @@
import { Application, Container, Graphics } from 'pixi.js';
import { Application, Container, Graphics, Text } from 'pixi.js';
import type {
AvatarState,
CameraState,
@@ -8,8 +8,10 @@ import type {
SnapshotMsg,
ViewerStat,
WelcomeMsg,
ZoneDef,
} from '@idle/shared';
import { AvatarView } from './avatar';
import { RockView } from './rock';
import { TreeView } from './tree';
export type WorldStatus = 'connecting' | 'online' | 'reconnecting';
@@ -30,9 +32,25 @@ export interface WorldHandle {
destroy(): void;
}
const GROUND = 0x4f8f56;
const GROUND_PATCH = 0x46824d;
const GROUND_DOT = 0x5aa061;
interface ZonePalette {
base: number;
patch: number;
dot: number;
}
const ZONE_PALETTES: Record<string, ZonePalette> = {
forest: { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 },
mine: { base: 0x7d6b52, patch: 0x6e5d46, dot: 0x8d7a60 },
forge: { base: 0x5f5f68, patch: 0x55555e, dot: 0x6d6d78 },
};
const GROUND_FALLBACK: ZonePalette = { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 };
function hashStr(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h;
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
@@ -45,18 +63,27 @@ function mulberry32(seed: number): () => number {
};
}
function drawGround(w: number, h: number, spawn: { x: number; y: number }): Graphics {
function drawGround(w: number, h: number, spawn: { x: number; y: number }, zones: ZoneDef[]): 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 });
for (const zone of zones) {
const p = ZONE_PALETTES[zone.id] ?? GROUND_FALLBACK;
const rng = mulberry32(hashStr(zone.id));
g.rect(zone.x, zone.y, zone.w, zone.h).fill(p.base);
for (let i = 0; i < 26; i++) {
g
.ellipse(
zone.x + rng() * zone.w,
zone.y + rng() * zone.h,
15 + rng() * 45,
10 + rng() * 30,
)
.fill({ color: p.patch, alpha: 0.5 });
}
for (let i = 0; i < 60; i++) {
g
.circle(zone.x + rng() * zone.w, zone.y + rng() * zone.h, 1 + rng() * 1.5)
.fill({ color: p.dot, alpha: 0.7 });
}
}
// полянка спавна
g.ellipse(spawn.x, spawn.y, 46, 26).fill({ color: 0x6b7280, alpha: 0.35 });
@@ -64,8 +91,41 @@ function drawGround(w: number, h: number, spawn: { x: number; y: number }): Grap
return g;
}
/** Наковальня с горном — рисуется по пропсам из протокола. */
function drawAnvil(x: number, y: number): Container {
const c = new Container();
c.position.set(x, y);
// горн позади
const furnace = new Graphics()
.rect(-58, -34, 34, 36, )
.fill(0x3a3a40)
.rect(-54, -26, 26, 20)
.fill(0x26262c)
.circle(-41, -16, 6)
.fill({ color: 0xd86a2a, alpha: 0.9 })
.circle(-41, -16, 3)
.fill({ color: 0xffc46b, alpha: 0.9 });
// сама наковальня
const anvil = new Graphics()
.rect(-7, -8, 14, 8)
.fill(0x2f333b)
.rect(-9, -13, 18, 5)
.fill(0x4a4f58)
.poly([9, -13, 17, -11, 9, -8])
.fill(0x4a4f58);
const label = new Text({
text: 'Кузница',
style: { fontFamily: 'monospace', fontSize: 12, fontWeight: 'bold', fill: 0xd8dee6, stroke: { color: 0x10131a, width: 3 } },
});
label.anchor.set(0.5, 1);
label.y = -40;
c.addChild(furnace, anvil, label);
c.zIndex = y;
return c;
}
/**
* Мир на канвасе: фон, ноды, аватары и серверная камера-видоискатель.
* Мир на канвасе: зоны, ноды, аватары и серверная камера-видоискатель.
* Состояние приходит из протокола; движение и прогресс дорисовываются локально.
*/
export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise<WorldHandle> {
@@ -87,8 +147,6 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
worldLayer.addChild(entityLayer);
app.stage.addChild(worldLayer);
let groundW = 0;
let clockOffset = 0; // serverNow - Date.now()
let camTarget: CameraState | null = null;
const camCur = { x: 0, y: 0, w: 900, h: 560 };
@@ -96,7 +154,7 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
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 nodeViews = new Map<string, TreeView | RockView>();
const serverNow = (): number => Date.now() + clockOffset;
@@ -113,10 +171,10 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
function syncNode(st: NodeState): void {
nodes.set(st.id, st);
let view = treeViews.get(st.id);
let view = nodeViews.get(st.id);
if (!view) {
view = new TreeView(st);
treeViews.set(st.id, view);
view = st.kind === 'tree' ? new TreeView(st) : new RockView(st);
nodeViews.set(st.id, view);
entityLayer.addChild(view.container);
}
view.apply(st);
@@ -129,17 +187,17 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
avatarViews.delete(id);
}
}
for (const [id, view] of treeViews) {
for (const [id, view] of nodeViews) {
if (!nodes.has(id)) {
view.container.destroy();
treeViews.delete(id);
nodeViews.delete(id);
}
}
}
function mergeCamera(c: CameraState): void {
camTarget = c;
// при первом кадре прыгаем сразу, дальше — плавно в тикере
// при смене зоны прыгаем к новой раскладке мгновенно, дальше — плавный lerp
if (camCur.w !== c.w || camCur.h !== c.h) {
camCur.x = c.x;
camCur.y = c.y;
@@ -177,11 +235,13 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
return {
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);
if (entityLayer.children.length === 0 && w.world.zones.length > 0) {
const ground = drawGround(w.world.w, w.world.h, w.world.spawn, w.world.zones);
ground.zIndex = -1000;
entityLayer.addChild(ground);
for (const prop of w.world.props) {
if (prop.kind === 'anvil') entityLayer.addChild(drawAnvil(prop.x, prop.y));
}
}
},
applySnapshot(snap) {