feat: add ore and blacksmith
This commit is contained in:
@@ -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,3 +1,4 @@
|
||||
export * from './avatar';
|
||||
export * from './rock';
|
||||
export * from './tree';
|
||||
export * from './world';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user