diff --git a/interface/skin.dmf b/interface/skin.dmf index 3b46689c881..c6c56fc9805 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -137,6 +137,15 @@ window "mapwindow" saved-params = "" text = "" align = left + elem "lobby_browser" + type = BROWSER + pos = 0,0 + size = 640x480 + anchor1 = 0,0 + anchor2 = 100,100 + is-visible = false + background-color = #0b0b0e + saved-params = "" window "info_and_buttons" elem "info_and_buttons" diff --git a/tgstation.dme b/tgstation.dme index 160dbb43a05..6a322525331 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7245,6 +7245,16 @@ #include "voidcrew\modules\lich\lich_spells.dm" #include "voidcrew\modules\lich\lich_thrall.dm" #include "voidcrew\modules\lich\lich_wards.dm" +#include "voidcrew\modules\lobby_terminal\_lobby_terminal.dm" +#include "voidcrew\modules\lobby_terminal\lobby_assets.dm" +#include "voidcrew\modules\lobby_terminal\lobby_bezel.dm" +#include "voidcrew\modules\lobby_terminal\lobby_character.dm" +#include "voidcrew\modules\lobby_terminal\lobby_character_prefs.dm" +#include "voidcrew\modules\lobby_terminal\lobby_character_preview.dm" +#include "voidcrew\modules\lobby_terminal\lobby_crews.dm" +#include "voidcrew\modules\lobby_terminal\lobby_housing.dm" +#include "voidcrew\modules\lobby_terminal\lobby_tgui.dm" +#include "voidcrew\modules\lobby_terminal\lobby_yard.dm" #include "voidcrew\modules\loot\abandoned_crate.dm" #include "voidcrew\modules\loot\gene_injectors.dm" #include "voidcrew\modules\loot\loot_guns.dm" diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx new file mode 100644 index 00000000000..b920f8f4f49 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx @@ -0,0 +1,272 @@ +/** + * The terminal's bezel: frame, top strip, deck with the big keys, grille and the tray of + * small lobby buttons. Class tree from the art's lobby_terminal.css: + * + * .lt-bezel (--s) > .lt-screen, .lt-frame > .lt-strip, .lt-deck > .lt-deck-row > + * .lt-keys > .lt-key..., .lt-grille-slot > .lt-grille, .lt-tray > .lt-sbtn... + * + * Without the art (static.bezel null) the same tree is drawn by PLACEHOLDER_CSS. + */ +import type { ReactNode } from 'react'; +import { resolveAsset } from 'tgui/assets'; +import { useBackend } from 'tgui/backend'; +import { useFocusable } from './nav'; +import type { LobbyData } from './types'; + +type KeyId = 'join' | 'ready' | 'notready' | 'observe' | 'character' | 'start'; + +const KEY_LABEL: Record = { + join: 'JOIN', + ready: 'READY', + notready: 'READY', + observe: 'OBSERVE', + character: 'CHARACTER', + start: 'START', +}; + +function Key(props: { + id: KeyId; + disabled?: boolean; + /** Glows while its screen is open (CHARACTER). */ + lit?: boolean; + title: string; + onClick: () => void; +}) { + const { id, disabled, lit, title, onClick } = props; + const focus = useFocusable(!disabled, { + activate: onClick, + }); + return ( + + ); +} + +type SmallId = 'wiki' | 'poll' | 'crew_manifest' | 'settings' | 'changelog'; + +function SmallButton(props: { + id: SmallId; + title: string; + disabled?: boolean; + mark?: boolean; + onClick: () => void; +}) { + const { id, title, disabled, mark, onClick } = props; + const focus = useFocusable(!disabled, { + activate: onClick, + }); + const state = disabled ? '_disabled' : focus.focused ? '_highlighted' : ''; + return ( + + ); +} + +export function Bezel(props: { + s: number; + children: ReactNode; + /** The CHARACTER tab is open: the key glows. */ + characterLit?: boolean; + /** The CHARACTER key: switches to the CHARACTER tab. */ + onCharacter: () => void; +}) { + const { s, children, characterLit, onCharacter } = props; + const { data, act } = useBackend(); + const art = !!data.bezel; + const preRound = data.phase === 'loading' || data.phase === 'pregame'; + const ready = !!data.ready; + const showReady = preRound || data.phase === 'setup'; + + return ( +
+ {!art && } +
{children}
+
+
+
+
+
+
+ {showReady ? ( + <> + act('ready')} + /> + + + ) : ( + act('join')} + /> + )} + act('observe')} + /> + + {data.localhost_admin && preRound ? ( + act('start_now')} + /> + ) : null} +
+
+
+
+
+ act('wiki')} + /> + act('polls')} + /> + act('manifest')} + /> + act('settings')} + /> + act('changelog')} + /> +
+
+
+
+ ); +} + +/** + * Keycap legends are baked into the art; only the placeholder (and the START key, which has + * no art) shows the text legend. + */ +const LEGEND_CSS = ` +.lt-key-legend { display: none; } +.lt-key--start { width: calc(var(--s) * 52px); height: calc(var(--s) * 34px); position: relative; + background: #9aa39f; box-shadow: inset 0 0 0 calc(var(--s) * 2px) #20202a; } +.lt-key--start .lt-key-legend, .lt-ph .lt-key-legend { display: block; position: absolute; + left: calc(var(--s) * 6px); right: calc(var(--s) * 6px); top: calc(var(--s) * 9px); height: calc(var(--s) * 13px); + background: #36373a; color: #7effbf; font-family: 'VCR', monospace; font-size: calc(var(--s) * 9px); + line-height: calc(var(--s) * 13px); text-align: center; letter-spacing: calc(var(--s) * 1px); } +.lt-key--start.is-focus .lt-key-legend { color: #ffc41f; } +.lt-key-legend { pointer-events: none; } +.lt-key.is-lit { position: relative; } +.lt-key.is-lit::after { content: ''; position: absolute; pointer-events: none; + left: calc(var(--s) * 9px); top: calc(var(--s) * 5px); width: calc(var(--s) * 77px); height: calc(var(--s) * 15px); + background: rgba(126,255,191,.16); mix-blend-mode: screen; + box-shadow: 0 0 calc(var(--s) * 6px) rgba(126,255,191,.55), inset 0 0 calc(var(--s) * 3px) rgba(126,255,191,.5); } +`; + +export const BEZEL_EXTRA_CSS = LEGEND_CSS; + +/** Stand-in for the art: the same layout in flat lobby colours. */ +const PLACEHOLDER_CSS = ` +.lt-ph.lt-bezel { --s: 1; position: absolute; inset: 0; overflow: hidden; background: #9aa39f; user-select: none; } +.lt-ph .lt-screen { position: absolute; top: calc(var(--s) * 15px); left: calc(var(--s) * 13px); + right: calc(var(--s) * 13px); bottom: calc(var(--s) * 52px); overflow: hidden; z-index: 1; } +.lt-ph .lt-frame { position: absolute; top: calc(var(--s) * 12px); left: calc(var(--s) * 10px); + right: calc(var(--s) * 10px); bottom: calc(var(--s) * 49px); box-sizing: border-box; + border: calc(var(--s) * 3px) solid #51575e; border-bottom-color: #e0e3dc; border-right-color: #e0e3dc; + pointer-events: none; z-index: 2; } +.lt-ph .lt-strip { position: absolute; top: calc(var(--s) * -10px); left: calc(50% - var(--s) * 30px); + width: calc(var(--s) * 61px); height: calc(var(--s) * 6px); background: #36373a; } +.lt-ph .lt-deck { position: absolute; left: 0; right: 0; bottom: 0; height: calc(var(--s) * 49px); z-index: 2; + border-top: calc(var(--s) * 2px) solid #e0e3dc; box-sizing: border-box; } +.lt-ph .lt-deck-row { position: absolute; top: calc(var(--s) * 6px); left: calc(var(--s) * 30px); + right: calc(var(--s) * 29px); display: flex; align-items: flex-start; } +.lt-ph .lt-keys { display: flex; align-items: flex-start; flex: none; } +.lt-ph .lt-key { display: block; flex: none; margin: 0; padding: 0; border: 0; outline: 0; position: relative; + height: calc(var(--s) * 34px); background: #bec4bf; box-shadow: inset 0 0 0 calc(var(--s) * 2px) #20202a; cursor: pointer; } +.lt-ph .lt-key + .lt-key { margin-left: calc(var(--s) * 4px); } +.lt-ph .lt-key--join, .lt-ph .lt-key--ready, .lt-ph .lt-key--notready { width: calc(var(--s) * 68px); } +.lt-ph .lt-key--observe { width: calc(var(--s) * 84px); } +.lt-ph .lt-key--character { width: calc(var(--s) * 100px); } +.lt-ph .lt-key.is-focus { background: #e0e3dc; } +.lt-ph .lt-key:active { background: #9aa39f; } +.lt-ph .lt-key.is-disabled { cursor: default; background: #9aa39f; } +.lt-ph .lt-key.is-disabled .lt-key-legend { color: #33676a; } +.lt-ph .lt-key--notready .lt-key-legend { color: #ff4f6a; } +.lt-ph .lt-lamp { flex: none; width: calc(var(--s) * 10px); height: calc(var(--s) * 9px); + margin: calc(var(--s) * 11px) calc(var(--s) * 1px) 0 calc(var(--s) * 2px); background: #20202a; } +.lt-ph .lt-lamp + .lt-key { margin-left: 0; } +.lt-ph .lt-lamp--ready { background: #7effbf; } +.lt-ph .lt-lamp--notready { background: #ff4f6a; } +.lt-ph .lt-grille-slot { flex: 1 1 0; min-width: 0; } +.lt-ph .lt-tray { flex: none; position: relative; display: flex; margin-top: calc(var(--s) * 1px); + padding: calc(var(--s) * 3px); width: calc(var(--s) * 142px); height: calc(var(--s) * 31px); + box-sizing: border-box; background: #36373a; } +.lt-ph .lt-sbtn { position: relative; flex: none; margin: 0; padding: 0; border: 0; background: none; + width: calc(var(--s) * 24px); height: calc(var(--s) * 24px); cursor: pointer; } +.lt-ph .lt-sbtn + .lt-sbtn { margin-left: calc(var(--s) * 4px); } +.lt-ph .lt-sbtn-icon, .lt-ph .lt-sbtn-mark { position: absolute; left: 0; top: 0; + transform: scale(var(--s)); transform-origin: 0 0; pointer-events: none; image-rendering: pixelated; } +.lt-ph .lt-sbtn-mark { top: calc(var(--s) * -1px); } +`; diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx new file mode 100644 index 00000000000..cfb0b346a46 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx @@ -0,0 +1,269 @@ +/** + * BILL: the fitted ship and its crew on the left, the one bill on the right (each line, + * total, parts on account, any shortfall) and [ COMMISSION ]. When commissioning leaves a + * crew, one amber LEAVES line shows and the key reads CONFIRM after the first press. + * Narrow: the bill alone. + * + * The client sends the configuration, never prices; the server works out the bill again. + */ +import type { ReactNode } from 'react'; + +import { ShipPic } from './ShipPic'; +import { cut, Frame, lpad, Put, pad, RPut, Rule, TOP, useGrid } from './term'; +import type { Hull, LobbyActions } from './types'; +import { + billOf, + costOf, + crewOf, + fitOf, + Key, + PART_ORDER, + partsOf, + usePreview, + type YardCtx, +} from './YardTab'; + +export function Bill(props: { + ctx: YardCtx; + hull: Hull; + onCommission: (params: LobbyActions['yard_commission']) => void; +}) { + const { ctx, hull, onCommission } = props; + const { view, set, own } = ctx; + const { cols, rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = fitOf(hull, view.finish, view.picks); + const bill = billOf(own, fit, ctx.account); + const crew = crewOf(fit); + const crewN = crew.reduce((a, l) => a + l[1], 0); + const out: ReactNode[] = []; + + const lw = narrow ? 0 : Math.floor(cols * 0.5); + if (!narrow) { + out.push( + , + ); + const ph = Math.floor(h * 0.55); + out.push( + , + ); + let y = TOP + ph + 1; + out.push(); + y++; + out.push( + + CREW + , + + {String(crewN)} + , + ); + y += 2; + // Two columns only when every job title still fits whole in one; otherwise one column. + const longest = crew.reduce((m, [job]) => Math.max(m, job.length), 0); + const twoColW = Math.floor((lw - 6) / 2); + const columns = twoColW - 5 >= longest ? 2 : 1; + const colW = columns === 2 ? twoColW : lw - 6; + const per = Math.max(1, Math.ceil(crew.length / columns)); + crew.forEach(([job, n], i) => { + const cx = 3 + Math.floor(i / per) * colW; + const cy = y + (i % per); + if (cy >= TOP + h - 1) { + return; + } + out.push( + + {pad(job, colW - 5)} + , + + {lpad(n, 2)} + , + ); + }); + } + + const x = narrow ? 0 : lw + 1; + const w = cols - x; + out.push(); + let y = TOP + 2; + // Keys on by; the status line two above; the parts table above that; the bill lines on top. + const by = TOP + h - 3; + const short = Object.keys(bill.short).length > 0; + const tableTop = by - (short ? 8 : 7); + const labelW = Math.min(14, Math.max(8, Math.floor(w * 0.25))); + const nameX = x + 3 + labelW + 1; + const lines = bill.lines; + lines.forEach(([label, name, cost], i) => { + const costText = cost ? costOf(cost) || '-' : '-'; + const nameRoom = x + w - 3 - nameX; + // A long price drops to its own row under the name. + const own = nameRoom - (costText.length + 2) < 10; + const need = own ? 2 : 1; + // Rows left above the table, keeping one for "+N MORE" unless this is the last line. + const left = tableTop - 1 - y - (i < lines.length - 1 ? 1 : 0); + if (y < 0 || need > left) { + if (y >= 0) { + out.push( + + {`+${lines.length - i} MORE`} + , + ); + } + y = -1; + return; + } + out.push( + + {pad(label, labelW)} + , + + {pad( + name, + own ? nameRoom : nameRoom - Math.max(9, costText.length + 2), + )} + , + ); + if (own) { + y++; + } + out.push( + + {cut(costText, w - 6)} + , + ); + y++; + }); + + // The parts table: one column per part, so TOTAL, ON ACCOUNT and SHORT line up and never + // run into each other however many parts there are. + out.push(); + const inner = w - 6; + const longLabels = inner >= 11 + 4 * 8; + const rowLabelW = longLabels ? 11 : 6; + const colW = Math.max(4, Math.min(9, Math.floor((inner - rowLabelW) / 4))); + const colX = (j: number) => x + 3 + rowLabelW + j * colW; + const cell = (n: number) => lpad(cut(String(n), colW - 1), colW - 1); + const due = partsOf(bill.total); + PART_ORDER.forEach((k, j) => { + const head = + colW - 1 >= k.length ? k.toUpperCase() : k.slice(0, 3).toUpperCase(); + out.push( + + {lpad(head, colW - 1)} + , + 0 ? 'amb' : 'faint'} + > + {cell(bill.total[k] || 0)} + , + + {cell(ctx.account?.[k] || 0)} + , + ); + if (short) { + out.push( + + {bill.short[k] ? cell(bill.short[k] || 0) : lpad('-', colW - 1)} + , + ); + } + }); + out.push( + + TOTAL + , + + {longLabels ? 'ON ACCOUNT' : 'HELD'} + , + ); + if (short) { + out.push( + + SHORT + , + ); + } + + const confirming = view.confirm === 'commission'; + const blocked = short || !ctx.open; + if (!short && !ctx.open) { + out.push( + + {cut('THE YARD OPENS AT DEPARTURE', w - 6)} + , + ); + } else if (!short && ctx.crewChange) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, w - 6)} + , + ); + } + const commission = () => { + if (ctx.crewChange && !confirming) { + set({ confirm: 'commission' }); + return; + } + const modules: Record = {}; + for (const s of fit.slots) { + if (s.pick) { + modules[s.key] = s.pick.id; + } + } + onCommission({ + hull: hull.id, + finish: fit.finish?.id || '', + modules, + leave_crew: ctx.crewChange ? 1 : 0, + }); + }; + out.push( + , + ); + // A hull with nothing to fit has no CHANGE FIT. + if (fit.hull.finishes.length || fit.hull.slots.length) { + out.push( + set({ screen: 'fit' })} + />, + ); + } + + return <>{out}; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/BodyEditor.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/BodyEditor.tsx new file mode 100644 index 00000000000..96963c921db --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/BodyEditor.tsx @@ -0,0 +1,730 @@ +/** + * BODY (CHARACTER-PLAN 2.3/2.4): data-driven rows from CharData.prefs in BODY_KEYS order, char_set, the species confirm line, ColorEntry for free colours. + * Owner: C3. The props are fixed. + * + * A row shows only when the server sent its key (its accessibility rules pick the rows); + * species feature keys are also checked against the species' enabled_features in the + * catalog. Rows flow down one column, into a second when the box is too short, and onto + * pages when even that is not enough. Choice lists longer than PICK_OVER open a paged list + * when their value is clicked. + */ +import { type ReactNode, useEffect, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { ColorEntry, parseHex } from './ColorEntry'; +import { useBack } from './nav'; +import { Btn, Cycler, Field, Panel, Put, pad, Swatch, wrap } from './term'; +import { + AGE_MAX, + AGE_MIN, + BODY_KEYS, + type CellBox, + type LobbyData, + NAME_MAX, + NATIVE_COLOR_KEYS, +} from './types'; +import { + choicesOf, + namedHex, + nameOf, + stepChoice, + usePrefsCatalog, +} from './usePrefsCatalog'; + +const LABELS: Record = { + real_name: 'NAME', + age: 'AGE', + species: 'SPECIES', + body_type: 'BODY', + gender: 'PRONOUNS', + background: 'BACKGROUND', + skin_tone: 'SKIN', + hairstyle_name: 'HAIR', + hair_color: 'HAIR', + hair_gradient: 'GRADIENT', + hair_gradient_color: 'GRADIENT', + facial_style_name: 'FACIAL HAIR', + facial_hair_color: 'FACIAL HAIR', + feature_mcolor: 'SCALES', + feature_ethcolor: 'GLOW', + eye_color: 'EYES', + voice_pack: 'VOICE', + bark_speech_pitch: 'PITCH', + bark_speech_speed: 'SPEED', + backpack: 'BAG', + jumpsuit_style: 'UNIFORM', + underwear: 'UNDERWEAR', + undershirt: 'UNDERSHIRT', + socks: 'SOCKS', + feature_human_ears: 'EARS', + feature_human_tail: 'TAIL', + feature_lizard_snout: 'SNOUT', + feature_lizard_horns: 'HORNS', + feature_lizard_frills: 'FRILLS', + feature_lizard_spines: 'SPINES', + feature_lizard_body_markings: 'MARKINGS', + feature_lizard_legs: 'LEGS', + feature_lizard_tail: 'TAIL', + feature_moth_wings: 'WINGS', + feature_moth_antennae: 'ANTENNAE', + feature_moth_markings: 'MARKINGS', +}; + +/** Species feature rows, in this order after SCALES; unknown ones follow, by key. */ +const FEATURE_ORDER = [ + 'feature_human_ears', + 'feature_human_tail', + 'feature_lizard_snout', + 'feature_lizard_horns', + 'feature_lizard_frills', + 'feature_lizard_spines', + 'feature_lizard_body_markings', + 'feature_lizard_legs', + 'feature_lizard_tail', + 'feature_moth_wings', + 'feature_moth_antennae', + 'feature_moth_markings', +]; + +/** Colours drawn on their style's row. */ +const PAIRED: Record = { + hairstyle_name: 'hair_color', + hair_gradient: 'hair_gradient_color', + facial_style_name: 'facial_hair_color', + underwear: 'underwear_color', +}; + +/** Named lists that show a swatch per choice. */ +const PALETTES = new Set(['skin_tone', 'feature_ethcolor']); + +/** Numeric rows and the amount one click moves them. */ +const NUMERIC_STEPS: Record = { + bark_speech_pitch: 0.1, + bark_speech_speed: 0.5, +}; + +/** Lists longer than this open the paged list when the value is clicked. */ +const PICK_OVER = 12; +/** Label column width. */ +const LW = 12; + +const labelOf = (key: string) => + LABELS[key] || + key + .replace(/^feature_/, '') + .replace(/_/g, ' ') + .toUpperCase(); + +type Row = { + key: string; + h: number; + draw: (L: number, V: number, y: number, end: number) => ReactNode; +}; + +export function BodyEditor(props: CellBox) { + const { x, y, w, h } = props; + const { act, data } = useBackend(); + const cat = usePrefsCatalog(); + const char = data.char; + const prefs = char?.prefs || {}; + const [name, setName] = useState(String(prefs.real_name ?? '')); + const [page, setPage] = useState(0); + const [picker, setPicker] = useState(null); + + useEffect(() => { + setName(String(prefs.real_name ?? '')); + }, [prefs.real_name, char?.slot]); + + if (!char) { + return null; + } + + const set = (key: string, value: unknown) => act('char_set', { key, value }); + const commitName = () => { + const next = name.trim(); + if (next && next !== prefs.real_name) { + set('real_name', next); + } else { + setName(String(prefs.real_name ?? '')); + } + }; + + const species = String(prefs.species ?? ''); + const enabled = cat?.species?.[species]?.enabled_features; + const shown = (k: string) => + k in prefs && + (!k.startsWith('feature_') || + !Array.isArray(enabled) || + enabled.includes(k)); + const isColour = (k: string) => + !choicesOf(cat, k) && !!parseHex(String(prefs[k] ?? '')); + + // Every colour on this file, offered in the colour panel's recent row. + const fileColours = [ + ...Object.values(prefs), + ...Object.values(char.kit || {}).flatMap((it) => + String(it.greyscale || '').split('#'), + ), + ] + .map((c) => parseHex(String(c))) + .filter((c): c is string => !!c); + + const colour = (key: string, cx: number, cy: number, end: number) => ( + set(key, hex)} + onPicker={() => act('char_color_pick', { key })} + /> + ); + + /** Cycler width for a row whose controls start at V and must end by end. */ + const widthFor = (V: number, end: number, extra: number) => + Math.max(6, Math.min(20, end - V - 4 - extra)); + + const choiceRow = (key: string): Row => { + const list = choicesOf(cat, key); + const value = prefs[key]; + const pair = PAIRED[key] && shown(PAIRED[key]) ? PAIRED[key] : null; + const palette = PALETTES.has(key) && list ? sortPalette(key, list) : null; + let shownValue = nameOf(cat, key, value); + if (key === 'voice_pack') { + // "Group: Name" in the list; the row shows the name. + shownValue = shownValue.split(': ').pop() || shownValue; + } + let prev = () => list && set(key, stepChoice(list, value, -1)); + let next = () => list && set(key, stepChoice(list, value, 1)); + if (key === 'body_type') { + const fem = + value === 'female' || (value !== 'male' && prefs.gender === 'female'); + shownValue = fem ? 'Feminine' : 'Masculine'; + prev = () => set(key, fem ? 'male' : 'female'); + next = prev; + } + const long = !!list && list.length > PICK_OVER; + return { + key, + // A palette that can't sit beside the cycler gets more rows (heightOf). + h: 1, + draw: (L, V, ry, end) => { + let VW = widthFor(V, end, pair ? 11 : key === 'voice_pack' ? 9 : 0); + if (pair && VW < 12) { + VW = widthFor(V, end, 3); + } + const after = V + VW + 5; + const out: ReactNode[] = [ + + {labelOf(key)} + , + setPicker(key) : undefined} + />, + ]; + if (pair) { + out.push(colour(pair, after, ry, end)); + } + if (key === 'voice_pack' && after + 8 <= end) { + out.push( + act('char_voice_play')} + />, + ); + } + if (palette) { + out.push(...paletteChips(key, palette, after, ry, end, V)); + } + return out; + }, + }; + }; + + /** Skin tones light to dark; other palettes in the server's order. */ + const sortPalette = (key: string, list: string[]) => { + if (key !== 'skin_tone') { + return list; + } + const luma = (c: string) => { + const n = parseInt((namedHex(cat, key, c) || '#000000').slice(1), 16); + return 0.3 * (n >> 16) + 0.59 * ((n >> 8) & 255) + 0.11 * (n & 255); + }; + return [...list].sort((a, b) => luma(b) - luma(a)); + }; + + /** Swatches for a named-colour list: beside the cycler when they fit, else one more row. */ + const paletteChips = ( + key: string, + list: string[], + after: number, + ry: number, + end: number, + V: number, + ) => { + const beside = after + list.length * 2 <= end; + const per = Math.max(1, Math.floor((end - V) / 2)); + return list.map((c, i) => { + const hex = namedHex(cat, key, c) || '#000000'; + const sx = beside ? after + i * 2 : V + (i % per) * 2; + const sy = beside ? ry : ry + 1 + Math.floor(i / per); + return ( + set(key, c)} + /> + ); + }); + }; + + /** A numeric preference (bark pitch, speed): steps within the catalog's range. */ + const numberRow = (key: string): Row => { + const spec = (cat?.[key] || {}) as { minimum?: number; maximum?: number }; + const step = NUMERIC_STEPS[key]; + const lo = spec.minimum ?? 0; + const hi = spec.maximum ?? lo + step * 20; + const value = Number(prefs[key]) || lo; + const to = (v: number) => + set(key, Math.round(Math.min(hi, Math.max(lo, v)) * 100) / 100); + const text = value.toFixed(1); + return { + key, + h: 1, + draw: (L, V, ry) => [ + + {labelOf(key)} + , + value > lo && to(value - step)} + onNext={() => value < hi && to(value + step)} + />, + ], + }; + }; + + const colourRow = (key: string): Row => ({ + key, + h: 1, + draw: (L, V, ry, end) => [ + + {labelOf(key)} + , + colour(key, V, ry, end), + ], + }); + + // ---- the rows, top to bottom + const extraFeatures = Object.keys(prefs) + .filter( + (k) => + k.startsWith('feature_') && + !(BODY_KEYS as readonly string[]).includes(k), + ) + .sort((a, b) => { + const ia = FEATURE_ORDER.indexOf(a); + const ib = FEATURE_ORDER.indexOf(b); + if (ia < 0 && ib < 0) { + return a < b ? -1 : 1; + } + return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib); + }); + const order: string[] = []; + for (const k of BODY_KEYS) { + if (k === 'real_name') { + continue; + } + if (k === 'voice_pack' && species === 'plasmaman') { + order.push('@suit'); + } + order.push(k); + if (k === 'feature_mcolor') { + order.push(...extraFeatures); + } + } + const pairedShown = new Set( + Object.entries(PAIRED) + .filter(([style]) => shown(style)) + .map(([, c]) => c), + ); + + const rows: Row[] = []; + for (const k of order) { + if (k === '@suit') { + rows.push({ + key: k, + h: 1, + draw: (L, V, ry) => [ + + {'SUIT'} + , + + {'ENVIROSUIT'} + , + ], + }); + continue; + } + if (!shown(k) || pairedShown.has(k)) { + continue; + } + if (k === 'age') { + const age = Number(prefs.age) || AGE_MIN; + rows.push({ + key: k, + h: 1, + draw: (L, V, ry) => [ + + {'AGE'} + , + age > AGE_MIN && set('age', age - 1)} + onNext={() => age < AGE_MAX && set('age', age + 1)} + />, + ], + }); + continue; + } + if (k in NUMERIC_STEPS) { + rows.push(numberRow(k)); + continue; + } + const row = isColour(k) ? colourRow(k) : choiceRow(k); + rows.push(row); + if (k === 'species' && char.confirm) { + rows.push(confirmRow()); + } + } + + function confirmRow(): Row { + const c = char!.confirm!; + const text = `${nameOf(cat, 'species', c.value)} can't keep: ${c.quirks.join(', ')}`; + return { + key: '@confirm', + h: 0, // set when laid out (depends on the column width) + draw: (L, _V, ry, end) => { + const lines = wrap(text.toUpperCase(), Math.max(10, end - L)); + return [ + ...lines.map((l, i) => ( + + {l} + + )), + act('char_confirm', { ok: 1 })} + />, + act('char_confirm', { ok: 0 })} + />, + ]; + }, + }; + } + + // ---- layout: one column, two, or pages + const nameY = y + 1; + const flowTop = nameY + (h >= 20 ? 2 : 1); + const last = y + h - 2; + const L0 = x + 3; + + const heightOf = (r: Row, colEnd: number, L: number, V: number) => { + if (r.key === '@confirm') { + return ( + wrap( + `${nameOf(cat, 'species', char.confirm?.value)} can't keep: ${char.confirm?.quirks.join(', ')}`, + Math.max(10, colEnd - L), + ).length + 1 + ); + } + if (PALETTES.has(r.key)) { + const list = choicesOf(cat, r.key); + if (list) { + const VW = widthFor(V, colEnd, 0); + const after = V + VW + 5; + if (after + list.length * 2 > colEnd) { + const per = Math.max(1, Math.floor((colEnd - V) / 2)); + return 1 + Math.ceil(list.length / per); + } + } + } + return r.h; + }; + + type Placed = { row: Row; L: number; V: number; y: number; end: number }; + const layout = (ncol: number, cap: number): Placed[][] => { + const colW = Math.floor((w - 2) / ncol); + const pages: Placed[][] = [[]]; + let col = 0; + let yy = flowTop; + for (const r of rows) { + const L = L0 + col * colW; + const end = x + (col + 1) * colW - 1; + const rh = heightOf(r, end, L, L + LW); + if (yy + rh - 1 > flowTop + cap - 1 && yy > flowTop) { + col++; + yy = flowTop; + if (col >= ncol) { + col = 0; + pages.push([]); + } + } + const L2 = L0 + col * colW; + const end2 = x + (col + 1) * colW - 1; + pages[pages.length - 1].push({ + row: r, + L: L2, + V: L2 + LW, + y: yy, + end: end2, + }); + yy += rh; + } + return pages; + }; + const cap = last - flowTop + 1; + let pages = layout(1, cap); + if (pages.length > 1 && w >= 64) { + pages = layout(2, cap); + } + if (pages.length > 1) { + pages = layout(w >= 64 ? 2 : 1, cap - 1); + } + const pg = Math.min(page, pages.length - 1); + + // ---- NAME, across the top + const V0 = L0 + LW; + const fw = Math.max(8, Math.min(24, x + w - V0 - 16)); + const nameRow = ( +
+ + {'NAME'} + + + act('char_random_name')} + /> +
+ ); + + return ( + <> + {nameRow} + {pages[pg].map((p) => ( + {p.row.draw(p.L, p.V, p.y, p.end)} + ))} + {pages.length > 1 && ( + + )} + {!!picker && ( + nameOf(cat, picker, v)} + onPick={(v) => { + setPicker(null); + if (v !== prefs[picker]) { + set(picker, v); + } + }} + onClose={() => setPicker(null)} + /> + )} + + ); +} + +/** ` < ` n/m ` > `, ending just before column x. */ +function Pager(props: { + x: number; + y: number; + page: number; + pages: number; + onPage: (p: number) => void; +}) { + const { x, y, page, pages, onPage } = props; + const label = `${page + 1}/${pages}`; + const px = x - 8 - label.length; + return ( + <> + {page > 0 ? ( + onPage(page - 1)}> + {' < '} + + ) : ( + + {' < '} + + )} + + {label} + + {page < pages - 1 ? ( + onPage(page + 1)} + > + {' > '} + + ) : ( + + {' > '} + + )} + + ); +} + +/** + * The long-list picker (hairstyles and the like): a panel over the editor with a FIND field, + * the choices in columns, paged, the current one lit. Click picks; Escape or CANCEL closes. + */ +function ListPicker(props: { + box: CellBox; + title: string; + list: string[]; + value: string; + nameOf: (v: string) => string; + onPick: (v: string) => void; + onClose: () => void; +}) { + const { box, title, list, value, nameOf: show, onPick, onClose } = props; + const px = box.x + 2; + const py = box.y; + const pw = Math.max(20, box.w - 4); + const ph = Math.max(8, box.h - 1); + const colW = Math.min(28, pw - 6); + const ncol = Math.max(1, Math.floor((pw - 4) / (colW + 2))); + const listTop = py + 4; + const listRows = Math.max(1, py + ph - 3 - listTop); + const per = listRows * ncol; + + const [find, setFind] = useState(''); + const [page, setPage] = useState(() => + Math.max(0, Math.floor(list.indexOf(value) / per)), + ); + + useBack(onClose); + + const needle = find.trim().toLowerCase(); + const shown = needle + ? list.filter((v) => show(v).toLowerCase().includes(needle)) + : list; + const pages = Math.max(1, Math.ceil(shown.length / per)); + const pg = Math.min(page, pages - 1); + const slice = shown.slice(pg * per, (pg + 1) * per); + + return ( + + + {'FIND'} + + { + setFind(v); + setPage(0); + }} + onEnter={() => slice.length === 1 && onPick(slice[0])} + /> + {slice.map((v, i) => { + const cx = px + 3 + Math.floor(i / listRows) * (colW + 2); + const cy = listTop + (i % listRows); + const cur = v === value; + return ( + onPick(v)} + auto={cur ? 1 : undefined} + > + {pad(show(v).toUpperCase(), colW)} + + ); + })} + {!slice.length && ( + + {'NO MATCH'} + + )} + + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CharPreview.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CharPreview.tsx new file mode 100644 index 00000000000..99945fa1bdd --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CharPreview.tsx @@ -0,0 +1,153 @@ +/** + * The character preview (CHARACTER-PLAN 2.5 / 3): a viewscreen over cells (x, y, w, h) + * showing the server's render of the dressed mannequin facing dir, on the dark floor tile. + * Keeps a client cache of char_preview pushes (key -> dir -> png, last 6 looks) and sends + * char_turn only for a facing it doesn't have for the current look. Keeps the last image on + * screen while a new one is on its way. The turn keys and dress label around it belong to + * CharacterTab. Owner: C2. + */ +import { type CSSProperties, useEffect, useRef, useState } from 'react'; +import { resolveAsset } from 'tgui/assets'; +import { useBackend } from 'tgui/backend'; + +import { useGrid } from './term'; +import type { CellBox, CharDir, LobbyData } from './types'; + +/** Looks kept client side, as many as the server keeps. */ +const LOOKS = 6; +/** Mannequin tile, in sprite pixels. */ +const TILE = 32; +/** An unanswered char_turn may be sent again after this long (ms). */ +const ASK_AGAIN = 3000; +/** char_face (a turn to a cached facing) goes out at most this often (ms). */ +const FACE_EVERY = 400; + +type Look = Partial>; + +export function CharPreview(props: CellBox & { dir: CharDir }) { + const { x, y, w, h, dir } = props; + const { cw, ch } = useGrid(); + const { data, act } = useBackend(); + const pushed = data.char_preview; + + // key -> look, oldest first. + const cache = useRef(new Map()); + // "key:dir" -> when char_turn asked for it. + const asked = useRef(new Map()); + // The facing the server last heard about (char_turn or char_face), and the one on screen. + const told = useRef<{ sent: CharDir; want: CharDir; timer: number | null }>({ + sent: 2, + want: 2, + timer: null, + }); + useEffect( + () => () => { + if (told.current.timer !== null) { + window.clearTimeout(told.current.timer); + } + }, + [], + ); + const [current, setCurrent] = useState(null); + const [shown, setShown] = useState(null); + const [size, setSize] = useState<[number, number]>([TILE, TILE]); + + // Every push goes into the cache; its key becomes the current look. + useEffect(() => { + if (!pushed?.png) { + return; + } + const looks = cache.current; + const look = looks.get(pushed.key) || {}; + look[pushed.dir] = pushed.png; + looks.delete(pushed.key); + looks.set(pushed.key, look); + while (looks.size > LOOKS) { + looks.delete(looks.keys().next().value as string); + } + asked.current.delete(`${pushed.key}:${pushed.dir}`); + setCurrent(pushed.key); + }, [pushed?.key, pushed?.dir, pushed?.png]); + + // Show the facing if this look has it; otherwise ask once and keep the last image. + const png = current ? cache.current.get(current)?.[dir] : undefined; + useEffect(() => { + if (png) { + setShown(png); + // A cached facing: only tell the server which side is on screen, at most every FACE_EVERY ms. + const t = told.current; + t.want = dir; + if (t.timer === null) { + t.timer = window.setTimeout(() => { + t.timer = null; + if (t.want !== t.sent) { + t.sent = t.want; + act('char_face', { dir: t.want }); + } + }, FACE_EVERY); + } + return; + } + told.current.sent = dir; + told.current.want = dir; + const ask = `${current || ''}:${dir}`; + const at = asked.current.get(ask); + if (at === undefined || Date.now() - at > ASK_AGAIN) { + asked.current.set(ask, Date.now()); + act('char_turn', { dir }); + } + }, [current, dir, png]); + + const sx = Math.round(x * cw); + const sy = Math.round(y * ch); + const sw = Math.round(w * cw); + const sh = Math.round(h * ch); + + // Largest whole scale that leaves a margin round the tile, as the mockup draws it. + const k = Math.max(1, Math.floor(Math.min(sw * 0.82, sh * 0.86) / TILE)); + const t = TILE * k; + const ox = Math.round((sw - t) / 2); + const oy = Math.round((sh - t) / 2) + Math.round(t * 0.08); + const [iw, ih] = size; + + const floor: CSSProperties = { + position: 'absolute', + inset: 0, + opacity: 0.38, + backgroundImage: `url(${resolveAsset('char_floor.png')})`, + backgroundSize: `${t}px ${t}px`, + backgroundPosition: `${ox}px ${oy}px`, + imageRendering: 'pixelated', + }; + + return ( +
+
+ {!!shown && ( + + setSize([ + e.currentTarget.naturalWidth, + e.currentTarget.naturalHeight, + ]) + } + style={{ + position: 'absolute', + // A render bigger than one tile stays centred on it, feet on the tile. + left: ox - Math.round(((iw - TILE) * k) / 2), + top: oy - (ih - TILE) * k, + width: iw * k, + height: ih * k, + imageRendering: 'pixelated', + zIndex: 1, + }} + /> + )} +
+
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CharacterTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CharacterTab.tsx new file mode 100644 index 00000000000..a0e89488c2c --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CharacterTab.tsx @@ -0,0 +1,567 @@ +/** + * CHARACTER tab, layout A "personnel file" (CHARACTER-PLAN 2.2): FILES, the preview, the + * PERSONNEL FILE cells and the crumbs; one editor sub-screen at a time. + * Owner: C3. + * + * Editors get the inside of the frame CharacterTab draws for them: CellBox (x, y = frame top + * + 1, w = frame width, h = frame height - 1), as the mockup's editors do. BODY, ROLES and KIT + * keep the preview on the left; TRAITS and every narrow screen use the full width. + */ +import { type ReactNode, useEffect, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { BodyEditor } from './BodyEditor'; +import { CharPreview } from './CharPreview'; +import { parseHex } from './ColorEntry'; +import { KitEditor } from './KitEditor'; +import { useBack } from './nav'; +import { RolesEditor } from './RolesEditor'; +import { TraitsEditor } from './TraitsEditor'; +import { + Frame, + Hit, + Put, + pad, + RPut, + Rule, + Swatch, + TOP, + useGrid, + wrap, +} from './term'; +import type { + CellBox, + CharData, + CharDir, + CharSection, + CharStatic, + LobbyData, +} from './types'; +import { + kitName, + namedHex, + nameOf, + quirkOf, + usePrefsCatalog, +} from './usePrefsCatalog'; + +const SECTIONS: [CharSection, string][] = [ + ['body', 'BODY'], + ['roles', 'ROLES'], + ['traits', 'TRAITS'], + ['kit', 'KIT'], +]; +const PRIO = ['NEVER', 'LOW', 'MED', 'HIGH']; +/** Facings in clockwise order: S, W, N, E. */ +const TURN: CharDir[] = [2, 8, 1, 4]; + +/** A `[ LABEL ]` key, dim at rest, lit on hover (the mockup's key()). */ +function Key(props: { + x: number; + y: number; + label: string; + cls?: string; + bg?: boolean; + onClick: () => void; +}) { + const { x, y, label, cls = 'dim', bg, onClick } = props; + return ( + + {label} + + ); +} + +/** The category with the highest level (first in role order on a tie), or null. */ +function topRole(char: CharData, stat?: CharStatic) { + let best: string | null = null; + let lvl = 0; + const order = stat?.roles.map((r) => r[0]) || Object.keys(char.roles); + for (const cat of order) { + const v = char.roles[cat] || 0; + if (v > lvl) { + lvl = v; + best = cat; + } + } + return best; +} + +export function CharacterTab(props: { home: number }) { + const { home } = props; + const { act, data } = useBackend(); + const { cols, rows, narrow } = useGrid(); + const cat = usePrefsCatalog(); + const [section, setSection] = useState(null); + useBack(section ? () => setSection(null) : null); + const [dir, setDir] = useState(2); + + useEffect(() => { + act('char_open'); + }, []); + useEffect(() => { + setSection(null); + }, [home]); + + const char = data.char; + const stat = data.char_static; + const h = rows - 1 - TOP; + const name = String(char?.prefs.real_name ?? ''); + const NAME = name.toUpperCase(); + + // ---- the preview with its turn keys and dress label + const preview = (x: number, y: number, w: number, ph: number) => { + const ky = Math.ceil(y + ph) + 1; + const turn = (by: number) => { + const i = TURN.indexOf(dir); + setDir(TURN[(i + by + TURN.length) % TURN.length]); + }; + const out: ReactNode[] = [ + , + turn(-1)} />, + turn(1)} + />, + ]; + if (char?.dress) { + const dress = char.dress.toUpperCase(); + const off = char.dress !== (topRole(char, stat) ?? char.dress); + const cats = (stat?.roles.map((r) => r[0]) || []).filter( + (c) => c !== 'Silicon', + ); + // Past the last category the preview goes back to automatic (the top role). + const at = cats.indexOf(char.dress); + const next = !cats.length + ? null + : at === cats.length - 1 + ? '' + : cats[at + 1]; + const lab = pad(dress, Math.max(1, w - 12)).trimEnd(); + out.push( + act('char_dress', { category: next }) + : undefined + } + > + {lab} + , + ); + } + return out; + }; + + // ---- crumbs and [ BACK ] on the tab row + const crumbs = (label: string) => { + const seg: [string, (() => void) | null][] = [ + ['CHARACTER', () => setSection(null)], + [NAME || 'FILE', () => setSection(null)], + [label, null], + ]; + const room = cols - 1 - 42 - 12; + const width = () => seg.map((s) => s[0]).join(' / ').length; + while (seg.length > 1 && width() > room) { + seg.shift(); + } + const fits = width() <= room; + let x = cols - 1 - (fits ? width() : 0); + const out: ReactNode[] = [ + setSection(null)} + />, + ]; + if (fits) { + seg.forEach(([lab, go], i) => { + const last = i === seg.length - 1; + out.push( + + {lab} + , + ); + x += lab.length; + if (!last) { + out.push( + + {' / '} + , + ); + x += 3; + } + }); + } + return out; + }; + + // ---- a section open + if (section && char) { + const label = SECTIONS.find((s) => s[0] === section)![1]; + const full = section === 'traits' || narrow; + const pw = full ? 0 : Math.min(40, Math.floor(cols * 0.3)); + const ex = full ? 0 : pw + 1; + const ew = cols - ex; + const box: CellBox = { x: ex, y: TOP + 1, w: ew, h: h - 1 }; + let editor: ReactNode = null; + if (section === 'body') { + editor = ; + } else if (section === 'roles') { + editor = ; + } else if (section === 'traits') { + editor = ; + } else { + editor = ; + } + return ( + <> + {crumbs(label)} + {!full && ( + + {preview(2, TOP + 1.5, pw - 4, h - 5.5)} + + )} + + {editor} + + ); + } + + // ---- the file + const slots = char?.slots || []; + const pw = Math.min(40, Math.floor(cols * 0.3)); + const files = ( + + {slots.map((s, i) => { + const cur = i + 1 === char?.slot; + const shown = cur ? name : s; + const yy = TOP + 1 + i; + return ( + + {!!cur && ( + + {' '.repeat(pw - 2)} + + )} + + {String(i + 1)} + + + {pad(shown ? shown.toUpperCase() : '-- EMPTY --', pw - 6)} + + {!cur && ( + act('char_slot', { slot: i + 1 })} + /> + )} + + ); + })} + + ); + const n = Math.max(3, slots.length); + const py = TOP + 2 + n; + const ph = h - 6.5 - n; + + const x = pw + 1; + const w = cols - x; + const gx = x + 2; + const gw = Math.floor((w - 5) / 2); + const gy = TOP + 4; + const gh = Math.floor((h - 5) / 2); + const compact = gh < 6; + // Tall cells leave a blank row under the title, as the mockup does. + const inset = gh >= 9 ? 2 : 1; + + const body = char ? identity(char) : null; + const cell = ( + i: number, + title: string, + sec: CharSection, + fill: (cx: number, cy: number, cw: number, rh: number) => ReactNode, + ) => { + const cx = gx + (i % 2) * (gw + 1); + const cy = gy + Math.floor(i / 2) * gh; + return ( + + + setSection(sec)} + /> + {fill(cx + 3, cy + inset, gw - 6, gh - 1 - inset)} + + ); + }; + + function identity(c: CharData) { + const p = c.prefs; + const voice = nameOf(cat, 'voice_pack', p.voice_pack); + const lines: [string, string][] = [ + ['SPECIES', nameOf(cat, 'species', p.species)], + ['AGE', String(p.age ?? '')], + ['PRONOUNS', nameOf(cat, 'gender', p.gender)], + ['VOICE', voice.includes(': ') ? voice.split(': ').pop()! : voice], + ]; + const chips: [string, string][] = []; + const add = (lab: string, hex: string | null) => { + if (hex) { + chips.push([lab, hex]); + } + }; + if ('skin_tone' in p) { + add('SKIN', namedHex(cat, 'skin_tone', p.skin_tone)); + } + if ('hairstyle_name' in p && 'hair_color' in p) { + add('HAIR', parseHex(p.hair_color)); + } + if ('feature_mcolor' in p) { + add('SCALES', parseHex(p.feature_mcolor)); + } + if ('feature_ethcolor' in p) { + add('GLOW', namedHex(cat, 'feature_ethcolor', p.feature_ethcolor)); + } + if ('eye_color' in p) { + add('EYES', parseHex(p.eye_color)); + } + return { lines, chips }; + } + + const bodyCell = (cx: number, cy: number, cw: number, rh: number) => { + if (!body) { + return null; + } + const out: ReactNode[] = []; + body.lines.slice(0, Math.max(0, rh - 1)).forEach(([k, v], i) => { + out.push( + + {k} + , + + {pad(v, cw - 11)} + , + ); + }); + let sx = cx; + const sy = cy + Math.min(body.lines.length, Math.max(0, rh - 1)); + for (const [k, hex] of body.chips) { + if (sx + k.length + 3 > cx + cw) { + break; + } + out.push( + + {k} + , + , + ); + sx += k.length + 5; + } + return out; + }; + + const rolesCell = (cx: number, cy: number, cw: number, rh: number) => { + if (!char) { + return null; + } + const order = stat?.roles.map((r) => r[0]) || Object.keys(char.roles); + const out: ReactNode[] = []; + let yy = cy; + for (let lvl = 3; lvl >= 1 && yy < cy + rh; lvl--) { + const names = order.filter((r) => char.roles[r] === lvl); + if (!names.length) { + continue; + } + out.push( + + {PRIO[lvl]} + , + ); + for (const l of wrap(names.join(', '), cw - 7).slice(0, cy + rh - yy)) { + out.push( + + {l} + , + ); + yy++; + } + } + if (yy === cy) { + out.push( + + {'ANY BERTH'} + , + ); + } + return out; + }; + + const points = () => + -(char?.quirks || []).reduce( + (sum, q) => sum + (quirkOf(cat, q)?.value || 0), + 0, + ); + + const traitsCell = (cx: number, cy: number, cw: number, rh: number) => { + if (!char) { + return null; + } + const pts = points(); + const out: ReactNode[] = char.quirks + .slice(0, Math.max(0, rh - 2)) + .map((q, i) => { + const info = quirkOf(cat, q); + const v = info?.value || 0; + return ( + + + {pad(info?.name || q, cw - 5)} + + 0 ? 'amb' : 'dim'}> + {String(v)} + + + ); + }); + if (!char.quirks.length) { + out.push( + + {'NONE'} + , + ); + } + if (cat?.quirks?.points_enabled !== false) { + out.push( + + {'POINTS'} + , + + {String(pts)} + , + ); + } + return out; + }; + + const kitCell = (cx: number, cy: number, cw: number, rh: number) => { + const items = Object.keys(char?.kit || {}); + if (!items.length) { + return ( + + {'NOTHING PACKED'} + + ); + } + return items.slice(0, rh).map((p, i) => ( + + {pad(kitName(cat, p), cw)} + + )); + }; + + /** One line per section when the cells don't fit (short screens). */ + const compactList = () => { + if (!char) { + return null; + } + const top = topRole(char, stat); + const lines: [CharSection, string, string][] = [ + [ + 'body', + 'BODY', + body + ? body.lines + .slice(0, 3) + .map((l) => l[1]) + .join(', ') + : '', + ], + ['roles', 'ROLES', top ? `${top}` : 'Any berth'], + [ + 'traits', + 'TRAITS', + char.quirks.map((q) => quirkOf(cat, q)?.name || q).join(', ') || 'None', + ], + [ + 'kit', + 'KIT', + Object.keys(char.kit) + .map((p) => kitName(cat, p)) + .join(', ') || 'Nothing packed', + ], + ]; + const step = h - 4 >= 8 ? 2 : 1; + return lines.map(([sec, lab, sum], i) => { + const yy = TOP + 3 + i * step; + return ( + + + {lab} + + + {pad(sum, Math.max(1, w - 25))} + + setSection(sec)} + /> + + ); + }); + }; + + return ( + <> + {files} + + {ph >= 3 && preview(2, py, pw - 4, ph)} + + {!!char && ( + <> + + {pad(NAME, Math.max(1, w - 20))} + + + {`FILE ${char.slot} OF ${char.slots.length}`} + + {compact + ? compactList() + : [ + cell(0, 'BODY', 'body', bodyCell), + cell(1, 'ROLES', 'roles', rolesCell), + cell(2, 'TRAITS', 'traits', traitsCell), + cell(3, 'KIT', 'kit', kitCell), + ]} + + )} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ColorEntry.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ColorEntry.tsx new file mode 100644 index 00000000000..89c4ee45927 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ColorEntry.tsx @@ -0,0 +1,368 @@ +/** + * Free colour entry for BODY and KIT (CHARACTER-PLAN 2.9). The row reads + * `LABEL [##] #RRGGBB`; clicking the chip or the hex opens the H/S/V panel. Only SET sends. + * Owner: C3. The props are the contract C4's KitEditor builds against; keep them. + * + * An empty label draws just the chip and hex (for a colour that sits after a cycler on the + * same row, as HAIR does); the panel is then titled COLOUR. The panel places itself under the + * row and stays on the screen. Dragging on a bar only changes local state: nothing is sent + * until SET, so the server never renders a colour that was dragged past. + */ +import { type PointerEvent as ReactPointerEvent, useState } from 'react'; + +import { useBack } from './nav'; +import { Btn, Field, lpad, Panel, Put, Swatch, TOP, useGrid } from './term'; + +export type ColorEntryProps = { + /** Column and row of the label. */ + x: number; + y: number; + /** Cells the row may use. */ + w: number; + label: string; + /** Current colour, "#rrggbb". */ + value: string; + /** Colours offered in the recent row (already on this file, plus the viewer's last ones). */ + recent: string[]; + /** Show [ PICKER ] (BYOND's native dialog). BODY colours only. */ + native?: boolean; + /** SET pressed with a valid colour. */ + onSet: (hex: string) => void; + /** [ PICKER ] pressed. */ + onPicker?: () => void; +}; + +// --------------------------------------------------------------------------- +// Colour maths + +type HSV = { h: number; s: number; v: number }; + +/** "#rrggbb", "rrggbb", "#rgb" or "rgb" to "#rrggbb" (lower case), else null. */ +export function parseHex(text: string): string | null { + let t = String(text ?? '') + .trim() + .toLowerCase(); + if (t.startsWith('#')) { + t = t.slice(1); + } + if (/^[0-9a-f]{3}$/.test(t)) { + t = t + .split('') + .map((c) => c + c) + .join(''); + } + return /^[0-9a-f]{6}$/.test(t) ? `#${t}` : null; +} + +function hexToHsv(hex: string): HSV { + const n = parseInt(hex.slice(1), 16); + const r = ((n >> 16) & 255) / 255; + const g = ((n >> 8) & 255) / 255; + const b = (n & 255) / 255; + const max = Math.max(r, g, b); + const d = max - Math.min(r, g, b); + let h = 0; + if (d) { + if (max === r) { + h = ((g - b) / d) % 6; + } else if (max === g) { + h = (b - r) / d + 2; + } else { + h = (r - g) / d + 4; + } + h *= 60; + if (h < 0) { + h += 360; + } + } + return { h, s: max ? d / max : 0, v: max }; +} + +function hsvToHex({ h, s, v }: HSV): string { + const f = (k: number) => { + const t = (k + h / 60) % 6; + return v - v * s * Math.max(0, Math.min(t, 4 - t, 1)); + }; + const c = (x: number) => + Math.round(x * 255) + .toString(16) + .padStart(2, '0'); + return `#${c(f(5))}${c(f(3))}${c(f(1))}`; +} + +// --------------------------------------------------------------------------- +// The viewer's last colours (per viewer, browser storage; the panel works without it) + +const STORE = 'lt-recent-colours'; +const KEEP = 8; + +function readStored(): string[] { + try { + const raw = JSON.parse(window.localStorage.getItem(STORE) || '[]'); + return Array.isArray(raw) + ? raw.map((c) => parseHex(String(c))).filter((c): c is string => !!c) + : []; + } catch { + return []; + } +} + +function remember(hex: string) { + try { + const next = [hex, ...readStored().filter((c) => c !== hex)].slice(0, KEEP); + window.localStorage.setItem(STORE, JSON.stringify(next)); + } catch { + // Storage blocked: the row just shows the file's colours. + } +} + +// --------------------------------------------------------------------------- + +const PANEL_W = 44; +const PANEL_H = 11; +const BAR = 24; +const CHANNELS: ['h' | 's' | 'v', string, number][] = [ + ['h', 'H', 360], + ['s', 'S', 100], + ['v', 'V', 100], +]; + +/** The CSS gradient a bar shows: its channel swept with the other two held. */ +function barGradient(ch: 'h' | 's' | 'v', hsv: HSV): string { + const stops: string[] = []; + for (let i = 0; i <= 12; i++) { + const t = i / 12; + const at = + ch === 'h' + ? { ...hsv, h: t * 359.9 } + : ch === 's' + ? { ...hsv, s: t } + : { ...hsv, v: t }; + stops.push(hsvToHex(at)); + } + return `linear-gradient(to right, ${stops.join(', ')})`; +} + +/** One H/S/V bar: BAR cells, click or drag to set. */ +function Bar(props: { + x: number; + y: number; + ch: 'h' | 's' | 'v'; + hsv: HSV; + onChange: (t: number) => void; +}) { + const { x, y, ch, hsv, onChange } = props; + const { cw, ch: rh } = useGrid(); + const t = ch === 'h' ? hsv.h / 360 : ch === 's' ? hsv.s : hsv.v; + const at = (e: ReactPointerEvent) => { + const r = e.currentTarget.getBoundingClientRect(); + onChange(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width))); + }; + return ( +
{ + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + at(e); + }} + onPointerMove={(e) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + at(e); + } + }} + onClick={(e) => e.stopPropagation()} + > +
+
+ ); +} + +export function ColorEntry(props: ColorEntryProps) { + const { x, y, w, label, value, recent, native, onSet, onPicker } = props; + const { cols, rows } = useGrid(); + const [open, setOpen] = useState(false); + const [hsv, setHsv] = useState(() => + hexToHsv(parseHex(value) || '#000000'), + ); + const [text, setText] = useState(String(value ?? '')); + + const current = parseHex(value) || '#000000'; + const typed = parseHex(text); + + const show = (hex: string) => { + setHsv(hexToHsv(hex)); + setText(hex.toUpperCase()); + }; + const openPanel = () => { + show(current); + setOpen(true); + }; + const set = (hex: string | null) => { + if (!hex) { + return; + } + remember(hex); + setOpen(false); + if (hex !== current) { + onSet(hex); + } + }; + + useBack(open ? () => setOpen(false) : null); + + const lx = label ? x + label.length + 1 : x; + const row = ( + <> + {!!label && ( + + {label} + + )} + + {lx - x + 10 <= w && ( + + {current.toUpperCase()} + + )} + + ); + if (!open) { + return row; + } + + // Under the row, kept on the screen. + const px = Math.max(0, Math.min(x, cols - PANEL_W - 1)); + const py = + y + 1 + PANEL_H <= rows - 1 + ? y + 1 + : Math.max(TOP, Math.min(y - PANEL_H, rows - 1 - PANEL_H)); + const draft = typed || hsvToHex(hsv); + const chips = [...recent.map((c) => parseHex(c)), ...readStored()] + .filter((c, i, all): c is string => !!c && all.indexOf(c) === i) + .slice(0, Math.floor((PANEL_W - 6) / 2)); + + return ( + <> + {row} + + + { + setText(v); + const hex = parseHex(v); + if (hex) { + setHsv(hexToHsv(hex)); + } + }} + onEnter={() => set(typed)} + /> + {!typed && ( + <> + + {'['} + + + {']'} + + + )} + {CHANNELS.map(([ch, name, scale], i) => { + const shown = ch === 'h' ? hsv.h : hsv[ch] * 100; + return ( + + + {name} + + { + const next = { ...hsv, [ch]: ch === 'h' ? t * 359.9 : t }; + setHsv(next); + setText(hsvToHex(next).toUpperCase()); + }} + /> + + {lpad(Math.round(Math.min(shown, scale)), 3)} + + + ); + })} + {chips.map((c, i) => ( + show(c)} + /> + ))} + {!!native && !!onPicker && ( + { + setOpen(false); + onPicker(); + }} + /> + )} + setOpen(false)} + /> + set(typed)} + /> + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx new file mode 100644 index 00000000000..73ef2940d4d --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx @@ -0,0 +1,370 @@ +/** + * The record pane of one crew: memo, berths with SIGN ON, CODE / NOTE + APPLY for locked + * crews, PING CREW when every berth is taken. Package P2. + */ +import { type ReactNode, useState } from 'react'; +import { useBackend } from 'tgui/backend'; +import { useBack } from './nav'; +import { + Blocks, + Btn, + Field, + Frame, + Put, + pad, + RPut, + Rule, + TOP, + wrapMax, +} from './term'; +import type { Crew, LobbyData } from './types'; + +/** SHIP_JOIN_PASSWORD_MAX_LEN and SHIP_APPLICATION_MESSAGE_MAX_LEN (voidcrew/_DEFINES/overmap.dm). */ +const CODE_MAX = 24; +const NOTE_MAX = 200; + +/** Seconds as m:ss. */ +export function clock(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s < 10 ? '0' : ''}${s}`; +} + +/** Cuts to n characters without padding; a cut ends in '.'. */ +export function cut(str: string, n: number): string { + return str.length > n ? `${str.slice(0, Math.max(0, n - 1))}.` : str; +} + +/** Locked and not yet cleared to board. */ +export const isLocked = (c: Crew) => !!c.locked && !c.cleared; + +type Props = { + crew: Crew; + x: number; + w: number; + h: number; + /** Narrow layout: the record has the whole screen and closes with BACK. */ + narrow: boolean; + onClose: () => void; +}; + +export function CrewRecord(props: Props) { + const { crew: c, x, w, h, narrow, onClose } = props; + const { act } = useBackend(); + const [code, setCode] = useState(''); + const [note, setNote] = useState(''); + const bottom = TOP + h - 1; + const out: ReactNode[] = []; + let y = TOP + 2; + useBack(onClose); + + const closeLabel = narrow ? ' BACK ' : ' X '; + out.push( + + {closeLabel} + , + ); + + out.push( + + {pad(c.cls.toUpperCase(), Math.max(1, w - 9 - `${c.crew} ABOARD`.length))} + , + + {`${c.crew} ABOARD`} + , + ); + y += 1; + if (c.doors) { + out.push( + + DOORS + , + + CREW ONLY + , + ); + } + y += 2; + + const locked = isLocked(c) && !c.applied; + // Rows the lower block needs: the code and note rows for a locked crew, one row otherwise. + const tail = locked ? 7 : c.applied ? 3 : 1; + + if (c.memo) { + const room = Math.max(1, bottom - y - tail - 6); + const lines = wrapMax(`"${c.memo}"`, w - 6, Math.min(4, room)); + for (const line of lines) { + out.push( + + {line} + , + ); + y++; + } + y += 1; + } + // Who is aboard, the captain first, in what room the berths below leave. + const aboard = c.aboard || []; + if (aboard.length) { + const below = + 1 + + (c.jobs.length ? Math.min(2, c.jobs.length) * 2 : 4) + + tail + + (c.rejoin_wait > 0 && !locked ? 3 : 0); + const room = Math.min(6, bottom - y - below - 1); + if (room >= 1) { + const shown = aboard.length > room ? room - 1 : aboard.length; + const more = Math.max(c.crew, aboard.length) - shown; + const jobW = Math.min( + 16, + Math.max(6, ...aboard.map(([, job]) => job.length)), + ); + const nameX = x + 8; + const nameW = Math.max(4, x + w - 3 - jobW - 2 - nameX); + aboard.slice(0, shown).forEach(([name, job, capt], i) => { + out.push( + + {pad(name.toUpperCase(), nameW)} + , + + {cut(job.toUpperCase(), jobW)} + , + ); + if (capt) { + out.push( + + CAPT + , + ); + } + y++; + }); + if (more > 0) { + out.push( + + {`+${more} MORE`} + , + ); + y++; + } + y += 1; + } + } + out.push(); + y += 1; + + if (!c.jobs.length) { + out.push( + + NO BERTHS OPEN + , + ); + y += 3; + if (c.ping_wait > 0) { + out.push( + + [ PING CREW ] + , + + {clock(c.ping_wait)} + , + ); + } else { + out.push( + act('crew_ping', { ship: c.ref })} + />, + ); + } + return <>{out}; + } + + if (c.rejoin_wait > 0 && !locked) { + y += 1; + out.push( + + {`BACK ABOARD IN ${clock(c.rejoin_wait)}`} + , + ); + y += 2; + } + + const grey = locked || !!c.applied; + const sign = '[ SIGN ON ]'; + // The job name gets the whole width up to SIGN ON; the open berths sit on the line under it. + const nameW = Math.max(8, w - 3 - sign.length - 5); + const maxBlocks = Math.max(1, Math.floor((nameW - 1) / 2)); + // Name, berths and a spacer per job; drop the spacer when that would not fit. + const step = y + c.jobs.length * 3 + tail <= bottom ? 3 : 2; + let jobRoom = Math.max(1, Math.floor((bottom - y - tail) / step)); + // Berths that don't fit leave a row saying how many more there are. + const jobsCut = c.jobs.length > jobRoom; + if (jobsCut) { + jobRoom = Math.max(1, Math.floor((bottom - y - tail - 1) / step)); + } + let autoGiven = false; + for (const job of c.jobs.slice(0, jobRoom)) { + const usable = !grey && !!job.ok && c.rejoin_wait <= 0; + const first = usable && !autoGiven; + autoGiven = autoGiven || usable; + out.push( + + {pad(job.title.toUpperCase(), nameW)} + , + ); + const shown = Math.min(job.open, maxBlocks); + for (let i = 0; i < shown; i++) { + out.push( + , + ); + } + out.push( + act('crew_sign_on', { ship: c.ref, job: job.ref })} + />, + ); + y += step; + } + if (jobsCut) { + out.push( + + {`+${c.jobs.length - jobRoom} MORE`} + , + ); + y += 1; + } + + if (locked) { + out.push(); + y += 2; + const waiting = c.code_wait > 0; + const sendCode = () => { + if (!code.trim() || waiting) { + return; + } + act('crew_code', { ship: c.ref, code }); + setCode(''); + }; + out.push( + + CODE + , + , + , + ); + if (waiting && w >= 42) { + out.push( + + {clock(c.code_wait)} + , + ); + } + y += 2; + const sendNote = () => { + act('crew_apply', { ship: c.ref, note }); + setNote(''); + }; + out.push( + + NOTE + , + , + ); + y += 2; + out.push( + , + ); + } else if (c.applied) { + out.push(); + y += 2; + out.push( + + [ APPLIED ] + , + ); + } + + return <>{out}; +} + +/** The record pane with its frame. */ +export function CrewRecordPane(props: Props) { + const { crew, x, w, h } = props; + return ( + + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx new file mode 100644 index 00000000000..9d59fe7c343 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx @@ -0,0 +1,254 @@ +/** + * CREWS tab: one line per crew, and the record of the selected one beside it (or in its + * place on a narrow screen). Package P2. Draws only with the term.tsx primitives; content + * lives between rows TOP and rows - 2 (rows - 1 is the shell's notice line). + */ +import { type ReactNode, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { CrewRecordPane, isLocked } from './CrewRecord'; +import { useBack } from './nav'; +import { Frame, Hit, lpad, Put, pad, TOP, useGrid } from './term'; +import type { Crew, LobbyData } from './types'; + +function openSlots(c: Crew): number { + return c.jobs.reduce((sum, job) => sum + job.open, 0); +} + +/** Status word and its colour, as in the mockup. Open crews show nothing. */ +function status(c: Crew): [string, string] | null { + if (!c.jobs.length) { + return ['FULL', 'red']; + } + if (c.applied) { + return ['APPLIED', 'amb']; + } + if (isLocked(c)) { + return ['LOCKED', 'amb']; + } + return null; +} + +export function CrewsTab() { + const { data } = useBackend(); + const { cols, rows, narrow } = useGrid(); + const crews = data.crews || []; + const [selRef, setSelRef] = useState(null); + const [page, setPage] = useState(1); + /** Which row of a new page takes the focus: the last when paging up with the arrow keys. */ + const [enterLast, setEnterLast] = useState(false); + + const selected = crews.find((c) => c.ref === selRef) || null; + const h = rows - 1 - TOP; + // The record's own BACK (CrewRecord) wins while it is open; this only covers a stale selection. + useBack(selected ? () => setSelRef(null) : null); + + if (selected && narrow) { + return ( + setSelRef(null)} + /> + ); + } + + const split = !!selected; + // The mockup splits 64 | 46 at 110 columns. + const lw = split ? Math.round((cols * 64) / 110) : cols; + const showMemo = !split && !narrow; + // Fixed-width columns are placed from the right; the class column takes a share of the rest. + // With the memo column it starts at the mockup's share of the width (column 70 of 126). + const memo = Math.round((cols * 70) / 126); + const st = showMemo ? memo - 10 : lw - 11; + const open = st - (showMemo ? 7 : 6); + const crewCol = open - (showMemo ? 7 : 6); + const name = 3; + const cls = name + Math.min(26, Math.round((crewCol - name) * 0.58)); + + const hy = TOP + 1; + const fit = Math.max(1, h - 3); + const pages = Math.max(1, Math.ceil(crews.length / fit)); + const pg = Math.min(pages, Math.max(1, page)); + const shown = crews.slice((pg - 1) * fit, pg * fit); + + const out: ReactNode[] = []; + out.push( + + SHIP + , + + CLASS + , + + CREW + , + + OPEN + , + ); + if (showMemo) { + out.push( + + MEMO + , + ); + } + + if (!crews.length) { + out.push( + + {data.phase === 'playing' + ? 'NO CREWS ARE SIGNING ON.' + : 'CREWS SIGN ON ONCE THE PORT OPENS.'} + , + ); + } + + shown.forEach((c, i) => { + const y = hy + 1 + i; + const isSel = c.ref === selRef; + const k = isSel ? 'inv' : ''; + const slots = openSlots(c); + const stat = status(c); + if (isSel) { + out.push( + + {' '.repeat(Math.max(0, lw - 3))} + , + ); + } + out.push( + + {isSel ? '>' : ' '} + , + + {pad(c.name.toUpperCase(), cls - name - 1)} + , + + {pad(c.cls, crewCol - cls - 1)} + , + + {lpad(c.crew, 3)} + , + + {lpad(slots || '-', 3)} + , + ); + if (stat) { + out.push( + + {stat[0]} + , + ); + } + if (showMemo) { + out.push( + + {pad(c.memo, cols - memo - 2)} + , + ); + } + const firstRow = i === 0; + const lastRow = i === shown.length - 1; + out.push( + setSelRef(isSel ? null : c.ref)} + auto={(enterLast ? lastRow : firstRow) ? 1 : undefined} + edge={{ + down: + lastRow && pg < pages + ? () => { + setEnterLast(false); + setPage(pg + 1); + return true; + } + : undefined, + up: + firstRow && pg > 1 + ? () => { + setEnterLast(true); + setPage(pg - 1); + return true; + } + : undefined, + }} + />, + ); + }); + + if (pages > 1) { + const py = TOP + h - 1; + const label = ` ${pg}/${pages} `; + out.push( + 1 ? 'inv' : 'faint'} + bg={pg <= 1} + onClick={ + pg > 1 + ? () => { + setEnterLast(false); + setPage(pg - 1); + } + : undefined + } + > + {' < '} + , + + {label} + , + = pages} + onClick={ + pg < pages + ? () => { + setEnterLast(false); + setPage(pg + 1); + } + : undefined + } + > + {' > '} + , + ); + } + + return ( + <> + + {out} + + {!!selected && ( + setSelRef(null)} + /> + )} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx new file mode 100644 index 00000000000..b3326e9f996 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx @@ -0,0 +1,452 @@ +/** + * FIT: the finish, then each module slot. Wide: the step list with the running total and + * crew on the left, the viewscreen and this step's options on the right. Narrow: the + * viewscreen above the options, PREV/NEXT between steps. + */ +import { type ReactNode, useEffect, useState } from 'react'; + +import { ShipPic } from './ShipPic'; +import { + cut, + Frame, + Hit, + lpad, + Put, + pad, + RPut, + Rule, + TOP, + useGrid, + wrap, +} from './term'; +import type { CrewLine, Finish, Hull, Module } from './types'; +import { + billOf, + costOf, + crewOf, + finishOwned, + fitOf, + Key, + moduleOwned, + partsOf, + type ShipFit, + usePreview, + type YardCtx, +} from './YardTab'; + +type Step = { + label: string; + /** Finish step, or the slot key. */ + slot: string | null; + opts: (Finish | Module)[]; + pick: number; +}; + +function jobsText(jobs: CrewLine[]): string { + return jobs.map(([t, n]) => (n > 1 ? `${t} x${n}` : t)).join(', '); +} + +function stepsOf(fit: ShipFit): Step[] { + const steps: Step[] = []; + const { hull, finish } = fit; + if (hull.finishes.length) { + steps.push({ + label: 'FINISH', + slot: null, + opts: hull.finishes, + pick: Math.max( + 0, + hull.finishes.findIndex((f) => f.id === finish?.id), + ), + }); + } + for (const s of fit.slots) { + steps.push({ + label: s.label, + slot: s.key, + opts: s.options, + pick: Math.max( + 0, + s.options.findIndex((m) => m.id === s.pick?.id), + ), + }); + } + return steps; +} + +export function Fit(props: { ctx: YardCtx; hull: Hull }) { + const { ctx, hull } = props; + const { view, set, own } = ctx; + const { cols, rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = fitOf(hull, view.finish, view.picks); + const steps = stepsOf(fit); + /** First option row shown when a step has more options than rows, and the row to focus. */ + const [win, setWin] = useState<{ st: number; top: number; want: number }>({ + st: -1, + top: 0, + want: -1, + }); + // Nothing to fit (no finishes, no rooms): show the bill instead of a blank screen. + useEffect(() => { + if (!steps.length) { + set({ screen: 'bill' }); + } + }, [steps.length]); + if (!steps.length) { + return null; + } + const st = Math.min(steps.length - 1, Math.max(0, view.step)); + const S = steps[st]; + const bill = billOf(own, fit, ctx.account); + const crew = crewOf(fit); + const crewN = crew.reduce((a, l) => a + l[1], 0); + const out: ReactNode[] = []; + + const ownedOpt = (o: Finish | Module) => + S.slot === null + ? finishOwned(own, hull, o as Finish) + : moduleOwned(own, hull, o as Module); + const choose = (j: number) => { + const o = S.opts[j]; + if (S.slot === null) { + set({ finish: o.id }); + } else { + set({ picks: { ...view.picks, [S.slot]: o.id } }); + } + }; + const last = st === steps.length - 1; + const next = () => (last ? set({ screen: 'bill' }) : set({ step: st + 1 })); + + const x = narrow + ? 0 + : Math.min(48, Math.max(34, Math.floor(cols * 0.36))) + 1; + const w = cols - x; + const lw = x - 1; + + // The viewscreen takes what the busiest step's options leave, so it doesn't jump. + const descRows = (o: Finish | Module) => + Math.min(2, wrap(o.desc, w - 6).length); + const need = + Math.max( + ...steps.map((s) => { + let n = s.opts.length + Math.max(0, ...s.opts.map(descRows)); + if (s.slot === null) { + n += Math.max( + 0, + ...s.opts.map( + (o) => + Math.min(2, wrap(jobsText((o as Finish).jobs), w - 12).length) + + 1, + ), + ); + } + return n; + }), + ) + (narrow ? 7 : 6); + const ph = Math.max(narrow ? 6 : 9, Math.min(Math.floor(h * 0.62), h - need)); + + if (!narrow) { + // Step list, total and crew. + out.push( + , + ); + // Two rows a step when there is room, one when not; past that, a window round this step. + const pitch = TOP + 2 + steps.length * 2 + 8 <= TOP + h ? 2 : 1; + const stepRoom = Math.max(1, Math.floor((h - 10) / pitch)); + const from = Math.max( + 0, + Math.min(steps.length - stepRoom, st - Math.floor(stepRoom / 2)), + ); + let y = TOP + 2; + steps.slice(from, from + stepRoom).forEach((s, n) => { + const i = from + n; + const o = s.opts[s.pick]; + const cur = i === st; + const paid = + !!o && + !(s.slot === null + ? finishOwned(own, hull, o as Finish) + : moduleOwned(own, hull, o as Module)); + if (cur) { + out.push( + + {' '.repeat(lw - 3)} + , + ); + } + out.push( + + {cur ? '>' : ' '} + , + + {pad(s.label, 14)} + , + + {pad(o ? o.name : '-', lw - 21)} + , + set({ step: i })} + />, + ); + y += pitch; + }); + if (pitch === 1) { + y++; + } + out.push(); + const tot = costOf(bill.total) || 'NOTHING DUE'; + const totLines = wrap(tot, lw - 13); + const short = Object.keys(bill.short).length > 0; + out.push( + + TOTAL + , + ); + totLines.forEach((l, i) => { + out.push( + + {l} + , + ); + }); + let cy = y + 3 + Math.max(0, totLines.length - 1); + const stop = TOP + h - 4; + if (cy + 2 < stop) { + out.push( + + CREW + , + + {String(crewN)} + , + ); + cy += 2; + if (cy + crew.length <= stop) { + crew.forEach(([job, n], i) => { + out.push( + + {pad(job, lw - 10)} + , + + {lpad(n, 2)} + , + ); + }); + } + } + out.push( + set({ screen: 'bill' })} + />, + ); + } + + // Viewscreen. + out.push( + , + , + ); + + // This step's options. + const oy = TOP + ph; + const oh = h - ph; + const title = narrow ? `${S.label} ${st + 1}/${steps.length}` : S.label; + out.push(); + let y = oy + 2; + const tagW = 18; + const by = oy + oh - 2; + // Options get the rows above the keys less two for the description; more scroll. + const optRows = Math.max(1, by - (narrow ? 2 : 1) - y - 2); + const many = S.opts.length > optRows; + let top = 0; + if (many) { + top = + win.st === st + ? win.top + : Math.max(0, Math.min(S.pick, S.opts.length - optRows)); + top = Math.max(0, Math.min(S.opts.length - optRows, top)); + out.push( + + {` ${top + 1}-${top + optRows}/${S.opts.length} `} + , + ); + } + const scroll = (to: number, want: number) => { + setWin({ st, top: to, want }); + return true; + }; + const shownOpts = S.opts.slice(top, top + optRows); + shownOpts.forEach((o, n) => { + const j = top + n; + const sel = j === S.pick; + const firstRow = n === 0; + const lastRow = n === shownOpts.length - 1; + const focusHere = win.st === st && win.want >= 0 ? win.want === j : sel; + const owned = ownedOpt(o); + if (sel) { + out.push( + + {' '.repeat(w - 3)} + , + ); + } + const tag = cut(o.def ? 'STOCK' : owned ? 'OWNED' : costOf(o.cost), tagW); + out.push( + + {sel ? '>' : ' '} + , + + {pad(o.name, w - 6 - Math.min(tagW, tag.length + 2))} + , + + {tag} + , + choose(j)} + auto={focusHere ? 1 : undefined} + edge={{ + down: + lastRow && j < S.opts.length - 1 + ? () => scroll(top + 1, j + 1) + : undefined, + up: firstRow && j > 0 ? () => scroll(top - 1, j - 1) : undefined, + }} + />, + ); + y++; + }); + y++; + const o = S.opts[S.pick]; + if (o) { + const jl = + S.slot === null ? wrap(jobsText((o as Finish).jobs), w - 12) : []; + const rowsFree = + by - (narrow ? 2 : 1) - y - (jl.length ? jl.length + 1 : 0); + wrap(o.desc, w - 6) + .slice(0, Math.max(1, rowsFree)) + .forEach((l, i) => { + out.push( + + {l} + , + ); + }); + if (jl.length) { + y++; + out.push( + + CREW + , + ); + jl.slice(0, Math.max(1, by - (narrow ? 2 : 1) - y)).forEach((l, i) => { + out.push( + + {l} + , + ); + }); + } + } + if (narrow) { + const tot = costOf(bill.total); + const short = Object.keys(bill.short).length > 0; + out.push( + + {`CREW ${crewN}`} + , + ); + if (tot) { + out.push( + + {tot.slice(0, w - 15)} + , + ); + } + if (!last) { + out.push( + set({ screen: 'bill' })} + />, + ); + } + } + if (st > 0) { + out.push( + set({ step: st - 1 })} + />, + ); + } + out.push( + , + ); + + return <>{out}; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx new file mode 100644 index 00000000000..d5ee44996ed --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { cut, Put, pad, RPut, Rule, useGrid } from './term'; +import type { LobbyData, TabId } from './types'; + +export const TABS: [TabId, string][] = [ + ['crews', 'CREWS'], + ['shipyard', 'SHIPYARD'], + ['housing', 'HOUSING'], + ['character', 'CHARACTER'], +]; + +const two = (n: number) => String(Math.floor(n)).padStart(2, '0'); + +function hms(seconds: number) { + const t = Math.max(0, Math.floor(seconds)); + return `${two(t / 3600)}:${two((t % 3600) / 60)}:${two(t % 60)}`; +} + +function ms(seconds: number) { + const t = Math.max(0, Math.floor(seconds)); + return `${two(t / 60)}:${two(t % 60)}`; +} + +/** Seconds since the last server value arrived, ticking once a second. */ +function useElapsed(stamp: number) { + const [since, setSince] = useState(() => Date.now()); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + setSince(Date.now()); + setNow(Date.now()); + }, [stamp]); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + return Math.max(0, (now - since) / 1000); +} + +function clockText(data: LobbyData, elapsed: number): string { + const { phase, clock } = data; + switch (phase) { + case 'playing': + return hms(clock / 10 + elapsed); + case 'loading': + case 'pregame': + if (clock < 0) { + return 'DEPARTURE DELAYED'; + } + return clock > 0 ? `DEPARTURE ${ms(clock - elapsed)}` : 'DEPARTURE SOON'; + case 'setup': + return 'DEPARTING'; + default: + return 'PORT CLOSED'; + } +} + +/** Title bar (row 0), tab bar (row 2) and its rule (row 3). */ +export function Header(props: { tab: TabId; setTab: (tab: TabId) => void }) { + const { tab, setTab } = props; + const { data } = useBackend(); + const { cols, narrow } = useGrid(); + const elapsed = useElapsed(data.clock); + + const title = data.port + ? ` ${data.port.toUpperCase()} : BERTH OFFICE` + : ' BERTH OFFICE'; + let right = clockText(data, elapsed); + if (data.phase !== 'playing' || !narrow) { + right = `${data.online} ONLINE ${right}`; + } + if (data.admin_ready) { + const [ready, admins, adminTotal] = data.admin_ready; + right = `${ready} READY ${admins}/${adminTotal} ADM ${right}`; + } + + if (right.length > cols - 2) { + right = right.slice(right.length - Math.max(0, cols - 2)); + } + // The port name gives way to the clock: cut it, never run it under the right-hand text. + const titleText = cut(title, Math.max(0, cols - right.length - 3)); + + let x = 1; + const tabs = TABS.map(([id, label]) => { + const text = ` ${label} `; + const at = x; + x += text.length + 3; + return ( + setTab(id)} + tab + > + {text} + + ); + }); + + return ( + <> + {/* One lit bar in three pieces that meet but never overlap: title, clock, end cell. */} + + {pad(titleText, Math.max(0, cols - 1 - right.length))} + + + {right} + + + {' '} + + {tabs} + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx new file mode 100644 index 00000000000..116313162cc --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx @@ -0,0 +1,231 @@ +/** + * HOUSING tab: player outposts taking residents (DM: lobby_housing.dm). + * Draws only with the term.tsx primitives. + */ +import { Fragment, type ReactNode, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { + Blocks, + Btn, + type Cls, + Field, + Frame, + Put, + Rule, + TOP, + useGrid, +} from './term'; +import type { LobbyData, Outpost, OutpostMode } from './types'; + +const MODES: Record = { + open: ['OPEN', ''], + password: ['CODE', 'amb'], + approved: ['INVITE', 'amb'], + closed: ['CLOSED', 'red'], +}; + +/** Cuts a name to n cells, ending a cut in '.'. */ +function cutName(name: string, n: number): string { + return name.length <= n ? name : `${name.slice(0, n - 1).trimEnd()}.`; +} + +/** Width of the code field between its brackets. */ +const FIELD = 10; +const MOVE_IN = '[ MOVE IN ]'; +/** Widest key group at the right of a row: CODE [field] [ MOVE IN ]. */ +const KEYS_W = 5 + FIELD + 3 + MOVE_IN.length; + +export function HousingTab() { + const { act, data } = useBackend(); + const { cols, rows, narrow } = useGrid(); + const [codes, setCodes] = useState>({}); + const [page, setPage] = useState(0); + const outposts = data.outposts || []; + + const h = rows - 1 - TOP; + const bottom = TOP + h - 1; + const hy = TOP + 1; + const right = cols - 4; + // The mockup's columns on a wide screen; tighter on a narrow one, with the keys on a second line. + const colA = narrow ? Math.min(22, Math.floor(cols * 0.42)) : 30; + const colR = narrow ? colA + 9 : 44; + const nameW = colA - 5; + const step = narrow ? 4 : 3; + const perPage = Math.max(1, Math.floor((bottom - 1 - (hy + 2)) / step)); + const pages = Math.max(1, Math.ceil(outposts.length / perPage)); + const shownPage = Math.min(page, pages - 1); + const shown = outposts.slice( + shownPage * perPage, + shownPage * perPage + perPage, + ); + const blockEnd = narrow ? cols - 3 : right - KEYS_W - 2; + const blockRoom = Math.max(0, Math.floor((blockEnd - (colR + 5)) / 2)); + + const moveIn = (o: Outpost) => { + act('home_move_in', { outpost: o.ref, code: codes[o.ref] || '' }); + setCodes({ ...codes, [o.ref]: '' }); + }; + + const codeKeys = (o: Outpost, y: number) => { + const locked = (o.code_wait || 0) > 0; + const code = codes[o.ref] || ''; + const fx = right - MOVE_IN.length - 2 - FIELD; + const cls = locked ? 'faint' : 'amb'; + return ( + <> + + CODE + + setCodes({ ...codes, [o.ref]: value })} + onEnter={() => moveIn(o)} + /> + 0} + disabled={locked} + onClick={() => moveIn(o)} + /> + + ); + }; + + const keys = (o: Outpost, y: number) => { + if (o.mode === 'password' && !o.cleared && !o.denial) { + return codeKeys(o, y); + } + const ax = right - MOVE_IN.length - 2; + let ask: ReactNode = null; + if (o.can_ask) { + ask = o.asked ? ( + + ) : ( + act('home_request', { outpost: o.ref })} + /> + ); + } + return ( + <> + {ask} + {o.denial ? ( + // Faint, but a press still answers with the reason as a notice. + act('home_move_in', { outpost: o.ref })} + > + {MOVE_IN} + + ) : ( + moveIn(o)} + /> + )} + + ); + }; + + return ( + + {outposts.length === 0 ? ( + + NO ROOMS TO LET. + + ) : ( + <> + + OUTPOST + + + ADMITS + + + {narrow ? 'RES' : 'RESIDENTS'} + + + )} + {shown.map((o, i) => { + const y = hy + 2 + i * step; + const [mode, modeCls] = MODES[o.mode] || MODES.closed; + const blocks = Math.max(0, Math.min(o.residents, blockRoom)); + return ( + + + {cutName(o.name.toUpperCase(), nameW)} + + + {mode} + + + {String(o.residents).padStart(3, ' ')} + + {Array.from({ length: blocks }, (_, k) => ( + + ))} + {keys(o, narrow ? y + 1 : y)} + {i < shown.length - 1 && ( + + )} + + ); + })} + {pages > 1 && ( + <> + setPage(shownPage - 1)} + /> + + {`${shownPage + 1}/${pages}`} + + = pages - 1} + onClick={() => setPage(shownPage + 1)} + /> + + )} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx new file mode 100644 index 00000000000..a3cc8b51c65 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx @@ -0,0 +1,132 @@ +/** + * One hull's record: the stock fit on the viewscreen, model name, description, crew, + * rooms, finishes, price and [ FIT OUT ]. + */ +import type { ReactNode } from 'react'; + +import { ShipPic } from './ShipPic'; +import { cut, Frame, Put, TOP, useGrid, wrapMax } from './term'; +import type { Hull } from './types'; +import { + costOf, + crewCount, + hullOwned, + Key, + stockFit, + usePreview, + type YardCtx, +} from './YardTab'; + +export function HullRecord(props: { + ctx: YardCtx; + hull: Hull; + x: number; + w: number; +}) { + const { ctx, hull, x, w } = props; + const { rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = stockFit(hull); + const picH = Math.max(6, Math.floor(h * 0.42)); + const out: ReactNode[] = []; + + out.push( + , + ); + let y = TOP + 2 + picH; + out.push( + + {cut(hull.name.toUpperCase(), w - 6)} + , + ); + y++; + const by = TOP + h - 3; + const owned = hullOwned(ctx.own, hull); + // Leave room for the stats line and the price above the key. + const descRows = Math.max(1, by - (owned ? 1 : 3) - 2 - y); + wrapMax(hull.desc, w - 6, descRows).forEach((l, i) => { + out.push( + + {l} + , + ); + }); + y++; + if (y < by - (owned ? 0 : 2)) { + out.push( + + CREW + , + + {String(crewCount(fit))} + , + + ROOMS + , + + {String(hull.slots.length)} + , + ); + if (hull.finishes.length && w >= 44) { + out.push( + + FINISHES + , + + {String(hull.finishes.length)} + , + ); + } + } + if (!owned) { + out.push( + + PRICE + , + + {cut(costOf(hull.cost), w - 13)} + , + ); + } + out.push( + + ctx.set({ + // A hull with no finishes and no rooms has nothing to fit: straight to the bill. + screen: hull.finishes.length || hull.slots.length ? 'fit' : 'bill', + finish: null, + picks: {}, + step: 0, + }) + } + />, + ); + if (!narrow) { + out.push( + ctx.set({ hull: null })} + > + {' X '} + , + ); + } + + return ( + + {out} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx new file mode 100644 index 00000000000..5144a1ad795 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx @@ -0,0 +1,212 @@ +/** + * HULLS registry. Wide: the list, and the selected hull's record beside it. Narrow: the + * list, or the record on its own screen ([ BACK ] returns to the list). + */ +import { type ReactNode, useMemo, useState } from 'react'; + +import { HullRecord } from './HullRecord'; +import { Frame, Hit, lpad, Put, pad, TOP, useGrid } from './term'; +import { + crewCount, + hullOwned, + partsOf, + stockFit, + type YardCtx, +} from './YardTab'; + +export function Hulls(props: { ctx: YardCtx }) { + const { ctx } = props; + const { catalog, hull, own } = ctx; + const { cols, rows, narrow } = useGrid(); + const h = rows - 1 - TOP; + const crews = useMemo( + () => catalog.map((hl) => crewCount(stockFit(hl))), + [catalog], + ); + const [page, setPage] = useState(1); + const [enterLast, setEnterLast] = useState(false); + + if (narrow && hull) { + return ; + } + + const split = !!hull && !narrow; + const lw = split ? Math.max(40, Math.floor(cols * 0.41)) : cols; + // Columns, right to left from the price. + const price = lw - 14; + const rooms = price - 8; + const crew = rooms - 6; + const fin = split ? 0 : price - 9; + const showModel = !split && crew - 28 >= 20; + const showFin = !split && !narrow; + const roomsAt = showFin ? fin - 8 : rooms; + const crewAt = showFin ? roomsAt - 7 : crew; + const nameEnd = showModel ? 27 : crewAt - 1; + + const out: ReactNode[] = []; + const hy = TOP + 1; + out.push( + + HULL + , + + CREW + , + + ROOMS + , + + PRICE + , + ); + if (showModel) { + out.push( + + MODEL + , + ); + } + if (showFin) { + out.push( + + FINISH + , + ); + } + // Rows below the header, less one for the pager when the list runs over. + let maxRows = Math.max(1, TOP + h - 2 - hy); + if (catalog.length > maxRows) { + maxRows = Math.max(1, maxRows - 1); + } + const pages = Math.max(1, Math.ceil(catalog.length / maxRows)); + const pg = Math.min(pages, Math.max(1, page)); + const goPage = (to: number, last: boolean) => { + setEnterLast(last); + setPage(to); + }; + const shown = catalog.slice((pg - 1) * maxRows, pg * maxRows); + shown.forEach((hl, n) => { + const i = (pg - 1) * maxRows + n; + const y = hy + 1 + n; + const firstRow = n === 0; + const lastRow = n === shown.length - 1; + const sel = hull?.id === hl.id; + const k = sel ? 'inv' : ''; + const owned = hullOwned(own, hl); + const cost = partsOf(hl.cost); + const priceText = owned + ? cost && !hl.free && !own.free + ? 'OWNED' + : 'FREE' + : `${cost} PARTS`; + if (sel) { + out.push( + + {' '.repeat(lw - 3)} + , + ); + } + out.push( + + {sel ? '>' : ' '} + , + + {pad(hl.short.toUpperCase(), nameEnd - 3)} + , + + {lpad(crews[i], 3)} + , + + {lpad(hl.slots.length, 3)} + , + + {pad(priceText, lw - 3 - price)} + , + ); + if (showModel) { + out.push( + + {pad(hl.name, crewAt - 29)} + , + ); + } + if (showFin) { + out.push( + + {lpad(hl.finishes.length || '-', 3)} + , + ); + } + out.push( + ctx.set({ hull: sel ? null : hl.id })} + auto={(enterLast ? lastRow : firstRow) && !hull ? 1 : undefined} + edge={{ + down: + lastRow && pg < pages + ? () => { + goPage(pg + 1, false); + return true; + } + : undefined, + up: + firstRow && pg > 1 + ? () => { + goPage(pg - 1, true); + return true; + } + : undefined, + }} + />, + ); + }); + if (pages > 1) { + const py = TOP + h - 1; + const label = ` ${pg}/${pages} `; + out.push( + 1 ? 'inv' : 'faint'} + bg={pg <= 1} + onClick={pg > 1 ? () => goPage(pg - 1, false) : undefined} + > + {' < '} + , + + {label} + , + = pages} + onClick={pg < pages ? () => goPage(pg + 1, false) : undefined} + > + {' > '} + , + ); + } + + return ( + <> + + {out} + {split && !!hull && ( + + )} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/KitEditor.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/KitEditor.tsx new file mode 100644 index 00000000000..fe4b10fc659 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/KitEditor.tsx @@ -0,0 +1,568 @@ +/** + * KIT (CHARACTER-PLAN 2.8): tg's loadout categories and items, char_kit. A packed item + * shows its options under it: one ColorEntry per GAGS colour slot (char_kit_color), a + * STYLE cycler for reskins (char_kit_skin) and a NAME field where tg allows renaming + * (char_kit_name). Nothing here has a price. Owner: C4. + * + * Box: x and w are the editor frame's outer columns, y the row under its top line, + * y + h - 1 its bottom line. + */ +import { useEffect, useState } from 'react'; +import { useBackend } from 'tgui/backend'; +import type { + LoadoutCategory, + LoadoutItem, +} from 'tgui/interfaces/PreferencesMenu/CharacterPreferences/loadout/base'; + +import { ColorEntry } from './ColorEntry'; +import { fileColors } from './TraitsEditor'; +import { + Btn, + Cycler, + Field, + Frame, + Hit, + Put, + pad, + Swatch, + useGrid, +} from './term'; +import { + type CellBox, + type CharKitItem, + KIT_NAME_MAX, + type LobbyData, +} from './types'; +import { usePrefsCatalog } from './usePrefsCatalog'; + +type Kit = Record; + +/** Category column width. */ +const CAT_W = 13; +/** PACKED box width on wide screens. */ +const PACK_W = 30; +/** Label column of the option rows under a packed item. */ +const OPT_W = 9; +/** Names the server takes for a kit item (empty clears). */ +const NAME_OK = /^[\x20-\x7e]*$/; +const NAME_BAD = /[<>/\\]/; +/** The category whose items stay at two (tg: /datum/loadout_category/pocket). */ +const TWO_ALLOWED = 'Other'; + +/** The colours of a packed item, one per slot, "#rrggbb". */ +function slotColors(item: CharKitItem | undefined, slots: number): string[] { + const found = (item?.greyscale || '').match(/#[0-9a-f]{6}/gi) || []; + const out: string[] = []; + for (let i = 0; i < slots; i++) { + out.push((found[i] || '#ffffff').toLowerCase()); + } + return out; +} + +/** The server stores names html-escaped; show them as typed. */ +function unescapeName(text: string): string { + return text + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_, n) => + String.fromCharCode(parseInt(n, 16)), + ) + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +/** One drawn line of the item list. */ +type Line = + | { kind: 'group'; text: string } + | { kind: 'item'; item: LoadoutItem } + | { kind: 'color'; item: LoadoutItem; slot: number; slots: number } + | { kind: 'skin'; item: LoadoutItem } + | { kind: 'name'; item: LoadoutItem } + | { kind: 'gap' }; + +export function KitEditor(props: CellBox) { + const { x, y, w, h } = props; + const { act, data } = useBackend(); + const { narrow } = useGrid(); + const catalog = usePrefsCatalog(); + const char = data.char; + const kitColors = data.char_static?.kit_colors || {}; + const [cat, setCat] = useState(0); + const [page, setPage] = useState(1); + // Local copy after a click, dropped on the next push (a refused edit pushes too). + const [local, setLocal] = useState(null); + useEffect(() => setLocal(null), [char]); + + if (!char || !catalog) { + return null; + } + const tabs: LoadoutCategory[] = catalog.loadout?.loadout_tabs || []; + if (!tabs.length) { + return null; + } + const kit: Kit = local || char.kit || {}; + const ci = Math.min(cat, tabs.length - 1); + const tab = tabs[ci]; + const tabOf = (path: string) => + tabs.findIndex((t) => t.contents.some((it) => it.path === path)); + + const toggle = (item: LoadoutItem) => { + const on = !(item.path in kit); + const next: Kit = { ...kit }; + if (on) { + // Show tg's duplicate rule at once: one per category, two in Other (oldest goes). + const same = Object.keys(next).filter( + (p) => tab.contents.some((it) => it.path === p) && p !== item.path, + ); + const limit = tab.name === TWO_ALLOWED ? 2 : 1; + while (same.length >= limit) { + delete next[same.shift() as string]; + } + next[item.path] = {}; + } else { + delete next[item.path]; + } + setLocal(next); + act('char_kit', { path: item.path, on: on ? 1 : 0 }); + }; + + const wide = !narrow && w >= 70; + const packW = wide ? PACK_W : 0; + const ix = x + CAT_W + 3; + const iw = w - CAT_W - 5 - packW; + const top = y + 1; + const bottom = y + h - 3; + const catStep = h - 2 >= tabs.length * 2 ? 2 : 1; + + // ---- the item lines of this category ---- + const multiGroup = + new Set(tab.contents.map((it) => it.group || tab.name)).size > 1; + // Items grouped as tg's menu groups them, groups in order of first appearance. + const groupOrder: string[] = []; + for (const it of tab.contents) { + const g = it.group || tab.name; + if (!groupOrder.includes(g)) { + groupOrder.push(g); + } + } + const items = [...tab.contents].sort( + (a, b) => + groupOrder.indexOf(a.group || tab.name) - + groupOrder.indexOf(b.group || tab.name), + ); + const blocksOf: Line[][] = []; + let group: string | null = null; + for (const item of items) { + const block: Line[] = []; + const g = item.group || tab.name; + if (multiGroup && g !== group) { + block.push({ kind: 'group', text: g }); + } + group = g; + block.push({ kind: 'item', item }); + if (item.path in kit) { + const slots = kitColors[item.path] || 0; + for (let s = 0; s < slots; s++) { + block.push({ kind: 'color', item, slot: s, slots }); + } + if (item.reskins?.length) { + block.push({ kind: 'skin', item }); + } + if (item.buttons?.some((b) => b.act_key === 'set_name')) { + block.push({ kind: 'name', item }); + } + } + blocksOf.push(block); + } + // Double spacing when the whole category fits that way, as the mockup. + const room = bottom - top + 1; + const total = blocksOf.reduce((n, b) => n + b.length, 0); + const spaced = total + blocksOf.length - 1 <= room; + // Pages hold whole items. + const pages: Line[][] = [[]]; + for (const block of blocksOf) { + const cur = pages[pages.length - 1]; + const need = block.length + (spaced && cur.length ? 1 : 0); + if (cur.length && cur.length + need > room - 1) { + // A page that opens mid-group repeats the group's name. + const g = block[0].kind === 'item' && block[0].item.group; + const head: Line[] = + multiGroup && block[0].kind !== 'group' + ? [{ kind: 'group', text: g || tab.name }] + : []; + pages.push([...head, ...block]); + } else { + if (spaced && cur.length) { + cur.push({ kind: 'gap' }); + } + cur.push(...block); + } + } + const pg = Math.min(pages.length, page); + const lines = pages[pg - 1]; + const recent = fileColors( + char.prefs, + Object.keys(kit).flatMap((p) => slotColors(kit[p], kitColors[p] || 0)), + ); + + return ( + <> + {tabs.map((t, i) => { + const n = Object.keys(kit).filter((p) => + t.contents.some((it) => it.path === p), + ).length; + return ( + { + setCat(i); + setPage(1); + }} + /> + ); + })} + {lines.map((line, i) => { + const ly = top + i; + if (line.kind === 'gap') { + return null; + } + if (line.kind === 'group') { + return ( + + {pad(line.text.toUpperCase(), iw)} + + ); + } + const item = line.item; + if (line.kind === 'item') { + return ( + toggle(item)} + /> + ); + } + if (line.kind === 'color') { + const cols = slotColors(kit[item.path], line.slots); + return ( + 1 ? `COLOUR ${line.slot + 1}` : 'COLOUR', + OPT_W, + )} + value={cols[line.slot]} + recent={recent} + onSet={(color) => + act('char_kit_color', { + path: item.path, + slot: line.slot, + color, + }) + } + /> + ); + } + if (line.kind === 'skin') { + return ( + act('char_kit_skin', { path: item.path, skin })} + /> + ); + } + return ( + act('char_kit_name', { path: item.path, name })} + /> + ); + })} + {pages.length > 1 && ( + + )} + {!!wide && ( + { + const t = tabOf(path); + if (t >= 0) { + setCat(t); + setPage(1); + } + }} + /> + )} + + ); +} + +function CatRow(props: { + x: number; + y: number; + name: string; + count: number; + cur: boolean; + onClick: () => void; +}) { + const { x, y, name, count, cur, onClick } = props; + return ( + <> + {!!cur && ( + + {' '.repeat(CAT_W)} + + )} + + {pad(name.toUpperCase(), CAT_W - 3)} + + {count > 0 && ( + + {String(count)} + + )} + + + ); +} + +function ItemRow(props: { + x: number; + y: number; + w: number; + name: string; + on: boolean; + onClick: () => void; +}) { + const { x, y, w, name, on, onClick } = props; + const [hover, setHover] = useState(false); + const label = pad(name, Math.max(4, w - 4)); + return ( + <> + + {on ? '[*]' : '[ ]'} + + + {label} + + + + ); +} + +function SkinRow(props: { + x: number; + y: number; + w: number; + item: LoadoutItem; + current?: string; + onSkin: (skin: string) => void; +}) { + const { x, y, w, item, current, onSkin } = props; + const skins = item.reskins || []; + let i = skins.findIndex((s) => s.name === current); + if (i < 0) { + i = Math.max( + 0, + skins.findIndex((s) => s.skin_icon_state === item.icon_state), + ); + } + const step = (d: number) => + onSkin(skins[(i + d + skins.length) % skins.length].name); + return ( + <> + + {'STYLE'} + + step(-1)} + onNext={() => step(1)} + /> + + ); +} + +function NameRow(props: { + x: number; + y: number; + w: number; + current: string; + onName: (name: string) => void; +}) { + const { x, y, w, current, onName } = props; + const [text, setText] = useState(current); + useEffect(() => setText(current), [current]); + const dirty = text.trim() !== current; + // The field takes the row; SET (9 cells) takes its end while there is an edit. + const fw = Math.max(8, Math.min(KIT_NAME_MAX, w - 7 - (dirty ? 9 : 0))); + // The server refuses markup characters, slashes and anything outside printable ASCII. + const ok = + text.trim().length <= KIT_NAME_MAX && + NAME_OK.test(text) && + !NAME_BAD.test(text); + const send = () => { + if (ok) { + onName(text.trim()); + } + }; + return ( + <> + + {'NAME'} + + + {!!dirty && ( + + )} + + ); +} + +function KitPager(props: { + x: number; + y: number; + page: number; + pages: number; + onPage: (page: number) => void; +}) { + const { x, y, page, pages, onPage } = props; + return ( + <> + {page > 1 ? ( + onPage(page - 1)}> + {' < '} + + ) : ( + + {' < '} + + )} + + {`${page}/${pages}`} + + {page < pages ? ( + onPage(page + 1)}> + {' > '} + + ) : ( + + {' > '} + + )} + + ); +} + +function Packed(props: { + x: number; + y: number; + w: number; + h: number; + tabs: LoadoutCategory[]; + kit: Kit; + kitColors: Record; + onJump: (path: string) => void; +}) { + const { x, y, w, h, tabs, kit, kitColors, onJump } = props; + const names: Record = {}; + for (const t of tabs) { + for (const it of t.contents) { + names[it.path] = it.name; + } + } + const packed = Object.keys(kit).filter((p) => !!names[p]); + const max = Math.max(0, h - 3); + return ( + <> + + {packed.slice(0, max).map((path, i) => { + const slots = kitColors[path] || 0; + const own = kit[path]?.name; + return ( + onJump(path)} + /> + ); + })} + + ); +} + +function Packline(props: { + x: number; + y: number; + w: number; + name: string; + color: string | null; + onClick: () => void; +}) { + const { x, y, w, name, color, onClick } = props; + return ( + <> + + {pad(name, w)} + + {!!color && } + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx new file mode 100644 index 00000000000..ac6bbe1d53f --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx @@ -0,0 +1,60 @@ +import { useBackend } from 'tgui/backend'; + +import { useBack } from './nav'; +import { Btn, Frame, Put, pad, TOP, useGrid, wrapMax } from './term'; +import type { LobbyData } from './types'; + +/** The CONFIRMED box (kind "ok"), drawn instead of the tab content. */ +export function Confirmed() { + const { data, act } = useBackend(); + const { cols } = useGrid(); + const notice = data.notice; + useBack(notice ? () => act('dismiss') : null); + if (!notice) { + return null; + } + const w = Math.min(44, cols - 2); + const x = Math.floor(cols / 2) - Math.floor(w / 2); + const lines = wrapMax(notice.text.toUpperCase(), w - 6, 3); + const h = 7 + lines.length; + return ( + + {lines.map((line, i) => ( + + {line} + + ))} + + STAND BY. + + act('dismiss')} + /> + + ); +} + +/** One amber or red line on the bottom row. Click to clear it. */ +export function NoticeLine() { + const { data, act } = useBackend(); + const { cols, rows } = useGrid(); + const notice = data.notice; + if (!notice || notice.kind === 'ok') { + return null; + } + return ( + act('dismiss')} + > + {pad(notice.text.toUpperCase(), cols - 2)} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx new file mode 100644 index 00000000000..6906b480afe --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx @@ -0,0 +1,153 @@ +/** + * A saved ship as a line drawing from its survey plan string: rows split by '/', each a + * run-length list of cells (`12o` = twelve floor). '#' wall, 'g' window, 'o' floor, + * 'e' engine, 'w' weapon, 's' sensor, 't' cargo, '.' nothing. Walls are joined centre to + * centre. A tall hull lies on its side when the space is wide. + */ +import { useMemo } from 'react'; + +import { useGrid } from './term'; + +function parsePlan(plan: string): string[][] { + return plan.split('/').map((row) => { + const out: string[] = []; + for (const m of row.matchAll(/(\d*)(\D)/g)) { + const n = m[1] ? Number(m[1]) : 1; + for (let i = 0; i < n; i++) { + out.push(m[2]); + } + } + return out; + }); +} + +export function PlanPic(props: { + x: number; + y: number; + w: number; + h: number; + plan: string; + /** Drawn dark (the ship can't be loaded). */ + faded?: boolean; +}) { + const { x, y, w, h, plan, faded } = props; + const { cw, ch } = useGrid(); + const aw = w * cw; + const ah = h * ch; + + const drawing = useMemo(() => { + let rows = parsePlan(plan); + let W = Math.max(0, ...rows.map((r) => r.length)); + let H = rows.length; + if (!W || !H) { + return null; + } + if (H > W * 1.15 && aw > ah) { + const rot: string[][] = []; + for (let cx = 0; cx < W; cx++) { + rot.push([]); + for (let cy = H - 1; cy >= 0; cy--) { + rot[cx].push(rows[cy][cx] || '.'); + } + } + rows = rot; + [W, H] = [H, W]; + } + const k = Math.min(aw / W, ah / H); + const at = (cx: number, cy: number) => rows[cy]?.[cx] || '.'; + const wall = (c: string) => c === '#' || c === 'g'; + let floor = ''; + let lines = ''; + let glass = ''; + let eng = ''; + const dots: [number, number, string][] = []; + for (let cy = 0; cy < H; cy++) { + for (let cx = 0; cx < W; cx++) { + const c = at(cx, cy); + if (c === '.') { + continue; + } + const px = (cx + 0.5) * k; + const py = (cy + 0.5) * k; + floor += `M${cx * k} ${cy * k}h${k}v${k}h-${k}z`; + if (wall(c)) { + let lone = true; + for (const [dx, dy] of [ + [1, 0], + [0, 1], + ]) { + const n = at(cx + dx, cy + dy); + if (wall(n)) { + const seg = `M${px} ${py}L${px + dx * k} ${py + dy * k}`; + if (c === 'g' || n === 'g') { + glass += seg; + } else { + lines += seg; + } + lone = false; + } + } + if (wall(at(cx - 1, cy)) || wall(at(cx, cy - 1))) { + lone = false; + } + if (lone) { + lines += `M${px - k * 0.3} ${py}h${k * 0.6}`; + } + } else if (c === 'e') { + eng += `M${cx * k + k * 0.15} ${cy * k + k * 0.15}h${k * 0.7}v${k * 0.7}h-${k * 0.7}z`; + } else if (c === 'w' || c === 's' || c === 't') { + dots.push([px, py, c]); + } + } + } + return { W, H, k, floor, lines, glass, eng, dots }; + }, [plan, aw, ah]); + + if (!drawing) { + return null; + } + const { W, H, k, floor, lines, glass, eng, dots } = drawing; + const ph = faded ? '#2b6450' : '#7effbf'; + const dim = faded ? '#1d4636' : '#3f9a76'; + const amb = faded ? '#5a4a15' : '#ffc41f'; + const red = faded ? '#5a2a30' : '#ff5f6f'; + const sw = Math.max(1.2, k * 0.18); + return ( +
+ + + + + + {dots.map(([cx, cy, c], i) => ( + + ))} + +
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/RolesEditor.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/RolesEditor.tsx new file mode 100644 index 00000000000..995131f3d8f --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/RolesEditor.tsx @@ -0,0 +1,161 @@ +/** + * ROLES (CHARACTER-PLAN 2.6): one row per ship role category, NEVER/LOW/MED/HIGH, char_role. + * Owner: C4. + * + * The box is the editor's frame as CharacterTab draws it: x and w are the frame's outer + * columns, y is the row under the frame's top line, y + h - 1 the frame's bottom line. + * Only one category may be HIGH; the server demotes the old HIGH to MED (Q3). A click + * shows that at once and the next push overwrites it. + */ +import { Fragment, useEffect, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { Blocks, Hit, Put, pad, useGrid } from './term'; +import type { CellBox, LobbyData, RoleLevel } from './types'; + +const LEVELS = ['NEVER', 'LOW', 'MED', 'HIGH'] as const; +/** Cells per level column. */ +const COL = 7; + +type Levels = Record; + +/** An empty level cell: a faint outline the size of the lit block. */ +function Slot(props: { x: number; y: number }) { + const { x, y } = props; + const { cw, ch, s } = useGrid(); + return ( +
+ ); +} + +export function RolesEditor(props: CellBox) { + const { x, y, w, h } = props; + const { act, data } = useBackend(); + const char = data.char; + const roles = data.char_static?.roles || []; + // Local copy after a click, dropped on the next push (a refused edit pushes too). + const [local, setLocal] = useState(null); + useEffect(() => setLocal(null), [char]); + if (!char) { + return null; + } + const levels: Levels = local || char.roles || {}; + + const set = (category: string, level: 0 | RoleLevel) => { + const next: Levels = {}; + for (const [cat, lv] of Object.entries(levels)) { + next[cat] = level === 3 && lv === 3 ? 2 : lv; + } + if (level) { + next[category] = level; + } else { + delete next[category]; + } + setLocal(next); + act('char_role', { category, level }); + }; + + const L = x + 3; + const cols = x + w - 3 - 4 * COL; + const nameW = 13; + const exW = cols - L - nameW - 3; + const hy = y + 1; + // Double spacing when the rows fit, as the mockup. + const step = h - 3 >= roles.length * 2 ? 2 : 1; + const first = hy + 1 + (step === 2 ? 1 : 0); + + return ( + <> + {LEVELS.map((label, i) => ( + + {label} + + ))} + {roles.map(([category, examples], ri) => ( + set(category, lv)} + /> + ))} + + ); +} + +function RoleRow(props: { + x: number; + y: number; + cols: number; + nameW: number; + exW: number; + category: string; + examples: string; + level: 0 | RoleLevel; + onSet: (level: 0 | RoleLevel) => void; +}) { + const { x, y, cols, nameW, exW, category, examples, level, onSet } = props; + return ( + <> + + {pad(category.toUpperCase(), nameW)} + + {exW >= 12 && !!examples && ( + + {pad(examples, exW)} + + )} + {LEVELS.map((label, i) => { + const cx = cols + i * COL + 2; + const on = i === level; + return ( + + {on ? ( + + ) : ( + + )} + { + if (!on) { + onSet(i as 0 | RoleLevel); + } + }} + /> + + ); + })} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx new file mode 100644 index 00000000000..75387e21a85 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx @@ -0,0 +1,335 @@ +/** + * SAVED SHIPS box on the shipyard home: one card per slot with the ship's floor plan, + * name, class, what it carries and LOAD, a denial line, or UNDER WAY. Too narrow for three + * cards, it becomes a one-line-per-slot list. + */ +import type { ReactNode } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { PlanPic } from './PlanPic'; +import { Blocks, cut, Frame, Put, pad, RPut, wrap, wrapMax } from './term'; +import type { LobbyData, SaveCard } from './types'; +import { Key, thousands, type YardCtx } from './YardTab'; + +const MIN_CARD = 26; + +type Props = { + saves: (SaveCard | null)[]; + x: number; + y: number; + w: number; + h: number; + ctx: YardCtx; +}; + +/** LOAD, or CONFIRM after the first press when loading leaves a crew. */ +function useLoad(ctx: YardCtx) { + const { act } = useBackend(); + return (sv: SaveCard) => { + const key = `load${sv.slot}`; + if (ctx.crewChange && ctx.view.confirm !== key) { + ctx.set({ confirm: key }); + return; + } + ctx.set({ confirm: null }); + act('yard_load', { slot: sv.slot, leave_crew: ctx.crewChange ? 1 : 0 }); + }; +} + +export function SaveSlots(props: Props) { + const { saves, x, y, w, h, ctx } = props; + const gap = 2; + const n = Math.max(1, saves.length); + const cardW = Math.floor((w - 4 - gap * (n - 1)) / n); + if (cardW < MIN_CARD || h < 14) { + return ; + } + return ( + + {saves.map((sv, i) => ( + + ))} + + ); +} + +function Card(props: { + sv: SaveCard | null; + n: number; + x: number; + y: number; + w: number; + h: number; + ctx: YardCtx; +}) { + const { sv, n, x, y, w, h, ctx } = props; + const load = useLoad(ctx); + const out: ReactNode[] = []; + if (!sv) { + return ( + + + EMPTY + + + ); + } + const ix = x + 2; + const iw = w - 4; + const denied = !!sv.denial; + const fit = sv.fit || []; + + const lines: [string, number | null, string][] = fit.map((f) => [ + f[0], + f[1], + f[2] ?? 'red', + ]); + if (sv.lab) { + lines.push(['Lab machines', sv.lab, '']); + } + if (sv.cargo) { + lines.push(['Cargo machines', sv.cargo, 'amb']); + } + if (sv.research) { + lines.push([`Research, ${sv.research} nodes`, null, '']); + } + if (sv.funds) { + lines.push([`Holds ${thousands(sv.funds)} cr`, null, 'amb']); + } + + const nameRows = Math.min(2, wrap(sv.name.toUpperCase(), iw).length); + const hasStats = sv.mass !== undefined || sv.crew !== undefined; + // Rows below the lines: the key, or a two-line denial, or LEAVES + the key. + const foot = denied ? 2 : 1 + (ctx.crewChange ? 1 : 0); + // The plan shrinks (to 3 rows) before the first few fit lines are cut. + // Short cards drop the blank row under the stats. + const statRows = hasStats ? (h < 15 ? 1 : 2) : 1; + const fixed = 4 + nameRows + 1 + statRows + foot; + const prH = Math.max(2, Math.min(11, h - fixed - Math.min(4, lines.length))); + if (sv.plan) { + out.push( + , + ); + } + let yy = y + 1 + prH + 1; + const nameLines = wrap(sv.name.toUpperCase(), iw); + const nm = nameLines.slice(0, 2); + if (nameLines.length > 2) { + nm[1] = `${pad(nm[1], iw - 1)}.`; + } + nm.forEach((l, i) => { + out.push( + + {l} + , + ); + }); + out.push( + + {pad(sv.class, iw)} + , + ); + if (hasStats) { + if (sv.mass !== undefined) { + out.push( + + MASS + , + + {cut(`${thousands(sv.mass)} t`, sv.crew !== undefined ? 10 : iw - 5)} + , + ); + } + if (sv.crew !== undefined) { + const cx = sv.mass !== undefined ? ix + 16 : ix; + out.push( + + CREW + , + + {String(sv.crew)} + , + ); + } + yy += statRows; + } else { + yy += 1; + } + + const room = Math.max(0, y + h - 2 - foot - yy); + const show = + lines.length > room ? lines.slice(0, Math.max(0, room - 1)) : lines; + show.forEach(([label, count, cls], i) => { + out.push( + , + + {pad(label, iw - 8)} + , + ); + if (count) { + out.push( + + {cut(String(count), 5)} + , + ); + } + yy++; + }); + if (room > 0 && show.length < lines.length) { + out.push( + + {`+${lines.length - show.length} more`} + , + ); + } + + const by = y + h - 2; + if (sv.denial) { + const denial = wrapMax(sv.denial, iw, 2); + denial.forEach((l, i) => { + out.push( + + {l} + , + ); + }); + } else if (sv.locked) { + out.push( + + UNDER WAY + , + ); + } else { + const confirming = ctx.view.confirm === `load${sv.slot}`; + if (ctx.crewChange && confirming) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, iw)} + , + ); + } + out.push( + load(sv)} + />, + ); + } + + return ( + + {out} + + ); +} + +/** Narrow windows: one line per slot. */ +function SaveList(props: Props) { + const { saves, x, y, w, h, ctx } = props; + const load = useLoad(ctx); + const out: ReactNode[] = []; + let yy = y + 2; + const keyW = 12; + saves.forEach((sv, i) => { + if (yy > y + h - 2) { + return; + } + const n = sv?.slot || i + 1; + out.push( + + {String(n)} + , + ); + if (!sv) { + out.push( + + EMPTY + , + ); + yy += 2; + return; + } + const nameW = Math.max(4, w - 6 - keyW - 4); + out.push( + + {pad(sv.name.toUpperCase(), nameW)} + , + ); + if (sv.denial) { + out.push( + + {pad(sv.denial, w - 9)} + , + ); + } else if (sv.locked) { + out.push( + + UNDER WAY + , + ); + } else { + const confirming = ctx.view.confirm === `load${sv.slot}`; + out.push( + + {pad(sv.class, w - 9)} + , + load(sv)} + />, + ); + } + yy += 3; + }); + if (ctx.crewChange && ctx.view.confirm?.startsWith('load')) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, w - 6)} + , + ); + } + return ( + + {out} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx new file mode 100644 index 00000000000..7169b0d3564 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx @@ -0,0 +1,176 @@ +/** + * The yard's real full-colour preview on a recessed viewscreen: the finish's hull picture + * with each picked module laid on its slot, the same compositing as ShipPreview in + * ShipUpgradeSelector.tsx (module connector tile onto the slot marker tile). + * + * Scale: whole screen pixels per tile. 32 px tiles or more snap to a multiple of 32 and + * draw pixelated; anything smaller is a smooth downscale at a whole number of px per tile, + * so tiles never land between pixels. + */ +import { type CSSProperties, type ReactNode, useEffect, useState } from 'react'; +import { resolveAsset } from 'tgui/assets'; + +/** + * A preview image that keeps trying until its file has arrived. The first time a hull is + * opened its PNGs (and the URL mappings) are still on their way to the client, so the + * first load can fail; retry a few times, re-resolving the asset name each time. + */ +function PreviewImg(props: { name: string; style: CSSProperties }) { + const { name, style } = props; + const [tries, setTries] = useState(0); + useEffect(() => setTries(0), [name]); + // A new key remounts the , so the browser asks for the file again. + return ( + { + if (tries < 20) { + setTimeout(() => setTries((n) => n + 1), 250); + } + }} + /> + ); +} + +import { useGrid } from './term'; +import type { ShipFit } from './YardTab'; + +type Geom = { + png: string; + width: number; + height: number; + connector?: [number, number]; +}; + +type PreviewData = { + tile_px?: number; + hulls: Record }>; + modules: Record }>; +}; + +const PAD = 10; + +export function ShipPic(props: { + /** Cells; fractions allowed. */ + x: number; + y: number; + w: number; + h: number; + fit: ShipFit; + /** Slot key to outline in amber, or null. */ + hiSlot?: string | null; +}) { + const { x, y, w, h, fit, hiSlot } = props; + const { cw, ch } = useGrid(); + const sx = Math.round(x * cw); + const sy = Math.round(y * ch); + const sw = Math.round(w * cw); + const sh = Math.round(h * ch); + const frame: CSSProperties = { + left: sx, + top: sy, + width: sw, + height: sh, + }; + + const pv = fit.hull.preview as PreviewData | null; + const themeKey = fit.finish?.id || ''; + const hull = pv?.hulls?.[themeKey] || pv?.hulls?.['']; + if (!pv || !hull || sw <= PAD * 2 || sh <= PAD * 2) { + return ( +
+
+
+ ); + } + + const tile = pv.tile_px || 32; + const aw = sw - PAD * 2; + const ah = sh - PAD * 2; + let d = Math.floor(Math.min(aw / hull.width, ah / hull.height)); + const crisp = d >= tile; + if (crisp) { + d = Math.floor(d / tile) * tile; + } + d = Math.max(1, d); + const iw = hull.width * d; + const ih = hull.height * d; + const rendering = crisp ? 'pixelated' : 'auto'; + + const layers: ReactNode[] = [ + , + ]; + for (const slot of fit.slots) { + const marker = hull.slots?.[slot.key]; + const mod = slot.pick ? pv.modules?.[slot.pick.id] : undefined; + if (!marker || !mod) { + continue; + } + const g = (themeKey && mod.themes?.[themeKey]) || mod; + const [cx, cy] = g.connector || [1, 1]; + const l = (marker[0] - cx) * d; + const t = (hull.height - (marker[1] - cy) - g.height) * d; + layers.push( + , + ); + if (slot.key === hiSlot) { + layers.push( +
, + ); + } + } + + return ( +
+
+ {layers} +
+
+
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/TraitsEditor.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/TraitsEditor.tsx new file mode 100644 index 00000000000..ea5925f65ac --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/TraitsEditor.tsx @@ -0,0 +1,592 @@ +/** + * TRAITS (CHARACTER-PLAN 2.7): GOOD/BAD/OTHER quirk list with values, the hovered or selected + * quirk's first sentence, the TAKEN box with POINTS and the positive count, char_quirk. + * Options of a taken quirk (customization_options) show under its description as ordinary + * char_set rows. Owner: C4. + * + * Box: x and w are the editor frame's outer columns, y the row under its top line, + * y + h - 1 its bottom line. Points and caps are worked out here for display only, with + * tg's own rules (QuirksPage.tsx); the server decides. + */ +import { type ReactNode, useState } from 'react'; +import { useBackend } from 'tgui/backend'; +import type { Quirk } from 'tgui/interfaces/PreferencesMenu/types'; + +import { ColorEntry } from './ColorEntry'; +import { + Cycler, + Frame, + Hit, + lpad, + Put, + pad, + RPut, + Rule, + useGrid, + wrap, +} from './term'; +import type { CellBox, LobbyData } from './types'; +import { usePrefsCatalog } from './usePrefsCatalog'; + +type Filter = 'good' | 'bad' | 'other'; +const FILTERS: [Filter, string][] = [ + ['good', 'GOOD'], + ['bad', 'BAD'], + ['other', 'OTHER'], +]; + +const HEX = /^#?[0-9a-f]{6}$/i; + +/** The first sentence of a description. */ +export function firstSentence(text: string): string { + const flat = (text || '').replace(/\s+/g, ' ').trim(); + const m = flat.match(/^(.+?[.!?])(\s|$)/); + return m ? m[1] : flat; +} + +/** A savefile key as a row label: "prosthetic_organ" -> "PROSTHETIC ORGAN". */ +function keyLabel(key: string): string { + return key.replace(/_/g, ' ').toUpperCase(); +} + +/** Every "#rrggbb" already on the file, for the colour entry's recent row. */ +export function fileColors( + prefs: Record, + extra: string[] = [], +): string[] { + const out: string[] = []; + const add = (v: unknown) => { + if (typeof v !== 'string' || !HEX.test(v)) { + return; + } + const hex = (v[0] === '#' ? v : `#${v}`).toLowerCase(); + if (!out.includes(hex)) { + out.push(hex); + } + }; + extra.forEach(add); + Object.values(prefs || {}).forEach(add); + return out.slice(0, 8); +} + +export function TraitsEditor(props: CellBox) { + const { x, y, w, h } = props; + const { act, data } = useBackend(); + const { narrow } = useGrid(); + const catalog = usePrefsCatalog(); + const char = data.char; + const [filter, setFilter] = useState('good'); + const [page, setPage] = useState(1); + const [sel, setSel] = useState(null); + const [hover, setHover] = useState(null); + + if (!char || !catalog) { + return null; + } + const { + quirk_info: info, + max_positive_quirks: maxGood, + quirk_blacklist: blacklist, + points_enabled: pointsOn, + } = catalog.quirks; + + const taken = (char.quirks || []).filter((k) => !!info[k]); + let balance = 0; + let good = 0; + for (const k of taken) { + balance += info[k].value; + if (info[k].value > 0) { + good++; + } + } + const points = -balance; + const takenNames = taken.map((k) => info[k].name); + const blocked = char.quirks_blocked || []; + + const canAdd = (k: string) => { + const q = info[k]; + if (blocked.includes(k) || blocked.includes(q.name)) { + return false; + } + if (q.value > 0) { + if (maxGood !== -1 && good >= maxGood) { + return false; + } + if (pointsOn && balance + q.value > 0) { + return false; + } + } + for (const group of blacklist || []) { + if (!group.includes(q.name)) { + continue; + } + if (group.some((n) => n !== q.name && takenNames.includes(n))) { + return false; + } + } + return true; + }; + const canRemove = (k: string) => !(pointsOn && balance - info[k].value > 0); + + const toggle = (k: string) => { + const on = !taken.includes(k); + if (on ? !canAdd(k) : !canRemove(k)) { + return; + } + act('char_quirk', { quirk: k, on: on ? 1 : 0 }); + }; + + const split = !narrow && w >= 80; + const takenW = split ? Math.floor(w * 0.42) : w; + const ax = x; + const aw = split ? w - takenW : w; + + // ---- TAKEN ---- + const tx = split ? x + aw : x; + const TW = split ? takenW - 2 : w; + const takenRows: ReactNode[] = []; + let ty = y + (split ? 2 : 1); + takenRows.push( + , + ); + ty += 2; + const takenMax = split + ? y + h - 4 - ty + : Math.max(2, Math.min(taken.length, Math.floor((h - 12) / 2))); + const shownTaken = taken.slice(0, Math.max(0, takenMax)); + for (const k of shownTaken) { + const q = info[k]; + const can = canRemove(k); + takenRows.push( + setSel(k)} + onRemove={() => toggle(k)} + />, + ); + ty++; + } + if (taken.length > shownTaken.length) { + takenRows.push( + + {`+${taken.length - shownTaken.length}`} + , + ); + ty++; + } + + // ---- list ---- + let yy = split ? y + 1 : ty + 1; + const ruleY = yy - 0.5; + if (!split) { + yy += 1; + } + const filterY = yy; + yy += 2; + + const options = + sel && info[sel] && taken.includes(sel) && (!hover || hover === sel) + ? (info[sel].customization_options || []).filter( + (key) => key in (char.prefs || {}), + ) + : []; + const descH = 3 + options.length; + + const list = Object.keys(info).filter((k) => { + const v = info[k].value; + return filter === 'good' ? v > 0 : filter === 'bad' ? v < 0 : v === 0; + }); + list.sort((a, b) => { + const va = Math.abs(info[a].value); + const vb = Math.abs(info[b].value); + if (va !== vb) { + return vb - va; + } + return info[a].name < info[b].name ? -1 : 1; + }); + const room = Math.max(3, Math.floor(y + h - 2 - descH - yy)); + const pages = Math.max(1, Math.ceil(list.length / room)); + const pg = Math.min(pages, page); + const rows = list.slice((pg - 1) * room, pg * room); + // The selected row, else the first one on the page, so the description is never blank. + const current = sel && info[sel] ? sel : rows[0] || null; + // The quirk whose words show at the bottom: the hovered row, else the current one. + const shown = hover && info[hover] ? hover : current; + const descLines = shown + ? wrap(firstSentence(info[shown].description), aw - 6).slice( + 0, + options.length ? 1 : 2, + ) + : []; + const py = y + h - 2 - descH; + const descY = y + h - 1 - descH; + const recent = fileColors(char.prefs); + + return ( + <> + {!!split && } + {takenRows} + {!split && } + { + setFilter(f); + setPage(1); + setHover(null); + }} + /> + {rows.map((k, i) => { + const isTaken = taken.includes(k); + return ( + { + setSel(k); + toggle(k); + }} + onHover={(over) => setHover((h) => (over ? k : h === k ? null : h))} + /> + ); + })} + {pages > 1 && ( + { + setPage(p); + setHover(null); + }} + /> + )} + {descLines.map((line, i) => ( + + {line} + + ))} + {options.map((key, i) => ( + act('char_set', { key, value })} + /> + ))} + + ); +} + +function TakenHead(props: { + x: number; + y: number; + w: number; + points: number | null; + good: number; + maxGood: number; +}) { + const { x, y, w, points, good, maxGood } = props; + const capped = maxGood !== -1 && good >= maxGood; + return ( + <> + {points !== null && ( + <> + + {'POINTS'} + + 0 ? 'amb' : ''} + > + {lpad(points, 3)} + + + )} + {maxGood !== 0 && ( + + {maxGood === -1 ? `GOOD ${good}` : `GOOD ${good}/${maxGood}`} + + )} + + ); +} + +function TakenRow(props: { + x: number; + y: number; + w: number; + quirk: Quirk; + sel: boolean; + canRemove: boolean; + onSelect: () => void; + onRemove: () => void; +}) { + const { x, y, w, quirk, sel, canRemove, onSelect, onRemove } = props; + const v = quirk.value; + return ( + <> + + {pad(quirk.name, w - 13)} + + 0 ? 'amb' : v < 0 ? 'dim' : 'faint'}> + {lpad(v, 3)} + + {canRemove ? ( + + {'[X]'} + + ) : ( + + {'[X]'} + + )} + + ); +} + +function FilterKeys(props: { + x: number; + y: number; + filter: Filter; + onFilter: (f: Filter) => void; +}) { + const { x, y, filter, onFilter } = props; + let fx = x; + return ( + <> + {FILTERS.map(([key, label]) => { + const at = fx; + fx += label.length + 4; + return ( + onFilter(key)} + > + {` ${label} `} + + ); + })} + + ); +} + +function QuirkRow(props: { + x: number; + y: number; + w: number; + quirk: Quirk; + taken: boolean; + /** Can't be taken now: dim, and a click only selects it. */ + cant: boolean; + /** Taken but can't be dropped now. */ + locked: boolean; + sel: boolean; + onClick: () => void; + onHover: (over: boolean) => void; +}) { + const { x, y, w, quirk, taken, cant, sel, onClick, onHover } = props; + const v = quirk.value; + const nameCls = sel ? 'inv' : taken ? 'amb' : cant ? 'faint' : ''; + const valCls = sel ? 'inv' : cant ? 'faint' : v > 0 ? 'amb' : 'dim'; + return ( + <> + {!!sel && ( + + {' '.repeat(Math.max(0, w - 2))} + + )} + + {taken ? '*' : ' '} + + + {pad(quirk.name, w - 10)} + + + {String(v)} + + + + ); +} + +function Pager(props: { + x: number; + y: number; + page: number; + pages: number; + onPage: (page: number) => void; +}) { + const { x, y, page, pages, onPage } = props; + return ( + <> + {page > 1 ? ( + onPage(page - 1)}> + {' < '} + + ) : ( + + {' < '} + + )} + + {`${page}/${pages}`} + + {page < pages ? ( + onPage(page + 1)}> + {' > '} + + ) : ( + + {' > '} + + )} + + ); +} + +type OptionServerData = { + choices?: string[]; + display_names?: Record; + minimum?: number; + maximum?: number; + step?: number; +} | null; + +/** One quirk option row: a choice cycler, a number stepper, a yes/no, or a colour. */ +function QuirkOption(props: { + x: number; + y: number; + w: number; + prefKey: string; + value: any; + serverData: OptionServerData; + recent: string[]; + onSet: (value: unknown) => void; +}) { + const { x, y, w, prefKey, value, serverData, recent, onSet } = props; + const label = keyLabel(prefKey); + const lw = 18; + const cw = Math.max(8, Math.min(24, w - lw - 4)); + const labelRow = ( + + {pad(label, lw - 1)} + + ); + if (serverData?.choices?.length) { + const choices = serverData.choices; + const i = Math.max(0, choices.indexOf(value)); + const name = (v: string) => serverData.display_names?.[v] || v; + const step = (d: number) => + onSet(choices[(i + d + choices.length) % choices.length]); + return ( + <> + {labelRow} + step(-1)} + onNext={() => step(1)} + /> + + ); + } + if (typeof serverData?.minimum === 'number') { + const lo = serverData.minimum; + const hi = serverData.maximum ?? lo; + const st = serverData.step || 1; + const n = Number(value) || 0; + return ( + <> + {labelRow} + onSet(Math.max(lo, n - st))} + onNext={() => onSet(Math.min(hi, n + st))} + /> + + ); + } + if (typeof value === 'string' && HEX.test(value)) { + const hex = value[0] === '#' ? value : `#${value}`; + return ( + onSet(c)} + /> + ); + } + if (typeof value === 'number') { + const flip = () => onSet(value ? 0 : 1); + return ( + <> + {labelRow} + + + ); + } + return null; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx new file mode 100644 index 00000000000..0b099dc9bd5 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx @@ -0,0 +1,673 @@ +/** + * SHIPYARD tab: home (new ship, requisition, saved ships), then HULLS -> hull record -> + * FIT -> BILL. Where the player is and what they picked stays in React (a module-level + * store, so it survives a tab switch); only yard_* actions reach DM. + * + * The catalog arrives in static data only after `yard_open`. Keys: see types.ts. + */ +import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { Bill } from './Bill'; +import { Fit } from './Fit'; +import { Hulls } from './Hulls'; +import { useBack } from './nav'; +import { SaveSlots } from './SaveSlots'; +import { Frame, type NavProps, Put, RPut, TOP, useGrid } from './term'; +import type { + CrewLine, + Finish, + Hull, + LobbyData, + Module, + Owned, + PartClass, + Parts, +} from './types'; + +// --------------------------------------------------------------------------- +// Catalog helpers (shared by Hulls, HullRecord, Fit and Bill). +// --------------------------------------------------------------------------- + +export const PART_ORDER: PartClass[] = ['combat', 'science', 'trade', 'misc']; + +/** "4 COMBAT 2 TRADE", or '' when nothing is due. */ +export function costOf(cost?: Parts | null): string { + return PART_ORDER.filter((k) => (cost?.[k] || 0) > 0) + .map((k) => `${cost?.[k]} ${k.toUpperCase()}`) + .join(' '); +} + +export function partsOf(cost?: Parts | null): number { + return PART_ORDER.reduce((a, k) => a + (cost?.[k] || 0), 0); +} + +/** 1,850 */ +export function thousands(n: number): string { + return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +export type Ownership = { + owned: Owned | null | undefined; + /** FREE_SHIPS config: everything is free. */ + free: boolean; +}; + +export function hullOwned(o: Ownership, hull: Hull): boolean { + return ( + o.free || + !!hull.free || + !partsOf(hull.cost) || + !!o.owned?.hulls?.includes(hull.id) + ); +} + +export function finishOwned(o: Ownership, hull: Hull, f: Finish): boolean { + return ( + o.free || + !!f.def || + !partsOf(f.cost) || + !!o.owned?.finishes?.[hull.id]?.includes(f.id) + ); +} + +export function moduleOwned(o: Ownership, hull: Hull, m: Module): boolean { + return ( + o.free || + !!m.def || + !partsOf(m.cost) || + !!o.owned?.modules?.[hull.id]?.includes(m.id) + ); +} + +/** Upper-case label for a slot key: from the catalog, else the key without its hull prefix. */ +export function slotLabel(hull: Hull, key: string): string { + const known = hull.slots.find((s) => s.key === key); + let s = (known?.label || key).toLowerCase(); + const pre = `${key.split('_')[0]}_`; + if (!known && s.startsWith(pre)) { + s = s.slice(pre.length); + } + return s.replace(/_s_/g, "'s ").replace(/_/g, ' ').toUpperCase(); +} + +/** The default finish, or the first, or null for a hull without finishes. */ +export function defaultFinish(hull: Hull): Finish | null { + return hull.finishes.find((f) => !!f.def) || hull.finishes[0] || null; +} + +/** Modules for a slot under a finish, the stock one first. */ +export function slotOptions( + hull: Hull, + key: string, + finish: Finish | null, +): Module[] { + const opts = hull.modules.filter( + (m) => + m.slot === key && (!m.themes || !finish || m.themes.includes(finish.id)), + ); + return [...opts.filter((m) => !!m.def), ...opts.filter((m) => !m.def)]; +} + +export type FitSlot = { + key: string; + label: string; + options: Module[]; + pick: Module | null; +}; + +export type ShipFit = { + hull: Hull; + finish: Finish | null; + slots: FitSlot[]; +}; + +/** + * The configuration as it would be built. A pick the finish doesn't offer falls back to + * that finish's stock module, as the server resets picks on a finish change. + */ +export function fitOf( + hull: Hull, + finishId: string | null, + picks: Record, +): ShipFit { + const finish = + hull.finishes.find((f) => f.id === finishId) || defaultFinish(hull); + const keys: string[] = finish ? finish.slots : hull.slots.map((s) => s.key); + const slots = keys.map((key) => { + const options = slotOptions(hull, key, finish); + const pick = options.find((m) => m.id === picks[key]) || options[0] || null; + return { key, label: slotLabel(hull, key), options, pick }; + }); + return { hull, finish, slots }; +} + +/** The stock fit of a hull. */ +export function stockFit(hull: Hull): ShipFit { + return fitOf(hull, null, {}); +} + +/** Crew manifest of a fit, same titles summed. */ +export function crewOf(fit: ShipFit): CrewLine[] { + const out: CrewLine[] = []; + const add = (lines?: CrewLine[]) => { + for (const [title, n] of lines || []) { + const have = out.find((l) => l[0] === title); + if (have) { + have[1] += n; + } else { + out.push([title, n]); + } + } + }; + add(fit.finish ? fit.finish.jobs : fit.hull.jobs); + for (const s of fit.slots) { + if (s.pick) { + const jobs = s.pick.jobs || {}; + add((fit.finish && jobs[fit.finish.id]) || jobs[''] || []); + } + } + return out; +} + +export function crewCount(fit: ShipFit): number { + return crewOf(fit).reduce((a, l) => a + l[1], 0); +} + +/** [label, name, cost or null when owned] */ +export type BillLine = [string, string, Parts | null]; + +export type ShipBill = { + lines: BillLine[]; + total: Parts; + short: Parts; +}; + +export function billOf( + o: Ownership, + fit: ShipFit, + account: Parts | null | undefined, +): ShipBill { + const { hull, finish } = fit; + const lines: BillLine[] = [ + ['HULL', hull.short, hullOwned(o, hull) ? null : hull.cost], + ]; + if (finish) { + lines.push([ + 'FINISH', + finish.name, + finishOwned(o, hull, finish) ? null : finish.cost, + ]); + } + for (const s of fit.slots) { + if (s.pick) { + lines.push([ + s.label, + s.pick.name, + moduleOwned(o, hull, s.pick) ? null : s.pick.cost, + ]); + } + } + const total: Parts = {}; + for (const l of lines) { + for (const k of PART_ORDER) { + const n = l[2]?.[k] || 0; + if (n) { + total[k] = (total[k] || 0) + n; + } + } + } + const short: Parts = {}; + for (const k of PART_ORDER) { + const due = total[k] || 0; + const have = account?.[k] || 0; + if (due > have) { + short[k] = due - have; + } + } + return { lines, total, short }; +} + +// --------------------------------------------------------------------------- +// UI-local state. +// --------------------------------------------------------------------------- + +export type YardScreen = 'home' | 'hulls' | 'fit' | 'bill'; + +export type YardView = { + screen: YardScreen; + /** Selected hull id (HULLS record, FIT, BILL). */ + hull: string | null; + finish: string | null; + /** slot key -> module id */ + picks: Record; + step: number; + /** The key that has been pressed once and now reads CONFIRM. */ + confirm: string | null; +}; + +const HOME: YardView = { + screen: 'home', + hull: null, + finish: null, + picks: {}, + step: 0, + confirm: null, +}; + +/** Survives the tab unmounting (tab switch, the CONFIRMED box). */ +const store: { view: YardView; fitSerial: number | null } = { + view: HOME, + fitSerial: null, +}; +/** Hulls whose preview pictures have been asked for this session. */ +const previewed = new Set(); + +export type SetView = (patch: Partial) => void; + +function useYardView(): [YardView, SetView] { + const [view, setView] = useState(store.view); + const set: SetView = (patch) => + setView((prev) => { + const next = { ...prev, confirm: null, ...patch }; + store.view = next; + return next; + }); + return [view, set]; +} + +/** Ask once per hull for its preview pictures. */ +export function usePreview(hullId: string | null) { + const { act } = useBackend(); + useEffect(() => { + if (hullId && !previewed.has(hullId)) { + previewed.add(hullId); + act('yard_preview', { hull: hullId }); + } + }, [hullId]); +} + +/** Everything the screens need, worked out once per render. */ +export type YardCtx = { + view: YardView; + set: SetView; + catalog: Hull[]; + hull: Hull | null; + own: Ownership; + account: Parts | null | undefined; + /** Commissioning, requisition and loading are open (round running). */ + open: boolean; + crewChange: string | null; +}; + +// --------------------------------------------------------------------------- +// Shared bits of drawing. +// --------------------------------------------------------------------------- + +/** A `[ LABEL ]` key in any colour class; faint and dead when disabled. */ +export function Key( + props: NavProps & { + x: number; + y: number; + label: string; + cls?: string; + right?: boolean; + disabled?: boolean; + bg?: boolean; + onClick?: () => void; + }, +) { + const { + x, + y, + label, + cls = '', + right, + disabled, + bg, + onClick, + auto, + edge, + } = props; + const text = `[ ${label} ]`; + const at = right ? x - text.length : x; + if (disabled) { + return ( + + {text} + + ); + } + const amber = cls === 'amb' || cls === 'inva'; + return ( + + {text} + + ); +} + +/** Breadcrumb and [ BACK ] on the tab row, right-aligned. */ +function Crumbs(props: { ctx: YardCtx; screen: YardScreen }) { + const { ctx, screen } = props; + const { view, set, hull } = ctx; + const { cols } = useGrid(); + const seg: [string, (() => void) | null][] = [ + ['SHIPYARD', () => set({ screen: 'home', hull: null })], + [ + 'HULLS', + () => + set({ + screen: 'hulls', + hull: screen === 'hulls' ? null : view.hull, + }), + ], + ]; + if (hull && screen !== 'hulls') { + seg.push([hull.short.toUpperCase(), () => set({ screen: 'hulls' })]); + } + // A hull with no finishes and no rooms skips FIT entirely. + const fittable = !hull || !!hull.finishes.length || !!hull.slots.length; + if ((screen === 'fit' || screen === 'bill') && fittable) { + seg.push(['FIT', () => set({ screen: 'fit' })]); + } + if (screen === 'bill') { + seg.push(['BILL', null]); + } + // The tab names end near column 41; drop leading crumbs until the rest fits. + const room = cols - 1 - 42 - 12; + const width = () => seg.map((s) => s[0]).join(' / ').length; + while (seg.length > 1 && width() > room) { + seg.shift(); + } + const showCrumbs = width() <= room; + const back: Record> = { + home: {}, + hulls: view.hull ? { hull: null } : { screen: 'home', hull: null }, + fit: { screen: 'hulls' }, + bill: { screen: fittable ? 'fit' : 'hulls' }, + }; + useBack(() => set(back[screen])); + const out: ReactNode[] = []; + let x = cols - 1 - (showCrumbs ? width() : 0); + out.push( + set(back[screen])} + />, + ); + if (showCrumbs) { + seg.forEach(([label, go], i) => { + const last = i === seg.length - 1; + out.push( + + {label} + , + ); + x += label.length; + if (!last) { + out.push( + + {' / '} + , + ); + x += 3; + } + }); + } + return <>{out}; +} + +// --------------------------------------------------------------------------- +// Home: NEW SHIP, REQUISITION, SAVED SHIPS. +// --------------------------------------------------------------------------- + +function Home(props: { ctx: YardCtx }) { + const { ctx } = props; + const { data, act } = useBackend(); + const { cols, rows, narrow } = useGrid(); + const bottom = rows - 1; + const req = !!data.can_requisition; + const account = data.account || {}; + const confirmReq = ctx.view.confirm === 'req'; + const reqLine = ctx.crewChange + ? `LEAVES ${ctx.crewChange.toUpperCase()}` + : 'SURPLUS HULL'; + + const requisition = () => { + if (ctx.crewChange && !confirmReq) { + ctx.set({ confirm: 'req' }); + return; + } + ctx.set({ confirm: null }); + act('yard_requisition', { leave_crew: ctx.crewChange ? 1 : 0 }); + }; + const newShip = () => ctx.set({ screen: 'hulls', hull: null }); + + const out: ReactNode[] = []; + let th = 5; + const y = TOP + 2; + if (narrow) { + // One box: NEW SHIP and the parts on account, the surplus hull below. + th = req ? 6 : 5; + out.push( + , + , + ); + const short = PART_ORDER.map( + (k) => `${k.slice(0, 3).toUpperCase()} ${account[k] || 0}`, + ).join(' '); + if (cols - 20 >= short.length) { + out.push( + + {short} + , + ); + } + if (req) { + out.push( + + {reqLine.slice(0, cols - 24)} + , + , + ); + } + } else { + const lw = req ? Math.floor(cols * 0.62) : cols; + out.push( + , + , + ); + const segs = PART_ORDER.map((k) => [ + k.toUpperCase(), + String(account[k] || 0), + ]); + const partsW = + 7 + + segs.reduce((a, [k, n]) => a + k.length + 1 + n.length, 0) + + 3 * (segs.length - 1); + let x = lw - 3 - partsW; + out.push( + + PARTS + , + ); + x += 7; + for (const [k, n] of segs) { + out.push( + + {k} + , + + {n} + , + ); + x += k.length + 1 + n.length + 3; + } + if (req) { + const rx = lw + 1; + const rw = cols - lw - 1; + out.push( + , + + {reqLine.slice(0, Math.max(0, rw - 23))} + , + , + ); + } + } + + if (data.saves) { + out.push( + , + ); + } + return <>{out}; +} + +// --------------------------------------------------------------------------- + +function Waiting() { + const { cols, rows } = useGrid(); + return ( + + + CONTACTING THE YARD... + + + ); +} + +export function YardTab(props: { home: number }) { + const { data, act } = useBackend(); + const [view, set] = useYardView(); + useEffect(() => { + act('yard_open'); + }, []); + // SHIPYARD pressed again while on it: back to the yard's front page. + const [home, setHome] = useState(props.home); + if (home !== props.home) { + setHome(props.home); + if (view.screen !== 'home') { + set(HOME); + } + } + // A commission put the player aboard: the fit was built, so forget it. A refusal keeps it. + const fitSerial = data.fit_serial; + useEffect(() => { + if (fitSerial === undefined) { + return; + } + if (store.fitSerial !== null && store.fitSerial !== fitSerial) { + set(HOME); + } + store.fitSerial = fitSerial; + }, [fitSerial]); + + const catalog = useMemo(() => data.catalog || [], [data.catalog]); + const hull = useMemo( + () => catalog.find((h) => h.id === view.hull) || null, + [catalog, view.hull], + ); + const ctx: YardCtx = { + view, + set, + catalog, + hull, + own: { owned: data.owned, free: !!data.free_ships }, + account: data.account, + open: data.phase === 'playing', + crewChange: data.crew_change || null, + }; + + let screen = view.screen; + if ((screen === 'fit' || screen === 'bill') && !hull && !!data.catalog) { + screen = 'hulls'; + } + let body: ReactNode; + if (screen === 'home') { + body = ; + } else if (!data.catalog) { + body = ; + } else if (screen === 'hulls') { + body = ; + } else if (screen === 'fit' && hull) { + body = ; + } else if (hull) { + body = ( + act('yard_commission', params)} + /> + ); + } + + return ( + <> + {screen !== 'home' && } + {body} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx new file mode 100644 index 00000000000..946b49f5506 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx @@ -0,0 +1,254 @@ +/** + * Dock terminal lobby. Rendered into the `lobby_browser` control over the map pane, not + * a popup window. + * + * Never wrap this in : Window winsets size and position on Byond.windowId, which + * here is the skin control, and would wreck the map pane. The root is a bare full-viewport + * div that follows the control's size. + * + * UI-local state (tab, selections, pages, fits) lives in React; only actions round-trip. + */ +import { useEffect, useMemo, useState } from 'react'; +import { resolveAsset } from 'tgui/assets'; +import { useBackend } from 'tgui/backend'; + +import { BEZEL_EXTRA_CSS, Bezel } from './Bezel'; +import { CharacterTab } from './CharacterTab'; +import { CrewsTab } from './CrewsTab'; +import { Header, TABS } from './Header'; +import { HousingTab } from './HousingTab'; +import { computeLayout, START_KEY_W } from './layout'; +import { Confirmed, NoticeLine } from './Notice'; +import { NAV_CSS, NavProvider } from './nav'; +import { type Grid, GridContext, TERM_CSS } from './term'; +import type { LobbyData, TabId } from './types'; +import { YardTab } from './YardTab'; + +const FONT = 'VCR'; +/** Advance of one character at 1px, for when the font hasn't loaded yet. */ +const FALLBACK_ADVANCE = 0.6; + +function usePaneSize() { + const read = () => ({ + w: document.documentElement.clientWidth || window.innerWidth, + h: document.documentElement.clientHeight || window.innerHeight, + dpr: window.devicePixelRatio || 1, + }); + const [size, setSize] = useState(read); + useEffect(() => { + const update = () => { + const next = read(); + setSize((prev) => + prev.w === next.w && prev.h === next.h && prev.dpr === next.dpr + ? prev + : next, + ); + }; + window.addEventListener('resize', update); + let observer: ResizeObserver | undefined; + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(update); + observer.observe(document.documentElement); + } + return () => { + window.removeEventListener('resize', update); + observer?.disconnect(); + }; + }, []); + return size; +} + +function measureAdvance(family: string): number { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return FALLBACK_ADVANCE; + } + ctx.font = `100px ${family}`; + const w = ctx.measureText('M'.repeat(50)).width / 50 / 100; + return w > 0.3 && w < 1 ? w : FALLBACK_ADVANCE; +} + +/** Loads VCR OSD Mono from the lobby asset and returns the measured advance. */ +function useTerminalFont() { + const [advance, setAdvance] = useState(FALLBACK_ADVANCE); + useEffect(() => { + let cancelled = false; + let tries = 0; + const load = () => { + const url = resolveAsset('VCR_OSD_Mono.ttf'); + const face = new FontFace(FONT, `url(${url})`); + face + .load() + .then((loaded) => { + if (cancelled) { + return; + } + document.fonts.add(loaded); + setAdvance(measureAdvance(`'${FONT}'`)); + }) + .catch(() => { + // The asset mapping can arrive a moment after the first render. + if (!cancelled && tries++ < 5) { + setTimeout(load, 1000); + } + }); + }; + load(); + return () => { + cancelled = true; + }; + }, []); + return advance; +} + +export function LobbyTerminal() { + const { data, act } = useBackend(); + const pane = usePaneSize(); + const advance = useTerminalFont(); + const startKey = + !!data.localhost_admin && + (data.phase === 'loading' || data.phase === 'pregame'); + const layout = useMemo( + () => + computeLayout( + pane.w, + pane.h, + pane.dpr, + advance, + startKey ? START_KEY_W : 0, + ), + [pane.w, pane.h, pane.dpr, advance, startKey], + ); + const grid: Grid = useMemo( + () => ({ + cols: layout.cols, + rows: layout.rows, + cw: layout.cw, + ch: layout.ch, + s: layout.s, + narrow: layout.narrow, + }), + [layout], + ); + + const [tab, setTab] = useState('crews'); + /** Bumped when SHIPYARD is pressed while already on it. */ + const [yardHome, setYardHome] = useState(0); + /** Bumped when CHARACTER (tab or bezel key) is pressed while already on it: back to the file view. */ + const [charHome, setCharHome] = useState(0); + const request = data.tab_request; + useEffect(() => { + if (request) { + setTab(request.tab); + } + }, [request?.serial]); + + // Report display metrics once the font settles (Windows scaling, pane size), for tuning. + useEffect(() => { + const id = setTimeout( + () => + act('metrics', { + dpr: layout.dpr, + w: pane.w, + h: pane.h, + s: layout.s, + cols: layout.cols, + rows: layout.rows, + }), + 3000, + ); + return () => clearTimeout(id); + }, [advance]); + + const notice = data.notice; + const confirmed = !!notice && notice.kind === 'ok'; + + const switchTab = (next: TabId) => { + if (next === tab && next === 'shipyard') { + setYardHome(yardHome + 1); + } + if (next === tab && next === 'character') { + setCharHome(charHome + 1); + } + setTab(next); + if (confirmed) { + act('dismiss'); + } + }; + const { s } = layout; + const scan = Math.max(2, Math.round(s)); + + let body; + if (confirmed) { + body = ; + } else if (tab === 'crews') { + body = ; + } else if (tab === 'shipyard') { + body = ; + } else if (tab === 'character') { + body = ; + } else { + body = ; + } + + return ( +
+ + { + const next = TABS[n - 1]; + if (next) { + switchTab(next[0]); + } + }} + onTabStep={(step) => { + const at = TABS.findIndex(([id]) => id === tab); + const next = TABS[(at + step + TABS.length) % TABS.length]; + switchTab(next[0]); + }} + > + switchTab('character')} + > +
+
+ +
+ {body} + + +
+
+
+
+ + +
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts new file mode 100644 index 00000000000..7354a3f5b2a --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts @@ -0,0 +1,95 @@ +/** + * Terminal geometry: bezel scale, the screen rect and the text grid, from the pane size. + * + * Bezel: integer pixel-art scale s = clamp(min(floor(H / 360), floor(W / 472)), 1, 4), + * snapped to whole device pixels so Windows display scaling doesn't blur the art. + * Grid: font size aims at about 110 columns, independent of s. + */ + +/** 1x pixel numbers of the bezel (bezel.json). */ +export const BEZEL = { + baseHeight: 360, + minWidth: 472, + screenTop: 15, + screenSide: 13, + screenBottom: 52, + deck: 49, +} as const; + +/** 1x width the localhost START key adds to the deck (52 px key plus its 4 px gap). */ +export const START_KEY_W = 56; + +/** Below this many columns or rows, tabs use their narrow (one panel at a time) layout. */ +export const NARROW_COLS = 96; +export const NARROW_ROWS = 22; + +export type Layout = { + /** CSS pixels per art pixel (a whole number of device pixels). */ + s: number; + dpr: number; + /** Screen rect in CSS px, relative to the pane. */ + screen: { x: number; y: number; w: number; h: number }; + fs: number; + /** Cell size in CSS px. */ + cw: number; + ch: number; + cols: number; + rows: number; + /** Grid origin inside the screen. */ + ox: number; + oy: number; + narrow: boolean; +}; + +const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + +/** + * @param advance width of one character at 1px font size (measured from the real font). + */ +export function computeLayout( + paneW: number, + paneH: number, + dpr: number, + advance: number, + /** Extra 1x deck width (the START key), so the deck still fits the pane. */ + extraW = 0, +): Layout { + const ratio = dpr > 0 ? dpr : 1; + const sCss = clamp( + Math.min( + Math.floor(paneH / BEZEL.baseHeight), + Math.floor(paneW / (BEZEL.minWidth + extraW)), + ), + 1, + 4, + ); + const sDevice = Math.max(1, Math.round(sCss * ratio)); + const s = sDevice / ratio; + + const sx = BEZEL.screenSide * s; + const sy = BEZEL.screenTop * s; + const sw = Math.max(0, paneW - 2 * sx); + const sh = Math.max(0, paneH - sy - BEZEL.screenBottom * s); + + const fs = clamp(Math.floor(sw / (0.6 * 110)), 11, 24); + const cw = fs * advance; + const ch = Math.round(fs * 1.2); + const cols = Math.max(20, Math.floor((sw - 2 * cw) / cw)); + const rows = Math.max(8, Math.floor((sh - ch) / ch)); + const ox = Math.floor((sw - cols * cw) / 2); + const oy = Math.floor((sh - rows * ch) / 2); + return { + s, + dpr: ratio, + screen: { x: sx, y: sy, w: sw, h: sh }, + fs, + cw, + ch, + cols, + rows, + ox, + oy, + narrow: cols < NARROW_COLS || rows < NARROW_ROWS, + }; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/nav.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/nav.tsx new file mode 100644 index 00000000000..26bd0cc95bd --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/nav.tsx @@ -0,0 +1,556 @@ +/** + * Keyboard and mouse focus for the dock terminal. + * + * Everything clickable registers here (Put with onClick, Btn, Hit, Field, the bezel keys). + * One item is focused at a time and draws lit. The mouse and the keyboard share it: hovering + * an item focuses it, the arrow keys move it to the nearest item in that direction, Enter or + * Space presses it, Escape or Backspace goes back. + * + * Keys reach the page because the terminal's skin control takes keyboard focus when it shows + * (lobby_terminal show code). tgui-core keeps arrows, Enter, Space, Escape and Tab in the page; + * a key handled here is preventDefault()ed so tgui-core never passes it on to BYOND either. + * + * Sounds play in the page from the terminal's assets, so a key press never costs a round trip. + */ +import { + createContext, + type ReactNode, + type RefObject, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { resolveAsset } from 'tgui/assets'; + +export type Dir = 'up' | 'down' | 'left' | 'right'; + +export type FocusOpts = { + /** What Enter, Space or a click does. */ + activate: () => void; + /** + * Take the focus when this mounts (the screen's first sensible target). Higher wins when + * several mount at once; ties go to the first. + */ + auto?: number; + /** Arrow keys that leave this item some other way, e.g. paging a list. TRUE = handled. */ + edge?: Partial boolean>>; + /** A tab: keeps the focus while the screen under it changes, and switches on arrow focus. */ + tab?: boolean; + /** Lit by the mouse only; the arrow keys pass it by (a cycler's < and >, which Left/Right drive). */ + mouseOnly?: boolean; + /** A text field: Enter starts typing in it. */ + input?: RefObject; +}; + +type Item = { + el: () => HTMLElement | null; + /** The FocusScope it sits in; only the newest open scope's items take the focus. */ + scope: string; + opts: RefObject; +}; + +/** auto: a stand-in the game picked; claim: a screen's first target. */ +type How = 'key' | 'mouse' | 'auto' | 'claim'; + +type Nav = { + pushScope: (id: string) => void; + popScope: (id: string) => void; + focused: string | null; + focus: (id: string, how: How) => void; + register: (id: string, item: Item) => void; + unregister: (id: string) => void; + pushBack: (id: string, fn: RefObject<() => void>) => void; + popBack: (id: string) => void; +}; + +const NavContext = createContext