feat: fishing and cooking
This commit is contained in:
@@ -42,7 +42,7 @@
|
|||||||
<div class="badge">{STATUS_TEXT[status]}</div>
|
<div class="badge">{STATUS_TEXT[status]}</div>
|
||||||
<!-- онбординг — в самом виджете, чат ботом не засоряем -->
|
<!-- онбординг — в самом виджете, чат ботом не засоряем -->
|
||||||
{#if status === 'online'}
|
{#if status === 'online'}
|
||||||
<div class="hint">!рубить — лес · !копать — рудник · !ковать слиток/топор/кирку · !стоп</div>
|
<div class="hint">!рубить · !копать · !рыбачить · !ковать <рецепт> · !готовить <рецепт> · !отдых · !стоп</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const BAR_FG = 0x9be870;
|
|||||||
const HANDLE = 0x8a6238;
|
const HANDLE = 0x8a6238;
|
||||||
const STEEL = 0xb8c4d0;
|
const STEEL = 0xb8c4d0;
|
||||||
const RUSTY = 0x9a6a3f;
|
const RUSTY = 0x9a6a3f;
|
||||||
|
const AURA = 0xffb347;
|
||||||
|
|
||||||
interface Chip {
|
interface Chip {
|
||||||
g: Graphics;
|
g: Graphics;
|
||||||
@@ -27,14 +28,19 @@ function makeTool(tool: string): Graphics {
|
|||||||
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') {
|
} else if (tool === 'hammer') {
|
||||||
g.rect(-4, -15, 8, 4).fill(0x55606c);
|
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;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Аватар M2: процедурный человечек, в руках — текущий инструмент
|
* Аватар M3: процедурный человечек с инструментом по действию (топор/кирка
|
||||||
* (axe/pick тирами или молот у наковальни). Прогресс цикла клиент считает сам
|
* тирами, молот, ложка, удочка), аурой баффа отдыха и прогресс-баром цикла.
|
||||||
* из actionStart/actionDur.
|
* Прогресс клиент считает сам из actionStart/actionDur.
|
||||||
*/
|
*/
|
||||||
export class AvatarView {
|
export class AvatarView {
|
||||||
readonly container = new Container();
|
readonly container = new Container();
|
||||||
@@ -43,6 +49,7 @@ export class AvatarView {
|
|||||||
private readonly legR: Graphics;
|
private readonly legR: Graphics;
|
||||||
private readonly toolHolder = new Container();
|
private readonly toolHolder = new Container();
|
||||||
private readonly tools = new Map<string, Graphics>();
|
private readonly tools = new Map<string, Graphics>();
|
||||||
|
private readonly aura = new Graphics();
|
||||||
private readonly label: Text;
|
private readonly label: Text;
|
||||||
private readonly bar: Graphics;
|
private readonly bar: Graphics;
|
||||||
private chips: Chip[] = [];
|
private chips: Chip[] = [];
|
||||||
@@ -61,7 +68,7 @@ export class AvatarView {
|
|||||||
const body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color);
|
const body = new Graphics().roundRect(-7, -18, 14, 13, 4).fill(av.color);
|
||||||
const head = new Graphics().circle(0, -23, 6.5).fill(SKIN);
|
const head = new Graphics().circle(0, -23, 6.5).fill(SKIN);
|
||||||
this.toolHolder.position.set(6, -14);
|
this.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);
|
const g = makeTool(tool);
|
||||||
g.visible = false;
|
g.visible = false;
|
||||||
this.tools.set(tool, g);
|
this.tools.set(tool, g);
|
||||||
@@ -80,9 +87,11 @@ export class AvatarView {
|
|||||||
this.label.anchor.set(0.5, 1);
|
this.label.anchor.set(0.5, 1);
|
||||||
this.label.y = -42;
|
this.label.y = -42;
|
||||||
this.bar = new Graphics();
|
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.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.container.position.set(av.x, av.y);
|
||||||
this.applyLabel(av);
|
this.applyLabel(av);
|
||||||
@@ -127,8 +136,13 @@ export class AvatarView {
|
|||||||
this.lastTool = av.tool;
|
this.lastTool = av.tool;
|
||||||
for (const [id, g] of this.tools) g.visible = id === 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.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;
|
const t = now / 1000;
|
||||||
if (av.moving) {
|
if (av.moving) {
|
||||||
@@ -136,7 +150,16 @@ export class AvatarView {
|
|||||||
this.legL.rotation = swing * 0.5;
|
this.legL.rotation = swing * 0.5;
|
||||||
this.legR.rotation = -swing * 0.5;
|
this.legR.rotation = -swing * 0.5;
|
||||||
this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5;
|
this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5;
|
||||||
|
this.toolHolder.rotation = 0;
|
||||||
this.bar.visible = false;
|
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) {
|
} else if (working && av.actionStart !== null && av.actionDur) {
|
||||||
const p = ((((now - av.actionStart) / av.actionDur) % 1) + 1) % 1;
|
const p = ((((now - av.actionStart) / av.actionDur) % 1) + 1) % 1;
|
||||||
const swing = Math.sin((Math.min(p, 0.35) / 0.35) * Math.PI);
|
const swing = Math.sin((Math.min(p, 0.35) / 0.35) * Math.PI);
|
||||||
@@ -148,10 +171,11 @@ export class AvatarView {
|
|||||||
this.bar.clear();
|
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, 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);
|
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;
|
this.lastChopPhase = p < this.lastChopPhase ? 0 : p;
|
||||||
} else {
|
} else {
|
||||||
// отдых: лёгкое «дыхание»
|
// отдых в сторону: лёгкое «дыхание»
|
||||||
this.rig.y = Math.sin(t * 2) * 0.8;
|
this.rig.y = Math.sin(t * 2) * 0.8;
|
||||||
this.legL.rotation = 0;
|
this.legL.rotation = 0;
|
||||||
this.legR.rotation = 0;
|
this.legR.rotation = 0;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './avatar';
|
export * from './avatar';
|
||||||
|
export * from './fish';
|
||||||
export * from './rock';
|
export * from './rock';
|
||||||
export * from './tree';
|
export * from './tree';
|
||||||
export * from './world';
|
export * from './world';
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import type {
|
|||||||
WelcomeMsg,
|
WelcomeMsg,
|
||||||
ZoneDef,
|
ZoneDef,
|
||||||
} from '@idle/shared';
|
} from '@idle/shared';
|
||||||
|
import { RIVER_WATER_Y } from '@idle/shared';
|
||||||
import { AvatarView } from './avatar';
|
import { AvatarView } from './avatar';
|
||||||
|
import { FishSpotView } from './fish';
|
||||||
import { RockView } from './rock';
|
import { RockView } from './rock';
|
||||||
import { TreeView } from './tree';
|
import { TreeView } from './tree';
|
||||||
|
|
||||||
@@ -38,10 +40,15 @@ interface ZonePalette {
|
|||||||
dot: number;
|
dot: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WATER = 0x4a7fae;
|
||||||
|
const WAVE = 0x9cc7e8;
|
||||||
|
|
||||||
const ZONE_PALETTES: Record<string, ZonePalette> = {
|
const ZONE_PALETTES: Record<string, ZonePalette> = {
|
||||||
forest: { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 },
|
forest: { base: 0x4f8f56, patch: 0x46824d, dot: 0x5aa061 },
|
||||||
mine: { base: 0x7d6b52, patch: 0x6e5d46, dot: 0x8d7a60 },
|
mine: { base: 0x7d6b52, patch: 0x6e5d46, dot: 0x8d7a60 },
|
||||||
forge: { base: 0x5f5f68, patch: 0x55555e, dot: 0x6d6d78 },
|
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 };
|
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) {
|
for (const zone of zones) {
|
||||||
const p = ZONE_PALETTES[zone.id] ?? GROUND_FALLBACK;
|
const p = ZONE_PALETTES[zone.id] ?? GROUND_FALLBACK;
|
||||||
const rng = mulberry32(hashStr(zone.id));
|
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);
|
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++) {
|
for (let i = 0; i < 26; i++) {
|
||||||
g
|
g
|
||||||
.ellipse(
|
.ellipse(zone.x + rng() * zone.w, topY + rng() * landH, 15 + rng() * 45, 10 + rng() * 30)
|
||||||
zone.x + rng() * zone.w,
|
|
||||||
zone.y + rng() * zone.h,
|
|
||||||
15 + rng() * 45,
|
|
||||||
10 + rng() * 30,
|
|
||||||
)
|
|
||||||
.fill({ color: p.patch, alpha: 0.5 });
|
.fill({ color: p.patch, alpha: 0.5 });
|
||||||
}
|
}
|
||||||
for (let i = 0; i < 60; i++) {
|
for (let i = 0; i < 60; i++) {
|
||||||
g
|
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 });
|
.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;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Наковальня с горном — рисуется по пропсам из протокола. */
|
interface Flicker {
|
||||||
function drawAnvil(x: number, y: number): Container {
|
g: Graphics;
|
||||||
|
base: number;
|
||||||
|
phase: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAnvil(): Container {
|
||||||
const c = new Container();
|
const c = new Container();
|
||||||
c.position.set(x, y);
|
|
||||||
// горн позади
|
|
||||||
const furnace = new Graphics()
|
const furnace = new Graphics()
|
||||||
.rect(-58, -34, 34, 36, )
|
.rect(-58, -34, 34, 36)
|
||||||
.fill(0x3a3a40)
|
.fill(0x3a3a40)
|
||||||
.rect(-54, -26, 26, 20)
|
.rect(-54, -26, 26, 20)
|
||||||
.fill(0x26262c)
|
.fill(0x26262c)
|
||||||
@@ -105,7 +122,6 @@ function drawAnvil(x: number, y: number): Container {
|
|||||||
.fill({ color: 0xd86a2a, alpha: 0.9 })
|
.fill({ color: 0xd86a2a, alpha: 0.9 })
|
||||||
.circle(-41, -16, 3)
|
.circle(-41, -16, 3)
|
||||||
.fill({ color: 0xffc46b, alpha: 0.9 });
|
.fill({ color: 0xffc46b, alpha: 0.9 });
|
||||||
// сама наковальня
|
|
||||||
const anvil = new Graphics()
|
const anvil = new Graphics()
|
||||||
.rect(-7, -8, 14, 8)
|
.rect(-7, -8, 14, 8)
|
||||||
.fill(0x2f333b)
|
.fill(0x2f333b)
|
||||||
@@ -120,12 +136,56 @@ function drawAnvil(x: number, y: number): Container {
|
|||||||
label.anchor.set(0.5, 1);
|
label.anchor.set(0.5, 1);
|
||||||
label.y = -40;
|
label.y = -40;
|
||||||
c.addChild(furnace, anvil, label);
|
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;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Мир на канвасе: зоны, ноды, аватары и серверная камера-видоискатель.
|
* Мир на канвасе: зоны, ноды, пропсы, аватары и серверная камера-видоискатель.
|
||||||
* Состояние приходит из протокола; движение и прогресс дорисовываются локально.
|
* Состояние приходит из протокола; движение и прогресс дорисовываются локально.
|
||||||
*/
|
*/
|
||||||
export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise<WorldHandle> {
|
export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise<WorldHandle> {
|
||||||
@@ -147,6 +207,8 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
worldLayer.addChild(entityLayer);
|
worldLayer.addChild(entityLayer);
|
||||||
app.stage.addChild(worldLayer);
|
app.stage.addChild(worldLayer);
|
||||||
|
|
||||||
|
const flickers: Flicker[] = [];
|
||||||
|
|
||||||
let clockOffset = 0; // serverNow - Date.now()
|
let clockOffset = 0; // serverNow - Date.now()
|
||||||
let camTarget: CameraState | null = null;
|
let camTarget: CameraState | null = null;
|
||||||
const camCur = { x: 0, y: 0, w: 900, h: 560 };
|
const camCur = { x: 0, y: 0, w: 900, h: 560 };
|
||||||
@@ -154,7 +216,7 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
const avatars = new Map<string, AvatarState>();
|
const avatars = new Map<string, AvatarState>();
|
||||||
const avatarViews = new Map<string, AvatarView>();
|
const avatarViews = new Map<string, AvatarView>();
|
||||||
const nodes = new Map<string, NodeState>();
|
const nodes = new Map<string, NodeState>();
|
||||||
const nodeViews = new Map<string, TreeView | RockView>();
|
const nodeViews = new Map<string, TreeView | RockView | FishSpotView>();
|
||||||
|
|
||||||
const serverNow = (): number => Date.now() + clockOffset;
|
const serverNow = (): number => Date.now() + clockOffset;
|
||||||
|
|
||||||
@@ -173,7 +235,8 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
nodes.set(st.id, st);
|
nodes.set(st.id, st);
|
||||||
let view = nodeViews.get(st.id);
|
let view = nodeViews.get(st.id);
|
||||||
if (!view) {
|
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);
|
nodeViews.set(st.id, view);
|
||||||
entityLayer.addChild(view.container);
|
entityLayer.addChild(view.container);
|
||||||
}
|
}
|
||||||
@@ -208,6 +271,9 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
|
|
||||||
app.ticker.add((ticker) => {
|
app.ticker.add((ticker) => {
|
||||||
const now = serverNow();
|
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) {
|
for (const [id, view] of avatarViews) {
|
||||||
const av = avatars.get(id);
|
const av = avatars.get(id);
|
||||||
if (av) {
|
if (av) {
|
||||||
@@ -240,7 +306,15 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
|||||||
ground.zIndex = -1000;
|
ground.zIndex = -1000;
|
||||||
entityLayer.addChild(ground);
|
entityLayer.addChild(ground);
|
||||||
for (const prop of w.world.props) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+67
-39
@@ -1,4 +1,4 @@
|
|||||||
/* Дымовой тест M2: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
|
/* Дымовой тест M3: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
|
||||||
`pnpm --filter @idle/server smoke` */
|
`pnpm --filter @idle/server smoke` */
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ const COOLDOWN = 1400;
|
|||||||
|
|
||||||
function connect(onMsg) {
|
function connect(onMsg) {
|
||||||
const ws = new WebSocket(WS);
|
const ws = new WebSocket(WS);
|
||||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 3 })));
|
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 4 })));
|
||||||
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
|
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,11 @@ async function inventory(name) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function state() {
|
||||||
|
const res = await fetch(`${BASE}/dev/state`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
/** Ждём дельту, удовлетворяющую условию. */
|
/** Ждём дельту, удовлетворяющую условию. */
|
||||||
function waitForDelta(pred, timeoutMs = 9000) {
|
function waitForDelta(pred, timeoutMs = 9000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -70,54 +75,77 @@ await new Promise((resolve, reject) => {
|
|||||||
setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000);
|
setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000);
|
||||||
});
|
});
|
||||||
const [welcome, snap] = first;
|
const [welcome, snap] = first;
|
||||||
console.log('welcome: v' + welcome.v, '| деревьев+жил:', snap.nodes.length, '| пропсов:', welcome.world.props.length);
|
const fishSpots = snap.nodes.filter((n) => n.kind === 'fish').length;
|
||||||
if (welcome.t !== 'welcome' || welcome.v !== 3) throw new Error('bad welcome');
|
console.log(
|
||||||
if (snap.nodes.length !== 13 || snap.nodes.filter((n) => n.kind === 'rock').length !== 6) {
|
'welcome: v' + welcome.v,
|
||||||
throw new Error('bad snapshot nodes');
|
'| нод:', snap.nodes.length,
|
||||||
}
|
'| рыбных мест:', fishSpots,
|
||||||
if (!welcome.world.props.some((p) => p.kind === 'anvil')) throw new Error('no anvil prop');
|
'| пропсов:', 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. спавн через !рубить
|
// 2. спавн через !рубить
|
||||||
await dev('/dev/command', { name: 'Тестер', text: '!рубить' });
|
await dev('/dev/command', { name: 'Тестер', text: '!рубить' });
|
||||||
const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
|
await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.moving));
|
||||||
console.log('spawn: движется к дереву ok');
|
console.log('spawn ok');
|
||||||
|
|
||||||
// 3. телепорт к медной жиле и !копать
|
// 3. рыбалка
|
||||||
await dev('/dev/tp', { name: 'Тестер', x: 1140, y: 346 });
|
await dev('/dev/tp', { name: 'Тестер', x: 2250, y: 375 });
|
||||||
await sleep(COOLDOWN);
|
await sleep(COOLDOWN);
|
||||||
await dev('/dev/command', { name: 'Тестер', text: '!копать' });
|
await dev('/dev/command', { name: 'Тестер', text: '!рыбачить' });
|
||||||
const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'mine'));
|
const d1 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'fish'));
|
||||||
const miner = d2.avatars.find((a) => a.name === 'Тестер');
|
const fisher = d1.avatars.find((a) => a.name === 'Тестер');
|
||||||
console.log('mine: action=mine tool=' + miner.tool);
|
console.log('fish: action=' + fisher.action, 'tool=' + fisher.tool);
|
||||||
if (miner.action !== 'mine' || miner.tool !== 'pick_rusty') throw new Error('bad mine state');
|
if (fisher.action !== 'fish' || fisher.tool !== 'rod') throw new Error('bad fish state');
|
||||||
|
await sleep(16_000);
|
||||||
// 4. ждём добытую руду в инвентаре
|
|
||||||
await sleep(13_500);
|
|
||||||
const inv1 = await inventory('Тестер');
|
const inv1 = await inventory('Тестер');
|
||||||
console.log('inventory: copper_ore=' + (inv1.items?.copper_ore ?? 0), 'mining xp=' + (inv1.skills?.mining?.xp ?? 0));
|
console.log('inventory: raw_fish=' + (inv1.items?.raw_fish ?? 0), 'fishing xp=' + (inv1.skills?.fishing?.xp ?? 0));
|
||||||
if ((inv1.items?.copper_ore ?? 0) < 1 || (inv1.skills?.mining?.xp ?? 0) < 15) throw new Error('mining did not yield');
|
if ((inv1.items?.raw_fish ?? 0) < 1 || (inv1.skills?.fishing?.xp ?? 0) < 15) throw new Error('fishing did not yield');
|
||||||
|
|
||||||
// 5. ковка медного слитка у наковальни
|
// 4. готовка жареной рыбы
|
||||||
await dev('/dev/give', { name: 'Тестер', item: 'copper_ore', qty: 2 });
|
await dev('/dev/give', { name: 'Тестер', item: 'raw_fish', qty: 2 });
|
||||||
await dev('/dev/tp', { name: 'Тестер', x: 1830, y: 415 });
|
await dev('/dev/tp', { name: 'Тестер', x: 2990, y: 425 });
|
||||||
await sleep(COOLDOWN);
|
await sleep(COOLDOWN);
|
||||||
await dev('/dev/command', { name: 'Тестер', text: '!ковать медный слиток' });
|
await dev('/dev/command', { name: 'Тестер', text: '!готовить жареную рыбу' });
|
||||||
const d3 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'smith'));
|
const d2 = await waitForDelta((m) => m.avatars?.some((a) => a.name === 'Тестер' && a.action === 'cook'));
|
||||||
const smith = d3.avatars.find((a) => a.name === 'Тестер');
|
const cook = d2.avatars.find((a) => a.name === 'Тестер');
|
||||||
console.log('smith: action=smith tool=' + smith.tool);
|
console.log('cook: action=' + cook.action, 'tool=' + cook.tool);
|
||||||
if (smith.tool !== 'hammer') throw new Error('expected hammer');
|
if (cook.action !== 'cook' || cook.tool !== 'spoon') throw new Error('bad cook state');
|
||||||
await sleep(9_500);
|
await sleep(11_000);
|
||||||
const inv2 = await inventory('Тестер');
|
const inv2 = await inventory('Тестер');
|
||||||
console.log('inventory: copper_bar=' + (inv2.items?.copper_bar ?? 0), 'smithing xp=' + (inv2.skills?.smithing?.xp ?? 0));
|
console.log('inventory: cooked_fish=' + (inv2.items?.cooked_fish ?? 0), 'cooking xp=' + (inv2.skills?.cooking?.xp ?? 0));
|
||||||
if ((inv2.items?.copper_bar ?? 0) !== 1) throw new Error('smelting did not yield');
|
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 sleep(COOLDOWN);
|
||||||
await dev('/dev/command', { name: 'Тестер', text: '!ковать топор' });
|
await dev('/dev/command', { name: 'Тестер2', text: '!отдых' });
|
||||||
const d4 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked'), 5000);
|
const d3 = await waitForDelta((m) => m.events?.some((e) => e.k === 'blocked' && e.name === 'Тестер2'), 5000);
|
||||||
const reason = d4.events.find((e) => e.k === 'blocked').reason;
|
console.log('blocked:', d3.events.find((e) => e.k === 'blocked').reason);
|
||||||
console.log('blocked:', reason);
|
|
||||||
if (!/уровень|хватает/.test(reason)) throw new Error('unexpected 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
|
// 7. неверная версия протокола — close 4001
|
||||||
const closeCode = await new Promise((resolve) => {
|
const closeCode = await new Promise((resolve) => {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
COMMAND_COOLDOWN_MS,
|
COMMAND_COOLDOWN_MS,
|
||||||
|
FISH,
|
||||||
|
FISH_SPOTS,
|
||||||
FORGE_SPOT,
|
FORGE_SPOT,
|
||||||
ITEM_LOG,
|
ITEM_LOG,
|
||||||
|
KITCHEN_SPOT,
|
||||||
ORES,
|
ORES,
|
||||||
RECIPES,
|
RECIPES,
|
||||||
|
REST,
|
||||||
|
REST_SPOT,
|
||||||
ROCK_SPOTS,
|
ROCK_SPOTS,
|
||||||
ROCK,
|
ROCK,
|
||||||
SKILLS,
|
SKILLS,
|
||||||
@@ -73,14 +78,22 @@ interface SimAvatar {
|
|||||||
lastCommandAt: number;
|
lastCommandAt: number;
|
||||||
pendingNodeId: string | null;
|
pendingNodeId: string | null;
|
||||||
pendingRecipeId: 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 CHOP_ALIASES = new Set(['рубить', 'chop']);
|
||||||
const MINE_ALIASES = new Set(['копать', 'mine']);
|
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 STOP_ALIASES = new Set(['стоп', 'stop']);
|
||||||
|
const REST_ALIASES = new Set(['отдых', 'rest']);
|
||||||
|
|
||||||
const SKILL_IDS: SkillId[] = SKILLS.map((s) => s.id);
|
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) — клиент дорисовывает сам;
|
* - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам;
|
||||||
* - инвентарь и навыки — мета-слой, вне ECS.
|
* - инвентарь, навыки и баффы — мета-слой, вне ECS.
|
||||||
*/
|
*/
|
||||||
export class SimWorld {
|
export class SimWorld {
|
||||||
readonly avatars = new Map<string, SimAvatar>();
|
readonly avatars = new Map<string, SimAvatar>();
|
||||||
@@ -119,6 +132,16 @@ export class SimWorld {
|
|||||||
maxHp: ROCK.maxHp,
|
maxHp: ROCK.maxHp,
|
||||||
respawnAt: null,
|
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<string>();
|
private readonly dirtyAvatars = new Set<string>();
|
||||||
@@ -143,7 +166,9 @@ export class SimWorld {
|
|||||||
const arg = parts.slice(1).join(' ');
|
const arg = parts.slice(1).join(' ');
|
||||||
if (CHOP_ALIASES.has(word)) this.doChop(cmd, now);
|
if (CHOP_ALIASES.has(word)) this.doChop(cmd, now);
|
||||||
else if (MINE_ALIASES.has(word)) this.doMine(cmd, now);
|
else if (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);
|
else if (STOP_ALIASES.has(word)) this.doStop(cmd, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +184,12 @@ export class SimWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const av of this.avatars.values()) {
|
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) {
|
if (av.moving) {
|
||||||
const travelMs = (dist(av.x, av.y, av.tx, av.ty) / av.speed) * 1000;
|
const travelMs = (dist(av.x, av.y, av.tx, av.ty) / av.speed) * 1000;
|
||||||
if (now >= av.moveStart + travelMs) {
|
if (now >= av.moveStart + travelMs) {
|
||||||
@@ -193,7 +224,7 @@ export class SimWorld {
|
|||||||
if (av.pendingRecipeId) {
|
if (av.pendingRecipeId) {
|
||||||
const recipe = RECIPES.find((r) => r.id === av.pendingRecipeId);
|
const recipe = RECIPES.find((r) => r.id === av.pendingRecipeId);
|
||||||
if (recipe && this.hasInputs(av, recipe)) {
|
if (recipe && this.hasInputs(av, recipe)) {
|
||||||
this.beginSmith(av, recipe, now);
|
this.beginCraft(av, recipe, now);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
av.pendingRecipeId = null;
|
av.pendingRecipeId = null;
|
||||||
@@ -205,6 +236,10 @@ export class SimWorld {
|
|||||||
reason: `материалы закончились: ${this.inputsText(recipe)}`,
|
reason: `материалы закончились: ${this.inputsText(recipe)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (av.pendingRest) {
|
||||||
|
av.pendingRest = false;
|
||||||
|
this.beginRest(av, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- команды ----
|
// ---- команды ----
|
||||||
@@ -235,22 +270,34 @@ export class SimWorld {
|
|||||||
});
|
});
|
||||||
if (!rock) {
|
if (!rock) {
|
||||||
const minReq = Math.min(...ROCK_SPOTS.map((r) => ORES[r.ore].level));
|
const minReq = Math.min(...ROCK_SPOTS.map((r) => ORES[r.ore].level));
|
||||||
this.pendingEvents.push(
|
this.pendingEvents.push(this.blocked(av, `нужен ${minReq}-й уровень добычи руды`));
|
||||||
this.blocked(av, `нужен ${minReq}-й уровень добычи руды`),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.walkTo(av, rock.x, rock.y + 26, now);
|
this.walkTo(av, rock.x, rock.y + 26, now);
|
||||||
av.pendingNodeId = rock.id;
|
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);
|
const av = this.ensureAvatar(cmd, now);
|
||||||
if (this.onCooldown(av, now)) return;
|
if (this.onCooldown(av, now)) return;
|
||||||
|
|
||||||
if (!arg) {
|
if (!arg) {
|
||||||
this.pendingEvents.push(
|
this.pendingEvents.push(
|
||||||
this.blocked(av, 'что ковать? медный слиток · железный слиток · топор · кирка'),
|
this.blocked(av, 'что готовим? медный слиток · железо · топор · кирка · жареная рыба'),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -261,9 +308,10 @@ export class SimWorld {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (av.skills.smithing.level < recipe.level) {
|
const skill = av.skills[recipe.skill];
|
||||||
|
if (!skill || skill.level < recipe.level) {
|
||||||
this.pendingEvents.push(
|
this.pendingEvents.push(
|
||||||
this.blocked(av, `«${recipe.title}» нужен ${recipe.level}-й уровень кузнечного дела`),
|
this.blocked(av, `«${recipe.title}» нужен ${recipe.level}-й уровень навыка`),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -272,10 +320,27 @@ export class SimWorld {
|
|||||||
return;
|
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;
|
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 {
|
private doStop(cmd: ChatCommand, now: number): void {
|
||||||
const av = this.ensureAvatar(cmd, now);
|
const av = this.ensureAvatar(cmd, now);
|
||||||
if (this.onCooldown(av, now)) return;
|
if (this.onCooldown(av, now)) return;
|
||||||
@@ -288,6 +353,7 @@ export class SimWorld {
|
|||||||
}
|
}
|
||||||
av.pendingNodeId = null;
|
av.pendingNodeId = null;
|
||||||
av.pendingRecipeId = null;
|
av.pendingRecipeId = null;
|
||||||
|
av.pendingRest = false;
|
||||||
if (av.action !== 'idle') {
|
if (av.action !== 'idle') {
|
||||||
av.action = 'idle';
|
av.action = 'idle';
|
||||||
av.nodeId = null;
|
av.nodeId = null;
|
||||||
@@ -305,23 +371,27 @@ export class SimWorld {
|
|||||||
const tool = this.bestTool(av, 'axe');
|
const tool = this.bestTool(av, 'axe');
|
||||||
av.action = 'chop';
|
av.action = 'chop';
|
||||||
av.actionDur = Math.round(TREE.chopDurMs / tool.multiplier);
|
av.actionDur = Math.round(TREE.chopDurMs / tool.multiplier);
|
||||||
} else {
|
} else if (node.kind === 'rock') {
|
||||||
const tool = this.bestTool(av, 'pick');
|
const tool = this.bestTool(av, 'pick');
|
||||||
av.action = 'mine';
|
av.action = 'mine';
|
||||||
const ore = ORES[(node.variant ?? 'copper') as OreKind];
|
const ore = ORES[(node.variant ?? 'copper') as OreKind];
|
||||||
av.actionDur = Math.round(ore.cycleMs / tool.multiplier);
|
av.actionDur = Math.round(ore.cycleMs / tool.multiplier);
|
||||||
|
} else {
|
||||||
|
av.action = 'fish';
|
||||||
|
av.actionDur = FISH.cycleMs;
|
||||||
}
|
}
|
||||||
av.nodeId = node.id;
|
av.nodeId = node.id;
|
||||||
av.actionStart = now;
|
av.actionStart = now;
|
||||||
av.pendingNodeId = null;
|
av.pendingNodeId = null;
|
||||||
av.pendingRecipeId = null;
|
av.pendingRecipeId = null;
|
||||||
|
av.pendingRest = false;
|
||||||
this.dirtyAvatars.add(av.id);
|
this.dirtyAvatars.add(av.id);
|
||||||
this.statsDirty = true;
|
this.statsDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private beginSmith(av: SimAvatar, recipe: RecipeDef, now: number): void {
|
private beginCraft(av: SimAvatar, recipe: RecipeDef, now: number): void {
|
||||||
av.action = 'smith';
|
av.action = recipe.place === 'kitchen' ? 'cook' : 'smith';
|
||||||
// для ковки nodeId хранит id рецепта — узел-нода у кузницы одна
|
// nodeId хранит id рецепта для действий у пропсов
|
||||||
av.nodeId = recipe.id;
|
av.nodeId = recipe.id;
|
||||||
av.actionDur = recipe.cycleMs;
|
av.actionDur = recipe.cycleMs;
|
||||||
av.actionStart = now;
|
av.actionStart = now;
|
||||||
@@ -330,6 +400,15 @@ export class SimWorld {
|
|||||||
this.statsDirty = true;
|
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 {
|
private completeCycle(av: SimAvatar, now: number, events: GameEvent[]): void {
|
||||||
if (av.action === 'chop') {
|
if (av.action === 'chop') {
|
||||||
const tree = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
const tree = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
||||||
@@ -343,10 +422,13 @@ export class SimWorld {
|
|||||||
tree.respawnAt = now + TREE.respawnMs;
|
tree.respawnAt = now + TREE.respawnMs;
|
||||||
events.push({ k: 'fell', userId: av.id, name: av.name, color: av.color, nodeId: tree.id });
|
events.push({ k: 'fell', userId: av.id, name: av.name, color: av.color, nodeId: tree.id });
|
||||||
}
|
}
|
||||||
this.addItem(av, ITEM_LOG, 1);
|
this.gatherYield(av, ITEM_LOG, TREE.xp, 'woodcutting', events);
|
||||||
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ITEM_LOG, qty: 1 });
|
this.finishOrContinue(
|
||||||
this.addXp(av, 'woodcutting', TREE.xp, events);
|
av,
|
||||||
this.finishOrContinue(av, now, Math.round(TREE.chopDurMs / this.bestTool(av, 'axe').multiplier), () => tree.respawnAt === null);
|
now,
|
||||||
|
Math.round(TREE.chopDurMs / this.bestTool(av, 'axe').multiplier),
|
||||||
|
() => tree.respawnAt === null,
|
||||||
|
);
|
||||||
} else if (av.action === 'mine') {
|
} else if (av.action === 'mine') {
|
||||||
const rock = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
const rock = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
||||||
const ore = rock?.variant ? ORES[rock.variant as OreKind] : undefined;
|
const ore = rock?.variant ? ORES[rock.variant as OreKind] : undefined;
|
||||||
@@ -360,19 +442,29 @@ export class SimWorld {
|
|||||||
rock.respawnAt = now + ore.respawnMs;
|
rock.respawnAt = now + ore.respawnMs;
|
||||||
events.push({ k: 'depleted', userId: av.id, name: av.name, color: av.color, nodeId: rock.id });
|
events.push({ k: 'depleted', userId: av.id, name: av.name, color: av.color, nodeId: rock.id });
|
||||||
}
|
}
|
||||||
this.addItem(av, ore.item, 1);
|
this.gatherYield(av, ore.item, ore.xp, 'mining', events);
|
||||||
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(
|
this.finishOrContinue(
|
||||||
av,
|
av,
|
||||||
now,
|
now,
|
||||||
Math.round(ore.cycleMs / this.bestTool(av, 'pick').multiplier),
|
Math.round(ore.cycleMs / this.bestTool(av, 'pick').multiplier),
|
||||||
() => rock.respawnAt === null,
|
() => rock.respawnAt === null,
|
||||||
);
|
);
|
||||||
} else if (av.action === 'smith') {
|
} else if (av.action === 'fish') {
|
||||||
const recipe = av.nodeId
|
const spot = av.nodeId ? this.nodeById(av.nodeId) : undefined;
|
||||||
? RECIPES.find((r) => r.id === av.nodeId)
|
if (!spot || spot.respawnAt !== null) {
|
||||||
: undefined;
|
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) {
|
if (!recipe) {
|
||||||
this.goIdle(av);
|
this.goIdle(av);
|
||||||
return;
|
return;
|
||||||
@@ -387,13 +479,46 @@ export class SimWorld {
|
|||||||
item: recipe.output.item,
|
item: recipe.output.item,
|
||||||
qty: recipe.output.qty,
|
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), () => {
|
this.finishOrContinue(av, now, recipe.cycleMs, () => this.hasInputs(av, recipe), () => {
|
||||||
events.push(
|
events.push(this.blocked(av, `материалы кончились: ${this.inputsText(recipe)}`));
|
||||||
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 (по правилу «работает, пока не скажут»). */
|
/** Продолжить цикл того же действия либо уйти в idle (по правилу «работает, пока не скажут»). */
|
||||||
@@ -520,6 +645,8 @@ export class SimWorld {
|
|||||||
if (av.action === 'chop') return this.bestTool(av, 'axe').item;
|
if (av.action === 'chop') return this.bestTool(av, 'axe').item;
|
||||||
if (av.action === 'mine') return this.bestTool(av, 'pick').item;
|
if (av.action === 'mine') return this.bestTool(av, 'pick').item;
|
||||||
if (av.action === 'smith') return 'hammer';
|
if (av.action === 'smith') return 'hammer';
|
||||||
|
if (av.action === 'cook') return 'spoon';
|
||||||
|
if (av.action === 'fish') return 'rod';
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +710,11 @@ export class SimWorld {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
lookupViewer(name: string): { name: string; color: string; skills: Record<string, SkillSim>; items: Record<string, number> } | undefined {
|
lookupViewer(
|
||||||
|
name: string,
|
||||||
|
):
|
||||||
|
| { name: string; color: string; skills: Record<string, SkillSim>; items: Record<string, number> }
|
||||||
|
| undefined {
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
const av = [...this.avatars.values()].find((a) => a.name.toLowerCase() === lower);
|
const av = [...this.avatars.values()].find((a) => a.name.toLowerCase() === lower);
|
||||||
if (av) {
|
if (av) {
|
||||||
@@ -623,6 +754,7 @@ export class SimWorld {
|
|||||||
actionStart: av.actionStart,
|
actionStart: av.actionStart,
|
||||||
actionDur: av.actionDur,
|
actionDur: av.actionDur,
|
||||||
skills,
|
skills,
|
||||||
|
restStacks: av.restStacks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,6 +848,10 @@ export class SimWorld {
|
|||||||
lastCommandAt: 0,
|
lastCommandAt: 0,
|
||||||
pendingNodeId: null,
|
pendingNodeId: null,
|
||||||
pendingRecipeId: null,
|
pendingRecipeId: null,
|
||||||
|
pendingRest: false,
|
||||||
|
restStacks: 0,
|
||||||
|
restUntil: 0,
|
||||||
|
bonusAcc: 0,
|
||||||
};
|
};
|
||||||
this.avatars.set(av.id, av);
|
this.avatars.set(av.id, av);
|
||||||
this.dirtyAvatars.add(av.id);
|
this.dirtyAvatars.add(av.id);
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Контент M2: мир-полоса зон (лес | рудник | кузница), ноды, навыки,
|
* Контент M3: полная полоса зон (лес | рудник | кузница | река | кухня),
|
||||||
* предметы, инструменты тирами, рецепты. Баланс правится данными здесь.
|
* ноды (деревья, жилы, рыбные места), навыки, предметы, инструменты,
|
||||||
|
* рецепты и отдых у костра. Баланс правится данными здесь.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ZoneDef } from './protocol';
|
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[] = [
|
export const ZONES: ZoneDef[] = [
|
||||||
{ id: 'forest', name: 'Лес', x: 0, y: 0, w: 1000, h: 720 },
|
{ id: 'forest', name: 'Лес', x: 0, y: 0, w: 1000, h: 720 },
|
||||||
{ id: 'mine', name: 'Рудник', x: 1000, y: 0, w: 680, h: 720 },
|
{ id: 'mine', name: 'Рудник', x: 1000, y: 0, w: 680, h: 720 },
|
||||||
{ id: 'forge', name: 'Кузница', x: 1680, y: 0, w: 420, 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 }[] = [
|
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 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: 'woodcutting', title: 'Рубка леса' },
|
||||||
{ id: 'mining', title: 'Добыча руды' },
|
{ id: 'mining', title: 'Добыча руды' },
|
||||||
{ id: 'smithing', title: 'Кузнечное дело' },
|
{ id: 'smithing', title: 'Кузнечное дело' },
|
||||||
|
{ id: 'fishing', title: 'Рыбалка' },
|
||||||
|
{ id: 'cooking', title: 'Кулинария' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SkillId = (typeof SKILLS)[number]['id'];
|
export type SkillId = (typeof SKILLS)[number]['id'];
|
||||||
@@ -87,6 +138,8 @@ export const ITEMS: Record<string, { title: string }> = {
|
|||||||
iron_ore: { title: 'железная руда' },
|
iron_ore: { title: 'железная руда' },
|
||||||
copper_bar: { title: 'медный слиток' },
|
copper_bar: { title: 'медный слиток' },
|
||||||
iron_bar: { title: 'железный слиток' },
|
iron_bar: { title: 'железный слиток' },
|
||||||
|
raw_fish: { title: 'сырая рыба' },
|
||||||
|
cooked_fish: { title: 'жареная рыба' },
|
||||||
axe_rusty: { title: 'ржавый топор' },
|
axe_rusty: { title: 'ржавый топор' },
|
||||||
axe_iron: { title: 'железный топор' },
|
axe_iron: { title: 'железный топор' },
|
||||||
pick_rusty: { title: 'ржавая кирка' },
|
pick_rusty: { title: 'ржавая кирка' },
|
||||||
@@ -114,7 +167,7 @@ export const TOOLS: ToolDef[] = [
|
|||||||
{ item: 'pick_iron', kind: 'pick', multiplier: 2 },
|
{ item: 'pick_iron', kind: 'pick', multiplier: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---- рецепты (ковка) ----
|
// ---- рецепты ----
|
||||||
|
|
||||||
export interface RecipeDef {
|
export interface RecipeDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -122,9 +175,12 @@ export interface RecipeDef {
|
|||||||
output: { item: string; qty: number };
|
output: { item: string; qty: number };
|
||||||
inputs: { item: string; qty: number }[];
|
inputs: { item: string; qty: number }[];
|
||||||
cycleMs: number;
|
cycleMs: number;
|
||||||
/** Требование к навыку кузнечного дела. */
|
/** Требование к навыку recipe.skill. */
|
||||||
level: number;
|
level: number;
|
||||||
xp: number;
|
xp: number;
|
||||||
|
/** Какой навык качает рецепт и где готовится. */
|
||||||
|
skill: 'smithing' | 'cooking';
|
||||||
|
place: 'forge' | 'kitchen';
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +193,8 @@ export const RECIPES: RecipeDef[] = [
|
|||||||
cycleMs: 8_000,
|
cycleMs: 8_000,
|
||||||
level: 1,
|
level: 1,
|
||||||
xp: 15,
|
xp: 15,
|
||||||
|
skill: 'smithing',
|
||||||
|
place: 'forge',
|
||||||
aliases: ['медный слиток', 'медь'],
|
aliases: ['медный слиток', 'медь'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -147,6 +205,8 @@ export const RECIPES: RecipeDef[] = [
|
|||||||
cycleMs: 10_000,
|
cycleMs: 10_000,
|
||||||
level: 2,
|
level: 2,
|
||||||
xp: 25,
|
xp: 25,
|
||||||
|
skill: 'smithing',
|
||||||
|
place: 'forge',
|
||||||
aliases: ['железный слиток', 'железо'],
|
aliases: ['железный слиток', 'железо'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,6 +220,8 @@ export const RECIPES: RecipeDef[] = [
|
|||||||
cycleMs: 15_000,
|
cycleMs: 15_000,
|
||||||
level: 3,
|
level: 3,
|
||||||
xp: 50,
|
xp: 50,
|
||||||
|
skill: 'smithing',
|
||||||
|
place: 'forge',
|
||||||
aliases: ['железный топор', 'топор'],
|
aliases: ['железный топор', 'топор'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -173,13 +235,42 @@ export const RECIPES: RecipeDef[] = [
|
|||||||
cycleMs: 15_000,
|
cycleMs: 15_000,
|
||||||
level: 3,
|
level: 3,
|
||||||
xp: 50,
|
xp: 50,
|
||||||
|
skill: 'smithing',
|
||||||
|
place: 'forge',
|
||||||
aliases: ['железная кирка', 'кирка'],
|
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 {
|
export function findRecipe(text: string): RecipeDef | undefined {
|
||||||
const q = text.trim().toLowerCase().replace(/\s+/g, ' ');
|
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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- прочее ----
|
// ---- прочее ----
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* дельты вместо полных снапшотов, игровые события, серверная камера.
|
* дельты вместо полных снапшотов, игровые события, серверная камера.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = 3;
|
export const PROTOCOL_VERSION = 4;
|
||||||
|
|
||||||
export interface ZoneDef {
|
export interface ZoneDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,7 +20,7 @@ export interface ZoneDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PropDef {
|
export interface PropDef {
|
||||||
kind: 'anvil';
|
kind: 'anvil' | 'campfire' | 'stove';
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ export interface WorldDef {
|
|||||||
props: PropDef[];
|
props: PropDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarAction = 'idle' | 'chop' | 'mine' | 'smith';
|
export type AvatarAction = 'idle' | 'chop' | 'mine' | 'smith' | 'cook' | 'fish' | 'rest';
|
||||||
|
|
||||||
export interface SkillState {
|
export interface SkillState {
|
||||||
xp: number;
|
xp: number;
|
||||||
@@ -64,12 +64,14 @@ export interface AvatarState {
|
|||||||
actionStart: number | null;
|
actionStart: number | null;
|
||||||
actionDur: number | null;
|
actionDur: number | null;
|
||||||
skills: Record<string, SkillState>;
|
skills: Record<string, SkillState>;
|
||||||
|
/** Стаки баффа отдыха (каждый +20% к добыче). */
|
||||||
|
restStacks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeState {
|
export interface NodeState {
|
||||||
id: string;
|
id: string;
|
||||||
kind: 'tree' | 'rock';
|
kind: 'tree' | 'rock' | 'fish';
|
||||||
/** Для руды — какая жила; для дерева отсутствует. */
|
/** Для руды — какая жила, для рыбного места — 'river'. */
|
||||||
variant?: string;
|
variant?: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -102,6 +104,7 @@ export type GameEvent =
|
|||||||
| { k: 'levelup'; userId: string; name: string; color: string; skill: string; level: number }
|
| { k: 'levelup'; userId: string; name: string; color: string; skill: string; level: number }
|
||||||
| { k: 'fell'; userId: string; name: string; color: string; nodeId: string }
|
| { k: 'fell'; userId: string; name: string; color: string; nodeId: string }
|
||||||
| { k: 'depleted'; userId: string; name: string; color: string; nodeId: string }
|
| { k: '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 };
|
| { k: 'blocked'; userId: string; name: string; color: string; reason: string };
|
||||||
|
|
||||||
export interface WelcomeMsg {
|
export interface WelcomeMsg {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
chop: '🌲',
|
chop: '🌲',
|
||||||
mine: '⛏️',
|
mine: '⛏️',
|
||||||
smith: '🔨',
|
smith: '🔨',
|
||||||
|
cook: '🍳',
|
||||||
|
fish: '🎣',
|
||||||
|
rest: '🔥',
|
||||||
idle: '💤',
|
idle: '💤',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,6 +54,8 @@
|
|||||||
return `${e.name} свалил(ла) дерево!`;
|
return `${e.name} свалил(ла) дерево!`;
|
||||||
case 'depleted':
|
case 'depleted':
|
||||||
return `${e.name} выработал(ла) жилу`;
|
return `${e.name} выработал(ла) жилу`;
|
||||||
|
case 'rest':
|
||||||
|
return `🔥 ${e.name} отдыхает у костра — бафф ×${e.stacks}`;
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return `${e.name}: ${e.reason}`;
|
return `${e.name}: ${e.reason}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user