feat: add ore and blacksmith

This commit is contained in:
2026-09-05 17:24:44 +05:00
parent f6c338e1b0
commit 6273f2849c
15 changed files with 1333 additions and 334 deletions
+167 -14
View File
@@ -1,21 +1,21 @@
/**
* Контент M1: мир, деревья, экономика рубки.
* Баланс правится данными здесь, без кода (принцип «контент как данные»).
* Контент M2: мир-полоса зон (лес | рудник | кузница), ноды, навыки,
* предметы, инструменты тирами, рецепты. Баланс правится данными здесь.
*/
import type { ZoneDef } from './protocol';
export const WORLD = { w: 1280, h: 720, spawn: { x: 180, y: 520 } } as const;
export const WORLD = { w: 2100, h: 720, spawn: { x: 180, y: 520 } } as const;
/** M1 — одна зона; в M2 мир станет полосой зон (лес | рудник | река | костёр | кухня). */
export const ZONES: ZoneDef[] = [{ id: 'forest', name: 'Лес', x: 0, y: 0, w: 1280, h: 720 }];
export const ZONES: ZoneDef[] = [
{ id: 'forest', name: 'Лес', x: 0, y: 0, w: 1000, h: 720 },
{ id: 'mine', name: 'Рудник', x: 1000, y: 0, w: 680, h: 720 },
{ id: 'forge', name: 'Кузница', x: 1680, y: 0, w: 420, h: 720 },
];
export interface TreeSpot {
x: number;
y: number;
}
// ---- лес ----
export const TREE_SPOTS: TreeSpot[] = [
export const TREE_SPOTS: { x: number; y: number }[] = [
{ x: 520, y: 320 },
{ x: 680, y: 430 },
{ x: 860, y: 250 },
@@ -25,16 +25,169 @@ export const TREE_SPOTS: TreeSpot[] = [
{ x: 780, y: 560 },
];
export const TREE = { maxHp: 5, respawnMs: 60_000, chopDurMs: 15_000 } as const;
export const TREE = { maxHp: 5, respawnMs: 60_000, chopDurMs: 15_000, xp: 10 } as const;
// ---- рудник ----
export type OreKind = 'copper' | 'iron';
export interface RockSpot {
x: number;
y: number;
ore: OreKind;
}
export const ROCK_SPOTS: RockSpot[] = [
{ x: 1150, y: 320, ore: 'copper' },
{ x: 1310, y: 440, ore: 'copper' },
{ x: 1470, y: 260, ore: 'copper' },
{ x: 1230, y: 540, ore: 'copper' },
{ x: 1580, y: 420, ore: 'iron' },
{ x: 1390, y: 150, ore: 'iron' },
];
export const ROCK = { maxHp: 4 } as const;
export const ORES: Record<
OreKind,
{ item: string; level: number; xp: number; cycleMs: number; respawnMs: number }
> = {
copper: { item: 'copper_ore', level: 1, xp: 15, cycleMs: 12_000, respawnMs: 45_000 },
iron: { item: 'iron_ore', level: 3, xp: 30, cycleMs: 16_000, respawnMs: 90_000 },
};
// ---- кузница ----
/** Точка, куда встаёт аватар для ковки. */
export const FORGE_SPOT = { x: 1830, y: 415 } as const;
export const PROPS = [{ kind: 'anvil', x: 1830, y: 370 }] as const;
// ---- навыки ----
export const SKILLS = [
{ id: 'woodcutting', title: 'Рубка леса' },
{ id: 'mining', title: 'Добыча руды' },
{ id: 'smithing', title: 'Кузнечное дело' },
] as const;
export type SkillId = (typeof SKILLS)[number]['id'];
export const SKILL_TITLES: Record<string, string> = Object.fromEntries(
SKILLS.map((s) => [s.id, s.title]),
);
// ---- предметы и инструменты ----
export const XP_PER_CHOP = 10;
export const ITEM_LOG = 'log';
export const ITEM_TITLES: Record<string, string> = { [ITEM_LOG]: 'брёвна' };
export const ITEMS: Record<string, { title: string }> = {
log: { title: 'брёвна' },
copper_ore: { title: 'медная руда' },
iron_ore: { title: 'железная руда' },
copper_bar: { title: 'медный слиток' },
iron_bar: { title: 'железный слиток' },
axe_rusty: { title: 'ржавый топор' },
axe_iron: { title: 'железный топор' },
pick_rusty: { title: 'ржавая кирка' },
pick_iron: { title: 'железная кирка' },
};
export function itemTitle(id: string): string {
return ITEMS[id]?.title ?? id;
}
/** Выдаются при первом спавне. */
export const START_ITEMS = ['axe_rusty', 'pick_rusty'] as const;
export interface ToolDef {
item: string;
kind: 'axe' | 'pick';
/** Во сколько раз быстрее базовый цикл действия. */
multiplier: number;
}
export const TOOLS: ToolDef[] = [
{ item: 'axe_rusty', kind: 'axe', multiplier: 1 },
{ item: 'axe_iron', kind: 'axe', multiplier: 2 },
{ item: 'pick_rusty', kind: 'pick', multiplier: 1 },
{ item: 'pick_iron', kind: 'pick', multiplier: 2 },
];
// ---- рецепты (ковка) ----
export interface RecipeDef {
id: string;
title: string;
output: { item: string; qty: number };
inputs: { item: string; qty: number }[];
cycleMs: number;
/** Требование к навыку кузнечного дела. */
level: number;
xp: number;
aliases: string[];
}
export const RECIPES: RecipeDef[] = [
{
id: 'copper_bar',
title: 'медный слиток',
output: { item: 'copper_bar', qty: 1 },
inputs: [{ item: 'copper_ore', qty: 2 }],
cycleMs: 8_000,
level: 1,
xp: 15,
aliases: ['медный слиток', 'медь'],
},
{
id: 'iron_bar',
title: 'железный слиток',
output: { item: 'iron_bar', qty: 1 },
inputs: [{ item: 'iron_ore', qty: 2 }],
cycleMs: 10_000,
level: 2,
xp: 25,
aliases: ['железный слиток', 'железо'],
},
{
id: 'axe_iron',
title: 'железный топор',
output: { item: 'axe_iron', qty: 1 },
inputs: [
{ item: 'copper_bar', qty: 1 },
{ item: 'iron_bar', qty: 2 },
],
cycleMs: 15_000,
level: 3,
xp: 50,
aliases: ['железный топор', 'топор'],
},
{
id: 'pick_iron',
title: 'железная кирка',
output: { item: 'pick_iron', qty: 1 },
inputs: [
{ item: 'copper_bar', qty: 1 },
{ item: 'iron_bar', qty: 2 },
],
cycleMs: 15_000,
level: 3,
xp: 50,
aliases: ['железная кирка', 'кирка'],
},
];
export function findRecipe(text: string): RecipeDef | undefined {
const q = text.trim().toLowerCase().replace(/\s+/g, ' ');
return RECIPES.find((r) => r.id === q || r.title === q || r.aliases.includes(q));
}
// ---- прочее ----
export const WALK_SPEED = 110; // px/сек
export const COMMAND_COOLDOWN_MS = 1200; // антиспам на смену действия
/** OSRS/Melvor-подобная кривая: суммарный XP, нужный для уровня. */
/** OSRS/Melvor-подобная кривая: суммарный XP, нужный для уровня (у каждого навыка своя). */
export function totalXpForLevel(level: number): number {
return Math.floor(((level - 1) + 300 * 2 ** ((level - 1) / 7)) / 4);
}
+26 -11
View File
@@ -8,7 +8,7 @@
* дельты вместо полных снапшотов, игровые события, серверная камера.
*/
export const PROTOCOL_VERSION = 2;
export const PROTOCOL_VERSION = 3;
export interface ZoneDef {
id: string;
@@ -19,14 +19,26 @@ export interface ZoneDef {
h: number;
}
export interface PropDef {
kind: 'anvil';
x: number;
y: number;
}
export interface WorldDef {
w: number;
h: number;
spawn: { x: number; y: number };
zones: ZoneDef[];
props: PropDef[];
}
export type AvatarAction = 'idle' | 'chop';
export type AvatarAction = 'idle' | 'chop' | 'mine' | 'smith';
export interface SkillState {
xp: number;
level: number;
}
/**
* Аватар — декларативное состояние: позиция анимируется клиентом
@@ -46,16 +58,19 @@ export interface AvatarState {
moveStart: number;
moving: boolean;
action: AvatarAction;
/** Чем работает в данный момент: item id инструмента, 'hammer' или 'none'. */
tool: string;
nodeId: string | null;
actionStart: number | null;
actionDur: number | null;
level: number;
xp: number;
skills: Record<string, SkillState>;
}
export interface NodeState {
id: string;
kind: 'tree';
kind: 'tree' | 'rock';
/** Для руды — какая жила; для дерева отсутствует. */
variant?: string;
x: number;
y: number;
hp: number;
@@ -68,9 +83,7 @@ export interface ViewerStat {
id: string;
name: string;
color: string;
level: number;
xp: number;
logs: number;
skills: Record<string, SkillState>;
action: AvatarAction;
}
@@ -85,9 +98,11 @@ export interface CameraState {
export type GameEvent =
| { k: 'spawn'; userId: string; name: string; color: string }
| { k: 'item'; userId: string; name: string; color: string; item: string; qty: number }
| { k: 'xp'; userId: string; name: string; color: string; amount: number; total: number }
| { k: 'levelup'; userId: string; name: string; color: string; level: number }
| { k: 'fell'; userId: string; name: string; color: string; nodeId: string };
| { k: 'xp'; userId: string; name: string; color: string; skill: string; amount: number; total: number }
| { k: 'levelup'; userId: string; name: string; color: string; skill: string; level: number }
| { k: 'fell'; userId: string; name: string; color: string; nodeId: string }
| { k: 'depleted'; userId: string; name: string; color: string; nodeId: string }
| { k: 'blocked'; userId: string; name: string; color: string; reason: string };
export interface WelcomeMsg {
t: 'welcome';