feat: setup project
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user