feat: add server and frontend initial - wood chopping
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Скопировать в .env и настроить под себя. .env в git не попадает.
|
||||
|
||||
# --- разработка без живого чата ---
|
||||
# фейковые зрители, генерируют !рубить/!стоп
|
||||
FAKE_VIEWERS=5
|
||||
# dev-эндпоинты: POST /dev/command {"name":"Ник","text":"!рубить"}, GET /dev/state
|
||||
DEV_HTTP=1
|
||||
|
||||
# --- Twitch (см. docs/twitch-setup.md) ---
|
||||
# TWITCH_CLIENT_ID=
|
||||
# TWITCH_CLIENT_SECRET=
|
||||
# TWITCH_CHANNEL=
|
||||
# TWITCH_BOT_TOKEN=
|
||||
# TWITCH_BOT_REFRESH=
|
||||
|
||||
# объявлять левелапы в чат (троттлинг 60 сек)
|
||||
# CHAT_ANNOUNCE_LEVELUP=1
|
||||
|
||||
# --- прочее ---
|
||||
# PORT=3000
|
||||
# TICK_MS=1000
|
||||
# SQLITE_PATH=data/idle-xboct.db
|
||||
@@ -3,7 +3,7 @@
|
||||
Idle-игра для Twitch-чата: аватары зрителей живут на экране стрима, команды чата
|
||||
управляют ими. План и все решения — в [docs/PLAN.md](docs/PLAN.md).
|
||||
|
||||
## Запуск (M0 — каркас)
|
||||
## Запуск (M1 — вертикальный срез)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
@@ -12,9 +12,23 @@ pnpm dev
|
||||
|
||||
- сервер: http://localhost:3000 — WS на `/ws`, здоровье на `/healthz`
|
||||
- overlay (browser source для OBS): http://localhost:5173
|
||||
- web-мир: http://localhost:5174
|
||||
- web-мир: http://localhost:5174 — топ зрителей и лента событий
|
||||
|
||||
Сборка клиентов (`pnpm build`) — сервер начнёт раздавать их сам: `/overlay/` и `/web/`
|
||||
на порту 3000 (это режим для OBS, без vite dev-сервера).
|
||||
|
||||
Twitch-подключение — с M1, чек-лист получения токенов: [docs/twitch-setup.md](docs/twitch-setup.md).
|
||||
### Разработка без живого стрима
|
||||
|
||||
Скопируй `.env.example` в `.env` и включи `FAKE_VIEWERS=5` — фейковые зрители начнут
|
||||
писать `!рубить`/`!стоп`. С `DEV_HTTP=1` можно дёргать вручную:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/dev/command -H "content-type: application/json" \
|
||||
-d '{"name":"Тестер","text":"!рубить"}'
|
||||
```
|
||||
|
||||
Живой чат: заполни `TWITCH_*` по чек-листу [docs/twitch-setup.md](docs/twitch-setup.md) —
|
||||
бот подхватит их при старте автоматически.
|
||||
|
||||
Дымовой тест: сервер с `DEV_HTTP=1 SQLITE_PATH=:memory:`, затем `pnpm --filter @idle/server smoke`.
|
||||
|
||||
|
||||
+9
-1
@@ -15,12 +15,17 @@
|
||||
| Клиенты | Svelte 5 + PixiJS 8 (8.x) + bitecs 0.4 |
|
||||
| Сервер | Node.js, `node:http` + `ws` + `sirv` (без фреймворка), симуляция на bitecs |
|
||||
| Twitch | `@twurple/easy-bot` (чтение команд + ответы в чат), `@twurple/eventsub-ws` (Channel Points) |
|
||||
| БД | SQLite (`better-sqlite3`) через тонкий слой репозиториев |
|
||||
| БД | SQLite через тонкий слой persist. M1: встроенный `node:sqlite` (Node ≥24, без нативных сборок и install-скриптов); замена на `better-sqlite3`/др. — при необходимости, не трогая остальной код |
|
||||
|
||||
Решения по ECS: **bitecs, новый API 0.4** (в 0.4 API переписан; старый доступен как `bitecs/legacy`).
|
||||
Запасной вариант — koota (pmndrs), если новый API окажется слишком зубодробительным.
|
||||
miniplex отклонён (объектный оверхед + не развивается с 2023).
|
||||
|
||||
**Уточнение по факту M1:** вертикальный срез сделан на plain TS за узким интерфейсом
|
||||
`SimWorld` (сущностей — десятки, выгод от ECS ноль, а 0.4 требует чтения исходников).
|
||||
Решение по ECS — в M2 по факту роста: bitecs 0.4, koota или остаться на plain TS,
|
||||
если масштабы не потребуют.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```txt
|
||||
@@ -195,6 +200,9 @@ Twitch не используем. На старте все зрители с о
|
||||
3. **Решено: идём полностью на бесплатном.** База — Kenney (CC0); OpenGameArt — с проверкой
|
||||
лицензии каждого ассета. Платные паки (LimeZu ≈ $5) — опция на потом, если захочется
|
||||
единого стиля
|
||||
4. **M1 — процедурные спрайты в коде** (человечек, топор, деревья рисуются Pixi-графикой):
|
||||
ноль лицензионных рисков и мгновенные правки; паки подключаем с M2, заменив
|
||||
`AvatarView`/`TreeView` в `render`
|
||||
|
||||
## Идеи на потом
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { mountWorld, type WorldHandle } from '@idle/render';
|
||||
import { connectWorld, type ConnectHandlers, type NetStatus } from './net';
|
||||
import { connectWorld, type NetStatus } from './net';
|
||||
|
||||
const STATUS_TEXT: Record<NetStatus, string> = {
|
||||
connecting: 'подключение…',
|
||||
@@ -19,13 +19,14 @@
|
||||
if (host) {
|
||||
void mountWorld(host, { backgroundAlpha: 0 }).then((w) => {
|
||||
world = w;
|
||||
const handlers: ConnectHandlers = {
|
||||
disposeNet = connectWorld({
|
||||
onStatus: (s) => {
|
||||
status = s;
|
||||
},
|
||||
onSnapshot: (snap) => world?.applySnapshot(snap.avatars),
|
||||
};
|
||||
disposeNet = connectWorld(handlers);
|
||||
onWelcome: (msg) => world?.setWelcome(msg),
|
||||
onSnapshot: (msg) => world?.applySnapshot(msg),
|
||||
onDelta: (msg) => world?.applyDelta(msg),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,6 +40,10 @@
|
||||
<div class="root">
|
||||
<div class="world" bind:this={host}></div>
|
||||
<div class="badge">{STATUS_TEXT[status]}</div>
|
||||
<!-- онбординг — в самом виджете, чат ботом не засоряем -->
|
||||
{#if status === 'online'}
|
||||
<div class="hint">!рубить — валить лес · !стоп — отдых</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -60,4 +65,16 @@
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font: 13px/1.4 monospace;
|
||||
color: #eef4fa;
|
||||
background: rgba(16, 19, 26, 0.6);
|
||||
padding: 5px 14px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage } from '@idle/shared';
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type DeltaMsg, type ServerMessage, type SnapshotMsg, type WelcomeMsg } from '@idle/shared';
|
||||
|
||||
export type Snapshot = Extract<ServerMessage, { t: 'snapshot' }>;
|
||||
export type NetStatus = 'connecting' | 'online' | 'reconnecting';
|
||||
|
||||
export function wsUrl(): string {
|
||||
@@ -13,7 +12,9 @@ export function wsUrl(): string {
|
||||
|
||||
export interface ConnectHandlers {
|
||||
onStatus: (status: NetStatus) => void;
|
||||
onSnapshot: (snap: Snapshot) => void;
|
||||
onWelcome: (msg: WelcomeMsg) => void;
|
||||
onSnapshot: (msg: SnapshotMsg) => void;
|
||||
onDelta: (msg: DeltaMsg) => void;
|
||||
}
|
||||
|
||||
/** Подключение к миру с автопереподключением; возвращает функцию закрытия. */
|
||||
@@ -41,8 +42,11 @@ export function connectWorld(handlers: ConnectHandlers): () => void {
|
||||
}
|
||||
if (msg.t === 'welcome') {
|
||||
handlers.onStatus('online');
|
||||
handlers.onWelcome(msg);
|
||||
} else if (msg.t === 'snapshot') {
|
||||
handlers.onSnapshot(msg);
|
||||
} else if (msg.t === 'delta') {
|
||||
handlers.onDelta(msg);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
|
||||
+148
-29
@@ -1,58 +1,177 @@
|
||||
import { Container, Graphics, Text } from 'pixi.js';
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
|
||||
const SKIN = 0xffe3c0;
|
||||
const LEG_COLOR = 0x3b4657;
|
||||
const CHIP_COLOR = 0xc79b5b;
|
||||
const BAR_BG = 0x10131a;
|
||||
const BAR_FG = 0x9be870;
|
||||
|
||||
interface Chip {
|
||||
g: Graphics;
|
||||
vx: number;
|
||||
vy: number;
|
||||
born: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Заглушка M0: человечек из примитивов. С M1 сюда придут спрайты из паков —
|
||||
* состав (тело + ник над головой) сохранится.
|
||||
* Аватар M1: процедурный человечек (код вместо спрайтов — ноль лицензионных
|
||||
* рисков). С M2 сюда придут текстуры из паков; состав (тело + ник + прогресс)
|
||||
* сохранится. Прогресс рубки клиент считает сам из actionStart/actionDur —
|
||||
* серверу не нужно слать его каждый тик.
|
||||
*/
|
||||
export class AvatarView {
|
||||
readonly container = new Container();
|
||||
private readonly rig = new Container();
|
||||
private readonly legL: Graphics;
|
||||
private readonly legR: Graphics;
|
||||
private readonly tool: Graphics;
|
||||
private readonly label: Text;
|
||||
private targetX: number;
|
||||
private targetY: number;
|
||||
private readonly bar: Graphics;
|
||||
private chips: Chip[] = [];
|
||||
private face: 1 | -1 = 1;
|
||||
private lastChopPhase = 0;
|
||||
private prevNow = 0;
|
||||
private lastLabelText = '';
|
||||
|
||||
constructor(av: AvatarState) {
|
||||
const shadow = new Graphics().ellipse(0, 0, 9, 3.5).fill({ color: 0x000000, alpha: 0.3 });
|
||||
const body = new Graphics()
|
||||
.roundRect(-7, -17, 14, 17, 5)
|
||||
.fill(av.color)
|
||||
.circle(0, -23, 7.5)
|
||||
.fill(0xffe3c0);
|
||||
const shadow = new Graphics().ellipse(0, 1, 9, 3.5).fill({ color: 0x000000, alpha: 0.3 });
|
||||
this.legL = new Graphics().roundRect(-2, -1, 4, 7, 2).fill(LEG_COLOR);
|
||||
this.legR = new Graphics().roundRect(-2, -1, 4, 7, 2).fill(LEG_COLOR);
|
||||
this.legL.position.set(-3, -6);
|
||||
this.legR.position.set(3, -6);
|
||||
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.tool = new Graphics()
|
||||
.rect(-1, -12, 2, 12)
|
||||
.fill(0x8a6238)
|
||||
.poly([-1, -12, 7, -12, 7, -7, -1, -8])
|
||||
.fill(0xb8c4d0);
|
||||
this.tool.position.set(6, -14);
|
||||
this.label = new Text({
|
||||
text: av.name,
|
||||
text: '',
|
||||
style: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
fill: av.color,
|
||||
stroke: { color: 0x10131a, width: 3 },
|
||||
},
|
||||
});
|
||||
this.label.anchor.set(0.5, 1);
|
||||
this.label.y = -36;
|
||||
this.label.y = -42;
|
||||
this.bar = new Graphics();
|
||||
|
||||
this.rig.addChild(shadow, body);
|
||||
this.container.addChild(this.rig, this.label);
|
||||
this.rig.addChild(shadow, this.legL, this.legR, body, head, this.tool);
|
||||
this.container.addChild(this.rig, this.bar, this.label);
|
||||
|
||||
this.targetX = av.x;
|
||||
this.targetY = av.y;
|
||||
this.container.position.set(av.x, av.y);
|
||||
this.apply(av);
|
||||
this.applyLabel(av);
|
||||
this.tool.visible = false;
|
||||
}
|
||||
|
||||
apply(av: AvatarState): void {
|
||||
this.targetX = av.x;
|
||||
this.targetY = av.y;
|
||||
this.rig.scale.x = av.face === 'left' ? -1 : 1;
|
||||
if (this.label.text !== av.name) this.label.text = av.name;
|
||||
if (this.label.style.fill !== av.color) this.label.style.fill = av.color;
|
||||
applyLabel(av: AvatarState): void {
|
||||
const text = `${av.name} [${av.level}]`;
|
||||
if (text !== this.lastLabelText) {
|
||||
this.lastLabelText = text;
|
||||
this.label.text = text;
|
||||
this.label.style.fill = av.color;
|
||||
} else if (this.label.style.fill !== av.color) {
|
||||
this.label.style.fill = av.color;
|
||||
}
|
||||
}
|
||||
|
||||
/** Плавное дотягивание до последнего снапшота — сглаживает дискретность тиков. */
|
||||
tick(dtMs: number): void {
|
||||
const k = 1 - Math.exp(-dtMs / 120);
|
||||
this.container.x += (this.targetX - this.container.x) * k;
|
||||
this.container.y += (this.targetY - this.container.y) * k;
|
||||
/** now — выровненный по серверу epoch ms. */
|
||||
tick(av: AvatarState, now: number): void {
|
||||
this.applyLabel(av);
|
||||
const dt = Math.max(0, Math.min(now - this.prevNow, 100));
|
||||
this.prevNow = now;
|
||||
|
||||
// позиция: декларативный твин из протокола
|
||||
let x = av.x;
|
||||
let y = av.y;
|
||||
if (av.moving) {
|
||||
const dx = av.tx - av.x;
|
||||
const dy = av.ty - av.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const k = Math.min(((now - av.moveStart) / 1000) * av.speed, d);
|
||||
if (d > 0) {
|
||||
x += (dx / d) * k;
|
||||
y += (dy / d) * k;
|
||||
}
|
||||
this.face = dx >= 0 ? 1 : -1;
|
||||
}
|
||||
this.container.position.set(x, y);
|
||||
this.rig.scale.x = this.face;
|
||||
|
||||
const t = now / 1000;
|
||||
if (av.moving) {
|
||||
const swing = Math.sin(t * 12);
|
||||
this.legL.rotation = swing * 0.5;
|
||||
this.legR.rotation = -swing * 0.5;
|
||||
this.rig.y = -Math.abs(Math.cos(t * 12)) * 1.5;
|
||||
this.tool.visible = false;
|
||||
this.bar.visible = false;
|
||||
} else if (av.action === 'chop' && 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);
|
||||
this.tool.visible = true;
|
||||
this.tool.rotation = -1.0 + swing * 1.7;
|
||||
this.rig.y = -swing * 1.5;
|
||||
this.legL.rotation = 0;
|
||||
this.legR.rotation = 0;
|
||||
this.bar.visible = true;
|
||||
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();
|
||||
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;
|
||||
this.tool.visible = false;
|
||||
this.bar.visible = false;
|
||||
this.lastChopPhase = 0;
|
||||
}
|
||||
|
||||
this.tickChips(now, dt);
|
||||
}
|
||||
|
||||
private spawnChips(): void {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const g = new Graphics().rect(-1.5, -1.5, 3, 3).fill(CHIP_COLOR);
|
||||
g.position.set(this.face * 9, -8);
|
||||
this.container.addChild(g);
|
||||
this.chips.push({
|
||||
g,
|
||||
vx: this.face * (30 + Math.random() * 40),
|
||||
vy: -(40 + Math.random() * 50),
|
||||
born: this.prevNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private tickChips(now: number, dt: number): void {
|
||||
if (this.chips.length === 0) return;
|
||||
const s = dt / 1000;
|
||||
this.chips = this.chips.filter((c) => {
|
||||
const age = now - c.born;
|
||||
if (age > 500) {
|
||||
c.g.destroy();
|
||||
return false;
|
||||
}
|
||||
c.vy += 260 * s;
|
||||
c.g.x += c.vx * s;
|
||||
c.g.y += c.vy * s;
|
||||
c.g.alpha = Math.max(0, 1 - age / 500);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.container.destroy({ children: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './avatar';
|
||||
export * from './tree';
|
||||
export * from './world';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Container, Graphics } from 'pixi.js';
|
||||
import type { NodeState } from '@idle/shared';
|
||||
|
||||
const TRUNK = 0x7a5230;
|
||||
const STUMP = 0x9a7448;
|
||||
const LEAF_A = 0x3f8f4f;
|
||||
const LEAF_B = 0x4da25d;
|
||||
const LEAF_C = 0x2f7340;
|
||||
|
||||
/** Дерево-заглушка M1: крона уменьшается с hp, сваленное — пень. */
|
||||
export class TreeView {
|
||||
readonly container = new Container();
|
||||
private readonly trunk = new Graphics().roundRect(-3, -12, 6, 13, 2).fill(TRUNK);
|
||||
private readonly canopy = new Graphics();
|
||||
private lastHp = -1;
|
||||
private lastDown = false;
|
||||
|
||||
constructor(private st: NodeState) {
|
||||
this.container.addChild(this.trunk, this.canopy);
|
||||
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 && this.lastHp === this.st.hp) return;
|
||||
this.lastDown = down;
|
||||
this.lastHp = this.st.hp;
|
||||
this.canopy.clear();
|
||||
if (down) {
|
||||
this.canopy.roundRect(-4, -4, 8, 5, 2).fill(STUMP);
|
||||
return;
|
||||
}
|
||||
const k = this.st.hp / this.st.maxHp;
|
||||
const r = 9 + 11 * k;
|
||||
this.canopy.circle(0, -20, r).fill(LEAF_A);
|
||||
this.canopy.circle(-r * 0.55, -16, r * 0.7).fill(LEAF_B);
|
||||
this.canopy.circle(r * 0.55, -16, r * 0.7).fill(LEAF_C);
|
||||
}
|
||||
}
|
||||
+168
-23
@@ -1,6 +1,16 @@
|
||||
import { Application, Container } from 'pixi.js';
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
import { Application, Container, Graphics } from 'pixi.js';
|
||||
import type {
|
||||
AvatarState,
|
||||
CameraState,
|
||||
DeltaMsg,
|
||||
GameEvent,
|
||||
NodeState,
|
||||
SnapshotMsg,
|
||||
ViewerStat,
|
||||
WelcomeMsg,
|
||||
} from '@idle/shared';
|
||||
import { AvatarView } from './avatar';
|
||||
import { TreeView } from './tree';
|
||||
|
||||
export type WorldStatus = 'connecting' | 'online' | 'reconnecting';
|
||||
|
||||
@@ -9,14 +19,55 @@ export interface MountWorldOptions {
|
||||
backgroundAlpha?: number;
|
||||
background?: number;
|
||||
onStatus?: (status: WorldStatus) => void;
|
||||
onEvent?: (e: GameEvent) => void;
|
||||
onStats?: (s: ViewerStat[]) => void;
|
||||
}
|
||||
|
||||
export interface WorldHandle {
|
||||
applySnapshot(avatars: AvatarState[]): void;
|
||||
setWelcome(w: WelcomeMsg): void;
|
||||
applySnapshot(snap: SnapshotMsg): void;
|
||||
applyDelta(d: DeltaMsg): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** Инициализация pixi-канваса в хосте; состояние мира придёт через applySnapshot. */
|
||||
const GROUND = 0x4f8f56;
|
||||
const GROUND_PATCH = 0x46824d;
|
||||
const GROUND_DOT = 0x5aa061;
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function drawGround(w: number, h: number, spawn: { x: number; y: number }): Graphics {
|
||||
const g = new Graphics();
|
||||
g.rect(0, 0, w, h).fill(GROUND);
|
||||
const rng = mulberry32(42);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
g.ellipse(rng() * w, rng() * h, 15 + rng() * 45, 10 + rng() * 30).fill({
|
||||
color: GROUND_PATCH,
|
||||
alpha: 0.5,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 90; i++) {
|
||||
g.circle(rng() * w, rng() * h, 1 + rng() * 1.5).fill({ color: GROUND_DOT, alpha: 0.7 });
|
||||
}
|
||||
// полянка спавна
|
||||
g.ellipse(spawn.x, spawn.y, 46, 26).fill({ color: 0x6b7280, alpha: 0.35 });
|
||||
g.ellipse(spawn.x, spawn.y, 34, 18).fill({ color: 0x8a919c, alpha: 0.35 });
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мир на канвасе: фон, ноды, аватары и серверная камера-видоискатель.
|
||||
* Состояние приходит из протокола; движение и прогресс дорисовываются локально.
|
||||
*/
|
||||
export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise<WorldHandle> {
|
||||
opts.onStatus?.('connecting');
|
||||
|
||||
@@ -30,33 +81,127 @@ export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}
|
||||
host.appendChild(app.canvas);
|
||||
app.canvas.style.display = 'block';
|
||||
|
||||
const world = new Container();
|
||||
app.stage.addChild(world);
|
||||
const worldLayer = new Container();
|
||||
const entityLayer = new Container();
|
||||
entityLayer.sortableChildren = true;
|
||||
worldLayer.addChild(entityLayer);
|
||||
app.stage.addChild(worldLayer);
|
||||
|
||||
const views = new Map<string, AvatarView>();
|
||||
let groundW = 0;
|
||||
|
||||
let clockOffset = 0; // serverNow - Date.now()
|
||||
let camTarget: CameraState | null = null;
|
||||
const camCur = { x: 0, y: 0, w: 900, h: 560 };
|
||||
|
||||
const avatars = new Map<string, AvatarState>();
|
||||
const avatarViews = new Map<string, AvatarView>();
|
||||
const nodes = new Map<string, NodeState>();
|
||||
const treeViews = new Map<string, TreeView>();
|
||||
|
||||
const serverNow = (): number => Date.now() + clockOffset;
|
||||
|
||||
function syncAvatar(av: AvatarState): void {
|
||||
avatars.set(av.id, av);
|
||||
let view = avatarViews.get(av.id);
|
||||
if (!view) {
|
||||
view = new AvatarView(av);
|
||||
avatarViews.set(av.id, view);
|
||||
entityLayer.addChild(view.container);
|
||||
}
|
||||
view.container.zIndex = av.y;
|
||||
}
|
||||
|
||||
function syncNode(st: NodeState): void {
|
||||
nodes.set(st.id, st);
|
||||
let view = treeViews.get(st.id);
|
||||
if (!view) {
|
||||
view = new TreeView(st);
|
||||
treeViews.set(st.id, view);
|
||||
entityLayer.addChild(view.container);
|
||||
}
|
||||
view.apply(st);
|
||||
}
|
||||
|
||||
function pruneEntities(): void {
|
||||
for (const [id, view] of avatarViews) {
|
||||
if (!avatars.has(id)) {
|
||||
view.destroy();
|
||||
avatarViews.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, view] of treeViews) {
|
||||
if (!nodes.has(id)) {
|
||||
view.container.destroy();
|
||||
treeViews.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeCamera(c: CameraState): void {
|
||||
camTarget = c;
|
||||
// при первом кадре прыгаем сразу, дальше — плавно в тикере
|
||||
if (camCur.w !== c.w || camCur.h !== c.h) {
|
||||
camCur.x = c.x;
|
||||
camCur.y = c.y;
|
||||
camCur.w = c.w;
|
||||
camCur.h = c.h;
|
||||
}
|
||||
}
|
||||
|
||||
app.ticker.add((ticker) => {
|
||||
for (const view of views.values()) view.tick(ticker.deltaMS);
|
||||
const now = serverNow();
|
||||
for (const [id, view] of avatarViews) {
|
||||
const av = avatars.get(id);
|
||||
if (av) {
|
||||
view.tick(av, now);
|
||||
view.container.zIndex = av.y;
|
||||
}
|
||||
}
|
||||
entityLayer.sortChildren();
|
||||
|
||||
if (camTarget) {
|
||||
const k = 1 - Math.exp(-ticker.deltaMS / 400);
|
||||
camCur.x += (camTarget.x - camCur.x) * k;
|
||||
camCur.y += (camTarget.y - camCur.y) * k;
|
||||
}
|
||||
const vw = app.renderer.width;
|
||||
const vh = app.renderer.height;
|
||||
const scale = Math.max(vw / camCur.w, vh / camCur.h);
|
||||
worldLayer.scale.set(scale);
|
||||
worldLayer.position.set(
|
||||
vw / 2 - (camCur.x + camCur.w / 2) * scale,
|
||||
vh / 2 - (camCur.y + camCur.h / 2) * scale,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
applySnapshot(avatars) {
|
||||
for (const av of avatars) {
|
||||
let view = views.get(av.id);
|
||||
if (!view) {
|
||||
view = new AvatarView(av);
|
||||
views.set(av.id, view);
|
||||
world.addChild(view.container);
|
||||
}
|
||||
view.apply(av);
|
||||
}
|
||||
for (const [id, view] of views) {
|
||||
if (!avatars.some((av) => av.id === id)) {
|
||||
view.container.destroy();
|
||||
views.delete(id);
|
||||
}
|
||||
setWelcome(w) {
|
||||
clockOffset = w.serverNow - Date.now();
|
||||
if (groundW !== w.world.w) {
|
||||
groundW = w.world.w;
|
||||
const ground = drawGround(w.world.w, w.world.h, w.world.spawn);
|
||||
ground.zIndex = -1000;
|
||||
entityLayer.addChild(ground);
|
||||
}
|
||||
},
|
||||
applySnapshot(snap) {
|
||||
clockOffset = snap.serverNow - Date.now();
|
||||
mergeCamera(snap.camera);
|
||||
avatars.clear();
|
||||
nodes.clear();
|
||||
for (const av of snap.avatars) syncAvatar(av);
|
||||
for (const st of snap.nodes) syncNode(st);
|
||||
pruneEntities();
|
||||
opts.onStats?.(snap.stats);
|
||||
},
|
||||
applyDelta(d) {
|
||||
clockOffset = d.serverNow - Date.now();
|
||||
if (d.camera) mergeCamera(d.camera);
|
||||
if (d.avatars) for (const av of d.avatars) syncAvatar(av);
|
||||
if (d.nodes) for (const st of d.nodes) syncNode(st);
|
||||
for (const e of d.events ?? []) opts.onEvent?.(e);
|
||||
if (d.stats) opts.onStats?.(d.stats);
|
||||
},
|
||||
destroy() {
|
||||
void app.destroy(true, { children: true });
|
||||
},
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"smoke": "node smoke.mjs",
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@idle/shared": "workspace:*",
|
||||
"@twurple/auth": "^8.0.0",
|
||||
"@twurple/chat": "^8.0.0",
|
||||
"sirv": "^3.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
/* Отправить команду в мир через dev-эндпоинт (сервер должен быть запущен с DEV_HTTP=1):
|
||||
node scripts/dev-cmd.mjs Тестер '!рубить' */
|
||||
const [name, ...rest] = process.argv.slice(2);
|
||||
const text = rest.join(' ');
|
||||
if (!name || !text) {
|
||||
console.error('usage: node scripts/dev-cmd.mjs <ник> <!команда>');
|
||||
process.exit(1);
|
||||
}
|
||||
const res = await fetch('http://localhost:3000/dev/command', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name, text }),
|
||||
});
|
||||
console.log(res.status, await res.text());
|
||||
+49
-19
@@ -1,38 +1,68 @@
|
||||
/* Дымовой тест M0: сервер должен быть запущен. `pnpm --filter @idle/server smoke` */
|
||||
/* Дымовой тест M1: сервер должен быть запущен с DEV_HTTP=1, SQLITE_PATH=:memory:.
|
||||
`pnpm --filter @idle/server smoke` */
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const seen = [];
|
||||
const BASE = 'http://localhost:3000';
|
||||
const WS = 'ws://localhost:3000/ws';
|
||||
|
||||
function connect(onMsg) {
|
||||
const ws = new WebSocket(WS);
|
||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 2 })));
|
||||
ws.on('message', (d) => onMsg(JSON.parse(String(d))));
|
||||
return ws;
|
||||
}
|
||||
|
||||
// 1. welcome + полный снапшот
|
||||
const first = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1 })));
|
||||
ws.on('message', (d) => {
|
||||
seen.push(JSON.parse(String(d)));
|
||||
if (seen.length >= 2) {
|
||||
const ws = connect((m) => {
|
||||
first.push(m);
|
||||
if (first.length >= 2) {
|
||||
ws.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
ws.on('error', reject);
|
||||
setTimeout(() => reject(new Error('timeout waiting for welcome+snapshot')), 5000);
|
||||
setTimeout(() => reject(new Error('timeout waiting welcome+snapshot')), 5000);
|
||||
});
|
||||
const [welcome, snap] = first;
|
||||
console.log('welcome:', JSON.stringify({ t: welcome.t, v: welcome.v, tickMs: welcome.tickMs }));
|
||||
if (welcome.t !== 'welcome' || welcome.v !== 2) throw new Error(`bad welcome: ${JSON.stringify(welcome)}`);
|
||||
if (snap.t !== 'snapshot' || snap.nodes.length !== 7) throw new Error(`bad snapshot: ${snap.t}, nodes=${snap.nodes?.length}`);
|
||||
console.log(`snapshot: деревьев=${snap.nodes.length}, зрителей=${snap.avatars.length}`);
|
||||
|
||||
const [welcome, snap] = seen;
|
||||
console.log('welcome:', JSON.stringify(welcome));
|
||||
console.log(
|
||||
'avatars:',
|
||||
snap.avatars.map((a) => `${a.name}@${a.x},${a.y} ${a.face}`).join(' | '),
|
||||
);
|
||||
if (welcome.t !== 'welcome' || welcome.v !== 1) throw new Error('bad welcome');
|
||||
if (snap.t !== 'snapshot' || snap.avatars.length !== 3) throw new Error('bad snapshot');
|
||||
// 2. команда через dev-эндпоинт
|
||||
const res = await fetch(`${BASE}/dev/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Тестер', text: '!рубить' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`dev command failed: ${res.status} ${await res.text()}`);
|
||||
console.log('dev command: accepted');
|
||||
|
||||
// неверная версия протокола — сервер обязан закрыть сокет с кодом 4001
|
||||
// 3. ждём дельту: аватар «Тестер» должен заспавниться и пойти к дереву
|
||||
const delta = await new Promise((resolve, reject) => {
|
||||
const ws = connect((m) => {
|
||||
if (m.t === 'delta' && m.avatars?.some((a) => a.name === 'Тестер')) {
|
||||
ws.close();
|
||||
resolve(m);
|
||||
}
|
||||
});
|
||||
ws.on('error', reject);
|
||||
setTimeout(() => reject(new Error('no delta with Тестер')), 8000);
|
||||
});
|
||||
const tester = delta.avatars.find((a) => a.name === 'Тестер');
|
||||
console.log(`delta: ${tester.name} moving=${tester.moving} action=${tester.action} target=(${tester.tx},${tester.ty})`);
|
||||
console.log(`events: ${(delta.events ?? []).map((e) => e.k).join(',') || '—'}`);
|
||||
if (!tester.moving || tester.action !== 'idle') throw new Error('Тестер должен идти к дереву (idle + moving)');
|
||||
|
||||
// 4. неверная версия протокола — close 4001
|
||||
const closeCode = await new Promise((resolve) => {
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
const ws = new WebSocket(WS);
|
||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 999 })));
|
||||
ws.on('close', (code) => resolve(code));
|
||||
setTimeout(() => resolve('no-close'), 3000);
|
||||
});
|
||||
console.log('close code for wrong version:', closeCode);
|
||||
if (closeCode !== 4001) throw new Error(`expected close 4001, got ${closeCode}`);
|
||||
|
||||
console.log('WS smoke: OK');
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Крошечный загрузчик .env из корня репо — без зависимостей и работающий в cmd.exe. */
|
||||
function loadDotEnv(): void {
|
||||
const file = path.resolve(__dirname, '../../../.env');
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (!m) continue;
|
||||
const key = m[1];
|
||||
if (!key || key in process.env) continue;
|
||||
process.env[key] = (m[2] ?? '').replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
export interface TwitchConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
channel: string;
|
||||
botToken: string;
|
||||
botRefresh: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
tickMs: number;
|
||||
dbPath: string;
|
||||
/** channelId присутствует везде с первого дня (задел на мультистримерный хостинг). */
|
||||
channelId: string;
|
||||
fakeViewers: number;
|
||||
devHttp: boolean;
|
||||
chatAnnounceLevelUp: boolean;
|
||||
twitch: TwitchConfig | null;
|
||||
}
|
||||
|
||||
function num(name: string, def: number): number {
|
||||
const v = Number(process.env[name]);
|
||||
return Number.isFinite(v) && v > 0 ? v : def;
|
||||
}
|
||||
|
||||
function bool(name: string, def: boolean): boolean {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === '') return def;
|
||||
return v !== '0' && v.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
loadDotEnv();
|
||||
|
||||
const clientId = process.env.TWITCH_CLIENT_ID ?? '';
|
||||
const clientSecret = process.env.TWITCH_CLIENT_SECRET ?? '';
|
||||
const channel = (process.env.TWITCH_CHANNEL ?? '').toLowerCase();
|
||||
const botToken = process.env.TWITCH_BOT_TOKEN ?? '';
|
||||
const botRefresh = process.env.TWITCH_BOT_REFRESH ?? '';
|
||||
const twitch =
|
||||
clientId && clientSecret && channel && botToken && botRefresh
|
||||
? { clientId, clientSecret, channel, botToken, botRefresh }
|
||||
: null;
|
||||
|
||||
return {
|
||||
port: num('PORT', 3000),
|
||||
tickMs: num('TICK_MS', 1000),
|
||||
dbPath: process.env.SQLITE_PATH ?? 'data/idle-xboct.db',
|
||||
channelId: twitch ? twitch.channel : '__local__',
|
||||
fakeViewers: num('FAKE_VIEWERS', 0),
|
||||
devHttp: bool('DEV_HTTP', false),
|
||||
chatAnnounceLevelUp: bool('CHAT_ANNOUNCE_LEVELUP', true),
|
||||
twitch,
|
||||
};
|
||||
}
|
||||
+179
-26
@@ -4,37 +4,156 @@ import { existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import sirv from 'sirv';
|
||||
import { PROTOCOL_VERSION, type ServerMessage } from '@idle/shared';
|
||||
import { WsGateway } from './net/ws';
|
||||
import { stubAvatarsAt } from './sim/stub';
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
WORLD,
|
||||
ZONES,
|
||||
colorForName,
|
||||
type DeltaMsg,
|
||||
type GameEvent,
|
||||
type SnapshotMsg,
|
||||
type WelcomeMsg,
|
||||
} from '@idle/shared';
|
||||
import { loadConfig } from './config';
|
||||
import { Db } from './persist/db';
|
||||
import { CameraController } from './net/camera';
|
||||
import { WsGateway, type WorldSource } from './net/ws';
|
||||
import { SimWorld, type ChatCommand } from './sim/world';
|
||||
import { FakeTwitchSource } from './twitch/fake';
|
||||
import { TwurpleSource } from './twitch/twurple';
|
||||
import type { TwitchSource } from './twitch/source';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000);
|
||||
const TICK_MS = Number(process.env.TICK_MS ?? 1000);
|
||||
const startedAt = Date.now();
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const currentTick = (): number => Math.floor((Date.now() - startedAt) / TICK_MS);
|
||||
|
||||
const snapshot = (): ServerMessage => ({
|
||||
t: 'snapshot',
|
||||
tick: currentTick(),
|
||||
avatars: stubAvatarsAt(Date.now()),
|
||||
// node:sqlite числится экспериментальным — глушим только это предупреждение
|
||||
process.on('warning', (w) => {
|
||||
if (w.name === 'ExperimentalWarning' && /sqlite/i.test(w.message)) return;
|
||||
console.warn(w.toString());
|
||||
});
|
||||
|
||||
const gateway = new WsGateway({ getSnapshot: snapshot, tickMs: TICK_MS });
|
||||
const cfg = loadConfig();
|
||||
const startedAt = Date.now();
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const currentTick = (): number => Math.floor((Date.now() - startedAt) / cfg.tickMs);
|
||||
|
||||
// Собранные клиенты раздаём сами — OBS обращается к одному порту.
|
||||
// В dev клиенты живут на своих vite-портах (5173/5174).
|
||||
const staticMounts = [
|
||||
{ prefix: '/overlay/', dir: path.resolve(__dirname, '../../overlay/dist') },
|
||||
{ prefix: '/web/', dir: path.resolve(__dirname, '../../web/dist') },
|
||||
].map((m) => ({ ...m, middleware: existsSync(m.dir) ? sirv(m.dir) : null }));
|
||||
const db = new Db(cfg.dbPath);
|
||||
const sim = new SimWorld(cfg.channelId, db.loadViewers(cfg.channelId));
|
||||
const camera = new CameraController();
|
||||
|
||||
function handleCommand(cmd: ChatCommand): void {
|
||||
const before = sim.avatars.size;
|
||||
sim.handleCommand(cmd);
|
||||
if (sim.avatars.size > before) {
|
||||
console.log(`[game] новый зритель в мире: ${cmd.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- источники команд чата ----
|
||||
|
||||
const sources: TwitchSource[] = [];
|
||||
|
||||
if (cfg.twitch) {
|
||||
const src = new TwurpleSource(cfg.twitch, handleCommand);
|
||||
void src
|
||||
.start()
|
||||
.then(() => console.log(`[twitch] бот слушает чат канала ${cfg.twitch?.channel}`))
|
||||
.catch((e) => console.error('[twitch] не запустился (проверь токены/сигнатуры twurple):', e));
|
||||
sources.push(src);
|
||||
} else {
|
||||
console.log('[twitch] TWITCH_* не заданы — живой чат выключен (см. docs/twitch-setup.md)');
|
||||
}
|
||||
|
||||
if (cfg.fakeViewers > 0) {
|
||||
const fake = new FakeTwitchSource(cfg.channelId, cfg.fakeViewers, handleCommand);
|
||||
void fake.start();
|
||||
sources.push(fake);
|
||||
console.log(`[fake] ${cfg.fakeViewers} фейковых зрителей генерируют команды`);
|
||||
}
|
||||
|
||||
// значимые события пишем первым ответившим источником, с его троттлингом
|
||||
const announce = (text: string): boolean => sources.some((s) => s.announce(text));
|
||||
|
||||
// ---- источник мира для WS-шлюза ----
|
||||
|
||||
const world: WorldSource = {
|
||||
welcome(): WelcomeMsg {
|
||||
return {
|
||||
t: 'welcome',
|
||||
v: PROTOCOL_VERSION,
|
||||
tickMs: cfg.tickMs,
|
||||
serverNow: Date.now(),
|
||||
world: { w: WORLD.w, h: WORLD.h, spawn: { ...WORLD.spawn }, zones: ZONES },
|
||||
};
|
||||
},
|
||||
snapshot(): SnapshotMsg {
|
||||
return {
|
||||
t: 'snapshot',
|
||||
tick: currentTick(),
|
||||
serverNow: Date.now(),
|
||||
camera: camera.state,
|
||||
avatars: [...sim.avatars.values()].map((av) => sim.serializeAvatar(av)),
|
||||
nodes: sim.serializeTrees(),
|
||||
stats: sim.stats(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const gateway = new WsGateway(world);
|
||||
|
||||
// ---- тик ----
|
||||
|
||||
function buildDelta(events: GameEvent[], camChanged: boolean): DeltaMsg | null {
|
||||
const avatars = sim.takeDirtyAvatars();
|
||||
const nodes = sim.takeDirtyTrees();
|
||||
const stats = sim.takeStatsIfDirty();
|
||||
if (avatars.length === 0 && nodes.length === 0 && !stats && events.length === 0 && !camChanged) {
|
||||
return null;
|
||||
}
|
||||
const msg: DeltaMsg = { t: 'delta', tick: currentTick(), serverNow: Date.now() };
|
||||
if (camChanged) msg.camera = camera.state;
|
||||
if (avatars.length > 0) msg.avatars = avatars;
|
||||
if (nodes.length > 0) msg.nodes = nodes;
|
||||
if (stats) msg.stats = stats;
|
||||
if (events.length > 0) msg.events = events;
|
||||
return msg;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const events = sim.tick(now);
|
||||
const camChanged = camera.tick(now);
|
||||
|
||||
for (const rec of sim.takeDirtyViewers()) db.saveViewer(cfg.channelId, rec);
|
||||
|
||||
for (const ev of events) {
|
||||
if (ev.k === 'levelup' && cfg.chatAnnounceLevelUp) {
|
||||
announce(`🌲 ${ev.name} вырос(ла) до уровня ${ev.level} в рубке леса!`);
|
||||
}
|
||||
}
|
||||
|
||||
const delta = buildDelta(events, camChanged);
|
||||
if (delta) gateway.broadcast(delta);
|
||||
}, cfg.tickMs);
|
||||
|
||||
// ---- http: healthz, dev-эндпоинты, раздача собранных клиентов ----
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage, cb: (body: string) => void): void {
|
||||
let data = '';
|
||||
req.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
if (data.length > 10_000) req.destroy();
|
||||
});
|
||||
req.on('end', () => cb(data));
|
||||
}
|
||||
|
||||
const staticMounts = [
|
||||
{ prefix: '/overlay/', dir: path.resolve(__dirname, '../../overlay/dist') },
|
||||
{ prefix: '/web/', dir: path.resolve(__dirname, '../../web/dist') },
|
||||
].map((m) => ({ ...m, middleware: existsSync(m.dir) ? sirv(m.dir) : null }));
|
||||
|
||||
function handleHttp(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const p = url.pathname;
|
||||
@@ -43,12 +162,45 @@ function handleHttp(req: IncomingMessage, res: ServerResponse): void {
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
protocol: PROTOCOL_VERSION,
|
||||
tickMs: TICK_MS,
|
||||
tickMs: cfg.tickMs,
|
||||
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
viewers: sim.avatars.size,
|
||||
channel: cfg.channelId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (cfg.devHttp && p === '/dev/command' && req.method === 'POST') {
|
||||
readBody(req, (body) => {
|
||||
try {
|
||||
const parsed = JSON.parse(body || '{}') as { name?: string; color?: string; text?: string };
|
||||
const name = (parsed.name ?? '').trim();
|
||||
const text = (parsed.text ?? '').trim();
|
||||
if (!name || !text) {
|
||||
json(res, 400, { error: 'нужны name и text' });
|
||||
return;
|
||||
}
|
||||
handleCommand({
|
||||
channelId: cfg.channelId,
|
||||
userId: `dev:${name}`,
|
||||
login: name,
|
||||
name,
|
||||
color: parsed.color ?? colorForName(name),
|
||||
text,
|
||||
});
|
||||
json(res, 200, { ok: true });
|
||||
} catch (e) {
|
||||
json(res, 400, { error: String(e) });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (cfg.devHttp && p === '/dev/state' && req.method === 'GET') {
|
||||
json(res, 200, world.snapshot());
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of staticMounts) {
|
||||
if (!m.middleware) continue;
|
||||
const base = m.prefix.slice(0, -1);
|
||||
@@ -81,13 +233,14 @@ server.on('upgrade', (req, socket, head) => {
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => gateway.broadcast(snapshot()), TICK_MS);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[idle-xboct] http://localhost:${PORT} — протокол v${PROTOCOL_VERSION}, тик ${TICK_MS} мс`);
|
||||
server.listen(cfg.port, () => {
|
||||
console.log(`[idle-xboct] http://localhost:${cfg.port} — протокол v${PROTOCOL_VERSION}, тик ${cfg.tickMs} мс, канал «${cfg.channelId}»`);
|
||||
console.log(`[idle-xboct] overlay: http://localhost:5173 (dev) | web: http://localhost:5174 (dev)`);
|
||||
const built = staticMounts.filter((m) => m.middleware).map((m) => m.prefix);
|
||||
if (built.length > 0) {
|
||||
console.log(`[idle-xboct] раздаю сборки: ${built.join(', ')}`);
|
||||
}
|
||||
if (cfg.devHttp) {
|
||||
console.log(`[dev] POST /dev/command {"name":"Ник","text":"!рубить"} | GET /dev/state`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ZONES, type CameraState, type ZoneDef } from '@idle/shared';
|
||||
|
||||
const CAM_W = 900;
|
||||
const CAM_H = 560;
|
||||
const DRIFT_X = 60;
|
||||
const DRIFT_Y = 30;
|
||||
|
||||
/**
|
||||
* Камера стрима авторитарна на сервере — все клиенты видят одно и то же.
|
||||
* M1: одна зона, камера медленно дрейфует вокруг её центра.
|
||||
* M2+: автотур по зонам с активностью (стоянка ~45 сек, плавные переезды).
|
||||
*/
|
||||
export class CameraController {
|
||||
state: CameraState;
|
||||
private readonly seed = Math.random() * 100;
|
||||
|
||||
constructor(private readonly zones: ZoneDef[] = ZONES) {
|
||||
const first = this.zones[0];
|
||||
this.state = first
|
||||
? this.forZone(first, 0)
|
||||
: { x: 0, y: 0, w: CAM_W, h: CAM_H };
|
||||
}
|
||||
|
||||
/** Возвращает true, если состояние изменилось (его стоит разослать). */
|
||||
tick(nowMs: number): boolean {
|
||||
const zone = this.zones[0];
|
||||
if (!zone) return false;
|
||||
const next = this.forZone(zone, nowMs);
|
||||
if (
|
||||
Math.abs(next.x - this.state.x) < 0.5 &&
|
||||
Math.abs(next.y - this.state.y) < 0.5
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.state = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
private forZone(z: ZoneDef, nowMs: number): CameraState {
|
||||
const w = Math.min(CAM_W, z.w);
|
||||
const h = Math.min(CAM_H, z.h);
|
||||
const cx = z.x + z.w / 2 + Math.sin(nowMs / 9000 + this.seed) * DRIFT_X;
|
||||
const cy = z.y + z.h / 2 + Math.cos(nowMs / 11000 + this.seed) * DRIFT_Y;
|
||||
return { x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage } from '@idle/shared';
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage, type SnapshotMsg, type WelcomeMsg } from '@idle/shared';
|
||||
|
||||
declare module 'ws' {
|
||||
interface WebSocket {
|
||||
@@ -9,20 +9,22 @@ declare module 'ws' {
|
||||
}
|
||||
}
|
||||
|
||||
interface GatewayOptions {
|
||||
getSnapshot: () => ServerMessage;
|
||||
tickMs: number;
|
||||
/** Что шлюз отдаёт клиенту при подключении; собирается в index.ts из sim + камеры. */
|
||||
export interface WorldSource {
|
||||
welcome(): WelcomeMsg;
|
||||
snapshot(): SnapshotMsg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Шлюз WS: рукопожатие по версии протокола, welcome + снапшот новому клиенту,
|
||||
* broadcast снапшота каждый тик, heartbeat для мёртвых OBS-сокетов.
|
||||
* Шлюз WS: рукопожатие по версии протокола, welcome + полный снапшот новому
|
||||
* клиенту, далее broadcast дельт (их собирает index), heartbeat для мёртвых
|
||||
* OBS-сокетов.
|
||||
*/
|
||||
export class WsGateway {
|
||||
private readonly wss = new WebSocketServer({ noServer: true });
|
||||
private readonly clients = new Set<WebSocket>();
|
||||
|
||||
constructor(private readonly opts: GatewayOptions) {
|
||||
constructor(private readonly source: WorldSource) {
|
||||
this.wss.on('connection', (ws) => this.onConnection(ws));
|
||||
setInterval(() => this.sweep(), 30_000).unref();
|
||||
}
|
||||
@@ -63,8 +65,8 @@ export class WsGateway {
|
||||
}
|
||||
|
||||
this.clients.add(ws);
|
||||
this.send(ws, { t: 'welcome', v: PROTOCOL_VERSION, tickMs: this.opts.tickMs });
|
||||
this.send(ws, this.opts.getSnapshot());
|
||||
this.send(ws, this.source.welcome());
|
||||
this.send(ws, this.source.snapshot());
|
||||
});
|
||||
|
||||
ws.on('close', () => this.clients.delete(ws));
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import type { ViewerRecord } from '../sim/world';
|
||||
|
||||
/**
|
||||
* Тонкий слой persist на встроенном node:sqlite — без нативных сборок и
|
||||
* install-скриптов. При необходимости заменяется (better-sqlite3/postgres)
|
||||
* без изменений в остальном коде.
|
||||
*/
|
||||
export class Db {
|
||||
private readonly db: DatabaseSync;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
if (dbPath !== ':memory:') {
|
||||
mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
}
|
||||
this.db = new DatabaseSync(dbPath);
|
||||
this.db.exec('PRAGMA journal_mode = WAL');
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS viewers (
|
||||
channel_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
logs INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (channel_id, user_id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
loadViewers(channelId: string): ViewerRecord[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT user_id, name, color, xp, logs FROM viewers WHERE channel_id = ?')
|
||||
.all(channelId) as Array<{ user_id: string; name: string; color: string; xp: number; logs: number }>;
|
||||
return rows.map((r) => ({ userId: r.user_id, name: r.name, color: r.color, xp: r.xp, logs: r.logs }));
|
||||
}
|
||||
|
||||
saveViewer(channelId: string, v: ViewerRecord): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO viewers (channel_id, user_id, name, color, xp, logs, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(channel_id, user_id) DO UPDATE SET
|
||||
name = excluded.name, color = excluded.color, xp = excluded.xp,
|
||||
logs = excluded.logs, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(channelId, v.userId, v.name, v.color, v.xp, v.logs, Date.now());
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
|
||||
interface Walker {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
y: number;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
/** px/сек */
|
||||
speed: number;
|
||||
/** сдвиг фазы 0..1, чтобы ходоки не шли строем */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
const WALKERS: Walker[] = [
|
||||
{ id: 'stub-1', name: 'XBOCT', color: '#ff8c3b', y: 300, minX: 140, maxX: 820, speed: 90, offset: 0 },
|
||||
{ id: 'stub-2', name: 'pixel_proger', color: '#5bd7ff', y: 360, minX: 220, maxX: 900, speed: 70, offset: 0.35 },
|
||||
{ id: 'stub-3', name: 'viewer_42', color: '#b58cff', y: 240, minX: 80, maxX: 700, speed: 110, offset: 0.7 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Заглушка M0: движение — чистая функция wall-clock.
|
||||
* Сервер мог «не жить» сколько угодно — позиция восстановится из таймстемпа,
|
||||
* как и задумано для ретроактивной симуляции.
|
||||
*/
|
||||
export function stubAvatarsAt(nowMs: number): AvatarState[] {
|
||||
return WALKERS.map((w) => {
|
||||
const length = w.maxX - w.minX;
|
||||
const period = (2 * length) / w.speed;
|
||||
const phase = (((nowMs / 1000) / period) + w.offset) % 1;
|
||||
const leg = phase < 0.5 ? phase * 2 : 2 - phase * 2;
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
color: w.color,
|
||||
x: Math.round(w.minX + length * leg),
|
||||
y: w.y,
|
||||
face: phase < 0.5 ? 'right' : 'left',
|
||||
moving: true,
|
||||
} satisfies AvatarState;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import {
|
||||
COMMAND_COOLDOWN_MS,
|
||||
ITEM_LOG,
|
||||
TREE,
|
||||
TREE_SPOTS,
|
||||
WALK_SPEED,
|
||||
WORLD,
|
||||
XP_PER_CHOP,
|
||||
colorForName,
|
||||
levelForTotalXp,
|
||||
type AvatarAction,
|
||||
type AvatarState,
|
||||
type GameEvent,
|
||||
type NodeState,
|
||||
type ViewerStat,
|
||||
} from '@idle/shared';
|
||||
|
||||
export interface ChatCommand {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
login: string;
|
||||
name: string;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ViewerRecord {
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
xp: number;
|
||||
logs: number;
|
||||
}
|
||||
|
||||
interface SimAvatar extends AvatarState {
|
||||
lastCommandAt: number;
|
||||
pendingNodeId: string | null;
|
||||
logs: number;
|
||||
}
|
||||
|
||||
type SimTree = NodeState;
|
||||
|
||||
const CHOP_ALIASES = new Set(['рубить', 'chop']);
|
||||
const STOP_ALIASES = new Set(['стоп', 'stop']);
|
||||
|
||||
function dist(ax: number, ay: number, bx: number, by: number): number {
|
||||
return Math.hypot(bx - ax, by - ay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Симуляционный слой M1: plain TS за узким интерфейсом (десятки сущностей).
|
||||
* Решение об ECS (bitecs 0.4 / koota) отложено до M2 — см. PLAN.md.
|
||||
*
|
||||
* Правила:
|
||||
* - команды обрабатываются сразу при приходе, не ждут тика;
|
||||
* - аватар продолжает действие, пока не придёт другая команда или !стоп;
|
||||
* - движение декларативно (x→tx, speed, moveStart) — клиент дорисовывает сам.
|
||||
*/
|
||||
export class SimWorld {
|
||||
readonly avatars = new Map<string, SimAvatar>();
|
||||
readonly trees: SimTree[] = TREE_SPOTS.map((t, i) => ({
|
||||
id: `tree-${i + 1}`,
|
||||
kind: 'tree',
|
||||
x: t.x,
|
||||
y: t.y,
|
||||
hp: TREE.maxHp,
|
||||
maxHp: TREE.maxHp,
|
||||
respawnAt: null,
|
||||
}));
|
||||
|
||||
private readonly dirtyAvatars = new Set<string>();
|
||||
private readonly dirtyTrees = new Set<string>();
|
||||
private readonly dirtyViewers = new Map<string, ViewerRecord>();
|
||||
private readonly preloaded = new Map<string, ViewerRecord>();
|
||||
private statsDirty = true;
|
||||
|
||||
constructor(
|
||||
private readonly channelId: string,
|
||||
preloaded: ViewerRecord[],
|
||||
) {
|
||||
for (const r of preloaded) this.preloaded.set(r.userId, r);
|
||||
}
|
||||
|
||||
handleCommand(cmd: ChatCommand, now = Date.now()): void {
|
||||
if (cmd.channelId !== this.channelId) return;
|
||||
if (!cmd.text.startsWith('!')) return;
|
||||
const word = (cmd.text.slice(1).split(/\s+/)[0] ?? '').toLowerCase();
|
||||
if (CHOP_ALIASES.has(word)) this.doChop(cmd, now);
|
||||
else if (STOP_ALIASES.has(word)) this.doStop(cmd, now);
|
||||
}
|
||||
|
||||
tick(now: number): GameEvent[] {
|
||||
const events: GameEvent[] = [];
|
||||
|
||||
for (const tree of this.trees) {
|
||||
if (tree.respawnAt !== null && now >= tree.respawnAt) {
|
||||
tree.respawnAt = null;
|
||||
tree.hp = tree.maxHp;
|
||||
this.dirtyTrees.add(tree.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const av of this.avatars.values()) {
|
||||
if (av.moving) {
|
||||
const travelMs = (dist(av.x, av.y, av.tx, av.ty) / av.speed) * 1000;
|
||||
if (now >= av.moveStart + travelMs) {
|
||||
av.x = av.tx;
|
||||
av.y = av.ty;
|
||||
av.moving = false;
|
||||
this.dirtyAvatars.add(av.id);
|
||||
const tree = av.pendingNodeId ? this.treeById(av.pendingNodeId) : undefined;
|
||||
if (tree && tree.respawnAt === null) {
|
||||
this.beginChop(av, tree, now);
|
||||
} else {
|
||||
av.pendingNodeId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (av.action === 'chop' && av.actionStart !== null) {
|
||||
const dur = av.actionDur ?? TREE.chopDurMs;
|
||||
if (now >= av.actionStart + dur) {
|
||||
const tree = av.nodeId ? this.treeById(av.nodeId) : undefined;
|
||||
if (!tree || tree.respawnAt !== null) {
|
||||
av.action = 'idle';
|
||||
av.nodeId = null;
|
||||
av.actionStart = null;
|
||||
this.dirtyAvatars.add(av.id);
|
||||
this.statsDirty = true;
|
||||
} else {
|
||||
this.completeChopCycle(av, tree, now, events);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// ---- команды ----
|
||||
|
||||
private doChop(cmd: ChatCommand, now: number): void {
|
||||
const av = this.ensureAvatar(cmd, now);
|
||||
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return;
|
||||
av.lastCommandAt = now;
|
||||
if (av.action === 'chop' && !av.moving) return; // уже рубит
|
||||
|
||||
const tree = this.nearestAvailableTree(av);
|
||||
if (!tree) return; // всё вырублено и не отросло — просто ждём
|
||||
|
||||
this.walkTo(av, tree.x, tree.y + 26, now);
|
||||
av.pendingNodeId = tree.id;
|
||||
}
|
||||
|
||||
private doStop(cmd: ChatCommand, now: number): void {
|
||||
const av = this.ensureAvatar(cmd, now);
|
||||
if (now - av.lastCommandAt < COMMAND_COOLDOWN_MS) return;
|
||||
av.lastCommandAt = now;
|
||||
|
||||
if (av.moving) {
|
||||
const p = this.currentPos(av, now);
|
||||
av.x = p.x;
|
||||
av.y = p.y;
|
||||
av.moving = false;
|
||||
}
|
||||
av.pendingNodeId = null;
|
||||
if (av.action !== 'idle') {
|
||||
av.action = 'idle';
|
||||
av.nodeId = null;
|
||||
av.actionStart = null;
|
||||
av.actionDur = null;
|
||||
this.statsDirty = true;
|
||||
}
|
||||
this.dirtyAvatars.add(av.id);
|
||||
}
|
||||
|
||||
private ensureAvatar(cmd: ChatCommand, now: number): SimAvatar {
|
||||
let av = this.avatars.get(cmd.userId);
|
||||
if (!av) {
|
||||
const rec = this.preloaded.get(cmd.userId);
|
||||
av = {
|
||||
id: cmd.userId,
|
||||
name: cmd.name,
|
||||
color: cmd.color || colorForName(cmd.name),
|
||||
x: WORLD.spawn.x + (Math.random() * 60 - 30),
|
||||
y: WORLD.spawn.y + (Math.random() * 40 - 20),
|
||||
tx: WORLD.spawn.x,
|
||||
ty: WORLD.spawn.y,
|
||||
speed: WALK_SPEED,
|
||||
moveStart: 0,
|
||||
moving: false,
|
||||
action: 'idle',
|
||||
nodeId: null,
|
||||
actionStart: null,
|
||||
actionDur: null,
|
||||
level: rec ? levelForTotalXp(rec.xp) : 1,
|
||||
xp: rec?.xp ?? 0,
|
||||
lastCommandAt: 0,
|
||||
pendingNodeId: null,
|
||||
logs: rec?.logs ?? 0,
|
||||
};
|
||||
this.avatars.set(av.id, av);
|
||||
this.dirtyAvatars.add(av.id);
|
||||
this.markViewerDirty(av);
|
||||
this.statsDirty = true;
|
||||
} else if (av.name !== cmd.name || av.color !== cmd.color) {
|
||||
av.name = cmd.name;
|
||||
av.color = cmd.color || av.color;
|
||||
this.dirtyAvatars.add(av.id);
|
||||
this.markViewerDirty(av);
|
||||
}
|
||||
return av;
|
||||
}
|
||||
|
||||
private walkTo(av: SimAvatar, tx: number, ty: number, now: number): void {
|
||||
if (av.moving) {
|
||||
const p = this.currentPos(av, now);
|
||||
av.x = p.x;
|
||||
av.y = p.y;
|
||||
}
|
||||
av.tx = tx;
|
||||
av.ty = ty;
|
||||
av.moveStart = now;
|
||||
av.moving = true;
|
||||
av.action = 'idle';
|
||||
av.actionStart = null;
|
||||
av.actionDur = null;
|
||||
this.dirtyAvatars.add(av.id);
|
||||
this.statsDirty = true;
|
||||
}
|
||||
|
||||
private beginChop(av: SimAvatar, tree: SimTree, now: number): void {
|
||||
av.pendingNodeId = null;
|
||||
av.action = 'chop';
|
||||
av.nodeId = tree.id;
|
||||
av.actionStart = now;
|
||||
av.actionDur = TREE.chopDurMs;
|
||||
this.dirtyAvatars.add(av.id);
|
||||
this.statsDirty = true;
|
||||
}
|
||||
|
||||
private completeChopCycle(av: SimAvatar, tree: SimTree, now: number, events: GameEvent[]): void {
|
||||
const start = av.actionStart;
|
||||
const dur = av.actionDur ?? TREE.chopDurMs;
|
||||
if (start === null) return;
|
||||
|
||||
tree.hp -= 1;
|
||||
this.dirtyTrees.add(tree.id);
|
||||
if (tree.hp <= 0) {
|
||||
tree.respawnAt = now + TREE.respawnMs;
|
||||
events.push({ k: 'fell', userId: av.id, name: av.name, color: av.color, nodeId: tree.id });
|
||||
}
|
||||
|
||||
av.logs += 1;
|
||||
events.push({ k: 'item', userId: av.id, name: av.name, color: av.color, item: ITEM_LOG, qty: 1 });
|
||||
|
||||
av.xp += XP_PER_CHOP;
|
||||
events.push({ k: 'xp', userId: av.id, name: av.name, color: av.color, amount: XP_PER_CHOP, total: av.xp });
|
||||
const level = levelForTotalXp(av.xp);
|
||||
if (level > av.level) {
|
||||
av.level = level;
|
||||
events.push({ k: 'levelup', userId: av.id, name: av.name, color: av.color, level });
|
||||
}
|
||||
this.dirtyAvatars.add(av.id);
|
||||
this.markViewerDirty(av);
|
||||
|
||||
if (tree.respawnAt !== null) {
|
||||
// дерево свалено — аватар отдыхает, пока зритель не отправит к следующему
|
||||
av.action = 'idle';
|
||||
av.nodeId = null;
|
||||
av.actionStart = null;
|
||||
} else {
|
||||
av.actionStart = start + dur; // следующий цикл той же команды
|
||||
}
|
||||
}
|
||||
|
||||
// ---- вспомогательное ----
|
||||
|
||||
private treeById(id: string): SimTree | undefined {
|
||||
return this.trees.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
private nearestAvailableTree(av: SimAvatar): SimTree | undefined {
|
||||
let best: SimTree | undefined;
|
||||
let bestD = Infinity;
|
||||
for (const t of this.trees) {
|
||||
if (t.respawnAt !== null) continue;
|
||||
const d = dist(av.x, av.y, t.x, t.y);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private currentPos(av: SimAvatar, now: number): { x: number; y: number } {
|
||||
if (!av.moving) return { x: av.x, y: av.y };
|
||||
const dx = av.tx - av.x;
|
||||
const dy = av.ty - av.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d === 0) return { x: av.tx, y: av.ty };
|
||||
const k = Math.min(((now - av.moveStart) / 1000) * av.speed, d);
|
||||
return { x: av.x + (dx / d) * k, y: av.y + (dy / d) * k };
|
||||
}
|
||||
|
||||
private markViewerDirty(av: SimAvatar): void {
|
||||
this.dirtyViewers.set(av.id, { userId: av.id, name: av.name, color: av.color, xp: av.xp, logs: av.logs });
|
||||
}
|
||||
|
||||
// ---- выдача для сети/себя ----
|
||||
|
||||
serializeAvatar(av: SimAvatar): AvatarState {
|
||||
return {
|
||||
id: av.id, name: av.name, color: av.color,
|
||||
x: av.x, y: av.y, tx: av.tx, ty: av.ty,
|
||||
speed: av.speed, moveStart: av.moveStart, moving: av.moving,
|
||||
action: av.action, nodeId: av.nodeId, actionStart: av.actionStart, actionDur: av.actionDur,
|
||||
level: av.level, xp: av.xp,
|
||||
};
|
||||
}
|
||||
|
||||
takeDirtyAvatars(): AvatarState[] {
|
||||
if (this.dirtyAvatars.size === 0) return [];
|
||||
const out: AvatarState[] = [];
|
||||
for (const id of this.dirtyAvatars) {
|
||||
const av = this.avatars.get(id);
|
||||
if (av) out.push(this.serializeAvatar(av));
|
||||
}
|
||||
this.dirtyAvatars.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
takeDirtyTrees(): NodeState[] {
|
||||
if (this.dirtyTrees.size === 0) return [];
|
||||
const out: NodeState[] = [];
|
||||
for (const id of this.dirtyTrees) {
|
||||
const t = this.treeById(id);
|
||||
if (t) out.push({ ...t });
|
||||
}
|
||||
this.dirtyTrees.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
takeDirtyViewers(): ViewerRecord[] {
|
||||
const out = [...this.dirtyViewers.values()];
|
||||
this.dirtyViewers.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
serializeTrees(): NodeState[] {
|
||||
return this.trees.map((t) => ({ ...t }));
|
||||
}
|
||||
|
||||
stats(): ViewerStat[] {
|
||||
return [...this.avatars.values()]
|
||||
.map((av) => ({
|
||||
id: av.id, name: av.name, color: av.color,
|
||||
level: av.level, xp: av.xp, logs: av.logs, action: av.action,
|
||||
}))
|
||||
.sort((a, b) => b.xp - a.xp);
|
||||
}
|
||||
|
||||
takeStatsIfDirty(): ViewerStat[] | null {
|
||||
if (!this.statsDirty) return null;
|
||||
this.statsDirty = false;
|
||||
return this.stats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { colorForName } from '@idle/shared';
|
||||
import type { ChatCommand } from '../sim/world';
|
||||
import type { TwitchSource } from './source';
|
||||
|
||||
const FAKE_NAMES = [
|
||||
'Медведь228',
|
||||
'ЛеснойЭльф',
|
||||
'pixel_proger',
|
||||
'Кот_Вася',
|
||||
'viewer_42',
|
||||
'ТихийОмут',
|
||||
'мамкин_дровосек',
|
||||
'ЗлойБобр',
|
||||
];
|
||||
|
||||
interface FakeViewer {
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Фейковые зрители/команды для разработки и проверки без живого стрима.
|
||||
* Включается через FAKE_VIEWERS=N в .env.
|
||||
*/
|
||||
export class FakeTwitchSource implements TwitchSource {
|
||||
private readonly viewers: FakeViewer[];
|
||||
private timers: NodeJS.Timeout[] = [];
|
||||
private lastAnnounce = 0;
|
||||
|
||||
constructor(
|
||||
private readonly channelId: string,
|
||||
viewerCount: number,
|
||||
private readonly onCommand: (cmd: ChatCommand) => void,
|
||||
) {
|
||||
this.viewers = FAKE_NAMES.slice(0, viewerCount).map((name, i) => ({
|
||||
userId: `fake-${i + 1}`,
|
||||
name,
|
||||
color: colorForName(name),
|
||||
}));
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.viewers.forEach((v, i) => this.schedule(v, 1500 + i * 700 + Math.random() * 3000));
|
||||
}
|
||||
|
||||
say(text: string): void {
|
||||
console.log(`[fake:chat] ${text}`);
|
||||
}
|
||||
|
||||
announce(text: string, now = Date.now()): boolean {
|
||||
if (now - this.lastAnnounce < 60_000) return false;
|
||||
this.lastAnnounce = now;
|
||||
this.say(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const t of this.timers) clearTimeout(t);
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
private schedule(v: FakeViewer, delayMs: number): void {
|
||||
this.timers.push(
|
||||
setTimeout(() => {
|
||||
this.fire(v);
|
||||
this.schedule(v, 4000 + Math.random() * 6000);
|
||||
}, delayMs),
|
||||
);
|
||||
}
|
||||
|
||||
private fire(v: FakeViewer): void {
|
||||
const roll = Math.random();
|
||||
const text = roll < 0.75 ? '!рубить' : roll < 0.9 ? '!стоп' : null;
|
||||
if (!text) return;
|
||||
this.onCommand({
|
||||
channelId: this.channelId,
|
||||
userId: v.userId,
|
||||
login: v.name,
|
||||
name: v.name,
|
||||
color: v.color,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { TwitchConfig } from '../config';
|
||||
import type { ChatCommand } from '../sim/world';
|
||||
|
||||
/** Источник команд чата. Локально — twurple-бот; на хостинге — OAuth-flow без изменений остального кода. */
|
||||
export interface TwitchSource {
|
||||
start(): Promise<void>;
|
||||
stop(): void;
|
||||
/** Ответ в чат; дозируется вызывающим кодом. */
|
||||
say(text: string): void;
|
||||
/** Значимые события (например, левелап) — с троттлингом. Возвращает true, если отправлено. */
|
||||
announce(text: string, now?: number): boolean;
|
||||
}
|
||||
|
||||
export type { ChatCommand, TwitchConfig };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { colorForName } from '@idle/shared';
|
||||
import { RefreshingAuthProvider } from '@twurple/auth';
|
||||
import { ChatClient } from '@twurple/chat';
|
||||
import type { TwitchConfig } from '../config';
|
||||
import type { ChatCommand } from '../sim/world';
|
||||
import type { TwitchSource } from './source';
|
||||
|
||||
/**
|
||||
* Живой чат через twurple:RefreshingAuthProvider сам обновляет токены,
|
||||
* вручную перелогиниваться не нужно. Токены — см. docs/twitch-setup.md.
|
||||
* Внимание: путь до live-чата будет впервые проверен при подключении реальных
|
||||
* токенов (сигнатуры twurple могут потребовать мелкой правки).
|
||||
*/
|
||||
export class TwurpleSource implements TwitchSource {
|
||||
private client: ChatClient | null = null;
|
||||
private lastAnnounce = 0;
|
||||
|
||||
constructor(
|
||||
private readonly cfg: TwitchConfig,
|
||||
private readonly onCommand: (cmd: ChatCommand) => void,
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const authProvider = new RefreshingAuthProvider({
|
||||
clientId: this.cfg.clientId,
|
||||
clientSecret: this.cfg.clientSecret,
|
||||
});
|
||||
// twitchtokengenerator выдаёт access на ~4 ч + долгоживущий refresh:
|
||||
// провайдер сам обновит токен до истечения; между рестартами работает refresh
|
||||
await authProvider.addUserForToken({
|
||||
accessToken: this.cfg.botToken,
|
||||
refreshToken: this.cfg.botRefresh,
|
||||
scope: ['chat:read', 'chat:edit'],
|
||||
expiresIn: 3600,
|
||||
obtainmentTimestamp: Date.now(),
|
||||
});
|
||||
authProvider.onRefresh((_userId, token) => {
|
||||
console.log(`[twitch] токен обновлён, действителен ещё ${token.expiresIn ?? '?'} с`);
|
||||
});
|
||||
|
||||
this.client = new ChatClient({ authProvider, channels: [this.cfg.channel] });
|
||||
this.client.onMessage((_channel, _user, text, msg) => {
|
||||
if (!text.startsWith('!')) return;
|
||||
const name = msg.userInfo.displayName || msg.userInfo.userName;
|
||||
this.onCommand({
|
||||
channelId: this.cfg.channel,
|
||||
userId: msg.userInfo.userId,
|
||||
login: msg.userInfo.userName,
|
||||
name,
|
||||
color: msg.userInfo.color || colorForName(name),
|
||||
text,
|
||||
});
|
||||
});
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
say(text: string): void {
|
||||
this.client?.say(this.cfg.channel, text).catch((e) => console.error('[twitch] say:', e));
|
||||
}
|
||||
|
||||
announce(text: string, now = Date.now()): boolean {
|
||||
if (now - this.lastAnnounce < 60_000) return false;
|
||||
this.lastAnnounce = now;
|
||||
this.say(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
void this.client?.quit();
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
@@ -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 +1,2 @@
|
||||
export * from './protocol';
|
||||
export * from './content';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+96
-20
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { ITEM_TITLES, levelForTotalXp, type GameEvent, type ViewerStat } from '@idle/shared';
|
||||
import { mountWorld, type WorldHandle } from '@idle/render';
|
||||
import { connectWorld, type ConnectHandlers, type NetStatus } from './net';
|
||||
import { connectWorld, type NetStatus } from './net';
|
||||
|
||||
const STATUS_TEXT: Record<NetStatus, string> = {
|
||||
connecting: 'подключение…',
|
||||
@@ -9,8 +10,32 @@
|
||||
reconnecting: 'переподключение…',
|
||||
};
|
||||
|
||||
interface FeedItem {
|
||||
id: number;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function eventText(e: GameEvent): string {
|
||||
switch (e.k) {
|
||||
case 'spawn':
|
||||
return `${e.name} пришёл(ла) в мир`;
|
||||
case 'item':
|
||||
return `${e.name}: +${e.qty} ${ITEM_TITLES[e.item] ?? e.item}`;
|
||||
case 'xp':
|
||||
return `${e.name}: +${e.amount} XP · ур.${levelForTotalXp(e.total)}`;
|
||||
case 'levelup':
|
||||
return `⭐ ${e.name}: уровень ${e.level}!`;
|
||||
case 'fell':
|
||||
return `${e.name} свалил(а) дерево!`;
|
||||
}
|
||||
}
|
||||
|
||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||
let status = $state<NetStatus>('connecting');
|
||||
let stats = $state<ViewerStat[]>([]);
|
||||
let feed = $state<FeedItem[]>([]);
|
||||
let feedId = 0;
|
||||
|
||||
onMount(() => {
|
||||
let world: WorldHandle | undefined;
|
||||
@@ -19,13 +44,19 @@
|
||||
if (host) {
|
||||
void mountWorld(host, { background: 0x1d232d }).then((w) => {
|
||||
world = w;
|
||||
const handlers: ConnectHandlers = {
|
||||
disposeNet = connectWorld({
|
||||
onStatus: (s) => {
|
||||
status = s;
|
||||
},
|
||||
onSnapshot: (snap) => world?.applySnapshot(snap.avatars),
|
||||
};
|
||||
disposeNet = connectWorld(handlers);
|
||||
onWelcome: (msg) => world?.setWelcome(msg),
|
||||
onSnapshot: (msg) => world?.applySnapshot(msg),
|
||||
onDelta: (msg) => {
|
||||
world?.applyDelta(msg);
|
||||
for (const e of msg.events ?? []) {
|
||||
feed = [{ id: ++feedId, color: e.color, text: eventText(e) }, ...feed].slice(0, 30);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,20 +78,33 @@
|
||||
|
||||
<aside>
|
||||
<section>
|
||||
<h2>Инвентарь</h2>
|
||||
<p class="muted">свой инвентарь по нику — с M2</p>
|
||||
<h2>Топ зрителей</h2>
|
||||
{#if stats.length === 0}
|
||||
<p class="muted">пока никто не написал !рубить</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each stats as s (s.id)}
|
||||
<li>
|
||||
<span class="dot" style:background={s.color}></span>
|
||||
<span class="name" style:color={s.color}>{s.name}</span>
|
||||
<span class="meta">ур.{s.level} · {s.xp} XP · {s.logs} 🪵 {s.action === 'chop' ? '🌲' : '💤'}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Рецепты</h2>
|
||||
<p class="muted">дерево рецептов — с M2</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Лидерборды</h2>
|
||||
<p class="muted">топ зрителей — с M5</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Костёр</h2>
|
||||
<p class="muted">общий прогресс — с M4</p>
|
||||
<h2>События</h2>
|
||||
{#if feed.length === 0}
|
||||
<p class="muted">тишина в лесу…</p>
|
||||
{:else}
|
||||
<ul class="feed">
|
||||
{#each feed as f (f.id)}
|
||||
<li style:color={f.color}>{f.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -104,19 +148,51 @@
|
||||
min-width: 0;
|
||||
}
|
||||
aside {
|
||||
width: 300px;
|
||||
width: 320px;
|
||||
border-left: 1px solid #2a3242;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 8px;
|
||||
color: #b7c4d4;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.meta {
|
||||
color: #8a99ab;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.feed li {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
padding: 2px 0;
|
||||
color: #c7d3e0;
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: #6d7d90;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage } from '@idle/shared';
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type DeltaMsg, type ServerMessage, type SnapshotMsg, type WelcomeMsg } from '@idle/shared';
|
||||
|
||||
export type Snapshot = Extract<ServerMessage, { t: 'snapshot' }>;
|
||||
export type NetStatus = 'connecting' | 'online' | 'reconnecting';
|
||||
|
||||
export function wsUrl(): string {
|
||||
@@ -13,7 +12,9 @@ export function wsUrl(): string {
|
||||
|
||||
export interface ConnectHandlers {
|
||||
onStatus: (status: NetStatus) => void;
|
||||
onSnapshot: (snap: Snapshot) => void;
|
||||
onWelcome: (msg: WelcomeMsg) => void;
|
||||
onSnapshot: (msg: SnapshotMsg) => void;
|
||||
onDelta: (msg: DeltaMsg) => void;
|
||||
}
|
||||
|
||||
/** Подключение к миру с автопереподключением; возвращает функцию закрытия. */
|
||||
@@ -41,8 +42,11 @@ export function connectWorld(handlers: ConnectHandlers): () => void {
|
||||
}
|
||||
if (msg.t === 'welcome') {
|
||||
handlers.onStatus('online');
|
||||
handlers.onWelcome(msg);
|
||||
} else if (msg.t === 'snapshot') {
|
||||
handlers.onSnapshot(msg);
|
||||
} else if (msg.t === 'delta') {
|
||||
handlers.onDelta(msg);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
|
||||
Generated
+178
@@ -48,6 +48,12 @@ importers:
|
||||
'@idle/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@twurple/auth':
|
||||
specifier: ^8.0.0
|
||||
version: 8.1.4
|
||||
'@twurple/chat':
|
||||
specifier: ^8.0.0
|
||||
version: 8.1.4(@twurple/auth@8.1.4)
|
||||
sirv:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.2
|
||||
@@ -98,6 +104,39 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@d-fischer/cache-decorators@4.0.1':
|
||||
resolution: {integrity: sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==}
|
||||
|
||||
'@d-fischer/connection@10.0.1':
|
||||
resolution: {integrity: sha512-CRP/azUPxwWpR4yT8wOQoM9XFliTVWVAJ8h1SlFnVRAgMlPNyg88/vbDEqZ+udtSB5m8uS10XafZxMUcegMBlQ==}
|
||||
|
||||
'@d-fischer/deprecate@2.0.2':
|
||||
resolution: {integrity: sha512-wlw3HwEanJFJKctwLzhfOM6LKwR70FPfGZGoKOhWBKyOPXk+3a9Cc6S9zhm6tka7xKtpmfxVIReGUwPnMbIaZg==}
|
||||
|
||||
'@d-fischer/detect-node@3.0.1':
|
||||
resolution: {integrity: sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w==}
|
||||
|
||||
'@d-fischer/escape-string-regexp@5.0.0':
|
||||
resolution: {integrity: sha512-7eoxnxcto5eVPW5h1T+ePnVFukmI9f/ZR9nlBLh1t3kyzJDUNor2C+YW9H/Terw3YnbZSDgDYrpCJCHtOtAQHw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@d-fischer/isomorphic-ws@7.0.2':
|
||||
resolution: {integrity: sha512-xK+qIJUF0ne3dsjq5Y3BviQ4M+gx9dzkN+dPP7abBMje4YRfow+X9jBgeEoTe5e+Q6+8hI9R0b37Okkk8Vf0hQ==}
|
||||
peerDependencies:
|
||||
ws: ^8.2.0
|
||||
|
||||
'@d-fischer/logger@4.2.4':
|
||||
resolution: {integrity: sha512-TFMZ/SVW8xyQtyJw9Rcuci4betSKy0qbQn2B5+1+72vVXeO8Qb1pYvuwF5qr0vDGundmSWq7W8r19nVPnXXSvA==}
|
||||
|
||||
'@d-fischer/rate-limiter@1.1.0':
|
||||
resolution: {integrity: sha512-O5HgACwApyCZhp4JTEBEtbv/W3eAwEkrARFvgWnEsDmXgCMWjIHwohWoHre5BW6IYXFSHBGsuZB/EvNL3942kQ==}
|
||||
|
||||
'@d-fischer/shared-utils@3.6.4':
|
||||
resolution: {integrity: sha512-BPkVLHfn2Lbyo/ENDBwtEB8JVQ+9OzkjJhUunLaxkw4k59YFlQxUUwlDBejVSFcpQT0t+D3CQlX+ySZnQj0wxw==}
|
||||
|
||||
'@d-fischer/typed-event-emitter@3.3.3':
|
||||
resolution: {integrity: sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -441,12 +480,29 @@ packages:
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.3.0 || ^7.0.0
|
||||
|
||||
'@twurple/api-call@8.1.4':
|
||||
resolution: {integrity: sha512-qh2TpdxxyiSkwadcCSes6uBHQB6l4Fz8sVfmzk+Brb12asemHMXTEyQAdrMJT7LlgtZq01nr+RASzWM3jmGtkw==}
|
||||
|
||||
'@twurple/auth@8.1.4':
|
||||
resolution: {integrity: sha512-ylsJoPInCw9BwOqxKcx+1k2ce9QG3vJpKFzPdIyHh49HvM/ulQZ0CAGysydugDYXF0iO/TGryh7PluSwx5fIwA==}
|
||||
|
||||
'@twurple/chat@8.1.4':
|
||||
resolution: {integrity: sha512-654LU7BwEpR7lLnWaVMgHtd7wMsaQo71GPTrFZ5ChS283d7B7GTe+9RJ5SxNliNxB5Xn0+R3R3M5HJlUOGYNeA==}
|
||||
peerDependencies:
|
||||
'@twurple/auth': 8.1.4
|
||||
|
||||
'@twurple/common@8.1.4':
|
||||
resolution: {integrity: sha512-1iN5DvOnW+g+Nl3OTI5zUJHgAfjmPCb50HpKsAFik6OYQEAHLsscQKgTOJ+KRuFBYepo/JkHsOWOmWhXxnK6lQ==}
|
||||
|
||||
'@types/earcut@3.0.0':
|
||||
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@20.19.43':
|
||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
||||
|
||||
'@types/node@24.13.3':
|
||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||
|
||||
@@ -523,6 +579,9 @@ packages:
|
||||
gifuct-js@2.1.2:
|
||||
resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==}
|
||||
|
||||
ircv3@0.33.1:
|
||||
resolution: {integrity: sha512-FPUj/q6zsLgIX6QDdLMjPRBObw0xK+k6eiI62dcTRwdl5aezYV0nuMhpmafyHOD6ZDqfw8DW4ayrvDfmYO65JQ==}
|
||||
|
||||
is-reference@3.0.3:
|
||||
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
||||
|
||||
@@ -532,6 +591,10 @@ packages:
|
||||
js-binary-schema-parser@2.0.3:
|
||||
resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==}
|
||||
|
||||
klona@2.0.6:
|
||||
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
locate-character@3.0.0:
|
||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||
|
||||
@@ -597,6 +660,9 @@ packages:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsx@4.23.13:
|
||||
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -607,6 +673,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
@@ -675,6 +744,55 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@d-fischer/cache-decorators@4.0.1':
|
||||
dependencies:
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@d-fischer/connection@10.0.1':
|
||||
dependencies:
|
||||
'@d-fischer/isomorphic-ws': 7.0.2(ws@8.21.3)
|
||||
'@d-fischer/logger': 4.2.4
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
'@d-fischer/typed-event-emitter': 3.3.3
|
||||
'@types/node': 20.19.43
|
||||
'@types/ws': 8.18.1
|
||||
tslib: 2.8.1
|
||||
ws: 8.21.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@d-fischer/deprecate@2.0.2': {}
|
||||
|
||||
'@d-fischer/detect-node@3.0.1': {}
|
||||
|
||||
'@d-fischer/escape-string-regexp@5.0.0': {}
|
||||
|
||||
'@d-fischer/isomorphic-ws@7.0.2(ws@8.21.3)':
|
||||
dependencies:
|
||||
ws: 8.21.3
|
||||
|
||||
'@d-fischer/logger@4.2.4':
|
||||
dependencies:
|
||||
'@d-fischer/detect-node': 3.0.1
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@d-fischer/rate-limiter@1.1.0':
|
||||
dependencies:
|
||||
'@d-fischer/logger': 4.2.4
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@d-fischer/shared-utils@3.6.4':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@d-fischer/typed-event-emitter@3.3.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
optional: true
|
||||
|
||||
@@ -875,10 +993,51 @@ snapshots:
|
||||
vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.13)
|
||||
vitefu: 1.1.3(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.13))
|
||||
|
||||
'@twurple/api-call@8.1.4':
|
||||
dependencies:
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
'@twurple/common': 8.1.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@twurple/auth@8.1.4':
|
||||
dependencies:
|
||||
'@d-fischer/logger': 4.2.4
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
'@d-fischer/typed-event-emitter': 3.3.3
|
||||
'@twurple/api-call': 8.1.4
|
||||
'@twurple/common': 8.1.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@twurple/chat@8.1.4(@twurple/auth@8.1.4)':
|
||||
dependencies:
|
||||
'@d-fischer/cache-decorators': 4.0.1
|
||||
'@d-fischer/deprecate': 2.0.2
|
||||
'@d-fischer/logger': 4.2.4
|
||||
'@d-fischer/rate-limiter': 1.1.0
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
'@d-fischer/typed-event-emitter': 3.3.3
|
||||
'@twurple/auth': 8.1.4
|
||||
'@twurple/common': 8.1.4
|
||||
ircv3: 0.33.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@twurple/common@8.1.4':
|
||||
dependencies:
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
klona: 2.0.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@types/earcut@3.0.0': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@20.19.43':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.13.3':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
@@ -953,6 +1112,19 @@ snapshots:
|
||||
dependencies:
|
||||
js-binary-schema-parser: 2.0.3
|
||||
|
||||
ircv3@0.33.1:
|
||||
dependencies:
|
||||
'@d-fischer/connection': 10.0.1
|
||||
'@d-fischer/escape-string-regexp': 5.0.0
|
||||
'@d-fischer/logger': 4.2.4
|
||||
'@d-fischer/shared-utils': 3.6.4
|
||||
'@d-fischer/typed-event-emitter': 3.3.3
|
||||
klona: 2.0.6
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
is-reference@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -961,6 +1133,8 @@ snapshots:
|
||||
|
||||
js-binary-schema-parser@2.0.3: {}
|
||||
|
||||
klona@2.0.6: {}
|
||||
|
||||
locate-character@3.0.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
@@ -1067,6 +1241,8 @@ snapshots:
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.23.13:
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
@@ -1075,6 +1251,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
vite@7.3.6(@types/node@24.13.3)(tsx@4.23.13):
|
||||
|
||||
Reference in New Issue
Block a user