Skip to content

Instantly share code, notes, and snippets.

@Yiin
Last active March 8, 2024 21:38
Show Gist options
  • Save Yiin/f167fafb4220125fc7f6f7b93c7c2be5 to your computer and use it in GitHub Desktop.
Save Yiin/f167fafb4220125fc7f6f7b93c7c2be5 to your computer and use it in GitHub Desktop.

Requirements

  1. JS/TS server setup
  2. Vue.js in webviews
  3. Ability to ignore social norms

Steps

  1. Install following packages:

    lodash-es pinia vue @vue/reactivity

  2. Use following structure for our server files:

    src/
      server/
      	player.store.ts
      client/
      	player.store.ts
      webview/
      	player.store.ts
      shared/
      	player.store.ts
      	store.ts
    
  3. Setup shared/store.ts

    import { get, set, isEqual } from "lodash-es";
    import { Store } from "pinia";
    import {
      TriggerOpTypes,
      toRaw,
      isRef,
      isReactive,
      isProxy,
      DebuggerEvent
    } from "@vue/reactivity";
    
    export type StoreUpdatePayload =
      | {
        type: TriggerOpTypes.ADD;
        path: string | undefined;
        target: object;
      }
      | {
        type: TriggerOpTypes.SET;
        path: string | undefined;
        key: any;
        newValue: any;
      }
      | {
        type: TriggerOpTypes.DELETE;
        path: string | undefined;
        key: any;
      }
      | {
        type: TriggerOpTypes.CLEAR;
        path: string | undefined;
      };
    
    export function subscribeToStore<T extends Store>(
      store: T,
      {
        onSetState,
        onUpdateState,
      }: {
        onSetState: (state: any) => void;
        onUpdateState: (payload: StoreUpdatePayload) => void;
      }
    ) {
      onSetState(toRaw(store.$state));
    
      return store.$subscribe(
        (mutation, state) => {
          if (!mutation.events) {
            onSetState(toRaw(state));
            return;
          }
          const events = Array.isArray(mutation.events) ? mutation.events : [mutation.events];
    
          for (const event of events) {
            const path = findPath(toRaw(state), event.target)?.join(".") ?? findPathApproximate(toRaw(state), event.target)?.join(".");
    
            const { type, target, key, newValue } = event;
    
            let payload;
            switch (type) {
              case "add":
                payload = { type, path, target: deepToRaw(target) };
                break;
              case "set":
                payload = { type, path, key, newValue: deepToRaw(newValue) };
                break;
              case "delete":
                payload = { type, path, key };
                break;
              case "clear":
                payload = { type, path };
                break;
            }
            if (payload) {
              onUpdateState(payload);
            }
          }
        },
        { immediate: true, flush: "sync" }
      );
    }
    
    export function updateStoreState<S extends Store>(store: S, event: StoreUpdatePayload) {
      switch (event.type) {
        case "add": {
          const { path, target } = event;
    
          if (path) {
            set(store.$state, path, target);
          }
          break;
        }
        case "set": {
          const { path, key, newValue } = event;
    
          set(store.$state, `${path ? path + "." : ""}${key}`, newValue);
          break;
        }
        case "delete": {
          const { path, key } = event;
    
          if (path) {
            if (get(store.$state, path) instanceof Set) {
              get(store.$state, path)?.delete(key);
            } else {
              delete get(store.$state, path)[key];
            }
          } else {
            delete (store.$state as any)[key];
          }
          break;
        }
        case "clear": {
          const { path } = event;
    
          if (path) {
            get(store.$state, path)?.clear();
          }
          break;
        }
      }
    }
    
    export function deepToRaw<T extends Record<string, any>>(sourceObj: T): T {
      const objectIterator = (input: any): any => {
        if (Array.isArray(input)) {
          return input.map((item) => objectIterator(item));
        } if (isRef(input) || isReactive(input) || isProxy(input)) {
          return objectIterator(toRaw(input));
        } if (input && typeof input === 'object' && (input.constructor === Object || input.constructor === null)) {
          return Object.keys(input).reduce((acc, key) => {
            acc[key as keyof typeof acc] = objectIterator(input[key]);
            return acc;
          }, {} as T);
        }
        return input;
      };
    
      return objectIterator(sourceObj);
    }
    
    export function findPath(
      obj: any,
      value: any,
      path: string[] = []
    ): string[] | null {
      if (obj === value) {
        return path;
      }
      if (typeof obj === "object") {
        for (const key in obj) {
          const result = findPath(obj[key], value, path.concat(key));
          if (result) {
            return result;
          }
        }
      }
      return null;
    }
    
    export function findPathApproximate(
      obj: any,
      value: any,
      path: string[] = []
    ): string[] | null {
      if (isEqual(obj, value)) {
        return path;
      }
      if (typeof obj === "object") {
        for (const key in obj) {
          const result = findPathApproximate(obj[key], value, path.concat(key));
          if (result) {
            return result;
          }
        }
      }
      return null;
    }
  4. Setup shared/player.store.ts

    export type PlayerState = {
      cash: number;
      items: string[];
    };
    
    export const getDefaultPlayerState = (): PlayerState => ({
      cash: 0,
      items: [],
    });
  5. Setup server/player.store.ts

    import alt from "alt-server";
    import { createPinia, defineStore, Pinia } from "pinia";
    import { getDefaultPlayerState } from "../shared/player.store";
    
    declare module "alt-server" {
        export interface Player {
            pinia: Pinia;
            state: ReturnType<typeof usePlayerState>;
            unsubscribePlayerState: () => void;
        }
    }
    
    export const usePlayerState = defineStore("player-state", {
      state: getDefaultPlayerState,
    });
    
    alt.on("playerConnect", (player) => {
      player.pinia = createPinia();
      player.state = usePlayerState(player.pinia);
    
      player.unsubscribePlayerState = subscribeToStore(player.state, {
        onSetState: (state) => {
          this.emitRaw("ClientEvents.FromServer.SET_PLAYER_STATE", state);
        },
        onUpdateState: (payload) => {
          this.emitRaw("ClientEvents.FromServer.UPDATE_PLAYER_STATE", payload);
        },
      });
    });
    
    alt.on("playerDisconnect", (player) => {
        player.unsubscribePlayerState?.();
    });
  6. Setup client/player.store.ts

    import alt from "alt-client";
    import { defineStore } from "pinia";
    import { updateStoreState } from "../shared/store";
    import { getDefaultPlayerState } from "../shared/player.store";
    import { pinia } from ".";
    
    const usePlayerState = defineStore("player-state", {
      state: getDefaultPlayerState,
    });
    
    export const playerState = usePlayerState(pinia);
    
    const webview = new alt.Webview("<setup your webview>");
    
    alt.onServer("ClientEvents.FromServer.UPDATE_PLAYER_STATE", (event: any) => {
      webview.emitRaw("WebviewEvents.FromClient.UPDATE_PLAYER_STATE", event);
    
      updateStoreState(playerState, event);
    });
    
    alt.onServer("ClientEvents.FromServer.SET_PLAYER_STATE", (state: any) => {
      webview.emitRaw("WebviewEvents.FromClient.SET_PLAYER_STATE", state);
    
      playerState.$state = state;
    });
  7. Setup webview/player.store.ts

    import { defineStore } from "pinia";
    import { updateStoreState } from "../shared/store";
    import { getDefaultPlayerState } from "../shared/player.store";
    
    export const usePlayerState = defineStore("player-state", {
      state: getDefaultPlayerState,
    });
    
    alt.on("WebviewEvents.FromClient.SET_PLAYER_STATE", (state: any) => {
      try {
        playerStateStore.$state = state;
      } catch (e) {
        console.error("Failed to set player state:", e);
      }
    });
    
    alt.on("WebviewEvents.FromClient.UPDATE_PLAYER_STATE", (event: any) => {
      try {
        updateStoreState(playerStateStore, event);
      } catch (e) {
        console.error("Failed to update player state:", e);
      }
    });

Fun part: usage

Now whenever you change anything on the player.state, the update to that state will be propagated to both client and webview, removing the need to call the events to update the data manually. So e.g. you can show how much cash player has in the UI and items in some window, and when you simply modify player.state.cash += 500, both UI and state on client will have updated cash value w/o a need for you to do anything. This approach also allows using watchers & effects from vue composable api, e.g. on the client you can do:

import alt from "alt-client";
import { playerState } from "./player.store";
import { watch } from "vue";

watch(() => player.state.cash, (newValue, oldValue) => {
  alt.log(`Player cash changed from ${oldValue} to ${newValue}!`);
});

and it will just work :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment