From 32527ec2edb32f598de098724a60fac132995b39 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 5 Sep 2026 17:46:15 +0500 Subject: [PATCH] feat: fishing and cooking --- packages/overlay/src/App.svelte | 2 +- packages/render/src/avatar.ts | 40 ++++-- packages/render/src/fish.ts | 43 +++++++ packages/render/src/index.ts | 1 + packages/render/src/world.ts | 110 ++++++++++++++--- packages/server/smoke.mjs | 106 ++++++++++------ packages/server/src/sim/world.ts | 206 +++++++++++++++++++++++++------ packages/shared/src/content.ts | 105 ++++++++++++++-- packages/shared/src/protocol.ts | 13 +- packages/web/src/App.svelte | 5 + 10 files changed, 518 insertions(+), 113 deletions(-) create mode 100644 packages/render/src/fish.ts diff --git a/packages/overlay/src/App.svelte b/packages/overlay/src/App.svelte index 0ba5f82..05f09e7 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 48c3644..885875b 100644 --- a/packages/render/src/avatar.ts +++ b/packages/render/src/avatar.ts @@ -9,6 +9,7 @@ const BAR_FG = 0x9be870; const HANDLE = 0x8a6238; const STEEL = 0xb8c4d0; const RUSTY = 0x9a6a3f; +const AURA = 0xffb347; interface Chip { g: Graphics; @@ -27,14 +28,19 @@ function makeTool(tool: string): Graphics { g.poly([-1, -11, -8, -15, -8, -12, -1, -8]).fill(head); } else if (tool === 'hammer') { g.rect(-4, -15, 8, 4).fill(0x55606c); + } else if (tool === 'spoon') { + g.ellipse(0, -13, 2.5, 3.5).fill(RUSTY); + } else if (tool === 'rod') { + g.rect(-0.5, -20, 1.5, 20).fill(0x7d5a38); + g.circle(0.25, -20, 1.5).fill(STEEL); } return g; } /** - * Аватар M2: процедурный человечек, в руках — текущий инструмент - * (axe/pick тирами или молот у наковальни). Прогресс цикла клиент считает сам - * из actionStart/actionDur. + * Аватар M3: процедурный человечек с инструментом по действию (топор/кирка + * тирами, молот, ложка, удочка), аурой баффа отдыха и прогресс-баром цикла. + * Прогресс клиент считает сам из actionStart/actionDur. */ export class AvatarView { readonly container = new Container(); @@ -43,6 +49,7 @@ export class AvatarView { private readonly legR: Graphics; private readonly toolHolder = new Container(); private readonly tools = new Map(); + private readonly aura = new Graphics(); private readonly label: Text; private readonly bar: Graphics; private chips: Chip[] = []; @@ -61,7 +68,7 @@ export class AvatarView { 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.toolHolder.position.set(6, -14); - for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer']) { + for (const tool of ['axe_rusty', 'axe_iron', 'pick_rusty', 'pick_iron', 'hammer', 'spoon', 'rod']) { const g = makeTool(tool); g.visible = false; this.tools.set(tool, g); @@ -80,9 +87,11 @@ export class AvatarView { this.label.anchor.set(0.5, 1); this.label.y = -42; this.bar = new Graphics(); + this.aura.ellipse(0, 0, 15, 6.5).fill({ color: AURA, alpha: 0.3 }); + this.aura.visible = false; 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.aura, this.rig, this.bar, this.label); this.container.position.set(av.x, av.y); this.applyLabel(av); @@ -127,8 +136,13 @@ export class AvatarView { 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'; + const working = + av.action === 'chop' || av.action === 'mine' || av.action === 'smith' || av.action === 'cook' || av.action === 'fish'; this.toolHolder.visible = working && av.tool !== 'none'; + this.aura.visible = av.restStacks > 0; + if (this.aura.visible) { + this.aura.alpha = 0.5 + av.restStacks * 0.2 + Math.sin(now / 300) * 0.08; + } const t = now / 1000; if (av.moving) { @@ -136,7 +150,16 @@ export class AvatarView { this.legL.rotation = swing * 0.5; this.legR.rotation = -swing * 0.5; this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5; + this.toolHolder.rotation = 0; this.bar.visible = false; + } else if (av.action === 'rest') { + // отдых: спокойное дыхание + this.rig.y = Math.sin(t * 1.5) * 0.6; + this.legL.rotation = 0; + this.legR.rotation = 0; + this.toolHolder.rotation = 0; + this.bar.visible = false; + this.lastChopPhase = 0; } 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); @@ -148,10 +171,11 @@ export class AvatarView { this.bar.clear(); this.bar.roundRect(-12, -36, 24, 3.5, 1.5).fill({ color: BAR_BG, alpha: 0.6 }); this.bar.roundRect(-12, -36, 24 * p, 3.5, 1.5).fill(BAR_FG); - if (this.lastChopPhase < 0.33 && p >= 0.33) this.spawnChips(); + const chipsy = av.action === 'chop' || av.action === 'mine'; + if (chipsy && this.lastChopPhase < 0.33 && p >= 0.33) this.spawnChips(); this.lastChopPhase = p < this.lastChopPhase ? 0 : p; } else { - // отдых: лёгкое «дыхание» + // отдых в сторону: лёгкое «дыхание» this.rig.y = Math.sin(t * 2) * 0.8; this.legL.rotation = 0; this.legR.rotation = 0; diff --git a/packages/render/src/fish.ts b/packages/render/src/fish.ts new file mode 100644 index 0000000..6de986c --- /dev/null +++ b/packages/render/src/fish.ts @@ -0,0 +1,43 @@ +import { Container, Graphics } from 'pixi.js'; +import type { NodeState } from '@idle/shared'; + +const WOOD = 0x8a6238; +const WOOD_DARK = 0x6e4d2c; +const RIPPLE = 0xbfe3ff; + +/** Рыбное место: мостки в воду и круги на воде; выработанное место — тихое. */ +export class FishSpotView { + readonly container = new Container(); + private readonly ripple = new Graphics(); + private lastDown = false; + + constructor(private st: NodeState) { + const pier = new Graphics() + .rect(-14, -2, 28, 6) + .fill(WOOD) + .rect(-14, 4, 28, 3) + .fill(WOOD_DARK) + .rect(-12, 4, 3, 8) + .fill(WOOD_DARK) + .rect(9, 4, 3, 8) + .fill(WOOD_DARK); + this.container.addChild(pier, this.ripple); + 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) return; + this.lastDown = down; + this.ripple.clear(); + if (down) return; + this.ripple.circle(0, 8, 6).stroke({ color: RIPPLE, width: 1, alpha: 0.7 }); + this.ripple.circle(0, 8, 11).stroke({ color: RIPPLE, width: 1, alpha: 0.4 }); + } +} diff --git a/packages/render/src/index.ts b/packages/render/src/index.ts index a9ad164..73ef5a1 100644 --- a/packages/render/src/index.ts +++ b/packages/render/src/index.ts @@ -1,4 +1,5 @@ export * from './avatar'; +export * from './fish'; export * from './rock'; export * from './tree'; export * from './world'; diff --git a/packages/render/src/world.ts b/packages/render/src/world.ts index 111d857..d8c7049 100644 --- a/packages/render/src/world.ts +++ b/packages/render/src/world.ts @@ -10,7 +10,9 @@ import type { WelcomeMsg, ZoneDef, } from '@idle/shared'; +import { RIVER_WATER_Y } from '@idle/shared'; import { AvatarView } from './avatar'; +import { FishSpotView } from './fish'; import { RockView } from './rock'; import { TreeView } from './tree'; @@ -38,10 +40,15 @@ interface ZonePalette { dot: number; } +const WATER = 0x4a7fae; +const WAVE = 0x9cc7e8; + 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 }, + river: { base: 0x5c8a52, patch: 0x527c49, dot: 0x6b9c60 }, + kitchen: { base: 0x6d5c4b, patch: 0x5f4f40, dot: 0x7d6b58 }, }; const GROUND_FALLBACK: ZonePalette = { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 }; @@ -68,20 +75,27 @@ function drawGround(w: number, h: number, spawn: { x: number; y: number }, zones for (const zone of zones) { const p = ZONE_PALETTES[zone.id] ?? GROUND_FALLBACK; const rng = mulberry32(hashStr(zone.id)); + const isRiver = zone.id === 'river'; + const topY = isRiver ? RIVER_WATER_Y : zone.y; + const landH = zone.h - (topY - zone.y); g.rect(zone.x, zone.y, zone.w, zone.h).fill(p.base); + if (isRiver) { + g.rect(zone.x, zone.y, zone.w, RIVER_WATER_Y).fill(WATER); + for (let i = 0; i < 24; i++) { + g + .circle(zone.x + rng() * zone.w, zone.y + rng() * RIVER_WATER_Y, 1 + rng() * 1.5) + .fill({ color: WAVE, alpha: 0.5 }); + } + g.rect(zone.x, topY, zone.w, 5).fill({ color: 0x8a7a5a, alpha: 0.8 }); + } 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, - ) + .ellipse(zone.x + rng() * zone.w, topY + rng() * landH, 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) + .circle(zone.x + rng() * zone.w, topY + rng() * landH, 1 + rng() * 1.5) .fill({ color: p.dot, alpha: 0.7 }); } } @@ -91,13 +105,16 @@ function drawGround(w: number, h: number, spawn: { x: number; y: number }, zones return g; } -/** Наковальня с горном — рисуется по пропсам из протокола. */ -function drawAnvil(x: number, y: number): Container { +interface Flicker { + g: Graphics; + base: number; + phase: number; +} + +function drawAnvil(): Container { const c = new Container(); - c.position.set(x, y); - // горн позади const furnace = new Graphics() - .rect(-58, -34, 34, 36, ) + .rect(-58, -34, 34, 36) .fill(0x3a3a40) .rect(-54, -26, 26, 20) .fill(0x26262c) @@ -105,7 +122,6 @@ function drawAnvil(x: number, y: number): Container { .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) @@ -120,12 +136,56 @@ function drawAnvil(x: number, y: number): Container { label.anchor.set(0.5, 1); label.y = -40; c.addChild(furnace, anvil, label); - c.zIndex = y; + return c; +} + +function drawCampfire(flickers: Flicker[]): Container { + const c = new Container(); + const stones = new Graphics(); + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + stones.circle(Math.cos(a) * 16, Math.sin(a) * 7, 4).fill(0x6d6d78); + } + const logA = new Graphics().rect(-10, -2, 20, 4).fill(0x6e4d2c); + logA.rotation = 0.35; + const logB = new Graphics().rect(-10, -2, 20, 4).fill(0x7d5a38); + logB.rotation = -0.35; + const glow = new Graphics().ellipse(0, -8, 24, 14).fill({ color: 0xff9a3b, alpha: 0.3 }); + const flame1 = new Graphics().poly([0, -26, 8, -8, -8, -8]).fill(0xff8c3b); + const flame2 = new Graphics().poly([0, -18, 5, -6, -5, -6]).fill(0xffd166); + flickers.push( + { g: glow, base: 0.75, phase: 0.8 }, + { g: flame1, base: 0.95, phase: 0 }, + { g: flame2, base: 0.9, phase: 1.7 }, + ); + c.addChild(glow, stones, logA, logB, flame1, flame2); + return c; +} + +function drawStove(flickers: Flicker[]): Container { + const c = new Container(); + const body = new Graphics() + .rect(-24, -46, 48, 46) + .fill(0x55555e) + .rect(-27, -50, 54, 6) + .fill(0x6d6d78) + .rect(-16, -68, 10, 22) + .fill(0x4a4f58) + .rect(-10, -22, 20, 16) + .fill(0x26262c); + const fire = new Graphics().ellipse(0, -14, 8, 5).fill({ color: 0xff8c3b, alpha: 0.9 }); + const pot = new Graphics() + .circle(0, -52, 9) + .fill(0x2f333b) + .circle(0, -52, 6) + .fill(0x4a4f58); + flickers.push({ g: fire, base: 0.85, phase: 2.6 }); + c.addChild(body, fire, pot); return c; } /** - * Мир на канвасе: зоны, ноды, аватары и серверная камера-видоискатель. + * Мир на канвасе: зоны, ноды, пропсы, аватары и серверная камера-видоискатель. * Состояние приходит из протокола; движение и прогресс дорисовываются локально. */ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise { @@ -147,6 +207,8 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} worldLayer.addChild(entityLayer); app.stage.addChild(worldLayer); + const flickers: Flicker[] = []; + let clockOffset = 0; // serverNow - Date.now() let camTarget: CameraState | null = null; const camCur = { x: 0, y: 0, w: 900, h: 560 }; @@ -154,7 +216,7 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} const avatars = new Map(); const avatarViews = new Map(); const nodes = new Map(); - const nodeViews = new Map(); + const nodeViews = new Map(); const serverNow = (): number => Date.now() + clockOffset; @@ -173,7 +235,8 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} nodes.set(st.id, st); let view = nodeViews.get(st.id); if (!view) { - view = st.kind === 'tree' ? new TreeView(st) : new RockView(st); + view = + st.kind === 'tree' ? new TreeView(st) : st.kind === 'rock' ? new RockView(st) : new FishSpotView(st); nodeViews.set(st.id, view); entityLayer.addChild(view.container); } @@ -208,6 +271,9 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} app.ticker.add((ticker) => { const now = serverNow(); + for (const f of flickers) { + f.g.alpha = f.base + Math.sin(now / 150 + f.phase) * 0.15; + } for (const [id, view] of avatarViews) { const av = avatars.get(id); if (av) { @@ -240,7 +306,15 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {} ground.zIndex = -1000; entityLayer.addChild(ground); for (const prop of w.world.props) { - if (prop.kind === 'anvil') entityLayer.addChild(drawAnvil(prop.x, prop.y)); + const view = + prop.kind === 'anvil' + ? drawAnvil() + : prop.kind === 'campfire' + ? drawCampfire(flickers) + : drawStove(flickers); + view.position.set(prop.x, prop.y); + view.zIndex = prop.y; + entityLayer.addChild(view); } } }, diff --git a/packages/server/smoke.mjs b/packages/server/smoke.mjs index 5210564..6e5a996 100644 --- a/packages/server/smoke.mjs +++ b/packages/server/smoke.mjs @@ -1,4 +1,4 @@ -/* Дымовой тест M2: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:. +/* Дымовой тест M3: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:. `pnpm --filter @idle/server smoke` */ import WebSocket from 'ws'; @@ -20,7 +20,7 @@ const COOLDOWN = 1400; function connect(onMsg) { const ws = new WebSocket(WS); - ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 3 }))); + ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 4 }))); ws.on('message', (d) => onMsg(JSON.parse(String(d)))); return ws; } @@ -39,6 +39,11 @@ async function inventory(name) { return res.json(); } +async function state() { + const res = await fetch(`${BASE}/dev/state`); + return res.json(); +} + /** Ждём дельту, удовлетворяющую условию. */ function waitForDelta(pred, timeoutMs = 9000) { return new Promise((resolve, reject) => { @@ -70,54 +75,77 @@ await new Promise((resolve, reject) => { setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000); }); const [welcome, snap] = first; -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'); +const fishSpots = snap.nodes.filter((n) => n.kind === 'fish').length; +console.log( + 'welcome: v' + welcome.v, + '| нод:', snap.nodes.length, + '| рыбных мест:', fishSpots, + '| пропсов:', welcome.world.props.map((p) => p.kind).join(','), +); +if (welcome.t !== 'welcome' || welcome.v !== 4) throw new Error('bad welcome'); +if (snap.nodes.length !== 16 || fishSpots !== 3) throw new Error('bad snapshot nodes'); +const propKinds = welcome.world.props.map((p) => p.kind).sort().join(','); +if (propKinds !== 'anvil,campfire,stove') throw new Error(`bad props: ${propKinds}`); // 2. спавн через !рубить await dev('/dev/command', { name: 'Тестер', text: '!рубить' }); -const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving)); -console.log('spawn: движется к дереву ok'); +await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving)); +console.log('spawn ok'); -// 3. телепорт к медной жиле и !копать -await dev('/dev/tp', { name: 'Тестер', x: 1140, y: 346 }); +// 3. рыбалка +await dev('/dev/tp', { name: 'Тестер', x: 2250, y: 375 }); await sleep(COOLDOWN); -await dev('/dev/command', { name: 'Тестер', text: '!копать' }); -const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'mine')); -const miner = d2.avatars.find((a) => a.name === 'Тестер'); -console.log('mine: action=mine tool=' + miner.tool); -if (miner.action !== 'mine' || miner.tool !== 'pick_rusty') throw new Error('bad mine state'); - -// 4. ждём добытую руду в инвентаре -await sleep(13_500); +await dev('/dev/command', { name: 'Тестер', text: '!рыбачить' }); +const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'fish')); +const fisher = d1.avatars.find((a) => a.name === 'Тестер'); +console.log('fish: action=' + fisher.action, 'tool=' + fisher.tool); +if (fisher.action !== 'fish' || fisher.tool !== 'rod') throw new Error('bad fish state'); +await sleep(16_000); 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'); +console.log('inventory: raw_fish=' + (inv1.items?.raw_fish ?? 0), 'fishing xp=' + (inv1.skills?.fishing?.xp ?? 0)); +if ((inv1.items?.raw_fish ?? 0) < 1 || (inv1.skills?.fishing?.xp ?? 0) < 15) throw new Error('fishing did not yield'); -// 5. ковка медного слитка у наковальни -await dev('/dev/give', { name: 'Тестер', item: 'copper_ore', qty: 2 }); -await dev('/dev/tp', { name: 'Тестер', x: 1830, y: 415 }); +// 4. готовка жареной рыбы +await dev('/dev/give', { name: 'Тестер', item: 'raw_fish', qty: 2 }); +await dev('/dev/tp', { name: 'Тестер', x: 2990, y: 425 }); 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); +await dev('/dev/command', { name: 'Тестер', text: '!готовить жареную рыбу' }); +const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'cook')); +const cook = d2.avatars.find((a) => a.name === 'Тестер'); +console.log('cook: action=' + cook.action, 'tool=' + cook.tool); +if (cook.action !== 'cook' || cook.tool !== 'spoon') throw new Error('bad cook state'); +await sleep(11_000); 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'); +console.log('inventory: cooked_fish=' + (inv2.items?.cooked_fish ?? 0), 'cooking xp=' + (inv2.skills?.cooking?.xp ?? 0)); +if ((inv2.items?.cooked_fish ?? 0) < 1 || (inv2.skills?.cooking?.xp ?? 0) < 20) throw new Error('cooking did not yield'); -// 6. недостижимый рецепт → blocked (нет железных слитков и низкий уровень ковки) +// 5. отдых без рыбы у второго зрителя → blocked +await dev('/dev/command', { name: 'Тестер2', text: '!рубить' }); +await sleep(300); +await dev('/dev/tp', { name: 'Тестер2', x: 1960, y: 515 }); 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'); +await dev('/dev/command', { name: 'Тестер2', text: '!отдых' }); +const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked' && e.name === 'Тестер2'), 5000); +console.log('blocked:', d3.events.find((e) => e.k === 'blocked').reason); + +// 6. отдых Тестера с рыбой: цикл 60 сек → стак баффа +await dev('/dev/tp', { name: 'Тестер', x: 1960, y: 515 }); +await sleep(COOLDOWN); +await dev('/dev/command', { name: 'Тестер', text: '!отдых' }); +const d4 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'rest')); +console.log('rest: начат'); +await sleep(62_000); +const st = await state(); +const tester = st.avatars.find((a) => a.name === 'Тестер'); +const inv3 = await inventory('Тестер'); +console.log( + 'after rest: restStacks=' + tester.restStacks, + 'cooked_fish=' + (inv3.items?.cooked_fish ?? 0), + 'action=' + tester.action, +); +if (tester.restStacks < 1) throw new Error('rest buff not granted'); +if ((inv3.items?.cooked_fish ?? 0) !== 0) throw new Error('cooked fish not consumed'); +if (tester.action !== 'idle') throw new Error('rest should end when out of fish'); // 7. неверная версия протокола — close 4001 const closeCode = await new Promise((resolve) => { diff --git a/packages/server/src/sim/world.ts b/packages/server/src/sim/world.ts index 5195046..5704db1 100644 --- a/packages/server/src/sim/world.ts +++ b/packages/server/src/sim/world.ts @@ -1,9 +1,14 @@ import { COMMAND_COOLDOWN_MS, + FISH, + FISH_SPOTS, FORGE_SPOT, ITEM_LOG, + KITCHEN_SPOT, ORES, RECIPES, + REST, + REST_SPOT, ROCK_SPOTS, ROCK, SKILLS, @@ -73,14 +78,22 @@ interface SimAvatar { lastCommandAt: number; pendingNodeId: string | null; pendingRecipeId: string | null; + pendingRest: boolean; + /** Бафф отдыха: стаки и до какого времени. */ + restStacks: number; + restUntil: number; + /** Дробные прибавки добычи от баффа копятся до целого предмета. */ + bonusAcc: number; } -interface SimNode extends NodeState {} +type SimNode = NodeState; const CHOP_ALIASES = new Set(['рубить', 'chop']); const MINE_ALIASES = new Set(['копать', 'mine']); -const SMITH_ALIASES = new Set(['ковать', 'smith']); +const FISH_ALIASES = new Set(['рыбачить', 'fish']); +const CRAFT_ALIASES = new Set(['ковать', 'готовить', 'craft']); const STOP_ALIASES = new Set(['стоп', 'stop']); +const REST_ALIASES = new Set(['отдых', 'rest']); const SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id); @@ -89,13 +102,13 @@ function dist(ax: number, ay: number, bx: number, by: number): number { } /** - * Симуляционный слой M2: plain TS за узким интерфейсом (решение об ECS — в M3). + * Симуляционный слой M3: plain TS за узким интерфейсом (решение об ECS — в M4). * Правила: * - команды обрабатываются сразу при приходе, не ждут тика; * - аватар продолжает действие, пока не придёт другая команда или !стоп - * (ковка — пока хватает материалов); + * (ковка — пока хватает материалов, отдых — пока есть рыба и есть место стакам); * - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам; - * - инвентарь и навыки — мета-слой, вне ECS. + * - инвентарь, навыки и баффы — мета-слой, вне ECS. */ export class SimWorld { readonly avatars = new Map(); @@ -119,6 +132,16 @@ export class SimWorld { maxHp: ROCK.maxHp, respawnAt: null, })), + ...FISH_SPOTS.map((f, i): SimNode => ({ + id: `fish-${i + 1}`, + kind: 'fish', + variant: 'river', + x: f.x, + y: f.y, + hp: FISH.maxHp, + maxHp: FISH.maxHp, + respawnAt: null, + })), ]; private readonly dirtyAvatars = new Set(); @@ -143,7 +166,9 @@ export class SimWorld { 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 (FISH_ALIASES.has(word)) this.doFish(cmd, now); + else if (CRAFT_ALIASES.has(word)) this.doCraft(cmd, arg, now); + else if (REST_ALIASES.has(word)) this.doRest(cmd, now); else if (STOP_ALIASES.has(word)) this.doStop(cmd, now); } @@ -159,6 +184,12 @@ export class SimWorld { } for (const av of this.avatars.values()) { + if (av.restStacks > 0 && now >= av.restUntil) { + av.restStacks = 0; + av.bonusAcc = 0; + this.dirtyAvatars.add(av.id); + } + if (av.moving) { const travelMs = (dist(av.x, av.y, av.tx, av.ty) / av.speed) * 1000; if (now >= av.moveStart + travelMs) { @@ -193,7 +224,7 @@ export class SimWorld { if (av.pendingRecipeId) { const recipe = RECIPES.find((r) => r.id === av.pendingRecipeId); if (recipe && this.hasInputs(av, recipe)) { - this.beginSmith(av, recipe, now); + this.beginCraft(av, recipe, now); return; } av.pendingRecipeId = null; @@ -205,6 +236,10 @@ export class SimWorld { reason: `материалы закончились: ${this.inputsText(recipe)}`, }); } + if (av.pendingRest) { + av.pendingRest = false; + this.beginRest(av, now); + } } // ---- команды ---- @@ -235,22 +270,34 @@ export class SimWorld { }); if (!rock) { const minReq = Math.min(...ROCK_SPOTS.map((r) => ORES[r.ore].level)); - this.pendingEvents.push( - this.blocked(av, `нужен ${minReq}-й уровень добычи руды`), - ); + 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 { + private doFish(cmd: ChatCommand, now: number): void { + const av = this.ensureAvatar(cmd, now); + if (this.onCooldown(av, now)) return; + if (av.action === 'fish' && !av.moving) return; + + const spot = this.nearestAvailable(av, (n) => n.kind === 'fish'); + if (!spot) { + this.pendingEvents.push(this.blocked(av, 'рыба ещё не подошла к берегу')); + return; + } + this.walkTo(av, spot.x, spot.y + 45, now); + av.pendingNodeId = spot.id; + } + + private doCraft(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, 'что ковать? медный слиток · железный слиток · топор · кирка'), + this.blocked(av, 'что готовим? медный слиток · железо · топор · кирка · жареная рыба'), ); return; } @@ -261,9 +308,10 @@ export class SimWorld { ); return; } - if (av.skills.smithing.level < recipe.level) { + const skill = av.skills[recipe.skill]; + if (!skill || skill.level < recipe.level) { this.pendingEvents.push( - this.blocked(av, `«${recipe.title}» нужен ${recipe.level}-й уровень кузнечного дела`), + this.blocked(av, `«${recipe.title}» нужен ${recipe.level}-й уровень навыка`), ); return; } @@ -272,10 +320,27 @@ export class SimWorld { return; } - this.walkTo(av, FORGE_SPOT.x, FORGE_SPOT.y, now); + const spot = recipe.place === 'kitchen' ? KITCHEN_SPOT : FORGE_SPOT; + this.walkTo(av, spot.x, spot.y, now); av.pendingRecipeId = recipe.id; } + private doRest(cmd: ChatCommand, now: number): void { + const av = this.ensureAvatar(cmd, now); + if (this.onCooldown(av, now)) return; + + if (av.restStacks >= REST.maxStacks && now < av.restUntil) { + this.pendingEvents.push(this.blocked(av, 'отдохнул с запасом — бафф ещё действует')); + return; + } + if ((av.items.get('cooked_fish') ?? 0) < 1) { + this.pendingEvents.push(this.blocked(av, 'нужна жареная рыба: !готовить рыбу')); + return; + } + this.walkTo(av, REST_SPOT.x, REST_SPOT.y, now); + av.pendingRest = true; + } + private doStop(cmd: ChatCommand, now: number): void { const av = this.ensureAvatar(cmd, now); if (this.onCooldown(av, now)) return; @@ -288,6 +353,7 @@ export class SimWorld { } av.pendingNodeId = null; av.pendingRecipeId = null; + av.pendingRest = false; if (av.action !== 'idle') { av.action = 'idle'; av.nodeId = null; @@ -305,23 +371,27 @@ export class SimWorld { const tool = this.bestTool(av, 'axe'); av.action = 'chop'; av.actionDur = Math.round(TREE.chopDurMs / tool.multiplier); - } else { + } else if (node.kind === 'rock') { 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); + } else { + av.action = 'fish'; + av.actionDur = FISH.cycleMs; } av.nodeId = node.id; av.actionStart = now; av.pendingNodeId = null; av.pendingRecipeId = null; + av.pendingRest = false; this.dirtyAvatars.add(av.id); this.statsDirty = true; } - private beginSmith(av: SimAvatar, recipe: RecipeDef, now: number): void { - av.action = 'smith'; - // для ковки nodeId хранит id рецепта — узел-нода у кузницы одна + private beginCraft(av: SimAvatar, recipe: RecipeDef, now: number): void { + av.action = recipe.place === 'kitchen' ? 'cook' : 'smith'; + // nodeId хранит id рецепта для действий у пропсов av.nodeId = recipe.id; av.actionDur = recipe.cycleMs; av.actionStart = now; @@ -330,6 +400,15 @@ export class SimWorld { this.statsDirty = true; } + private beginRest(av: SimAvatar, now: number): void { + av.action = 'rest'; + av.nodeId = null; + av.actionDur = REST.cycleMs; + av.actionStart = now; + 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; @@ -343,10 +422,13 @@ export class SimWorld { 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); + this.gatherYield(av, ITEM_LOG, TREE.xp, 'woodcutting', 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; @@ -360,19 +442,29 @@ export class SimWorld { 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.gatherYield(av, ore.item, ore.xp, 'mining', 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; + } else if (av.action === 'fish') { + const spot = av.nodeId ? this.nodeById(av.nodeId) : undefined; + if (!spot || spot.respawnAt !== null) { + this.goIdle(av); + return; + } + spot.hp -= 1; + this.dirtyNodes.add(spot.id); + if (spot.hp <= 0) { + spot.respawnAt = now + FISH.respawnMs; + events.push({ k: 'depleted', userId: av.id, name: av.name, color: av.color, nodeId: spot.id }); + } + this.gatherYield(av, FISH.item, FISH.xp, 'fishing', events); + this.finishOrContinue(av, now, FISH.cycleMs, () => spot.respawnAt === null); + } else if (av.action === 'smith' || av.action === 'cook') { + const recipe = av.nodeId ? RECIPES.find((r) => r.id === av.nodeId) : undefined; if (!recipe) { this.goIdle(av); return; @@ -387,15 +479,48 @@ export class SimWorld { item: recipe.output.item, qty: recipe.output.qty, }); - this.addXp(av, 'smithing', recipe.xp, events); + this.addXp(av, recipe.skill, recipe.xp, events); this.finishOrContinue(av, now, recipe.cycleMs, () => this.hasInputs(av, recipe), () => { - events.push( - this.blocked(av, `материалы кончились: ${this.inputsText(recipe)}`), - ); + events.push(this.blocked(av, `материалы кончились: ${this.inputsText(recipe)}`)); }); + } else if (av.action === 'rest') { + if ((av.items.get('cooked_fish') ?? 0) < 1) { + this.goIdle(av); + return; + } + this.takeItem(av, 'cooked_fish', 1); + av.restStacks = Math.min(REST.maxStacks, av.restStacks + 1); + av.restUntil = now + REST.buffMs; + events.push({ k: 'rest', userId: av.id, name: av.name, color: av.color, stacks: av.restStacks }); + this.dirtyAvatars.add(av.id); + if (av.restStacks >= REST.maxStacks) { + this.pendingEvents.push(this.blocked(av, 'отдохнул с запасом — бафф на максимум')); + this.finishOrContinue(av, now, REST.cycleMs, () => false); + } else { + this.finishOrContinue(av, now, REST.cycleMs, () => (av.items.get('cooked_fish') ?? 0) >= 1); + } } } + /** Добыча за цикл: предмет + XP, бафф отдыха добавляет дробные прибавки. */ + private gatherYield( + av: SimAvatar, + item: string, + xp: number, + skill: SkillId, + events: GameEvent[], + ): void { + let qty = 1; + av.bonusAcc += av.restStacks * REST.bonusPerStack; + if (av.bonusAcc >= 1) { + qty += Math.floor(av.bonusAcc); + av.bonusAcc %= 1; + } + this.addItem(av, item, qty); + events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item, qty }); + this.addXp(av, skill, xp, events); + } + /** Продолжить цикл того же действия либо уйти в idle (по правилу «работает, пока не скажут»). */ private finishOrContinue( av: SimAvatar, @@ -520,6 +645,8 @@ export class SimWorld { 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'; + if (av.action === 'cook') return 'spoon'; + if (av.action === 'fish') return 'rod'; return 'none'; } @@ -583,7 +710,11 @@ export class SimWorld { return true; } - lookupViewer(name: string): { name: string; color: string; skills: Record; items: Record } | undefined { + 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) { @@ -623,6 +754,7 @@ export class SimWorld { actionStart: av.actionStart, actionDur: av.actionDur, skills, + restStacks: av.restStacks, }; } @@ -716,6 +848,10 @@ export class SimWorld { lastCommandAt: 0, pendingNodeId: null, pendingRecipeId: null, + pendingRest: false, + restStacks: 0, + restUntil: 0, + bonusAcc: 0, }; this.avatars.set(av.id, av); this.dirtyAvatars.add(av.id); diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 4556d1f..0655ac8 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -1,18 +1,24 @@ /** - * Контент M2: мир-полоса зон (лес | рудник | кузница), ноды, навыки, - * предметы, инструменты тирами, рецепты. Баланс правится данными здесь. + * Контент M3: полная полоса зон (лес | рудник | кузница | река | кухня), + * ноды (деревья, жилы, рыбные места), навыки, предметы, инструменты, + * рецепты и отдых у костра. Баланс правится данными здесь. */ import type { ZoneDef } from './protocol'; -export const WORLD = { w: 2100, h: 720, spawn: { x: 180, y: 520 } } as const; +export const WORLD = { w: 3200, h: 720, spawn: { x: 180, y: 520 } } as const; 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 }, + { id: 'river', name: 'Река', x: 2100, y: 0, w: 680, h: 720 }, + { id: 'kitchen', name: 'Кухня', x: 2780, y: 0, w: 420, h: 720 }, ]; +/** Уровень воды в зоне реки: выше — вода, ниже — берег. */ +export const RIVER_WATER_Y = 360; + // ---- лес ---- export const TREE_SPOTS: { x: number; y: number }[] = [ @@ -61,7 +67,50 @@ export const ORES: Record< /** Точка, куда встаёт аватар для ковки. */ export const FORGE_SPOT = { x: 1830, y: 415 } as const; -export const PROPS = [{ kind: 'anvil', x: 1830, y: 370 }] as const; +/** Точка отдыха у костра. */ +export const REST_SPOT = { x: 1960, y: 515 } as const; + +/** Точка ковки на кухне (у печи). */ +export const KITCHEN_SPOT = { x: 2990, y: 425 } as const; + +export const PROPS = [ + { kind: 'anvil', x: 1830, y: 370 }, + { kind: 'campfire', x: 1960, y: 480 }, + { kind: 'stove', x: 2990, y: 380 }, +] as const; + +// ---- река ---- + +export interface FishSpot { + x: number; + y: number; +} + +export const FISH_SPOTS: FishSpot[] = [ + { x: 2250, y: 330 }, + { x: 2450, y: 240 }, + { x: 2650, y: 330 }, +]; + +export const FISH = { + item: 'raw_fish', + level: 1, + xp: 15, + cycleMs: 14_000, + respawnMs: 60_000, + maxHp: 4, +} as const; + +// ---- отдых у костра ---- + +export const REST = { + /** Длительность одного отдыха (план: «+20% добычи за 1 минуту отдыха»). */ + cycleMs: 60_000, + buffMs: 300_000, + maxStacks: 2, + /** Прибавка к добыче за стак (дробные циклы копятся аккумулятором). */ + bonusPerStack: 0.2, +} as const; // ---- навыки ---- @@ -69,6 +118,8 @@ export const SKILLS = [ { id: 'woodcutting', title: 'Рубка леса' }, { id: 'mining', title: 'Добыча руды' }, { id: 'smithing', title: 'Кузнечное дело' }, + { id: 'fishing', title: 'Рыбалка' }, + { id: 'cooking', title: 'Кулинария' }, ] as const; export type SkillId = (typeof SKILLS)[number]['id']; @@ -87,6 +138,8 @@ export const ITEMS: Record = { iron_ore: { title: 'железная руда' }, copper_bar: { title: 'медный слиток' }, iron_bar: { title: 'железный слиток' }, + raw_fish: { title: 'сырая рыба' }, + cooked_fish: { title: 'жареная рыба' }, axe_rusty: { title: 'ржавый топор' }, axe_iron: { title: 'железный топор' }, pick_rusty: { title: 'ржавая кирка' }, @@ -114,7 +167,7 @@ export const TOOLS: ToolDef[] = [ { item: 'pick_iron', kind: 'pick', multiplier: 2 }, ]; -// ---- рецепты (ковка) ---- +// ---- рецепты ---- export interface RecipeDef { id: string; @@ -122,9 +175,12 @@ export interface RecipeDef { output: { item: string; qty: number }; inputs: { item: string; qty: number }[]; cycleMs: number; - /** Требование к навыку кузнечного дела. */ + /** Требование к навыку recipe.skill. */ level: number; xp: number; + /** Какой навык качает рецепт и где готовится. */ + skill: 'smithing' | 'cooking'; + place: 'forge' | 'kitchen'; aliases: string[]; } @@ -137,6 +193,8 @@ export const RECIPES: RecipeDef[] = [ cycleMs: 8_000, level: 1, xp: 15, + skill: 'smithing', + place: 'forge', aliases: ['медный слиток', 'медь'], }, { @@ -147,6 +205,8 @@ export const RECIPES: RecipeDef[] = [ cycleMs: 10_000, level: 2, xp: 25, + skill: 'smithing', + place: 'forge', aliases: ['железный слиток', 'железо'], }, { @@ -160,6 +220,8 @@ export const RECIPES: RecipeDef[] = [ cycleMs: 15_000, level: 3, xp: 50, + skill: 'smithing', + place: 'forge', aliases: ['железный топор', 'топор'], }, { @@ -173,13 +235,42 @@ export const RECIPES: RecipeDef[] = [ cycleMs: 15_000, level: 3, xp: 50, + skill: 'smithing', + place: 'forge', aliases: ['железная кирка', 'кирка'], }, + { + id: 'cooked_fish', + title: 'жареная рыба', + output: { item: 'cooked_fish', qty: 1 }, + inputs: [{ item: 'raw_fish', qty: 2 }], + cycleMs: 10_000, + level: 1, + xp: 20, + skill: 'cooking', + place: 'kitchen', + aliases: ['жареная рыба', 'рыба'], + }, ]; +/** Грубый стем для склонений: «жареную рыбу» → «жар», «рыб». */ +function stem(word: string): string { + return word.slice(0, 3); +} + 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)); + const exact = RECIPES.find((r) => r.id === q || r.title === q || r.aliases.includes(q)); + if (exact) return exact; + // фолбэк для склонений: каждое слово запроса должно попасть в слова рецепта + const qStems = q.split(' ').map(stem); + if (qStems.length === 0) return undefined; + return RECIPES.find((r) => { + const stems = new Set( + [...r.title.split(' '), ...r.aliases.flatMap((a) => a.split(' '))].map(stem), + ); + return qStems.every((s) => stems.has(s)); + }); } // ---- прочее ---- diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index c6542e1..363566b 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -8,7 +8,7 @@ * дельты вместо полных снапшотов, игровые события, серверная камера. */ -export const PROTOCOL_VERSION = 3; +export const PROTOCOL_VERSION = 4; export interface ZoneDef { id: string; @@ -20,7 +20,7 @@ export interface ZoneDef { } export interface PropDef { - kind: 'anvil'; + kind: 'anvil' | 'campfire' | 'stove'; x: number; y: number; } @@ -33,7 +33,7 @@ export interface WorldDef { props: PropDef[]; } -export type AvatarAction = 'idle' | 'chop' | 'mine' | 'smith'; +export type AvatarAction = 'idle' | 'chop' | 'mine' | 'smith' | 'cook' | 'fish' | 'rest'; export interface SkillState { xp: number; @@ -64,12 +64,14 @@ export interface AvatarState { actionStart: number | null; actionDur: number | null; skills: Record; + /** Стаки баффа отдыха (каждый +20% к добыче). */ + restStacks: number; } export interface NodeState { id: string; - kind: 'tree' | 'rock'; - /** Для руды — какая жила; для дерева отсутствует. */ + kind: 'tree' | 'rock' | 'fish'; + /** Для руды — какая жила, для рыбного места — 'river'. */ variant?: string; x: number; y: number; @@ -102,6 +104,7 @@ export type GameEvent = | { 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: 'rest'; userId: string; name: string; color: string; stacks: number } | { k: 'blocked'; userId: string; name: string; color: string; reason: string }; export interface WelcomeMsg { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 987098a..8fae8b7 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -20,6 +20,9 @@ chop: '🌲', mine: '⛏️', smith: '🔨', + cook: '🍳', + fish: '🎣', + rest: '🔥', idle: '💤', }; @@ -51,6 +54,8 @@ return `${e.name} свалил(ла) дерево!`; case 'depleted': return `${e.name} выработал(ла) жилу`; + case 'rest': + return `🔥 ${e.name} отдыхает у костра — бафф ×${e.stacks}`; case 'blocked': return `${e.name}: ${e.reason}`; }