feat: add ore and blacksmith

This commit is contained in:
2026-09-05 17:24:44 +05:00
parent f6c338e1b0
commit 6273f2849c
15 changed files with 1333 additions and 334 deletions
+1
View File
@@ -1 +1,2 @@
VITE_WS_URL=ws://localhost:3000/ws
VITE_API_URL=http://localhost:3000
+108 -9
View File
@@ -1,8 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ITEM_TITLES, levelForTotalXp, type GameEvent, type ViewerStat } from '@idle/shared';
import {
SKILL_TITLES,
itemTitle,
type GameEvent,
type SkillState,
type ViewerStat,
} from '@idle/shared';
import { mountWorld, type WorldHandle } from '@idle/render';
import { connectWorld, type NetStatus } from './net';
import { apiUrl, connectWorld, type NetStatus } from './net';
const STATUS_TEXT: Record<NetStatus, string> = {
connecting: 'подключение…',
@@ -10,33 +16,77 @@
reconnecting: 'переподключение…',
};
const ACTION_EMOJI: Record<string, string> = {
chop: '🌲',
mine: '⛏️',
smith: '🔨',
idle: '💤',
};
interface FeedItem {
id: number;
color: string;
text: string;
}
interface InvData {
found: boolean;
name?: string;
color?: string;
skills?: Record<string, SkillState>;
items?: Record<string, number>;
}
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}`;
return `${e.name}: +${e.qty} ${itemTitle(e.item)}`;
case 'xp':
return `${e.name}: +${e.amount} XP · ур.${levelForTotalXp(e.total)}`;
return `${e.name}: +${e.amount} XP ${SKILL_TITLES[e.skill] ?? e.skill}`;
case 'levelup':
return `⭐ ${e.name}: уровень ${e.level}!`;
return `⭐ ${e.name}: ${SKILL_TITLES[e.skill] ?? e.skill} уровень ${e.level}!`;
case 'fell':
return `${e.name} свалил(а) дерево!`;
return `${e.name} свалил(ла) дерево!`;
case 'depleted':
return `${e.name} выработал(ла) жилу`;
case 'blocked':
return `${e.name}: ${e.reason}`;
}
}
function totalXp(s: ViewerStat): number {
return Object.values(s.skills).reduce((sum, v) => sum + v.xp, 0);
}
function maxLevel(s: ViewerStat): number {
return Math.max(1, ...Object.values(s.skills).map((v) => v.level));
}
let host = $state<HTMLDivElement | undefined>(undefined);
let status = $state<NetStatus>('connecting');
let stats = $state<ViewerStat[]>([]);
let feed = $state<FeedItem[]>([]);
let feedId = 0;
let invName = $state('');
let inv = $state<InvData | null>(null);
let invSearched = $state(false);
async function lookupInventory(e: SubmitEvent): Promise<void> {
e.preventDefault();
const name = invName.trim();
if (!name) return;
invSearched = true;
try {
const res = await fetch(apiUrl(`/api/inventory?name=${encodeURIComponent(name)}`));
inv = (await res.json()) as InvData;
} catch {
inv = { found: false };
}
}
onMount(() => {
let world: WorldHandle | undefined;
let disposeNet: (() => void) | undefined;
@@ -80,20 +130,42 @@
<section>
<h2>Топ зрителей</h2>
{#if stats.length === 0}
<p class="muted">пока никто не написал !рубить</p>
<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>
<span class="meta">ур.{maxLevel(s)} · {totalXp(s)} XP {ACTION_EMOJI[s.action] ?? ''}</span>
</li>
{/each}
</ul>
{/if}
</section>
<section>
<h2>Инвентарь</h2>
<form onsubmit={lookupInventory}>
<input bind:value={invName} placeholder="ник зрителя" aria-label="ник зрителя" />
<button type="submit">показать</button>
</form>
{#if inv && inv.found}
<ul class="inv">
{#each Object.entries(inv.skills ?? {}) as [id, s] (id)}
<li><span class="k">{SKILL_TITLES[id] ?? id}</span><span class="meta">ур.{s.level} · {s.xp} XP</span></li>
{/each}
{#each Object.entries(inv.items ?? {}) as [id, qty] (id)}
{#if qty > 0}
<li><span class="k">{itemTitle(id)}</span><span class="meta">×{qty}</span></li>
{/if}
{/each}
</ul>
{:else if invSearched && inv && !inv.found}
<p class="muted">не найден — пусть напишет любую команду в чате</p>
{/if}
</section>
<section>
<h2>События</h2>
{#if feed.length === 0}
@@ -148,7 +220,7 @@
min-width: 0;
}
aside {
width: 320px;
width: 340px;
border-left: 1px solid #2a3242;
padding: 12px 16px;
overflow: auto;
@@ -186,6 +258,33 @@
color: #8a99ab;
font-size: 12px;
margin-left: auto;
white-space: nowrap;
}
form {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
input {
flex: 1;
background: #232b38;
border: 1px solid #2a3242;
border-radius: 6px;
color: #dfe7f0;
padding: 4px 8px;
font-size: 13px;
}
button {
background: #2f3a4c;
color: #dfe7f0;
border: none;
border-radius: 6px;
padding: 4px 10px;
font-size: 13px;
cursor: pointer;
}
.inv .k {
color: #c7d3e0;
}
.feed li {
display: block;
+7
View File
@@ -10,6 +10,13 @@ export function wsUrl(): string {
return `${scheme}://${location.host}/ws`;
}
/** База HTTP API (в dev сервер на другом порту, в проде — тот же origin). */
export function apiUrl(path: string): string {
const base = import.meta.env.VITE_API_URL;
if (typeof base === 'string' && base.length > 0) return base + path;
return path;
}
export interface ConnectHandlers {
onStatus: (status: NetStatus) => void;
onWelcome: (msg: WelcomeMsg) => void;