Something went wrong. Try again.
Starship Simulator Controls thoriumsim.com
Something went wrong. Try again.
19 kB · 716 lines
TSX
at develop
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717import type { ComponentQuery, EntityQuery, ValueQuery,} from "@thorium/.server/classes/Plugins/TimelineStep";import { components, type ComponentIds } from "@thorium/ecs-components";import { SoundConfigForm } from "@thorium/routes/config/systems/soundId";import Checkbox from "@thorium/ui/Checkbox";import { popoverTransitionClasses } from "@thorium/ui/Dropdown";import { Icon } from "@thorium/ui/Icon";import Input from "@thorium/ui/Input";import Select from "@thorium/ui/Select";import TagInput from "@thorium/ui/TagInput";import { Tooltip } from "@thorium/ui/Tooltip";import { cn } from "@thorium/utils/cn";import { type InputTypes, ZOD_COMPARISONS, getInputType, parseSchema, schemaWithoutDefault,} from "@thorium/utils/zodAutoForm";import { capitalCase } from "change-case";import { produce } from "immer";import { type Dispatch, useId } from "react";import { Button, ComboBox, ListBox, ListBoxItem, Popover, Input as RAInput,} from "react-aria-components";
import { ShipTemplate } from "./ShipTemplate";import { StarmapCoordinates } from "./StarmapCoordinates";
type QueryReducerAction = | { type: "add"; component?: keyof typeof components | ""; path?: string } | { type: "remove"; path: string } | { type: "component"; path: string; value: keyof typeof components | "" } | { type: "property"; path: string; value: string; comparison: string | null; } | { type: "comparison"; path: string; value: string | null } | { type: "value"; path: string; value: string | ValueQuery } | { type: "matchType"; path: string; value: "all" | "first" | "random" };
export function queryReducer(state: EntityQuery, action: QueryReducerAction): EntityQuery { switch (action.type) { case "add": return produce(state, (draft) => { getObject(draft, action.path || "").push({ component: action.component || "", property: "", comparison: null, value: "", }); }); case "remove": return produce(state, (draft) => { const path = action.path.split(".").slice(0, -1).join("."); let index: number | string | undefined = action.path.split(".").pop(); index = Number.isNaN(Number(index)) ? index : Number(index); getObject(draft, path).splice(index, 1); }); case "component": return produce(state, (draft) => { getObject(draft, action.path).component = action.value; getObject(draft, action.path).property = "isPresent"; getObject(draft, action.path).comparison = null; }); case "property": return produce(state, (draft) => { getObject(draft, action.path).property = action.value; getObject(draft, action.path).comparison = action.comparison; }); case "comparison": return produce(state, (draft) => { getObject(draft, action.path).comparison = action.value; }); case "value": return produce(state, (draft) => { getObject(draft, action.path).value = action.value; }); case "matchType": return produce(state, (draft) => { getObject(draft, action.path).matchType = action.value; }); default: return state; }}
export function getObject(object: any, path: string | null) { if (!path) return object; const paths = path.split(".").map((p) => (Number.isNaN(Number(p)) ? p : Number(p))); const target = paths.reduce((obj, key) => { if (!obj[key]) obj[key] = typeof key === "number" ? [] : {}; return obj[key]; }, object) || object; return target;}
const matchItems = [ { id: "all", label: "All Matches" }, { id: "first", label: "First Match" }, { id: "random", label: "Random Match" },];
function QueryComponent({ component, property, value, comparison, path, dispatch, showDelete,}: Omit<ComponentQuery, "value" | "comparison"> & Partial<Pick<ComponentQuery, "value" | "comparison">> & { path: string; dispatch: Dispatch<QueryReducerAction>; showDelete: boolean; }) { const item = component ? parseSchema(schemaWithoutDefault(component as ComponentIds)).find((p) => p.key === property) : null;
const isSelect = path.endsWith(".select"); return ( <div className="flex flex-wrap items-end gap-2"> <ComponentCombobox component={component as ComponentIds} onChange={(value) => { dispatch({ type: "component", path, value }); }} isSelect={isSelect} /> <PropertyCombobox component={component as ComponentIds} property={property} onChange={(value) => { const item = component ? parseSchema(schemaWithoutDefault(component as ComponentIds)).find( (p) => p.key === value, ) : null; const comparison = ZOD_COMPARISONS[item?.type as keyof typeof ZOD_COMPARISONS]?.[0] || null;
dispatch({ type: "property", path, value, comparison }); }} onlyShowProperties={isSelect} /> {!isSelect && typeof comparison === "string" && property && !["isPresent", "isNotPresent"].includes(property) ? ( <ComparisonSelect baseType={item?.type as keyof typeof ZOD_COMPARISONS} comparison={comparison} setComparison={(value: string | null) => { dispatch({ type: "comparison", path, value }); }} /> ) : null} {!isSelect && property && comparison && item?.type !== "ZodBoolean" && item ? ( <> <ValueInput item={item} path={path} value={value} dispatch={dispatch} queryInput /> {item.helper && <p className="text-xs text-gray-400">{item.helper}</p>} {showDelete ? <RemoveButton onClick={() => dispatch({ type: "remove", path })} /> : null} </> ) : null} {showDelete && (!comparison || !property) ? ( <RemoveButton onClick={() => dispatch({ type: "remove", path })} /> ) : null} </div> );}
const voices = typeof window === "undefined" ? [] : window.speechSynthesis .getVoices() .filter((s) => s.lang === navigator.language) .map((s) => s.name);
export function ValueInput({ value, item, dispatch, path, queryInput,}: { value: string | ValueQuery | undefined; item: { key: string; name: string; type: any; values: any; helper?: string; inputProps: React.InputHTMLAttributes<HTMLInputElement>; isNested: boolean; }; dispatch: React.Dispatch<QueryReducerAction>;
path: string; queryInput?: boolean;}) { const id = useId(); const noEntityQuery = item.type === "components"; // Special override for the voice input if (item.key.endsWith("voice")) { return ( <div className={item.isNested ? "value-input-is-nested" : ""}> <label htmlFor={id}>{item.name}</label> <PropertyInput id={id} inputType="select" inputValues={voices} value={value} setValue={(value) => dispatch({ type: "value", path: queryInput ? path : `${path}.values.${item.key}`, value, }) } label={item.name} labelHidden {...item.inputProps} /> </div> ); }
return !(typeof value === "object" && "query" in value && "select" in value) ? ( <div className={cn("flex items-end", item.isNested ? "value-input-is-nested" : "")}> <div className="flex-1"> {queryInput ? null : <label htmlFor={id}>{item.name}</label>} <PropertyInput id={id} inputType={getInputType(item, "=")} label={item.name} labelHidden inputValues={item.values} setValue={(value) => dispatch({ type: "value", path: queryInput ? path : `${path}.values.${item.key}`, value, }) } value={value} {...item.inputProps} /> </div> {noEntityQuery ? null : ( <Tooltip content="Use entity query value"> <button className="btn btn-xs btn-primary btn-outline" onClick={() => { dispatch({ type: "value", path: queryInput ? path : `${path}.values.${item.key}`, value: { query: [ { component: "", property: "", comparison: null, value: "", }, ], select: { component: "id" as any, property: "", matchType: "first", }, }, }); }} > <Icon name="sparkles" /> </button> </Tooltip> )} </div> ) : typeof value === "object" ? ( <div className={item.isNested ? "value-input-is-nested" : ""}> <div className="flex gap-2"> <label htmlFor={id}>{item.name}</label>
<Tooltip content="Use text value"> <button className="btn btn-xs btn-warning btn-outline" onClick={() => { dispatch({ type: "value", path: queryInput ? path : `${path}.values.${item.key}`, value: "", }); }} > <Icon name="text-cursor-input" /> </button> </Tooltip> </div> <div className="w-full"> <div className="w-fit rounded border border-gray-50/20 p-2"> <p>Entity Query</p> {value.query.map((q, i) => ( <QueryComponent key={i} {...q} path={ queryInput ? `${path}.value.query.${i}` : `${path}.values.${item.key}.query.${i}` } dispatch={dispatch} showDelete={value.query.length > 1} /> ))} <button className="btn btn-xs btn-primary max-w-fit" onClick={() => dispatch({ type: "add", path: queryInput ? `${path}.value.query` : `${path}.values.${item.key}.query`, }) } > Add Filter </button> <p className="mt-2">ID/Component Select</p> <QueryComponent {...value.select} path={queryInput ? `${path}.value.select` : `${path}.values.${item.key}.select`} dispatch={dispatch} showDelete={false} /> <Select id={id} className="max-w-fit" size="xs" label="Which entities to select?" items={matchItems} selected={value.select.matchType || null} setSelected={(val) => { if (!val || Array.isArray(val)) return; dispatch({ type: "matchType", path: queryInput ? `${path}.value.select` : `${path}.values.${item.key}.select`, value: val as any, }); }} /> </div> </div> </div> ) : null;}
function RemoveButton({ onClick }: { onClick: () => void }) { return ( <Tooltip content="Remove Filter"> <button className="btn-outline btn btn-xs btn-error" onClick={onClick}> <Icon name="x" /> </button> </Tooltip> );}
function ComparisonSelect({ baseType, comparison, setComparison,}: { baseType: keyof typeof ZOD_COMPARISONS | undefined; comparison: string | null; setComparison: (value: string | null) => void;}) { const comparisons = baseType ? ZOD_COMPARISONS[baseType] : []; const items = comparisons.map((c) => ({ id: c, label: c })); const id = useId(); return ( <Select id={id} size="xs" disabled={!baseType} label="Comparison" labelHidden items={items} selected={comparison} setSelected={(value) => { if (Array.isArray(value)) return;
setComparison(value ? value : null); }} /> );}
function ComponentCombobox({ component, onChange, isSelect,}: { component: keyof typeof components | ""; onChange: (value: keyof typeof components | "") => void; isSelect?: boolean;}) { return ( <ComboBox aria-label="Component" selectedKey={component} onSelectionChange={(selection) => onChange(selection as keyof typeof components)} > <div className="border-secondary relative h-6 min-h-6 cursor-pointer rounded-lg border leading-5"> <RAInput className="placeholder:text-secondary text-secondary-content w-full border-none bg-transparent pr-10 pl-3 text-xs leading-5 outline-none placeholder:font-semibold focus:ring-0" /> <Button className="bg-secondary/20 hover:bg-secondary/50 absolute inset-y-0 right-0 flex w-10 cursor-pointer items-center justify-center rounded"> <Icon name="chevron-down" className="text-secondary-content h-5 w-5" aria-hidden="true" /> </Button> </div> <Popover className={popoverTransitionClasses}> <ListBox className="max-h-60 w-full overflow-auto rounded-md border border-gray-400 bg-gray-900/90 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm" items={[ isSelect ? { id: "id" } : [], ...Object.keys(components).map((id) => ({ id })), ].flat()} > {(item) => ( <ListBoxItem className="data-[focused]:bg-secondary cursor-default truncate px-2 py-1 font-normal text-white select-none"> {capitalCase(item.id)} </ListBoxItem> )} </ListBox> </Popover> </ComboBox> );}
export function PropertyCombobox({ component, property, onChange, onlyShowProperties,}: { component: keyof typeof components | "id" | ""; property: string; onChange: (value: string) => void; onlyShowProperties?: boolean;}) { if (component === "id") return null; const properties = !component ? [] : [ ...(onlyShowProperties ? [] : ["isPresent", "isNotPresent"]), ...parseSchema(schemaWithoutDefault(component)).map((item) => ZOD_COMPARISONS[item.type as keyof typeof ZOD_COMPARISONS] ? item.key : [], ), ] .flat() .map((id) => ({ id }));
return ( <ComboBox aria-label="Property" selectedKey={property} onSelectionChange={(selection) => onChange(selection as string)} > <div className="border-secondary relative h-6 min-h-6 cursor-pointer rounded-lg border leading-5"> <RAInput className="placeholder:text-secondary text-secondary-content w-full border-none bg-transparent pr-10 pl-3 text-xs leading-5 outline-none placeholder:font-semibold focus:ring-0" /> <Button className="bg-secondary/20 hover:bg-secondary/50 absolute inset-y-0 right-0 flex w-10 cursor-pointer items-center justify-center rounded"> <Icon name="chevron-down" className="text-secondary-content h-5 w-5" aria-hidden="true" /> </Button> </div> <Popover className={popoverTransitionClasses}> <ListBox className="max-h-60 w-full overflow-auto rounded-md border border-gray-400 bg-gray-900/90 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm" items={properties} > {(item) => ( <ListBoxItem className="data-[focused]:bg-secondary cursor-default truncate px-2 py-1 font-normal text-white select-none"> {capitalCase(item.id)} </ListBoxItem> )} </ListBox> </Popover> </ComboBox> );}
export function PropertyInput({ inputType, inputValues, value, setValue, label, labelHidden = true, multiple, id, ...inputProps}: { inputType: InputTypes; inputValues?: string[]; value?: any; setValue: (value: any) => void; label: string; labelHidden?: boolean; multiple?: boolean; id: string;}) { switch (inputType) { case "number": return ( <Input id={id} className="input-xs" fixed label={label} labelHidden={labelHidden} onChange={(e) => setValue(e.target.value)} defaultValue={value} {...inputProps} /> ); case "checkbox": return ( <Checkbox id={id} label={label} labelHidden={labelHidden} onChange={(e) => setValue(e.target.checked)} checked={value} {...inputProps} /> ); case "select": return ( <Select id={id} size="xs" label={label} labelHidden={labelHidden} items={inputValues?.map((i) => ({ id: i, label: i })) || []} selected={value} setSelected={(newValue) => { setValue(newValue); }} multiple={multiple} {...inputProps} /> ); case "date": return ( <Input id={id} fixed className="input-xs" type="date" label={label} labelHidden={labelHidden} onChange={(e) => setValue(e.target.value)} value={value} {...inputProps} /> ); case "tags": return ( <TagInput label={label} labelHidden tags={value || []} onAdd={(t) => setValue([...(value || []), t].filter((a, i, arr) => arr.indexOf(a) === i))} onRemove={(t) => setValue(value?.filter((v: any) => v !== t) || [])} {...inputProps} /> ); case "object": return null; case "starmapCoordinates": return <StarmapCoordinates value={value} setValue={setValue} />; case "shipTemplate": return <ShipTemplate value={value} setValue={setValue} />; case "components": return <ComponentsEditor components={value} setValue={setValue} />; case "sound": { const sound = value || { url: "", volume: [1, 1], playbackRate: [1, 1], loop: false, loopStart: null, loopEnd: null, delay: 0, loopGap: 0, channel: null, }; return ( <SoundConfigForm sound={sound} updateSound={(property, value) => setValue({ ...sound, [property]: value })} /> ); } case "textarea": return ( <Input as="textarea" fixed label={label} labelHidden={labelHidden} onBlur={(e) => setValue(e.target.value)} defaultValue={value} {...inputProps} /> ); default: if (inputType !== "text") { console.warn("Unknown input type", inputType); } return ( <Input id={id} className="input-xs" fixed label={label} labelHidden={labelHidden} onBlur={(e) => setValue(e.target.value)} defaultValue={value} {...inputProps} /> ); }}
function ComponentsEditor({ components, setValue,}: { components: EntityQuery; setValue: (value: EntityQuery) => void;}) { return ( <div> {!components || components.length === 0 ? ( <ComponentCombobox component="" onChange={(component) => setValue(queryReducer(components || [], { type: "add", component })) } /> ) : ( components?.map(({ component, property, value }, i) => { const item = component ? parseSchema(schemaWithoutDefault(component as ComponentIds)).find( (p) => p.key === property, ) : null; return ( <div key={`${i}-${component as ComponentIds}`} className="flex w-full"> <ComponentCombobox component={component as ComponentIds} onChange={(component) => setValue( queryReducer(components, { type: "component", path: i.toString(), value: component, }), ) } /> <PropertyCombobox onlyShowProperties component={component as ComponentIds} property={property} onChange={(property) => setValue( queryReducer(components, { type: "property", path: i.toString(), comparison: "", value: property, }), ) } /> {item ? ( <> <ValueInput item={item} path={i.toString()} value={value} dispatch={(action) => setValue(queryReducer(components, action))} queryInput /> {item.helper && <p className="text-xs text-gray-400">{item.helper}</p>} {/* {showDelete ? ( <RemoveButton onClick={() => dispatch({ type: "remove", path })} /> ) : null} */} </> ) : null} </div> ); }) )} </div> );}