From 6273f2849c492166b242e927349d516af7b76300 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 5 Sep 2026 17:24:44 +0500 Subject: [PATCH] feat: add ore and blacksmith --- packages/overlay/src/App.svelte | 2 +- packages/render/src/avatar.ts | 65 ++- packages/render/src/index.ts | 1 + packages/render/src/rock.ts | 48 ++ packages/render/src/world.ts | 116 +++-- packages/server/smoke.mjs | 123 +++-- packages/server/src/index.ts | 71 ++- packages/server/src/net/camera.ts | 44 +- packages/server/src/persist/db.ts | 132 ++++-- packages/server/src/sim/world.ts | 722 ++++++++++++++++++++++-------- packages/shared/src/content.ts | 181 +++++++- packages/shared/src/protocol.ts | 37 +- packages/web/.env.development | 1 + packages/web/src/App.svelte | 117 ++++- packages/web/src/net.ts | 7 + 15 files changed, 1333 insertions(+), 334 deletions(-) create mode 100644 packages/render/src/rock.ts diff --git a/packages/overlay/src/App.svelte b/packages/overlay/src/App.svelte index 164002c..0ba5f82 100644 --- a/packages/overlay/src/App.svelte +++ b/packages/overlay/src/App.svelte @@ -42,7 +42,7 @@
{STATUS_TEXT[status]}
{#if status === 'online'} -
!рубить — валить лес  ·  !стоп — отдых
+
!рубить — лес  ·  !копать — рудник  ·  !ковать слиток/топор/кирку  ·  !стоп
{/if} diff --git a/packages/render/src/avatar.ts b/packages/render/src/avatar.ts index 58fde3e..48c3644 100644 --- a/packages/render/src/avatar.ts +++ b/packages/render/src/avatar.ts @@ -6,6 +6,9 @@ const LEG_COLOR = 0x3b4657; const CHIP_COLOR = 0xc79b5b; const BAR_BG = 0x10131a; const BAR_FG = 0x9be870; +const HANDLE = 0x8a6238; +const STEEL = 0xb8c4d0; +const RUSTY = 0x9a6a3f; interface Chip { g: Graphics; @@ -14,18 +17,32 @@ interface Chip { born: number; } +function makeTool(tool: string): Graphics { + const g = new Graphics().rect(-1, -12, 2, 12).fill(HANDLE); + if (tool === 'axe_rusty' || tool === 'axe_iron') { + g.poly([-1, -12, 7, -12, 7, -7, -1, -8]).fill(tool === 'axe_iron' ? STEEL : RUSTY); + } else if (tool === 'pick_rusty' || tool === 'pick_iron') { + const head = tool === 'pick_iron' ? STEEL : RUSTY; + g.poly([-1, -11, 8, -15, 8, -12, -1, -8]).fill(head); + g.poly([-1, -11, -8, -15, -8, -12, -1, -8]).fill(head); + } else if (tool === 'hammer') { + g.rect(-4, -15, 8, 4).fill(0x55606c); + } + return g; +} + /** - * Аватар M1: процедурный человечек (код вместо спрайтов — ноль лицензионных - * рисков). С M2 сюда придут текстуры из паков; состав (тело + ник + прогресс) - * сохранится. Прогресс рубки клиент считает сам из actionStart/actionDur — - * серверу не нужно слать его каждый тик. + * Аватар M2: процедурный человечек, в руках — текущий инструмент + * (axe/pick тирами или молот у наковальни). Прогресс цикла клиент считает сам + * из actionStart/actionDur. */ export class AvatarView { readonly container = new Container(); private readonly rig = new Container(); private readonly legL: Graphics; private readonly legR: Graphics; - private readonly tool: Graphics; + private readonly toolHolder = new Container(); + private readonly tools = new Map(); private readonly label: Text; private readonly bar: Graphics; private chips: Chip[] = []; @@ -33,6 +50,7 @@ export class AvatarView { private lastChopPhase = 0; private prevNow = 0; private lastLabelText = ''; + private lastTool = ''; constructor(av: AvatarState) { const shadow = new Graphics().ellipse(0, 1, 9, 3.5).fill({ color: 0x000000, alpha: 0.3 }); @@ -42,13 +60,13 @@ export class AvatarView { this.legR.position.set(3, -6); const body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color); const head = new Graphics().circle(0, -23, 6.5).fill(SKIN); - // топор: точка вращения — плечо; покачивается только при рубке - this.tool = new Graphics() - .rect(-1, -12, 2, 12) - .fill(0x8a6238) - .poly([-1, -12, 7, -12, 7, -7, -1, -8]) - .fill(0xb8c4d0); - this.tool.position.set(6, -14); + this.toolHolder.position.set(6, -14); + for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer']) { + const g = makeTool(tool); + g.visible = false; + this.tools.set(tool, g); + this.toolHolder.addChild(g); + } this.label = new Text({ text: '', style: { @@ -63,16 +81,16 @@ export class AvatarView { this.label.y = -42; this.bar = new Graphics(); - this.rig.addChild(shadow, this.legL, this.legR, body, head, this.tool); + this.rig.addChild(shadow, this.legL, this.legR, body, head, this.toolHolder); this.container.addChild(this.rig, this.bar, this.label); this.container.position.set(av.x, av.y); this.applyLabel(av); - this.tool.visible = false; } applyLabel(av: AvatarState): void { - const text = `${av.name} [${av.level}]`; + const maxLevel = Math.max(1, ...Object.values(av.skills).map((s) => s.level)); + const text = `${av.name} [${maxLevel}]`; if (text !== this.lastLabelText) { this.lastLabelText = text; this.label.text = text; @@ -105,19 +123,24 @@ export class AvatarView { this.container.position.set(x, y); this.rig.scale.x = this.face; + if (av.tool !== this.lastTool) { + this.lastTool = av.tool; + for (const [id, g] of this.tools) g.visible = id === av.tool; + } + const working = av.action === 'chop' || av.action === 'mine' || av.action === 'smith'; + this.toolHolder.visible = working && av.tool !== 'none'; + const t = now / 1000; if (av.moving) { const swing = Math.sin(t * 12); this.legL.rotation = swing * 0.5; this.legR.rotation = -swing * 0.5; this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5; - this.tool.visible = false; this.bar.visible = false; - } else if (av.action === 'chop' && av.actionStart !== null && av.actionDur) { - const p = (((now - av.actionStart) / av.actionDur) % 1 + 1) % 1; + } else if (working && av.actionStart !== null && av.actionDur) { + const p = ((((now - av.actionStart) / av.actionDur) % 1) + 1) % 1; const swing = Math.sin((Math.min(p, 0.35) / 0.35) * Math.PI); - this.tool.visible = true; - this.tool.rotation = -1.0 + swing * 1.7; + this.toolHolder.rotation = -1.0 + swing * 1.7; this.rig.y = -swing * 1.5; this.legL.rotation = 0; this.legR.rotation = 0; @@ -132,7 +155,7 @@ export class AvatarView { this.rig.y = Math.sin(t * 2) * 0.8; this.legL.rotation = 0; this.legR.rotation = 0; - this.tool.visible = false; + this.toolHolder.rotation = 0; this.bar.visible = false; this.lastChopPhase = 0; } diff --git a/packages/render/src/index.ts b/packages/render/src/index.ts index 278eb5d..a9ad164 100644 --- a/packages/render/src/index.ts +++ b/packages/render/src/index.ts @@ -1,3 +1,4 @@ export * from './avatar'; +export * from './rock'; export * from './tree'; export * from './world'; diff --git a/packages/render/src/rock.ts b/packages/render/src/rock.ts new file mode 100644 index 0000000..d12c6d0 --- /dev/null +++ b/packages/render/src/rock.ts @@ -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 = { 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); + } + } +} diff --git a/packages/render/src/world.ts b/packages/render/src/world.ts index aa41196..111d857 100644 --- a/packages/render/src/world.ts +++ b/packages/render/src/world.ts @@ -1,4 +1,4 @@ -import { Application, Container, Graphics } from 'pixi.js'; +import { Application, Container, Graphics, Text } from 'pixi.js'; import type { AvatarState, CameraState, @@ -8,8 +8,10 @@ import type { SnapshotMsg, ViewerStat, WelcomeMsg, + ZoneDef, } from '@idle/shared'; import { AvatarView } from './avatar'; +import { RockView } from './rock'; import { TreeView } from './tree'; export type WorldStatus = 'connecting' | 'online' | 'reconnecting'; @@ -30,9 +32,25 @@ export interface WorldHandle { destroy(): void; } -const GROUND = 0x4f8f56; -const GROUND_PATCH = 0x46824d; -const GROUND_DOT = 0x5aa061; +interface ZonePalette { + base: number; + patch: number; + dot: number; +} + +const ZONE_PALETTES: Record = { + forest: { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 }, + mine: { base: 0x7d6b52, patch: 0x6e5d46, dot: 0x8d7a60 }, + forge: { base: 0x5f5f68, patch: 0x55555e, dot: 0x6d6d78 }, +}; + +const GROUND_FALLBACK: ZonePalette = { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 }; + +function hashStr(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return h; +} function mulberry32(seed: number): () => number { let a = seed >>> 0; @@ -45,18 +63,27 @@ function mulberry32(seed: number): () => number { }; } -function drawGround(w: number, h: number, spawn: { x: number; y: number }): Graphics { +function drawGround(w: number, h: number, spawn: { x: number; y: number }, zones: ZoneDef[]): Graphics { const g = new Graphics(); - g.rect(0, 0, w, h).fill(GROUND); - const rng = mulberry32(42); - for (let i = 0; i < 40; i++) { - g.ellipse(rng() * w, rng() * h, 15 + rng() * 45, 10 + rng() * 30).fill({ - color: GROUND_PATCH, - alpha: 0.5, - }); - } - for (let i = 0; i < 90; i++) { - g.circle(rng() * w, rng() * h, 1 + rng() * 1.5).fill({ color: GROUND_DOT, alpha: 0.7 }); + for (const zone of zones) { + const p = ZONE_PALETTES[zone.id] ?? GROUND_FALLBACK; + const rng = mulberry32(hashStr(zone.id)); + g.rect(zone.x, zone.y, zone.w, zone.h).fill(p.base); + for (let i = 0; i < 26; i++) { + g + .ellipse( + zone.x + rng() * zone.w, + zone.y + rng() * zone.h, + 15 + rng() * 45, + 10 + rng() * 30, + ) + .fill({ color: p.patch, alpha: 0.5 }); + } + for (let i = 0; i < 60; i++) { + g + .circle(zone.x + rng() * zone.w, zone.y + rng() * zone.h, 1 + rng() * 1.5) + .fill({ color: p.dot, alpha: 0.7 }); + } } // полянка спавна g.ellipse(spawn.x, spawn.y, 46, 26).fill({ color: 0x6b7280, alpha: 0.35 }); @@ -64,8 +91,41 @@ function drawGround(w: number, h: number, spawn: { x: number; y: number }): Grap return g; } +/** Наковальня с горном — рисуется по пропсам из протокола. */ +function drawAnvil(x: number, y: number): Container { + const c = new Container(); + c.position.set(x, y); + // горн позади + const furnace = new Graphics() + .rect(-58, -34, 34, 36, ) + .fill(0x3a3a40) + .rect(-54, -26, 26, 20) + .fill(0x26262c) + .circle(-41, -16, 6) + .fill({ color: 0xd86a2a, alpha: 0.9 }) + .circle(-41, -16, 3) + .fill({ color: 0xffc46b, alpha: 0.9 }); + // сама наковальня + const anvil = new Graphics() + .rect(-7, -8, 14, 8) + .fill(0x2f333b) + .rect(-9, -13, 18, 5) + .fill(0x4a4f58) + .poly([9, -13, 17, -11, 9, -8]) + .fill(0x4a4f58); + const label = new Text({ + text: 'Кузница', + style: { fontFamily: 'monospace', fontSize: 12, fontWeight: 'bold', fill: 0xd8dee6, stroke: { color: 0x10131a, width: 3 } }, + }); + label.anchor.set(0.5, 1); + label.y = -40; + c.addChild(furnace, anvil, label); + c.zIndex = y; + return c; +} + /** - * Мир на канвасе: фон, ноды, аватары и серверная камера-видоискатель. + * Мир на канвасе: зоны, ноды, аватары и серверная камера-видоискатель. * Состояние приходит из протокола; движение и прогресс дорисовываются локально. */ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise { @@ -87,8 +147,6 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} worldLayer.addChild(entityLayer); app.stage.addChild(worldLayer); - let groundW = 0; - let clockOffset = 0; // serverNow - Date.now() let camTarget: CameraState | null = null; const camCur = { x: 0, y: 0, w: 900, h: 560 }; @@ -96,7 +154,7 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} const avatars = new Map(); const avatarViews = new Map(); const nodes = new Map(); - const treeViews = new Map(); + const nodeViews = new Map(); const serverNow = (): number => Date.now() + clockOffset; @@ -113,10 +171,10 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} function syncNode(st: NodeState): void { nodes.set(st.id, st); - let view = treeViews.get(st.id); + let view = nodeViews.get(st.id); if (!view) { - view = new TreeView(st); - treeViews.set(st.id, view); + view = st.kind === 'tree' ? new TreeView(st) : new RockView(st); + nodeViews.set(st.id, view); entityLayer.addChild(view.container); } view.apply(st); @@ -129,17 +187,17 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} avatarViews.delete(id); } } - for (const [id, view] of treeViews) { + for (const [id, view] of nodeViews) { if (!nodes.has(id)) { view.container.destroy(); - treeViews.delete(id); + nodeViews.delete(id); } } } function mergeCamera(c: CameraState): void { camTarget = c; - // при первом кадре прыгаем сразу, дальше — плавно в тикере + // при смене зоны прыгаем к новой раскладке мгновенно, дальше — плавный lerp if (camCur.w !== c.w || camCur.h !== c.h) { camCur.x = c.x; camCur.y = c.y; @@ -177,11 +235,13 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} return { setWelcome(w) { clockOffset = w.serverNow - Date.now(); - if (groundW !== w.world.w) { - groundW = w.world.w; - const ground = drawGround(w.world.w, w.world.h, w.world.spawn); + if (entityLayer.children.length === 0 && w.world.zones.length > 0) { + const ground = drawGround(w.world.w, w.world.h, w.world.spawn, w.world.zones); ground.zIndex = -1000; entityLayer.addChild(ground); + for (const prop of w.world.props) { + if (prop.kind === 'anvil') entityLayer.addChild(drawAnvil(prop.x, prop.y)); + } } }, applySnapshot(snap) { diff --git a/packages/server/smoke.mjs b/packages/server/smoke.mjs index 41a8a90..5210564 100644 --- a/packages/server/smoke.mjs +++ b/packages/server/smoke.mjs @@ -1,17 +1,61 @@ -/* Дымовой тест M1: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:. +/* Дымовой тест M2: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:. `pnpm --filter @idle/server smoke` */ import WebSocket from 'ws'; const BASE = 'http://localhost:3000'; 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) { 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)))); 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 + полный снапшот const first = []; await new Promise((resolve, reject) => { @@ -26,37 +70,56 @@ await new Promise((resolve, reject) => { setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000); }); const [welcome, snap] = first; -console.log('welcome:', JSON.stringify({ t: welcome.t, v: welcome.v, tickMs: welcome.tickMs })); -if (welcome.t !== 'welcome' || welcome.v !== 2) throw new Error(`bad welcome: ${JSON.stringify(welcome)}`); -if (snap.t !== 'snapshot' || snap.nodes.length !== 7) throw new Error(`bad snapshot: ${snap.t}, nodes=${snap.nodes?.length}`); -console.log(`snapshot: деревьев=${snap.nodes.length}, зрителей=${snap.avatars.length}`); +console.log('welcome: v' + welcome.v, '| деревьев+жил:', snap.nodes.length, '| пропсов:', welcome.world.props.length); +if (welcome.t !== 'welcome' || welcome.v !== 3) throw new Error('bad welcome'); +if (snap.nodes.length !== 13 || snap.nodes.filter((n) => n.kind === 'rock').length !== 6) { + throw new Error('bad snapshot nodes'); +} +if (!welcome.world.props.some((p) => p.kind === 'anvil')) throw new Error('no anvil prop'); -// 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'); +// 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. ждём дельту: аватар «Тестер» должен заспавниться и пойти к дереву -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); - } - }); - 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)'); +// 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. неверная версия протокола — 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 ws = new WebSocket(WS); ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 999 }))); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8a059ad..d25bce2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,9 +6,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import sirv from 'sirv'; import { PROTOCOL_VERSION, + PROPS, + SKILL_TITLES, WORLD, ZONES, colorForName, + levelForTotalXp, type DeltaMsg, type GameEvent, type SnapshotMsg, @@ -80,7 +83,13 @@ const world: WorldSource = { v: PROTOCOL_VERSION, tickMs: cfg.tickMs, 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 { @@ -90,7 +99,7 @@ const world: WorldSource = { serverNow: Date.now(), camera: camera.state, avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)), - nodes: sim.serializeTrees(), + nodes: sim.serializeNodes(), stats: sim.stats(), }; }, @@ -102,7 +111,7 @@ const gateway = new WsGateway(world); function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null { const avatars = sim.takeDirtyAvatars(); - const nodes = sim.takeDirtyTrees(); + const nodes = sim.takeDirtyNodes(); const stats = sim.takeStatsIfDirty(); if (avatars.length === 0 && nodes.length === 0 && !stats && events.length === 0 && !camChanged) { return null; @@ -119,13 +128,13 @@ function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null { setInterval(() => { const now = Date.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 ev of events) { 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; } + 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) { if (!m.middleware) continue; const base = m.prefix.slice(0, -1); diff --git a/packages/server/src/net/camera.ts b/packages/server/src/net/camera.ts index e8661d6..cf7a44b 100644 --- a/packages/server/src/net/camera.ts +++ b/packages/server/src/net/camera.ts @@ -4,26 +4,48 @@ const CAM_W = 900; const CAM_H = 560; const DRIFT_X = 60; const DRIFT_Y = 30; +const DWELL_MS = 45_000; /** * Камера стрима авторитарна на сервере — все клиенты видят одно и то же. - * M1: одна зона, камера медленно дрейфует вокруг её центра. - * M2+: автотур по зонам с активностью (стоянка ~45 сек, плавные переезды). + * Автотур: стоянка на зоне ~45 сек; зона с активностью приоритетна; + * без активности — круговой обход + медленный дрейф. + * Плавность переезда даёт клиентский lerp по прямоугольнику камеры. */ export class CameraController { state: CameraState; private readonly seed = Math.random() * 100; + private zoneIdx = 0; + private dwellUntil = 0; constructor(private readonly zones: ZoneDef[] = ZONES) { const first = this.zones[0]; - this.state = first - ? this.forZone(first, 0) - : { x: 0, y: 0, w: CAM_W, h: CAM_H }; + this.state = first ? this.forZone(first, 0) : { x: 0, y: 0, w: CAM_W, h: CAM_H }; } - /** Возвращает true, если состояние изменилось (его стоит разослать). */ - tick(nowMs: number): boolean { - const zone = this.zones[0]; + /** activity — количество аватаров в каждой зоне (порядок ZONES). */ + tick(nowMs: number, activity: number[]): boolean { + 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; const next = this.forZone(zone, nowMs); if ( @@ -39,8 +61,10 @@ export class CameraController { private forZone(z: ZoneDef, nowMs: number): CameraState { const w = Math.min(CAM_W, z.w); const h = Math.min(CAM_H, z.h); - const cx = z.x + z.w / 2 + Math.sin(nowMs / 9000 + this.seed) * DRIFT_X; - const cy = z.y + z.h / 2 + Math.cos(nowMs / 11000 + this.seed) * DRIFT_Y; + const rangeX = Math.max(0, z.w - w) / 2; + 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 }; } } diff --git a/packages/server/src/persist/db.ts b/packages/server/src/persist/db.ts index b7c54bf..4c27ec1 100644 --- a/packages/server/src/persist/db.ts +++ b/packages/server/src/persist/db.ts @@ -1,7 +1,21 @@ import { mkdirSync } from 'node:fs'; import path from 'node:path'; 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; + /** item id -> количество */ + items: Record; +} + +const SCHEMA_VERSION = 2; /** * Тонкий слой persist на встроенном node:sqlite — без нативных сборок и @@ -17,37 +31,103 @@ export class Db { } this.db = new DatabaseSync(dbPath); this.db.exec('PRAGMA journal_mode = WAL'); - this.db.exec(` - CREATE TABLE IF NOT EXISTS viewers ( - channel_id TEXT NOT NULL, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT NOT NULL, - xp INTEGER NOT NULL DEFAULT 0, - logs INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL, - PRIMARY KEY (channel_id, user_id) - ) - `); + + 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(` + 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, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL, + updated_at INTEGER NOT NULL, + 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[] { - const rows = this.db - .prepare('SELECT user_id, name, color, xp, logs FROM viewers WHERE channel_id = ?') - .all(channelId) as Array<{ user_id: string; name: string; color: string; xp: number; logs: number }>; - return rows.map((r) => ({ userId: r.user_id, name: r.name, color: r.color, xp: r.xp, logs: r.logs })); + const viewers = this.db + .prepare('SELECT user_id, name, color FROM viewers WHERE channel_id = ?') + .all(channelId) as Array<{ user_id: string; name: string; color: string }>; + + 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(); + 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 { - this.db - .prepare( - `INSERT INTO viewers (channel_id, user_id, name, color, xp, logs, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(channel_id, user_id) DO UPDATE SET - name = excluded.name, color = excluded.color, xp = excluded.xp, - logs = excluded.logs, updated_at = excluded.updated_at`, - ) - .run(channelId, v.userId, v.name, v.color, v.xp, v.logs, Date.now()); + this.db.exec('BEGIN'); + try { + this.db + .prepare( + `INSERT INTO viewers (channel_id, user_id, name, color, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(channel_id, user_id) DO UPDATE SET + name = excluded.name, color = excluded.color, updated_at = excluded.updated_at`, + ) + .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 { diff --git a/packages/server/src/sim/world.ts b/packages/server/src/sim/world.ts index 73446b9..5195046 100644 --- a/packages/server/src/sim/world.ts +++ b/packages/server/src/sim/world.ts @@ -1,17 +1,31 @@ import { COMMAND_COOLDOWN_MS, + FORGE_SPOT, ITEM_LOG, + ORES, + RECIPES, + ROCK_SPOTS, + ROCK, + SKILLS, + START_ITEMS, + TOOLS, TREE, TREE_SPOTS, WALK_SPEED, WORLD, - XP_PER_CHOP, + ZONES, colorForName, + findRecipe, + itemTitle, levelForTotalXp, type AvatarAction, type AvatarState, type GameEvent, type NodeState, + type OreKind, + type RecipeDef, + type SkillId, + type ToolDef, type ViewerStat, } from '@idle/shared'; @@ -28,50 +42,90 @@ export interface ViewerRecord { userId: string; name: string; color: string; - xp: number; - logs: number; + /** skill id -> суммарный xp */ + skills: Record; + /** item id -> количество */ + items: Record; } -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; + items: Map; lastCommandAt: number; pendingNodeId: string | null; - logs: number; + pendingRecipeId: string | null; } -type SimTree = NodeState; +interface SimNode extends NodeState {} 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 SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id); + function dist(ax: number, ay: number, bx: number, by: number): number { return Math.hypot(bx - ax, by - ay); } /** - * Симуляционный слой M1: plain TS за узким интерфейсом (десятки сущностей). - * Решение об ECS (bitecs 0.4 / koota) отложено до M2 — см. PLAN.md. - * + * Симуляционный слой M2: plain TS за узким интерфейсом (решение об ECS — в M3). * Правила: * - команды обрабатываются сразу при приходе, не ждут тика; - * - аватар продолжает действие, пока не придёт другая команда или !стоп; - * - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам. + * - аватар продолжает действие, пока не придёт другая команда или !стоп + * (ковка — пока хватает материалов); + * - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам; + * - инвентарь и навыки — мета-слой, вне ECS. */ export class SimWorld { readonly avatars = new Map(); - readonly trees: SimTree[] = TREE_SPOTS.map((t, i) => ({ - id: `tree-${i + 1}`, - kind: 'tree', - x: t.x, - y: t.y, - hp: TREE.maxHp, - maxHp: TREE.maxHp, - respawnAt: null, - })); + readonly nodes: SimNode[] = [ + ...TREE_SPOTS.map((t, i): SimNode => ({ + id: `tree-${i + 1}`, + kind: 'tree', + x: t.x, + y: t.y, + hp: TREE.maxHp, + maxHp: TREE.maxHp, + 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(); - private readonly dirtyTrees = new Set(); + private readonly dirtyNodes = new Set(); private readonly dirtyViewers = new Map(); private readonly preloaded = new Map(); + private readonly pendingEvents: GameEvent[] = []; private statsDirty = true; constructor( @@ -84,19 +138,23 @@ export class SimWorld { handleCommand(cmd: ChatCommand, now = Date.now()): void { if (cmd.channelId !== this.channelId) 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); + 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); } tick(now: number): GameEvent[] { - const events: GameEvent[] = []; + const events = this.pendingEvents.splice(0); - for (const tree of this.trees) { - if (tree.respawnAt !== null && now >= tree.respawnAt) { - tree.respawnAt = null; - tree.hp = tree.maxHp; - this.dirtyTrees.add(tree.id); + for (const node of this.nodes) { + if (node.respawnAt !== null && now >= node.respawnAt) { + node.respawnAt = null; + node.hp = node.maxHp; + this.dirtyNodes.add(node.id); } } @@ -108,28 +166,14 @@ export class SimWorld { av.y = av.ty; av.moving = false; this.dirtyAvatars.add(av.id); - const tree = av.pendingNodeId ? this.treeById(av.pendingNodeId) : undefined; - if (tree && tree.respawnAt === null) { - this.beginChop(av, tree, now); - } else { - av.pendingNodeId = null; - } + this.onArrival(av, now, events); } } - if (av.action === 'chop' && av.actionStart !== null) { - const dur = av.actionDur ?? TREE.chopDurMs; - if (now >= av.actionStart + dur) { - const tree = av.nodeId ? this.treeById(av.nodeId) : undefined; - 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); - } + if (av.action !== 'idle' && av.actionStart !== null) { + const dur = av.actionDur ?? 0; + if (dur > 0 && now >= av.actionStart + dur) { + this.completeCycle(av, now, events); } } } @@ -137,25 +181,104 @@ export class SimWorld { 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 { const av = this.ensureAvatar(cmd, now); - if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return; - av.lastCommandAt = now; + if (this.onCooldown(av, now)) return; if (av.action === 'chop' && !av.moving) return; // уже рубит - const tree = this.nearestAvailableTree(av); - if (!tree) return; // всё вырублено и не отросло — просто ждём - + const tree = this.nearestAvailable(av, (n) => n.kind === 'tree'); + if (!tree) { + this.pendingEvents.push(this.blocked(av, 'все деревья ещё не отросли')); + return; + } this.walkTo(av, tree.x, tree.y + 26, now); 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 { const av = this.ensureAvatar(cmd, now); - if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return; - av.lastCommandAt = now; + if (this.onCooldown(av, now)) return; if (av.moving) { const p = this.currentPos(av, now); @@ -164,6 +287,7 @@ export class SimWorld { av.moving = false; } av.pendingNodeId = null; + av.pendingRecipeId = null; if (av.action !== 'idle') { av.action = 'idle'; av.nodeId = null; @@ -174,42 +298,239 @@ export class SimWorld { this.dirtyAvatars.add(av.id); } - private ensureAvatar(cmd: ChatCommand, now: number): SimAvatar { - let av = this.avatars.get(cmd.userId); - if (!av) { - const rec = this.preloaded.get(cmd.userId); - 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, - 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); + // ---- действия ---- + + private beginGather(av: SimAvatar, node: SimNode, now: number): void { + if (node.kind === 'tree') { + const tool = this.bestTool(av, 'axe'); + av.action = 'chop'; + av.actionDur = Math.round(TREE.chopDurMs / tool.multiplier); + } else { + const tool = this.bestTool(av, 'pick'); + av.action = 'mine'; + const ore = ORES[(node.variant ?? 'copper') as OreKind]; + av.actionDur = Math.round(ore.cycleMs / tool.multiplier); } - 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 { @@ -229,94 +550,79 @@ export class SimWorld { 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 { - 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 = {}; + for (const id of SKILL_IDS) skills[id] = av.skills[id]?.xp ?? 0; + const items: Record = {}; + 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; items: Record } | 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 = {}; + 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 { + const skills: Record = {}; + for (const id of SKILL_IDS) { + const s = av.skills[id]; + if (s) skills[id] = { xp: s.xp, level: s.level }; + } return { id: av.id, name: av.name, color: av.color, x: av.x, y: av.y, tx: av.tx, ty: av.ty, speed: av.speed, moveStart: av.moveStart, moving: av.moving, - action: av.action, nodeId: av.nodeId, actionStart: av.actionStart, actionDur: av.actionDur, - level: av.level, xp: av.xp, + action: av.action, + tool: this.toolForAction(av), + nodeId: av.nodeId, + actionStart: av.actionStart, + actionDur: av.actionDur, + skills, }; } @@ -331,14 +637,14 @@ export class SimWorld { return out; } - takeDirtyTrees(): NodeState[] { - if (this.dirtyTrees.size === 0) return []; + takeDirtyNodes(): NodeState[] { + if (this.dirtyNodes.size === 0) return []; const out: NodeState[] = []; - for (const id of this.dirtyTrees) { - const t = this.treeById(id); - if (t) out.push({ ...t }); + for (const id of this.dirtyNodes) { + const n = this.nodeById(id); + if (n) out.push({ ...n }); } - this.dirtyTrees.clear(); + this.dirtyNodes.clear(); return out; } @@ -348,17 +654,25 @@ export class SimWorld { return out; } - serializeTrees(): NodeState[] { - return this.trees.map((t) => ({ ...t })); + serializeNodes(): NodeState[] { + return this.nodes.map((n) => ({ ...n })); } stats(): ViewerStat[] { return [...this.avatars.values()] .map((av) => ({ - id: av.id, name: av.name, color: av.color, - level: av.level, xp: av.xp, logs: av.logs, action: av.action, + id: av.id, + 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 { @@ -366,4 +680,54 @@ export class SimWorld { this.statsDirty = false; 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; + for (const id of SKILL_IDS) { + const xp = rec?.skills[id] ?? 0; + skills[id] = { xp, level: levelForTotalXp(xp) }; + } + const items = new Map(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; + } } diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 3f852b3..4556d1f 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -1,21 +1,21 @@ /** - * Контент M1: мир, деревья, экономика рубки. - * Баланс правится данными здесь, без кода (принцип «контент как данные»). + * Контент M2: мир-полоса зон (лес | рудник | кузница), ноды, навыки, + * предметы, инструменты тирами, рецепты. Баланс правится данными здесь. */ 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[] = [{ id: 'forest', name: 'Лес', x: 0, y: 0, w: 1280, h: 720 }]; +export const ZONES: ZoneDef[] = [ + { 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: 680, y: 430 }, { x: 860, y: 250 }, @@ -25,16 +25,169 @@ export const TREE_SPOTS: TreeSpot[] = [ { 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 = Object.fromEntries( + SKILLS.map((s) => [s.id, s.title]), +); + +// ---- предметы и инструменты ---- -export const XP_PER_CHOP = 10; export const ITEM_LOG = 'log'; -export const ITEM_TITLES: Record = { [ITEM_LOG]: 'брёвна' }; + +export const ITEMS: Record = { + 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 COMMAND_COOLDOWN_MS = 1200; // антиспам на смену действия -/** OSRS/Melvor-подобная кривая: суммарный XP, нужный для уровня. */ +/** OSRS/Melvor-подобная кривая: суммарный XP, нужный для уровня (у каждого навыка своя). */ export function totalXpForLevel(level: number): number { return Math.floor(((level - 1) + 300 * 2 ** ((level - 1) / 7)) / 4); } diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 241c537..c6542e1 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -8,7 +8,7 @@ * дельты вместо полных снапшотов, игровые события, серверная камера. */ -export const PROTOCOL_VERSION = 2; +export const PROTOCOL_VERSION = 3; export interface ZoneDef { id: string; @@ -19,14 +19,26 @@ export interface ZoneDef { h: number; } +export interface PropDef { + kind: 'anvil'; + x: number; + y: number; +} + export interface WorldDef { w: number; h: number; spawn: { x: number; y: number }; 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; moving: boolean; action: AvatarAction; + /** Чем работает в данный момент: item id инструмента, 'hammer' или 'none'. */ + tool: string; nodeId: string | null; actionStart: number | null; actionDur: number | null; - level: number; - xp: number; + skills: Record; } export interface NodeState { id: string; - kind: 'tree'; + kind: 'tree' | 'rock'; + /** Для руды — какая жила; для дерева отсутствует. */ + variant?: string; x: number; y: number; hp: number; @@ -68,9 +83,7 @@ export interface ViewerStat { id: string; name: string; color: string; - level: number; - xp: number; - logs: number; + skills: Record; action: AvatarAction; } @@ -85,9 +98,11 @@ export interface CameraState { export type GameEvent = | { k: 'spawn'; userId: string; name: string; color: string } | { 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: 'levelup'; userId: string; name: string; color: string; level: number } - | { k: 'fell'; userId: string; name: string; color: string; nodeId: string }; + | { k: 'xp'; userId: string; name: string; color: string; skill: string; amount: number; total: number } + | { k: 'levelup'; userId: string; name: string; color: string; skill: string; level: number } + | { 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 { t: 'welcome'; diff --git a/packages/web/.env.development b/packages/web/.env.development index 82be29d..f1a404a 100644 --- a/packages/web/.env.development +++ b/packages/web/.env.development @@ -1 +1,2 @@ VITE_WS_URL=ws://localhost:3000/ws +VITE_API_URL=http://localhost:3000 diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 47a199c..987098a 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,8 +1,14 @@