Yaca Install

This commit is contained in:
Miho931 2025-06-30 21:56:25 +02:00
parent b622af6e3b
commit cc47e529cc
68 changed files with 11192 additions and 0 deletions

View 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))

View 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:*"
}
}

File diff suppressed because it is too large Load diff

View file

@ -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()
}
}

View file

@ -0,0 +1,8 @@
/// <reference types="@citizenfx/client" />
import { initCache } from './utils'
import { YaCAClientModule } from './yaca'
initCache()
new YaCAClientModule()

View file

@ -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 }

View file

@ -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]),
}
}

View file

@ -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
}

View file

@ -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()
}
})
}

View file

@ -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)
}

View file

@ -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]),
}
}

View file

@ -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
}

View file

@ -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',
}),
)
}
}

View file

@ -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 }

View file

@ -0,0 +1,6 @@
export * from './data'
export * from './intercom'
export * from './main'
export * from './megaphone'
export * from './phone'
export * from './radio'

View file

@ -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,
)
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -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)
}
}

View 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,
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
{
"extends": "@yaca-voice/typescript-config/fivem.json",
"compilerOptions": {
"baseUrl": "./",
"rootDir": "./src",
"types": ["@citizenfx/client"]
},
"include": ["./"]
}

View 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))

View 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"
}
}

File diff suppressed because it is too large Load diff

View file

@ -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)
}
}
}
}
}

View file

@ -0,0 +1,5 @@
/// <reference types="@citizenfx/server" />
import { YaCAServerModule } from 'src/yaca'
new YaCAServerModule()

View file

@ -0,0 +1,8 @@
import type { ServerCache } from '@yaca-voice/types'
/**
* Cached values for the server.
*/
export const cache: ServerCache = {
resource: GetCurrentResourceName(),
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -0,0 +1,4 @@
export * from './cache'
export * from './events'
export * from './generator'
export * from './versioncheck'

View file

@ -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
}
}
}
}

View file

@ -0,0 +1,4 @@
export * from './main'
export * from './megaphone'
export * from './phone'
export * from './radio'

View 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)
}
}

View file

@ -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)
}
}
}

View 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)
}
}
}

View 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())))
}
}
}

View file

@ -0,0 +1,9 @@
{
"extends": "@yaca-voice/typescript-config/fivem.json",
"compilerOptions": {
"baseUrl": "./",
"rootDir": "./src",
"types": ["@types/node", "@citizenfx/server"]
},
"include": ["./"]
}