mirror of
https://github.com/Pax1601/DCSOlympus.git
synced 2025-10-29 16:56:34 +00:00
feat: Implemented server mode
This commit is contained in:
parent
032b74b57b
commit
c2d5d4ea17
@ -23,14 +23,12 @@ import {
|
||||
} from "../events";
|
||||
import { OlympusConfig } from "../interfaces";
|
||||
import { TextToSpeechSource } from "./texttospeechsource";
|
||||
import { SpeechController } from "./speechcontroller";
|
||||
|
||||
export class AudioManager {
|
||||
#audioContext: AudioContext;
|
||||
#devices: MediaDeviceInfo[] = [];
|
||||
#input: MediaDeviceInfo;
|
||||
#output: MediaDeviceInfo;
|
||||
#speechController: SpeechController;
|
||||
|
||||
/* The playback pipeline enables audio playback on the speakers/headphones */
|
||||
#playbackPipeline: PlaybackPipeline;
|
||||
@ -72,8 +70,6 @@ export class AudioManager {
|
||||
altKey: false,
|
||||
});
|
||||
});
|
||||
|
||||
this.#speechController = new SpeechController();
|
||||
}
|
||||
|
||||
start() {
|
||||
@ -93,7 +89,7 @@ export class AudioManager {
|
||||
if (res === null) res = location.toString().match(/(?:http|https):\/\/(.+)/);
|
||||
|
||||
let wsAddress = res ? res[1] : location.toString();
|
||||
if (wsAddress.at(wsAddress.length - 1) === "/") wsAddress = wsAddress.substring(0, wsAddress.length - 1)
|
||||
if (wsAddress.at(wsAddress.length - 1) === "/") wsAddress = wsAddress.substring(0, wsAddress.length - 1);
|
||||
if (this.#endpoint) this.#socket = new WebSocket(`wss://${wsAddress}/${this.#endpoint}`);
|
||||
else if (this.#port) this.#socket = new WebSocket(`ws://${wsAddress}:${this.#port}`);
|
||||
else console.error("The audio backend was enabled but no port/endpoint was provided in the configuration");
|
||||
@ -127,6 +123,8 @@ export class AudioManager {
|
||||
if (sink.getFrequency() === frequencyInfo.frequency && sink.getModulation() === frequencyInfo.modulation && sink.getTuned()) {
|
||||
sink.setReceiving(true);
|
||||
|
||||
sink.setTransmittingUnit(getApp().getUnitsManager().getUnitByID(audioPacket.getUnitID()) ?? undefined)
|
||||
|
||||
/* Make a copy of the array buffer for the playback pipeline to use */
|
||||
var dst = new ArrayBuffer(audioPacket.getAudioData().buffer.byteLength);
|
||||
new Uint8Array(dst).set(new Uint8Array(audioPacket.getAudioData().buffer));
|
||||
@ -164,7 +162,7 @@ export class AudioManager {
|
||||
let newRadio = this.addRadio();
|
||||
this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio);
|
||||
this.#sources.find((source) => source instanceof TextToSpeechSource)?.connect(newRadio);
|
||||
|
||||
|
||||
newRadio = this.addRadio();
|
||||
this.#sources.find((source) => source instanceof MicrophoneSource)?.connect(newRadio);
|
||||
this.#sources.find((source) => source instanceof TextToSpeechSource)?.connect(newRadio);
|
||||
@ -192,8 +190,8 @@ export class AudioManager {
|
||||
let sessionConnections = getApp().getSessionDataManager().getSessionData().connections;
|
||||
if (sessionConnections) {
|
||||
sessionConnections.forEach((connection) => {
|
||||
this.#sources[connection[0]]?.connect(this.#sinks[connection[1]]);
|
||||
})
|
||||
if (connection[0] < this.#sources.length && connection[1] < this.#sinks.length) this.#sources[connection[0]]?.connect(this.#sinks[connection[1]]);
|
||||
});
|
||||
}
|
||||
|
||||
this.#running = true;
|
||||
@ -208,7 +206,7 @@ export class AudioManager {
|
||||
AudioManagerDevicesChangedEvent.dispatch(devices);
|
||||
});
|
||||
|
||||
this.#internalTextToSpeechSource = new TextToSpeechSource();
|
||||
this.#internalTextToSpeechSource = new TextToSpeechSource();
|
||||
}
|
||||
|
||||
stop() {
|
||||
@ -265,7 +263,6 @@ export class AudioManager {
|
||||
addRadio() {
|
||||
console.log("Adding new radio");
|
||||
const newRadio = new RadioSink();
|
||||
newRadio.speechDataAvailable = (blob) => this.#speechController.analyzeData(blob, newRadio);
|
||||
this.#sinks.push(newRadio);
|
||||
/* Set radio name by default to be incremental number */
|
||||
newRadio.setName(`Radio ${this.#sinks.length}`);
|
||||
|
||||
@ -4,6 +4,7 @@ import { getApp } from "../olympusapp";
|
||||
import { AudioSinksChangedEvent } from "../events";
|
||||
import { makeID } from "../other/utils";
|
||||
import { Recorder } from "./recorder";
|
||||
import { Unit } from "../unit/unit";
|
||||
|
||||
/* Radio sink, basically implements a simple SRS Client in Olympus. Does not support encryption at this moment */
|
||||
export class RadioSink extends AudioSink {
|
||||
@ -20,7 +21,8 @@ export class RadioSink extends AudioSink {
|
||||
#packetID = 0;
|
||||
#guid = makeID(22);
|
||||
#recorder: Recorder;
|
||||
speechDataAvailable: (blob: Blob) => void;
|
||||
#transmittingUnit: Unit | undefined;
|
||||
speechDataAvailable: (blob: Blob) => void = (blob) => {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@ -158,4 +160,12 @@ export class RadioSink extends AudioSink {
|
||||
recordArrayBuffer(arrayBuffer: ArrayBuffer) {
|
||||
this.#recorder.recordBuffer(arrayBuffer);
|
||||
}
|
||||
|
||||
setTransmittingUnit(transmittingUnit: Unit | undefined) {
|
||||
this.#transmittingUnit = transmittingUnit;
|
||||
}
|
||||
|
||||
getTransmittingUnit() {
|
||||
return this.#transmittingUnit;
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,6 +275,7 @@ export const defaultMapLayers = {
|
||||
|
||||
export enum OlympusState {
|
||||
NOT_INITIALIZED = "Not initialized",
|
||||
SERVER = "Server",
|
||||
LOGIN = "Login",
|
||||
IDLE = "Idle",
|
||||
MAIN_MENU = "Main menu",
|
||||
|
||||
@ -1,27 +1,30 @@
|
||||
import { getApp } from "../olympusapp";
|
||||
import { Coalition } from "../types/types";
|
||||
import { Unit } from "../unit/unit";
|
||||
import { bearing, coalitionToEnum, computeBearingRangeString, mToFt, rad2deg } from "../other/utils";
|
||||
import { bearing, coalitionToEnum, computeBearingRangeString, mToFt, rad2deg, spellNumbers } from "../other/utils";
|
||||
import { Controller } from "./controller";
|
||||
|
||||
const trackStrings = ["North", "North-East", "East", "South-East", "South", "South-West", "West", "North-West", "North"];
|
||||
const relTrackStrings = ["hot", "flank right", "beam right", "cold", "cold", "cold", "beam left", "flank left", "hot"];
|
||||
|
||||
export class AWACSController {
|
||||
export class AWACSController extends Controller {
|
||||
#coalition: Coalition = "blue";
|
||||
#callsign: string = "Magic";
|
||||
#referenceUnit: Unit;
|
||||
|
||||
constructor() {
|
||||
|
||||
constructor(radioOptions: { frequency: number; modulation: number }, coalition: Coalition, callsign: string) {
|
||||
super(radioOptions);
|
||||
this.#coalition = coalition;
|
||||
this.#callsign = callsign;
|
||||
}
|
||||
|
||||
executeCommand(text, radio) {
|
||||
parseText(text) {
|
||||
if (text.indexOf("request picture") > 0) {
|
||||
console.log("Requested AWACS picture");
|
||||
const readout = this.createPicture(true);
|
||||
getApp()
|
||||
.getAudioManager()
|
||||
.playText(readout.reduce((acc, line) => (acc += " " + line), ""));
|
||||
this.playText(readout.reduce((acc, line) => (acc += " " + line), ""));
|
||||
} else if (text.indexOf("request bogey dope") > 0) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,16 +57,24 @@ export class AWACSController {
|
||||
if (trackDegs > 360) trackDegs -= 360;
|
||||
let trackIndex = Math.round(trackDegs / 45);
|
||||
|
||||
let groupLine = `${activeGroups.length > 1 ? idx + 1 + "" + order + " group" : "Single group"} bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", " ")}, ${(mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, ${relTrackStrings[trackIndex]}`;
|
||||
let groupLine = `${activeGroups.length > 1 ? idx + 1 + "" + order + " group" : "Single group"}.`;
|
||||
|
||||
if (group.find((unit) => unit.getCoalition() === "neutral")) groupLine += ", bogey";
|
||||
else groupLine += ", hostile";
|
||||
if (forTextToSpeech) {
|
||||
groupLine += `bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", ` <break time="0.5s"/> `)},`;
|
||||
groupLine += `${(mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, ${relTrackStrings[trackIndex]}`;
|
||||
} else {
|
||||
groupLine += `bullseye ${spellNumbers(computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", " "))},`;
|
||||
groupLine += `${(mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, ${relTrackStrings[trackIndex]}`;
|
||||
}
|
||||
|
||||
return groupLine;
|
||||
if (group.find((unit) => unit.getCoalition() === "neutral")) groupLine += ", bogey.";
|
||||
else groupLine += ", hostile.";
|
||||
|
||||
return groupLine + forTextToSpeech && `<break time="1s"/>`;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
readout.push(`${this.#callsign}, ${activeGroups.length} group${activeGroups.length > 1 ? "s" : ""}`);
|
||||
readout.push(`${this.#callsign}, ${activeGroups.length} group${activeGroups.length > 1 ? "s" : ""}.`);
|
||||
readout.push(
|
||||
...activeGroups.map((group, idx) => {
|
||||
let order = "th";
|
||||
@ -75,12 +86,19 @@ export class AWACSController {
|
||||
if (trackDegs < 0) trackDegs += 360;
|
||||
let trackIndex = Math.round(trackDegs / 45);
|
||||
|
||||
let groupLine = `${activeGroups.length > 1 ? idx + 1 + "" + order + " group" : "Single group"} bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", " ")}, ${(mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, track ${trackStrings[trackIndex]}`;
|
||||
let groupLine = `${activeGroups.length > 1 ? idx + 1 + "" + order + " group" : "Single group"}.`;
|
||||
if (forTextToSpeech) {
|
||||
groupLine += `bullseye ${spellNumbers(computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", ` <break time="0.5s"/> `))},`;
|
||||
groupLine += `${(mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, track ${trackStrings[trackIndex]}`;
|
||||
} else {
|
||||
groupLine += `bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", " ")},`;
|
||||
groupLine += `${(mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, track ${trackStrings[trackIndex]}`;
|
||||
}
|
||||
|
||||
if (group.find((unit) => unit.getCoalition() === "neutral")) groupLine += ", bogey";
|
||||
else groupLine += ", hostile";
|
||||
if (group.find((unit) => unit.getCoalition() === "neutral")) groupLine += ", bogey.";
|
||||
else groupLine += ", hostile.";
|
||||
|
||||
return groupLine;
|
||||
return groupLine + forTextToSpeech && `<break time="1s"/>`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
import { RadioSink } from "../audio/radiosink";
|
||||
import { getApp } from "../olympusapp";
|
||||
import { blobToBase64 } from "../other/utils";
|
||||
import { RadioSink } from "./radiosink";
|
||||
|
||||
export class SpeechController {
|
||||
export abstract class Controller {
|
||||
#radio: RadioSink;
|
||||
#playingText: boolean = false;
|
||||
constructor() {}
|
||||
|
||||
analyzeData(blob: Blob, radio: RadioSink) {
|
||||
constructor(radioOptions: { frequency: number; modulation: number }) {
|
||||
this.#radio = getApp().getAudioManager().addRadio();
|
||||
this.#radio.setFrequency(radioOptions.frequency);
|
||||
this.#radio.setModulation(radioOptions.modulation);
|
||||
|
||||
this.#radio.speechDataAvailable = (blob) => this.analyzeData(blob)
|
||||
}
|
||||
|
||||
analyzeData(blob: Blob) {
|
||||
blobToBase64(blob)
|
||||
.then((base64) => {
|
||||
const requestOptions = {
|
||||
@ -25,46 +33,28 @@ export class SpeechController {
|
||||
throw new Error("Error saving profile");
|
||||
}
|
||||
})
|
||||
.then((text) => this.#executeCommand(text.toLowerCase(), radio))
|
||||
.then((text) => this.parseText(text.toLowerCase()))
|
||||
.catch((error) => console.error(error)); // Handle errors
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
}
|
||||
|
||||
playText(text, radio: RadioSink) {
|
||||
playText(text) {
|
||||
if (this.#playingText) return;
|
||||
this.#playingText = true;
|
||||
const textToSpeechSource = getApp().getAudioManager().getInternalTextToSpeechSource();
|
||||
textToSpeechSource.connect(radio);
|
||||
textToSpeechSource.connect(this.#radio);
|
||||
textToSpeechSource.playText(text);
|
||||
radio.setPtt(true);
|
||||
this.#radio.setPtt(true);
|
||||
textToSpeechSource.onMessageCompleted = () => {
|
||||
this.#playingText = false;
|
||||
radio.setPtt(false);
|
||||
textToSpeechSource.disconnect(radio);
|
||||
this.#radio.setPtt(false);
|
||||
textToSpeechSource.disconnect(this.#radio);
|
||||
};
|
||||
window.setTimeout(() => {
|
||||
this.#playingText = false;
|
||||
}, 30000); // Reset to false as failsafe
|
||||
}
|
||||
|
||||
#executeCommand(text, radio) {
|
||||
console.log(`Received speech command: ${text}`);
|
||||
|
||||
if (text.indexOf("olympus") === 0) {
|
||||
this.#olympusCommand(text, radio);
|
||||
} else if (text.indexOf(getApp().getAWACSController()?.getCallsign().toLowerCase()) === 0) {
|
||||
getApp().getAWACSController()?.executeCommand(text, radio);
|
||||
}
|
||||
}
|
||||
|
||||
#olympusCommand(text, radio) {
|
||||
if (text.indexOf("request straight") > 0 || text.indexOf("request straightin") > 0) {
|
||||
this.playText("Confirm you are on step 13, being a pussy?", radio);
|
||||
} else if (text.indexOf("bolter") > 0) {
|
||||
this.playText("What an idiot, I never boltered, 100% boarding rate", radio);
|
||||
} else if (text.indexOf("read back") > 0) {
|
||||
this.playText(text.replace("olympus", ""), radio);
|
||||
}
|
||||
}
|
||||
abstract parseText(text: string): void;
|
||||
}
|
||||
15
frontend/react/src/controllers/controllermanager.ts
Normal file
15
frontend/react/src/controllers/controllermanager.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Controller } from "./controller";
|
||||
|
||||
export class ControllerManager {
|
||||
#controllers: Controller[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
getControllers() {
|
||||
return this.#controllers;
|
||||
}
|
||||
|
||||
addController(controller: Controller) {
|
||||
this.#controllers.push(controller);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { LatLng } from "leaflet";
|
||||
import { MapOptions } from "./types/types";
|
||||
import { Coalition, MapOptions } from "./types/types";
|
||||
|
||||
export interface OlympusConfig {
|
||||
frontend: {
|
||||
@ -25,13 +25,23 @@ export interface OlympusConfig {
|
||||
mapMirrors: {
|
||||
[key: string]: string;
|
||||
};
|
||||
autoconnectWhenLocal: boolean;
|
||||
};
|
||||
audio: {
|
||||
SRSPort: number;
|
||||
WSPort?: number;
|
||||
WSEndpoint?: string;
|
||||
};
|
||||
controllers: [
|
||||
{type: string, coalition: Coalition, frequency: number, modulation: number, callsign: string},
|
||||
];
|
||||
local: boolean;
|
||||
profiles?: {[key: string]: ProfileOptions};
|
||||
authentication?: {
|
||||
gameMasterPassword: string,
|
||||
blueCommanderPasword: string,
|
||||
redCommanderPassword: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
|
||||
@ -7,7 +7,7 @@ export class CustomMarker extends Marker {
|
||||
|
||||
SelectionEnabledChangedEvent.on((enabled) => {
|
||||
const el = this.getElement();
|
||||
if (el === undefined) return;
|
||||
if (el === undefined || el === null) return;
|
||||
if (enabled) el.classList.add("disable-pointer-events");
|
||||
else el.classList.remove("disable-pointer-events");
|
||||
});
|
||||
|
||||
@ -29,7 +29,7 @@ export class Airbase extends CustomMarker {
|
||||
|
||||
AppStateChangedEvent.on((state, subState) => {
|
||||
const el = this.getElement();
|
||||
if (el === undefined) return;
|
||||
if (el === undefined || el === null) return;
|
||||
if (state === OlympusState.IDLE || state === OlympusState.AIRBASE) el.classList.remove("airbase-disable-pointer-events");
|
||||
else el.classList.add("airbase-disable-pointer-events");
|
||||
});
|
||||
|
||||
@ -20,11 +20,12 @@ import { WeaponsManager } from "./weapon/weaponsmanager";
|
||||
import { ServerManager } from "./server/servermanager";
|
||||
import { AudioManager } from "./audio/audiomanager";
|
||||
|
||||
import { LoginSubState, NO_SUBSTATE, OlympusState, OlympusSubState } from "./constants/constants";
|
||||
import { GAME_MASTER, LoginSubState, NO_SUBSTATE, OlympusState, OlympusSubState } from "./constants/constants";
|
||||
import { AppStateChangedEvent, ConfigLoadedEvent, InfoPopupEvent, MapOptionsChangedEvent, SelectedUnitsChangedEvent, ShortcutsChangedEvent } from "./events";
|
||||
import { OlympusConfig, ProfileOptions } from "./interfaces";
|
||||
import { AWACSController } from "./controllers/awacs";
|
||||
import { OlympusConfig } from "./interfaces";
|
||||
import { SessionDataManager } from "./sessiondata";
|
||||
import { ControllerManager } from "./controllers/controllermanager";
|
||||
import { AWACSController } from "./controllers/awacs";
|
||||
|
||||
export var VERSION = "{{OLYMPUS_VERSION_NUMBER}}";
|
||||
export var IP = window.location.toString();
|
||||
@ -48,11 +49,9 @@ export class OlympusApp {
|
||||
#weaponsManager: WeaponsManager;
|
||||
#audioManager: AudioManager;
|
||||
#sessionDataManager: SessionDataManager;
|
||||
#controllerManager: ControllerManager;
|
||||
//#pluginsManager: // TODO
|
||||
|
||||
/* Controllers */
|
||||
#AWACSController: AWACSController;
|
||||
|
||||
constructor() {
|
||||
SelectedUnitsChangedEvent.on((selectedUnits) => {
|
||||
if (selectedUnits.length > 0) this.setState(OlympusState.UNIT_CONTROL);
|
||||
@ -95,16 +94,16 @@ export class OlympusApp {
|
||||
return this.#sessionDataManager;
|
||||
}
|
||||
|
||||
getControllerManager() {
|
||||
return this.#controllerManager;
|
||||
}
|
||||
|
||||
/* TODO
|
||||
getPluginsManager() {
|
||||
return null // this.#pluginsManager as PluginsManager;
|
||||
}
|
||||
*/
|
||||
|
||||
getAWACSController() {
|
||||
return this.#AWACSController;
|
||||
}
|
||||
|
||||
start() {
|
||||
/* Initialize base functionalitites */
|
||||
this.#shortcutManager = new ShortcutManager(); /* Keep first */
|
||||
@ -117,9 +116,7 @@ export class OlympusApp {
|
||||
this.#unitsManager = new UnitsManager();
|
||||
this.#weaponsManager = new WeaponsManager();
|
||||
this.#audioManager = new AudioManager();
|
||||
|
||||
/* Controllers */
|
||||
this.#AWACSController = new AWACSController();
|
||||
this.#controllerManager = new ControllerManager();
|
||||
|
||||
/* Check if we are running the latest version */
|
||||
const request = new Request("https://raw.githubusercontent.com/Pax1601/DCSOlympus/main/version.json");
|
||||
@ -143,8 +140,8 @@ export class OlympusApp {
|
||||
/* Load the config file from the server */
|
||||
const configRequest = new Request("./resources/config", {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
}
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
|
||||
fetch(configRequest)
|
||||
@ -166,7 +163,28 @@ export class OlympusApp {
|
||||
this.setState(OlympusState.LOGIN, LoginSubState.COMMAND_MODE);
|
||||
}
|
||||
}
|
||||
if (this.getState() !== OlympusState.LOGIN) {
|
||||
if (this.#config.local && this.#config.authentication) {
|
||||
if (this.#config.frontend.autoconnectWhenLocal) {
|
||||
this.getServerManager().setUsername("Game master");
|
||||
this.getServerManager().setPassword(this.#config.authentication.gameMasterPassword);
|
||||
this.getServerManager().startUpdate();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const server = urlParams.get("server");
|
||||
if (server == null) {
|
||||
this.setState(OlympusState.IDLE);
|
||||
/* If no profile exists already with that name, create it from scratch from the defaults */
|
||||
if (this.getProfile() === null) this.saveProfile();
|
||||
/* Load the profile */
|
||||
this.loadProfile();
|
||||
} else {
|
||||
this.setState(OlympusState.SERVER);
|
||||
this.startServerMode();
|
||||
}
|
||||
|
||||
this.getServerManager().setActiveCommandMode(GAME_MASTER);
|
||||
}
|
||||
} else if (this.getState() !== OlympusState.LOGIN) {
|
||||
this.setState(OlympusState.LOGIN, LoginSubState.CREDENTIALS);
|
||||
}
|
||||
ConfigLoadedEvent.dispatch(this.#config as OlympusConfig);
|
||||
@ -301,4 +319,22 @@ export class OlympusApp {
|
||||
InfoPopupEvent.dispatch(this.#infoMessages);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
startServerMode() {
|
||||
ConfigLoadedEvent.on((config) => {
|
||||
this.getAudioManager().start();
|
||||
|
||||
Object.values(config.controllers).forEach((controllerOptions) => {
|
||||
if (controllerOptions.type.toLowerCase() === "awacs") {
|
||||
this.getControllerManager().addController(
|
||||
new AWACSController(
|
||||
{ frequency: controllerOptions.frequency, modulation: controllerOptions.modulation },
|
||||
controllerOptions.coalition,
|
||||
controllerOptions.callsign
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,7 +72,7 @@ export function keyEventWasInInput(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
export const zeroAppend = function (num: number, places: number, decimal: boolean = false, decimalPlaces: number = 2) {
|
||||
var string = decimal ? num.toFixed(decimalPlaces) : String(num);
|
||||
var string = decimal ? num.toFixed(decimalPlaces) : num.toFixed(0);
|
||||
while (string.length < places) {
|
||||
string = "0" + string;
|
||||
}
|
||||
@ -395,7 +395,21 @@ export function wait(time) {
|
||||
}
|
||||
|
||||
export function computeBearingRangeString(latlng1, latlng2) {
|
||||
return `${bearing(latlng1.lat, latlng1.lng, latlng2.lat, latlng2.lng).toFixed()}/${(latlng1.distanceTo(latlng2) / 1852).toFixed(0)}`;
|
||||
return `${zeroAppend(bearing(latlng1.lat, latlng1.lng, latlng2.lat, latlng2.lng), 3)}/${zeroAppend(latlng1.distanceTo(latlng2) / 1852, 3)}`;
|
||||
}
|
||||
|
||||
export function spellNumbers(string: string) {
|
||||
string = string.replaceAll("1", "one ");
|
||||
string = string.replaceAll("2", "two ");
|
||||
string = string.replaceAll("3", "three ");
|
||||
string = string.replaceAll("4", "four ");
|
||||
string = string.replaceAll("5", "five ");
|
||||
string = string.replaceAll("6", "six ");
|
||||
string = string.replaceAll("7", "seven ");
|
||||
string = string.replaceAll("8", "eight ");
|
||||
string = string.replaceAll("9", "nine ");
|
||||
string = string.replaceAll("0", "zero ");
|
||||
return string;
|
||||
}
|
||||
|
||||
export function blobToBase64(blob) {
|
||||
|
||||
@ -2,6 +2,7 @@ import { AudioSink } from "./audio/audiosink";
|
||||
import { FileSource } from "./audio/filesource";
|
||||
import { RadioSink } from "./audio/radiosink";
|
||||
import { UnitSink } from "./audio/unitsink";
|
||||
import { OlympusState } from "./constants/constants";
|
||||
import { AudioSinksChangedEvent, AudioSourcesChangedEvent, SessionDataLoadedEvent as SessionDataChangedEvent } from "./events";
|
||||
import { SessionData } from "./interfaces";
|
||||
import { getApp } from "./olympusapp";
|
||||
@ -75,6 +76,8 @@ export class SessionDataManager {
|
||||
}
|
||||
|
||||
loadSessionData(sessionHash?: string) {
|
||||
if (getApp().getState() === OlympusState.SERVER) return;
|
||||
|
||||
if (sessionHash) this.#sessionHash = sessionHash;
|
||||
if (this.#sessionHash === undefined) {
|
||||
console.error("Trying to load session data but no session hash provided");
|
||||
@ -110,6 +113,8 @@ export class SessionDataManager {
|
||||
}
|
||||
|
||||
#saveSessionData() {
|
||||
if (getApp().getState() === OlympusState.SERVER) return;
|
||||
|
||||
if (this.#saveSessionDataTimeout) window.clearTimeout(this.#saveSessionDataTimeout);
|
||||
this.#saveSessionDataTimeout = window.setTimeout(() => {
|
||||
const requestOptions = {
|
||||
|
||||
73
frontend/react/src/ui/serveroverlay.tsx
Normal file
73
frontend/react/src/ui/serveroverlay.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ServerStatusUpdatedEvent } from "../events";
|
||||
import { ServerStatus } from "../interfaces";
|
||||
import { FaCheck, FaXmark } from "react-icons/fa6";
|
||||
import { zeroAppend } from "../other/utils";
|
||||
|
||||
export function ServerOverlay() {
|
||||
const [serverStatus, setServerStatus] = useState({} as ServerStatus);
|
||||
|
||||
useEffect(() => {
|
||||
ServerStatusUpdatedEvent.on((status) => setServerStatus(status));
|
||||
}, []);
|
||||
|
||||
let loadColor = "#8BFF63";
|
||||
if (serverStatus.load > 1000) loadColor = "#F05252";
|
||||
else if (serverStatus.load >= 100 && serverStatus.load < 1000) loadColor = "#FF9900";
|
||||
|
||||
let frameRateColor = "#8BFF63";
|
||||
if (serverStatus.frameRate < 30) frameRateColor = "#F05252";
|
||||
else if (serverStatus.frameRate >= 30 && serverStatus.frameRate < 60) frameRateColor = "#FF9900";
|
||||
|
||||
|
||||
const MThours = serverStatus.missionTime? serverStatus.missionTime.h: 0;
|
||||
const MTminutes = serverStatus.missionTime? serverStatus.missionTime.m: 0;
|
||||
const MTseconds = serverStatus.missionTime? serverStatus.missionTime.s: 0;
|
||||
|
||||
const EThours = Math.floor((serverStatus.elapsedTime ?? 0) / 3600);
|
||||
const ETminutes = Math.floor((serverStatus.elapsedTime ?? 0) / 60) % 60;
|
||||
const ETseconds = Math.round(serverStatus.elapsedTime ?? 0) % 60;
|
||||
|
||||
let MTtimeString = `${zeroAppend(MThours, 2)}:${zeroAppend(MTminutes, 2)}:${zeroAppend(MTseconds, 2)}`;
|
||||
let ETtimeString = `${zeroAppend(EThours, 2)}:${zeroAppend(ETminutes, 2)}:${zeroAppend(ETseconds, 2)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
absolute left-0 top-0 z-50 h-full w-full flex-col bg-olympus-900 p-5
|
||||
`}
|
||||
>
|
||||
<div className="flex-col content-center">
|
||||
<h2 className="mb-10 text-3xl font-bold text-white">DCS Olympus server</h2>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-5 text-white">
|
||||
<div className="w-64">Connected to DCS:</div>
|
||||
<div>{serverStatus.connected? <FaCheck className={`
|
||||
text-xl text-green-500
|
||||
`}/> : <FaXmark className={`text-xl text-red-500`}/>}</div>
|
||||
</div>
|
||||
<div className="flex gap-5 text-white">
|
||||
<div className="w-64">Server load:</div>
|
||||
<div style={{color: loadColor}}>{serverStatus.load}</div>
|
||||
</div>
|
||||
<div className="flex gap-5 text-white">
|
||||
<div className="w-64">Server framerate:</div>
|
||||
<div style={{color: frameRateColor}}>{serverStatus.frameRate} fps</div>
|
||||
</div>
|
||||
<div className="flex gap-5 text-white">
|
||||
<div className="w-64">Elapsed time:</div>
|
||||
<div>{ETtimeString}</div>
|
||||
</div>
|
||||
<div className="flex gap-5 text-white">
|
||||
<div className="w-64">Mission local time:</div>
|
||||
<div>{MTtimeString}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<img src="./images/olympus-500x500.png" className={`
|
||||
absolute right-4 top-4 ml-auto flex h-24
|
||||
`}></img>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -32,6 +32,7 @@ import { SpawnContextMenu } from "./contextmenus/spawncontextmenu";
|
||||
import { CoordinatesPanel } from "./panels/coordinatespanel";
|
||||
import { RadiosSummaryPanel } from "./panels/radiossummarypanel";
|
||||
import { AWACSMenu } from "./panels/awacsmenu";
|
||||
import { ServerOverlay } from "./serveroverlay";
|
||||
|
||||
export type OlympusUIState = {
|
||||
mainMenuVisible: boolean;
|
||||
@ -58,7 +59,7 @@ export function UI() {
|
||||
|
||||
useEffect(() => {
|
||||
setupApp();
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -67,50 +68,64 @@ export function UI() {
|
||||
font-sans
|
||||
`}
|
||||
>
|
||||
<Header />
|
||||
{appState !== OlympusState.SERVER && (
|
||||
<>
|
||||
<Header />
|
||||
</>
|
||||
)}
|
||||
<div className="flex h-full w-full flex-row-reverse">
|
||||
<LoginModal open={appState === OlympusState.LOGIN} />
|
||||
<ProtectionPromptModal open={appState === OlympusState.UNIT_CONTROL && appSubState == UnitControlSubState.PROTECTION} />
|
||||
<KeybindModal open={appState === OlympusState.OPTIONS && appSubState === OptionsSubstate.KEYBIND} />
|
||||
|
||||
{appState === OlympusState.SERVER && <ServerOverlay />}
|
||||
|
||||
{appState !== OlympusState.SERVER && (
|
||||
<>
|
||||
<LoginModal open={appState === OlympusState.LOGIN} />
|
||||
<ProtectionPromptModal open={appState === OlympusState.UNIT_CONTROL && appSubState == UnitControlSubState.PROTECTION} />
|
||||
<KeybindModal open={appState === OlympusState.OPTIONS && appSubState === OptionsSubstate.KEYBIND} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div id="map-container" className="z-0 h-full w-screen" />
|
||||
|
||||
<MainMenu open={appState === OlympusState.MAIN_MENU} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<SpawnMenu open={appState === OlympusState.SPAWN} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<OptionsMenu open={appState === OlympusState.OPTIONS} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<UnitControlMenu
|
||||
open={
|
||||
appState === OlympusState.UNIT_CONTROL &&
|
||||
![UnitControlSubState.FORMATION, UnitControlSubState.UNIT_EXPLOSION_MENU].includes(appSubState as UnitControlSubState)
|
||||
}
|
||||
onClose={() => getApp().setState(OlympusState.IDLE)}
|
||||
/>
|
||||
<FormationMenu
|
||||
open={appState === OlympusState.UNIT_CONTROL && appSubState === UnitControlSubState.FORMATION}
|
||||
onClose={() => getApp().setState(OlympusState.IDLE)}
|
||||
/>
|
||||
<DrawingMenu open={appState === OlympusState.DRAW} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<AirbaseMenu open={appState === OlympusState.AIRBASE} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<AudioMenu open={appState === OlympusState.AUDIO} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<GameMasterMenu open={appState === OlympusState.GAME_MASTER} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<UnitExplosionMenu
|
||||
open={appState === OlympusState.UNIT_CONTROL && appSubState === UnitControlSubState.UNIT_EXPLOSION_MENU}
|
||||
onClose={() => getApp().setState(OlympusState.IDLE)}
|
||||
/>
|
||||
{/*}<JTACMenu open={appState === OlympusState.JTAC} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
{appState !== OlympusState.SERVER && (
|
||||
<>
|
||||
<MainMenu open={appState === OlympusState.MAIN_MENU} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<SpawnMenu open={appState === OlympusState.SPAWN} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<OptionsMenu open={appState === OlympusState.OPTIONS} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<UnitControlMenu
|
||||
open={
|
||||
appState === OlympusState.UNIT_CONTROL &&
|
||||
![UnitControlSubState.FORMATION, UnitControlSubState.UNIT_EXPLOSION_MENU].includes(appSubState as UnitControlSubState)
|
||||
}
|
||||
onClose={() => getApp().setState(OlympusState.IDLE)}
|
||||
/>
|
||||
<FormationMenu
|
||||
open={appState === OlympusState.UNIT_CONTROL && appSubState === UnitControlSubState.FORMATION}
|
||||
onClose={() => getApp().setState(OlympusState.IDLE)}
|
||||
/>
|
||||
<DrawingMenu open={appState === OlympusState.DRAW} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<AirbaseMenu open={appState === OlympusState.AIRBASE} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<AudioMenu open={appState === OlympusState.AUDIO} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<GameMasterMenu open={appState === OlympusState.GAME_MASTER} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<UnitExplosionMenu
|
||||
open={appState === OlympusState.UNIT_CONTROL && appSubState === UnitControlSubState.UNIT_EXPLOSION_MENU}
|
||||
onClose={() => getApp().setState(OlympusState.IDLE)}
|
||||
/>
|
||||
{/*}<JTACMenu open={appState === OlympusState.JTAC} onClose={() => getApp().setState(OlympusState.IDLE)} />
|
||||
<AWACSMenu open={appState === OlympusState.AWACS} onClose={() => getApp().setState(OlympusState.IDLE)} />{*/}
|
||||
|
||||
<MiniMapPanel />
|
||||
<ControlsPanel />
|
||||
<CoordinatesPanel />
|
||||
<RadiosSummaryPanel />
|
||||
<MiniMapPanel />
|
||||
<ControlsPanel />
|
||||
<CoordinatesPanel />
|
||||
<RadiosSummaryPanel />
|
||||
|
||||
<SideBar />
|
||||
<InfoBar />
|
||||
<HotGroupBar />
|
||||
<SideBar />
|
||||
<InfoBar />
|
||||
<HotGroupBar />
|
||||
|
||||
<MapContextMenu />
|
||||
<SpawnContextMenu />
|
||||
<MapContextMenu />
|
||||
<SpawnContextMenu />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
66
frontend/server/.vscode/launch.json
vendored
66
frontend/server/.vscode/launch.json
vendored
@ -7,7 +7,7 @@
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Server (DCS)",
|
||||
"name": "Launch server (core only)",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
@ -26,75 +26,25 @@
|
||||
"--vite"
|
||||
],
|
||||
"restart": true
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Server (No DCS)",
|
||||
"name": "Launch server (Electron)",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"runtimeExecutable": "nodemon",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"--watch",
|
||||
"src/**/*.ts",
|
||||
"--exec",
|
||||
"node",
|
||||
"--inspect",
|
||||
"-r",
|
||||
"ts-node/register",
|
||||
"src/www.ts",
|
||||
"run",
|
||||
"server",
|
||||
"--",
|
||||
"-s",
|
||||
"-c",
|
||||
"${input:enterDir}/Config/olympus.json",
|
||||
"--vite"
|
||||
],
|
||||
"restart": true,
|
||||
"preLaunchTask": "demo-server"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Server static Vite (DCS)",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"runtimeExecutable": "nodemon",
|
||||
"runtimeArgs": [
|
||||
"--watch",
|
||||
"src/**/*.ts",
|
||||
"--exec",
|
||||
"node",
|
||||
"--inspect",
|
||||
"-r",
|
||||
"ts-node/register",
|
||||
"src/www.ts",
|
||||
"-c",
|
||||
"${input:enterDir}/Config/olympus.json"
|
||||
],
|
||||
"restart": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Server static Vite (No DCS)",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"runtimeExecutable": "nodemon",
|
||||
"runtimeArgs": [
|
||||
"--watch",
|
||||
"src/**/*.ts",
|
||||
"--exec",
|
||||
"node",
|
||||
"--inspect",
|
||||
"-r",
|
||||
"ts-node/register",
|
||||
"src/www.ts",
|
||||
"-c",
|
||||
"${input:enterDir}/Config/olympus.json"
|
||||
],
|
||||
"restart": true,
|
||||
"preLaunchTask": "demo-server"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
@ -1,57 +1,79 @@
|
||||
const { app, BrowserWindow } = require('electron/main')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { spawn } = require('child_process');
|
||||
const yargs = require('yargs');
|
||||
const { app, BrowserWindow } = require("electron/main");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { spawn } = require("child_process");
|
||||
const yargs = require("yargs");
|
||||
|
||||
yargs.alias('c', 'config').describe('c', 'olympus.json config location').string('rp');
|
||||
yargs
|
||||
.alias("c", "config")
|
||||
.describe("c", "olympus.json config location")
|
||||
.string("rp");
|
||||
|
||||
yargs.alias("s", "server").describe("s", "run in server mode").string("rp");
|
||||
args = yargs.argv;
|
||||
|
||||
console.log(`Config location: ${args["config"]}`)
|
||||
console.log(`Config location: ${args["config"]}`);
|
||||
var frontendPort = 3000;
|
||||
var serverMode = args["server"] ?? false;
|
||||
if (fs.existsSync(args["config"])) {
|
||||
var json = JSON.parse(fs.readFileSync(args["config"], 'utf-8'));
|
||||
frontendPort = json["frontend"]["port"];
|
||||
var json = JSON.parse(fs.readFileSync(args["config"], "utf-8"));
|
||||
frontendPort = json["frontend"]["port"];
|
||||
} else {
|
||||
console.log("Failed to read config, trying default port");
|
||||
console.log("Failed to read config, trying default port");
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
icon: "./../img/olympus.ico"
|
||||
})
|
||||
const win = new BrowserWindow({
|
||||
icon: "./../img/olympus.ico",
|
||||
});
|
||||
|
||||
win.loadURL(`http://localhost:${frontendPort}`);
|
||||
win.setMenuBarVisibility(false);
|
||||
win.maximize();
|
||||
win.loadURL(
|
||||
`http://localhost:${frontendPort}${serverMode ? "/?server" : ""}`
|
||||
);
|
||||
win.setMenuBarVisibility(false);
|
||||
|
||||
if (serverMode) {
|
||||
} else {
|
||||
win.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const server = spawn('node', [path.join('.', 'build', 'www.js'), "--config", args["config"]]);
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
const server = spawn("node", [
|
||||
path.join(".", "build", "www.js"),
|
||||
"--config",
|
||||
args["config"],
|
||||
"--vite",
|
||||
]);
|
||||
|
||||
server.stdout.on('data', (data) => {
|
||||
console.log(`${data}`);
|
||||
});
|
||||
server.stdout.on("data", (data) => {
|
||||
console.log(`${data}`);
|
||||
});
|
||||
|
||||
server.stderr.on('data', (data) => {
|
||||
console.error(`stderr: ${data}`);
|
||||
});
|
||||
server.stderr.on("data", (data) => {
|
||||
console.error(`stderr: ${data}`);
|
||||
});
|
||||
|
||||
server.on('close', (code) => {
|
||||
console.log(`Child process exited with code ${code}`);
|
||||
});
|
||||
server.on("close", (code) => {
|
||||
console.log(`Child process exited with code ${code}`);
|
||||
});
|
||||
|
||||
createWindow()
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"version": "{{OLYMPUS_VERSION_NUMBER}}",
|
||||
"scripts": {
|
||||
"build-release": "call ./scripts/build-release.bat",
|
||||
"server": "node ./build/www.js",
|
||||
"server": "electron .",
|
||||
"client": "electron .",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
|
||||
@ -11,7 +11,7 @@ const generateClient = new textToSpeech.TextToSpeechClient();
|
||||
module.exports = function () {
|
||||
router.put("/generate", (req, res, next) => {
|
||||
const request = {
|
||||
input: {text: req.body.text},
|
||||
input: {ssml: `<speak>${req.body.text}</speak>`},
|
||||
voice: {languageCode: 'en-US', ssmlGender: 'MALE'},
|
||||
audioConfig: {audioEncoding: 'MP3'},
|
||||
};
|
||||
|
||||
@ -23,13 +23,20 @@ module.exports = function (configLocation) {
|
||||
}
|
||||
if (fs.existsSync(configLocation)) {
|
||||
let rawdata = fs.readFileSync(configLocation, "utf-8");
|
||||
const local = ["127.0.0.1", "::ffff:127.0.0.1", "::1"].includes(req.connection.remoteAddress);
|
||||
const config = JSON.parse(rawdata);
|
||||
let resConfig = {
|
||||
frontend: { ...config.frontend },
|
||||
audio: { ...(config.audio ?? {}) },
|
||||
controllers: { ...(config.controllers ?? {}) },
|
||||
profiles: { ...(profiles ?? {}) },
|
||||
local: local,
|
||||
};
|
||||
if (local) {
|
||||
resConfig["authentication"] = config["authentication"]
|
||||
}
|
||||
res.send(
|
||||
JSON.stringify({
|
||||
frontend: { ...config.frontend },
|
||||
audio: { ...(config.audio ?? {}) },
|
||||
profiles: { ...(profiles ?? {}) },
|
||||
})
|
||||
JSON.stringify(resConfig)
|
||||
);
|
||||
res.end();
|
||||
} else {
|
||||
|
||||
@ -37,11 +37,15 @@
|
||||
"mapMirrors": {
|
||||
"DCS Map (Official)": "https://maps.dcsolympus.com/maps",
|
||||
"DCS Map (Alt.)": "https://refugees.dcsolympus.com/maps"
|
||||
}
|
||||
},
|
||||
"autoconnectWhenLocal": true
|
||||
},
|
||||
"audio": {
|
||||
"SRSPort": 5002,
|
||||
"WSPort": 4000,
|
||||
"WSEndpoint": "audio"
|
||||
}
|
||||
},
|
||||
"controllers": [
|
||||
{"type": "awacs", "coalition": "blue", "callsign": "Magic", "frequency": 251000000, "modulation": 0}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user