ChatCraft / frontend /src /lib /components /ResourceBar.svelte
gabraken's picture
feat: add game engine, voice commands, leaderboard, tutorial overlay, and stats tracking
29a88f8
<script lang="ts">
import { myPlayer, enemyPlayer, gameState, mySupply, myPlayerId } from '$lib/stores/game';
import { UNIT_LABELS, BUILDING_LABELS, type UnitType, type BuildingType, UNIT_SUPPLY_COST, BUILDING_SUPPLY_PROVIDED } from '$lib/types';
import type { PlayerState } from '$lib/types';
import { backendUrl } from '$lib/socket';
$: base = typeof window !== 'undefined' ? backendUrl() : '';
$: isObserver = $myPlayerId === null && $gameState !== null;
$: p = $myPlayer;
$: enemy = $enemyPlayer;
$: supply = $mySupply;
$: tick = $gameState?.tick ?? 0;
// Format seconds from ticks
$: elapsed = Math.floor(tick / 4);
$: minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
$: seconds = (elapsed % 60).toString().padStart(2, '0');
// Production bars: group all queued items by unit type
$: productionByType = (() => {
const map: Record<string, { count: number; minRemaining: number; maxTicks: number }> = {};
for (const building of Object.values(p?.buildings ?? {})) {
for (const item of building.production_queue) {
if (!map[item.unit_type]) {
map[item.unit_type] = { count: 0, minRemaining: item.ticks_remaining, maxTicks: item.max_ticks };
}
map[item.unit_type].count++;
if (item.ticks_remaining < map[item.unit_type].minRemaining) {
map[item.unit_type].minRemaining = item.ticks_remaining;
map[item.unit_type].maxTicks = item.max_ticks;
}
}
}
return Object.entries(map)
.map(([type, data]) => ({
type,
label: type.slice(0, 4).toUpperCase(),
sprite: `units/${type}`,
...data,
}))
.sort((a, b) => a.minRemaining - b.minRemaining);
})();
$: hasProduction = productionByType.length > 0;
// Buildings under construction
$: constructingBuildings = Object.values(p?.buildings ?? {})
.filter(b => b.status === 'constructing')
.map(b => ({
id: b.id,
type: b.building_type,
label: b.building_type.split('_')[0].slice(0, 4).toUpperCase(),
sprite: `buildings/${b.building_type}`,
remaining: b.construction_ticks_remaining,
maxTicks: b.construction_max_ticks || 1,
}))
.sort((a, b) => a.remaining - b.remaining);
$: hasActivity = hasProduction || constructingBuildings.length > 0;
// โ”€โ”€ Observer helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function supplyFor(player: PlayerState | null): { used: number; max: number } {
if (!player) return { used: 0, max: 0 };
const used = Object.values(player.units).reduce(
(sum, u) => sum + (UNIT_SUPPLY_COST[u.unit_type] ?? 0), 0
);
const max = Object.values(player.buildings).reduce(
(sum, b) => b.status === 'constructing' || b.status === 'destroyed'
? sum : sum + (BUILDING_SUPPLY_PROVIDED[b.building_type] ?? 0), 0
);
return { used, max };
}
function productionFor(player: PlayerState | null) {
const map: Record<string, { count: number; minRemaining: number; maxTicks: number }> = {};
for (const building of Object.values(player?.buildings ?? {})) {
for (const item of building.production_queue) {
if (!map[item.unit_type]) {
map[item.unit_type] = { count: 0, minRemaining: item.ticks_remaining, maxTicks: item.max_ticks };
}
map[item.unit_type].count++;
if (item.ticks_remaining < map[item.unit_type].minRemaining) {
map[item.unit_type].minRemaining = item.ticks_remaining;
map[item.unit_type].maxTicks = item.max_ticks;
}
}
}
return Object.entries(map)
.map(([type, data]) => ({ type, label: type.slice(0, 4).toUpperCase(), sprite: `units/${type}`, ...data }))
.sort((a, b) => a.minRemaining - b.minRemaining);
}
function constructingFor(player: PlayerState | null) {
return Object.values(player?.buildings ?? {})
.filter(b => b.status === 'constructing')
.map(b => ({
id: b.id,
type: b.building_type,
label: b.building_type.split('_')[0].slice(0, 4).toUpperCase(),
sprite: `buildings/${b.building_type}`,
remaining: b.construction_ticks_remaining,
maxTicks: b.construction_max_ticks || 1,
}))
.sort((a, b) => a.remaining - b.remaining);
}
$: obsPlayers = (() => {
if (!$gameState) return [null, null] as [PlayerState | null, PlayerState | null];
const all = Object.values($gameState.players);
return [all[0] ?? null, all[1] ?? null] as [PlayerState | null, PlayerState | null];
})();
</script>
<div class="resource-bar-wrap">
{#if isObserver}
<!-- Observer layout: [P1 name + resources + prod] [timer] [prod + resources + P2 name] -->
{@const p1 = obsPlayers[0]}
{@const p2 = obsPlayers[1]}
{@const s1 = supplyFor(p1)}
{@const s2 = supplyFor(p2)}
{@const prod1 = productionFor(p1)}
{@const con1 = constructingFor(p1)}
{@const prod2 = productionFor(p2)}
{@const con2 = constructingFor(p2)}
<div class="resource-bar obs-bar">
<!-- Left player -->
<div class="obs-side obs-left">
<div class="obs-name player-name">
<span class="player-dot p1-dot"></span>
<span class="obs-name-text">{p1?.player_name ?? 'โ€ฆ'}</span>
</div>
<div class="resources own">
<span class="res mineral">
<img class="res-icon-img" src="{base}/static/sprites/icons/mineral.png" alt="Minerals" />
<span class="res-val mono">{p1?.minerals ?? 0}</span>
</span>
<span class="res gas">
<img class="res-icon-img" src="{base}/static/sprites/icons/gas.png" alt="Gas" />
<span class="res-val mono">{p1?.gas ?? 0}</span>
</span>
<span class="res supply" class:supply-critical={s1.max - s1.used <= 2}>
<img class="res-icon-img" src="{base}/static/sprites/icons/supply.png" alt="Supply" />
<span class="res-val mono">{s1.used}/{s1.max}</span>
</span>
</div>
{#if con1.length > 0 || prod1.length > 0}
<div class="production-inline">
{#each con1 as bld}
{@const pct = Math.round((1 - bld.remaining / bld.maxTicks) * 100)}
<div class="prod-card">
<div class="prod-icon-wrap constructing">
<img class="prod-sprite" src="{base}/static/sprites/{bld.sprite}.png" alt={bld.type} />
</div>
<span class="prod-label">{bld.label}</span>
<div class="prod-bar-wrap"><div class="prod-bar-fill construction" style="width: {pct}%"></div></div>
</div>
{/each}
{#if con1.length > 0 && prod1.length > 0}<div class="row-sep"></div>{/if}
{#each prod1 as prod}
{@const pct = Math.round((1 - prod.minRemaining / prod.maxTicks) * 100)}
<div class="prod-card">
<div class="prod-icon-wrap">
<img class="prod-sprite" src="{base}/static/sprites/{prod.sprite}.png" alt={prod.type} />
{#if prod.count > 1}<span class="prod-badge">{prod.count}</span>{/if}
</div>
<span class="prod-label">{prod.label}</span>
<div class="prod-bar-wrap"><div class="prod-bar-fill p1-fill" style="width: {pct}%"></div></div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Center: timer -->
<div class="timer mono obs-timer">{minutes}:{seconds}</div>
<!-- Right player (mirrored) -->
<div class="obs-side obs-right">
{#if con2.length > 0 || prod2.length > 0}
<div class="production-inline prod-right">
{#each prod2 as prod}
{@const pct = Math.round((1 - prod.minRemaining / prod.maxTicks) * 100)}
<div class="prod-card">
<div class="prod-icon-wrap">
<img class="prod-sprite" src="{base}/static/sprites/{prod.sprite}.png" alt={prod.type} />
{#if prod.count > 1}<span class="prod-badge p2-badge">{prod.count}</span>{/if}
</div>
<span class="prod-label">{prod.label}</span>
<div class="prod-bar-wrap"><div class="prod-bar-fill p2-fill" style="width: {pct}%"></div></div>
</div>
{/each}
{#if con2.length > 0 && prod2.length > 0}<div class="row-sep"></div>{/if}
{#each con2 as bld}
{@const pct = Math.round((1 - bld.remaining / bld.maxTicks) * 100)}
<div class="prod-card">
<div class="prod-icon-wrap constructing">
<img class="prod-sprite" src="{base}/static/sprites/{bld.sprite}.png" alt={bld.type} />
</div>
<span class="prod-label">{bld.label}</span>
<div class="prod-bar-wrap"><div class="prod-bar-fill construction" style="width: {pct}%"></div></div>
</div>
{/each}
</div>
{/if}
<div class="resources own">
<span class="res mineral">
<img class="res-icon-img" src="{base}/static/sprites/icons/mineral.png" alt="Minerals" />
<span class="res-val mono">{p2?.minerals ?? 0}</span>
</span>
<span class="res gas">
<img class="res-icon-img" src="{base}/static/sprites/icons/gas.png" alt="Gas" />
<span class="res-val mono">{p2?.gas ?? 0}</span>
</span>
<span class="res supply" class:supply-critical={s2.max - s2.used <= 2}>
<img class="res-icon-img" src="{base}/static/sprites/icons/supply.png" alt="Supply" />
<span class="res-val mono">{s2.used}/{s2.max}</span>
</span>
</div>
<div class="obs-name player-name">
<span class="obs-name-text">{p2?.player_name ?? 'โ€ฆ'}</span>
<span class="player-dot p2-dot"></span>
</div>
</div>
</div>
{:else}
<div class="resource-bar">
<!-- Left: own resources -->
<div class="resources own">
<span class="res mineral">
<img class="res-icon-img" src="{base}/static/sprites/icons/mineral.png" alt="Minerals" />
<span class="res-val mono">{p?.minerals ?? 0}</span>
</span>
<span class="res gas">
<img class="res-icon-img" src="{base}/static/sprites/icons/gas.png" alt="Gas" />
<span class="res-val mono">{p?.gas ?? 0}</span>
</span>
<span class="res supply" class:supply-critical={supply.max - supply.used <= 2}>
<img class="res-icon-img" src="{base}/static/sprites/icons/supply.png" alt="Supply" />
<span class="res-val mono">{supply.used}/{supply.max}</span>
</span>
</div>
<!-- Production + construction inline -->
{#if hasActivity}
<div class="production-inline">
{#each constructingBuildings as bld}
{@const pct = Math.round((1 - bld.remaining / bld.maxTicks) * 100)}
<div class="prod-card">
<div class="prod-icon-wrap constructing">
<img class="prod-sprite" src="{base}/static/sprites/{bld.sprite}.png" alt={bld.type} />
</div>
<span class="prod-label">{bld.label}</span>
<div class="prod-bar-wrap">
<div class="prod-bar-fill construction" style="width: {pct}%"></div>
</div>
</div>
{/each}
{#if constructingBuildings.length > 0 && hasProduction}
<div class="row-sep"></div>
{/if}
{#each productionByType as prod}
{@const pct = Math.round((1 - prod.minRemaining / prod.maxTicks) * 100)}
<div class="prod-card">
<div class="prod-icon-wrap">
<img class="prod-sprite" src="{base}/static/sprites/{prod.sprite}.png" alt={prod.type} />
{#if prod.count > 1}
<span class="prod-badge">{prod.count}</span>
{/if}
</div>
<span class="prod-label">{prod.label}</span>
<div class="prod-bar-wrap">
<div class="prod-bar-fill" style="width: {pct}%"></div>
</div>
</div>
{/each}
</div>
{/if}
<!-- Center: timer -->
<div class="timer mono">{minutes}:{seconds}</div>
<!-- Right: enemy name -->
<div class="enemy-label">
<span class="enemy-name">{enemy?.player_name ?? 'โ€ฆ'}</span>
<span class="enemy-dot"></span>
</div>
</div>
{/if}
</div>
<style>
.resource-bar-wrap {
background: rgba(13, 17, 23, 0.95);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
flex-shrink: 0;
}
.resource-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
height: 44px;
gap: 8px;
}
.production-inline {
display: flex;
align-items: center;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
min-width: 0;
}
.production-inline::-webkit-scrollbar { display: none; }
.prod-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
width: 36px;
}
.prod-icon-wrap {
position: relative;
width: 28px;
height: 28px;
background: rgba(88, 166, 255, 0.08);
border: 1px solid rgba(88, 166, 255, 0.22);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.prod-icon-wrap.constructing {
background: rgba(255, 167, 38, 0.08);
border-color: rgba(255, 167, 38, 0.3);
}
.prod-bar-fill.construction {
background: var(--supply);
}
.row-sep {
width: 1px;
height: 32px;
background: rgba(255, 255, 255, 0.1);
flex-shrink: 0;
margin: 0 2px;
}
.prod-sprite {
width: 20px;
height: 20px;
object-fit: contain;
image-rendering: pixelated;
}
.prod-label {
font-size: 0.58rem;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
letter-spacing: 0.02em;
text-transform: uppercase;
white-space: nowrap;
}
.prod-badge {
position: absolute;
top: -5px;
right: -5px;
background: var(--player);
color: #fff;
font-size: 0.6rem;
font-weight: 800;
min-width: 16px;
height: 16px;
border-radius: 99px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
line-height: 1;
}
.prod-bar-wrap {
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 99px;
overflow: hidden;
}
.prod-bar-fill {
height: 100%;
background: var(--player);
border-radius: 99px;
transition: width 0.25s linear;
}
.resources {
display: flex;
align-items: center;
gap: 10px;
}
.res {
display: flex;
align-items: center;
gap: 4px;
}
.res-icon-img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
image-rendering: pixelated;
}
.res-val {
font-size: 0.85rem;
font-weight: 700;
}
.mineral .res-val { color: var(--mineral); }
.gas .res-val { color: var(--gas); }
.supply .res-val { color: var(--supply); }
.supply-critical .res-val {
color: var(--danger);
animation: blink 0.8s ease-in-out infinite alternate;
}
@keyframes blink { from { opacity: 1; } to { opacity: 0.4; } }
.timer {
font-size: 0.9rem;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.05em;
}
.enemy-label {
display: flex;
align-items: center;
gap: 6px;
flex-direction: row-reverse;
}
.enemy-name {
font-size: 0.8rem;
color: var(--enemy);
font-weight: 600;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.enemy-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--enemy);
flex-shrink: 0;
}
/* โ”€โ”€ Observer layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
.obs-bar {
justify-content: space-between;
gap: 0;
}
.obs-side {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.obs-right {
flex-direction: row-reverse;
}
.obs-right .production-inline {
flex-direction: row-reverse;
}
.obs-timer {
flex-shrink: 0;
padding: 0 16px;
}
.player-name {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.obs-name-text {
font-size: 0.8rem;
font-weight: 600;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.p1-dot { background: var(--player); }
.p2-dot { background: var(--enemy); }
.obs-left .obs-name-text { color: var(--player); }
.obs-right .obs-name-text { color: var(--enemy); }
.p1-fill { background: var(--player); }
.p2-fill { background: var(--enemy); }
.p2-badge { background: var(--enemy); }
</style>