main commit

This commit is contained in:
2026-06-21 11:27:44 +02:00
commit f45e5197a4
44 changed files with 4511 additions and 0 deletions
View File
+95
View File
@@ -0,0 +1,95 @@
import settingsFile from "@common/shared/settings";
import { SettingDefinition, SettingRealType, SettingType } from "@common/shared/typings/settings";
import { Controller, OnStart } from "@flamework/core";
import { Atom, atom } from "@rbxts/charm";
import Object, { keys } from "@rbxts/object-utils";
import { Events, Functions } from "../network";
/**
* Setting Management Controller
*/
@Controller()
export class SettingsController implements OnStart {
private atomsCache: Map<string, Map<string, Atom<SettingRealType<SettingType>>>> = new Map();
private defCache: Map<string, Map<string, SettingDefinition>> = new Map();
/**
* Fetch player settings from the server.
*/
fetchSettings() {
Functions.settings.GetSettings().then((settings) => {
settings.forEach((section, sectionKey) => {
const sectionAtoms = this.atomsCache.get(sectionKey)!;
section.forEach((val, key) => {
sectionAtoms.get(key)?.(val);
});
});
});
}
/**
* Get all sections names.
* @returns Sections names in the settings file.
*/
getSections() {
return keys(this.atomsCache);
}
/**
* Get All atoms and their definition in the given section.
* @param section The name of the section.
* @returns An array with setting definition and an the atom linked to the type definition.
*/
getSectionWithDef(section: string) {
const ret: [SettingDefinition, Atom<unknown>][] = [];
this.atomsCache.get(section)?.forEach((value, key) => {
ret.push([this.defCache.get(section)!.get(key)!, value]);
});
return ret;
}
/**
* Get an atom with an section name and a setting name.
* @param section Section name.
* @param setting Setting name.
* @returns The atom linked to the setting.
*/
getSetting(section: string, setting: string) {
return this.atomsCache.get(section)?.get(setting);
}
/**
* @inheritdoc
*/
onStart(): void {
this.setupAtoms();
this.fetchSettings();
}
/**
* Send a section of settings to the server to save.
* @param section The section to save.
*/
saveSettings(section: string) {
const atoms = this.atomsCache.get(section);
if (!atoms) return;
const map = new Map<string, boolean | number | string>();
atoms.forEach((value, key) => {
map.set(key, value() as boolean | number | string);
});
Events.settings.SetSettings(section, map);
}
/**
* Setup atoms and definition at service start.
*/
private setupAtoms() {
const sections = Object.keys<Record<string, SettingDefinition[]>>(settingsFile).filter((key) =>
typeIs(settingsFile[key], "table"),
);
sections.forEach((key) => {
const map = new Map<string, Charm.Atom<unknown>>();
const defMap = new Map<string, SettingDefinition>();
this.atomsCache.set(key, map);
this.defCache.set(key, defMap);
settingsFile[key].forEach((def) => {
map.set(def.id, atom(def.default));
defMap.set(def.id, def);
});
});
}
}
+4
View File
@@ -0,0 +1,4 @@
import { GlobalEvents, GlobalFunctions } from "@common/shared/network";
export const Events = GlobalEvents.createClient({});
export const Functions = GlobalFunctions.createClient({});
@@ -0,0 +1,59 @@
import { NotDerivate, useBox } from "@common/shared/utils/ui/vide";
import { Atom } from "@rbxts/charm";
import Vide, { Derivable, source } from "@rbxts/vide";
/**
* Props of a toggle.
*/
interface ToggleProps {
/**
* A charm atom linked to the toggle.
*/
Atom?: Atom<boolean>;
/**
* The position of the toggle in roblox ui.
*/
Position?: Derivable<UDim2>;
/**
* The size of the toggle
*/
Size?: Derivable<UDim2>;
}
/**
* A toggle is a UI component linked to an boolean.
* @param props Props of the toggle.
* @returns A Vide node with the toggle.
*/
export function Toggle(props: ToggleProps = {}) {
const [enable, setEnable] = useBox(props.Atom, false);
const sphereSize = source(0);
return (
<imagebutton
BackgroundColor3={new Color3(1, 1, 1)}
MouseButton1Click={() => setEnable(!enable())}
Position={props?.Position}
Size={props?.Size ?? UDim2.fromOffset(90, 30)}
>
<uicorner CornerRadius={new UDim(1, 0)} />
<uiaspectratioconstraint AspectRatio={1.8} />
<frame
BackgroundColor3={new Color3(0.5, 0.5, 0.5)}
Position={new UDim2(0, 2, 0, 2)}
Size={new UDim2(1, -4, 1, -4)}
Visible={NotDerivate(enable)}
>
<uicorner CornerRadius={new UDim(1, 0)} />
</frame>
<frame
AbsoluteSizeChanged={(value) => sphereSize(value.X)}
BackgroundColor3={new Color3(0, 0, 0)}
Position={() => (enable() ? new UDim2(1, -sphereSize() - 2, 0, 2) : new UDim2(0, 2, 0, 2))}
Size={new UDim2(1, -4, 1, -4)}
>
<uicorner CornerRadius={new UDim(1, 0)} />
<uiaspectratioconstraint AspectRatio={1} />
</frame>
</imagebutton>
);
}
@@ -0,0 +1,127 @@
import { SettingsController } from "@common/client/controllers/settings";
import { SettingDefinition, SettingRealType, SettingType } from "@common/shared/typings/settings";
import { Atom } from "@rbxts/charm";
import Vide, { Case, For, Source, source, Switch } from "@rbxts/vide";
import { Toggle } from "../global/toggle";
/**
* The settings menu as vide components.
* @param settingsController The settings Controller for fetching settings.
* @returns A Vide node with the settings menu.
*/
export function Settings(settingsController: SettingsController) {
const keys = source(settingsController.getSections());
const panel = source(keys()[0]);
return (
<frame
BackgroundColor3={new Color3(0, 0, 0)}
Position={UDim2.fromScale(0.05, 0.05)}
Size={UDim2.fromScale(0.9, 0.9)}
>
<uicorner />
<uistroke Color={new Color3(1, 1, 1)} />
<scrollingframe BackgroundTransparency={1} Size={UDim2.fromScale(0.3, 1)}>
<uigridlayout CellPadding={UDim2.fromOffset(0, 0)} CellSize={new UDim2(1, 0, 0, 40)} />
<For each={keys}>{(group) => <SettingsGroup name={group} panelSource={panel} />}</For>
</scrollingframe>
<frame Position={UDim2.fromScale(0.3, 0)} Size={new UDim2(0, 1, 1, 0)} />
<frame BackgroundTransparency={1} Position={new UDim2(0.3, 10, 0, 0)} Size={new UDim2(0.7, -10, 1, 0)}>
<uilistlayout Padding={new UDim(0, 10)} />
<For each={() => settingsController.getSectionWithDef(panel())}>
{([def, atom]) => <SettingsEntry atom={atom} definition={def} />}
</For>
</frame>
</frame>
);
}
/**
* An entry in the settings menu.
* @param props Properties of the entry.
* @param props.definition Setting definition.
* @param props.atom Charm atom with the value of the setting.
* @returns A setting entry.
*/
export function SettingsEntry<T extends SettingType>(props: {
/**
* The atom with the value of the setting.
*/
atom: Atom<SettingRealType<T>>;
/**
* The definition of the setting.
*/
definition: SettingDefinition<T>;
}) {
return (
<frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1}>
<uilistlayout />
<textlabel
AutomaticSize={Enum.AutomaticSize.Y}
BackgroundTransparency={1}
Text={props.definition.name}
TextColor3={new Color3(1, 1, 1)}
TextSize={30}
TextXAlignment={Enum.TextXAlignment.Left}
/>
<textlabel
AutomaticSize={Enum.AutomaticSize.Y}
BackgroundTransparency={1}
Text={props.definition.description}
TextColor3={new Color3(1, 1, 1)}
TextSize={25}
TextTransparency={0.5}
TextXAlignment={Enum.TextXAlignment.Left}
/>
<Switch condition={() => props.definition.type}>
<Case match={SettingType.Boolean}>
{() => (
<>
<frame Size={UDim2.fromOffset(0, 10)} />
<Toggle Atom={props.atom as unknown as Atom<boolean>} />
</>
)}
</Case>
</Switch>
</frame>
);
}
/**
* A button for each setting section the settings menu.
* @param props Properties of the button.
* @param props.name The name of the entry.
* @param props.panelSource A vide source for the current panel.
* @returns The Settings group button.
*/
export function SettingsGroup(props: {
/**
* The name of the settings group.
*/
name: string;
/**
* A vide source with the name of the current panel.
*/
panelSource: Source<string>;
}) {
return (
<frame BackgroundTransparency={1}>
<textbutton
BackgroundTransparency={1}
FontFace={() =>
props.name === props.panelSource()
? Font.fromEnum(Enum.Font.SourceSansBold)
: Font.fromEnum(Enum.Font.SourceSans)
}
MouseButton1Click={() => {
props.panelSource(props.name);
}}
Size={UDim2.fromScale(1, 1)}
Text={props.name}
TextColor3={new Color3(1, 1, 1)}
TextScaled={true}
/>
</frame>
);
}
@@ -0,0 +1,5 @@
import { Toggle } from "@common/client/ui/components/global/toggle";
import { Hoarcekat } from "@common/shared/utils/ui/hoarcekat";
import { Node } from "@rbxts/vide";
export = Hoarcekat(Toggle as () => Node);
View File
+4
View File
@@ -0,0 +1,4 @@
import { GlobalEvents, GlobalFunctions } from "@common/shared/network";
export const Events = GlobalEvents.createServer({});
export const Functions = GlobalFunctions.createServer({});
+59
View File
@@ -0,0 +1,59 @@
import { Service } from "@flamework/core";
import { DataStoreService } from "@rbxts/services";
/**
* A data store, linked to a roblox DataStore and the DataService.
*/
export class Store {
/**
* @param service The instance of the data service.
* @param name The name of the store.
* @param realStore The real roblox dataStore.
*/
constructor(
private service: DataService,
private name: string,
private realStore: DataStore,
) {}
/**
* Get a data in the database.
* @param key The key of the data.
* @returns The value of the key.
*/
get<T>(key: string): T | undefined {
const [value] = this.realStore.GetAsync(key);
return value as T;
}
/**
* Set a data in the database.
* @param key The key of the data.
* @param value The value to set.
*/
set<T>(key: string, value: T) {
this.realStore.SetAsync(key, value);
}
}
/**
* Alias Alastor Service
*
* Work in Progress
*
* A flamework service to manage the roblox DataStoreService.
*/
@Service()
export class DataService {
private cache = new Map<string, Store>();
/**
* Get a store with his name.
* @param name The name of the store.
* @returns A Store class linked the given name.
*/
getStore(name: string): Store {
const store = this.cache.get(name);
if (store) return store;
const newStore = new Store(this, name, DataStoreService.GetDataStore(name));
this.cache.set(name, newStore);
return newStore;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { OnPlayerJoined, OnPlayerQuit } from "@common/shared/modding/players";
import { Modding, OnStart } from "@flamework/core";
import { Players } from "@rbxts/services";
/**
* This Service Manage some player event with modding.
*/
export class PlayerEventsService implements OnStart {
/**
* @inheritdoc
*/
onStart() {
const playerJoinListener = new Set<OnPlayerJoined>();
const playerQuitListener = new Set<OnPlayerQuit>();
Modding.onListenerAdded<OnPlayerJoined>((object) => playerJoinListener.add(object));
Modding.onListenerRemoved<OnPlayerJoined>((object) => playerJoinListener.delete(object));
Modding.onListenerAdded<OnPlayerQuit>((object) => playerQuitListener.add(object));
Modding.onListenerRemoved<OnPlayerQuit>((object) => playerQuitListener.delete(object));
Players.PlayerAdded.Connect((player) => {
for (const listener of playerJoinListener) {
task.spawn(() => listener.onPlayerJoined(player));
}
});
Players.PlayerRemoving.Connect((player) => {
for (const listener of playerQuitListener) {
task.spawn(() => listener.onPlayerQuit(player));
}
});
for (const player of Players.GetPlayers()) {
for (const listener of playerJoinListener) {
task.spawn(() => listener.onPlayerJoined(player));
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { OnPlayerJoined } from "@common/shared/modding/players";
import SettingsFile from "@common/shared/settings";
import { SetSetting, SettingTypeChecker } from "@common/shared/typings/settings";
import { OnStart, Service } from "@flamework/core";
import { Events, Functions } from "../network";
import { DataService, Store } from "./data";
/**
* The Setting Manager service
*/
@Service()
export class SettingsService implements OnPlayerJoined, OnStart {
cache = new Map<number, SetSetting>();
store: Store;
/**
* @param dataService The instance of the DataService.
*/
constructor(private dataService: DataService) {
this.store = this.dataService.getStore("UserSettings");
}
/**
* @inheritdoc
*/
onPlayerJoined(player: Player): void {
const store: SetSetting = this.store.get(tostring(player.UserId)) ?? new Map();
this.cache.set(player.UserId, store);
}
/**
* @inheritdoc
*/
onStart(): void {
Functions.settings.GetVersion.setCallback(() => SettingsFile.version);
Functions.settings.GetSettings.setCallback((player) => {
return this.cache.get(player.UserId) ?? this.store.get<SetSetting>(tostring(player.UserId)) ?? new Map();
});
Events.settings.SetSettings.connect((player, section, settings) => {
settings.forEach((value, key) => {
const setting = SettingsFile[section]?.find((sett) => sett.id === key);
if (setting === undefined) return;
if (typeIs(value, SettingTypeChecker[setting.type])) {
// TODO Better checker
this.cache.get(player.UserId)?.get(section)?.set(key, value);
}
});
});
}
}
View File
+39
View File
@@ -0,0 +1,39 @@
// This file is automatically @generated by Asphalt.
// It is not intended for manual editing.
declare const assets: {
audio: {
music: {
"a-bench-for-ducks.ogg": Content
"eggplants-in-the-sky.ogg": Content
"lobby.ogg": Content
"meowmeowmeow.ogg": Content
"railway-resonance.ogg": Content
"root.ogg": Content
"solstice.ogg": Content
"stay-on-track.ogg": Content
"weltschmerz.ogg": Content
}
}
images: {
kiwirina: {
"LUNA....png": Content
"eyes.png": Content
"kiwi tag.png": Content
"lateworkerconceptmob.png": Content
"next station ost cover.png": Content
"porte closed.png": Content
"porte open.png": Content
}
svg: {
"cube.svg": Content
}
}
rooms: {
normal: {
"plus-corridor.rbxm": Content
"straight-corridor.rbxm": Content
}
}
}
export = assets
+39
View File
@@ -0,0 +1,39 @@
-- This file is automatically @generated by Asphalt.
-- It is not intended for manual editing.
local assets = {
audio = {
music = {
["a-bench-for-ducks.ogg"] = Content.fromUri("rbxassetid://93869395830786"),
["eggplants-in-the-sky.ogg"] = Content.fromUri("rbxassetid://74192100149187"),
["lobby.ogg"] = Content.fromUri("rbxassetid://119303577477446"),
["meowmeowmeow.ogg"] = Content.fromUri("rbxassetid://106235234984065"),
["railway-resonance.ogg"] = Content.fromUri("rbxassetid://97452653460582"),
["root.ogg"] = Content.fromUri("rbxassetid://76389194668757"),
["solstice.ogg"] = Content.fromUri("rbxassetid://76565610359366"),
["stay-on-track.ogg"] = Content.fromUri("rbxassetid://123486861030898"),
["weltschmerz.ogg"] = Content.fromUri("rbxassetid://108803792430337"),
},
},
images = {
kiwirina = {
["LUNA....png"] = Content.fromUri("rbxassetid://105469337892524"),
["eyes.png"] = Content.fromUri("rbxassetid://115382005766484"),
["kiwi tag.png"] = Content.fromUri("rbxassetid://100562459880003"),
["lateworkerconceptmob.png"] = Content.fromUri("rbxassetid://120860142409755"),
["next station ost cover.png"] = Content.fromUri("rbxassetid://127096632190841"),
["porte closed.png"] = Content.fromUri("rbxassetid://119045003698167"),
["porte open.png"] = Content.fromUri("rbxassetid://83329348606912"),
},
svg = {
["cube.svg"] = Content.fromUri("rbxassetid://92619665942094"),
},
},
rooms = {
normal = {
["plus-corridor.rbxm"] = Content.fromUri("rbxassetid://129493473437352"),
["straight-corridor.rbxm"] = Content.fromUri("rbxassetid://76052577701850"),
},
},
}
return assets
View File
+50
View File
@@ -0,0 +1,50 @@
import { Modding } from "@flamework/core";
export class Generation {
/**
* Load all rooms in the given folder.
* @metadata macro intrinsic-arg-shift
*/
addPaths<T extends string>(path: T, meta?: Modding.Intrinsic<"path", [T]>): void;
addPaths<T extends string>(paths: T[][]): void {
// WIP
print(paths);
}
}
// Placeholder
export class PrefixSumArray<T extends defined> {
private bounds: number[] = [];
private totalLength: number = 0;
private values: T[] = [];
add(size: number, value: T) {
if (size <= 0) return;
this.totalLength += size;
this.bounds.push(this.totalLength);
this.values.push(value);
}
get(index: number) {
if (typeIs(index, "number") && index < 1 && index > this.totalLength) return undefined;
let low = 1;
let high = this.bounds.size();
while (low < high) {
const mid = math.floor((low + high) / 2);
if (this.bounds[mid] < index) {
low = mid + 1;
} else {
high = mid;
}
}
return this.values[low];
}
size() {
return this.totalLength;
}
}
+41
View File
@@ -0,0 +1,41 @@
type ToNumberTuple<T extends readonly defined[]> = {
[K in keyof T]: number;
};
export class NArray<N extends number[], T extends defined> {
private array: Array<T>;
private dims: N;
constructor(value?: T, ...dims: N) {
this.dims = dims;
const size = factorAnArray(dims);
this.array = value !== undefined ? new Array(size, value) : new Array(size);
}
get(...index: ToNumberTuple<N>): T | undefined {
return this.array[this.getIndex(index)];
}
set(value: T, ...index: ToNumberTuple<N>) {
this.array[this.getIndex(index)] = value;
}
private getIndex(index: ToNumberTuple<N>) {
let factor = 1;
let ret = 0;
index.forEach((value, index) => {
if (index === 0) ret += value;
factor *= this.dims[index - 1];
ret += value * factor;
});
return ret;
}
}
function factorAnArray(arr: number[]) {
let size = 1;
for (const dim of arr) {
size *= dim;
}
return size;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Hook into the Singleton to a event which trigger each player join.
*/
export interface OnPlayerJoined {
/**
* This function will be called when a player join the game.
*/
onPlayerJoined(player: Player): void;
}
/**
* Hook into the Singleton to a event which trigger each player quit.
*/
export interface OnPlayerQuit {
/**
* This function will be called when a player quit the game.
*/
onPlayerQuit(player: Player): void;
}
+50
View File
@@ -0,0 +1,50 @@
import { Networking } from "@flamework/networking";
import { SetSetting } from "./typings/settings";
/**
* Global client to server events.
*/
export interface ClientToServerEvents {
/**
* Setting client to server events.
*/
settings: {
/**
* Send current player setting to the player.
*/
SetSettings(section: string, settings: Map<string, boolean | number | string>): void;
};
}
/**
* Global client to server functions.
*/
export interface ClientToServerFunctions {
/**
* Setting client to server functions.
*/
settings: {
/**
* Get saved settings.
*/
GetSettings(): SetSetting;
/**
* Get server settings version
*/
GetVersion(): number;
};
}
/**
* Global server to client events.
*/
export interface ServerToClientEvents {}
/**
* Global server to client functions.
*/
export interface ServerToClientFunctions {}
export const GlobalEvents = Networking.createEvent<ClientToServerEvents, ServerToClientEvents>();
export const GlobalFunctions = Networking.createFunction<ClientToServerFunctions, ServerToClientFunctions>();
+16
View File
@@ -0,0 +1,16 @@
import { SettingDefinition } from "@common/shared/typings/settings";
/**
* Type definition of the setting definition file.
*/
export declare interface SettingsFile {
[group: string]: SettingDefinition[];
/**
* The current version of the setting file.
*/
version: number;
}
declare const settingsFile: SettingsFile;
export = settingsFile;
+104
View File
@@ -0,0 +1,104 @@
version = 2
[[Accessibility]]
default = false
description = "Reduce the visual effects of flashing lights and fast motion."
name = "Epilepsy mode"
type = "Boolean"
[[Accessibility]]
default = true
description = "Enable the shaking screen."
name = "Shaking Screen"
type = "Boolean"
[[Accessibility]]
default = false
description = "Disables all game sounds and adds information about them."
name = "Deaf mode"
type = "Boolean"
[[Accessibility]]
default = 1
description = "You know what is subtitle isn't it"
name = "Subtitle"
option = ["Nothing", "Dialogue only", "All Sound"]
type = "Enum"
[[Accessibility]]
default = true
description = "Activities mod to be fair with the blind people"
name = "Blind mode"
type = "Boolean"
[[Graphic]]
default = true
description = "Allow you to see your body in first person."
name = "Introspection"
type = "Boolean"
[[Control]]
default = true
description = "Do like if you are on mobile"
name = "Force mobile"
type = "Boolean"
[[Control]]
default = true
description = "let you Edit mobile bouton"
name = "Edit mobile bouton"
type = "Boolean"
[[Sond]]
default = 90
description = " Change l'angle de vision"
max = 120
min = 30
name = "FOV"
type = "Int"
[[Sond]]
default = 100
description = "Change the volume of Main"
max = 200
min = 0
name = "Main Volume"
type = "Int"
[[Sond]]
default = 100
description = "Change the volume of SFX"
max = 200
min = 0
name = "SFX Volume"
type = "Int"
[[Sond]]
default = 100
description = "Change the volume of Musique"
max = 200
min = 0
name = "Musique Volume"
type = "Int"
[[Sond]]
default = 100
description = "Change the volume of Mob"
max = 200
min = 0
name = "Mob Volume"
type = "Int"
[[Sond]]
default = 100
description = "Change the volume of Radio"
max = 200
min = 0
name = "Musique Radio"
type = "Int"
[[Performance]]
default = false
description = "Do you have a potato PC ?if it the case enable is"
name = "Toaster mod"
type = "Boolean"
+53
View File
@@ -0,0 +1,53 @@
/**
* Possible types of Setting.
*/
export enum SettingType {
Boolean = "Boolean",
Enum = "Enum",
Float = "Float",
Int = "Int",
String = "String",
}
/**
* A setting definition.
*/
export interface SettingDefinition<T extends SettingType = SettingType> {
/**
* The default value of the setting.
*/
default: SettingRealType<T>;
/**
* The english description of the setting.
*/
description: string;
/**
* The unique id of the setting.
*/
id: string;
/**
* The name of the setting.
*/
name: string;
/**
* The type of the setting.
*/
type: T;
}
export const SettingTypeChecker: Record<SettingType, keyof CheckableTypes> = {
Boolean: "boolean",
Enum: "number",
Float: "number",
Int: "number",
String: "string",
};
/**
* The data storage form of saved setting.
*/
export type SetSetting = Map<string, Map<string, boolean | number | string>>;
/**
* Transform Setting type enum to real typescript/lua type.
*/
export type SettingRealType<T extends SettingType> = CheckableTypes[(typeof SettingTypeChecker)[T]];
+12
View File
@@ -0,0 +1,12 @@
import { mount } from "@rbxts/vide";
/**
* Transform a vide component into a Hoarcekat story.
* @param component A vide component.
* @returns A Hoarcekat story.
*/
export function Hoarcekat<T>(component: () => T) {
return (target: Instance) => {
return mount(component, target);
};
}
+29
View File
@@ -0,0 +1,29 @@
import { Atom } from "@rbxts/charm";
import { derive, source, Source } from "@rbxts/vide";
import { useAtom } from "@rbxts/vide-charm";
/**
* Create an derivate which a "not" (`!`) operator on an boolean source.
* @param source A Boolean Source
* @returns The derivate.
*/
export function NotDerivate(source: Source<boolean>): () => boolean {
return derive(() => !source());
}
/**
* Create two function linked to an charm atom or a vide source.
* @param atom An optional charm atom.
* @param defaultValue The default value if `atom` is `undefined`.
* @returns Two function, one to get the value of the box, one to set the value of the box.
*/
export function useBox<T>(atom: Atom<T>, defaultValue?: T): [getter: () => T, setter: (value: T) => T];
export function useBox<T>(atom: Atom<T> | undefined, defaultValue: T): [getter: () => T, setter: (value: T) => T];
export function useBox<T>(atom: Atom<T> | undefined, defaultValue: T): [getter: () => T, setter: (value: T) => T] {
if (atom) {
return [useAtom(atom), atom];
} else {
const value = source(defaultValue);
return [value, value];
}
}