feat: setup project
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# секреты и локальные настройки
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# локальные данные (sqlite и пр.)
|
||||
data/
|
||||
|
||||
.DS_Store
|
||||
@@ -0,0 +1,20 @@
|
||||
# Idle-XBOCT
|
||||
|
||||
Idle-игра для Twitch-чата: аватары зрителей живут на экране стрима, команды чата
|
||||
управляют ими. План и все решения — в [docs/PLAN.md](docs/PLAN.md).
|
||||
|
||||
## Запуск (M0 — каркас)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- сервер: http://localhost:3000 — WS на `/ws`, здоровье на `/healthz`
|
||||
- overlay (browser source для OBS): http://localhost:5173
|
||||
- web-мир: http://localhost:5174
|
||||
|
||||
Сборка клиентов (`pnpm build`) — сервер начнёт раздавать их сам: `/overlay/` и `/web/`
|
||||
на порту 3000 (это режим для OBS, без vite dev-сервера).
|
||||
|
||||
Twitch-подключение — с M1, чек-лист получения токенов: [docs/twitch-setup.md](docs/twitch-setup.md).
|
||||
+25
-14
@@ -132,9 +132,10 @@ XP-кривая Melvor-подобная, уровни открывают нод
|
||||
|
||||
- **M0 — каркас.** Монорепа, overlay+web скелеты, WS-эхо.
|
||||
✅ аватар-заглушка виден в OBS
|
||||
- **M1 — вертикальный срез.** `!рубить`/`!стоп`, спавн аватара (аватарка из тегов чата),
|
||||
ходьба к дереву, брёвна + XP + уровень, автосейв, WS-снапшот+дельты,
|
||||
камера-окно + простейший автотур.
|
||||
- **M1 — вертикальный срез.** `!рубить`/`!стоп`, спавн аватара (спрайт-человечек, ник
|
||||
над головой в цвете чата), ходьба к дереву, брёвна + XP + уровень, автосейв,
|
||||
WS-снапшот+дельты, камера-окно + простейший автотур, FakeTwitchSource — отладка
|
||||
команд без живого чата.
|
||||
✅ зритель пишет `!рубить` и видит себя рубящим в лесу
|
||||
- **M2 — руда и кузница.** `!копать` + `!ковать` + тиры топоров/кирок.
|
||||
✅ скрафтил железный топор → рубишь заметно быстрее
|
||||
@@ -152,29 +153,36 @@ XP-кривая Melvor-подобная, уровни открывают нод
|
||||
системы оборачиваем тонким интерфейсом для дешёвого fallback
|
||||
- **OBS browser source кэширует**: dev через отдельный порт, версионирование протокола
|
||||
в рукопожатии клиента/сервера
|
||||
- **Аватарки Twitch CDN**: ленивая загрузка + фолбэк-спрайт
|
||||
- **Free-версии паков (Sprout Lands, Mystic Woods) — только некоммерческое**:
|
||||
для монетизированного канала — CC0 или платная лицензия
|
||||
- **Rate limits чата**: бот отвечает дозированно
|
||||
- **EventSub**: нужен Twitch-апп (client id/secret) у стримера; EventSub WebSocket
|
||||
работает локально без публичного webhook-URL — ок для локального MVP
|
||||
|
||||
## Ассеты
|
||||
|
||||
Аватар зрителя = фото профиля Twitch в кольце; спрайтовые человечки — «тела» под ними
|
||||
и фолбэк. Поэтому берём паки, где тело и голова разделены.
|
||||
Аватар зрителя = обычный спрайт-человечек целиком — лицо и тело из пака, фото профиля
|
||||
Twitch не используем. На старте все зрители с одним и тем же спрайтом; личность читается
|
||||
по нику над головой в цвете ника чата. Разные скины — на будущее (донатеры/сабы), поэтому
|
||||
пак желателен с запасом: генератор персонажей или палитрные свопы.
|
||||
|
||||
### Человечки + скины
|
||||
|
||||
- **LimeZu — Modern Interiors / Modern Exteriors** (itch.io, ~$10–15) — основной кандидат:
|
||||
генератор персонажей с сотнями скинов/одежды, домики, мебель, здания уровня «ратуша»,
|
||||
всё в одном стиле
|
||||
- **LimeZu — Modern Interiors / Modern Exteriors** (itch.io, name-your-own-price ≈ $5,
|
||||
лицензия CC-BY: коммерция ок с указанием авторства) — основной кандидат: домики, мебель,
|
||||
здания уровня «ратуша», предметы — всё в одном стиле; генератор персонажей в платной
|
||||
версии пригодится, когда дойдёт до скинов донатеров
|
||||
- **Universal LPC Spritesheet Generator** (бесплатно, онлайн) — сборка спрайт-листов
|
||||
с ходьбой/анимациями из тысяч деталей. Лицензия GPL/CC-BY-SA — проверить перед коммерциализацией
|
||||
- **Kenney.nl** — CC0, человечки простые геометрические — для прототипа M0–M1
|
||||
- **Kenney.nl** — CC0 (тайлы, предметы, простые человечки) — **текущая база проекта**;
|
||||
конкретные паки подбираем при M1 (нужны: персонаж с ходьбой, деревья/камни, вода)
|
||||
|
||||
### Домики, костёр, ратуша
|
||||
|
||||
- **Sprout Lands** (Cup Nooble, itch.io) — милый фермерский стиль
|
||||
- **Mystic Woods** (Game Endeavor, itch.io) — есть бесплатная lite-версия: домики, костёр, природа
|
||||
- **Sprout Lands** (Cup Nooble, itch.io) — милый фермерский стиль; free-версия только
|
||||
для некоммерческого, premium от ~$4 снимает ограничение
|
||||
- **Mystic Woods** (Game Endeavor, itch.io) — есть бесплатная lite-версия: домики, костёр,
|
||||
природа; free-версия тоже только для некоммерческого
|
||||
- **OpenGameArt.org** — много RPG-паков зданий, лицензии смешанные — читать каждую
|
||||
- **CraftPix freebies** — бесплатные наборы зданий/персонажей
|
||||
|
||||
@@ -184,11 +192,14 @@ XP-кривая Melvor-подобная, уровни открывают нод
|
||||
CC-BY только с готовностью указать авторство (можно в дашборде)
|
||||
2. Один стиль важнее качества каждого пака — брать линейку у одного автора
|
||||
(LimeZu закрывает людей, дома, ратушу и предметы)
|
||||
3. Прототип M0–M1 — на CC0/Kenney, красивые паки подключаем с M2+
|
||||
3. **Решено: идём полностью на бесплатном.** База — Kenney (CC0); OpenGameArt — с проверкой
|
||||
лицензии каждого ассета. Платные паки (LimeZu ≈ $5) — опция на потом, если захочется
|
||||
единого стиля
|
||||
|
||||
## Идеи на потом
|
||||
|
||||
- FakeTwitchSource: генератор фейковых зрителей/команд для разработки без живого стрима
|
||||
- Скины для донатеров/сабов: разные внешности аватаров (генератор персонажей LimeZu
|
||||
или палитрные свопы)
|
||||
- Событие усталости: аватар временно перестаёт работать, зритель реагирует
|
||||
- Совместная работа: несколько аватаров у одной ноды дают небольшой бонус (социальная связка)
|
||||
- Рандомные события для удержания: золотая жила, дух леса и т.п.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Twitch: чек-лист подключения (нужен к M1)
|
||||
|
||||
Бот-аккаунт уже есть. Осталось зарегистрировать приложение и получить токены —
|
||||
примерно 10 минут действий, делается один раз.
|
||||
|
||||
## 1. Приложение (один раз)
|
||||
|
||||
1. На аккаунте, который регистрирует приложение (можно сам бот-аккаунт), включите
|
||||
2FA: twitch.tv → Настройки → Безопасность и конфиденциальность. Без 2FA
|
||||
Twitch не даёт создавать приложения.
|
||||
2. Зайти на https://dev.twitch.tv/console под этим аккаунтом → **Register Your Application**:
|
||||
- Name: любое уникальное, например `idle-xboct-bot`
|
||||
- OAuth Redirect URLs: `http://localhost:3000/oauth/callback`
|
||||
- Category: **Chat Bot**
|
||||
3. Скопировать **Client ID**, затем Manage → **New Secret** → **Client Secret**
|
||||
(показывается один раз).
|
||||
|
||||
## 2. Токен бота — чтение и ответы в чате (M1)
|
||||
|
||||
Scope: `chat:read chat:edit`.
|
||||
|
||||
- Быстрый способ: https://twitchtokengenerator.com → «Chat Bot / Quick Token» →
|
||||
залогиниться **именно бот-аккаунтом** → получить access token и refresh token.
|
||||
- Надёжный способ (когда дойдём до M1): мини-OAuth-скрипт на redirect из п.1.
|
||||
|
||||
## 3. Токен стримера — Channel Points (нужен только к M5, спотлайт)
|
||||
|
||||
Scope: `channel:read:redemptions`. Получается так же (twitchtokengenerator или свой
|
||||
OAuth), но залогиниться нужно **аккаунтом стримера**. EventSub-события выкупа наград
|
||||
принимают только пользовательский токен владельца канала — токена бота тут недостаточно.
|
||||
|
||||
## 4. Куда положить
|
||||
|
||||
Файл `.env` в корне `packages/server/` (в git не попадает — уже в .gitignore):
|
||||
|
||||
```env
|
||||
TWITCH_CLIENT_ID=...
|
||||
TWITCH_CLIENT_SECRET=...
|
||||
TWITCH_CHANNEL=имя_канала
|
||||
|
||||
TWITCH_BOT_TOKEN=...
|
||||
TWITCH_BOT_REFRESH=...
|
||||
|
||||
TWITCH_STREAMER_TOKEN=...
|
||||
TWITCH_STREAMER_REFRESH=...
|
||||
```
|
||||
|
||||
Refresh-токены обновляет `RefreshingAuthProvider` из twurple автоматически —
|
||||
перелогиниваться вручную не придётся.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "idle-xboct",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.20.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm -r --parallel --stream dev",
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
VITE_WS_URL=ws://localhost:3000/ws
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Idle XBOCT — overlay</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@idle/overlay",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@idle/render": "workspace:*",
|
||||
"@idle/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { mountWorld, type WorldHandle } from '@idle/render';
|
||||
import { connectWorld, type ConnectHandlers, type NetStatus } from './net';
|
||||
|
||||
const STATUS_TEXT: Record<NetStatus, string> = {
|
||||
connecting: 'подключение…',
|
||||
online: 'онлайн',
|
||||
reconnecting: 'переподключение…',
|
||||
};
|
||||
|
||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||
let status = $state<NetStatus>('connecting');
|
||||
|
||||
onMount(() => {
|
||||
let world: WorldHandle | undefined;
|
||||
let disposeNet: (() => void) | undefined;
|
||||
|
||||
if (host) {
|
||||
void mountWorld(host, { backgroundAlpha: 0 }).then((w) => {
|
||||
world = w;
|
||||
const handlers: ConnectHandlers = {
|
||||
onStatus: (s) => {
|
||||
status = s;
|
||||
},
|
||||
onSnapshot: (snap) => world?.applySnapshot(snap.avatars),
|
||||
};
|
||||
disposeNet = connectWorld(handlers);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposeNet?.();
|
||||
world?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="world" bind:this={host}></div>
|
||||
<div class="badge">{STATUS_TEXT[status]}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
.world {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font: 12px/1.4 monospace;
|
||||
color: #9fb0c3;
|
||||
background: rgba(16, 19, 26, 0.55);
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
/* прозрачность обязательна: канвас лежит поверх картинки стрима в OBS */
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mount } from 'svelte';
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
export default mount(App, { target: document.getElementById('app')! });
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage } from '@idle/shared';
|
||||
|
||||
export type Snapshot = Extract<ServerMessage, { t: 'snapshot' }>;
|
||||
export type NetStatus = 'connecting' | 'online' | 'reconnecting';
|
||||
|
||||
export function wsUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_WS_URL;
|
||||
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
|
||||
// сборка раздаётся самим сервером — тот же origin
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${scheme}://${location.host}/ws`;
|
||||
}
|
||||
|
||||
export interface ConnectHandlers {
|
||||
onStatus: (status: NetStatus) => void;
|
||||
onSnapshot: (snap: Snapshot) => void;
|
||||
}
|
||||
|
||||
/** Подключение к миру с автопереподключением; возвращает функцию закрытия. */
|
||||
export function connectWorld(handlers: ConnectHandlers): () => void {
|
||||
let ws: WebSocket | null = null;
|
||||
let disposed = false;
|
||||
let attempt = 0;
|
||||
|
||||
const open = (): void => {
|
||||
if (disposed) return;
|
||||
handlers.onStatus(attempt === 0 ? 'connecting' : 'reconnecting');
|
||||
ws = new WebSocket(wsUrl());
|
||||
|
||||
ws.onopen = () => {
|
||||
attempt = 0;
|
||||
const hello: ClientMessage = { t: 'hello', v: PROTOCOL_VERSION };
|
||||
ws?.send(JSON.stringify(hello));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: ServerMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) as ServerMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.t === 'welcome') {
|
||||
handlers.onStatus('online');
|
||||
} else if (msg.t === 'snapshot') {
|
||||
handlers.onSnapshot(msg);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (disposed) return;
|
||||
attempt += 1;
|
||||
setTimeout(open, Math.min(500 * 2 ** Math.min(attempt, 4), 8000));
|
||||
};
|
||||
ws.onerror = () => ws?.close();
|
||||
};
|
||||
|
||||
open();
|
||||
return () => {
|
||||
disposed = true;
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: { port: 5173, strictPort: true },
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@idle/render",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@idle/shared": "workspace:*",
|
||||
"pixi.js": "^8.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Container, Graphics, Text } from 'pixi.js';
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
|
||||
/**
|
||||
* Заглушка M0: человечек из примитивов. С M1 сюда придут спрайты из паков —
|
||||
* состав (тело + ник над головой) сохранится.
|
||||
*/
|
||||
export class AvatarView {
|
||||
readonly container = new Container();
|
||||
private readonly rig = new Container();
|
||||
private readonly label: Text;
|
||||
private targetX: number;
|
||||
private targetY: number;
|
||||
|
||||
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);
|
||||
this.label = new Text({
|
||||
text: av.name,
|
||||
style: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
fill: av.color,
|
||||
stroke: { color: 0x10131a, width: 3 },
|
||||
},
|
||||
});
|
||||
this.label.anchor.set(0.5, 1);
|
||||
this.label.y = -36;
|
||||
|
||||
this.rig.addChild(shadow, body);
|
||||
this.container.addChild(this.rig, this.label);
|
||||
|
||||
this.targetX = av.x;
|
||||
this.targetY = av.y;
|
||||
this.container.position.set(av.x, av.y);
|
||||
this.apply(av);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Плавное дотягивание до последнего снапшота — сглаживает дискретность тиков. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './avatar';
|
||||
export * from './world';
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Application, Container } from 'pixi.js';
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
import { AvatarView } from './avatar';
|
||||
|
||||
export type WorldStatus = 'connecting' | 'online' | 'reconnecting';
|
||||
|
||||
export interface MountWorldOptions {
|
||||
/** 0 — прозрачный фон (overlay для OBS). */
|
||||
backgroundAlpha?: number;
|
||||
background?: number;
|
||||
onStatus?: (status: WorldStatus) => void;
|
||||
}
|
||||
|
||||
export interface WorldHandle {
|
||||
applySnapshot(avatars: AvatarState[]): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** Инициализация pixi-канваса в хосте; состояние мира придёт через applySnapshot. */
|
||||
export async function mountWorld(host: HTMLElement, opts: MountWorldOptions = {}): Promise<WorldHandle> {
|
||||
opts.onStatus?.('connecting');
|
||||
|
||||
const app = new Application();
|
||||
await app.init({
|
||||
backgroundAlpha: opts.backgroundAlpha ?? 1,
|
||||
background: opts.background ?? 0x151a21,
|
||||
resizeTo: host,
|
||||
antialias: true,
|
||||
});
|
||||
host.appendChild(app.canvas);
|
||||
app.canvas.style.display = 'block';
|
||||
|
||||
const world = new Container();
|
||||
app.stage.addChild(world);
|
||||
|
||||
const views = new Map<string, AvatarView>();
|
||||
|
||||
app.ticker.add((ticker) => {
|
||||
for (const view of views.values()) view.tick(ticker.deltaMS);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
void app.destroy(true, { children: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@idle/server",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@idle/shared": "workspace:*",
|
||||
"sirv": "^3.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"tsx": "^4.20.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Дымовой тест M0: сервер должен быть запущен. `pnpm --filter @idle/server smoke` */
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const seen = [];
|
||||
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) {
|
||||
ws.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
ws.on('error', reject);
|
||||
setTimeout(() => reject(new Error('timeout waiting for welcome+snapshot')), 5000);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// неверная версия протокола — сервер обязан закрыть сокет с кодом 4001
|
||||
const closeCode = await new Promise((resolve) => {
|
||||
const ws = new WebSocket('ws://localhost:3000/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,93 @@
|
||||
import { createServer } from 'node:http';
|
||||
import path from 'node:path';
|
||||
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';
|
||||
|
||||
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()),
|
||||
});
|
||||
|
||||
const gateway = new WsGateway({ getSnapshot: snapshot, tickMs: TICK_MS });
|
||||
|
||||
// Собранные клиенты раздаём сами — 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 }));
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function handleHttp(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const p = url.pathname;
|
||||
|
||||
if (p === '/healthz') {
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
protocol: PROTOCOL_VERSION,
|
||||
tickMs: TICK_MS,
|
||||
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of staticMounts) {
|
||||
if (!m.middleware) continue;
|
||||
const base = m.prefix.slice(0, -1);
|
||||
if (p === base) {
|
||||
res.writeHead(301, { location: `${base}/` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (p.startsWith(m.prefix)) {
|
||||
req.url = p.slice(base.length) + url.search;
|
||||
m.middleware(req, res, () => json(res, 404, { error: 'not found' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
json(res, 404, {
|
||||
error: 'not found',
|
||||
hint: 'dev: overlay http://localhost:5173, web http://localhost:5174; для /overlay/ и /web/ сначала pnpm build',
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer(handleHttp);
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const { pathname } = new URL(req.url ?? '/', 'http://localhost');
|
||||
if (pathname === '/ws') {
|
||||
gateway.handleUpgrade(req, socket, head);
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => gateway.broadcast(snapshot()), TICK_MS);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[idle-xboct] http://localhost:${PORT} — протокол v${PROTOCOL_VERSION}, тик ${TICK_MS} мс`);
|
||||
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(', ')}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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';
|
||||
|
||||
declare module 'ws' {
|
||||
interface WebSocket {
|
||||
isAlive?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
interface GatewayOptions {
|
||||
getSnapshot: () => ServerMessage;
|
||||
tickMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Шлюз WS: рукопожатие по версии протокола, welcome + снапшот новому клиенту,
|
||||
* broadcast снапшота каждый тик, heartbeat для мёртвых OBS-сокетов.
|
||||
*/
|
||||
export class WsGateway {
|
||||
private readonly wss = new WebSocketServer({ noServer: true });
|
||||
private readonly clients = new Set<WebSocket>();
|
||||
|
||||
constructor(private readonly opts: GatewayOptions) {
|
||||
this.wss.on('connection', (ws) => this.onConnection(ws));
|
||||
setInterval(() => this.sweep(), 30_000).unref();
|
||||
}
|
||||
|
||||
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req));
|
||||
}
|
||||
|
||||
broadcast(msg: ServerMessage): void {
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const ws of this.clients) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onConnection(ws: WebSocket): void {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
let helloSeen = false;
|
||||
ws.on('message', (raw) => {
|
||||
if (helloSeen) return;
|
||||
helloSeen = true;
|
||||
|
||||
let msg: ClientMessage | null = null;
|
||||
try {
|
||||
msg = JSON.parse(String(raw)) as ClientMessage;
|
||||
} catch {
|
||||
msg = null;
|
||||
}
|
||||
if (!msg || msg.t !== 'hello' || msg.v !== PROTOCOL_VERSION) {
|
||||
ws.close(4001, 'protocol version mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
this.clients.add(ws);
|
||||
this.send(ws, { t: 'welcome', v: PROTOCOL_VERSION, tickMs: this.opts.tickMs });
|
||||
this.send(ws, this.opts.getSnapshot());
|
||||
});
|
||||
|
||||
ws.on('close', () => this.clients.delete(ws));
|
||||
ws.on('error', () => ws.terminate());
|
||||
}
|
||||
|
||||
private send(ws: WebSocket, msg: ServerMessage): void {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
for (const ws of this.clients) {
|
||||
if (ws.isAlive === false) {
|
||||
this.clients.delete(ws);
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@idle/shared",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './protocol';
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Протокол WS — контракт между сервером и клиентами (overlay / web).
|
||||
* Клиенты зависят только от него; язык сервера можно сменить позже.
|
||||
* Версия проверяется в рукопожатии (hello/welcome), чтобы OBS со старым
|
||||
* бандлом не висел молча.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export interface AvatarState {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Цвет ника из тегов чата Twitch. */
|
||||
color: string;
|
||||
/** Позиция в мировых координатах, px. */
|
||||
x: number;
|
||||
y: number;
|
||||
face: 'left' | 'right';
|
||||
moving: boolean;
|
||||
}
|
||||
|
||||
export interface WelcomeMsg {
|
||||
t: 'welcome';
|
||||
v: number;
|
||||
/** Период тика симуляции, мс — клиенту для отрисовки прогрессов. */
|
||||
tickMs: number;
|
||||
}
|
||||
|
||||
/** M0: полный снапшот каждый тик; дельты появятся с M1. */
|
||||
export interface SnapshotMsg {
|
||||
t: 'snapshot';
|
||||
tick: number;
|
||||
avatars: AvatarState[];
|
||||
}
|
||||
|
||||
export type ServerMessage = WelcomeMsg | SnapshotMsg;
|
||||
|
||||
export type ClientMessage = {
|
||||
t: 'hello';
|
||||
v: number;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
VITE_WS_URL=ws://localhost:3000/ws
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Idle XBOCT — мир</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@idle/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@idle/render": "workspace:*",
|
||||
"@idle/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { mountWorld, type WorldHandle } from '@idle/render';
|
||||
import { connectWorld, type ConnectHandlers, type NetStatus } from './net';
|
||||
|
||||
const STATUS_TEXT: Record<NetStatus, string> = {
|
||||
connecting: 'подключение…',
|
||||
online: 'онлайн',
|
||||
reconnecting: 'переподключение…',
|
||||
};
|
||||
|
||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||
let status = $state<NetStatus>('connecting');
|
||||
|
||||
onMount(() => {
|
||||
let world: WorldHandle | undefined;
|
||||
let disposeNet: (() => void) | undefined;
|
||||
|
||||
if (host) {
|
||||
void mountWorld(host, { background: 0x1d232d }).then((w) => {
|
||||
world = w;
|
||||
const handlers: ConnectHandlers = {
|
||||
onStatus: (s) => {
|
||||
status = s;
|
||||
},
|
||||
onSnapshot: (snap) => world?.applySnapshot(snap.avatars),
|
||||
};
|
||||
disposeNet = connectWorld(handlers);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposeNet?.();
|
||||
world?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<header>
|
||||
<span class="title">Idle XBOCT</span>
|
||||
<span class="badge" data-status={status}>{STATUS_TEXT[status]}</span>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
<div class="world" bind:this={host}></div>
|
||||
|
||||
<aside>
|
||||
<section>
|
||||
<h2>Инвентарь</h2>
|
||||
<p class="muted">свой инвентарь по нику — с M2</p>
|
||||
</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>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #2a3242;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.badge {
|
||||
margin-left: auto;
|
||||
font: 12px/1.4 monospace;
|
||||
color: #9fb0c3;
|
||||
background: #232b38;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.badge[data-status='online'] {
|
||||
color: #7ee2a8;
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.world {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
aside {
|
||||
width: 300px;
|
||||
border-left: 1px solid #2a3242;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
margin: 0 0 4px;
|
||||
color: #b7c4d4;
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: #6d7d90;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #151a21;
|
||||
color: #dfe7f0;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mount } from 'svelte';
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
export default mount(App, { target: document.getElementById('app')! });
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage } from '@idle/shared';
|
||||
|
||||
export type Snapshot = Extract<ServerMessage, { t: 'snapshot' }>;
|
||||
export type NetStatus = 'connecting' | 'online' | 'reconnecting';
|
||||
|
||||
export function wsUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_WS_URL;
|
||||
if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
|
||||
// сборка раздаётся самим сервером — тот же origin
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${scheme}://${location.host}/ws`;
|
||||
}
|
||||
|
||||
export interface ConnectHandlers {
|
||||
onStatus: (status: NetStatus) => void;
|
||||
onSnapshot: (snap: Snapshot) => void;
|
||||
}
|
||||
|
||||
/** Подключение к миру с автопереподключением; возвращает функцию закрытия. */
|
||||
export function connectWorld(handlers: ConnectHandlers): () => void {
|
||||
let ws: WebSocket | null = null;
|
||||
let disposed = false;
|
||||
let attempt = 0;
|
||||
|
||||
const open = (): void => {
|
||||
if (disposed) return;
|
||||
handlers.onStatus(attempt === 0 ? 'connecting' : 'reconnecting');
|
||||
ws = new WebSocket(wsUrl());
|
||||
|
||||
ws.onopen = () => {
|
||||
attempt = 0;
|
||||
const hello: ClientMessage = { t: 'hello', v: PROTOCOL_VERSION };
|
||||
ws?.send(JSON.stringify(hello));
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: ServerMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data)) as ServerMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.t === 'welcome') {
|
||||
handlers.onStatus('online');
|
||||
} else if (msg.t === 'snapshot') {
|
||||
handlers.onSnapshot(msg);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (disposed) return;
|
||||
attempt += 1;
|
||||
setTimeout(open, Math.min(500 * 2 ** Math.min(attempt, 4), 8000));
|
||||
};
|
||||
ws.onerror = () => ws?.close();
|
||||
};
|
||||
|
||||
open();
|
||||
return () => {
|
||||
disposed = true;
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: { port: 5174, strictPort: true },
|
||||
});
|
||||
Generated
+1099
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- packages/*
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user