feat: add ore and blacksmith

This commit is contained in:
2026-09-05 17:24:44 +05:00
parent f6c338e1b0
commit 6273f2849c
15 changed files with 1333 additions and 334 deletions
+1 -1
View File
@@ -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">!рубить — валить лес&nbsp;&nbsp;·&nbsp;&nbsp;!стоп — отдых</div> <div class="hint">!рубить — лес&nbsp;&nbsp;·&nbsp;&nbsp;!копать — рудник&nbsp;&nbsp;·&nbsp;&nbsp;!ковать слиток/топор/кирку&nbsp;&nbsp;·&nbsp;&nbsp;!стоп</div>
{/if} {/if}
</div> </div>
+44 -21
View File
@@ -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
View File
@@ -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';
+48
View File
@@ -0,0 +1,48 @@
import { Container, Graphics } from 'pixi.js';
import type { NodeState } from '@idle/shared';
const BODY = 0x8a8f98;
const BODY_DARK = 0x777c86;
const SPECKLE: Record<string, number> = { copper: 0xc77b3f, iron: 0x5a6478 };
/** Жила руды: вкрапления по типу руды, размер тает с hp, выработанная — обломки. */
export class RockView {
readonly container = new Container();
private readonly body = new Graphics();
private lastHp = -1;
private lastDown = false;
constructor(private st: NodeState) {
this.container.addChild(this.body);
this.redraw();
}
apply(st: NodeState): void {
this.st = st;
this.container.zIndex = st.y;
this.redraw();
}
private redraw(): void {
const down = this.st.respawnAt !== null;
if (down === this.lastDown && this.lastHp === this.st.hp) return;
this.lastDown = down;
this.lastHp = this.st.hp;
this.body.clear();
if (down) {
this.body.ellipse(0, 0, 10, 5).fill(BODY_DARK);
this.body.ellipse(7, 2, 5, 3).fill(BODY);
this.body.ellipse(-8, 3, 4, 2.5).fill(BODY);
return;
}
const k = this.st.hp / this.st.maxHp;
const r = 13 + 7 * k;
this.body.ellipse(0, -r * 0.45, r, r * 0.8).fill(BODY);
this.body.ellipse(-r * 0.4, -r * 0.25, r * 0.5, r * 0.4).fill(BODY_DARK);
const spec = SPECKLE[this.st.variant ?? 'copper'] ?? 0xc77b3f;
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2;
this.body.circle(Math.cos(a) * r * 0.45, -r * 0.45 + Math.sin(a) * r * 0.3, 1.6).fill(spec);
}
}
}
+87 -27
View File
@@ -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) {
+93 -30
View File
@@ -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');
}
if (!welcome.world.props.some((p) => p.kind === 'anvil')) throw new Error('no anvil prop');
// 2. команда через dev-эндпоинт // 2. спавн через !рубить
const res = await fetch(`${BASE}/dev/command`, { await dev('/dev/command', { name: 'Тестер', text: '!рубить' });
method: 'POST', const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
headers: { 'content-type': 'application/json' }, console.log('spawn: движется к дереву ok');
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. ждём дельту: аватар «Тестер» должен заспавниться и пойти к дереву // 3. телепорт к медной жиле и !копать
const delta = await new Promise((resolve, reject) => { await dev('/dev/tp', { name: 'Тестер', x: 1140, y: 346 });
const ws = connect((m) => { await sleep(COOLDOWN);
if (m.t === 'delta' && m.avatars?.some((a) => a.name === 'Тестер')) { await dev('/dev/command', { name: 'Тестер', text: '!копать' });
ws.close(); const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'mine'));
resolve(m); 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');
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 // 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 })));
+66 -5
View File
@@ -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);
+34 -10
View File
@@ -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 };
} }
} }
+94 -14
View File
@@ -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
View File
@@ -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.
*
* Правила: * Правила:
* - команды обрабатываются сразу при приходе, не ждут тика; * - команды обрабатываются сразу при приходе, не ждут тика;
* - аватар продолжает действие, пока не придёт другая команда или !стоп; * - аватар продолжает действие, пока не придёт другая команда или !стоп
* - движение декларативно (xtx, speed, moveStart) клиент дорисовывает сам. * (ковка пока хватает материалов);
* - движение декларативно (xtx, 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
View File
@@ -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);
} }
+26 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+7
View File
@@ -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;