diff --git a/docs/api/client/system/useStreamSyncedGetter.md b/docs/api/client/system/useStreamSyncedGetter.md new file mode 100644 index 000000000..09334914e --- /dev/null +++ b/docs/api/client/system/useStreamSyncedGetter.md @@ -0,0 +1,17 @@ +# useStreamSyncedGetter + +When using the [Stream Synced Binder](../../server/systems/useStreamSyncedBinder.md), this allows you to get type safe responses on client-side. + +```ts +import * as alt from 'alt-client'; +import { useRebarClient } from '../../../main/client/index.js'; + +const RebarClient = useRebarClient(); + +const streamGetter = RebarClient.systems.useStreamSyncedGetter(); + +const money = streamGetter.player(alt.Player.local).get('money'); +const name = streamGetter.player(alt.Player.local).get('name'); + +const fuel = streamGetter.vehicle(someVehicle).get('fuel'); +``` diff --git a/docs/api/server/systems/useStreamSyncedBinder.md b/docs/api/server/systems/useStreamSyncedBinder.md new file mode 100644 index 000000000..aa0f6e0e0 --- /dev/null +++ b/docs/api/server/systems/useStreamSyncedBinder.md @@ -0,0 +1,21 @@ +# useStreamSyncedBinder + +What this allows you to do is target a specific document field from a `Character` or `Vehicle` document and automatically synchronize the document data to client-side through `streamSyncedMeta`. + +Whenever the value changes, it automatically updates for a player or vehicle. + +For instance, if specify the propery `money` and `money` is updated for the player, it will be available on client-side, and in the webview. + +```ts +import { useRebar } from '../../../main/server/index.js'; + +const Rebar = useRebar(); +const SyncedBinder = Rebar.systems.useStreamSyncedBinder(); + +// Bind character document data +SyncedBinder.syncCharacterKey('money'); +SyncedBinder.syncCharacterKey('name'); + +// Bind vehicle document data +SyncedBinder.syncVehicleKey('fuel'); +``` diff --git a/docs/changelog.md b/docs/changelog.md index 8c934b583..fa2bc1794 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,19 @@ # Changelog +## Version 41 + +### Code Changes + +- Added `useStreamSyncedBinder` to automatically synchronize document data from server to client for vehicles and characters +- Added `useSyncedMeta` composable to the webview, to get data synced from `useStreamSyncedBinder` +- Added `useStreamSyncedGetter` to client-side to get type safe responses for stream synced meta data + +### Docs Changes + +- Documented `useStreamSyncedBinder`, `useSyncedMeta`, and `useStreamSyncedGetter` + +--- + ## Version 40 ### Code Changes diff --git a/docs/webview/composables/use-synced-meta.md b/docs/webview/composables/use-synced-meta.md new file mode 100644 index 000000000..ea1bdfbc7 --- /dev/null +++ b/docs/webview/composables/use-synced-meta.md @@ -0,0 +1,23 @@ +# useSyncedMeta + +When using the [Stream Synced Binder](../../api/server/systems/useStreamSyncedBinder.md), this webview component allows you to get that data as vue refs. + +```jsx + + + +``` diff --git a/package.json b/package.json index 669edf084..596f944a9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "author": "stuyk", "type": "module", - "version": "40", + "version": "41", "scripts": { "dev": "nodemon -x pnpm start", "dev:linux": "nodemon -x pnpm start:linux", @@ -40,7 +40,7 @@ "mongodb": "^6.7.0", "sjcl": "^1.0.8", "typescript": "^5.5.2", - "vite": "^5.3.1", + "vite": "^5.3.2", "vue": "^3.4.30", "vue-tsc": "^2.0.22" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01ead7cd3..472ca856f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@vitejs/plugin-vue': specifier: ^5.0.5 - version: 5.0.5(vite@5.3.1(@types/node@20.14.9))(vue@3.4.30(typescript@5.5.2)) + version: 5.0.5(vite@5.3.2(@types/node@20.14.9))(vue@3.4.30(typescript@5.5.2)) dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -24,8 +24,8 @@ importers: specifier: ^5.5.2 version: 5.5.2 vite: - specifier: ^5.3.1 - version: 5.3.1(@types/node@20.14.9) + specifier: ^5.3.2 + version: 5.3.2(@types/node@20.14.9) vue: specifier: ^3.4.30 version: 3.4.30(typescript@5.5.2) @@ -539,8 +539,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001637: - resolution: {integrity: sha512-1x0qRI1mD1o9e+7mBI7XtzFAP4XszbHaVWsMiGbSPLYekKTJF7K+FNk6AsXH4sUpc+qrsI3pVgf1Jdl/uGkuSQ==} + caniuse-lite@1.0.30001638: + resolution: {integrity: sha512-5SuJUJ7cZnhPpeLHaH0c/HPAnAHZvS6ElWyHK9GSIbVOQABLzowiI2pjmpvZ1WEbkyz46iFd4UXlOHR5SqgfMQ==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1265,8 +1265,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@5.3.1: - resolution: {integrity: sha512-XBmSKRLXLxiaPYamLv3/hnP/KXDai1NDexN0FpkTaZXTfycHvkRHoenpgl/fvuK/kPbB6xAgoyiryAhQNxYmAQ==} + vite@5.3.2: + resolution: {integrity: sha512-6lA7OBHBlXUxiJxbO5aAY2fsHHzDr1q7DvXYnyZycRs2Dz+dXBWuhpWHvmljTRTpQC2uvGmUFFkSHF2vGo90MA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -1573,9 +1573,9 @@ snapshots: dependencies: '@types/webidl-conversions': 7.0.3 - '@vitejs/plugin-vue@5.0.5(vite@5.3.1(@types/node@20.14.9))(vue@3.4.30(typescript@5.5.2))': + '@vitejs/plugin-vue@5.0.5(vite@5.3.2(@types/node@20.14.9))(vue@3.4.30(typescript@5.5.2))': dependencies: - vite: 5.3.1(@types/node@20.14.9) + vite: 5.3.2(@types/node@20.14.9) vue: 3.4.30(typescript@5.5.2) '@volar/language-core@2.3.4': @@ -1693,7 +1693,7 @@ snapshots: autoprefixer@10.4.19(postcss@8.4.38): dependencies: browserslist: 4.23.1 - caniuse-lite: 1.0.30001637 + caniuse-lite: 1.0.30001638 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.1 @@ -1724,7 +1724,7 @@ snapshots: browserslist@4.23.1: dependencies: - caniuse-lite: 1.0.30001637 + caniuse-lite: 1.0.30001638 electron-to-chromium: 1.4.812 node-releases: 2.0.14 update-browserslist-db: 1.0.16(browserslist@4.23.1) @@ -1733,7 +1733,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001637: {} + caniuse-lite@1.0.30001638: {} chalk@4.1.2: dependencies: @@ -2384,7 +2384,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@5.3.1(@types/node@20.14.9): + vite@5.3.2(@types/node@20.14.9): dependencies: esbuild: 0.21.5 postcss: 8.4.38 diff --git a/src/main/client/index.ts b/src/main/client/index.ts index dbbda2b10..8032dafa2 100644 --- a/src/main/client/index.ts +++ b/src/main/client/index.ts @@ -23,6 +23,7 @@ import { useRaycast } from './system/raycasts.js'; import { isWorldMenuOpen, useWorldMenu } from './menus/world/index.js'; import { drawText2D, drawText3D } from './screen/textlabel.js'; import { draw, drawSimple } from './screen/marker.js'; +import { useStreamSyncedGetter } from './system/streamSyncedGetter.js'; export function useRebarClient() { return { @@ -62,7 +63,10 @@ export function useRebarClient() { drawSimple, }, }, - useProxyFetch, + systems: { + useStreamSyncedGetter, + useProxyFetch, + }, utility: { math, text, diff --git a/src/main/client/system/streamSyncedGetter.ts b/src/main/client/system/streamSyncedGetter.ts new file mode 100644 index 000000000..60fe437b2 --- /dev/null +++ b/src/main/client/system/streamSyncedGetter.ts @@ -0,0 +1,52 @@ +import * as alt from 'alt-client'; +import { Vehicle } from '../../shared/types/vehicle.js'; +import { Character } from '@Shared/types/character.js'; + +export function useStreamSyncedGetter() { + /** + * Get synced data from `streamedSyncedBinder` if it is available for a given vehicle + * + * @param {alt.Vehicle} vehicle + * @return + */ + function vehicle(vehicle: alt.Vehicle) { + function has(key: K): boolean { + return vehicle.hasStreamSyncedMeta(key); + } + + function get(key: K): Vehicle[K] { + return vehicle.getStreamSyncedMeta(key) as Vehicle[K]; + } + + return { + has, + get, + }; + } + + /** + * Get synced data from `streamedSyncedBinder` if it is available for a given player + * + * @param {alt.Player} player + * @return + */ + function player(player: alt.Player) { + function has(key: K): boolean { + return player.hasStreamSyncedMeta(key); + } + + function get(key: K): Character[K] { + return player.getStreamSyncedMeta(key) as Character[K]; + } + + return { + has, + get, + }; + } + + return { + player, + vehicle, + }; +} diff --git a/src/main/client/webview/index.ts b/src/main/client/webview/index.ts index e643e594c..db6f5bbf0 100644 --- a/src/main/client/webview/index.ts +++ b/src/main/client/webview/index.ts @@ -324,6 +324,42 @@ export function useWebview(path = 'http://assets/webview/index.html') { onCloseEvents[pageName].push(callback); } + function onSyncedMetaChange(object: alt.Object, key: string, newValue: any) { + if (native.isEntityAPed(object.scriptID)) { + webview.emit(Events.view.syncPartialCharacter, key, newValue); + return; + } + + if (!alt.Player.local.vehicle) { + return; + } + + if (!native.isEntityAVehicle(object.scriptID)) { + return; + } + + if (alt.Player.local.vehicle.scriptID !== object.scriptID) { + return; + } + + webview.emit(Events.view.syncPartialVehicle, key, newValue); + } + + function onVehicleEnter(vehicle: alt.Vehicle) { + const keys = vehicle.getStreamSyncedMetaKeys(); + const data = {}; + + for (let key of keys) { + data[key] = vehicle.getStreamSyncedMeta(key); + } + + webview.emit(Events.view.syncVehicle, data); + } + + function onVehicleLeave() { + webview.emit(Events.view.syncVehicle, {}); + } + if (!isInitialized) { alt.onServer(Events.view.focus, focus); alt.onServer(Events.view.unfocus, unfocus); @@ -337,6 +373,9 @@ export function useWebview(path = 'http://assets/webview/index.html') { webview.on(Events.view.emitServer, handleServerEvent); webview.on(Events.view.emitServerRpc, handleServerRpcEvent); webview.on(Events.view.emitClientRpc, handleClientRpcEvent); + alt.on('streamSyncedMetaChange', onSyncedMetaChange); + alt.on('enteredVehicle', onVehicleEnter); + alt.on('leftVehicle', onVehicleLeave); } return { diff --git a/src/main/server/index.ts b/src/main/server/index.ts index ded0a3838..3e34a1545 100644 --- a/src/main/server/index.ts +++ b/src/main/server/index.ts @@ -75,6 +75,7 @@ import * as ClothingUtility from '@Shared/data/clothing.js'; import { useScreenshot } from './systems/screenshot.js'; import { useKeypress } from './systems/serverKeypress.js'; import { useD2DTextLabel, useD2DTextLabelLocal } from './controllers/d2dTextLabel.js'; +import { useStreamSyncedBinder } from './systems/streamSyncedBinder.js'; export function useRebar() { return { @@ -165,6 +166,15 @@ export function useRebar() { useServerConfig, useServerTime, useServerWeather, + systems: { + useStreamSyncedBinder, + useKeybinder, + useKeypress, + useProxyFetch, + useServerConfig, + useServerTime, + useServerWeather, + }, utility: { clothing: { ...ClothingUtility }, sha256, @@ -183,6 +193,8 @@ export function useRebar() { }; } +useRebar().useKeybinder; + declare module 'alt-server' { // extending interface by interface merging export interface ICustomGlobalMeta { diff --git a/src/main/server/systems/streamSyncedBinder.ts b/src/main/server/systems/streamSyncedBinder.ts new file mode 100644 index 000000000..2718c13bb --- /dev/null +++ b/src/main/server/systems/streamSyncedBinder.ts @@ -0,0 +1,75 @@ +import * as alt from 'alt-server'; +import { Character, Vehicle } from '@Shared/types/index.js'; +import { useRebar } from '../index.js'; + +type DataTypes = { + Character: keyof Character; + Vehicle: keyof Vehicle; +}; + +const Rebar = useRebar(); +const RebarEvents = Rebar.events.useEvents(); + +const keys: { [K in keyof DataTypes]: DataTypes[K][] } = { + Character: [], + Vehicle: [], +}; + +function handleKeySet(entity: alt.Entity, key: string, newValue: any) { + entity.setStreamSyncedMeta(key, newValue); +} + +export function useStreamSyncedBinder() { + /** + * Automatically synchronize a character document property to the attached player + * + * @template K + * @param {K} key + * @return + */ + function syncCharacterKey(key: K) { + const index = keys.Character.findIndex((x) => x === key); + if (index >= 0) { + return; + } + + keys.Character.push(key); + Rebar.document.character + .useCharacterEvents() + .on(key, (entity, newValue) => handleKeySet(entity, key, newValue)); + } + + /** + * Automatically synchronize a vehicle document property to the attached vehicle + * + * @template K + * @param {K} key + * @return + */ + function syncVehicleKey(key: K) { + const index = keys.Vehicle.findIndex((x) => x === key); + if (index >= 0) { + return; + } + + keys.Vehicle.push(key); + Rebar.document.vehicle.useVehicleEvents().on(key, (entity, newValue) => handleKeySet(entity, key, newValue)); + } + + return { + syncCharacterKey, + syncVehicleKey, + }; +} + +RebarEvents.on('character-bound', (player, document) => { + for (let key of keys.Character) { + handleKeySet(player, key, document[key]); + } +}); + +RebarEvents.on('vehicle-bound', (vehicle, document) => { + for (let key of keys.Vehicle) { + handleKeySet(vehicle, key, document[key]); + } +}); diff --git a/src/main/shared/events/index.ts b/src/main/shared/events/index.ts index 737c33bac..c8bb91ad1 100644 --- a/src/main/shared/events/index.ts +++ b/src/main/shared/events/index.ts @@ -163,5 +163,9 @@ export const Events = { localStorageDelete: 'webview:localstorage:delete', onPageClose: 'webview:page:close', onPageOpen: 'webview:page:open', + syncCharacter: 'webview:sync:character', + syncPartialCharacter: 'webview:sync:partial:character', + syncVehicle: 'webview:sync:vehicle', + syncPartialVehicle: 'webview:sync:partial:vehicle', }, }; diff --git a/webview/composables/useSyncedMeta.ts b/webview/composables/useSyncedMeta.ts new file mode 100644 index 000000000..19918dbbe --- /dev/null +++ b/webview/composables/useSyncedMeta.ts @@ -0,0 +1,51 @@ +import { Events } from '../../src/main/shared/events'; +import { Ref, ref } from 'vue'; +import { Vehicle } from '../../src/main/shared/types/vehicle.js'; +import { Character } from '../../src/main/shared/types/character.js'; + +let isInit = false; + +const character = ref>({}); +const vehicle = ref>({}); + +function syncCharacterData(data: Object) { + for (let key of Object.keys(data)) { + character.value[key] = data[key]; + } +} + +function syncVehicleData(data: Object) { + for (let key of Object.keys(data)) { + vehicle.value[key] = data[key]; + } +} + +export function useSyncedMeta() { + function init() { + if (isInit) { + return; + } + + isInit = true; + if (!('alt' in window)) { + return; + } + + alt.on(Events.view.syncCharacter, syncCharacterData); + alt.on(Events.view.syncVehicle, syncVehicleData); + alt.on(Events.view.syncPartialCharacter, (key: string, data: any) => { + character.value[key] = data; + }); + alt.on(Events.view.syncPartialVehicle, (key: string, data: any) => (vehicle.value[key] = data)); + } + + return { + init, + getCharacter() { + return character as Ref; + }, + getVehicle() { + return vehicle as Ref; + }, + }; +} diff --git a/webview/src/App.vue b/webview/src/App.vue index 419cd3a1a..7950f085b 100644 --- a/webview/src/App.vue +++ b/webview/src/App.vue @@ -5,6 +5,7 @@ import { useMessenger } from '../composables/useMessenger'; import { useMinimap } from '../composables/useMinimap'; import { usePageEvents } from '../composables/usePageEvents'; import { usePages } from '../composables/usePages'; +import { useSyncedMeta } from '../composables/useSyncedMeta'; import DevelopmentBar from './components/Development.vue'; const { pagesPersistent, pagesOverlay, page } = usePages(); @@ -20,6 +21,7 @@ function handleMount() { useAudio(); useMessenger(); useMinimap().init(); + useSyncedMeta().init(); } onMounted(handleMount); diff --git a/webview/tsconfig.node.json b/webview/tsconfig.node.json index 97ede7ee6..1b1775ddf 100644 --- a/webview/tsconfig.node.json +++ b/webview/tsconfig.node.json @@ -1,11 +1,19 @@ { - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "paths": { + "@Server/*": ["main/server/*"], + "@Client/*": ["main/client/*"], + "@Shared/*": ["../main/shared/*"], + "@Plugins/*": ["plugins/*"], + "@Composables/*": ["../webview/composables/*"], + "@Components/*": ["../webview/components/*"] + } + }, + "include": ["vite.config.ts"] }