diff --git a/packages/varuna/src/index.ts b/packages/varuna/src/index.ts index dbe3d3e44..4cb201bf6 100644 --- a/packages/varuna/src/index.ts +++ b/packages/varuna/src/index.ts @@ -2,24 +2,49 @@ import { produce } from 'immer'; import { merge } from 'lodash-es'; import { useEffect, useState } from 'react'; -export class Varuna { +interface ProviderProps { + args: Arguments; +} + +export class Varuna { private listeners = new Set<(state: Type) => void>(); - public initial: Type; + + private initialized = false; + private _initial?: Type; + private _state?: Type; constructor( - public state: Type, - persistence?: string, + private creator: Type | ((...args: Arguments[]) => Type), + private persistence?: string, ) { - if (persistence) { - const dehydrated = localStorage.getItem(persistence); + if (typeof creator !== 'function') this.initialize(creator); + } + + Provider({ args }: ProviderProps) { + if (typeof this.creator !== 'function') { + throw new Error('Provider must be used with a function creator'); + } + + this.initialize((this.creator as (...args: Arguments[]) => Type)(args)); + + return null; + } + + private initialize(initial: Type) { + let data = initial; + + if (this.persistence) { + const dehydrated = localStorage.getItem(this.persistence); if (dehydrated) { const rehydrated = JSON.parse(dehydrated) as unknown; - this.state = merge(this.state, rehydrated); + data = merge(data, rehydrated); } } - this.initial = this.state; + this.initialized = true; + this._initial = data; + this._state = data; } private dispatch() { @@ -46,11 +71,26 @@ export class Varuna { this.set(produce(this.state, mutator)); } + private assertInitialized() { + if (!this.initialized) throw new Error('State used before initialization'); + } + + get state() { + this.assertInitialized(); + return this._state as Type; + } + set(state: Type) { - this.state = state; + this.assertInitialized(); + this._state = state; this.dispatch(); } + get initial() { + this.assertInitialized(); + return this._initial as Type; + } + use( slicer: (state: Type) => Slice = (state) => state as unknown as Slice, ) { diff --git a/packages/website/src/components/Armor/components/ArmorPlateDisplay.tsx b/packages/website/src/components/Armor/components/ArmorPlateDisplay.tsx index d3286e8d6..030a99f58 100644 --- a/packages/website/src/components/Armor/components/ArmorPlateDisplay.tsx +++ b/packages/website/src/components/Armor/components/ArmorPlateDisplay.tsx @@ -21,11 +21,9 @@ export function ArmorPlateDisplay() { const highlightArmor = TankopediaEphemeral.use( (state) => state.highlightArmor, ); - const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const developerMode = App.useDeferred((state) => state.developerMode, false); const duelStore = Duel.useStore(); const input = useRef(null); - const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); if (highlightArmor === undefined) return null; @@ -95,7 +93,7 @@ export function ArmorPlateDisplay() { if (event.key === 'Enter') { input.current?.blur(); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.highlightArmor = undefined; const { protagonist } = duelStore.getState(); const tank = draft.model; @@ -119,7 +117,7 @@ export function ArmorPlateDisplay() { }); } else if (event.key === 'Escape') { input.current?.blur(); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.highlightArmor = undefined; }); } @@ -137,7 +135,7 @@ export function ArmorPlateDisplay() { mt="2" color="red" onClick={() => { - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { if (!highlightArmor.editingPlate) return; const index = resolveArmorIndex(highlightArmor.name); @@ -145,8 +143,7 @@ export function ArmorPlateDisplay() { if (index === undefined) return; draft.highlightArmor = undefined; - const initialState = - tankopediaEphemeralStore.getInitialState(); + const initialState = TankopediaEphemeral.initial; const { protagonist } = duelStore.getState(); const tank = draft.model; const initialTank = initialState.model; diff --git a/packages/website/src/components/Armor/components/PrimaryArmorSceneComponent/index.tsx b/packages/website/src/components/Armor/components/PrimaryArmorSceneComponent/index.tsx index 19529103e..60e5e3932 100644 --- a/packages/website/src/components/Armor/components/PrimaryArmorSceneComponent/index.tsx +++ b/packages/website/src/components/Armor/components/PrimaryArmorSceneComponent/index.tsx @@ -43,7 +43,6 @@ export function PrimaryArmorSceneComponent({ }: PrimaryArmorSceneComponentProps) { const tankopediaPersistentStore = TankopediaPersistent.useStore(); const duelStore = Duel.useStore(); - const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); const material = new ShaderMaterial({ fragmentShader, vertexShader, @@ -82,7 +81,7 @@ export function PrimaryArmorSceneComponent({ useEffect(() => { function handleShellChange() { const duel = duelStore.getState(); - const tankopediaEphemeral = tankopediaEphemeralStore.getState(); + const tankopediaEphemeral = TankopediaEphemeral.state; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; material.uniforms.caliber.value = shell.caliber; @@ -136,7 +135,7 @@ export function PrimaryArmorSceneComponent({ noInvalidate = false, ) { const duel = duelStore.getState(); - const tankopediaEphemeral = tankopediaEphemeralStore.getState(); + const tankopediaEphemeral = TankopediaEphemeral.state; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const penetration = shell.penetration.near; const hasCalibratedShells = hasEquipment( @@ -168,10 +167,7 @@ export function PrimaryArmorSceneComponent({ const unsubscribes = [ duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), - tankopediaEphemeralStore.subscribe( - (state) => state.customShell, - handleShellChange, - ), + TankopediaEphemeral.on((state) => state.customShell, handleShellChange), tankopediaPersistentStore.subscribe( (state) => state.greenPenetration, handleGreenPenetrationChange, diff --git a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx index b17c81d08..25ca146ca 100644 --- a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx +++ b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubExternal/index.tsx @@ -29,7 +29,6 @@ export function SpacedArmorSubExternal({ variant, clip, }: SpacedArmorSubExternalProps) { - const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); const duelStore = Duel.useStore(); const material = new ShaderMaterial({ fragmentShader, @@ -50,7 +49,7 @@ export function SpacedArmorSubExternal({ useEffect(() => { function handleShellChange() { const duel = duelStore.getState(); - const tankopediaEphemeral = tankopediaEphemeralStore.getState(); + const tankopediaEphemeral = TankopediaEphemeral.state; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; material.uniforms.penetration.value = shell.penetration.near; } @@ -67,7 +66,7 @@ export function SpacedArmorSubExternal({ } function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { const duel = duelStore.getState(); - const tankopediaEphemeral = tankopediaEphemeralStore.getState(); + const tankopediaEphemeral = TankopediaEphemeral.state; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const penetration = shell.penetration.near; const hasCalibratedShells = hasEquipment( @@ -91,10 +90,7 @@ export function SpacedArmorSubExternal({ const unsubscribes = [ duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), - tankopediaEphemeralStore.subscribe( - (state) => state.customShell, - handleShellChange, - ), + TankopediaEphemeral.on((state) => state.customShell, handleShellChange), duelStore.subscribe( (state) => state.protagonist.equipmentMatrix, handleProtagonistEquipmentChange, diff --git a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx index a6a1dda9f..3a94ac18e 100644 --- a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx +++ b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/components/SpacedArmorSubSpaced/index.tsx @@ -30,7 +30,6 @@ export function SpacedArmorSubSpaced({ node, thickness, }: SpacedArmorSubSpacedProps) { - const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); const duelStore = Duel.useStore(); const material = new ShaderMaterial({ fragmentShader, @@ -52,7 +51,7 @@ export function SpacedArmorSubSpaced({ useEffect(() => { function handleShellChange() { const duel = duelStore.getState(); - const tankopediaEphemeral = tankopediaEphemeralStore.getState(); + const tankopediaEphemeral = TankopediaEphemeral.state; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; material.uniforms.penetration.value = shell.penetration.near; @@ -77,7 +76,7 @@ export function SpacedArmorSubSpaced({ } function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { const duel = duelStore.getState(); - const tankopediaEphemeral = tankopediaEphemeralStore.getState(); + const tankopediaEphemeral = TankopediaEphemeral.state; const shell = tankopediaEphemeral.customShell ?? duel.antagonist.shell; const penetration = shell.penetration.near; const hasCalibratedShells = hasEquipment( @@ -101,10 +100,7 @@ export function SpacedArmorSubSpaced({ const unsubscribes = [ duelStore.subscribe((state) => state.antagonist.shell, handleShellChange), - tankopediaEphemeralStore.subscribe( - (state) => state.customShell, - handleShellChange, - ), + TankopediaEphemeral.on((state) => state.customShell, handleShellChange), duelStore.subscribe( (state) => state.protagonist.equipmentMatrix, handleProtagonistEquipmentChange, diff --git a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx index da9a781e2..94f6a5e5f 100644 --- a/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx +++ b/packages/website/src/components/Armor/components/SpacedArmorSceneComponent/index.tsx @@ -76,7 +76,6 @@ export function SpacedArmorSceneComponent({ clip, ...props }: SpacedArmorSceneComponentProps) { - const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); const duelStore = Duel.useStore(); const camera = useThree((state) => state.camera); @@ -88,7 +87,7 @@ export function SpacedArmorSceneComponent({ remainingPenetrationInput?: number, ) => { const { antagonist, protagonist } = duelStore.getState(); - const { customShell } = tankopediaEphemeralStore.getState(); + const { customShell } = TankopediaEphemeral.state; const shell = customShell ?? antagonist.shell; const cameraNormal = camera.position.clone().sub(point).normalize(); const shot: Shot = { @@ -391,7 +390,9 @@ export function SpacedArmorSceneComponent({ const shot = shoot(event.point, event.intersections, true)!; - tankopediaEphemeralStore.setState({ shot }); + TankopediaEphemeral.mutate((draft) => { + draft.shot = shot; + }); }} /> ); diff --git a/packages/website/src/components/Armor/components/StaticArmor.tsx b/packages/website/src/components/Armor/components/StaticArmor.tsx index e2da51e4b..3f66093bf 100644 --- a/packages/website/src/components/Armor/components/StaticArmor.tsx +++ b/packages/website/src/components/Armor/components/StaticArmor.tsx @@ -46,7 +46,6 @@ export const StaticArmor = memo(({ thicknessRange }) => { const turretOrigin = correctZYTuple(tankModelDefinition.turret_origin); const gunOrigin = correctZYTuple(turretModelDefinition.gun_origin); const canvas = useThree((state) => state.gl.domElement); - const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const duelStore = Duel.useStore(); const maskOrigin = gunModelDefinition.mask === undefined @@ -155,10 +154,10 @@ export const StaticArmor = memo(({ thicknessRange }) => { position.set(event.clientX, event.clientY); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.controlsEnabled = false; }); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.shot = undefined; draft.highlightArmor = undefined; }); @@ -194,7 +193,7 @@ export const StaticArmor = memo(({ thicknessRange }) => { modelTransformEvent.dispatch({ pitch, yaw }); } function handlePointerUp() { - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.controlsEnabled = true; }); window.removeEventListener('pointermove', handlePointerMove); @@ -245,10 +244,10 @@ export const StaticArmor = memo(({ thicknessRange }) => { function onPointerDown(event: ThreeEvent) { event.stopPropagation(); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.controlsEnabled = false; }); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.shot = undefined; draft.highlightArmor = undefined; }); @@ -287,7 +286,7 @@ export const StaticArmor = memo(({ thicknessRange }) => { modelTransformEvent.dispatch({ pitch, yaw }); } function handlePointerUp() { - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.controlsEnabled = true; }); window.removeEventListener('pointermove', handlePointerMove); @@ -327,10 +326,10 @@ export const StaticArmor = memo(({ thicknessRange }) => { function onPointerDown(event: ThreeEvent) { event.stopPropagation(); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.controlsEnabled = false; }); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.shot = undefined; draft.highlightArmor = undefined; }); @@ -368,7 +367,7 @@ export const StaticArmor = memo(({ thicknessRange }) => { modelTransformEvent.dispatch({ pitch, yaw }); } function handlePointerUp() { - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.controlsEnabled = true; }); diff --git a/packages/website/src/components/Armor/components/StaticArmorSceneComponent.tsx b/packages/website/src/components/Armor/components/StaticArmorSceneComponent.tsx index 0002a473e..58f5b3b8e 100644 --- a/packages/website/src/components/Armor/components/StaticArmorSceneComponent.tsx +++ b/packages/website/src/components/Armor/components/StaticArmorSceneComponent.tsx @@ -69,8 +69,6 @@ export function StaticArmorSceneComponent({ onPointerDown, ...props }: StaticArmorSceneComponentProps) { - const mutateTankopediaEphemeralStore = TankopediaEphemeral.useMutation(); - const tankopediaEphemeralStore = TankopediaEphemeral.useStore(); const camera = useThree((state) => state.camera); const x = thickness / thicknessRange.value; const xClamped = clamp(x, 0, 1); @@ -166,7 +164,7 @@ export function StaticArmorSceneComponent({ } const unsubscribes = [ - tankopediaEphemeralStore.subscribe( + TankopediaEphemeral.on( (state) => state.highlightArmor?.name, handleHighlightArmor, ), @@ -238,7 +236,7 @@ export function StaticArmorSceneComponent({ event.stopPropagation(); - const { editStatic } = tankopediaEphemeralStore.getState(); + const { editStatic } = TankopediaEphemeral.state; const bounds = new Box3().setFromObject(event.object); const point = bounds.min @@ -259,7 +257,7 @@ export function StaticArmorSceneComponent({ const thicknessAngled = thickness / Math.sin(Math.PI / 2 - angle); - mutateTankopediaEphemeralStore((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.highlightArmor = { type: props.type, name, diff --git a/packages/website/src/components/GuessBackground/index.tsx b/packages/website/src/components/GuessBackground/index.tsx index 443a98690..b71d56e96 100644 --- a/packages/website/src/components/GuessBackground/index.tsx +++ b/packages/website/src/components/GuessBackground/index.tsx @@ -18,13 +18,12 @@ export function GuessBackground() { const guessState = GuessEphemeral.use((state) => state.guessState); const tank = GuessEphemeral.use((state) => state.tank); const mutateDuel = Duel.useMutation(); - const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); useEffect(() => { mutateDuel((state) => { state.protagonist = tankToDuelMember(tank, provisionDefinitions); }); - mutateTankopediaEphemeral((state) => { + TankopediaEphemeral.mutate((state) => { state.model = modelDefinitions.models[tank.id]; }); }, [tank]); diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/CompareOptions.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/CompareOptions.tsx index 40a142671..a2324d8dc 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/CompareOptions.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/CompareOptions.tsx @@ -13,7 +13,6 @@ export function CompareOptions(props: FlexProps) { (state) => state.relativeAgainst, ); const tank = Duel.use((state) => state.protagonist.tank); - const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const { strings } = useLocale(); return ( @@ -33,7 +32,7 @@ export function CompareOptions(props: FlexProps) { size="1" value={`${relativeAgainst}`} onValueChange={(value) => { - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.relativeAgainst = Number(value) as TankopediaRelativeAgainst; }); }} diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/StatSearch.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/StatSearch.tsx index e6a0c4ad5..08edfb622 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/StatSearch.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/StatSearch.tsx @@ -10,7 +10,6 @@ export function StatSearch(props: FlexProps) { const { strings } = useLocale(); const [searching, setSearching] = useState(false); const input = useRef(null); - const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation(); const count = useRef(null); const [index, setIndex] = useState(0); const search = useCallback(() => { @@ -34,14 +33,14 @@ export function StatSearch(props: FlexProps) { count.current.textContent = ''; setSearching(false); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.statSearch = undefined; }); return; } setSearching(false); - mutateTankopediaEphemeral((draft) => { + TankopediaEphemeral.mutate((draft) => { draft.statSearch = input.current!.value; }); }, 500), diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Skills.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Skills.tsx index 45f488ecf..3a92e682b 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Skills.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Skills.tsx @@ -14,7 +14,6 @@ import { ConfigurationChildWrapper } from './ConfigurationChildWrapper'; export function Skills() { const skillLevels = TankopediaEphemeral.use((state) => state.skills); - const mutateTankopediaTemporary = TankopediaEphemeral.useMutation(); const { strings } = useLocale(); return ( @@ -46,7 +45,7 @@ export function Skills() { variant="ghost" color="red" onClick={() => { - mutateTankopediaTemporary((draft) => { + TankopediaEphemeral.mutate((draft) => { Object.keys(draft.skills).forEach((skill) => { draft.skills[skill] = 0; }); @@ -58,7 +57,7 @@ export function Skills() {