feat: fishing and cooking
This commit is contained in:
@@ -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<string, SimAvatar>();
|
||||
@@ -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<string>();
|
||||
@@ -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<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 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);
|
||||
|
||||
Reference in New Issue
Block a user