- JS/TS server setup
- Vue.js in webviews
- Ability to ignore social norms
-
Install following packages:
lodash-es pinia vue @vue/reactivity
-
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
-
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; }
-
Setup
shared/player.store.ts
export type PlayerState = { cash: number; items: string[]; }; export const getDefaultPlayerState = (): PlayerState => ({ cash: 0, items: [], });
-
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?.(); });
-
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; });
-
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); } });
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 :)