Yaca Install
This commit is contained in:
parent
b622af6e3b
commit
cc47e529cc
68 changed files with 11192 additions and 0 deletions
25
resources/[voice]/yaca-voice/apps/yaca-client/build.js
Normal file
25
resources/[voice]/yaca-voice/apps/yaca-client/build.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { build } from 'esbuild'
|
||||
|
||||
const production = process.argv.includes('--mode=production')
|
||||
|
||||
build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
outfile: './dist/client.js',
|
||||
bundle: true,
|
||||
loader: {
|
||||
'.ts': 'ts',
|
||||
'.js': 'js',
|
||||
},
|
||||
write: true,
|
||||
platform: 'browser',
|
||||
target: 'es2021',
|
||||
format: 'iife',
|
||||
minify: production,
|
||||
sourcemap: production ? false : 'inline',
|
||||
dropLabels: production ? ['DEV'] : undefined,
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Client built successfully')
|
||||
})
|
||||
// skipcq: JS-0263
|
||||
.catch(() => process.exit(1))
|
22
resources/[voice]/yaca-voice/apps/yaca-client/package.json
Normal file
22
resources/[voice]/yaca-voice/apps/yaca-client/package.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "yaca-client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.js --mode=production",
|
||||
"dev": "node build.js",
|
||||
"typecheck": "tsc --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventemitter2": "^6.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@citizenfx/client": "latest",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.16.10",
|
||||
"@yaca-voice/common": "workspace:*",
|
||||
"@yaca-voice/types": "workspace:*",
|
||||
"@yaca-voice/typescript-config": "workspace:*"
|
||||
}
|
||||
}
|
1088
resources/[voice]/yaca-voice/apps/yaca-client/pnpm-lock.yaml
generated
Normal file
1088
resources/[voice]/yaca-voice/apps/yaca-client/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,181 @@
|
|||
import { saltyChatExport, sleep } from '@yaca-voice/common'
|
||||
import { YacaPluginStates } from '@yaca-voice/types'
|
||||
import { cache } from '../utils'
|
||||
import type { YaCAClientModule } from '../yaca'
|
||||
|
||||
/**
|
||||
* The SaltyChat bridge for the client.
|
||||
*/
|
||||
export class YaCAClientSaltyChatBridge {
|
||||
private clientModule: YaCAClientModule
|
||||
|
||||
private currentPluginState = -1
|
||||
|
||||
private isPrimarySending = false
|
||||
private isSecondarySending = false
|
||||
|
||||
private isPrimaryReceiving = false
|
||||
private isSecondaryReceiving = false
|
||||
|
||||
/**
|
||||
* Creates an instance of the SaltyChat bridge.
|
||||
*
|
||||
* @param {YaCAClientModule} clientModule - The client module.
|
||||
*/
|
||||
constructor(clientModule: YaCAClientModule) {
|
||||
this.clientModule = clientModule
|
||||
|
||||
this.registerSaltyChatExports()
|
||||
this.enableRadio().then()
|
||||
|
||||
console.log('[YaCA] SaltyChat bridge loaded')
|
||||
|
||||
on('onResourceStop', (resourceName: string) => {
|
||||
if (cache.resource !== resourceName) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('onClientResourceStop', 'saltychat')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the radio on bridge load.
|
||||
*/
|
||||
async enableRadio() {
|
||||
while (!this.clientModule.isPluginInitialized(true)) {
|
||||
await sleep(1000)
|
||||
}
|
||||
|
||||
this.clientModule.radioModule.enableRadio(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register SaltyChat exports.
|
||||
*/
|
||||
registerSaltyChatExports() {
|
||||
saltyChatExport('GetVoiceRange', () => this.clientModule.getVoiceRange())
|
||||
|
||||
saltyChatExport('GetRadioChannel', (primary: boolean) => {
|
||||
const channel = primary ? 1 : 2
|
||||
|
||||
const currentFrequency = this.clientModule.radioModule.getRadioFrequency(channel)
|
||||
|
||||
if (currentFrequency === '0') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return currentFrequency
|
||||
})
|
||||
|
||||
saltyChatExport('GetRadioVolume', () => {
|
||||
return this.clientModule.radioModule.getRadioChannelVolume(1)
|
||||
})
|
||||
|
||||
saltyChatExport('GetRadioSpeaker', () => {
|
||||
console.warn('GetRadioSpeaker is not implemented in YaCA')
|
||||
return false
|
||||
})
|
||||
|
||||
saltyChatExport('GetMicClick', () => {
|
||||
console.warn('GetMicClick is not implemented in YaCA')
|
||||
return false
|
||||
})
|
||||
|
||||
saltyChatExport('SetRadioChannel', (radioChannelName: string, primary: boolean) => {
|
||||
const channel = primary ? 1 : 2
|
||||
const newRadioChannelName = radioChannelName === '' ? '0' : radioChannelName
|
||||
|
||||
this.clientModule.radioModule.changeRadioFrequencyRaw(newRadioChannelName, channel)
|
||||
})
|
||||
|
||||
saltyChatExport('SetRadioVolume', (volume: number) => {
|
||||
this.clientModule.radioModule.changeRadioChannelVolumeRaw(volume, 1)
|
||||
this.clientModule.radioModule.changeRadioChannelVolumeRaw(volume, 2)
|
||||
})
|
||||
|
||||
saltyChatExport('SetRadioSpeaker', () => {
|
||||
console.warn('SetRadioSpeaker is not implemented in YaCA')
|
||||
})
|
||||
|
||||
saltyChatExport('SetMicClick', () => {
|
||||
console.warn('SetMicClick is not implemented in YaCA')
|
||||
})
|
||||
|
||||
saltyChatExport('GetPluginState', () => {
|
||||
return this.currentPluginState
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the plugin state change.
|
||||
*
|
||||
* @param response - The last response code.
|
||||
*/
|
||||
handleChangePluginState(response: YacaPluginStates) {
|
||||
let state = 0
|
||||
|
||||
switch (response) {
|
||||
case YacaPluginStates.IN_EXCLUDED_CHANNEL:
|
||||
state = 3
|
||||
break
|
||||
case YacaPluginStates.IN_INGAME_CHANNEL:
|
||||
state = 2
|
||||
break
|
||||
case YacaPluginStates.CONNECTED:
|
||||
state = 1
|
||||
break
|
||||
case YacaPluginStates.WRONG_TS_SERVER:
|
||||
case YacaPluginStates.OUTDATED_VERSION:
|
||||
state = 0
|
||||
break
|
||||
case YacaPluginStates.NOT_CONNECTED:
|
||||
state = -1
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
emit('SaltyChat_PluginStateChanged', state)
|
||||
this.currentPluginState = state
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the radio talking state.
|
||||
*/
|
||||
sendRadioTalkingState() {
|
||||
emit('SaltyChat_RadioTrafficStateChanged', this.isPrimaryReceiving, this.isPrimarySending, this.isSecondaryReceiving, this.isSecondarySending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle radio talking state change.
|
||||
*
|
||||
* @param state - The state of the radio talking.
|
||||
* @param channel - The radio channel.
|
||||
*/
|
||||
handleRadioTalkingStateChange(state: boolean, channel: number) {
|
||||
if (channel === 1) {
|
||||
this.isPrimarySending = state
|
||||
} else {
|
||||
this.isSecondarySending = state
|
||||
}
|
||||
|
||||
this.sendRadioTalkingState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle radio receiving state change.
|
||||
*
|
||||
* @param state - The state of the radio receiving.
|
||||
* @param channel - The radio channel.
|
||||
*/
|
||||
handleRadioReceivingStateChange(state: boolean, channel: number) {
|
||||
if (channel === 1) {
|
||||
this.isPrimaryReceiving = state
|
||||
} else {
|
||||
this.isSecondaryReceiving = state
|
||||
}
|
||||
|
||||
this.sendRadioTalkingState()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/// <reference types="@citizenfx/client" />
|
||||
|
||||
import { initCache } from './utils'
|
||||
import { YaCAClientModule } from './yaca'
|
||||
|
||||
initCache()
|
||||
|
||||
new YaCAClientModule()
|
|
@ -0,0 +1,79 @@
|
|||
import type { ClientCache } from '@yaca-voice/types'
|
||||
|
||||
const playerId = PlayerId()
|
||||
|
||||
/**
|
||||
* Cached values for the client.
|
||||
*/
|
||||
const cache: ClientCache = new Proxy(
|
||||
{
|
||||
playerId,
|
||||
serverId: GetPlayerServerId(playerId),
|
||||
ped: PlayerPedId(),
|
||||
vehicle: false,
|
||||
seat: false,
|
||||
resource: GetCurrentResourceName(),
|
||||
game: GetGameName() as 'fivem' | 'redm',
|
||||
},
|
||||
{
|
||||
set(target: ClientCache, key: keyof ClientCache, value: never) {
|
||||
if (target[key] === value) return true
|
||||
|
||||
target[key] = value
|
||||
emit(`yaca:cache:${key}`, value)
|
||||
return true
|
||||
},
|
||||
get(target: ClientCache, key: keyof ClientCache) {
|
||||
return target[key]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Initializes the cache and starts updating it.
|
||||
*/
|
||||
function initCache() {
|
||||
/**
|
||||
* This function will update the cache every 100ms.
|
||||
*/
|
||||
const updateCache = () => {
|
||||
const ped = PlayerPedId()
|
||||
cache.ped = ped
|
||||
|
||||
const vehicle = GetVehiclePedIsIn(ped, false)
|
||||
|
||||
if (vehicle > 0) {
|
||||
if (vehicle !== cache.vehicle) {
|
||||
cache.seat = false
|
||||
}
|
||||
|
||||
cache.vehicle = vehicle
|
||||
|
||||
if (!cache.seat || GetPedInVehicleSeat(vehicle, cache.seat) !== ped) {
|
||||
for (let i = -1; i < GetVehicleMaxNumberOfPassengers(vehicle) - 1; i++) {
|
||||
if (GetPedInVehicleSeat(vehicle, i) === ped) {
|
||||
cache.seat = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cache.vehicle = false
|
||||
cache.seat = false
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(updateCache, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for cache updates.
|
||||
*
|
||||
* @param key - The cache key to listen for.
|
||||
* @param cb - The callback to execute when the cache updates.
|
||||
*/
|
||||
export const onCache = <T = never>(key: keyof ClientCache, cb: (value: T) => void) => {
|
||||
on(`yaca:cache:${key}`, cb)
|
||||
}
|
||||
|
||||
export { initCache, cache }
|
|
@ -0,0 +1,40 @@
|
|||
import { cache } from './cache'
|
||||
|
||||
export * from './cache'
|
||||
export * from './props'
|
||||
export * from './redm'
|
||||
export * from './streaming'
|
||||
export * from './vectors'
|
||||
export * from './vehicle'
|
||||
export * from './websocket'
|
||||
|
||||
/**
|
||||
* Rounds a float to a specified number of decimal places.
|
||||
* Defaults to 2 decimal places if not provided.
|
||||
*
|
||||
* @param {number} num - The number to round.
|
||||
* @param {number} decimalPlaces - The number of decimal places to round to.
|
||||
*
|
||||
* @returns {number} The rounded number.
|
||||
*/
|
||||
export function roundFloat(num: number, decimalPlaces = 17): number {
|
||||
return Number.parseFloat(num.toFixed(decimalPlaces))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert camera rotation to direction vector.
|
||||
*
|
||||
* @returns {x: number, y: number, z: number} The direction vector.
|
||||
*/
|
||||
export function getCamDirection(): { x: number; y: number; z: number } {
|
||||
const rotVector = GetGameplayCamRot(0)
|
||||
const num = rotVector[2] * 0.0174532924
|
||||
const num2 = rotVector[0] * 0.0174532924
|
||||
const num3 = Math.abs(Math.cos(num2))
|
||||
|
||||
return {
|
||||
x: roundFloat(-Math.sin(num) * num3),
|
||||
y: roundFloat(Math.cos(num) * num3),
|
||||
z: roundFloat(GetEntityForwardVector(cache.ped)[2]),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import { cache } from './cache'
|
||||
import { requestModel } from './streaming'
|
||||
|
||||
export const joaat = (input: string, ignore_casing = true) => {
|
||||
input = !ignore_casing ? input.toLowerCase() : input
|
||||
const length = input.length
|
||||
|
||||
let hash: number
|
||||
let i: number
|
||||
|
||||
for (hash = i = 0; i < length; i++) {
|
||||
hash += input.charCodeAt(i)
|
||||
hash += hash << 10
|
||||
hash ^= hash >>> 6
|
||||
}
|
||||
|
||||
hash += hash << 3
|
||||
hash ^= hash >>> 11
|
||||
hash += hash << 15
|
||||
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a prop and attach it to the player.
|
||||
*
|
||||
* @param model - The model of the prop.
|
||||
* @param boneId - The bone id to attach the prop to.
|
||||
* @param offset - The offset of the prop.
|
||||
* @param rotation - The rotation of the prop.
|
||||
*/
|
||||
export const createProp = async (
|
||||
model: string | number,
|
||||
boneId: number,
|
||||
offset: [number, number, number] = [0.0, 0.0, 0.0],
|
||||
rotation: [number, number, number] = [0.0, 0.0, 0.0],
|
||||
) => {
|
||||
const modelHash = await requestModel(model)
|
||||
if (!modelHash) return
|
||||
|
||||
const [x, y, z] = GetEntityCoords(cache.ped, true)
|
||||
const [ox, oy, oz] = offset
|
||||
const [rx, ry, rz] = rotation
|
||||
const object = CreateObject(modelHash, x, y, z, true, true, false)
|
||||
SetEntityCollision(object, false, false)
|
||||
AttachEntityToEntity(object, cache.ped, GetPedBoneIndex(cache.ped, boneId), ox, oy, oz, rx, ry, rz, true, false, false, true, 2, true)
|
||||
|
||||
return object
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { REDM_KEY_TO_HASH } from '../yaca'
|
||||
import { requestAnimDict } from './streaming'
|
||||
|
||||
/**
|
||||
* Play a facial animation on a ped.
|
||||
*
|
||||
* @param ped - The ped to play the facial animation on.
|
||||
* @param animName - The animation name to use.
|
||||
* @param animDict - The animation dictionary to use.
|
||||
*/
|
||||
export const playRdrFacialAnim = async (ped: number, animName: string, animDict: string) => {
|
||||
const loadedAnimDict = await requestAnimDict(animDict)
|
||||
if (!loadedAnimDict) return
|
||||
|
||||
SetFacialIdleAnimOverride(ped, animName, loadedAnimDict)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a notification in RDR.
|
||||
*
|
||||
* @param text - The text to display.
|
||||
* @param duration - The duration to display the notification for.
|
||||
*/
|
||||
export const displayRdrNotification = (text: string, duration: number) => {
|
||||
// @ts-expect-error VarString is a redm native
|
||||
const str = VarString(10, 'LITERAL_STRING', text)
|
||||
|
||||
const struct1 = new DataView(new ArrayBuffer(96))
|
||||
struct1.setUint32(0, duration, true)
|
||||
|
||||
const struct2 = new DataView(new ArrayBuffer(8 + 8))
|
||||
struct2.setBigUint64(8, BigInt(str), true)
|
||||
|
||||
Citizen.invokeNative('0x049D5C615BD38BAD', struct1, struct2, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a keybind for RDR.
|
||||
*
|
||||
* @param key - The key to bind.
|
||||
* @param onPressed - The function to call when the key is pressed.
|
||||
* @param onReleased - The function to call when the key is released.
|
||||
*/
|
||||
export const registerRdrKeyBind = (key: string, onPressed?: () => void, onReleased?: () => void) => {
|
||||
const keyHash = REDM_KEY_TO_HASH[key]
|
||||
|
||||
if (!keyHash) {
|
||||
console.error(`[YaCA] No key hash available for ${key}, please choose another keybind`)
|
||||
return
|
||||
}
|
||||
|
||||
setTick(() => {
|
||||
DisableControlAction(0, keyHash, true)
|
||||
if (onPressed && IsDisabledControlJustPressed(0, keyHash)) {
|
||||
onPressed()
|
||||
}
|
||||
|
||||
if (onReleased && IsDisabledControlJustReleased(0, keyHash)) {
|
||||
onReleased()
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { waitFor } from '@yaca-voice/common'
|
||||
import { joaat } from './props'
|
||||
|
||||
/**
|
||||
* Request an asset and wait for it to load.
|
||||
*
|
||||
* @param request - The function to request the asset
|
||||
* @param hasLoaded - The function to check if the asset has loaded
|
||||
* @param assetType - The type of the asset
|
||||
* @param asset - The asset to request
|
||||
* @param timeout - The timeout in ms
|
||||
*/
|
||||
async function streamingRequest<T extends string | number>(
|
||||
request: (asset: T) => unknown,
|
||||
hasLoaded: (asset: T) => boolean,
|
||||
assetType: string,
|
||||
asset: T,
|
||||
timeout = 30000,
|
||||
) {
|
||||
if (hasLoaded(asset)) return asset
|
||||
|
||||
request(asset)
|
||||
|
||||
return waitFor(
|
||||
() => {
|
||||
if (hasLoaded(asset)) return asset
|
||||
},
|
||||
`failed to load ${assetType} '${asset}' - this may be caused by\n- too many loaded assets\n- oversized, invalid, or corrupted assets`,
|
||||
timeout,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a animation dictionary.
|
||||
*
|
||||
* @param animDict - The animation dictionary to request.
|
||||
* @returns A promise that resolves to the animation dictionary once it is loaded.
|
||||
* @throws Will throw an error if the animation dictionary is not valid or if the animation dictionary fails to load within the timeout.
|
||||
*/
|
||||
export const requestAnimDict = (animDict: string) => {
|
||||
if (!DoesAnimDictExist(animDict)) throw new Error(`attempted to load invalid animDict '${animDict}'`)
|
||||
|
||||
return streamingRequest(RequestAnimDict, HasAnimDictLoaded, 'animDict', animDict)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a model by its name or hash key.
|
||||
*
|
||||
* @param modelName - The name or hash key of the model to load.
|
||||
* @returns A promise that resolves to the model hash key once the model is loaded.
|
||||
* @throws Will throw an error if the model is not valid or if the model fails to load within the timeout.
|
||||
*/
|
||||
export const requestModel = (modelName: string | number) => {
|
||||
if (typeof modelName !== 'number') modelName = joaat(modelName)
|
||||
if (!IsModelValid(modelName)) throw new Error(`attempted to load invalid model '${modelName}'`)
|
||||
|
||||
return streamingRequest(RequestModel, HasModelLoaded, 'model', modelName)
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { roundFloat } from './index'
|
||||
|
||||
/**
|
||||
* Calculate the distance between two points in 3D space
|
||||
*
|
||||
* @param firstPoint - The first point
|
||||
* @param secondPoint - The second point
|
||||
*/
|
||||
export function calculateDistanceVec3(firstPoint: number[], secondPoint: number[]) {
|
||||
return Math.sqrt((firstPoint[0] - secondPoint[0]) ** 2 + (firstPoint[1] - secondPoint[1]) ** 2 + (firstPoint[2] - secondPoint[2]) ** 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the distance between two points in 2D space
|
||||
*
|
||||
* @param firstPoint - The first point
|
||||
* @param secondPoint - The second point
|
||||
*/
|
||||
export function calculateDistanceVec2(firstPoint: number[], secondPoint: number[]) {
|
||||
return Math.sqrt((firstPoint[0] - secondPoint[0]) ** 2 + (firstPoint[1] - secondPoint[1]) ** 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of numbers to an object with x, y, and z properties
|
||||
*
|
||||
* @param array - The array to convert
|
||||
*/
|
||||
export function convertNumberArrayToXYZ(array: number[]): {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
} {
|
||||
return {
|
||||
x: roundFloat(array[0]),
|
||||
y: roundFloat(array[1]),
|
||||
z: roundFloat(array[2]),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Checks if the vehicle has a window.
|
||||
*
|
||||
* @param vehicle - The vehicle.
|
||||
* @param windowId - The window ID to check.
|
||||
* @returns {boolean} - Whether the vehicle has a window.
|
||||
*/
|
||||
export function hasWindow(vehicle: number, windowId: number): boolean {
|
||||
switch (windowId) {
|
||||
case 0:
|
||||
return GetEntityBoneIndexByName(vehicle, 'window_lf') !== -1
|
||||
case 1:
|
||||
return GetEntityBoneIndexByName(vehicle, 'window_rf') !== -1
|
||||
case 2:
|
||||
return GetEntityBoneIndexByName(vehicle, 'window_lr') !== -1
|
||||
case 3:
|
||||
return GetEntityBoneIndexByName(vehicle, 'window_rr') !== -1
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the vehicle has a door.
|
||||
*
|
||||
* @param vehicle - The vehicle.
|
||||
* @param doorId - The door ID to check.
|
||||
* @returns {boolean} - Whether the vehicle has a door.
|
||||
*/
|
||||
export function hasDoor(vehicle: number, doorId: number): boolean {
|
||||
switch (doorId) {
|
||||
case 0:
|
||||
return GetEntityBoneIndexByName(vehicle, 'door_dside_f') !== -1
|
||||
case 1:
|
||||
return GetEntityBoneIndexByName(vehicle, 'door_pside_f') !== -1
|
||||
case 2:
|
||||
return GetEntityBoneIndexByName(vehicle, 'door_dside_r') !== -1
|
||||
case 3:
|
||||
return GetEntityBoneIndexByName(vehicle, 'door_pside_r') !== -1
|
||||
case 4:
|
||||
return GetEntityBoneIndexByName(vehicle, 'bonnet') !== -1
|
||||
case 5:
|
||||
return GetEntityBoneIndexByName(vehicle, 'boot') !== -1
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the vehicle has an opening.
|
||||
*
|
||||
* @param vehicle - The vehicle.
|
||||
* @returns {boolean} - Whether the vehicle has an opening.
|
||||
*/
|
||||
export function vehicleHasOpening(vehicle: number): boolean {
|
||||
const doors = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (i === 4 || !hasDoor(vehicle, i)) continue
|
||||
doors.push(i)
|
||||
}
|
||||
|
||||
if (doors.length === 0) return true
|
||||
for (const door of doors) {
|
||||
const doorAngle = GetVehicleDoorAngleRatio(vehicle, door)
|
||||
if (doorAngle > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (IsVehicleDoorDamaged(vehicle, door)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (!AreAllVehicleWindowsIntact(vehicle)) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (let i = 0; i < 8 /* max windows */; i++) {
|
||||
const hasWindows = hasWindow(vehicle, i)
|
||||
if (hasWindows && !IsVehicleWindowIntact(vehicle, i)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (IsVehicleAConvertible(vehicle, false)) {
|
||||
const roofState = GetConvertibleRoofState(vehicle)
|
||||
if (roofState !== 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
import { sleep } from '@yaca-voice/common'
|
||||
import EventEmitter2 from 'eventemitter2'
|
||||
|
||||
/**
|
||||
* The WebSocket class handles the communication between the nui and the client.
|
||||
*/
|
||||
export class WebSocket extends EventEmitter2 {
|
||||
public readyState = 0
|
||||
nuiReady = false
|
||||
initialized = false
|
||||
|
||||
/**
|
||||
* Creates an instance of the WebSocket class.
|
||||
*/
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
RegisterNuiCallbackType('YACA_OnMessage')
|
||||
RegisterNuiCallbackType('YACA_OnConnected')
|
||||
RegisterNuiCallbackType('YACA_OnDisconnected')
|
||||
|
||||
on('__cfx_nui:YACA_OnMessage', (data: object, cb: (data: unknown) => void) => {
|
||||
this.emit('message', data)
|
||||
cb({})
|
||||
})
|
||||
|
||||
on('__cfx_nui:YACA_OnConnected', (_: unknown, cb: (data: unknown) => void) => {
|
||||
this.readyState = 1
|
||||
this.emit('open')
|
||||
cb({})
|
||||
})
|
||||
|
||||
on('__cfx_nui:YACA_OnDisconnected', (data: { code: number; reason: string }, cb: (data: unknown) => void) => {
|
||||
this.readyState = 3
|
||||
this.emit('close', data.code, data.reason)
|
||||
cb({})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the message to the nui that the websocket should connect.
|
||||
*/
|
||||
async start() {
|
||||
while (!this.nuiReady) {
|
||||
await sleep(100)
|
||||
}
|
||||
|
||||
SendNuiMessage(
|
||||
JSON.stringify({
|
||||
action: 'connect',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the message to the nui that the websocket should disconnect.
|
||||
*
|
||||
* @param data - The data to send.
|
||||
*/
|
||||
send(data: object) {
|
||||
if (this.readyState !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
SendNuiMessage(
|
||||
JSON.stringify({
|
||||
action: 'command',
|
||||
data,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the message to the nui that the websocket should disconnect.
|
||||
*/
|
||||
close() {
|
||||
if (this.readyState === 3) {
|
||||
return
|
||||
}
|
||||
|
||||
SendNuiMessage(
|
||||
JSON.stringify({
|
||||
action: 'close',
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
const localLipSyncAnimations: Record<'fivem' | 'redm', Record<string, { name: string; dict: string }>> = {
|
||||
fivem: {
|
||||
true: {
|
||||
name: 'mic_chatter',
|
||||
dict: 'mp_facial',
|
||||
},
|
||||
false: {
|
||||
name: 'mood_normal_1',
|
||||
dict: 'facials@gen_male@variations@normal',
|
||||
},
|
||||
},
|
||||
redm: {
|
||||
true: {
|
||||
name: 'mood_talking_normal',
|
||||
dict: 'face_human@gen_male@base',
|
||||
},
|
||||
false: {
|
||||
name: 'mood_normal',
|
||||
dict: 'face_human@gen_male@base',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const REDM_KEY_TO_HASH: Record<string, number | null> = {
|
||||
// Letters
|
||||
A: 0x7065027d,
|
||||
B: 0x4cc0e2fe,
|
||||
C: 0x9959a6f0,
|
||||
D: 0xb4e465b4,
|
||||
E: 0xcefd9220,
|
||||
F: 0xb2f377e8,
|
||||
G: 0x760a9c6f,
|
||||
H: 0x24978a28,
|
||||
I: 0xc1989f95,
|
||||
J: 0xf3830d8e,
|
||||
K: null,
|
||||
L: 0x80f28e95,
|
||||
M: 0xe31c6a41,
|
||||
N: 0x4bc9dabb, // (Push to Talk)
|
||||
O: 0xf1301666,
|
||||
P: 0xd82e0bd2,
|
||||
Q: 0xde794e3e,
|
||||
R: 0xe30cd707,
|
||||
S: 0xd27782e3,
|
||||
T: null,
|
||||
U: 0xd8f73058,
|
||||
V: 0x7f8d09b8,
|
||||
W: 0x8fd015d8,
|
||||
X: 0x8cc9cd42,
|
||||
Y: null,
|
||||
Z: 0x26e9dc00,
|
||||
|
||||
// Symbol Keys
|
||||
RIGHTBRACKET: 0xa5bdcd3c,
|
||||
LEFTBRACKET: 0x430593aa,
|
||||
|
||||
// Mouse buttons
|
||||
MOUSE1: 0x07ce1e61,
|
||||
MOUSE2: 0xf84fa74f,
|
||||
MOUSE3: 0xcee12b50,
|
||||
MWUP: 0x3076e97c,
|
||||
|
||||
// Modifier Keys
|
||||
CTRL: 0xdb096b85,
|
||||
TAB: 0xb238fe0b,
|
||||
SHIFT: 0x8ffc75d6,
|
||||
SPACEBAR: 0xd9d0e1c0,
|
||||
ENTER: 0xc7b5340a,
|
||||
BACKSPACE: 0x156f7119,
|
||||
LALT: 0x8aaa0ad4,
|
||||
DEL: 0x4af4d473,
|
||||
PGUP: 0x446258b6,
|
||||
PGDN: 0x3c3dd371,
|
||||
|
||||
// Function Keys
|
||||
F1: 0xa8e3f467,
|
||||
F4: 0x1f6d95e5,
|
||||
F6: 0x3c0a40f2,
|
||||
|
||||
// Number Keys
|
||||
'1': 0xe6f612e4,
|
||||
'2': 0x1ce6d9eb,
|
||||
'3': 0x4f49cc4c,
|
||||
'4': 0x8f9f9e58,
|
||||
'5': 0xab62e997,
|
||||
'6': 0xa1fde2a6,
|
||||
'7': 0xb03a913b,
|
||||
'8': 0x42385422,
|
||||
|
||||
// Arrow Keys
|
||||
DOWN: 0x05ca7c52,
|
||||
UP: 0x6319db71,
|
||||
LEFT: 0xa65ebab4,
|
||||
RIGHT: 0xdeb34313,
|
||||
}
|
||||
|
||||
export { localLipSyncAnimations, REDM_KEY_TO_HASH }
|
|
@ -0,0 +1,6 @@
|
|||
export * from './data'
|
||||
export * from './intercom'
|
||||
export * from './main'
|
||||
export * from './megaphone'
|
||||
export * from './phone'
|
||||
export * from './radio'
|
|
@ -0,0 +1,59 @@
|
|||
import { CommDeviceMode, YacaFilterEnum, type YacaPlayerData } from '@yaca-voice/types'
|
||||
import type { YaCAClientModule } from './main'
|
||||
|
||||
/**
|
||||
* The intercom module for the client.
|
||||
*/
|
||||
export class YaCAClientIntercomModule {
|
||||
clientModule: YaCAClientModule
|
||||
|
||||
/**
|
||||
* Creates an instance of the intercom module.
|
||||
*
|
||||
* @param clientModule - The client module.
|
||||
*/
|
||||
constructor(clientModule: YaCAClientModule) {
|
||||
this.clientModule = clientModule
|
||||
|
||||
this.registerEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the intercom events.
|
||||
*/
|
||||
registerEvents() {
|
||||
/**
|
||||
* Handles the "client:yaca:addRemovePlayerIntercomFilter" server event.
|
||||
*
|
||||
* @param {number[] | number} playerIDs - The IDs of the players to be added or removed from the intercom filter.
|
||||
* @param {boolean} state - The state indicating whether to add or remove the players.
|
||||
*/
|
||||
onNet('client:yaca:addRemovePlayerIntercomFilter', (playerIDs: number | number[], state: boolean) => {
|
||||
if (!Array.isArray(playerIDs)) {
|
||||
playerIDs = [playerIDs]
|
||||
}
|
||||
|
||||
const playersToAddRemove: Set<YacaPlayerData> = new Set()
|
||||
for (const playerID of playerIDs) {
|
||||
const player = this.clientModule.getPlayerByID(playerID)
|
||||
if (!player) {
|
||||
continue
|
||||
}
|
||||
playersToAddRemove.add(player)
|
||||
}
|
||||
|
||||
if (playersToAddRemove.size < 1) {
|
||||
return
|
||||
}
|
||||
this.clientModule.setPlayersCommType(
|
||||
Array.from(playersToAddRemove),
|
||||
YacaFilterEnum.INTERCOM,
|
||||
state,
|
||||
undefined,
|
||||
undefined,
|
||||
CommDeviceMode.TRANSCEIVER,
|
||||
CommDeviceMode.TRANSCEIVER,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
1733
resources/[voice]/yaca-voice/apps/yaca-client/src/yaca/main.ts
Normal file
1733
resources/[voice]/yaca-voice/apps/yaca-client/src/yaca/main.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,218 @@
|
|||
import { locale, MEGAPHONE_STATE_NAME } from '@yaca-voice/common'
|
||||
import { CommDeviceMode, YacaFilterEnum } from '@yaca-voice/types'
|
||||
import { cache, joaat, onCache, registerRdrKeyBind } from '../utils'
|
||||
import type { YaCAClientModule } from './main'
|
||||
|
||||
/**
|
||||
* The megaphone module for the client.
|
||||
*/
|
||||
export class YaCAClientMegaphoneModule {
|
||||
clientModule: YaCAClientModule
|
||||
|
||||
canUseMegaphone = false
|
||||
lastMegaphoneState = false
|
||||
|
||||
megaphoneVehicleWhitelistHashes = new Set<number>()
|
||||
|
||||
/**
|
||||
* Creates an instance of the megaphone module.
|
||||
*
|
||||
* @param clientModule - The client module.
|
||||
*/
|
||||
constructor(clientModule: YaCAClientModule) {
|
||||
this.clientModule = clientModule
|
||||
|
||||
this.registerEvents()
|
||||
if (this.clientModule.isFiveM) {
|
||||
this.registerKeybinds()
|
||||
|
||||
for (const vehicleModel of this.clientModule.sharedConfig.megaphone.allowedVehicleModels) {
|
||||
this.megaphoneVehicleWhitelistHashes.add(joaat(vehicleModel))
|
||||
}
|
||||
} else if (this.clientModule.isRedM) {
|
||||
this.registerRdrKeybinds()
|
||||
}
|
||||
this.registerExports()
|
||||
this.registerStateBagHandlers()
|
||||
}
|
||||
|
||||
registerEvents() {
|
||||
/**
|
||||
* Handles the "client:yaca:setLastMegaphoneState" server event.
|
||||
*
|
||||
* @param {boolean} state - The state of the megaphone.
|
||||
*/
|
||||
onNet('client:yaca:setLastMegaphoneState', (state: boolean) => {
|
||||
this.lastMegaphoneState = state
|
||||
})
|
||||
|
||||
if (this.clientModule.isFiveM && this.clientModule.sharedConfig.megaphone.automaticVehicleDetection) {
|
||||
/**
|
||||
* Checks if the player can use the megaphone when they enter a vehicle.
|
||||
* If they can, it sets the `canUseMegaphone` property to `true`.
|
||||
* If they can't, it sets the `canUseMegaphone` property to `false`.
|
||||
* If the player is not in a vehicle, it sets the `canUseMegaphone` property to `false` and emits the "server:yaca:playerLeftVehicle" event.
|
||||
*/
|
||||
onCache<number | false>('seat', (seat) => {
|
||||
if (seat === false || seat > 0 || !cache.vehicle) {
|
||||
this.canUseMegaphone = false
|
||||
emitNet('server:yaca:playerLeftVehicle')
|
||||
return
|
||||
}
|
||||
|
||||
const vehicleClass = GetVehicleClass(cache.vehicle)
|
||||
const vehicleModel = GetEntityModel(cache.vehicle)
|
||||
|
||||
this.canUseMegaphone =
|
||||
this.clientModule.sharedConfig.megaphone.allowedVehicleClasses.includes(vehicleClass) ||
|
||||
this.megaphoneVehicleWhitelistHashes.has(vehicleModel)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the command and key mapping for the megaphone.
|
||||
* This is only available in FiveM.
|
||||
*/
|
||||
registerKeybinds() {
|
||||
if (this.clientModule.sharedConfig.keyBinds.megaphone === false) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the command and key mapping for the megaphone.
|
||||
*/
|
||||
RegisterCommand(
|
||||
'+yaca:megaphone',
|
||||
() => {
|
||||
this.useMegaphone(true)
|
||||
},
|
||||
false,
|
||||
)
|
||||
RegisterCommand(
|
||||
'-yaca:megaphone',
|
||||
() => {
|
||||
this.useMegaphone(false)
|
||||
},
|
||||
false,
|
||||
)
|
||||
RegisterKeyMapping('+yaca:megaphone', locale('use_megaphone'), 'keyboard', this.clientModule.sharedConfig.keyBinds.megaphone)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the keybindings for the megaphone.
|
||||
* This is only available in RedM.
|
||||
*/
|
||||
registerRdrKeybinds() {
|
||||
if (this.clientModule.sharedConfig.keyBinds.megaphone === false) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the command and key mapping for the megaphone.
|
||||
*/
|
||||
registerRdrKeyBind(this.clientModule.sharedConfig.keyBinds.megaphone, () => {
|
||||
this.useMegaphone(!this.lastMegaphoneState)
|
||||
})
|
||||
}
|
||||
|
||||
registerExports() {
|
||||
/**
|
||||
* Gets the `canUseMegaphone` property.
|
||||
*
|
||||
* @returns {boolean} - The `canUseMegaphone` property.
|
||||
*/
|
||||
exports('getCanUseMegaphone', () => {
|
||||
return this.canUseMegaphone
|
||||
})
|
||||
|
||||
/**
|
||||
* Sets the `canUseMegaphone` property.
|
||||
*
|
||||
* @param {boolean} state - The state to set the `canUseMegaphone` property to.
|
||||
*/
|
||||
exports('setCanUseMegaphone', (state: boolean) => {
|
||||
this.canUseMegaphone = state
|
||||
|
||||
if (!state && this.lastMegaphoneState) {
|
||||
emitNet('server:yaca:playerLeftVehicle')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggles the use of the megaphone.
|
||||
*
|
||||
* @param {boolean} [state=false] - The state of the megaphone. Defaults to false if not provided.
|
||||
*/
|
||||
exports('useMegaphone', (state = false) => {
|
||||
this.useMegaphone(state)
|
||||
})
|
||||
}
|
||||
|
||||
registerStateBagHandlers() {
|
||||
/**
|
||||
* Handles the megaphone state bag change.
|
||||
*/
|
||||
AddStateBagChangeHandler(MEGAPHONE_STATE_NAME, '', (bagName: string, _: string, value: number | null, __: number, replicated: boolean) => {
|
||||
if (replicated) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = GetPlayerFromStateBagName(bagName)
|
||||
if (playerId === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerSource = GetPlayerServerId(playerId)
|
||||
if (playerSource === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (playerSource === cache.serverId) {
|
||||
this.clientModule.setPlayersCommType(
|
||||
[],
|
||||
YacaFilterEnum.MEGAPHONE,
|
||||
typeof value === 'number',
|
||||
undefined,
|
||||
value,
|
||||
CommDeviceMode.SENDER,
|
||||
CommDeviceMode.RECEIVER,
|
||||
)
|
||||
} else {
|
||||
const player = this.clientModule.getPlayerByID(playerSource)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clientModule.setPlayersCommType(
|
||||
player,
|
||||
YacaFilterEnum.MEGAPHONE,
|
||||
typeof value === 'number',
|
||||
undefined,
|
||||
value,
|
||||
CommDeviceMode.RECEIVER,
|
||||
CommDeviceMode.SENDER,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the use of the megaphone.
|
||||
*
|
||||
* @param {boolean} [state=false] - The state of the megaphone. Defaults to false if not provided.
|
||||
*/
|
||||
useMegaphone(state = false) {
|
||||
if (
|
||||
(!cache.vehicle && this.clientModule.sharedConfig.megaphone.automaticVehicleDetection) ||
|
||||
!this.canUseMegaphone ||
|
||||
state === this.lastMegaphoneState
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.lastMegaphoneState = !this.lastMegaphoneState
|
||||
emitNet('server:yaca:useMegaphone', state)
|
||||
emit('yaca:external:megaphoneState', state)
|
||||
}
|
||||
}
|
288
resources/[voice]/yaca-voice/apps/yaca-client/src/yaca/phone.ts
Normal file
288
resources/[voice]/yaca-voice/apps/yaca-client/src/yaca/phone.ts
Normal file
|
@ -0,0 +1,288 @@
|
|||
import { GLOBAL_ERROR_LEVEL_STATE_NAME, PHONE_SPEAKER_STATE_NAME } from '@yaca-voice/common'
|
||||
import { CommDeviceMode, YacaFilterEnum, type YacaPlayerData } from '@yaca-voice/types'
|
||||
import { cache } from '../utils'
|
||||
import type { YaCAClientModule } from './main'
|
||||
|
||||
/**
|
||||
* The phone module for the client.
|
||||
*/
|
||||
export class YaCAClientPhoneModule {
|
||||
clientModule: YaCAClientModule
|
||||
|
||||
inCallWith = new Set<number>()
|
||||
phoneSpeakerActive = false
|
||||
|
||||
/**
|
||||
* Creates an instance of the phone module.
|
||||
*
|
||||
* @param clientModule - The client module.
|
||||
*/
|
||||
constructor(clientModule: YaCAClientModule) {
|
||||
this.clientModule = clientModule
|
||||
|
||||
this.registerEvents()
|
||||
this.registerExports()
|
||||
this.registerStateBagHandlers()
|
||||
}
|
||||
|
||||
registerEvents() {
|
||||
/**
|
||||
* Handles the "client:yaca:phone" server event.
|
||||
*
|
||||
* @param {number | number[]} targetIDs - The ID of the target.
|
||||
* @param {boolean} state - The state of the phone.
|
||||
*/
|
||||
onNet('client:yaca:phone', (targetIDs: number | number[], state: boolean, filter: YacaFilterEnum = YacaFilterEnum.PHONE) => {
|
||||
if (!Array.isArray(targetIDs)) {
|
||||
targetIDs = [targetIDs]
|
||||
}
|
||||
|
||||
this.enablePhoneCall(targetIDs, state, filter)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "client:yaca:phoneHearAround" server event.
|
||||
*
|
||||
* @param {number[]} targetClientIds - The IDs of the targets.
|
||||
* @param {boolean} state - The state of the phone hear around.
|
||||
*/
|
||||
onNet('client:yaca:phoneHearAround', (targetClientIds: number[], state: boolean) => {
|
||||
if (!targetClientIds.length) return
|
||||
|
||||
const commTargets = Array.from(targetClientIds).map((clientId) => ({ clientId }))
|
||||
|
||||
this.clientModule.setPlayersCommType(
|
||||
commTargets,
|
||||
YacaFilterEnum.PHONE,
|
||||
state,
|
||||
undefined,
|
||||
undefined,
|
||||
CommDeviceMode.TRANSCEIVER,
|
||||
CommDeviceMode.TRANSCEIVER,
|
||||
GlobalState[PHONE_SPEAKER_STATE_NAME] ?? undefined,
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "client:yaca:phoneMute" server event.
|
||||
*
|
||||
* @param {number} targetID - The ID of the target.
|
||||
* @param {boolean} state - The state of the phone mute.
|
||||
* @param {boolean} onCallStop - The state of the call.
|
||||
*/
|
||||
onNet('client:yaca:phoneMute', (targetID: number, state: boolean, onCallStop = false) => {
|
||||
const target = this.clientModule.getPlayerByID(targetID)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
target.mutedOnPhone = state
|
||||
|
||||
if (onCallStop) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.clientModule.useWhisper && target.remoteID === cache.serverId) {
|
||||
this.clientModule.setPlayersCommType([], YacaFilterEnum.PHONE, !state, undefined, undefined, CommDeviceMode.SENDER)
|
||||
} else if (!this.clientModule.useWhisper && this.inCallWith.has(targetID)) {
|
||||
this.clientModule.setPlayersCommType(
|
||||
target,
|
||||
YacaFilterEnum.PHONE,
|
||||
state,
|
||||
undefined,
|
||||
undefined,
|
||||
CommDeviceMode.TRANSCEIVER,
|
||||
CommDeviceMode.TRANSCEIVER,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "client:yaca:phoneSpeaker" server event.
|
||||
*
|
||||
* @param {number | number[]} playerIDs - The IDs of the players to be added or removed from the phone speaker.
|
||||
* @param {boolean} state - The state indicating whether to add or remove the players.
|
||||
*/
|
||||
onNet('client:yaca:playersToPhoneSpeakerEmitWhisper', (playerIDs: number | number[], state: boolean) => {
|
||||
if (!this.clientModule.useWhisper) return
|
||||
|
||||
if (!Array.isArray(playerIDs)) {
|
||||
playerIDs = [playerIDs]
|
||||
}
|
||||
|
||||
const targets = new Set<YacaPlayerData>()
|
||||
for (const playerID of playerIDs) {
|
||||
const player = this.clientModule.getPlayerByID(playerID)
|
||||
if (!player) {
|
||||
continue
|
||||
}
|
||||
|
||||
targets.add(player)
|
||||
}
|
||||
|
||||
if (targets.size < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clientModule.setPlayersCommType(
|
||||
Array.from(targets),
|
||||
YacaFilterEnum.PHONE_SPEAKER,
|
||||
state,
|
||||
undefined,
|
||||
undefined,
|
||||
CommDeviceMode.SENDER,
|
||||
CommDeviceMode.RECEIVER,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
registerExports() {
|
||||
/**
|
||||
* Exports the "isInCall" function.
|
||||
* This function returns whether the player is in a phone call.
|
||||
*
|
||||
* @returns {boolean} - Whether the player is in a phone call.
|
||||
*/
|
||||
exports('isInCall', () => this.inCallWith.size > 0)
|
||||
}
|
||||
|
||||
registerStateBagHandlers() {
|
||||
/**
|
||||
* Handles the "yaca:phone" state bag change.
|
||||
*/
|
||||
AddStateBagChangeHandler(PHONE_SPEAKER_STATE_NAME, '', (bagName: string, _: string, value: number | number[] | null) => {
|
||||
const playerId = GetPlayerFromStateBagName(bagName)
|
||||
if (playerId === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerSource = GetPlayerServerId(playerId)
|
||||
if (playerSource === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (playerSource === cache.serverId) {
|
||||
this.phoneSpeakerActive = value !== null
|
||||
}
|
||||
|
||||
this.removePhoneSpeakerFromEntity(playerSource)
|
||||
if (value !== null) {
|
||||
this.clientModule.setPlayerVariable(playerSource, 'phoneCallMemberIds', Array.isArray(value) ? value : [value])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the phone speaker effect from a player entity.
|
||||
*
|
||||
* @param {number} player - The player entity from which the phone speaker effect is to be removed.
|
||||
*/
|
||||
removePhoneSpeakerFromEntity(player: number) {
|
||||
const entityData = this.clientModule.getPlayerByID(player)
|
||||
if (!entityData?.phoneCallMemberIds) {
|
||||
return
|
||||
}
|
||||
|
||||
const playersToSet = []
|
||||
for (const phoneCallMemberId of entityData.phoneCallMemberIds) {
|
||||
const phoneCallMember = this.clientModule.getPlayerByID(phoneCallMemberId)
|
||||
if (!phoneCallMember) {
|
||||
continue
|
||||
}
|
||||
|
||||
playersToSet.push(phoneCallMember)
|
||||
}
|
||||
|
||||
this.clientModule.setPlayersCommType(
|
||||
playersToSet,
|
||||
YacaFilterEnum.PHONE_SPEAKER,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
CommDeviceMode.RECEIVER,
|
||||
CommDeviceMode.SENDER,
|
||||
)
|
||||
|
||||
entityData.phoneCallMemberIds = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the disconnection of a player from a phone call.
|
||||
*
|
||||
* @param {number} targetID - The ID of the target.
|
||||
*/
|
||||
handleDisconnect(targetID: number) {
|
||||
this.inCallWith.delete(targetID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reestablishes a phone call with a target, when a player has restarted the voice plugin.
|
||||
*
|
||||
* @param {number | number[]} targetIDs - The IDs of the targets.
|
||||
*/
|
||||
reestablishCalls(targetIDs: number | number[]) {
|
||||
if (!this.inCallWith.size) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(targetIDs)) {
|
||||
targetIDs = [targetIDs]
|
||||
}
|
||||
|
||||
if (!targetIDs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetsToReestablish = []
|
||||
for (const targetId of targetIDs) {
|
||||
if (this.inCallWith.has(targetId)) {
|
||||
targetsToReestablish.push(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
if (targetsToReestablish.length) {
|
||||
this.enablePhoneCall(targetsToReestablish, true, YacaFilterEnum.PHONE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables a phone call.
|
||||
*
|
||||
* @param {number[]} targetIDs - The IDs of the targets.
|
||||
* @param {boolean} state - The state of the phone call.
|
||||
* @param {YacaFilterEnum} filter - The filter to use.
|
||||
*/
|
||||
enablePhoneCall(targetIDs: number[], state: boolean, filter: YacaFilterEnum = YacaFilterEnum.PHONE) {
|
||||
if (!targetIDs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const commTargets = []
|
||||
for (const targetID of targetIDs) {
|
||||
const target = this.clientModule.getPlayerByID(targetID)
|
||||
if (!target) {
|
||||
if (!state) this.inCallWith.delete(targetID)
|
||||
continue
|
||||
}
|
||||
|
||||
if (state) {
|
||||
this.inCallWith.add(targetID)
|
||||
} else {
|
||||
this.inCallWith.delete(targetID)
|
||||
}
|
||||
|
||||
commTargets.push(target)
|
||||
}
|
||||
|
||||
this.clientModule.setPlayersCommType(
|
||||
commTargets,
|
||||
filter,
|
||||
state,
|
||||
undefined,
|
||||
undefined,
|
||||
state || (!state && this.inCallWith.size) ? CommDeviceMode.TRANSCEIVER : undefined,
|
||||
CommDeviceMode.TRANSCEIVER,
|
||||
GlobalState[GLOBAL_ERROR_LEVEL_STATE_NAME] ?? undefined,
|
||||
)
|
||||
}
|
||||
}
|
1124
resources/[voice]/yaca-voice/apps/yaca-client/src/yaca/radio.ts
Normal file
1124
resources/[voice]/yaca-voice/apps/yaca-client/src/yaca/radio.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "@yaca-voice/typescript-config/fivem.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"types": ["@citizenfx/client"]
|
||||
},
|
||||
"include": ["./"]
|
||||
}
|
23
resources/[voice]/yaca-voice/apps/yaca-server/build.js
Normal file
23
resources/[voice]/yaca-voice/apps/yaca-server/build.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { build } from 'esbuild'
|
||||
|
||||
const production = process.argv.includes('--mode=production')
|
||||
|
||||
build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
outfile: './dist/server.js',
|
||||
bundle: true,
|
||||
loader: {
|
||||
'.ts': 'ts',
|
||||
'.js': 'js',
|
||||
},
|
||||
write: true,
|
||||
platform: 'node',
|
||||
target: 'node16',
|
||||
sourcemap: production ? false : 'inline',
|
||||
dropLabels: production ? ['DEV'] : undefined,
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Server built successfully')
|
||||
})
|
||||
// skipcq: JS-0263
|
||||
.catch(() => process.exit(1))
|
25
resources/[voice]/yaca-voice/apps/yaca-server/package.json
Normal file
25
resources/[voice]/yaca-voice/apps/yaca-server/package.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "yaca-server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.js --mode=production",
|
||||
"dev": "node build.js",
|
||||
"typecheck": "tsc --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@citizenfx/server": "latest",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.16.10",
|
||||
"@yaca-voice/common": "workspace:*",
|
||||
"@yaca-voice/types": "workspace:*",
|
||||
"@yaca-voice/typescript-config": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.9.1"
|
||||
}
|
||||
}
|
1136
resources/[voice]/yaca-voice/apps/yaca-server/pnpm-lock.yaml
generated
Normal file
1136
resources/[voice]/yaca-voice/apps/yaca-server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,165 @@
|
|||
import { saltyChatExport } from '@yaca-voice/common'
|
||||
import { cache } from '../utils'
|
||||
import type { YaCAServerModule } from '../yaca'
|
||||
|
||||
/**
|
||||
* The SaltyChat bridge for the server.
|
||||
*/
|
||||
export class YaCAServerSaltyChatBridge {
|
||||
serverModule: YaCAServerModule
|
||||
|
||||
private callMap = new Map<string, Set<number>>()
|
||||
|
||||
/**
|
||||
* Creates an instance of the SaltyChat bridge.
|
||||
*
|
||||
* @param {YaCAServerModule} serverModule - The server module.
|
||||
*/
|
||||
constructor(serverModule: YaCAServerModule) {
|
||||
this.serverModule = serverModule
|
||||
|
||||
this.registerSaltyChatEvents()
|
||||
|
||||
console.log('[YaCA] SaltyChat bridge loaded')
|
||||
|
||||
on('onResourceStop', (resourceName: string) => {
|
||||
if (cache.resource !== resourceName) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('onServerResourceStop', 'saltychat')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register SaltyChat events.
|
||||
*/
|
||||
registerSaltyChatEvents() {
|
||||
saltyChatExport('GetPlayerAlive', (netId: number) => {
|
||||
this.serverModule.getPlayerAliveStatus(netId)
|
||||
})
|
||||
|
||||
saltyChatExport('SetPlayerAlive', (netId: number, isAlive: boolean) => {
|
||||
this.serverModule.changePlayerAliveStatus(netId, isAlive)
|
||||
})
|
||||
|
||||
saltyChatExport('GetPlayerVoiceRange', (netId: number) => {
|
||||
this.serverModule.getPlayerVoiceRange(netId)
|
||||
})
|
||||
|
||||
saltyChatExport('SetPlayerVoiceRange', (netId: number, voiceRange: number) => {
|
||||
this.serverModule.changeVoiceRange(netId, voiceRange)
|
||||
})
|
||||
|
||||
saltyChatExport('AddPlayerToCall', (callIdentifier: string, playerHandle: number) => this.addPlayerToCall(callIdentifier, playerHandle))
|
||||
|
||||
saltyChatExport('AddPlayersToCall', (callIdentifier: string, playerHandles: number[]) => this.addPlayerToCall(callIdentifier, playerHandles))
|
||||
|
||||
saltyChatExport('RemovePlayerFromCall', (callIdentifier: string, playerHandle: number) => this.removePlayerFromCall(callIdentifier, playerHandle))
|
||||
|
||||
saltyChatExport('RemovePlayersFromCall', (callIdentifier: string, playerHandles: number[]) => this.removePlayerFromCall(callIdentifier, playerHandles))
|
||||
|
||||
saltyChatExport('SetPhoneSpeaker', (playerHandle: number, toggle: boolean) => {
|
||||
this.serverModule.phoneModule.enablePhoneSpeaker(playerHandle, toggle)
|
||||
})
|
||||
|
||||
saltyChatExport('SetPlayerRadioSpeaker', () => {
|
||||
console.warn('SetPlayerRadioSpeaker is not implemented in YaCA')
|
||||
})
|
||||
|
||||
saltyChatExport('GetPlayersInRadioChannel', (radioChannelName: string) => this.serverModule.radioModule.getPlayersInRadioFrequency(radioChannelName))
|
||||
|
||||
saltyChatExport('SetPlayerRadioChannel', (netId: number, radioChannelName: string, primary = true) => {
|
||||
const channel = primary ? 1 : 2
|
||||
const newRadioChannelName = radioChannelName === '' ? '0' : radioChannelName
|
||||
|
||||
this.serverModule.radioModule.changeRadioFrequency(netId, channel, newRadioChannelName)
|
||||
})
|
||||
|
||||
saltyChatExport('RemovePlayerRadioChannel', (netId: number, primary: boolean) => {
|
||||
const channel = primary ? 1 : 2
|
||||
this.serverModule.radioModule.changeRadioFrequency(netId, channel, '0')
|
||||
})
|
||||
|
||||
saltyChatExport('SetRadioTowers', () => {
|
||||
console.warn('SetRadioTowers is not implemented in YaCA')
|
||||
})
|
||||
|
||||
saltyChatExport('EstablishCall', (callerId: number, targetId: number) => {
|
||||
this.serverModule.phoneModule.callPlayer(callerId, targetId, true)
|
||||
})
|
||||
|
||||
saltyChatExport('EndCall', (callerId: number, targetId: number) => {
|
||||
this.serverModule.phoneModule.callPlayer(callerId, targetId, false)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a player to a call.
|
||||
*
|
||||
* @param callIdentifier - The call identifier.
|
||||
* @param playerHandle - The player handles.
|
||||
*/
|
||||
addPlayerToCall(callIdentifier: string, playerHandle: number | number[]) {
|
||||
if (!Array.isArray(playerHandle)) {
|
||||
playerHandle = [playerHandle]
|
||||
}
|
||||
|
||||
const currentlyInCall = this.callMap.get(callIdentifier) ?? new Set<number>()
|
||||
const newInCall = new Set<number>()
|
||||
|
||||
for (const player of playerHandle) {
|
||||
if (!currentlyInCall.has(player)) {
|
||||
currentlyInCall.add(player)
|
||||
newInCall.add(player)
|
||||
}
|
||||
}
|
||||
|
||||
this.callMap.set(callIdentifier, currentlyInCall)
|
||||
|
||||
for (const player of currentlyInCall) {
|
||||
for (const otherPlayer of newInCall) {
|
||||
if (player !== otherPlayer) {
|
||||
this.serverModule.phoneModule.callPlayer(player, otherPlayer, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a player from a call.
|
||||
*
|
||||
* @param callIdentifier - The call identifier.
|
||||
* @param playerHandle - The player handles.
|
||||
*/
|
||||
removePlayerFromCall(callIdentifier: string, playerHandle: number | number[]) {
|
||||
if (!Array.isArray(playerHandle)) {
|
||||
playerHandle = [playerHandle]
|
||||
}
|
||||
|
||||
const beforeInCall = this.callMap.get(callIdentifier)
|
||||
if (!beforeInCall) {
|
||||
return
|
||||
}
|
||||
|
||||
const nowInCall = new Set<number>(beforeInCall)
|
||||
|
||||
const removedFromCall = new Set<number>()
|
||||
for (const player of playerHandle) {
|
||||
if (beforeInCall.has(player)) {
|
||||
nowInCall.delete(player)
|
||||
removedFromCall.add(player)
|
||||
}
|
||||
}
|
||||
|
||||
this.callMap.set(callIdentifier, nowInCall)
|
||||
|
||||
for (const player of removedFromCall) {
|
||||
for (const otherPlayer of beforeInCall) {
|
||||
if (player !== otherPlayer) {
|
||||
this.serverModule.phoneModule.callPlayer(player, otherPlayer, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="@citizenfx/server" />
|
||||
|
||||
import { YaCAServerModule } from 'src/yaca'
|
||||
|
||||
new YaCAServerModule()
|
|
@ -0,0 +1,8 @@
|
|||
import type { ServerCache } from '@yaca-voice/types'
|
||||
|
||||
/**
|
||||
* Cached values for the server.
|
||||
*/
|
||||
export const cache: ServerCache = {
|
||||
resource: GetCurrentResourceName(),
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Send a event to one or multiple clients.
|
||||
*
|
||||
* @param eventName - The name of the event.
|
||||
* @param targetIds - The target ids.
|
||||
* @param args - The arguments to send.
|
||||
*/
|
||||
export const triggerClientEvent = (eventName: string, targetIds: number[] | number, ...args: unknown[]) => {
|
||||
if (!Array.isArray(targetIds)) {
|
||||
targetIds = [targetIds]
|
||||
}
|
||||
|
||||
if (targetIds.length < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-expect-error - msgpack_pack is not typed but available in the global scope.
|
||||
const dataSerialized = msgpack_pack(args)
|
||||
|
||||
for (const targetId of targetIds) {
|
||||
TriggerClientEventInternal(eventName, targetId.toString(), dataSerialized, dataSerialized.length)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Generate a random name and insert it into the database.
|
||||
*
|
||||
* @param src The ID of the player.
|
||||
* @param nameSet The set of names to check against.
|
||||
* @param namePattern The pattern to use for the name.
|
||||
*/
|
||||
export function generateRandomName(src: number, nameSet: Set<string>, namePattern: string): string | undefined {
|
||||
let name: string | undefined
|
||||
|
||||
const playerName = GetPlayerName(src.toString())
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
let generatedName = namePattern
|
||||
generatedName = generatedName.replace('{serverid}', src.toString())
|
||||
generatedName = generatedName.replace('{playername}', playerName)
|
||||
generatedName = generatedName.replace('{guid}', randomUUID().replace(/-/g, ''))
|
||||
generatedName = generatedName.slice(0, 30)
|
||||
|
||||
if (!nameSet.has(generatedName)) {
|
||||
name = generatedName
|
||||
nameSet.add(generatedName)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
console.error(`YaCA: Couldn't generate a random name for player ${playerName} (ID: ${src}).`)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from './cache'
|
||||
export * from './events'
|
||||
export * from './generator'
|
||||
export * from './versioncheck'
|
|
@ -0,0 +1,57 @@
|
|||
import fetch from 'node-fetch'
|
||||
import { cache } from './cache'
|
||||
|
||||
/**
|
||||
* Checks the version of the resource against the latest release on GitHub.
|
||||
* If the resource is outdated, a message will be printed to the console.
|
||||
*/
|
||||
export const checkVersion = async () => {
|
||||
const currentVersion = GetResourceMetadata(cache.resource, 'version', 0)
|
||||
|
||||
if (!currentVersion) {
|
||||
console.error('[YaCA] Version check failed, no version found in resource manifest.')
|
||||
return
|
||||
}
|
||||
|
||||
const parsedVersion = currentVersion.match(/\d+\.\d+\.\d+/g)
|
||||
|
||||
if (!parsedVersion) {
|
||||
console.error('[YaCA] Version check failed, version in resource manifest is not in the correct format.')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.github.com/repos/yaca-systems/fivem-yaca-typescript/releases/latest')
|
||||
if (response.status !== 200) {
|
||||
console.error('[YaCA] Version check failed, unable to fetch latest release.')
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tag_name: string; html_url: string }
|
||||
|
||||
const latestVersion = data.tag_name
|
||||
if (!latestVersion && latestVersion === currentVersion) {
|
||||
console.log('[YaCA] You are running the latest version of YaCA.')
|
||||
return
|
||||
}
|
||||
|
||||
const parsedLatestVersion = latestVersion.match(/\d+\.\d+\.\d+/g)
|
||||
if (!parsedLatestVersion) {
|
||||
console.error('[YaCA] Version check failed, latest release is not in the correct format.')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < parsedVersion.length; i++) {
|
||||
const current = Number.parseInt(parsedVersion[i])
|
||||
const latest = Number.parseInt(parsedLatestVersion[i])
|
||||
|
||||
if (current !== latest) {
|
||||
if (current < latest) {
|
||||
console.error(
|
||||
`[YaCA] You are running an outdated version of YaCA. (current: ${currentVersion}, latest: ${latestVersion}) \r\n ${data.html_url}`,
|
||||
)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from './main'
|
||||
export * from './megaphone'
|
||||
export * from './phone'
|
||||
export * from './radio'
|
394
resources/[voice]/yaca-voice/apps/yaca-server/src/yaca/main.ts
Normal file
394
resources/[voice]/yaca-voice/apps/yaca-server/src/yaca/main.ts
Normal file
|
@ -0,0 +1,394 @@
|
|||
import { GLOBAL_ERROR_LEVEL_STATE_NAME, getGlobalErrorLevel, initLocale, loadConfig, setGlobalErrorLevel, VOICE_RANGE_STATE_NAME } from '@yaca-voice/common'
|
||||
import {
|
||||
type DataObject,
|
||||
defaultServerConfig,
|
||||
defaultSharedConfig,
|
||||
defaultTowerConfig,
|
||||
type ServerCache,
|
||||
type YacaServerConfig,
|
||||
type YacaSharedConfig,
|
||||
type YacaTowerConfig,
|
||||
} from '@yaca-voice/types'
|
||||
import { YaCAServerSaltyChatBridge } from '../bridge/saltychat'
|
||||
import { checkVersion, generateRandomName } from '../utils'
|
||||
import { triggerClientEvent } from '../utils/events'
|
||||
import { YaCAServerMegaphoneModule } from './megaphone'
|
||||
import { YaCAServerPhoneModle } from './phone'
|
||||
import { YaCAServerRadioModule } from './radio'
|
||||
|
||||
/**
|
||||
* The player data type for YaCA.
|
||||
*/
|
||||
export type YaCAPlayer = {
|
||||
voiceSettings: {
|
||||
voiceFirstConnect: boolean
|
||||
forceMuted: boolean
|
||||
ingameName: string
|
||||
mutedOnPhone: boolean
|
||||
inCallWith: Set<number>
|
||||
emittedPhoneSpeaker: Map<number, Set<number>>
|
||||
}
|
||||
radioSettings: {
|
||||
activated: boolean
|
||||
hasLong: boolean
|
||||
frequencies: Record<number, string>
|
||||
}
|
||||
voicePlugin?: {
|
||||
playerId: number
|
||||
clientId: number
|
||||
forceMuted: boolean
|
||||
mutedOnPhone: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main server module for YaCA.
|
||||
*/
|
||||
export class YaCAServerModule {
|
||||
cache: ServerCache
|
||||
|
||||
nameSet: Set<string> = new Set()
|
||||
players: Map<number, YaCAPlayer> = new Map()
|
||||
|
||||
defaultVoiceRange: number
|
||||
|
||||
serverConfig: YacaServerConfig
|
||||
sharedConfig: YacaSharedConfig
|
||||
towerConfig: YacaTowerConfig
|
||||
|
||||
phoneModule: YaCAServerPhoneModle
|
||||
radioModule: YaCAServerRadioModule
|
||||
megaphoneModule: YaCAServerMegaphoneModule
|
||||
|
||||
saltChatBridge?: YaCAServerSaltyChatBridge
|
||||
|
||||
/**
|
||||
* Creates an instance of the server module.
|
||||
*/
|
||||
constructor() {
|
||||
console.log('~g~ --> YaCA: Server loaded')
|
||||
|
||||
this.serverConfig = loadConfig<YacaServerConfig>('config/server.json5', defaultServerConfig)
|
||||
this.sharedConfig = loadConfig<YacaSharedConfig>('config/shared.json5', defaultSharedConfig)
|
||||
this.towerConfig = loadConfig<YacaTowerConfig>('config/tower.json5', defaultTowerConfig)
|
||||
|
||||
initLocale(this.sharedConfig.locale)
|
||||
|
||||
if (this.sharedConfig.voiceRange.ranges[this.sharedConfig.voiceRange.defaultIndex]) {
|
||||
this.defaultVoiceRange = this.sharedConfig.voiceRange.ranges[this.sharedConfig.voiceRange.defaultIndex]
|
||||
} else {
|
||||
this.defaultVoiceRange = 1
|
||||
this.sharedConfig.voiceRange.ranges = [1]
|
||||
|
||||
console.error('[YaCA] Default voice range is not set correctly in the config.')
|
||||
}
|
||||
|
||||
this.phoneModule = new YaCAServerPhoneModle(this)
|
||||
this.radioModule = new YaCAServerRadioModule(this)
|
||||
this.megaphoneModule = new YaCAServerMegaphoneModule(this)
|
||||
|
||||
this.registerExports()
|
||||
this.registerEvents()
|
||||
|
||||
if (this.sharedConfig.saltyChatBridge) {
|
||||
this.saltChatBridge = new YaCAServerSaltyChatBridge(this)
|
||||
}
|
||||
|
||||
if (this.sharedConfig.versionCheck) {
|
||||
checkVersion().then()
|
||||
}
|
||||
|
||||
GlobalState.set(GLOBAL_ERROR_LEVEL_STATE_NAME, 0, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the player data for a specific player.
|
||||
*/
|
||||
getPlayer(playerId: number): YaCAPlayer | undefined {
|
||||
return this.players.get(playerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the player on first connect.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to initialize.
|
||||
*/
|
||||
connectToVoice(src: number) {
|
||||
const name = generateRandomName(src, this.nameSet, this.serverConfig.userNamePattern)
|
||||
if (!name) {
|
||||
DropPlayer(src.toString(), '[YaCA] Failed to generate a random name.')
|
||||
return
|
||||
}
|
||||
|
||||
const playerState = Player(src).state
|
||||
playerState.set(VOICE_RANGE_STATE_NAME, this.defaultVoiceRange, true)
|
||||
|
||||
this.players.set(src, {
|
||||
voiceSettings: {
|
||||
voiceFirstConnect: false,
|
||||
forceMuted: false,
|
||||
ingameName: name,
|
||||
mutedOnPhone: false,
|
||||
inCallWith: new Set<number>(),
|
||||
emittedPhoneSpeaker: new Map<number, Set<number>>(),
|
||||
},
|
||||
radioSettings: {
|
||||
activated: false,
|
||||
hasLong: true,
|
||||
frequencies: {},
|
||||
},
|
||||
})
|
||||
|
||||
this.connect(src)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all exports for the YaCA module.
|
||||
*/
|
||||
registerExports() {
|
||||
exports('connectToVoice', (src: number) => this.connectToVoice(src))
|
||||
/**
|
||||
* Get the alive status of a player.
|
||||
*
|
||||
* @param {number} playerId - The ID of the player to get the alive status for.
|
||||
* @returns {boolean} - The alive status of the player.
|
||||
*/
|
||||
exports('getPlayerAliveStatus', (playerId: number) => this.getPlayerAliveStatus(playerId))
|
||||
|
||||
/**
|
||||
* Set the alive status of a player.
|
||||
*
|
||||
* @param {number} playerId - The ID of the player to set the alive status for.
|
||||
* @param {boolean} state - The new alive status.
|
||||
*/
|
||||
exports('setPlayerAliveStatus', (playerId: number, state: boolean) => this.changePlayerAliveStatus(playerId, state))
|
||||
|
||||
/**
|
||||
* Get the voice range of a player.
|
||||
*
|
||||
* @param {number} playerId - The ID of the player to get the voice range for.
|
||||
* @returns {number} - The voice range of the player.
|
||||
*/
|
||||
exports('getPlayerVoiceRange', (playerId: number) => this.getPlayerVoiceRange(playerId))
|
||||
|
||||
/**
|
||||
* Set the voice range of a player.
|
||||
*
|
||||
* @param {number} playerId - The ID of the player to set the voice range for.
|
||||
*/
|
||||
exports('setPlayerVoiceRange', (playerId: number, range: number) => this.changeVoiceRange(playerId, range))
|
||||
|
||||
/**
|
||||
* Set the global error level.
|
||||
*
|
||||
* @param {number} errorLevel - The new error level. Between 0 and 1.
|
||||
*/
|
||||
exports('setGlobalErrorLevel', (errorLevel: number) => setGlobalErrorLevel(errorLevel))
|
||||
|
||||
/**
|
||||
* Get the global error level.
|
||||
*
|
||||
* @returns {number} - The global error level.
|
||||
*/
|
||||
exports('getGlobalErrorLevel', () => getGlobalErrorLevel())
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all events for the YaCA module.
|
||||
*/
|
||||
registerEvents() {
|
||||
// FiveM: player dropped
|
||||
on('playerDropped', (_reason: string) => {
|
||||
this.handlePlayerDisconnect(source)
|
||||
})
|
||||
|
||||
// YaCA: connect to voice when NUI is ready
|
||||
onNet('server:yaca:nuiReady', () => {
|
||||
if (!this.sharedConfig.autoConnectOnJoin) return
|
||||
this.connectToVoice(source)
|
||||
})
|
||||
|
||||
// YaCA:successful voice connection and client-id sync
|
||||
onNet('server:yaca:addPlayer', (clientId: number) => {
|
||||
this.addNewPlayer(source, clientId)
|
||||
})
|
||||
|
||||
// YaCa: voice restart
|
||||
onNet('server:yaca:wsReady', () => {
|
||||
this.playerReconnect(source)
|
||||
})
|
||||
|
||||
// TxAdmin: spectate stop event
|
||||
onNet('txsv:req:spectate:end', () => {
|
||||
emitNet('client:yaca:txadmin:stopspectate', source)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle various cases if player disconnects.
|
||||
*
|
||||
* @param {number} src - The source-id of the player who disconnected.
|
||||
*/
|
||||
handlePlayerDisconnect(src: number) {
|
||||
const player = this.players.get(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
this.nameSet.delete(player.voiceSettings?.ingameName)
|
||||
|
||||
const allFrequencies = this.radioModule.radioFrequencyMap
|
||||
for (const [key, value] of allFrequencies) {
|
||||
value.delete(src)
|
||||
if (!value.size) {
|
||||
this.radioModule.radioFrequencyMap.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [targetId, emitterTargets] of player.voiceSettings.emittedPhoneSpeaker) {
|
||||
const target = this.players.get(targetId)
|
||||
if (!target || !target.voicePlugin) {
|
||||
continue
|
||||
}
|
||||
|
||||
triggerClientEvent('client:yaca:phoneHearAround', Array.from(emitterTargets), [target.voicePlugin.clientId], false)
|
||||
}
|
||||
|
||||
emitNet('client:yaca:disconnect', -1, src)
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs player alive status and mute him if he is dead or whatever.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to sync.
|
||||
* @param {boolean} alive - The new alive status.
|
||||
*/
|
||||
changePlayerAliveStatus(src: number, alive: boolean) {
|
||||
const player = this.players.get(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
player.voiceSettings.forceMuted = !alive
|
||||
emitNet('client:yaca:muteTarget', -1, src, !alive)
|
||||
|
||||
if (player.voicePlugin) {
|
||||
player.voicePlugin.forceMuted = !alive
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the alive status of a player.
|
||||
*
|
||||
* @param playerId - The ID of the player to get the alive status for.
|
||||
*/
|
||||
getPlayerAliveStatus(playerId: number) {
|
||||
return this.players.get(playerId)?.voiceSettings.forceMuted ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Used if a player reconnects to the server.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to reconnect.
|
||||
*/
|
||||
playerReconnect(src: number) {
|
||||
const player = this.players.get(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!player.voiceSettings.voiceFirstConnect) {
|
||||
return
|
||||
}
|
||||
|
||||
this.connect(src)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the voice range of a player.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to change the voice range for.
|
||||
* @param {number} range - The new voice range. Defaults to the default voice range if not provided.
|
||||
*/
|
||||
changeVoiceRange(src: number, range?: number) {
|
||||
const playerState = Player(src).state
|
||||
|
||||
playerState.set(VOICE_RANGE_STATE_NAME, range ?? this.defaultVoiceRange, true)
|
||||
emitNet('client:yaca:changeVoiceRange', src, range)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the voice range of a player.
|
||||
*
|
||||
* @param playerId - The ID of the player to get the voice range for.
|
||||
*/
|
||||
getPlayerVoiceRange(playerId: number) {
|
||||
const playerState = Player(playerId).state
|
||||
return playerState[VOICE_RANGE_STATE_NAME] ?? this.defaultVoiceRange
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends initial data needed to connect to teamspeak plugin.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to connect
|
||||
*/
|
||||
connect(src: number) {
|
||||
const player = this.players.get(src)
|
||||
if (!player) {
|
||||
console.error(`YaCA: Missing player data for ${src}.`)
|
||||
return
|
||||
}
|
||||
|
||||
player.voiceSettings.voiceFirstConnect = true
|
||||
|
||||
const initObject: DataObject = {
|
||||
suid: this.serverConfig.uniqueServerId,
|
||||
chid: this.serverConfig.ingameChannelId,
|
||||
deChid: this.serverConfig.defaultChannelId,
|
||||
channelPassword: this.serverConfig.ingameChannelPassword,
|
||||
ingameName: player.voiceSettings.ingameName,
|
||||
useWhisper: this.serverConfig.useWhisper,
|
||||
excludeChannels: this.serverConfig.excludeChannels,
|
||||
}
|
||||
emitNet('client:yaca:init', src, initObject)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new player to all other players on connect or reconnect, so they know about some variables.
|
||||
*
|
||||
* @param src - The source-id of the player to add.
|
||||
* @param {number} clientId - The client ID of the player.
|
||||
*/
|
||||
addNewPlayer(src: number, clientId: number) {
|
||||
const player = this.players.get(src)
|
||||
if (!player || !clientId) {
|
||||
return
|
||||
}
|
||||
|
||||
player.voicePlugin = {
|
||||
playerId: src,
|
||||
clientId,
|
||||
forceMuted: player.voiceSettings.forceMuted,
|
||||
mutedOnPhone: player.voiceSettings.mutedOnPhone,
|
||||
}
|
||||
|
||||
emitNet('client:yaca:addPlayers', -1, player.voicePlugin)
|
||||
|
||||
const allPlayersData = []
|
||||
for (const playerSource of getPlayers()) {
|
||||
const intPlayerSource = Number.parseInt(playerSource)
|
||||
const playerServer = this.players.get(intPlayerSource)
|
||||
if (!playerServer) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!playerServer.voicePlugin || intPlayerSource === src) {
|
||||
continue
|
||||
}
|
||||
|
||||
allPlayersData.push(playerServer.voicePlugin)
|
||||
}
|
||||
|
||||
emitNet('client:yaca:addPlayers', src, allPlayersData)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { MEGAPHONE_STATE_NAME } from '@yaca-voice/common'
|
||||
import type { YacaSharedConfig } from '@yaca-voice/types'
|
||||
import type { YaCAServerModule } from './main'
|
||||
|
||||
/**
|
||||
* The server-side megaphone module.
|
||||
*/
|
||||
export class YaCAServerMegaphoneModule {
|
||||
private serverModule: YaCAServerModule
|
||||
private sharedConfig: YacaSharedConfig
|
||||
|
||||
/**
|
||||
* Creates an instance of the megaphone module.
|
||||
*
|
||||
* @param serverModule - The server module.
|
||||
*/
|
||||
constructor(serverModule: YaCAServerModule) {
|
||||
this.serverModule = serverModule
|
||||
this.sharedConfig = serverModule.sharedConfig
|
||||
|
||||
this.registerEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register server events.
|
||||
*/
|
||||
registerEvents() {
|
||||
/**
|
||||
* Changes megaphone state by player
|
||||
*
|
||||
* @param {boolean} state - The state of the megaphone effect.
|
||||
*/
|
||||
onNet('server:yaca:useMegaphone', (state: boolean) => {
|
||||
this.playerUseMegaphone(source, state)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "server:yaca:playerLeftVehicle" event.
|
||||
*/
|
||||
onNet('server:yaca:playerLeftVehicle', () => {
|
||||
this.changeMegaphoneState(source, false, true)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the megaphone effect on a specific player via client event.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to apply the megaphone effect to.
|
||||
* @param {boolean} state - The state of the megaphone effect.
|
||||
*/
|
||||
playerUseMegaphone(src: number, state: boolean) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerState = Player(src).state
|
||||
|
||||
if ((!state && !playerState[MEGAPHONE_STATE_NAME]) || (state && playerState[MEGAPHONE_STATE_NAME])) {
|
||||
return
|
||||
}
|
||||
|
||||
this.changeMegaphoneState(src, state)
|
||||
emit('yaca:external:changeMegaphoneState', src, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the megaphone effect on a specific player.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to apply the megaphone effect to.
|
||||
* @param {boolean} state - The state of the megaphone effect.
|
||||
* @param {boolean} [forced=false] - Whether the change is forced. Defaults to false if not provided.
|
||||
*/
|
||||
changeMegaphoneState(src: number, state: boolean, forced = false) {
|
||||
const playerState = Player(src).state
|
||||
|
||||
if (!state && playerState[MEGAPHONE_STATE_NAME]) {
|
||||
playerState.set(MEGAPHONE_STATE_NAME, null, true)
|
||||
if (forced) {
|
||||
emitNet('client:yaca:setLastMegaphoneState', src, false)
|
||||
}
|
||||
} else if (state && !playerState[MEGAPHONE_STATE_NAME]) {
|
||||
playerState.set(MEGAPHONE_STATE_NAME, this.sharedConfig.megaphone.range, true)
|
||||
}
|
||||
}
|
||||
}
|
278
resources/[voice]/yaca-voice/apps/yaca-server/src/yaca/phone.ts
Normal file
278
resources/[voice]/yaca-voice/apps/yaca-server/src/yaca/phone.ts
Normal file
|
@ -0,0 +1,278 @@
|
|||
import { PHONE_SPEAKER_STATE_NAME } from '@yaca-voice/common'
|
||||
import { YacaFilterEnum } from '@yaca-voice/types'
|
||||
import { triggerClientEvent } from '../utils/events'
|
||||
import type { YaCAServerModule } from './main'
|
||||
|
||||
/**
|
||||
* The phone module for the server.
|
||||
*/
|
||||
export class YaCAServerPhoneModle {
|
||||
private serverModule: YaCAServerModule
|
||||
|
||||
/**
|
||||
* Creates an instance of the phone module.
|
||||
*
|
||||
* @param {YaCAServerModule} serverModule - The server module.
|
||||
*/
|
||||
constructor(serverModule: YaCAServerModule) {
|
||||
this.serverModule = serverModule
|
||||
|
||||
this.registerEvents()
|
||||
this.registerExports()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register server events.
|
||||
*/
|
||||
registerEvents() {
|
||||
/**
|
||||
* Handles the "server:yaca:phoneSpeakerEmitWhisper" event.
|
||||
*
|
||||
* @param {number[]} enableForTargets - The IDs of the players to enable the phone speaker for.
|
||||
* @param {number[]} disableForTargets - The IDs of the players to disable the phone speaker for.
|
||||
*/
|
||||
onNet('server:yaca:phoneSpeakerEmitWhisper', (enableForTargets?: number[], disableForTargets?: number[]) => {
|
||||
const player = this.serverModule.players.get(source)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const targets = new Set<number>()
|
||||
|
||||
for (const callTarget of player.voiceSettings.inCallWith) {
|
||||
const target = this.serverModule.players.get(callTarget)
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
|
||||
targets.add(callTarget)
|
||||
}
|
||||
|
||||
if (targets.size && enableForTargets?.length) {
|
||||
triggerClientEvent('client:yaca:playersToPhoneSpeakerEmitWhisper', Array.from(targets), enableForTargets, true)
|
||||
}
|
||||
|
||||
if (targets.size && disableForTargets?.length) {
|
||||
triggerClientEvent('client:yaca:playersToPhoneSpeakerEmitWhisper', Array.from(targets), disableForTargets, false)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "server:yaca:phoneEmit" event.
|
||||
*
|
||||
* @param {number[]} enableForTargets - The IDs of the players to enable the phone speaker for.
|
||||
* @param {number[]} disableForTargets - The IDs of the players to disable the phone speaker for.
|
||||
*/
|
||||
onNet('server:yaca:phoneEmit', (enableForTargets?: number[], disableForTargets?: number[]) => {
|
||||
if (!this.serverModule.sharedConfig.phoneHearPlayersNearby) return
|
||||
|
||||
const player = this.serverModule.players.get(source)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const enableReceive = new Set<number>()
|
||||
const disableReceive = new Set<number>()
|
||||
|
||||
if (enableForTargets?.length) {
|
||||
for (const callTarget of player.voiceSettings.inCallWith) {
|
||||
const target = this.serverModule.players.get(callTarget)
|
||||
if (!target) continue
|
||||
|
||||
enableReceive.add(callTarget)
|
||||
|
||||
for (const targetID of enableForTargets) {
|
||||
const map = player.voiceSettings.emittedPhoneSpeaker
|
||||
const set = map.get(targetID) ?? new Set<number>()
|
||||
set.add(callTarget)
|
||||
map.set(targetID, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (disableForTargets?.length) {
|
||||
for (const targetID of disableForTargets) {
|
||||
const emittedFor = player.voiceSettings.emittedPhoneSpeaker.get(targetID)
|
||||
if (!emittedFor) continue
|
||||
|
||||
for (const emittedTarget of emittedFor) {
|
||||
const target = this.serverModule.players.get(emittedTarget)
|
||||
if (!target) continue
|
||||
|
||||
disableReceive.add(emittedTarget)
|
||||
}
|
||||
|
||||
player.voiceSettings.emittedPhoneSpeaker.delete(targetID)
|
||||
}
|
||||
}
|
||||
|
||||
if (enableReceive.size && enableForTargets?.length) {
|
||||
const enableForTargetsData = new Set<number>()
|
||||
|
||||
for (const enableTarget of enableForTargets) {
|
||||
const target = this.serverModule.players.get(enableTarget)
|
||||
if (!target || !target.voicePlugin) continue
|
||||
|
||||
enableForTargetsData.add(target.voicePlugin.clientId)
|
||||
}
|
||||
|
||||
triggerClientEvent('client:yaca:phoneHearAround', Array.from(enableReceive), Array.from(enableForTargetsData), true)
|
||||
}
|
||||
|
||||
if (disableReceive.size && disableForTargets?.length) {
|
||||
const disableForTargetsData = new Set<number>()
|
||||
|
||||
for (const disableTarget of disableForTargets) {
|
||||
const target = this.serverModule.players.get(disableTarget)
|
||||
if (!target || !target.voicePlugin) continue
|
||||
|
||||
disableForTargetsData.add(target.voicePlugin.clientId)
|
||||
}
|
||||
|
||||
triggerClientEvent('client:yaca:phoneHearAround', Array.from(disableReceive), Array.from(disableForTargetsData), false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerExports() {
|
||||
/**
|
||||
* Creates a phone call between two players.
|
||||
*
|
||||
* @param {number} src - The player who is making the call.
|
||||
* @param {number} target - The player who is being called.
|
||||
* @param {boolean} state - The state of the call.
|
||||
*/
|
||||
exports('callPlayer', (src: number, target: number, state: boolean) => this.callPlayer(src, target, state))
|
||||
|
||||
/**
|
||||
* Creates a phone call between two players with the old effect.
|
||||
*
|
||||
* @param {number} src - The player who is making the call.
|
||||
* @param {number} target - The player who is being called.
|
||||
* @param {boolean} state - The state of the call.
|
||||
*/
|
||||
exports('callPlayerOldEffect', (src: number, target: number, state: boolean) => this.callPlayer(src, target, state, YacaFilterEnum.PHONE_HISTORICAL))
|
||||
|
||||
/**
|
||||
* Mute a player during a phone call.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to mute.
|
||||
* @param {boolean} state - The mute state.
|
||||
*/
|
||||
exports('muteOnPhone', (src: number, state: boolean) => this.muteOnPhone(src, state))
|
||||
|
||||
/**
|
||||
* Enable or disable the phone speaker for a player.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to enable the phone speaker for.
|
||||
* @param {boolean} state - The state of the phone speaker.
|
||||
*/
|
||||
exports('enablePhoneSpeaker', (src: number, state: boolean) => this.enablePhoneSpeaker(src, state))
|
||||
|
||||
/**
|
||||
* Is player in a phone call.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to check.
|
||||
*/
|
||||
exports('isPlayerInCall', (src: number): [boolean, number[]] => {
|
||||
const player = this.serverModule.players.get(src)
|
||||
if (!player) {
|
||||
return [false, []]
|
||||
}
|
||||
|
||||
return [player.voiceSettings.inCallWith.size > 0, [...player.voiceSettings.inCallWith]]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Call another player.
|
||||
*
|
||||
* @param {number} src - The player who is making the call.
|
||||
* @param {number} target - The player who is being called.
|
||||
* @param {boolean} state - The state of the call.
|
||||
* @param {YacaFilterEnum} filter - The filter to use for the call. Defaults to PHONE if not provided.
|
||||
*/
|
||||
callPlayer(src: number, target: number, state: boolean, filter: YacaFilterEnum = YacaFilterEnum.PHONE) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
const targetPlayer = this.serverModule.getPlayer(target)
|
||||
if (!player || !targetPlayer) {
|
||||
return
|
||||
}
|
||||
|
||||
emitNet('client:yaca:phone', target, src, state, filter)
|
||||
emitNet('client:yaca:phone', src, target, state, filter)
|
||||
|
||||
const playerState = Player(src).state
|
||||
const targetState = Player(target).state
|
||||
|
||||
if (state) {
|
||||
player.voiceSettings.inCallWith.add(target)
|
||||
targetPlayer.voiceSettings.inCallWith.add(src)
|
||||
|
||||
if (playerState[PHONE_SPEAKER_STATE_NAME]) {
|
||||
this.enablePhoneSpeaker(src, true)
|
||||
}
|
||||
|
||||
if (targetState[PHONE_SPEAKER_STATE_NAME]) {
|
||||
this.enablePhoneSpeaker(target, true)
|
||||
}
|
||||
} else {
|
||||
this.muteOnPhone(src, false, true)
|
||||
this.muteOnPhone(target, false, true)
|
||||
|
||||
player.voiceSettings.inCallWith.delete(target)
|
||||
targetPlayer.voiceSettings.inCallWith.delete(src)
|
||||
|
||||
if (playerState[PHONE_SPEAKER_STATE_NAME]) {
|
||||
this.enablePhoneSpeaker(src, false)
|
||||
}
|
||||
|
||||
if (targetState[PHONE_SPEAKER_STATE_NAME]) {
|
||||
this.enablePhoneSpeaker(target, false)
|
||||
}
|
||||
}
|
||||
|
||||
emit('yaca:external:phoneCall', src, target, state, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute a player during a phone call.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to mute.
|
||||
* @param {boolean} state - The mute state.
|
||||
* @param {boolean} [onCallStop=false] - Whether the call has stopped. Defaults to false if not provided.
|
||||
*/
|
||||
muteOnPhone(src: number, state: boolean, onCallStop = false) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
player.voiceSettings.mutedOnPhone = state
|
||||
emitNet('client:yaca:phoneMute', -1, src, state, onCallStop)
|
||||
emit('yaca:external:phoneMute', src, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the phone speaker for a player.
|
||||
*
|
||||
* @param {number} src - The source-id of the player to enable the phone speaker for.
|
||||
* @param {boolean} state - The state of the phone speaker.
|
||||
*/
|
||||
enablePhoneSpeaker(src: number, state: boolean) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerState = Player(src).state
|
||||
|
||||
if (state && player.voiceSettings.inCallWith.size) {
|
||||
playerState.set(PHONE_SPEAKER_STATE_NAME, Array.from(player.voiceSettings.inCallWith), true)
|
||||
emit('yaca:external:phoneSpeaker', src, true)
|
||||
} else {
|
||||
playerState.set(PHONE_SPEAKER_STATE_NAME, null, true)
|
||||
emit('yaca:external:phoneSpeaker', src, false)
|
||||
}
|
||||
}
|
||||
}
|
379
resources/[voice]/yaca-voice/apps/yaca-server/src/yaca/radio.ts
Normal file
379
resources/[voice]/yaca-voice/apps/yaca-server/src/yaca/radio.ts
Normal file
|
@ -0,0 +1,379 @@
|
|||
import { locale } from '@yaca-voice/common'
|
||||
import { YacaNotificationType, type YacaServerConfig, type YacaSharedConfig } from '@yaca-voice/types'
|
||||
import { triggerClientEvent } from '../utils/events'
|
||||
import type { YaCAServerModule } from './main'
|
||||
|
||||
/**
|
||||
* The server-side radio module.
|
||||
*/
|
||||
export class YaCAServerRadioModule {
|
||||
private serverModule: YaCAServerModule
|
||||
private sharedConfig: YacaSharedConfig
|
||||
private serverConfig: YacaServerConfig
|
||||
|
||||
radioFrequencyMap = new Map<string, Map<number, { muted: boolean }>>()
|
||||
|
||||
/**
|
||||
* Creates an instance of the radio module.
|
||||
*
|
||||
* @param {YaCAServerModule} serverModule - The server module.
|
||||
*/
|
||||
constructor(serverModule: YaCAServerModule) {
|
||||
this.serverModule = serverModule
|
||||
this.sharedConfig = serverModule.sharedConfig
|
||||
this.serverConfig = serverModule.serverConfig
|
||||
|
||||
this.registerEvents()
|
||||
this.registerExports()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register server events.
|
||||
*/
|
||||
registerEvents() {
|
||||
/**
|
||||
* Handles the "server:yaca:enableRadio" server event.
|
||||
*
|
||||
* @param {boolean} state - The state of the radio.
|
||||
*/
|
||||
onNet('server:yaca:enableRadio', (state: boolean) => {
|
||||
this.enableRadio(source, state)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "server:yaca:changeRadioFrequency" server event.
|
||||
*
|
||||
* @param {number} channel - The channel to change the frequency of.
|
||||
* @param {string} frequency - The new frequency.
|
||||
*/
|
||||
onNet('server:yaca:changeRadioFrequency', (channel: number, frequency: string) => {
|
||||
this.changeRadioFrequency(source, channel, frequency)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "server:yaca:muteRadioChannel" server event.
|
||||
*
|
||||
* @param {number} channel - The channel to mute.
|
||||
*/
|
||||
onNet('server:yaca:muteRadioChannel', (channel: number, state?: boolean) => {
|
||||
this.radioChannelMute(source, channel, state)
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the "server:yaca:radioTalking" server event.
|
||||
*
|
||||
* @param {boolean} state - The state of the radio.
|
||||
* @param {number} channel - The channel to change the talking state for.
|
||||
* @param {number} distanceToTower - The distance to the tower.
|
||||
*/
|
||||
onNet('server:yaca:radioTalking', (state: boolean, channel: number, distanceToTower = -1) => {
|
||||
this.radioTalkingState(source, state, channel, distanceToTower)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register server exports.
|
||||
*/
|
||||
registerExports() {
|
||||
/**
|
||||
* Get all players in a radio frequency.
|
||||
*
|
||||
* @param {string} frequency - The frequency to get the players for.
|
||||
* @returns {number[]} - The players in the radio frequency.
|
||||
*/
|
||||
exports('getPlayersInRadioFrequency', (frequency: string) => this.getPlayersInRadioFrequency(frequency))
|
||||
|
||||
/**
|
||||
* Set the radio channel for a player.
|
||||
*
|
||||
* @param {number} src - The player to set the radio channel for.
|
||||
* @param {number} channel - The channel to set.
|
||||
* @param {string} frequency - The frequency to set.
|
||||
*/
|
||||
exports('setPlayerRadioChannel', (src: number, channel: number, frequency: string) => this.changeRadioFrequency(src, channel, frequency))
|
||||
|
||||
/**
|
||||
* Get if a player has long range radio.
|
||||
*
|
||||
* @param {number} src - The player to set the long range radio for.
|
||||
*/
|
||||
exports('getPlayerHasLongRange', (src: number) => this.getPlayerHasLongRange(src))
|
||||
|
||||
/**
|
||||
* Set if a player has long range radio.
|
||||
*
|
||||
* @param {number} src - The player to set the long range radio for.
|
||||
* @param {boolean} state - The new state of the long range radio.
|
||||
*/
|
||||
exports('setPlayerHasLongRange', (src: number, state: boolean) => this.setPlayerHasLongRange(src, state))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all players in a radio frequency.
|
||||
*
|
||||
* @param frequency - The frequency to get the players for.
|
||||
*/
|
||||
getPlayersInRadioFrequency(frequency: string) {
|
||||
const allPlayersInChannel = this.radioFrequencyMap.get(frequency)
|
||||
const playersArray: number[] = []
|
||||
|
||||
if (!allPlayersInChannel) {
|
||||
return playersArray
|
||||
}
|
||||
|
||||
for (const [key] of allPlayersInChannel) {
|
||||
const target = this.serverModule.getPlayer(key)
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
playersArray.push(key)
|
||||
}
|
||||
return playersArray
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets if a player has long range radio.
|
||||
*
|
||||
* @param src - The player to get the long range radio for.
|
||||
*/
|
||||
getPlayerHasLongRange(src: number) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return false
|
||||
}
|
||||
|
||||
return player.radioSettings.hasLong
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets if a player has long range radio.
|
||||
*
|
||||
* @param src - The player to set the long range radio for.
|
||||
* @param state - The new state of the long range radio.
|
||||
*/
|
||||
setPlayerHasLongRange(src: number, state: boolean) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
player.radioSettings.hasLong = state
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the radio for a player.
|
||||
*
|
||||
* @param {number} src - The player to enable or disable the radio for.
|
||||
* @param {boolean} state - The new state of the radio.
|
||||
*/
|
||||
enableRadio(src: number, state: boolean) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
player.radioSettings.activated = state
|
||||
|
||||
emit('yaca:export:enabledRadio', src, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the radio frequency for a player.
|
||||
*
|
||||
* @param {number} src - The player to change the radio frequency for.
|
||||
* @param {number} channel - The channel to change the frequency of.
|
||||
* @param {string} frequency - The new frequency.
|
||||
*/
|
||||
changeRadioFrequency(src: number, channel: number, frequency: string) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!player.radioSettings.activated) {
|
||||
emitNet('client:yaca:notification', src, locale('radio_not_activated'), YacaNotificationType.ERROR)
|
||||
return
|
||||
}
|
||||
|
||||
if (Number.isNaN(channel) || channel < 1 || channel > this.sharedConfig.radioSettings.channelCount) {
|
||||
emitNet('client:yaca:notification', src, locale('radio_channel_invalid'), YacaNotificationType.ERROR)
|
||||
return
|
||||
}
|
||||
|
||||
const oldFrequency = player.radioSettings.frequencies[channel]
|
||||
|
||||
// Leave the old frequency if the new one is 0
|
||||
if (frequency === '0') {
|
||||
this.leaveRadioFrequency(src, channel, oldFrequency)
|
||||
return
|
||||
}
|
||||
|
||||
// Leave the old frequency if it's different from the new one
|
||||
if (oldFrequency !== frequency) {
|
||||
this.leaveRadioFrequency(src, channel, oldFrequency)
|
||||
}
|
||||
|
||||
// Add player to channel map, so we know who is in which channel
|
||||
if (!this.radioFrequencyMap.has(frequency)) {
|
||||
this.radioFrequencyMap.set(frequency, new Map<number, { muted: boolean }>())
|
||||
}
|
||||
this.radioFrequencyMap.get(frequency)?.set(src, { muted: false })
|
||||
|
||||
player.radioSettings.frequencies[channel] = frequency
|
||||
|
||||
emitNet('client:yaca:setRadioFreq', src, channel, frequency)
|
||||
emit('yaca:external:changedRadioFrequency', src, channel, frequency)
|
||||
|
||||
/*
|
||||
* TODO: Add radio effect to player in new frequency
|
||||
* const newPlayers = this.getPlayersInRadioFrequency(frequency);
|
||||
* if (newPlayers.length) alt.emitClientRaw(newPlayers, "client:yaca:setRadioEffectInFrequency", frequency, player.id);
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a player leave a radio frequency.
|
||||
*
|
||||
* @param {number} src - The player to leave the radio frequency.
|
||||
* @param {number} channel - The channel to leave.
|
||||
* @param {string} frequency - The frequency to leave.
|
||||
*/
|
||||
leaveRadioFrequency(src: number, channel: number, frequency: string) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const allPlayersInChannel = this.radioFrequencyMap.get(frequency)
|
||||
if (!allPlayersInChannel) {
|
||||
return
|
||||
}
|
||||
|
||||
player.radioSettings.frequencies[channel] = '0'
|
||||
|
||||
const playersArray = []
|
||||
const allTargets = []
|
||||
for (const [key] of allPlayersInChannel) {
|
||||
const target = this.serverModule.getPlayer(key)
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
|
||||
playersArray.push(key)
|
||||
|
||||
if (key === src) {
|
||||
continue
|
||||
}
|
||||
|
||||
allTargets.push(key)
|
||||
}
|
||||
|
||||
if (this.serverConfig.useWhisper) {
|
||||
emitNet('client:yaca:radioTalking', src, allTargets, frequency, false, null, true)
|
||||
} else if (player.voicePlugin) {
|
||||
triggerClientEvent('client:yaca:leaveRadioChannel', playersArray, player.voicePlugin.clientId, frequency)
|
||||
}
|
||||
|
||||
allPlayersInChannel.delete(src)
|
||||
if (!allPlayersInChannel.size) {
|
||||
this.radioFrequencyMap.delete(frequency)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute a radio channel for a player.
|
||||
*
|
||||
* @param {number} src - The player to mute the radio channel for.
|
||||
* @param {number} channel - The channel to mute.
|
||||
*/
|
||||
radioChannelMute(src: number, channel: number, state?: boolean) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const radioFrequency = player.radioSettings.frequencies[channel]
|
||||
const foundPlayer = this.radioFrequencyMap.get(radioFrequency)?.get(src)
|
||||
if (!foundPlayer) {
|
||||
return
|
||||
}
|
||||
|
||||
foundPlayer.muted = typeof state !== 'undefined' ? state : !foundPlayer.muted
|
||||
emitNet('client:yaca:setRadioMuteState', src, channel, foundPlayer.muted)
|
||||
emit('yaca:external:changedRadioMuteState', src, channel, foundPlayer.muted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the talking state of a player on the radio.
|
||||
*
|
||||
* @param {number} src - The player to change the talking state for.
|
||||
* @param {boolean} state - The new talking state.
|
||||
* @param {number} channel - The channel to change the talking state for.
|
||||
* @param {number} distanceToTower - The distance to the tower.
|
||||
*/
|
||||
radioTalkingState(src: number, state: boolean, channel: number, distanceToTower: number) {
|
||||
const player = this.serverModule.getPlayer(src)
|
||||
if (!player || !player.radioSettings.activated) {
|
||||
return
|
||||
}
|
||||
|
||||
const radioFrequency = player.radioSettings.frequencies[channel]
|
||||
if (!radioFrequency || radioFrequency === '0') {
|
||||
return
|
||||
}
|
||||
|
||||
const getPlayers = this.radioFrequencyMap.get(radioFrequency)
|
||||
if (!getPlayers) {
|
||||
return
|
||||
}
|
||||
|
||||
let targets: number[] = []
|
||||
const targetsToSender: number[] = []
|
||||
const radioInfos: Record<number, { shortRange: boolean }> = {}
|
||||
|
||||
for (const [key, values] of getPlayers) {
|
||||
if (values.muted) {
|
||||
if (key === src) {
|
||||
targets = []
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === src) {
|
||||
continue
|
||||
}
|
||||
|
||||
const target = this.serverModule.getPlayer(key)
|
||||
if (!target || !target.radioSettings.activated) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shortRange = !player.radioSettings.hasLong && !target.radioSettings.hasLong
|
||||
if ((player.radioSettings.hasLong && target.radioSettings.hasLong) || shortRange) {
|
||||
targets.push(key)
|
||||
|
||||
radioInfos[key] = {
|
||||
shortRange,
|
||||
}
|
||||
|
||||
targetsToSender.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
triggerClientEvent(
|
||||
'client:yaca:radioTalking',
|
||||
targets,
|
||||
src,
|
||||
radioFrequency,
|
||||
state,
|
||||
radioInfos,
|
||||
distanceToTower,
|
||||
GetEntityCoords(GetPlayerPed(src.toString())),
|
||||
)
|
||||
|
||||
if (this.serverConfig.useWhisper) {
|
||||
emitNet('client:yaca:radioTalkingWhisper', src, targetsToSender, radioFrequency, state, GetEntityCoords(GetPlayerPed(src.toString())))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "@yaca-voice/typescript-config/fivem.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"types": ["@types/node", "@citizenfx/server"]
|
||||
},
|
||||
"include": ["./"]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue