ui: real SHIP SYSTEMS tab (upgrade/expand/build buttons) + left column mirrors live ship state

This commit is contained in:
2026-09-28 23:43:03 +00:00
parent fce27ff6ca
commit de0cb45b09
3 changed files with 191 additions and 58 deletions
+66 -58
View File
@@ -13,12 +13,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
import { CrewPanel } from '@/components/crew-panel'
import { LocalSystemPanel } from '@/components/local-system'
import { ShipSystemsPanel } from '@/components/ship-systems'
import { WorldMapPanel } from '@/components/world-map'
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
import type { CrewDto } from '@/lib/game/save'
import type { ShipState } from '@/lib/game/ship'
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
// WORLD MAP, LOCAL SYSTEM and CREW tabs render live data (/api/world, /api/system, /api/crew);
// the other tabs keep placeholder content until their own issues land. Single player, no auth, by design.
// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system,
// /api/crew, /api/ship); the other tabs keep placeholder content until their own issues land.
// Single player, no auth, by design.
function useNarrow(threshold = 900): boolean {
const [narrow, setNarrow] = useState(
@@ -38,22 +43,6 @@ function now(): string {
// ── Placeholder content (no game logic — real values land with the turn engine) ──
const shipSystems: SystemEntry[] = [
{ name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
{ name: 'LIFE SUPPORT', status: 'ACTIVE' },
{ name: 'NAV COMPUTER', status: 'SCANNING' },
{ name: 'WEAPONS', status: 'OFFLINE' },
{ name: 'COMMS', status: 'ACTIVE' },
]
const shipSystemCards = [
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] },
{ name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
]
const skillCards = [
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
@@ -89,6 +78,36 @@ const logLines: TerminalLine[] = [
export function MissionControl() {
const narrow = useNarrow()
const [tick, setTick] = useState(0)
// Ship state is owned here (not in the tab panel) so the left column mirrors ship actions
// immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count
// feeds the roster bar — refetched on page load, like the other panels.
const [ship, setShip] = useState<ShipState | null>(null)
const [crewCount, setCrewCount] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetch('/api/ship')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as ShipState
})
.then((s) => {
if (!cancelled) setShip(s)
})
.catch(() => {}) // left column + tab keep their loading state on failure
fetch('/api/crew')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as CrewDto[]
})
.then((c) => {
if (!cancelled) setCrewCount(c.length)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000)
@@ -257,11 +276,25 @@ export function MissionControl() {
<PanelHeader>
<PanelTitle>SHIP SYSTEMS</PanelTitle>
<div style={{ marginLeft: 'auto' }}>
<Badge variant="ACTIVE">4 ONLINE</Badge>
<Badge variant={ship && ship.systems.some((s) => s.built) ? 'ACTIVE' : 'OFFLINE'}>
{ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
</Badge>
</div>
</PanelHeader>
<StatusGrid systems={shipSystems} />
{ship ? (
<StatusGrid
systems={ship.systems.map((s) => ({
name: s.type,
status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const),
detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined,
}))}
/>
) : (
<div style={{ padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
</div>
)}
</Panel>
<Panel notch="sm">
@@ -269,11 +302,17 @@ export function MissionControl() {
<PanelTitle>VESSEL METRICS</PanelTitle>
</PanelHeader>
<PanelContent>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
<Progress label="HULL INTEGRITY" value={87} />
<Progress label="REACTOR OUTPUT" value={94} />
<Progress label="CREW ROSTER / 6" value={50} />
</div>
{ship ? (
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
// crew roster vs the display cap.
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
</div>
) : (
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
)}
</PanelContent>
</Panel>
</div>
@@ -330,40 +369,9 @@ export function MissionControl() {
<CrewPanel narrow={narrow} />
</TabsContent>
{/* SHIP SYSTEMS TAB */}
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
gap: '0.75rem',
}}
>
{shipSystemCards.map((system) => (
<Card key={system.name}>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{system.name}</CardTitle>
<Badge variant={system.status} className="ml-auto">
{system.level}
</Badge>
</div>
</CardHeader>
<CardContent>
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
MODULE BAY // PLACEHOLDER READOUT
</p>
</CardContent>
<CardFooter className="gap-2">
{system.actions.map((action) => (
<Button key={action} variant="OUTLINE" size="SM">
{action}
</Button>
))}
</CardFooter>
</Card>
))}
</div>
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} />
</TabsContent>
{/* SKILL TREE TAB */}
+122
View File
@@ -0,0 +1,122 @@
'use client'
import { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left
// column mirrors actions immediately; this panel applies each action's response directly via
// onChanged (no polling). Buttons are disabled when unaffordable or capped, using the same
// cost constants the API enforces (lib/game/ship-costs.ts).
interface ShipSystemsPanelProps {
ship: ShipState | null
narrow?: boolean
onChanged: (next: ShipState) => void
}
export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelProps) {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
if (!ship) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING SHIP SYSTEMS…</span>
</div>
)
}
const resources = totalQty(ship.inventory, 'resource')
const parts = totalQty(ship.inventory, 'part')
async function act(url: string, body: unknown) {
setBusy(true)
setError(null)
try {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = (await res.json()) as ShipState & { error?: string }
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
onChanged(data) // response is the fresh ship state — apply directly
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
{ship.systems.map((system) => (
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} onAct={act} />
))}
</div>
</div>
)
}
function SystemCard({
system,
resources,
parts,
busy,
onAct,
}: {
system: ShipSystemDto
resources: number
parts: number
busy: boolean
onAct: (url: string, body: unknown) => void
}) {
const upgradeDisabled = busy || !system.built || system.level >= MAX_LEVEL || resources < upgradeCost(system.level)
const expandDisabled = busy || !system.built || system.capacity >= MAX_CAPACITY || resources < expandCost(system.capacity)
const buildDisabled = busy || system.built || parts < BUILD_PART_COST
return (
<Card>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{system.type}</CardTitle>
<Badge variant={system.built ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
{system.built ? `LV ${system.level}` : 'OFFLINE'}
</Badge>
</div>
</CardHeader>
<CardContent>
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
{system.built ? `LEVEL ${system.level} · CAPACITY ${system.capacity}` : 'NOT INSTALLED'}
</p>
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
{system.built
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
: `BUILD ${BUILD_PART_COST}× PARTS`}
</p>
</CardContent>
<CardFooter className="gap-2">
{system.built ? (
<>
<Button variant="OUTLINE" size="SM" disabled={upgradeDisabled} onClick={() => onAct('/api/ship/upgrade', { systemId: system.id })}>
UPGRADE
</Button>
<Button variant="OUTLINE" size="SM" disabled={expandDisabled} onClick={() => onAct('/api/ship/expand', { systemId: system.id })}>
EXPAND
</Button>
</>
) : (
<Button variant="OUTLINE" size="SM" disabled={buildDisabled} onClick={() => onAct('/api/ship/build', { type: system.type })}>
BUILD NEW
</Button>
)}
</CardFooter>
</Card>
)
}
+3
View File
@@ -23,6 +23,9 @@ export const BASE_CAPACITY = 2
export const MAX_LEVEL = 5 // UPGRADE cap
export const MAX_CAPACITY = 10 // EXPAND cap
// Display-only cap for the VESSEL METRICS crew roster bar (no gameplay effect yet).
export const CREW_CAPACITY = 6
// Cost model — all costs are in inventory items, consumed from the oldest cargo row of the
// required kind first (see spendItems in lib/game/ship.ts):
// UPGRADE {systemId} — `level` units of any resource (scales with current level)