feat: add server and frontend initial - wood chopping

This commit is contained in:
2026-09-05 16:37:45 +05:00
parent c79681dc59
commit f6c338e1b0
28 changed files with 1857 additions and 195 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* Контент M1: мир, деревья, экономика рубки.
* Баланс правится данными здесь, без кода (принцип «контент как данные»).
*/
import type { ZoneDef } from './protocol';
export const WORLD = { w: 1280, 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 interface TreeSpot {
x: number;
y: number;
}
export const TREE_SPOTS: TreeSpot[] = [
{ x: 520, y: 320 },
{ x: 680, y: 430 },
{ x: 860, y: 250 },
{ x: 430, y: 470 },
{ x: 640, y: 180 },
{ x: 950, y: 470 },
{ x: 780, y: 560 },
];
export const TREE = { maxHp: 5, respawnMs: 60_000, chopDurMs: 15_000 } as const;
export const XP_PER_CHOP = 10;
export const ITEM_LOG = 'log';
export const ITEM_TITLES: Record<string, string> = { [ITEM_LOG]: 'брёвна' };
export const WALK_SPEED = 110; // px/сек
export const COMMAND_COOLDOWN_MS = 1200; // антиспам на смену действия
/** OSRS/Melvor-подобная кривая: суммарный XP, нужный для уровня. */
export function totalXpForLevel(level: number): number {
return Math.floor(((level - 1) + 300 * 2 ** ((level - 1) / 7)) / 4);
}
const LEVEL_XP: number[] = Array.from({ length: 99 }, (_, i) => totalXpForLevel(i + 1));
export function levelForTotalXp(xp: number): number {
let level = 1;
for (let l = 2; l <= 99; l++) {
const need = LEVEL_XP[l - 1];
if (need === undefined || xp < need) break;
level = l;
}
return level;
}
/** Палитра для ников без цвета в чате и для фейковых зрителей. */
const PALETTE = [
'#ff8c3b', '#5bd7ff', '#b58cff', '#7ee2a8', '#ffd166',
'#ff6b9d', '#8ad4a1', '#f4a4c0', '#a3c8ff', '#d9c17e',
];
export function colorForName(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
const c = PALETTE[h % PALETTE.length];
return c ?? '#ff8c3b';
}
+1
View File
@@ -1 +1,2 @@
export * from './protocol';
export * from './content';
+93 -11
View File
@@ -3,39 +3,121 @@
* Клиенты зависят только от него; язык сервера можно сменить позже.
* Версия проверяется в рукопожатии (hello/welcome), чтобы OBS со старым
* бандлом не висел молча.
*
* v2: мир с нодами ресурсов, декларативное движение (x→tx со скоростью),
* дельты вместо полных снапшотов, игровые события, серверная камера.
*/
export const PROTOCOL_VERSION = 1;
export const PROTOCOL_VERSION = 2;
export interface ZoneDef {
id: string;
name: string;
x: number;
y: number;
w: number;
h: number;
}
export interface WorldDef {
w: number;
h: number;
spawn: { x: number; y: number };
zones: ZoneDef[];
}
export type AvatarAction = 'idle' | 'chop';
/**
* Аватар — декларативное состояние: позиция анимируется клиентом
* (из x в tx со скоростью speed, начиная с moveStart), прогресс действия
* считается от actionStart/actionDur. Сервер остаётся авторитетом:
* каждое сообщение несёт serverNow для выравнивания часов.
*/
export interface AvatarState {
id: string;
name: string;
/** Цвет ника из тегов чата Twitch. */
color: string;
/** Позиция в мировых координатах, px. */
x: number;
y: number;
face: 'left' | 'right';
tx: number;
ty: number;
speed: number;
moveStart: number;
moving: boolean;
action: AvatarAction;
nodeId: string | null;
actionStart: number | null;
actionDur: number | null;
level: number;
xp: number;
}
export interface NodeState {
id: string;
kind: 'tree';
x: number;
y: number;
hp: number;
maxHp: number;
/** server epoch ms, когда нода восстановится; null — нода стоит. */
respawnAt: number | null;
}
export interface ViewerStat {
id: string;
name: string;
color: string;
level: number;
xp: number;
logs: number;
action: AvatarAction;
}
export interface CameraState {
/** Прямоугольник окна камеры в мировых координатах. */
x: number;
y: number;
w: number;
h: number;
}
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 };
export interface WelcomeMsg {
t: 'welcome';
v: number;
/** Период тика симуляции, мс — клиенту для отрисовки прогрессов. */
tickMs: number;
serverNow: number;
world: WorldDef;
}
/** M0: полный снапшот каждый тик; дельты появятся с M1. */
export interface SnapshotMsg {
t: 'snapshot';
tick: number;
serverNow: number;
camera: CameraState;
avatars: AvatarState[];
nodes: NodeState[];
stats: ViewerStat[];
}
export type ServerMessage = WelcomeMsg | SnapshotMsg;
export interface DeltaMsg {
t: 'delta';
tick: number;
serverNow: number;
camera?: CameraState;
avatars?: AvatarState[];
nodes?: NodeState[];
stats?: ViewerStat[];
events?: GameEvent[];
}
export type ClientMessage = {
t: 'hello';
v: number;
};
export type ServerMessage = WelcomeMsg | SnapshotMsg | DeltaMsg;
export type ClientMessage = { t: 'hello'; v: number };