feat: add server and frontend initial - wood chopping
This commit is contained in:
+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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user