Spaces:
Sleeping
Sleeping
| <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> | |