diff --git a/SQL/database_changelog.md b/SQL/database_changelog.md index df9e3b5b276..5a5eb4ece91 100644 --- a/SQL/database_changelog.md +++ b/SQL/database_changelog.md @@ -2,22 +2,45 @@ Any time you make a change to the schema files, remember to increment the databa Make sure to also update `DB_MAJOR_VERSION` and `DB_MINOR_VERSION`, which can be found in `code/__DEFINES/subsystem.dm`. -The latest database version is 5.34; The query to update the schema revision table is: +The latest database version is 5.35; The query to update the schema revision table is: ```sql -INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 34); +INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 35); ``` or ```sql -INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 34); +INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 35); ``` In any query remember to add a prefix to the table names if you use one. --- +Version 5.35, 5 November 2026, by Jackriip +Adds the `player_ship_saves` table for Voidcrew ship saving: one row per used save slot, with the ship's +map stored through `COMPRESS()` and what the save holds and cost as JSON in `summary`. + +```sql +CREATE TABLE `player_ship_saves` ( + `ckey` VARCHAR(32) NOT NULL, + `slot` TINYINT(3) UNSIGNED NOT NULL, + `ship_name` VARCHAR(128) NOT NULL, + `ship_class` VARCHAR(128) NULL, + `tile_count` SMALLINT(5) UNSIGNED NOT NULL, + `format_version` SMALLINT(5) UNSIGNED NOT NULL, + `saved_round` INT(11) NOT NULL, + `saved_at` DATETIME NOT NULL, + `raw_bytes` INT(10) UNSIGNED NOT NULL, + `summary` TEXT NULL, + `payload` MEDIUMBLOB NOT NULL, + PRIMARY KEY (`ckey`, `slot`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +``` + +--- + Version 5.34, 28 September 2026, by Jackriip Adds the `round_metric` table for Voidcrew round metrics: economy, trade, cargo, missions, research, NPC ship encounters, combat tallies and deaths, one row per event. diff --git a/SQL/migrations/voidcrew_ship_saves.sql b/SQL/migrations/voidcrew_ship_saves.sql new file mode 100644 index 00000000000..bb9ac1fbe99 --- /dev/null +++ b/SQL/migrations/voidcrew_ship_saves.sql @@ -0,0 +1,19 @@ +-- Ship saving (voidcrew/modules/ship_saves): one row per used save slot. Schema version 5.35. +-- A save is removed when it is used, so this table needs no purge job. + +CREATE TABLE IF NOT EXISTS `player_ship_saves` ( + `ckey` VARCHAR(32) NOT NULL, + `slot` TINYINT(3) UNSIGNED NOT NULL, + `ship_name` VARCHAR(128) NOT NULL, + `ship_class` VARCHAR(128) NULL, + `tile_count` SMALLINT(5) UNSIGNED NOT NULL, + `format_version` SMALLINT(5) UNSIGNED NOT NULL, + `saved_round` INT(11) NOT NULL, + `saved_at` DATETIME NOT NULL, + `raw_bytes` INT(10) UNSIGNED NOT NULL, + `summary` TEXT NULL, + `payload` MEDIUMBLOB NOT NULL, + PRIMARY KEY (`ckey`, `slot`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 35); diff --git a/SQL/tgstation_schema.sql b/SQL/tgstation_schema.sql index 0a0b1b99a94..bed198bd46c 100644 --- a/SQL/tgstation_schema.sql +++ b/SQL/tgstation_schema.sql @@ -561,6 +561,25 @@ CREATE TABLE `round_metric` ( KEY `idx_event_datetime` (`event`, `datetime`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +-- +-- Table structure for table `player_ship_saves` +-- +DROP TABLE IF EXISTS `player_ship_saves`; +CREATE TABLE `player_ship_saves` ( + `ckey` VARCHAR(32) NOT NULL, + `slot` TINYINT(3) UNSIGNED NOT NULL, + `ship_name` VARCHAR(128) NOT NULL, + `ship_class` VARCHAR(128) NULL, + `tile_count` SMALLINT(5) UNSIGNED NOT NULL, + `format_version` SMALLINT(5) UNSIGNED NOT NULL, + `saved_round` INT(11) NOT NULL, + `saved_at` DATETIME NOT NULL, + `raw_bytes` INT(10) UNSIGNED NOT NULL, + `summary` TEXT NULL, + `payload` MEDIUMBLOB NOT NULL, + PRIMARY KEY (`ckey`, `slot`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + -- -- Table structure for table `schema_revision` -- diff --git a/SQL/tgstation_schema_prefixed.sql b/SQL/tgstation_schema_prefixed.sql index e04275e4b41..9e6c5fa3dea 100644 --- a/SQL/tgstation_schema_prefixed.sql +++ b/SQL/tgstation_schema_prefixed.sql @@ -560,6 +560,25 @@ CREATE TABLE `SS13_round_metric` ( KEY `idx_event_datetime` (`event`, `datetime`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +-- +-- Table structure for table `SS13_player_ship_saves` +-- +DROP TABLE IF EXISTS `SS13_player_ship_saves`; +CREATE TABLE `SS13_player_ship_saves` ( + `ckey` VARCHAR(32) NOT NULL, + `slot` TINYINT(3) UNSIGNED NOT NULL, + `ship_name` VARCHAR(128) NOT NULL, + `ship_class` VARCHAR(128) NULL, + `tile_count` SMALLINT(5) UNSIGNED NOT NULL, + `format_version` SMALLINT(5) UNSIGNED NOT NULL, + `saved_round` INT(11) NOT NULL, + `saved_at` DATETIME NOT NULL, + `raw_bytes` INT(10) UNSIGNED NOT NULL, + `summary` TEXT NULL, + `payload` MEDIUMBLOB NOT NULL, + PRIMARY KEY (`ckey`, `slot`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + -- -- Table structure for table `SS13_schema_revision` -- diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 5e1cd893fbf..fe0f40b5147 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -20,7 +20,7 @@ * * make sure you add an update to the schema_version stable in the db changelog */ -#define DB_MINOR_VERSION 34 // VOIDCREW EDIT - 34 adds round_metric (voidcrew/modules/metrics) +#define DB_MINOR_VERSION 35 // VOIDCREW EDIT - 34 adds round_metric (voidcrew/modules/metrics), 35 adds player_ship_saves (voidcrew/modules/ship_saves) //! ## Timing subsystem diff --git a/code/modules/station_goals/vault_mutation.dm b/code/modules/station_goals/vault_mutation.dm index db35d0c34fc..92666720bbe 100644 --- a/code/modules/station_goals/vault_mutation.dm +++ b/code/modules/station_goals/vault_mutation.dm @@ -8,6 +8,10 @@ /datum/mutation/breathless/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END ADD_TRAIT(acquirer, TRAIT_NOBREATH, GENETIC_MUTATION) /datum/mutation/breathless/on_losing(mob/living/carbon/human/owner)//this shouldnt happen under normal condition but just to be sure @@ -23,6 +27,10 @@ /datum/mutation/quick/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.add_movespeed_modifier(/datum/movespeed_modifier/dna_vault_speedup) /datum/mutation/quick/on_losing(mob/living/carbon/human/owner) @@ -38,6 +46,10 @@ /datum/mutation/tough/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.physiology.brute_mod *= 0.7 ADD_TRAIT(acquirer, TRAIT_PIERCEIMMUNE, GENETIC_MUTATION) @@ -55,6 +67,10 @@ /datum/mutation/dextrous/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.next_move_modifier *= 0.5 /datum/mutation/dextrous/on_losing(mob/living/carbon/human/owner) @@ -70,6 +86,10 @@ /datum/mutation/fire_immunity/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.physiology.burn_mod *= 0.5 acquirer.add_traits(list(TRAIT_RESISTHEAT, TRAIT_NOFIRE), GENETIC_MUTATION) @@ -87,6 +107,10 @@ /datum/mutation/quick_recovery/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END acquirer.physiology.stun_mod *= 0.5 /datum/mutation/quick_recovery/on_losing(mob/living/carbon/human/owner) @@ -102,6 +126,10 @@ /datum/mutation/plasmocile/on_acquiring(mob/living/carbon/human/acquirer) . = ..() + // VOIDCREW EDIT ADDITION START - a failed acquisition must not keep the buff (gene injector loot, voidcrew/modules/loot/gene_injectors.dm) + if(!.) + return + // VOIDCREW EDIT ADDITION END var/obj/item/organ/lungs/improved_lungs = acquirer.get_organ_slot(ORGAN_SLOT_LUNGS) ADD_TRAIT(owner, TRAIT_VIRUSIMMUNE, GENETIC_MUTATION) if(improved_lungs) 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 51a7c2bd84a..1eda2945621 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6743,12 +6743,15 @@ #include "voidcrew\_DEFINES\ship_combat.dm" #include "voidcrew\_DEFINES\ship_construction.dm" #include "voidcrew\_DEFINES\ship_defines.dm" +#include "voidcrew\_DEFINES\ship_saves.dm" #include "voidcrew\_DEFINES\ship_signals.dm" #include "voidcrew\_DEFINES\shuttles.dm" #include "voidcrew\_DEFINES\sound.dm" #include "voidcrew\_DEFINES\subsystems.dm" #include "voidcrew\_DEFINES\techweb_nodes.dm" #include "voidcrew\_DEFINES\trade.dm" +#include "voidcrew\_DEFINES\tutorials.dm" +#include "voidcrew\_DEFINES\xenoarch.dm" #include "voidcrew\_HELPERS\data_huds.dm" #include "voidcrew\_HELPERS\debug_atmos.dm" #include "voidcrew\_HELPERS\docking.dm" @@ -6847,6 +6850,7 @@ #include "voidcrew\edits\datums\components\spawner.dm" #include "voidcrew\edits\datums\elements\decals\_decal.dm" #include "voidcrew\edits\datums\mapgen\CaveGenerator.dm" +#include "voidcrew\edits\datums\mutations\sight.dm" #include "voidcrew\edits\datums\weather\weather.dm" #include "voidcrew\edits\datums\weather\weather_types\rain_storm.dm" #include "voidcrew\edits\datums\weather\weather_types\sand_storm.dm" @@ -7366,7 +7370,15 @@ #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_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" #include "voidcrew\modules\loot\zone_loot.dm" #include "voidcrew\modules\loot\zone_mobs.dm" @@ -7553,7 +7565,6 @@ #include "voidcrew\modules\npc_ships\code\subtrees\combat_subtree.dm" #include "voidcrew\modules\npc_ships\code\subtrees\movement_subtree.dm" #include "voidcrew\modules\onboarding\first_time_notice.dm" -#include "voidcrew\modules\onboarding\orientation.dm" #include "voidcrew\modules\onboarding\zone_advisory.dm" #include "voidcrew\modules\outfits\corporate.dm" #include "voidcrew\modules\outfits\misc.dm" @@ -7759,6 +7770,14 @@ #include "voidcrew\modules\ship_combat\electronic_warfare\ew_suite.dm" #include "voidcrew\modules\ship_purchase\ship_economy_database.dm" #include "voidcrew\modules\ship_purchase\transaction_helpers.dm" +#include "voidcrew\modules\ship_saves\ship_save_capture.dm" +#include "voidcrew\modules\ship_saves\ship_save_console.dm" +#include "voidcrew\modules\ship_saves\ship_save_construction.dm" +#include "voidcrew\modules\ship_saves\ship_save_lobby.dm" +#include "voidcrew\modules\ship_saves\ship_save_progress.dm" +#include "voidcrew\modules\ship_saves\ship_save_store.dm" +#include "voidcrew\modules\ship_saves\ship_save_survey.dm" +#include "voidcrew\modules\ship_saves\ship_saves.dm" #include "voidcrew\modules\ship_upgrades\_ship_upgrades.dm" #include "voidcrew\modules\ship_upgrades\admin_verbs.dm" #include "voidcrew\modules\ship_upgrades\modular_map_root_ship.dm" @@ -7870,6 +7889,23 @@ #include "voidcrew\modules\trade\favor_uniques\outfitter.dm" #include "voidcrew\modules\trade\favor_uniques\pike.dm" #include "voidcrew\modules\trade\favor_uniques\vex.dm" +#include "voidcrew\modules\tutorials\hooks_chrome_finisher.dm" +#include "voidcrew\modules\tutorials\hooks_contract.dm" +#include "voidcrew\modules\tutorials\hooks_medical.dm" +#include "voidcrew\modules\tutorials\hooks_power.dm" +#include "voidcrew\modules\tutorials\hooks_repair_drones.dm" +#include "voidcrew\modules\tutorials\hooks_research.dm" +#include "voidcrew\modules\tutorials\hooks_ship.dm" +#include "voidcrew\modules\tutorials\hooks_ship_save.dm" +#include "voidcrew\modules\tutorials\hooks_storage.dm" +#include "voidcrew\modules\tutorials\hooks_trade.dm" +#include "voidcrew\modules\tutorials\hooks_vestige.dm" +#include "voidcrew\modules\tutorials\hooks_world.dm" +#include "voidcrew\modules\tutorials\hooks_zones.dm" +#include "voidcrew\modules\tutorials\preferences.dm" +#include "voidcrew\modules\tutorials\tutorial_catalog.dm" +#include "voidcrew\modules\tutorials\tutorial_panel.dm" +#include "voidcrew\modules\tutorials\tutorial_progress.dm" #include "voidcrew\modules\transporter\_transporter.dm" #include "voidcrew\modules\transporter\transporter_console.dm" #include "voidcrew\modules\transporter\transporter_designs.dm" @@ -7891,6 +7927,76 @@ #include "voidcrew\modules\voice_barks\voice_screen.dm" #include "voidcrew\modules\weapons_bench\ammunition.dm" #include "voidcrew\modules\worldgen_logging\worldgen.dm" +#include "voidcrew\modules\xenoarch\core\artifact.dm" +#include "voidcrew\modules\xenoarch\core\artifact_appearance.dm" +#include "voidcrew\modules\xenoarch\core\artifact_effect.dm" +#include "voidcrew\modules\xenoarch\core\artifact_energy_kinds.dm" +#include "voidcrew\modules\xenoarch\core\artifact_helpers.dm" +#include "voidcrew\modules\xenoarch\core\artifact_label.dm" +#include "voidcrew\modules\xenoarch\core\artifact_pricing.dm" +#include "voidcrew\modules\xenoarch\dig\ambush.dm" +#include "voidcrew\modules\xenoarch\dig\boulder.dm" +#include "voidcrew\modules\xenoarch\dig\curios.dm" +#include "voidcrew\modules\xenoarch\dig\designs.dm" +#include "voidcrew\modules\xenoarch\dig\dig_reserve.dm" +#include "voidcrew\modules\xenoarch\dig\dig_sites.dm" +#include "voidcrew\modules\xenoarch\dig\finds.dm" +#include "voidcrew\modules\xenoarch\dig\site_walls.dm" +#include "voidcrew\modules\xenoarch\dig\spectrometer.dm" +#include "voidcrew\modules\xenoarch\dig\stabiliser.dm" +#include "voidcrew\modules\xenoarch\dig\strange_rock.dm" +#include "voidcrew\modules\xenoarch\dig\tools.dm" +#include "voidcrew\modules\xenoarch\effects\blood.dm" +#include "voidcrew\modules\xenoarch\effects\bodyhorror.dm" +#include "voidcrew\modules\xenoarch\effects\cell.dm" +#include "voidcrew\modules\xenoarch\effects\disgust.dm" +#include "voidcrew\modules\xenoarch\effects\emp.dm" +#include "voidcrew\modules\xenoarch\effects\feelings.dm" +#include "voidcrew\modules\xenoarch\effects\gas.dm" +#include "voidcrew\modules\xenoarch\effects\gravity.dm" +#include "voidcrew\modules\xenoarch\effects\hallucination.dm" +#include "voidcrew\modules\xenoarch\effects\health.dm" +#include "voidcrew\modules\xenoarch\effects\light.dm" +#include "voidcrew\modules\xenoarch\effects\noise.dm" +#include "voidcrew\modules\xenoarch\effects\radiation.dm" +#include "voidcrew\modules\xenoarch\effects\sleepy.dm" +#include "voidcrew\modules\xenoarch\effects\stun.dm" +#include "voidcrew\modules\xenoarch\effects\teleport.dm" +#include "voidcrew\modules\xenoarch\effects\temperature.dm" +#include "voidcrew\modules\xenoarch\effects\tesla.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\animate.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\berserk.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\disarm.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\electric_field.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\extinguisher.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\faraday.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\feysight.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\forcefield.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\fortify.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\gaia.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\gravity_wave.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\life_drain.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\life_steal.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\plasma_vent.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\poltergeist.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\resurrect.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\sleeping_gas.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\spines.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\sprint.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\status_effects.dm" +#include "voidcrew\modules\xenoarch\effects_polaris\sweat.dm" +#include "voidcrew\modules\xenoarch\study\analyser.dm" +#include "voidcrew\modules\xenoarch\study\labeler.dm" +#include "voidcrew\modules\xenoarch\study\research.dm" +#include "voidcrew\modules\xenoarch\study\scanpad.dm" +#include "voidcrew\modules\xenoarch\study\techweb.dm" +#include "voidcrew\modules\xenoarch\trade\buyers.dm" +#include "voidcrew\modules\xenoarch\trade\checkpoint.dm" +#include "voidcrew\modules\xenoarch\trade\metrics.dm" +#include "voidcrew\modules\xenoarch\trade\specimen_crate.dm" +#include "voidcrew\modules\xenoarch\use\battery.dm" +#include "voidcrew\modules\xenoarch\use\harvester.dm" +#include "voidcrew\modules\xenoarch\use\utilizer.dm" #include "voidcrew\objects\items\blueprints.dm" #include "voidcrew\objects\items\cards_ids.dm" #include "voidcrew\objects\items\trash.dm" diff --git a/tgui/packages/voidcrew_tgui/interfaces/AnomalyUtilizer.tsx b/tgui/packages/voidcrew_tgui/interfaces/AnomalyUtilizer.tsx new file mode 100644 index 00000000000..628f049a942 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/AnomalyUtilizer.tsx @@ -0,0 +1,112 @@ +/** + * Exotic utilizer: one power button ringed by the battery's charge, glowing in the colour of the + * energy it holds. DM side: voidcrew/modules/xenoarch/use/utilizer.dm. + */ +import { Box, Button, Stack } from 'tgui-core/components'; + +import { useBackend } from '../../tgui/backend'; +import { Window } from '../../tgui/layouts'; + +type Cell = { + fill: number; + color: string | null; + serial: string | null; + mode: string | null; +}; + +type Data = { + emitting: number; + cell: Cell | null; +}; + +const RING_R = 52; +const RING_C = 2 * Math.PI * RING_R; + +export const AnomalyUtilizer = (props) => { + const { act, data } = useBackend(); + const { emitting, cell } = data; + const on = !!emitting; + const color = cell?.color || '#4b5563'; + const fill = cell ? Math.max(0, Math.min(1, cell.fill)) : 0; + return ( + + + + + + + {on ? ( + + ) : null} + + act('power')} + /> + + {on ? 'EMIT' : 'OFF'} + + + + + + {cell?.serial ? cell.serial.toUpperCase() : 'NO CELL'} + {cell?.mode ? ` · ${cell.mode.toUpperCase()}` : ''} + + + + + + + + ); +}; diff --git a/tgui/packages/voidcrew_tgui/interfaces/ArtifactHarvester.tsx b/tgui/packages/voidcrew_tgui/interfaces/ArtifactHarvester.tsx new file mode 100644 index 00000000000..39c43f0dccd --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/ArtifactHarvester.tsx @@ -0,0 +1,266 @@ +/** + * Exotic particle harvester: a phosphor scope traces the signal of the find beside the machine, + * in its energy's colour, and a glass tube fills with the battery's colour as it draws. + * DM side: voidcrew/modules/xenoarch/use/harvester.dm. + */ +import { Box, Button, Stack } from 'tgui-core/components'; + +import { useBackend } from '../../tgui/backend'; +import { Window } from '../../tgui/layouts'; + +type Source = { + serial: string; + state: string; + color: string | null; +}; + +type Cell = { + fill: number; + color: string | null; + kind: string | null; + serial: string | null; +}; + +type Data = { + powered: number; + drawing: number; + source: Source | null; + cell: Cell | null; +}; + +const WORDS: Record = { + ready: 'SIGNAL', + drawing: 'DRAWING', + quiet: 'NO SIGNAL', + tangled: 'CROSSED', + unfit: 'WILL NOT HOLD', + spent: 'SPENT', + busy: 'ENGAGED', + mismatch: 'CELL MISMATCH', +}; + +const SCREEN_W = 240; +const SCREEN_H = 90; +const PHOSPHOR = '#59d48a'; +/** One period of the base wave in pixels; animated traces only use whole harmonics of it. */ +const PERIOD = 2 * Math.PI * 20; + +/** The trace for a state, twice the screen wide so it can scroll seamlessly. */ +function tracePath(state: string): string { + const points: string[] = []; + for (let x = 0; x <= SCREEN_W * 2; x += 3) { + const t = x / 20; + let y: number; + switch (state) { + case 'ready': + case 'drawing': + y = Math.sin(t) * 22; + break; + case 'tangled': + y = Math.sin(t) * 15 + Math.sin(t * 3 + 1) * 13; + break; + case 'unfit': + y = Math.sin(t * 3) * 26 * Math.exp(-((x % 60) / 16)); + break; + case 'mismatch': + y = Math.sin(t) * 12 + Math.sin(t * 5) * 6; + break; + case 'busy': + y = Math.sign(Math.sin(t * 0.8)) * 14; + break; + default: + y = Math.sin(t * 7) * 1.2; + } + points.push(`${x},${(SCREEN_H / 2 - y).toFixed(1)}`); + } + return `M ${points.join(' L ')}`; +} + +const MOVING = ['ready', 'drawing', 'tangled']; + +function Scope(props: { state: string; color: string; lit: boolean }) { + const { state, color, lit } = props; + return ( + + + {[...Array(9)].map((_, i) => ( + + ))} + {[...Array(3)].map((_, i) => ( + + ))} + {lit ? ( + + {MOVING.includes(state) ? ( + + ) : null} + + ) : null} + + ); +} + +function Tube(props: { fill: number; color: string | null; live: boolean }) { + const { fill, color, live } = props; + const height = 104; + const level = Math.round(height * Math.max(0, Math.min(1, fill))); + return ( + + + + + ); +} + +const etched = { + fontFamily: 'monospace', + fontSize: '10px', + letterSpacing: '2px', + color: '#8f98a6', +}; + +export const ArtifactHarvester = (props) => { + const { act, data } = useBackend(); + const { powered, drawing, source, cell } = data; + const state = source ? source.state : 'none'; + const lit = !!powered; + const traceColor = + (state === 'drawing' || state === 'ready') && source?.color + ? source.color + : PHOSPHOR; + const canDraw = + lit && !drawing && !!cell && cell.fill < 1 && state === 'ready'; + return ( + + + + + + + + + + {source ? source.serial.toUpperCase() : 'NO SOURCE'} + + + {source ? WORDS[state] || '' : ''} + + + + + + + CELL + + + + + + {drawing ? ( + + ) : ( + + )} + + + + + + + + + + + ); +}; diff --git a/tgui/packages/voidcrew_tgui/interfaces/CaptainManagement.tsx b/tgui/packages/voidcrew_tgui/interfaces/CaptainManagement.tsx index 569f0246f37..bd0c6c33080 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/CaptainManagement.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/CaptainManagement.tsx @@ -52,6 +52,7 @@ type Data = { joining_allowed: BooleanLike; join_password: string; can_set_password: BooleanLike; + password_price: number; crew_only_airlocks: BooleanLike; is_captain: BooleanLike; crew: CrewMember[]; @@ -363,12 +364,18 @@ const SettingsTab = () => { joining_allowed, join_password, can_set_password, + password_price, crew_only_airlocks, can_rename, } = data; const [newName, setNewName] = useState(ship_name ?? ''); const [newMemo, setNewMemo] = useState(memo ?? ''); const [newPassword, setNewPassword] = useState(join_password ?? ''); + // Same comparison as check_join_password(): trimmed, any case. + const enteredPassword = newPassword.trim().toLowerCase(); + const passwordCosts = + !!enteredPassword && + enteredPassword !== (join_password ?? '').trim().toLowerCase(); return ( @@ -451,7 +458,11 @@ const SettingsTab = () => { act('set_password', { password: newPassword }) } > - {newPassword ? 'Set Password' : 'Clear'} + {passwordCosts + ? `Set Password · ${password_price} parts` + : newPassword + ? 'Set Password' + : 'Clear'} 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..b8fc77bc957 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx @@ -0,0 +1,257 @@ +/** + * 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; + title: string; + onClick: () => void; +}) { + const { id, disabled, 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 }) { + const { s, children } = 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')} + /> + act('character')} + /> + {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; } +`; + +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/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..5763a0c0232 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx @@ -0,0 +1,119 @@ +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'], +]; + +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/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/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/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..210d2b97976 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx @@ -0,0 +1,242 @@ +/** + * 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 { 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); + 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 { s } = layout; + const scan = Math.max(2, Math.round(s)); + + const pickTab = (next: TabId) => { + if (next === tab && next === 'shipyard') { + setYardHome(yardHome + 1); + } + setTab(next); + if (confirmed) { + act('dismiss'); + } + }; + + let body; + if (confirmed) { + body = ; + } else if (tab === 'crews') { + body = ; + } else if (tab === 'shipyard') { + body = ; + } else { + body = ; + } + + return ( +
+ + { + const next = TABS[n - 1]; + if (next) { + pickTab(next[0]); + } + }} + onTabStep={(step) => { + const at = TABS.findIndex(([id]) => id === tab); + const next = TABS[(at + step + TABS.length) % TABS.length]; + pickTab(next[0]); + }} + > + +
+
+ +
+ {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..8d938833d9b --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/nav.tsx @@ -0,0 +1,551 @@ +/** + * 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; + /** 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