feat: add ore and blacksmith
This commit is contained in:
@@ -42,7 +42,7 @@
|
|||||||
<div class="badge">{STATUS_TEXT[status]}</div>
|
<div class="badge">{STATUS_TEXT[status]}</div>
|
||||||
<!-- онбординг — в самом виджете, чат ботом не засоряем -->
|
<!-- онбординг — в самом виджете, чат ботом не засоряем -->
|
||||||
{#if status === 'online'}
|
{#if status === 'online'}
|
||||||
<div class="hint">!рубить — валить лес · !стоп — отдых</div>
|
<div class="hint">!рубить — лес · !копать — рудник · !ковать слиток/топор/кирку · !стоп</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const LEG_COLOR = 0x3b4657;
|
|||||||
const CHIP_COLOR = 0xc79b5b;
|
const CHIP_COLOR = 0xc79b5b;
|
||||||
const BAR_BG = 0x10131a;
|
const BAR_BG = 0x10131a;
|
||||||
const BAR_FG = 0x9be870;
|
const BAR_FG = 0x9be870;
|
||||||
|
const HANDLE = 0x8a6238;
|
||||||
|
const STEEL = 0xb8c4d0;
|
||||||
|
const RUSTY = 0x9a6a3f;
|
||||||
|
|
||||||
interface Chip {
|
interface Chip {
|
||||||
g: Graphics;
|
g: Graphics;
|
||||||
@@ -14,18 +17,32 @@ interface Chip {
|
|||||||
born: number;
|
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: процедурный человечек, в руках — текущий инструмент
|
||||||
* рисков). С M2 сюда придут текстуры из паков; состав (тело + ник + прогресс)
|
* (axe/pick тирами или молот у наковальни). Прогресс цикла клиент считает сам
|
||||||
* сохранится. Прогресс рубки клиент считает сам из actionStart/actionDur —
|
* из actionStart/actionDur.
|
||||||
* серверу не нужно слать его каждый тик.
|
|
||||||
*/
|
*/
|
||||||
export class AvatarView {
|
export class AvatarView {
|
||||||
readonly container = new Container();
|
readonly container = new Container();
|
||||||
private readonly rig = new Container();
|
private readonly rig = new Container();
|
||||||
private readonly legL: Graphics;
|
private readonly legL: Graphics;
|
||||||
private readonly legR: 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 label: Text;
|
||||||
private readonly bar: Graphics;
|
private readonly bar: Graphics;
|
||||||
private chips: Chip[] = [];
|
private chips: Chip[] = [];
|
||||||
@@ -33,6 +50,7 @@ export class AvatarView {
|
|||||||
private lastChopPhase = 0;
|
private lastChopPhase = 0;
|
||||||
private prevNow = 0;
|
private prevNow = 0;
|
||||||
private lastLabelText = '';
|
private lastLabelText = '';
|
||||||
|
private lastTool = '';
|
||||||
|
|
||||||
constructor(av: AvatarState) {
|
constructor(av: AvatarState) {
|
||||||
const shadow = new Graphics().ellipse(0, 1, 9, 3.5).fill({ color: 0x000000, alpha: 0.3 });
|
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);
|
this.legR.position.set(3, -6);
|
||||||
const body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color);
|
const body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color);
|
||||||
const head = new Graphics().circle(0, -23, 6.5).fill(SKIN);
|
const head = new Graphics().circle(0, -23, 6.5).fill(SKIN);
|
||||||
// топор: точка вращения — плечо; покачивается только при рубке
|
this.toolHolder.position.set(6, -14);
|
||||||
this.tool = new Graphics()
|
for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer']) {
|
||||||
.rect(-1, -12, 2, 12)
|
const g = makeTool(tool);
|
||||||
.fill(0x8a6238)
|
g.visible = false;
|
||||||
.poly([-1, -12, 7, -12, 7, -7, -1, -8])
|
this.tools.set(tool, g);
|
||||||
.fill(0xb8c4d0);
|
this.toolHolder.addChild(g);
|
||||||
this.tool.position.set(6, -14);
|
}
|
||||||
this.label = new Text({
|
this.label = new Text({
|
||||||
text: '',
|
text: '',
|
||||||
style: {
|
style: {
|
||||||
@@ -63,16 +81,16 @@ export class AvatarView {
|
|||||||
this.label.y = -42;
|
this.label.y = -42;
|
||||||
this.bar = new Graphics();
|
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.addChild(this.rig, this.bar, this.label);
|
||||||
|
|
||||||
this.container.position.set(av.x, av.y);
|
this.container.position.set(av.x, av.y);
|
||||||
this.applyLabel(av);
|
this.applyLabel(av);
|
||||||
this.tool.visible = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyLabel(av: AvatarState): void {
|
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) {
|
if (text !== this.lastLabelText) {
|
||||||
this.lastLabelText = text;
|
this.lastLabelText = text;
|
||||||
this.label.text = text;
|
this.label.text = text;
|
||||||
@@ -105,19 +123,24 @@ export class AvatarView {
|
|||||||
this.container.position.set(x, y);
|
this.container.position.set(x, y);
|
||||||
this.rig.scale.x = this.face;
|
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;
|
const t = now / 1000;
|
||||||
if (av.moving) {
|
if (av.moving) {
|
||||||
const swing = Math.sin(t * 12);
|
const swing = Math.sin(t * 12);
|
||||||
this.legL.rotation = swing * 0.5;
|
this.legL.rotation = swing * 0.5;
|
||||||
this.legR.rotation = -swing * 0.5;
|
this.legR.rotation = -swing * 0.5;
|
||||||
this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5;
|
this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5;
|
||||||
this.tool.visible = false;
|
|
||||||
this.bar.visible = false;
|
this.bar.visible = false;
|
||||||
} else if (av.action === 'chop' && av.actionStart !== null && av.actionDur) {
|
} else if (working && av.actionStart !== null && av.actionDur) {
|
||||||
const p = (((now - av.actionStart) / av.actionDur) % 1 + 1) % 1;
|
const p = ((((now - av.actionStart) / av.actionDur) % 1) + 1) % 1;
|
||||||
const swing = Math.sin((Math.min(p, 0.35) / 0.35) * Math.PI);
|
const swing = Math.sin((Math.min(p, 0.35) / 0.35) * Math.PI);
|
||||||
this.tool.visible = true;
|
this.toolHolder.rotation = -1.0 + swing * 1.7;
|
||||||
this.tool.rotation = -1.0 + swing * 1.7;
|
|
||||||
this.rig.y = -swing * 1.5;
|
this.rig.y = -swing * 1.5;
|
||||||
this.legL.rotation = 0;
|
this.legL.rotation = 0;
|
||||||
this.legR.rotation = 0;
|
this.legR.rotation = 0;
|
||||||
@@ -132,7 +155,7 @@ export class AvatarView {
|
|||||||
this.rig.y = Math.sin(t * 2) * 0.8;
|
this.rig.y = Math.sin(t * 2) * 0.8;
|
||||||
this.legL.rotation = 0;
|
this.legL.rotation = 0;
|
||||||
this.legR.rotation = 0;
|
this.legR.rotation = 0;
|
||||||
this.tool.visible = false;
|
this.toolHolder.rotation = 0;
|
||||||
this.bar.visible = false;
|
this.bar.visible = false;
|
||||||
this.lastChopPhase = 0;
|
this.lastChopPhase = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './avatar';
|
export * from './avatar';
|
||||||
|
export * from './rock';
|
||||||
export * from './tree';
|
export * from './tree';
|
||||||
export * from './world';
|
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 {
|
import type {
|
||||||
AvatarState,
|
AvatarState,
|
||||||
CameraState,
|
CameraState,
|
||||||
@@ -8,8 +8,10 @@ import type {
|
|||||||
SnapshotMsg,
|
SnapshotMsg,
|
||||||
ViewerStat,
|
ViewerStat,
|
||||||
WelcomeMsg,
|
WelcomeMsg,
|
||||||
|
ZoneDef,
|
||||||
} from '@idle/shared';
|
} from '@idle/shared';
|
||||||
import { AvatarView } from './avatar';
|
import { AvatarView } from './avatar';
|
||||||
|
import { RockView } from './rock';
|
||||||
import { TreeView } from './tree';
|
import { TreeView } from './tree';
|
||||||
|
|
||||||
export type WorldStatus = 'connecting' | 'online' | 'reconnecting';
|
export type WorldStatus = 'connecting' | 'online' | 'reconnecting';
|
||||||
@@ -30,9 +32,25 @@ export interface WorldHandle {
|
|||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUND = 0x4f8f56;
|
interface ZonePalette {
|
||||||
const GROUND_PATCH = 0x46824d;
|
base: number;
|
||||||
const GROUND_DOT = 0x5aa061;
|
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 {
|
function mulberry32(seed: number): () => number {
|
||||||
let a = seed >>> 0;
|
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();
|
const g = new Graphics();
|
||||||
g.rect(0, 0, w, h).fill(GROUND);
|
for (const zone of zones) {
|
||||||
const rng = mulberry32(42);
|
const p = ZONE_PALETTES[zone.id] ?? GROUND_FALLBACK;
|
||||||
for (let i = 0; i < 40; i++) {
|
const rng = mulberry32(hashStr(zone.id));
|
||||||
g.ellipse(rng() * w, rng() * h, 15 + rng() * 45, 10 + rng() * 30).fill({
|
g.rect(zone.x, zone.y, zone.w, zone.h).fill(p.base);
|
||||||
color: GROUND_PATCH,
|
for (let i = 0; i < 26; i++) {
|
||||||
alpha: 0.5,
|
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 });
|
||||||
}
|
}
|
||||||
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, 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;
|
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> {
|
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);
|
worldLayer.addChild(entityLayer);
|
||||||
app.stage.addChild(worldLayer);
|
app.stage.addChild(worldLayer);
|
||||||
|
|
||||||
let groundW = 0;
|
|
||||||
|
|
||||||
let clockOffset = 0; // serverNow - Date.now()
|
let clockOffset = 0; // serverNow - Date.now()
|
||||||
let camTarget: CameraState | null = null;
|
let camTarget: CameraState | null = null;
|
||||||
const camCur = { x: 0, y: 0, w: 900, h: 560 };
|
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 avatars = new Map<string, AvatarState>();
|
||||||
const avatarViews = new Map<string, AvatarView>();
|
const avatarViews = new Map<string, AvatarView>();
|
||||||
const nodes = new Map<string, NodeState>();
|
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;
|
const serverNow = (): number => Date.now() + clockOffset;
|
||||||
|
|
||||||
@@ -113,10 +171,10 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
|
|
||||||
function syncNode(st: NodeState): void {
|
function syncNode(st: NodeState): void {
|
||||||
nodes.set(st.id, st);
|
nodes.set(st.id, st);
|
||||||
let view = treeViews.get(st.id);
|
let view = nodeViews.get(st.id);
|
||||||
if (!view) {
|
if (!view) {
|
||||||
view = new TreeView(st);
|
view = st.kind === 'tree' ? new TreeView(st) : new RockView(st);
|
||||||
treeViews.set(st.id, view);
|
nodeViews.set(st.id, view);
|
||||||
entityLayer.addChild(view.container);
|
entityLayer.addChild(view.container);
|
||||||
}
|
}
|
||||||
view.apply(st);
|
view.apply(st);
|
||||||
@@ -129,17 +187,17 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
avatarViews.delete(id);
|
avatarViews.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [id, view] of treeViews) {
|
for (const [id, view] of nodeViews) {
|
||||||
if (!nodes.has(id)) {
|
if (!nodes.has(id)) {
|
||||||
view.container.destroy();
|
view.container.destroy();
|
||||||
treeViews.delete(id);
|
nodeViews.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeCamera(c: CameraState): void {
|
function mergeCamera(c: CameraState): void {
|
||||||
camTarget = c;
|
camTarget = c;
|
||||||
// при первом кадре прыгаем сразу, дальше — плавно в тикере
|
// при смене зоны прыгаем к новой раскладке мгновенно, дальше — плавный lerp
|
||||||
if (camCur.w !== c.w || camCur.h !== c.h) {
|
if (camCur.w !== c.w || camCur.h !== c.h) {
|
||||||
camCur.x = c.x;
|
camCur.x = c.x;
|
||||||
camCur.y = c.y;
|
camCur.y = c.y;
|
||||||
@@ -177,11 +235,13 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
return {
|
return {
|
||||||
setWelcome(w) {
|
setWelcome(w) {
|
||||||
clockOffset = w.serverNow - Date.now();
|
clockOffset = w.serverNow - Date.now();
|
||||||
if (groundW !== w.world.w) {
|
if (entityLayer.children.length === 0 && w.world.zones.length > 0) {
|
||||||
groundW = w.world.w;
|
const ground = drawGround(w.world.w, w.world.h, w.world.spawn, w.world.zones);
|
||||||
const ground = drawGround(w.world.w, w.world.h, w.world.spawn);
|
|
||||||
ground.zIndex = -1000;
|
ground.zIndex = -1000;
|
||||||
entityLayer.addChild(ground);
|
entityLayer.addChild(ground);
|
||||||
|
for (const prop of w.world.props) {
|
||||||
|
if (prop.kind === 'anvil') entityLayer.addChild(drawAnvil(prop.x, prop.y));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
applySnapshot(snap) {
|
applySnapshot(snap) {
|
||||||
|
|||||||
+94
-31
@@ -1,17 +1,61 @@
|
|||||||
/* Дымовой тест M1: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
|
/* Дымовой тест M2: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
|
||||||
`pnpm --filter @idle/server smoke` */
|
`pnpm --filter @idle/server smoke` */
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
const BASE = 'http://localhost:3000';
|
const BASE = 'http://localhost:3000';
|
||||||
const WS = 'ws://localhost:3000/ws';
|
const WS = 'ws://localhost:3000/ws';
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// ждём готовности сервера (tsx может стартовать дольше пары секунд)
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${BASE}/healthz`);
|
||||||
|
if (r.ok) break;
|
||||||
|
} catch {}
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// антиспам-кулдаун команд — между командами выдерживаем паузу
|
||||||
|
const COOLDOWN = 1400;
|
||||||
|
|
||||||
function connect(onMsg) {
|
function connect(onMsg) {
|
||||||
const ws = new WebSocket(WS);
|
const ws = new WebSocket(WS);
|
||||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 2 })));
|
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 3 })));
|
||||||
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
|
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dev(path, body) {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`POST ${path} -> ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inventory(name) {
|
||||||
|
const res = await fetch(`${BASE}/api/inventory?name=${encodeURIComponent(name)}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ждём дельту, удовлетворяющую условию. */
|
||||||
|
function waitForDelta(pred, timeoutMs = 9000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = connect((m) => {
|
||||||
|
if (m.t === 'delta' && pred(m)) {
|
||||||
|
ws.close();
|
||||||
|
resolve(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on('error', reject);
|
||||||
|
setTimeout(() => {
|
||||||
|
ws.terminate();
|
||||||
|
reject(new Error('timeout waiting delta'));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 1. welcome + полный снапшот
|
// 1. welcome + полный снапшот
|
||||||
const first = [];
|
const first = [];
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -26,37 +70,56 @@ await new Promise((resolve, reject) => {
|
|||||||
setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000);
|
setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000);
|
||||||
});
|
});
|
||||||
const [welcome, snap] = first;
|
const [welcome, snap] = first;
|
||||||
console.log('welcome:', JSON.stringify({ t: welcome.t, v: welcome.v, tickMs: welcome.tickMs }));
|
console.log('welcome: v' + welcome.v, '| деревьев+жил:', snap.nodes.length, '| пропсов:', welcome.world.props.length);
|
||||||
if (welcome.t !== 'welcome' || welcome.v !== 2) throw new Error(`bad welcome: ${JSON.stringify(welcome)}`);
|
if (welcome.t !== 'welcome' || welcome.v !== 3) throw new Error('bad welcome');
|
||||||
if (snap.t !== 'snapshot' || snap.nodes.length !== 7) throw new Error(`bad snapshot: ${snap.t}, nodes=${snap.nodes?.length}`);
|
if (snap.nodes.length !== 13 || snap.nodes.filter((n) => n.kind === 'rock').length !== 6) {
|
||||||
console.log(`snapshot: деревьев=${snap.nodes.length}, зрителей=${snap.avatars.length}`);
|
throw new Error('bad snapshot nodes');
|
||||||
|
|
||||||
// 2. команда через dev-эндпоинт
|
|
||||||
const res = await fetch(`${BASE}/dev/command`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: 'Тестер', text: '!рубить' }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`dev command failed: ${res.status} ${await res.text()}`);
|
|
||||||
console.log('dev command: accepted');
|
|
||||||
|
|
||||||
// 3. ждём дельту: аватар «Тестер» должен заспавниться и пойти к дереву
|
|
||||||
const delta = await new Promise((resolve, reject) => {
|
|
||||||
const ws = connect((m) => {
|
|
||||||
if (m.t === 'delta' && m.avatars?.some((a) => a.name === 'Тестер')) {
|
|
||||||
ws.close();
|
|
||||||
resolve(m);
|
|
||||||
}
|
}
|
||||||
});
|
if (!welcome.world.props.some((p) => p.kind === 'anvil')) throw new Error('no anvil prop');
|
||||||
ws.on('error', reject);
|
|
||||||
setTimeout(() => reject(new Error('no delta with Тестер')), 8000);
|
|
||||||
});
|
|
||||||
const tester = delta.avatars.find((a) => a.name === 'Тестер');
|
|
||||||
console.log(`delta: ${tester.name} moving=${tester.moving} action=${tester.action} target=(${tester.tx},${tester.ty})`);
|
|
||||||
console.log(`events: ${(delta.events ?? []).map((e) => e.k).join(',') || '—'}`);
|
|
||||||
if (!tester.moving || tester.action !== 'idle') throw new Error('Тестер должен идти к дереву (idle + moving)');
|
|
||||||
|
|
||||||
// 4. неверная версия протокола — close 4001
|
// 2. спавн через !рубить
|
||||||
|
await dev('/dev/command', { name: 'Тестер', text: '!рубить' });
|
||||||
|
const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
|
||||||
|
console.log('spawn: движется к дереву ok');
|
||||||
|
|
||||||
|
// 3. телепорт к медной жиле и !копать
|
||||||
|
await dev('/dev/tp', { name: 'Тестер', x: 1140, y: 346 });
|
||||||
|
await sleep(COOLDOWN);
|
||||||
|
await dev('/dev/command', { name: 'Тестер', text: '!копать' });
|
||||||
|
const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'mine'));
|
||||||
|
const miner = d2.avatars.find((a) => a.name === 'Тестер');
|
||||||
|
console.log('mine: action=mine tool=' + miner.tool);
|
||||||
|
if (miner.action !== 'mine' || miner.tool !== 'pick_rusty') throw new Error('bad mine state');
|
||||||
|
|
||||||
|
// 4. ждём добытую руду в инвентаре
|
||||||
|
await sleep(13_500);
|
||||||
|
const inv1 = await inventory('Тестер');
|
||||||
|
console.log('inventory: copper_ore=' + (inv1.items?.copper_ore ?? 0), 'mining xp=' + (inv1.skills?.mining?.xp ?? 0));
|
||||||
|
if ((inv1.items?.copper_ore ?? 0) < 1 || (inv1.skills?.mining?.xp ?? 0) < 15) throw new Error('mining did not yield');
|
||||||
|
|
||||||
|
// 5. ковка медного слитка у наковальни
|
||||||
|
await dev('/dev/give', { name: 'Тестер', item: 'copper_ore', qty: 2 });
|
||||||
|
await dev('/dev/tp', { name: 'Тестер', x: 1830, y: 415 });
|
||||||
|
await sleep(COOLDOWN);
|
||||||
|
await dev('/dev/command', { name: 'Тестер', text: '!ковать медный слиток' });
|
||||||
|
const d3 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'smith'));
|
||||||
|
const smith = d3.avatars.find((a) => a.name === 'Тестер');
|
||||||
|
console.log('smith: action=smith tool=' + smith.tool);
|
||||||
|
if (smith.tool !== 'hammer') throw new Error('expected hammer');
|
||||||
|
await sleep(9_500);
|
||||||
|
const inv2 = await inventory('Тестер');
|
||||||
|
console.log('inventory: copper_bar=' + (inv2.items?.copper_bar ?? 0), 'smithing xp=' + (inv2.skills?.smithing?.xp ?? 0));
|
||||||
|
if ((inv2.items?.copper_bar ?? 0) !== 1) throw new Error('smelting did not yield');
|
||||||
|
|
||||||
|
// 6. недостижимый рецепт → blocked (нет железных слитков и низкий уровень ковки)
|
||||||
|
await sleep(COOLDOWN);
|
||||||
|
await dev('/dev/command', { name: 'Тестер', text: '!ковать топор' });
|
||||||
|
const d4 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked'), 5000);
|
||||||
|
const reason = d4.events.find((e) => e.k === 'blocked').reason;
|
||||||
|
console.log('blocked:', reason);
|
||||||
|
if (!/уровень|хватает/.test(reason)) throw new Error('unexpected blocked reason');
|
||||||
|
|
||||||
|
// 7. неверная версия протокола — close 4001
|
||||||
const closeCode = await new Promise((resolve) => {
|
const closeCode = await new Promise((resolve) => {
|
||||||
const ws = new WebSocket(WS);
|
const ws = new WebSocket(WS);
|
||||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 999 })));
|
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 999 })));
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|||||||
import sirv from 'sirv';
|
import sirv from 'sirv';
|
||||||
import {
|
import {
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
|
PROPS,
|
||||||
|
SKILL_TITLES,
|
||||||
WORLD,
|
WORLD,
|
||||||
ZONES,
|
ZONES,
|
||||||
colorForName,
|
colorForName,
|
||||||
|
levelForTotalXp,
|
||||||
type DeltaMsg,
|
type DeltaMsg,
|
||||||
type GameEvent,
|
type GameEvent,
|
||||||
type SnapshotMsg,
|
type SnapshotMsg,
|
||||||
@@ -80,7 +83,13 @@ const world: WorldSource = {
|
|||||||
v: PROTOCOL_VERSION,
|
v: PROTOCOL_VERSION,
|
||||||
tickMs: cfg.tickMs,
|
tickMs: cfg.tickMs,
|
||||||
serverNow: Date.now(),
|
serverNow: Date.now(),
|
||||||
world: { w: WORLD.w, h: WORLD.h, spawn: { ...WORLD.spawn }, zones: ZONES },
|
world: {
|
||||||
|
w: WORLD.w,
|
||||||
|
h: WORLD.h,
|
||||||
|
spawn: { ...WORLD.spawn },
|
||||||
|
zones: ZONES,
|
||||||
|
props: PROPS.map((p) => ({ ...p })),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
snapshot(): SnapshotMsg {
|
snapshot(): SnapshotMsg {
|
||||||
@@ -90,7 +99,7 @@ const world: WorldSource = {
|
|||||||
serverNow: Date.now(),
|
serverNow: Date.now(),
|
||||||
camera: camera.state,
|
camera: camera.state,
|
||||||
avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)),
|
avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)),
|
||||||
nodes: sim.serializeTrees(),
|
nodes: sim.serializeNodes(),
|
||||||
stats: sim.stats(),
|
stats: sim.stats(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -102,7 +111,7 @@ const gateway = new WsGateway(world);
|
|||||||
|
|
||||||
function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
|
function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
|
||||||
const avatars = sim.takeDirtyAvatars();
|
const avatars = sim.takeDirtyAvatars();
|
||||||
const nodes = sim.takeDirtyTrees();
|
const nodes = sim.takeDirtyNodes();
|
||||||
const stats = sim.takeStatsIfDirty();
|
const stats = sim.takeStatsIfDirty();
|
||||||
if (avatars.length === 0 && nodes.length === 0 && !stats && events.length === 0 && !camChanged) {
|
if (avatars.length === 0 && nodes.length === 0 && !stats && events.length === 0 && !camChanged) {
|
||||||
return null;
|
return null;
|
||||||
@@ -119,13 +128,13 @@ function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const events = sim.tick(now);
|
const events = sim.tick(now);
|
||||||
const camChanged = camera.tick(now);
|
const camChanged = camera.tick(now, sim.zoneActivity());
|
||||||
|
|
||||||
for (const rec of sim.takeDirtyViewers()) db.saveViewer(cfg.channelId, rec);
|
for (const rec of sim.takeDirtyViewers()) db.saveViewer(cfg.channelId, rec);
|
||||||
|
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
if (ev.k === 'levelup' && cfg.chatAnnounceLevelUp) {
|
if (ev.k === 'levelup' && cfg.chatAnnounceLevelUp) {
|
||||||
announce(`🌲 ${ev.name} вырос(ла) до уровня ${ev.level} в рубке леса!`);
|
announce(`⭐ ${ev.name} вырос(ла) до ${ev.level}-го уровня: ${SKILL_TITLES[ev.skill] ?? ev.skill}!`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +210,58 @@ function handleHttp(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cfg.devHttp && p === '/dev/give' && req.method === 'POST') {
|
||||||
|
readBody(req, (body) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body || '{}') as { name?: string; item?: string; qty?: number };
|
||||||
|
const name = (parsed.name ?? '').trim();
|
||||||
|
const item = (parsed.item ?? '').trim();
|
||||||
|
const qty = Math.max(1, Math.min(999, Number(parsed.qty ?? 1)));
|
||||||
|
if (!name || !item) {
|
||||||
|
json(res, 400, { error: 'нужны name и item' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = sim.giveItem(`dev:${name}`, item, qty);
|
||||||
|
json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'зритель не в мире' });
|
||||||
|
} catch (e) {
|
||||||
|
json(res, 400, { error: String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.devHttp && p === '/dev/tp' && req.method === 'POST') {
|
||||||
|
readBody(req, (body) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body || '{}') as { name?: string; x?: number; y?: number };
|
||||||
|
const name = (parsed.name ?? '').trim();
|
||||||
|
if (!name || !Number.isFinite(parsed.x) || !Number.isFinite(parsed.y)) {
|
||||||
|
json(res, 400, { error: 'нужны name, x, y' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = sim.teleport(`dev:${name}`, Number(parsed.x), Number(parsed.y));
|
||||||
|
json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'зритель не в мире' });
|
||||||
|
} catch (e) {
|
||||||
|
json(res, 400, { error: String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === '/api/inventory' && req.method === 'GET') {
|
||||||
|
const name = (url.searchParams.get('name') ?? '').trim();
|
||||||
|
const found = name ? sim.lookupViewer(name) : undefined;
|
||||||
|
if (!found) {
|
||||||
|
json(res, 200, { found: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const skills = Object.fromEntries(
|
||||||
|
Object.entries(found.skills).map(([id, s]) => [id, { xp: s.xp, level: s.level ?? levelForTotalXp(s.xp) }]),
|
||||||
|
);
|
||||||
|
json(res, 200, { found: true, name: found.name, color: found.color, skills, items: found.items });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const m of staticMounts) {
|
for (const m of staticMounts) {
|
||||||
if (!m.middleware) continue;
|
if (!m.middleware) continue;
|
||||||
const base = m.prefix.slice(0, -1);
|
const base = m.prefix.slice(0, -1);
|
||||||
|
|||||||
@@ -4,26 +4,48 @@ const CAM_W = 900;
|
|||||||
const CAM_H = 560;
|
const CAM_H = 560;
|
||||||
const DRIFT_X = 60;
|
const DRIFT_X = 60;
|
||||||
const DRIFT_Y = 30;
|
const DRIFT_Y = 30;
|
||||||
|
const DWELL_MS = 45_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Камера стрима авторитарна на сервере — все клиенты видят одно и то же.
|
* Камера стрима авторитарна на сервере — все клиенты видят одно и то же.
|
||||||
* M1: одна зона, камера медленно дрейфует вокруг её центра.
|
* Автотур: стоянка на зоне ~45 сек; зона с активностью приоритетна;
|
||||||
* M2+: автотур по зонам с активностью (стоянка ~45 сек, плавные переезды).
|
* без активности — круговой обход + медленный дрейф.
|
||||||
|
* Плавность переезда даёт клиентский lerp по прямоугольнику камеры.
|
||||||
*/
|
*/
|
||||||
export class CameraController {
|
export class CameraController {
|
||||||
state: CameraState;
|
state: CameraState;
|
||||||
private readonly seed = Math.random() * 100;
|
private readonly seed = Math.random() * 100;
|
||||||
|
private zoneIdx = 0;
|
||||||
|
private dwellUntil = 0;
|
||||||
|
|
||||||
constructor(private readonly zones: ZoneDef[] = ZONES) {
|
constructor(private readonly zones: ZoneDef[] = ZONES) {
|
||||||
const first = this.zones[0];
|
const first = this.zones[0];
|
||||||
this.state = first
|
this.state = first ? this.forZone(first, 0) : { x: 0, y: 0, w: CAM_W, h: CAM_H };
|
||||||
? this.forZone(first, 0)
|
|
||||||
: { x: 0, y: 0, w: CAM_W, h: CAM_H };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Возвращает true, если состояние изменилось (его стоит разослать). */
|
/** activity — количество аватаров в каждой зоне (порядок ZONES). */
|
||||||
tick(nowMs: number): boolean {
|
tick(nowMs: number, activity: number[]): boolean {
|
||||||
const zone = this.zones[0];
|
if (this.zones.length > 1) {
|
||||||
|
const curActive = (activity[this.zoneIdx] ?? 0) > 0;
|
||||||
|
if (nowMs >= this.dwellUntil || !curActive) {
|
||||||
|
let best = -1;
|
||||||
|
let bestA = 0;
|
||||||
|
for (let i = 0; i < this.zones.length; i++) {
|
||||||
|
const a = activity[i] ?? 0;
|
||||||
|
if (a > bestA) {
|
||||||
|
bestA = a;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = best >= 0 ? best : (this.zoneIdx + 1) % this.zones.length;
|
||||||
|
if (next !== this.zoneIdx || nowMs >= this.dwellUntil) {
|
||||||
|
this.zoneIdx = next;
|
||||||
|
this.dwellUntil = nowMs + DWELL_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = this.zones[this.zoneIdx];
|
||||||
if (!zone) return false;
|
if (!zone) return false;
|
||||||
const next = this.forZone(zone, nowMs);
|
const next = this.forZone(zone, nowMs);
|
||||||
if (
|
if (
|
||||||
@@ -39,8 +61,10 @@ export class CameraController {
|
|||||||
private forZone(z: ZoneDef, nowMs: number): CameraState {
|
private forZone(z: ZoneDef, nowMs: number): CameraState {
|
||||||
const w = Math.min(CAM_W, z.w);
|
const w = Math.min(CAM_W, z.w);
|
||||||
const h = Math.min(CAM_H, z.h);
|
const h = Math.min(CAM_H, z.h);
|
||||||
const cx = z.x + z.w / 2 + Math.sin(nowMs / 9000 + this.seed) * DRIFT_X;
|
const rangeX = Math.max(0, z.w - w) / 2;
|
||||||
const cy = z.y + z.h / 2 + Math.cos(nowMs / 11000 + this.seed) * DRIFT_Y;
|
const rangeY = Math.max(0, z.h - h) / 2;
|
||||||
|
const cx = z.x + z.w / 2 + Math.sin(nowMs / 9000 + this.seed) * Math.min(DRIFT_X, rangeX);
|
||||||
|
const cy = z.y + z.h / 2 + Math.cos(nowMs / 11000 + this.seed) * Math.min(DRIFT_Y, rangeY);
|
||||||
return { x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h };
|
return { x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import type { ViewerRecord } from '../sim/world';
|
|
||||||
|
/**
|
||||||
|
* Запись зрителя: инвентарь и XP навыков. Хранится вне ECS (мета-слой).
|
||||||
|
*/
|
||||||
|
export interface ViewerRecord {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
/** skill id -> суммарный xp */
|
||||||
|
skills: Record<string, number>;
|
||||||
|
/** item id -> количество */
|
||||||
|
items: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тонкий слой persist на встроенном node:sqlite — без нативных сборок и
|
* Тонкий слой persist на встроенном node:sqlite — без нативных сборок и
|
||||||
@@ -17,37 +31,103 @@ export class Db {
|
|||||||
}
|
}
|
||||||
this.db = new DatabaseSync(dbPath);
|
this.db = new DatabaseSync(dbPath);
|
||||||
this.db.exec('PRAGMA journal_mode = WAL');
|
this.db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
|
||||||
|
const row = this.db.prepare('PRAGMA user_version').get() as { user_version?: number };
|
||||||
|
const version = row?.user_version ?? 0;
|
||||||
|
if (version < SCHEMA_VERSION) {
|
||||||
|
// дев-стадия: старую схему не мигрируем, а пересоздаём
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS viewers (
|
DROP TABLE IF EXISTS viewer_skills;
|
||||||
|
DROP TABLE IF EXISTS viewer_items;
|
||||||
|
DROP TABLE IF EXISTS viewers;
|
||||||
|
CREATE TABLE viewers (
|
||||||
channel_id TEXT NOT NULL,
|
channel_id TEXT NOT NULL,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT NOT NULL,
|
color TEXT NOT NULL,
|
||||||
xp INTEGER NOT NULL DEFAULT 0,
|
|
||||||
logs INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (channel_id, user_id)
|
PRIMARY KEY (channel_id, user_id)
|
||||||
)
|
);
|
||||||
|
CREATE TABLE viewer_skills (
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
skill TEXT NOT NULL,
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (channel_id, user_id, skill)
|
||||||
|
);
|
||||||
|
CREATE TABLE viewer_items (
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
item TEXT NOT NULL,
|
||||||
|
qty INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (channel_id, user_id, item)
|
||||||
|
);
|
||||||
|
PRAGMA user_version = ${SCHEMA_VERSION};
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadViewers(channelId: string): ViewerRecord[] {
|
loadViewers(channelId: string): ViewerRecord[] {
|
||||||
const rows = this.db
|
const viewers = this.db
|
||||||
.prepare('SELECT user_id, name, color, xp, logs FROM viewers WHERE channel_id = ?')
|
.prepare('SELECT user_id, name, color FROM viewers WHERE channel_id = ?')
|
||||||
.all(channelId) as Array<{ user_id: string; name: string; color: string; xp: number; logs: number }>;
|
.all(channelId) as Array<{ user_id: string; name: string; color: string }>;
|
||||||
return rows.map((r) => ({ userId: r.user_id, name: r.name, color: r.color, xp: r.xp, logs: r.logs }));
|
|
||||||
|
const skills = this.db
|
||||||
|
.prepare('SELECT user_id, skill, xp FROM viewer_skills WHERE channel_id = ?')
|
||||||
|
.all(channelId) as Array<{ user_id: string; skill: string; xp: number }>;
|
||||||
|
const items = this.db
|
||||||
|
.prepare('SELECT user_id, item, qty FROM viewer_items WHERE channel_id = ?')
|
||||||
|
.all(channelId) as Array<{ user_id: string; item: string; qty: number }>;
|
||||||
|
|
||||||
|
const byId = new Map<string, ViewerRecord>();
|
||||||
|
for (const v of viewers) {
|
||||||
|
byId.set(v.user_id, { userId: v.user_id, name: v.name, color: v.color, skills: {}, items: {} });
|
||||||
|
}
|
||||||
|
for (const s of skills) {
|
||||||
|
const rec = byId.get(s.user_id);
|
||||||
|
if (rec) rec.skills[s.skill] = s.xp;
|
||||||
|
}
|
||||||
|
for (const it of items) {
|
||||||
|
const rec = byId.get(it.user_id);
|
||||||
|
if (rec) rec.items[it.item] = it.qty;
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
saveViewer(channelId: string, v: ViewerRecord): void {
|
saveViewer(channelId: string, v: ViewerRecord): void {
|
||||||
|
this.db.exec('BEGIN');
|
||||||
|
try {
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO viewers (channel_id, user_id, name, color, xp, logs, updated_at)
|
`INSERT INTO viewers (channel_id, user_id, name, color, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(channel_id, user_id) DO UPDATE SET
|
ON CONFLICT(channel_id, user_id) DO UPDATE SET
|
||||||
name = excluded.name, color = excluded.color, xp = excluded.xp,
|
name = excluded.name, color = excluded.color, updated_at = excluded.updated_at`,
|
||||||
logs = excluded.logs, updated_at = excluded.updated_at`,
|
|
||||||
)
|
)
|
||||||
.run(channelId, v.userId, v.name, v.color, v.xp, v.logs, Date.now());
|
.run(channelId, v.userId, v.name, v.color, Date.now());
|
||||||
|
this.db
|
||||||
|
.prepare('DELETE FROM viewer_skills WHERE channel_id = ? AND user_id = ?')
|
||||||
|
.run(channelId, v.userId);
|
||||||
|
this.db
|
||||||
|
.prepare('DELETE FROM viewer_items WHERE channel_id = ? AND user_id = ?')
|
||||||
|
.run(channelId, v.userId);
|
||||||
|
const skillStmt = this.db.prepare(
|
||||||
|
'INSERT INTO viewer_skills (channel_id, user_id, skill, xp) VALUES (?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
for (const [skill, xp] of Object.entries(v.skills)) {
|
||||||
|
skillStmt.run(channelId, v.userId, skill, xp);
|
||||||
|
}
|
||||||
|
const itemStmt = this.db.prepare(
|
||||||
|
'INSERT INTO viewer_items (channel_id, user_id, item, qty) VALUES (?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
for (const [item, qty] of Object.entries(v.items)) {
|
||||||
|
if (qty > 0) itemStmt.run(channelId, v.userId, item, qty);
|
||||||
|
}
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
|
|||||||
+536
-172
@@ -1,17 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
COMMAND_COOLDOWN_MS,
|
COMMAND_COOLDOWN_MS,
|
||||||
|
FORGE_SPOT,
|
||||||
ITEM_LOG,
|
ITEM_LOG,
|
||||||
|
ORES,
|
||||||
|
RECIPES,
|
||||||
|
ROCK_SPOTS,
|
||||||
|
ROCK,
|
||||||
|
SKILLS,
|
||||||
|
START_ITEMS,
|
||||||
|
TOOLS,
|
||||||
TREE,
|
TREE,
|
||||||
TREE_SPOTS,
|
TREE_SPOTS,
|
||||||
WALK_SPEED,
|
WALK_SPEED,
|
||||||
WORLD,
|
WORLD,
|
||||||
XP_PER_CHOP,
|
ZONES,
|
||||||
colorForName,
|
colorForName,
|
||||||
|
findRecipe,
|
||||||
|
itemTitle,
|
||||||
levelForTotalXp,
|
levelForTotalXp,
|
||||||
type AvatarAction,
|
type AvatarAction,
|
||||||
type AvatarState,
|
type AvatarState,
|
||||||
type GameEvent,
|
type GameEvent,
|
||||||
type NodeState,
|
type NodeState,
|
||||||
|
type OreKind,
|
||||||
|
type RecipeDef,
|
||||||
|
type SkillId,
|
||||||
|
type ToolDef,
|
||||||
type ViewerStat,
|
type ViewerStat,
|
||||||
} from '@idle/shared';
|
} from '@idle/shared';
|
||||||
|
|
||||||
@@ -28,37 +42,65 @@ export interface ViewerRecord {
|
|||||||
userId: string;
|
userId: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
xp: number;
|
/** skill id -> суммарный xp */
|
||||||
logs: number;
|
skills: Record<string, number>;
|
||||||
|
/** item id -> количество */
|
||||||
|
items: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimAvatar extends AvatarState {
|
interface SkillSim {
|
||||||
|
xp: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimAvatar {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
speed: number;
|
||||||
|
moveStart: number;
|
||||||
|
moving: boolean;
|
||||||
|
action: AvatarAction;
|
||||||
|
nodeId: string | null;
|
||||||
|
actionStart: number | null;
|
||||||
|
actionDur: number | null;
|
||||||
|
skills: Record<SkillId, SkillSim>;
|
||||||
|
items: Map<string, number>;
|
||||||
lastCommandAt: number;
|
lastCommandAt: number;
|
||||||
pendingNodeId: string | null;
|
pendingNodeId: string | null;
|
||||||
logs: number;
|
pendingRecipeId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SimTree = NodeState;
|
interface SimNode extends NodeState {}
|
||||||
|
|
||||||
const CHOP_ALIASES = new Set(['рубить', 'chop']);
|
const CHOP_ALIASES = new Set(['рубить', 'chop']);
|
||||||
|
const MINE_ALIASES = new Set(['копать', 'mine']);
|
||||||
|
const SMITH_ALIASES = new Set(['ковать', 'smith']);
|
||||||
const STOP_ALIASES = new Set(['стоп', 'stop']);
|
const STOP_ALIASES = new Set(['стоп', 'stop']);
|
||||||
|
|
||||||
|
const SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id);
|
||||||
|
|
||||||
function dist(ax: number, ay: number, bx: number, by: number): number {
|
function dist(ax: number, ay: number, bx: number, by: number): number {
|
||||||
return Math.hypot(bx - ax, by - ay);
|
return Math.hypot(bx - ax, by - ay);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Симуляционный слой M1: plain TS за узким интерфейсом (десятки сущностей).
|
* Симуляционный слой M2: plain TS за узким интерфейсом (решение об ECS — в M3).
|
||||||
* Решение об ECS (bitecs 0.4 / koota) отложено до M2 — см. PLAN.md.
|
|
||||||
*
|
|
||||||
* Правила:
|
* Правила:
|
||||||
* - команды обрабатываются сразу при приходе, не ждут тика;
|
* - команды обрабатываются сразу при приходе, не ждут тика;
|
||||||
* - аватар продолжает действие, пока не придёт другая команда или !стоп;
|
* - аватар продолжает действие, пока не придёт другая команда или !стоп
|
||||||
* - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам.
|
* (ковка — пока хватает материалов);
|
||||||
|
* - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам;
|
||||||
|
* - инвентарь и навыки — мета-слой, вне ECS.
|
||||||
*/
|
*/
|
||||||
export class SimWorld {
|
export class SimWorld {
|
||||||
readonly avatars = new Map<string, SimAvatar>();
|
readonly avatars = new Map<string, SimAvatar>();
|
||||||
readonly trees: SimTree[] = TREE_SPOTS.map((t, i) => ({
|
readonly nodes: SimNode[] = [
|
||||||
|
...TREE_SPOTS.map((t, i): SimNode => ({
|
||||||
id: `tree-${i + 1}`,
|
id: `tree-${i + 1}`,
|
||||||
kind: 'tree',
|
kind: 'tree',
|
||||||
x: t.x,
|
x: t.x,
|
||||||
@@ -66,12 +108,24 @@ export class SimWorld {
|
|||||||
hp: TREE.maxHp,
|
hp: TREE.maxHp,
|
||||||
maxHp: TREE.maxHp,
|
maxHp: TREE.maxHp,
|
||||||
respawnAt: null,
|
respawnAt: null,
|
||||||
}));
|
})),
|
||||||
|
...ROCK_SPOTS.map((r, i): SimNode => ({
|
||||||
|
id: `rock-${i + 1}`,
|
||||||
|
kind: 'rock',
|
||||||
|
variant: r.ore,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
hp: ROCK.maxHp,
|
||||||
|
maxHp: ROCK.maxHp,
|
||||||
|
respawnAt: null,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
private readonly dirtyAvatars = new Set<string>();
|
private readonly dirtyAvatars = new Set<string>();
|
||||||
private readonly dirtyTrees = new Set<string>();
|
private readonly dirtyNodes = new Set<string>();
|
||||||
private readonly dirtyViewers = new Map<string, ViewerRecord>();
|
private readonly dirtyViewers = new Map<string, ViewerRecord>();
|
||||||
private readonly preloaded = new Map<string, ViewerRecord>();
|
private readonly preloaded = new Map<string, ViewerRecord>();
|
||||||
|
private readonly pendingEvents: GameEvent[] = [];
|
||||||
private statsDirty = true;
|
private statsDirty = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -84,19 +138,23 @@ export class SimWorld {
|
|||||||
handleCommand(cmd: ChatCommand, now = Date.now()): void {
|
handleCommand(cmd: ChatCommand, now = Date.now()): void {
|
||||||
if (cmd.channelId !== this.channelId) return;
|
if (cmd.channelId !== this.channelId) return;
|
||||||
if (!cmd.text.startsWith('!')) return;
|
if (!cmd.text.startsWith('!')) return;
|
||||||
const word = (cmd.text.slice(1).split(/\s+/)[0] ?? '').toLowerCase();
|
const parts = cmd.text.slice(1).trim().split(/\s+/);
|
||||||
|
const word = (parts[0] ?? '').toLowerCase();
|
||||||
|
const arg = parts.slice(1).join(' ');
|
||||||
if (CHOP_ALIASES.has(word)) this.doChop(cmd, now);
|
if (CHOP_ALIASES.has(word)) this.doChop(cmd, now);
|
||||||
|
else if (MINE_ALIASES.has(word)) this.doMine(cmd, now);
|
||||||
|
else if (SMITH_ALIASES.has(word)) this.doSmith(cmd, arg, now);
|
||||||
else if (STOP_ALIASES.has(word)) this.doStop(cmd, now);
|
else if (STOP_ALIASES.has(word)) this.doStop(cmd, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(now: number): GameEvent[] {
|
tick(now: number): GameEvent[] {
|
||||||
const events: GameEvent[] = [];
|
const events = this.pendingEvents.splice(0);
|
||||||
|
|
||||||
for (const tree of this.trees) {
|
for (const node of this.nodes) {
|
||||||
if (tree.respawnAt !== null && now >= tree.respawnAt) {
|
if (node.respawnAt !== null && now >= node.respawnAt) {
|
||||||
tree.respawnAt = null;
|
node.respawnAt = null;
|
||||||
tree.hp = tree.maxHp;
|
node.hp = node.maxHp;
|
||||||
this.dirtyTrees.add(tree.id);
|
this.dirtyNodes.add(node.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,28 +166,14 @@ export class SimWorld {
|
|||||||
av.y = av.ty;
|
av.y = av.ty;
|
||||||
av.moving = false;
|
av.moving = false;
|
||||||
this.dirtyAvatars.add(av.id);
|
this.dirtyAvatars.add(av.id);
|
||||||
const tree = av.pendingNodeId ? this.treeById(av.pendingNodeId) : undefined;
|
this.onArrival(av, now, events);
|
||||||
if (tree && tree.respawnAt === null) {
|
|
||||||
this.beginChop(av, tree, now);
|
|
||||||
} else {
|
|
||||||
av.pendingNodeId = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (av.action === 'chop' && av.actionStart !== null) {
|
if (av.action !== 'idle' && av.actionStart !== null) {
|
||||||
const dur = av.actionDur ?? TREE.chopDurMs;
|
const dur = av.actionDur ?? 0;
|
||||||
if (now >= av.actionStart + dur) {
|
if (dur > 0 && now >= av.actionStart + dur) {
|
||||||
const tree = av.nodeId ? this.treeById(av.nodeId) : undefined;
|
this.completeCycle(av, now, events);
|
||||||
if (!tree || tree.respawnAt !== null) {
|
|
||||||
av.action = 'idle';
|
|
||||||
av.nodeId = null;
|
|
||||||
av.actionStart = null;
|
|
||||||
this.dirtyAvatars.add(av.id);
|
|
||||||
this.statsDirty = true;
|
|
||||||
} else {
|
|
||||||
this.completeChopCycle(av, tree, now, events);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,25 +181,104 @@ export class SimWorld {
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onArrival(av: SimAvatar, now: number, events: GameEvent[]): void {
|
||||||
|
if (av.pendingNodeId) {
|
||||||
|
const node = this.nodeById(av.pendingNodeId);
|
||||||
|
if (node && node.respawnAt === null) {
|
||||||
|
this.beginGather(av, node, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
av.pendingNodeId = null;
|
||||||
|
}
|
||||||
|
if (av.pendingRecipeId) {
|
||||||
|
const recipe = RECIPES.find((r) => r.id === av.pendingRecipeId);
|
||||||
|
if (recipe && this.hasInputs(av, recipe)) {
|
||||||
|
this.beginSmith(av, recipe, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
av.pendingRecipeId = null;
|
||||||
|
events.push({
|
||||||
|
k: 'blocked',
|
||||||
|
userId: av.id,
|
||||||
|
name: av.name,
|
||||||
|
color: av.color,
|
||||||
|
reason: `материалы закончились: ${this.inputsText(recipe)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- команды ----
|
// ---- команды ----
|
||||||
|
|
||||||
private doChop(cmd: ChatCommand, now: number): void {
|
private doChop(cmd: ChatCommand, now: number): void {
|
||||||
const av = this.ensureAvatar(cmd, now);
|
const av = this.ensureAvatar(cmd, now);
|
||||||
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return;
|
if (this.onCooldown(av, now)) return;
|
||||||
av.lastCommandAt = now;
|
|
||||||
if (av.action === 'chop' && !av.moving) return; // уже рубит
|
if (av.action === 'chop' && !av.moving) return; // уже рубит
|
||||||
|
|
||||||
const tree = this.nearestAvailableTree(av);
|
const tree = this.nearestAvailable(av, (n) => n.kind === 'tree');
|
||||||
if (!tree) return; // всё вырублено и не отросло — просто ждём
|
if (!tree) {
|
||||||
|
this.pendingEvents.push(this.blocked(av, 'все деревья ещё не отросли'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.walkTo(av, tree.x, tree.y + 26, now);
|
this.walkTo(av, tree.x, tree.y + 26, now);
|
||||||
av.pendingNodeId = tree.id;
|
av.pendingNodeId = tree.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private doMine(cmd: ChatCommand, now: number): void {
|
||||||
|
const av = this.ensureAvatar(cmd, now);
|
||||||
|
if (this.onCooldown(av, now)) return;
|
||||||
|
if (av.action === 'mine' && !av.moving) return;
|
||||||
|
|
||||||
|
const miningLevel = av.skills.mining.level;
|
||||||
|
const rock = this.nearestAvailable(av, (n) => {
|
||||||
|
if (n.kind !== 'rock' || !n.variant) return false;
|
||||||
|
return (ORES[n.variant as OreKind]?.level ?? 99) <= miningLevel;
|
||||||
|
});
|
||||||
|
if (!rock) {
|
||||||
|
const minReq = Math.min(...ROCK_SPOTS.map((r) => ORES[r.ore].level));
|
||||||
|
this.pendingEvents.push(
|
||||||
|
this.blocked(av, `нужен ${minReq}-й уровень добычи руды`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.walkTo(av, rock.x, rock.y + 26, now);
|
||||||
|
av.pendingNodeId = rock.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private doSmith(cmd: ChatCommand, arg: string, now: number): void {
|
||||||
|
const av = this.ensureAvatar(cmd, now);
|
||||||
|
if (this.onCooldown(av, now)) return;
|
||||||
|
|
||||||
|
if (!arg) {
|
||||||
|
this.pendingEvents.push(
|
||||||
|
this.blocked(av, 'что ковать? медный слиток · железный слиток · топор · кирка'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recipe = findRecipe(arg);
|
||||||
|
if (!recipe) {
|
||||||
|
this.pendingEvents.push(
|
||||||
|
this.blocked(av, `не знаю рецепт «${arg}». Можно: ${RECIPES.map((r) => r.title).join(', ')}`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (av.skills.smithing.level < recipe.level) {
|
||||||
|
this.pendingEvents.push(
|
||||||
|
this.blocked(av, `«${recipe.title}» нужен ${recipe.level}-й уровень кузнечного дела`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.hasInputs(av, recipe)) {
|
||||||
|
this.pendingEvents.push(this.blocked(av, `не хватает: ${this.inputsText(recipe)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.walkTo(av, FORGE_SPOT.x, FORGE_SPOT.y, now);
|
||||||
|
av.pendingRecipeId = recipe.id;
|
||||||
|
}
|
||||||
|
|
||||||
private doStop(cmd: ChatCommand, now: number): void {
|
private doStop(cmd: ChatCommand, now: number): void {
|
||||||
const av = this.ensureAvatar(cmd, now);
|
const av = this.ensureAvatar(cmd, now);
|
||||||
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return;
|
if (this.onCooldown(av, now)) return;
|
||||||
av.lastCommandAt = now;
|
|
||||||
|
|
||||||
if (av.moving) {
|
if (av.moving) {
|
||||||
const p = this.currentPos(av, now);
|
const p = this.currentPos(av, now);
|
||||||
@@ -164,6 +287,7 @@ export class SimWorld {
|
|||||||
av.moving = false;
|
av.moving = false;
|
||||||
}
|
}
|
||||||
av.pendingNodeId = null;
|
av.pendingNodeId = null;
|
||||||
|
av.pendingRecipeId = null;
|
||||||
if (av.action !== 'idle') {
|
if (av.action !== 'idle') {
|
||||||
av.action = 'idle';
|
av.action = 'idle';
|
||||||
av.nodeId = null;
|
av.nodeId = null;
|
||||||
@@ -174,42 +298,239 @@ export class SimWorld {
|
|||||||
this.dirtyAvatars.add(av.id);
|
this.dirtyAvatars.add(av.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAvatar(cmd: ChatCommand, now: number): SimAvatar {
|
// ---- действия ----
|
||||||
let av = this.avatars.get(cmd.userId);
|
|
||||||
if (!av) {
|
private beginGather(av: SimAvatar, node: SimNode, now: number): void {
|
||||||
const rec = this.preloaded.get(cmd.userId);
|
if (node.kind === 'tree') {
|
||||||
av = {
|
const tool = this.bestTool(av, 'axe');
|
||||||
id: cmd.userId,
|
av.action = 'chop';
|
||||||
name: cmd.name,
|
av.actionDur = Math.round(TREE.chopDurMs / tool.multiplier);
|
||||||
color: cmd.color || colorForName(cmd.name),
|
} else {
|
||||||
x: WORLD.spawn.x + (Math.random() * 60 - 30),
|
const tool = this.bestTool(av, 'pick');
|
||||||
y: WORLD.spawn.y + (Math.random() * 40 - 20),
|
av.action = 'mine';
|
||||||
tx: WORLD.spawn.x,
|
const ore = ORES[(node.variant ?? 'copper') as OreKind];
|
||||||
ty: WORLD.spawn.y,
|
av.actionDur = Math.round(ore.cycleMs / tool.multiplier);
|
||||||
speed: WALK_SPEED,
|
|
||||||
moveStart: 0,
|
|
||||||
moving: false,
|
|
||||||
action: 'idle',
|
|
||||||
nodeId: null,
|
|
||||||
actionStart: null,
|
|
||||||
actionDur: null,
|
|
||||||
level: rec ? levelForTotalXp(rec.xp) : 1,
|
|
||||||
xp: rec?.xp ?? 0,
|
|
||||||
lastCommandAt: 0,
|
|
||||||
pendingNodeId: null,
|
|
||||||
logs: rec?.logs ?? 0,
|
|
||||||
};
|
|
||||||
this.avatars.set(av.id, av);
|
|
||||||
this.dirtyAvatars.add(av.id);
|
|
||||||
this.markViewerDirty(av);
|
|
||||||
this.statsDirty = true;
|
|
||||||
} else if (av.name !== cmd.name || av.color !== cmd.color) {
|
|
||||||
av.name = cmd.name;
|
|
||||||
av.color = cmd.color || av.color;
|
|
||||||
this.dirtyAvatars.add(av.id);
|
|
||||||
this.markViewerDirty(av);
|
|
||||||
}
|
}
|
||||||
return av;
|
av.nodeId = node.id;
|
||||||
|
av.actionStart = now;
|
||||||
|
av.pendingNodeId = null;
|
||||||
|
av.pendingRecipeId = null;
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
this.statsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginSmith(av: SimAvatar, recipe: RecipeDef, now: number): void {
|
||||||
|
av.action = 'smith';
|
||||||
|
// для ковки nodeId хранит id рецепта — узел-нода у кузницы одна
|
||||||
|
av.nodeId = recipe.id;
|
||||||
|
av.actionDur = recipe.cycleMs;
|
||||||
|
av.actionStart = now;
|
||||||
|
av.pendingRecipeId = null;
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
this.statsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeCycle(av: SimAvatar, now: number, events: GameEvent[]): void {
|
||||||
|
if (av.action === 'chop') {
|
||||||
|
const tree = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
||||||
|
if (!tree || tree.respawnAt !== null) {
|
||||||
|
this.goIdle(av);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tree.hp -= 1;
|
||||||
|
this.dirtyNodes.add(tree.id);
|
||||||
|
if (tree.hp <= 0) {
|
||||||
|
tree.respawnAt = now + TREE.respawnMs;
|
||||||
|
events.push({ k: 'fell', userId: av.id, name: av.name, color: av.color, nodeId: tree.id });
|
||||||
|
}
|
||||||
|
this.addItem(av, ITEM_LOG, 1);
|
||||||
|
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ITEM_LOG, qty: 1 });
|
||||||
|
this.addXp(av, 'woodcutting', TREE.xp, events);
|
||||||
|
this.finishOrContinue(av, now, Math.round(TREE.chopDurMs / this.bestTool(av, 'axe').multiplier), () => tree.respawnAt === null);
|
||||||
|
} else if (av.action === 'mine') {
|
||||||
|
const rock = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
||||||
|
const ore = rock?.variant ? ORES[rock.variant as OreKind] : undefined;
|
||||||
|
if (!rock || !ore || rock.respawnAt !== null) {
|
||||||
|
this.goIdle(av);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rock.hp -= 1;
|
||||||
|
this.dirtyNodes.add(rock.id);
|
||||||
|
if (rock.hp <= 0) {
|
||||||
|
rock.respawnAt = now + ore.respawnMs;
|
||||||
|
events.push({ k: 'depleted', userId: av.id, name: av.name, color: av.color, nodeId: rock.id });
|
||||||
|
}
|
||||||
|
this.addItem(av, ore.item, 1);
|
||||||
|
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ore.item, qty: 1 });
|
||||||
|
this.addXp(av, 'mining', ore.xp, events);
|
||||||
|
this.finishOrContinue(
|
||||||
|
av,
|
||||||
|
now,
|
||||||
|
Math.round(ore.cycleMs / this.bestTool(av, 'pick').multiplier),
|
||||||
|
() => rock.respawnAt === null,
|
||||||
|
);
|
||||||
|
} else if (av.action === 'smith') {
|
||||||
|
const recipe = av.nodeId
|
||||||
|
? RECIPES.find((r) => r.id === av.nodeId)
|
||||||
|
: undefined;
|
||||||
|
if (!recipe) {
|
||||||
|
this.goIdle(av);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const input of recipe.inputs) this.takeItem(av, input.item, input.qty);
|
||||||
|
this.addItem(av, recipe.output.item, recipe.output.qty);
|
||||||
|
events.push({
|
||||||
|
k: 'item',
|
||||||
|
userId: av.id,
|
||||||
|
name: av.name,
|
||||||
|
color: av.color,
|
||||||
|
item: recipe.output.item,
|
||||||
|
qty: recipe.output.qty,
|
||||||
|
});
|
||||||
|
this.addXp(av, 'smithing', recipe.xp, events);
|
||||||
|
this.finishOrContinue(av, now, recipe.cycleMs, () => this.hasInputs(av, recipe), () => {
|
||||||
|
events.push(
|
||||||
|
this.blocked(av, `материалы кончились: ${this.inputsText(recipe)}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Продолжить цикл того же действия либо уйти в idle (по правилу «работает, пока не скажут»). */
|
||||||
|
private finishOrContinue(
|
||||||
|
av: SimAvatar,
|
||||||
|
now: number,
|
||||||
|
durMs: number,
|
||||||
|
canContinue: () => boolean,
|
||||||
|
onBlocked?: () => void,
|
||||||
|
): void {
|
||||||
|
if (canContinue()) {
|
||||||
|
av.actionStart = now + durMs; // сервер-время следующего завершения
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
} else {
|
||||||
|
if (onBlocked) onBlocked();
|
||||||
|
this.goIdle(av);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private goIdle(av: SimAvatar): void {
|
||||||
|
av.action = 'idle';
|
||||||
|
av.nodeId = null;
|
||||||
|
av.actionStart = null;
|
||||||
|
av.actionDur = null;
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
this.statsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- инвентарь / навыки ----
|
||||||
|
|
||||||
|
private addItem(av: SimAvatar, item: string, qty: number): void {
|
||||||
|
av.items.set(item, (av.items.get(item) ?? 0) + qty);
|
||||||
|
this.markViewerDirty(av);
|
||||||
|
this.dirtyAvatars.add(av.id); // мог смениться лучший инструмент
|
||||||
|
}
|
||||||
|
|
||||||
|
private takeItem(av: SimAvatar, item: string, qty: number): void {
|
||||||
|
const left = (av.items.get(item) ?? 0) - qty;
|
||||||
|
if (left > 0) av.items.set(item, left);
|
||||||
|
else av.items.delete(item);
|
||||||
|
this.markViewerDirty(av);
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasInputs(av: SimAvatar, recipe: RecipeDef): boolean {
|
||||||
|
return recipe.inputs.every((i) => (av.items.get(i.item) ?? 0) >= i.qty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private inputsText(recipe: RecipeDef | undefined): string {
|
||||||
|
if (!recipe) return '?';
|
||||||
|
return recipe.inputs.map((i) => `${i.qty}× ${itemTitle(i.item)}`).join(' + ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addXp(av: SimAvatar, skill: SkillId, amount: number, events: GameEvent[]): void {
|
||||||
|
const s = av.skills[skill];
|
||||||
|
if (!s) return;
|
||||||
|
s.xp += amount;
|
||||||
|
events.push({
|
||||||
|
k: 'xp',
|
||||||
|
userId: av.id,
|
||||||
|
name: av.name,
|
||||||
|
color: av.color,
|
||||||
|
skill,
|
||||||
|
amount,
|
||||||
|
total: s.xp,
|
||||||
|
});
|
||||||
|
const level = levelForTotalXp(s.xp);
|
||||||
|
if (level > s.level) {
|
||||||
|
s.level = level;
|
||||||
|
events.push({
|
||||||
|
k: 'levelup',
|
||||||
|
userId: av.id,
|
||||||
|
name: av.name,
|
||||||
|
color: av.color,
|
||||||
|
skill,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.markViewerDirty(av);
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- вспомогательное ----
|
||||||
|
|
||||||
|
private blocked(av: SimAvatar, reason: string): GameEvent {
|
||||||
|
return { k: 'blocked', userId: av.id, name: av.name, color: av.color, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
private onCooldown(av: SimAvatar, now: number): boolean {
|
||||||
|
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return true;
|
||||||
|
av.lastCommandAt = now;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nodeById(id: string): SimNode | undefined {
|
||||||
|
return this.nodes.find((n) => n.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nearestAvailable(av: SimAvatar, filter: (n: SimNode) => boolean): SimNode | undefined {
|
||||||
|
let best: SimNode | undefined;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const n of this.nodes) {
|
||||||
|
if (n.respawnAt !== null || !filter(n)) continue;
|
||||||
|
const d = dist(av.x, av.y, n.x, n.y);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bestTool(av: SimAvatar, kind: ToolDef['kind']): ToolDef {
|
||||||
|
let best: ToolDef | undefined;
|
||||||
|
for (const t of TOOLS) {
|
||||||
|
if (t.kind !== kind || (av.items.get(t.item) ?? 0) < 1) continue;
|
||||||
|
if (!best || t.multiplier > best.multiplier) best = t;
|
||||||
|
}
|
||||||
|
// базовый случай: без инструмента — но START_ITEMS гарантирует ржавый набор
|
||||||
|
return best ?? { item: 'none', kind, multiplier: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private toolForAction(av: SimAvatar): string {
|
||||||
|
if (av.action === 'chop') return this.bestTool(av, 'axe').item;
|
||||||
|
if (av.action === 'mine') return this.bestTool(av, 'pick').item;
|
||||||
|
if (av.action === 'smith') return 'hammer';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentPos(av: SimAvatar, now: number): { x: number; y: number } {
|
||||||
|
if (!av.moving) return { x: av.x, y: av.y };
|
||||||
|
const dx = av.tx - av.x;
|
||||||
|
const dy = av.ty - av.y;
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d === 0) return { x: av.tx, y: av.ty };
|
||||||
|
const k = Math.min(((now - av.moveStart) / 1000) * av.speed, d);
|
||||||
|
return { x: av.x + (dx / d) * k, y: av.y + (dy / d) * k };
|
||||||
}
|
}
|
||||||
|
|
||||||
private walkTo(av: SimAvatar, tx: number, ty: number, now: number): void {
|
private walkTo(av: SimAvatar, tx: number, ty: number, now: number): void {
|
||||||
@@ -229,94 +550,79 @@ export class SimWorld {
|
|||||||
this.statsDirty = true;
|
this.statsDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private beginChop(av: SimAvatar, tree: SimTree, now: number): void {
|
|
||||||
av.pendingNodeId = null;
|
|
||||||
av.action = 'chop';
|
|
||||||
av.nodeId = tree.id;
|
|
||||||
av.actionStart = now;
|
|
||||||
av.actionDur = TREE.chopDurMs;
|
|
||||||
this.dirtyAvatars.add(av.id);
|
|
||||||
this.statsDirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private completeChopCycle(av: SimAvatar, tree: SimTree, now: number, events: GameEvent[]): void {
|
|
||||||
const start = av.actionStart;
|
|
||||||
const dur = av.actionDur ?? TREE.chopDurMs;
|
|
||||||
if (start === null) return;
|
|
||||||
|
|
||||||
tree.hp -= 1;
|
|
||||||
this.dirtyTrees.add(tree.id);
|
|
||||||
if (tree.hp <= 0) {
|
|
||||||
tree.respawnAt = now + TREE.respawnMs;
|
|
||||||
events.push({ k: 'fell', userId: av.id, name: av.name, color: av.color, nodeId: tree.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
av.logs += 1;
|
|
||||||
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ITEM_LOG, qty: 1 });
|
|
||||||
|
|
||||||
av.xp += XP_PER_CHOP;
|
|
||||||
events.push({ k: 'xp', userId: av.id, name: av.name, color: av.color, amount: XP_PER_CHOP, total: av.xp });
|
|
||||||
const level = levelForTotalXp(av.xp);
|
|
||||||
if (level > av.level) {
|
|
||||||
av.level = level;
|
|
||||||
events.push({ k: 'levelup', userId: av.id, name: av.name, color: av.color, level });
|
|
||||||
}
|
|
||||||
this.dirtyAvatars.add(av.id);
|
|
||||||
this.markViewerDirty(av);
|
|
||||||
|
|
||||||
if (tree.respawnAt !== null) {
|
|
||||||
// дерево свалено — аватар отдыхает, пока зритель не отправит к следующему
|
|
||||||
av.action = 'idle';
|
|
||||||
av.nodeId = null;
|
|
||||||
av.actionStart = null;
|
|
||||||
} else {
|
|
||||||
av.actionStart = start + dur; // следующий цикл той же команды
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- вспомогательное ----
|
|
||||||
|
|
||||||
private treeById(id: string): SimTree | undefined {
|
|
||||||
return this.trees.find((t) => t.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private nearestAvailableTree(av: SimAvatar): SimTree | undefined {
|
|
||||||
let best: SimTree | undefined;
|
|
||||||
let bestD = Infinity;
|
|
||||||
for (const t of this.trees) {
|
|
||||||
if (t.respawnAt !== null) continue;
|
|
||||||
const d = dist(av.x, av.y, t.x, t.y);
|
|
||||||
if (d < bestD) {
|
|
||||||
bestD = d;
|
|
||||||
best = t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
private currentPos(av: SimAvatar, now: number): { x: number; y: number } {
|
|
||||||
if (!av.moving) return { x: av.x, y: av.y };
|
|
||||||
const dx = av.tx - av.x;
|
|
||||||
const dy = av.ty - av.y;
|
|
||||||
const d = Math.hypot(dx, dy);
|
|
||||||
if (d === 0) return { x: av.tx, y: av.ty };
|
|
||||||
const k = Math.min(((now - av.moveStart) / 1000) * av.speed, d);
|
|
||||||
return { x: av.x + (dx / d) * k, y: av.y + (dy / d) * k };
|
|
||||||
}
|
|
||||||
|
|
||||||
private markViewerDirty(av: SimAvatar): void {
|
private markViewerDirty(av: SimAvatar): void {
|
||||||
this.dirtyViewers.set(av.id, { userId: av.id, name: av.name, color: av.color, xp: av.xp, logs: av.logs });
|
this.dirtyViewers.set(av.id, this.toRecord(av));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- выдача для сети/себя ----
|
private toRecord(av: SimAvatar): ViewerRecord {
|
||||||
|
const skills: Record<string, number> = {};
|
||||||
|
for (const id of SKILL_IDS) skills[id] = av.skills[id]?.xp ?? 0;
|
||||||
|
const items: Record<string, number> = {};
|
||||||
|
for (const [item, qty] of av.items) items[item] = qty;
|
||||||
|
return { userId: av.id, name: av.name, color: av.color, skills, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dev-хелперы (только DEV_HTTP) ----
|
||||||
|
|
||||||
|
giveItem(userId: string, item: string, qty: number): boolean {
|
||||||
|
const av = this.avatars.get(userId);
|
||||||
|
if (!av) return false;
|
||||||
|
this.addItem(av, item, qty);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
teleport(userId: string, x: number, y: number): boolean {
|
||||||
|
const av = this.avatars.get(userId);
|
||||||
|
if (!av) return false;
|
||||||
|
av.x = x;
|
||||||
|
av.y = y;
|
||||||
|
av.tx = x;
|
||||||
|
av.ty = y;
|
||||||
|
av.moving = false;
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupViewer(name: string): { name: string; color: string; skills: Record<string, SkillSim>; items: Record<string, number> } | undefined {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
const av = [...this.avatars.values()].find((a) => a.name.toLowerCase() === lower);
|
||||||
|
if (av) {
|
||||||
|
return { name: av.name, color: av.color, skills: av.skills, items: Object.fromEntries(av.items) };
|
||||||
|
}
|
||||||
|
const rec = [...this.preloaded.values()].find((r) => r.name.toLowerCase() === lower);
|
||||||
|
if (!rec) return undefined;
|
||||||
|
const skills: Record<string, SkillSim> = {};
|
||||||
|
for (const id of SKILL_IDS) {
|
||||||
|
const xp = rec.skills[id] ?? 0;
|
||||||
|
skills[id] = { xp, level: levelForTotalXp(xp) };
|
||||||
|
}
|
||||||
|
return { name: rec.name, color: rec.color, skills, items: { ...rec.items } };
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneActivity(): number[] {
|
||||||
|
return ZONES.map(
|
||||||
|
(z) => [...this.avatars.values()].filter((a) => a.x >= z.x && a.x < z.x + z.w).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- выдача для сети ----
|
||||||
|
|
||||||
serializeAvatar(av: SimAvatar): AvatarState {
|
serializeAvatar(av: SimAvatar): AvatarState {
|
||||||
|
const skills: Record<string, { xp: number; level: number }> = {};
|
||||||
|
for (const id of SKILL_IDS) {
|
||||||
|
const s = av.skills[id];
|
||||||
|
if (s) skills[id] = { xp: s.xp, level: s.level };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: av.id, name: av.name, color: av.color,
|
id: av.id, name: av.name, color: av.color,
|
||||||
x: av.x, y: av.y, tx: av.tx, ty: av.ty,
|
x: av.x, y: av.y, tx: av.tx, ty: av.ty,
|
||||||
speed: av.speed, moveStart: av.moveStart, moving: av.moving,
|
speed: av.speed, moveStart: av.moveStart, moving: av.moving,
|
||||||
action: av.action, nodeId: av.nodeId, actionStart: av.actionStart, actionDur: av.actionDur,
|
action: av.action,
|
||||||
level: av.level, xp: av.xp,
|
tool: this.toolForAction(av),
|
||||||
|
nodeId: av.nodeId,
|
||||||
|
actionStart: av.actionStart,
|
||||||
|
actionDur: av.actionDur,
|
||||||
|
skills,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,14 +637,14 @@ export class SimWorld {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
takeDirtyTrees(): NodeState[] {
|
takeDirtyNodes(): NodeState[] {
|
||||||
if (this.dirtyTrees.size === 0) return [];
|
if (this.dirtyNodes.size === 0) return [];
|
||||||
const out: NodeState[] = [];
|
const out: NodeState[] = [];
|
||||||
for (const id of this.dirtyTrees) {
|
for (const id of this.dirtyNodes) {
|
||||||
const t = this.treeById(id);
|
const n = this.nodeById(id);
|
||||||
if (t) out.push({ ...t });
|
if (n) out.push({ ...n });
|
||||||
}
|
}
|
||||||
this.dirtyTrees.clear();
|
this.dirtyNodes.clear();
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,17 +654,25 @@ export class SimWorld {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
serializeTrees(): NodeState[] {
|
serializeNodes(): NodeState[] {
|
||||||
return this.trees.map((t) => ({ ...t }));
|
return this.nodes.map((n) => ({ ...n }));
|
||||||
}
|
}
|
||||||
|
|
||||||
stats(): ViewerStat[] {
|
stats(): ViewerStat[] {
|
||||||
return [...this.avatars.values()]
|
return [...this.avatars.values()]
|
||||||
.map((av) => ({
|
.map((av) => ({
|
||||||
id: av.id, name: av.name, color: av.color,
|
id: av.id,
|
||||||
level: av.level, xp: av.xp, logs: av.logs, action: av.action,
|
name: av.name,
|
||||||
|
color: av.color,
|
||||||
|
skills: Object.fromEntries(
|
||||||
|
SKILL_IDS.filter((id) => av.skills[id]).map((id) => [id, { ...av.skills[id]! }]),
|
||||||
|
),
|
||||||
|
action: av.action,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.xp - a.xp);
|
.sort((a, b) => {
|
||||||
|
const total = (s: ViewerStat) => Object.values(s.skills).reduce((sum, v) => sum + v.xp, 0);
|
||||||
|
return total(b) - total(a);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
takeStatsIfDirty(): ViewerStat[] | null {
|
takeStatsIfDirty(): ViewerStat[] | null {
|
||||||
@@ -366,4 +680,54 @@ export class SimWorld {
|
|||||||
this.statsDirty = false;
|
this.statsDirty = false;
|
||||||
return this.stats();
|
return this.stats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- спавн ----
|
||||||
|
|
||||||
|
private ensureAvatar(cmd: ChatCommand, now: number): SimAvatar {
|
||||||
|
let av = this.avatars.get(cmd.userId);
|
||||||
|
if (!av) {
|
||||||
|
const rec = this.preloaded.get(cmd.userId);
|
||||||
|
const skills = {} as Record<SkillId, SkillSim>;
|
||||||
|
for (const id of SKILL_IDS) {
|
||||||
|
const xp = rec?.skills[id] ?? 0;
|
||||||
|
skills[id] = { xp, level: levelForTotalXp(xp) };
|
||||||
|
}
|
||||||
|
const items = new Map<string, number>(Object.entries(rec?.items ?? {}));
|
||||||
|
for (const item of START_ITEMS) {
|
||||||
|
if (!items.has(item)) items.set(item, 1);
|
||||||
|
}
|
||||||
|
av = {
|
||||||
|
id: cmd.userId,
|
||||||
|
name: cmd.name,
|
||||||
|
color: cmd.color || colorForName(cmd.name),
|
||||||
|
x: WORLD.spawn.x + (Math.random() * 60 - 30),
|
||||||
|
y: WORLD.spawn.y + (Math.random() * 40 - 20),
|
||||||
|
tx: WORLD.spawn.x,
|
||||||
|
ty: WORLD.spawn.y,
|
||||||
|
speed: WALK_SPEED,
|
||||||
|
moveStart: 0,
|
||||||
|
moving: false,
|
||||||
|
action: 'idle',
|
||||||
|
nodeId: null,
|
||||||
|
actionStart: null,
|
||||||
|
actionDur: null,
|
||||||
|
skills,
|
||||||
|
items,
|
||||||
|
lastCommandAt: 0,
|
||||||
|
pendingNodeId: null,
|
||||||
|
pendingRecipeId: null,
|
||||||
|
};
|
||||||
|
this.avatars.set(av.id, av);
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
this.markViewerDirty(av);
|
||||||
|
this.statsDirty = true;
|
||||||
|
this.pendingEvents.push({ k: 'spawn', userId: av.id, name: av.name, color: av.color });
|
||||||
|
} else if (av.name !== cmd.name || av.color !== cmd.color) {
|
||||||
|
av.name = cmd.name;
|
||||||
|
av.color = cmd.color || av.color;
|
||||||
|
this.dirtyAvatars.add(av.id);
|
||||||
|
this.markViewerDirty(av);
|
||||||
|
}
|
||||||
|
return av;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-14
@@ -1,21 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Контент M1: мир, деревья, экономика рубки.
|
* Контент M2: мир-полоса зон (лес | рудник | кузница), ноды, навыки,
|
||||||
* Баланс правится данными здесь, без кода (принцип «контент как данные»).
|
* предметы, инструменты тирами, рецепты. Баланс правится данными здесь.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ZoneDef } from './protocol';
|
import type { ZoneDef } from './protocol';
|
||||||
|
|
||||||
export const WORLD = { w: 1280, h: 720, spawn: { x: 180, y: 520 } } as const;
|
export const WORLD = { w: 2100, h: 720, spawn: { x: 180, y: 520 } } as const;
|
||||||
|
|
||||||
/** M1 — одна зона; в M2 мир станет полосой зон (лес | рудник | река | костёр | кухня). */
|
export const ZONES: ZoneDef[] = [
|
||||||
export const ZONES: ZoneDef[] = [{ id: 'forest', name: 'Лес', x: 0, y: 0, w: 1280, h: 720 }];
|
{ id: 'forest', name: 'Лес', x: 0, y: 0, w: 1000, h: 720 },
|
||||||
|
{ id: 'mine', name: 'Рудник', x: 1000, y: 0, w: 680, h: 720 },
|
||||||
|
{ id: 'forge', name: 'Кузница', x: 1680, y: 0, w: 420, h: 720 },
|
||||||
|
];
|
||||||
|
|
||||||
export interface TreeSpot {
|
// ---- лес ----
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TREE_SPOTS: TreeSpot[] = [
|
export const TREE_SPOTS: { x: number; y: number }[] = [
|
||||||
{ x: 520, y: 320 },
|
{ x: 520, y: 320 },
|
||||||
{ x: 680, y: 430 },
|
{ x: 680, y: 430 },
|
||||||
{ x: 860, y: 250 },
|
{ x: 860, y: 250 },
|
||||||
@@ -25,16 +25,169 @@ export const TREE_SPOTS: TreeSpot[] = [
|
|||||||
{ x: 780, y: 560 },
|
{ x: 780, y: 560 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TREE = { maxHp: 5, respawnMs: 60_000, chopDurMs: 15_000 } as const;
|
export const TREE = { maxHp: 5, respawnMs: 60_000, chopDurMs: 15_000, xp: 10 } as const;
|
||||||
|
|
||||||
|
// ---- рудник ----
|
||||||
|
|
||||||
|
export type OreKind = 'copper' | 'iron';
|
||||||
|
|
||||||
|
export interface RockSpot {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
ore: OreKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROCK_SPOTS: RockSpot[] = [
|
||||||
|
{ x: 1150, y: 320, ore: 'copper' },
|
||||||
|
{ x: 1310, y: 440, ore: 'copper' },
|
||||||
|
{ x: 1470, y: 260, ore: 'copper' },
|
||||||
|
{ x: 1230, y: 540, ore: 'copper' },
|
||||||
|
{ x: 1580, y: 420, ore: 'iron' },
|
||||||
|
{ x: 1390, y: 150, ore: 'iron' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ROCK = { maxHp: 4 } as const;
|
||||||
|
|
||||||
|
export const ORES: Record<
|
||||||
|
OreKind,
|
||||||
|
{ item: string; level: number; xp: number; cycleMs: number; respawnMs: number }
|
||||||
|
> = {
|
||||||
|
copper: { item: 'copper_ore', level: 1, xp: 15, cycleMs: 12_000, respawnMs: 45_000 },
|
||||||
|
iron: { item: 'iron_ore', level: 3, xp: 30, cycleMs: 16_000, respawnMs: 90_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- кузница ----
|
||||||
|
|
||||||
|
/** Точка, куда встаёт аватар для ковки. */
|
||||||
|
export const FORGE_SPOT = { x: 1830, y: 415 } as const;
|
||||||
|
|
||||||
|
export const PROPS = [{ kind: 'anvil', x: 1830, y: 370 }] as const;
|
||||||
|
|
||||||
|
// ---- навыки ----
|
||||||
|
|
||||||
|
export const SKILLS = [
|
||||||
|
{ id: 'woodcutting', title: 'Рубка леса' },
|
||||||
|
{ id: 'mining', title: 'Добыча руды' },
|
||||||
|
{ id: 'smithing', title: 'Кузнечное дело' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SkillId = (typeof SKILLS)[number]['id'];
|
||||||
|
|
||||||
|
export const SKILL_TITLES: Record<string, string> = Object.fromEntries(
|
||||||
|
SKILLS.map((s) => [s.id, s.title]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- предметы и инструменты ----
|
||||||
|
|
||||||
export const XP_PER_CHOP = 10;
|
|
||||||
export const ITEM_LOG = 'log';
|
export const ITEM_LOG = 'log';
|
||||||
export const ITEM_TITLES: Record<string, string> = { [ITEM_LOG]: 'брёвна' };
|
|
||||||
|
export const ITEMS: Record<string, { title: string }> = {
|
||||||
|
log: { title: 'брёвна' },
|
||||||
|
copper_ore: { title: 'медная руда' },
|
||||||
|
iron_ore: { title: 'железная руда' },
|
||||||
|
copper_bar: { title: 'медный слиток' },
|
||||||
|
iron_bar: { title: 'железный слиток' },
|
||||||
|
axe_rusty: { title: 'ржавый топор' },
|
||||||
|
axe_iron: { title: 'железный топор' },
|
||||||
|
pick_rusty: { title: 'ржавая кирка' },
|
||||||
|
pick_iron: { title: 'железная кирка' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function itemTitle(id: string): string {
|
||||||
|
return ITEMS[id]?.title ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выдаются при первом спавне. */
|
||||||
|
export const START_ITEMS = ['axe_rusty', 'pick_rusty'] as const;
|
||||||
|
|
||||||
|
export interface ToolDef {
|
||||||
|
item: string;
|
||||||
|
kind: 'axe' | 'pick';
|
||||||
|
/** Во сколько раз быстрее базовый цикл действия. */
|
||||||
|
multiplier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOOLS: ToolDef[] = [
|
||||||
|
{ item: 'axe_rusty', kind: 'axe', multiplier: 1 },
|
||||||
|
{ item: 'axe_iron', kind: 'axe', multiplier: 2 },
|
||||||
|
{ item: 'pick_rusty', kind: 'pick', multiplier: 1 },
|
||||||
|
{ item: 'pick_iron', kind: 'pick', multiplier: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- рецепты (ковка) ----
|
||||||
|
|
||||||
|
export interface RecipeDef {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
output: { item: string; qty: number };
|
||||||
|
inputs: { item: string; qty: number }[];
|
||||||
|
cycleMs: number;
|
||||||
|
/** Требование к навыку кузнечного дела. */
|
||||||
|
level: number;
|
||||||
|
xp: number;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECIPES: RecipeDef[] = [
|
||||||
|
{
|
||||||
|
id: 'copper_bar',
|
||||||
|
title: 'медный слиток',
|
||||||
|
output: { item: 'copper_bar', qty: 1 },
|
||||||
|
inputs: [{ item: 'copper_ore', qty: 2 }],
|
||||||
|
cycleMs: 8_000,
|
||||||
|
level: 1,
|
||||||
|
xp: 15,
|
||||||
|
aliases: ['медный слиток', 'медь'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'iron_bar',
|
||||||
|
title: 'железный слиток',
|
||||||
|
output: { item: 'iron_bar', qty: 1 },
|
||||||
|
inputs: [{ item: 'iron_ore', qty: 2 }],
|
||||||
|
cycleMs: 10_000,
|
||||||
|
level: 2,
|
||||||
|
xp: 25,
|
||||||
|
aliases: ['железный слиток', 'железо'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'axe_iron',
|
||||||
|
title: 'железный топор',
|
||||||
|
output: { item: 'axe_iron', qty: 1 },
|
||||||
|
inputs: [
|
||||||
|
{ item: 'copper_bar', qty: 1 },
|
||||||
|
{ item: 'iron_bar', qty: 2 },
|
||||||
|
],
|
||||||
|
cycleMs: 15_000,
|
||||||
|
level: 3,
|
||||||
|
xp: 50,
|
||||||
|
aliases: ['железный топор', 'топор'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pick_iron',
|
||||||
|
title: 'железная кирка',
|
||||||
|
output: { item: 'pick_iron', qty: 1 },
|
||||||
|
inputs: [
|
||||||
|
{ item: 'copper_bar', qty: 1 },
|
||||||
|
{ item: 'iron_bar', qty: 2 },
|
||||||
|
],
|
||||||
|
cycleMs: 15_000,
|
||||||
|
level: 3,
|
||||||
|
xp: 50,
|
||||||
|
aliases: ['железная кирка', 'кирка'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findRecipe(text: string): RecipeDef | undefined {
|
||||||
|
const q = text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
return RECIPES.find((r) => r.id === q || r.title === q || r.aliases.includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- прочее ----
|
||||||
|
|
||||||
export const WALK_SPEED = 110; // px/сек
|
export const WALK_SPEED = 110; // px/сек
|
||||||
export const COMMAND_COOLDOWN_MS = 1200; // антиспам на смену действия
|
export const COMMAND_COOLDOWN_MS = 1200; // антиспам на смену действия
|
||||||
|
|
||||||
/** OSRS/Melvor-подобная кривая: суммарный XP, нужный для уровня. */
|
/** OSRS/Melvor-подобная кривая: суммарный XP, нужный для уровня (у каждого навыка своя). */
|
||||||
export function totalXpForLevel(level: number): number {
|
export function totalXpForLevel(level: number): number {
|
||||||
return Math.floor(((level - 1) + 300 * 2 ** ((level - 1) / 7)) / 4);
|
return Math.floor(((level - 1) + 300 * 2 ** ((level - 1) / 7)) / 4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* дельты вместо полных снапшотов, игровые события, серверная камера.
|
* дельты вместо полных снапшотов, игровые события, серверная камера.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = 2;
|
export const PROTOCOL_VERSION = 3;
|
||||||
|
|
||||||
export interface ZoneDef {
|
export interface ZoneDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,14 +19,26 @@ export interface ZoneDef {
|
|||||||
h: number;
|
h: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PropDef {
|
||||||
|
kind: 'anvil';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorldDef {
|
export interface WorldDef {
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
spawn: { x: number; y: number };
|
spawn: { x: number; y: number };
|
||||||
zones: ZoneDef[];
|
zones: ZoneDef[];
|
||||||
|
props: PropDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarAction = 'idle' | 'chop';
|
export type AvatarAction = 'idle' | 'chop' | 'mine' | 'smith';
|
||||||
|
|
||||||
|
export interface SkillState {
|
||||||
|
xp: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Аватар — декларативное состояние: позиция анимируется клиентом
|
* Аватар — декларативное состояние: позиция анимируется клиентом
|
||||||
@@ -46,16 +58,19 @@ export interface AvatarState {
|
|||||||
moveStart: number;
|
moveStart: number;
|
||||||
moving: boolean;
|
moving: boolean;
|
||||||
action: AvatarAction;
|
action: AvatarAction;
|
||||||
|
/** Чем работает в данный момент: item id инструмента, 'hammer' или 'none'. */
|
||||||
|
tool: string;
|
||||||
nodeId: string | null;
|
nodeId: string | null;
|
||||||
actionStart: number | null;
|
actionStart: number | null;
|
||||||
actionDur: number | null;
|
actionDur: number | null;
|
||||||
level: number;
|
skills: Record<string, SkillState>;
|
||||||
xp: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeState {
|
export interface NodeState {
|
||||||
id: string;
|
id: string;
|
||||||
kind: 'tree';
|
kind: 'tree' | 'rock';
|
||||||
|
/** Для руды — какая жила; для дерева отсутствует. */
|
||||||
|
variant?: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
hp: number;
|
hp: number;
|
||||||
@@ -68,9 +83,7 @@ export interface ViewerStat {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
level: number;
|
skills: Record<string, SkillState>;
|
||||||
xp: number;
|
|
||||||
logs: number;
|
|
||||||
action: AvatarAction;
|
action: AvatarAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +98,11 @@ export interface CameraState {
|
|||||||
export type GameEvent =
|
export type GameEvent =
|
||||||
| { k: 'spawn'; userId: string; name: string; color: string }
|
| { k: 'spawn'; userId: string; name: string; color: string }
|
||||||
| { k: 'item'; userId: string; name: string; color: string; item: string; qty: number }
|
| { k: 'item'; userId: string; name: string; color: string; item: string; qty: number }
|
||||||
| { k: 'xp'; userId: string; name: string; color: string; amount: number; total: number }
|
| { k: 'xp'; userId: string; name: string; color: string; skill: string; amount: number; total: number }
|
||||||
| { k: 'levelup'; userId: string; name: string; color: string; level: number }
|
| { k: 'levelup'; userId: string; name: string; color: string; skill: string; level: number }
|
||||||
| { k: 'fell'; userId: string; name: string; color: string; nodeId: string };
|
| { k: 'fell'; userId: string; name: string; color: string; nodeId: string }
|
||||||
|
| { k: 'depleted'; userId: string; name: string; color: string; nodeId: string }
|
||||||
|
| { k: 'blocked'; userId: string; name: string; color: string; reason: string };
|
||||||
|
|
||||||
export interface WelcomeMsg {
|
export interface WelcomeMsg {
|
||||||
t: 'welcome';
|
t: 'welcome';
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
VITE_WS_URL=ws://localhost:3000/ws
|
VITE_WS_URL=ws://localhost:3000/ws
|
||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
|
|||||||
+108
-9
@@ -1,8 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { ITEM_TITLES, levelForTotalXp, type GameEvent, type ViewerStat } from '@idle/shared';
|
import {
|
||||||
|
SKILL_TITLES,
|
||||||
|
itemTitle,
|
||||||
|
type GameEvent,
|
||||||
|
type SkillState,
|
||||||
|
type ViewerStat,
|
||||||
|
} from '@idle/shared';
|
||||||
import { mountWorld, type WorldHandle } from '@idle/render';
|
import { mountWorld, type WorldHandle } from '@idle/render';
|
||||||
import { connectWorld, type NetStatus } from './net';
|
import { apiUrl, connectWorld, type NetStatus } from './net';
|
||||||
|
|
||||||
const STATUS_TEXT: Record<NetStatus, string> = {
|
const STATUS_TEXT: Record<NetStatus, string> = {
|
||||||
connecting: 'подключение…',
|
connecting: 'подключение…',
|
||||||
@@ -10,33 +16,77 @@
|
|||||||
reconnecting: 'переподключение…',
|
reconnecting: 'переподключение…',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACTION_EMOJI: Record<string, string> = {
|
||||||
|
chop: '🌲',
|
||||||
|
mine: '⛏️',
|
||||||
|
smith: '🔨',
|
||||||
|
idle: '💤',
|
||||||
|
};
|
||||||
|
|
||||||
interface FeedItem {
|
interface FeedItem {
|
||||||
id: number;
|
id: number;
|
||||||
color: string;
|
color: string;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InvData {
|
||||||
|
found: boolean;
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
skills?: Record<string, SkillState>;
|
||||||
|
items?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
function eventText(e: GameEvent): string {
|
function eventText(e: GameEvent): string {
|
||||||
switch (e.k) {
|
switch (e.k) {
|
||||||
case 'spawn':
|
case 'spawn':
|
||||||
return `${e.name} пришёл(ла) в мир`;
|
return `${e.name} пришёл(ла) в мир`;
|
||||||
case 'item':
|
case 'item':
|
||||||
return `${e.name}: +${e.qty} ${ITEM_TITLES[e.item] ?? e.item}`;
|
return `${e.name}: +${e.qty} ${itemTitle(e.item)}`;
|
||||||
case 'xp':
|
case 'xp':
|
||||||
return `${e.name}: +${e.amount} XP · ур.${levelForTotalXp(e.total)}`;
|
return `${e.name}: +${e.amount} XP ${SKILL_TITLES[e.skill] ?? e.skill}`;
|
||||||
case 'levelup':
|
case 'levelup':
|
||||||
return `⭐ ${e.name}: уровень ${e.level}!`;
|
return `⭐ ${e.name}: ${SKILL_TITLES[e.skill] ?? e.skill} — уровень ${e.level}!`;
|
||||||
case 'fell':
|
case 'fell':
|
||||||
return `${e.name} свалил(а) дерево!`;
|
return `${e.name} свалил(ла) дерево!`;
|
||||||
|
case 'depleted':
|
||||||
|
return `${e.name} выработал(ла) жилу`;
|
||||||
|
case 'blocked':
|
||||||
|
return `${e.name}: ${e.reason}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function totalXp(s: ViewerStat): number {
|
||||||
|
return Object.values(s.skills).reduce((sum, v) => sum + v.xp, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxLevel(s: ViewerStat): number {
|
||||||
|
return Math.max(1, ...Object.values(s.skills).map((v) => v.level));
|
||||||
|
}
|
||||||
|
|
||||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||||
let status = $state<NetStatus>('connecting');
|
let status = $state<NetStatus>('connecting');
|
||||||
let stats = $state<ViewerStat[]>([]);
|
let stats = $state<ViewerStat[]>([]);
|
||||||
let feed = $state<FeedItem[]>([]);
|
let feed = $state<FeedItem[]>([]);
|
||||||
let feedId = 0;
|
let feedId = 0;
|
||||||
|
|
||||||
|
let invName = $state('');
|
||||||
|
let inv = $state<InvData | null>(null);
|
||||||
|
let invSearched = $state(false);
|
||||||
|
|
||||||
|
async function lookupInventory(e: SubmitEvent): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = invName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
invSearched = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(apiUrl(`/api/inventory?name=${encodeURIComponent(name)}`));
|
||||||
|
inv = (await res.json()) as InvData;
|
||||||
|
} catch {
|
||||||
|
inv = { found: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let world: WorldHandle | undefined;
|
let world: WorldHandle | undefined;
|
||||||
let disposeNet: (() => void) | undefined;
|
let disposeNet: (() => void) | undefined;
|
||||||
@@ -80,20 +130,42 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Топ зрителей</h2>
|
<h2>Топ зрителей</h2>
|
||||||
{#if stats.length === 0}
|
{#if stats.length === 0}
|
||||||
<p class="muted">пока никто не написал !рубить</p>
|
<p class="muted">пока никто не написал команду</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#each stats as s (s.id)}
|
{#each stats as s (s.id)}
|
||||||
<li>
|
<li>
|
||||||
<span class="dot" style:background={s.color}></span>
|
<span class="dot" style:background={s.color}></span>
|
||||||
<span class="name" style:color={s.color}>{s.name}</span>
|
<span class="name" style:color={s.color}>{s.name}</span>
|
||||||
<span class="meta">ур.{s.level} · {s.xp} XP · {s.logs} 🪵 {s.action === 'chop' ? '🌲' : '💤'}</span>
|
<span class="meta">ур.{maxLevel(s)} · {totalXp(s)} XP {ACTION_EMOJI[s.action] ?? ''}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Инвентарь</h2>
|
||||||
|
<form onsubmit={lookupInventory}>
|
||||||
|
<input bind:value={invName} placeholder="ник зрителя" aria-label="ник зрителя" />
|
||||||
|
<button type="submit">показать</button>
|
||||||
|
</form>
|
||||||
|
{#if inv && inv.found}
|
||||||
|
<ul class="inv">
|
||||||
|
{#each Object.entries(inv.skills ?? {}) as [id, s] (id)}
|
||||||
|
<li><span class="k">{SKILL_TITLES[id] ?? id}</span><span class="meta">ур.{s.level} · {s.xp} XP</span></li>
|
||||||
|
{/each}
|
||||||
|
{#each Object.entries(inv.items ?? {}) as [id, qty] (id)}
|
||||||
|
{#if qty > 0}
|
||||||
|
<li><span class="k">{itemTitle(id)}</span><span class="meta">×{qty}</span></li>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if invSearched && inv && !inv.found}
|
||||||
|
<p class="muted">не найден — пусть напишет любую команду в чате</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>События</h2>
|
<h2>События</h2>
|
||||||
{#if feed.length === 0}
|
{#if feed.length === 0}
|
||||||
@@ -148,7 +220,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
aside {
|
aside {
|
||||||
width: 320px;
|
width: 340px;
|
||||||
border-left: 1px solid #2a3242;
|
border-left: 1px solid #2a3242;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -186,6 +258,33 @@
|
|||||||
color: #8a99ab;
|
color: #8a99ab;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
background: #232b38;
|
||||||
|
border: 1px solid #2a3242;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #dfe7f0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #2f3a4c;
|
||||||
|
color: #dfe7f0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.inv .k {
|
||||||
|
color: #c7d3e0;
|
||||||
}
|
}
|
||||||
.feed li {
|
.feed li {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export function wsUrl(): string {
|
|||||||
return `${scheme}://${location.host}/ws`;
|
return `${scheme}://${location.host}/ws`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** База HTTP API (в dev сервер на другом порту, в проде — тот же origin). */
|
||||||
|
export function apiUrl(path: string): string {
|
||||||
|
const base = import.meta.env.VITE_API_URL;
|
||||||
|
if (typeof base === 'string' && base.length > 0) return base + path;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectHandlers {
|
export interface ConnectHandlers {
|
||||||
onStatus: (status: NetStatus) => void;
|
onStatus: (status: NetStatus) => void;
|
||||||
onWelcome: (msg: WelcomeMsg) => void;
|
onWelcome: (msg: WelcomeMsg) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user