mirror of
https://github.com/Pax1601/DCSOlympus.git
synced 2025-10-29 16:56:34 +00:00
Merge branch 'v2' into srs-integration
This commit is contained in:
commit
914d4a4a28
@ -15,7 +15,9 @@
|
||||
"description": "",
|
||||
"abilities": "",
|
||||
"canTargetPoint": false,
|
||||
"canRearm": false
|
||||
"canRearm": false,
|
||||
"carrierFilename": "nimitz.svg",
|
||||
"length": 300
|
||||
},
|
||||
"CVN_72": {
|
||||
"name": "CVN_72",
|
||||
@ -33,7 +35,9 @@
|
||||
"description": "",
|
||||
"abilities": "",
|
||||
"canTargetPoint": false,
|
||||
"canRearm": false
|
||||
"canRearm": false,
|
||||
"carrierFilename": "nimitz.svg",
|
||||
"length": 300
|
||||
},
|
||||
"CVN_73": {
|
||||
"name": "CVN_73",
|
||||
@ -51,7 +55,9 @@
|
||||
"description": "",
|
||||
"abilities": "",
|
||||
"canTargetPoint": false,
|
||||
"canRearm": false
|
||||
"canRearm": false,
|
||||
"carrierFilename": "nimitz.svg",
|
||||
"length": 300
|
||||
},
|
||||
"CVN_75": {
|
||||
"name": "CVN_75",
|
||||
@ -91,7 +97,9 @@
|
||||
"description": "",
|
||||
"abilities": "",
|
||||
"canTargetPoint": false,
|
||||
"canRearm": false
|
||||
"canRearm": false,
|
||||
"carrierFilename": "nimitz.svg",
|
||||
"length": 300
|
||||
},
|
||||
"CV_1143_5": {
|
||||
"name": "CV_1143_5",
|
||||
|
||||
BIN
frontend/react/public/images/carriers/633052.jpg
Normal file
BIN
frontend/react/public/images/carriers/633052.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 728 KiB |
BIN
frontend/react/public/images/carriers/kuznetsov.png
Normal file
BIN
frontend/react/public/images/carriers/kuznetsov.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 862 KiB |
BIN
frontend/react/public/images/carriers/nimitz.png
Normal file
BIN
frontend/react/public/images/carriers/nimitz.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@ -1,14 +0,0 @@
|
||||
class AudioDopplerProcessor extends AudioWorkletProcessor {
|
||||
process(inputs, outputs, parameters) {
|
||||
const output = outputs[0];
|
||||
output.forEach((channel) => {
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
channel[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("audio-doppler-processor", AudioDopplerProcessor);
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
// TODO Convert to typescript
|
||||
// Audio library I shamelessly copied from the web
|
||||
|
||||
// SAFARI Polyfills
|
||||
if (!window.AudioBuffer.prototype.copyToChannel) {
|
||||
window.AudioBuffer.prototype.copyToChannel = function copyToChannel(buffer, channel) {
|
||||
|
||||
@ -24,6 +24,9 @@ export class AudioManager {
|
||||
/* List of all possible audio sources (microphone, file stream etc...) */
|
||||
#sources: AudioSource[] = [];
|
||||
|
||||
/* The audio backend must be manually started so that the browser can detect the user is enabling audio.
|
||||
Otherwise, no playback will be performed. */
|
||||
#running: boolean = false;
|
||||
#address: string = "localhost";
|
||||
#port: number = 4000;
|
||||
#socket: WebSocket | null = null;
|
||||
@ -44,6 +47,7 @@ export class AudioManager {
|
||||
}
|
||||
|
||||
start() {
|
||||
this.#running = true;
|
||||
this.#audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
this.#playbackPipeline = new PlaybackPipeline();
|
||||
|
||||
@ -77,11 +81,12 @@ export class AudioManager {
|
||||
/* Extract the frequency value and play it on the speakers if we are listening to it*/
|
||||
audioPacket.getFrequencies().forEach((frequencyInfo) => {
|
||||
if (sink.getFrequency() === frequencyInfo.frequency && sink.getModulation() === frequencyInfo.modulation) {
|
||||
this.#playbackPipeline.play(audioPacket.getAudioData().buffer);
|
||||
this.#playbackPipeline.playBuffer(audioPacket.getAudioData().buffer);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.#SRSClientUnitIDs = JSON.parse(new TextDecoder().decode(packetUint8Array.slice(1))).unitIDs;
|
||||
document.dispatchEvent(new CustomEvent("SRSClientsUpdated"));
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -99,18 +104,25 @@ export class AudioManager {
|
||||
/* Add two default radios */
|
||||
this.addRadio();
|
||||
this.addRadio();
|
||||
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent("audioManagerStateChanged"));
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.#running = false;
|
||||
this.#sources.forEach((source) => {
|
||||
source.disconnect();
|
||||
});
|
||||
this.#sinks.forEach((sink) => {
|
||||
sink.disconnect();
|
||||
});
|
||||
this.#sources = [];
|
||||
this.#sinks = [];
|
||||
|
||||
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
|
||||
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
|
||||
document.dispatchEvent(new CustomEvent("audioManagerStateChanged"));
|
||||
}
|
||||
|
||||
setAddress(address) {
|
||||
@ -122,22 +134,47 @@ export class AudioManager {
|
||||
}
|
||||
|
||||
addFileSource(file) {
|
||||
console.log(`Adding file source from ${file.name}`);
|
||||
if (!this.#running) {
|
||||
console.log("Audio manager not started, aborting...");
|
||||
return;
|
||||
}
|
||||
const newSource = new FileSource(file);
|
||||
this.#sources.push(newSource);
|
||||
newSource.connect(this.#sinks[0]);
|
||||
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
|
||||
}
|
||||
|
||||
getSources() {
|
||||
return this.#sources;
|
||||
}
|
||||
|
||||
removeSource(source: AudioSource) {
|
||||
console.log(`Removing source ${source.getName()}`);
|
||||
if (!this.#running) {
|
||||
console.log("Audio manager not started, aborting...");
|
||||
return;
|
||||
}
|
||||
source.disconnect();
|
||||
this.#sources = this.#sources.filter((v) => v != source);
|
||||
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
|
||||
}
|
||||
|
||||
addUnitSink(unit: Unit) {
|
||||
console.log(`Adding unit sink for unit with ID ${unit.ID}`);
|
||||
if (!this.#running) {
|
||||
console.log("Audio manager not started, aborting...");
|
||||
return;
|
||||
}
|
||||
this.#sinks.push(new UnitSink(unit));
|
||||
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
|
||||
}
|
||||
|
||||
getSinks() {
|
||||
return this.#sinks;
|
||||
}
|
||||
|
||||
addRadio() {
|
||||
console.log("Adding new radio");
|
||||
if (!this.#running) {
|
||||
console.log("Audio manager not started, aborting...");
|
||||
return;
|
||||
}
|
||||
const newRadio = new RadioSink();
|
||||
this.#sinks.push(newRadio);
|
||||
newRadio.setName(`Radio ${this.#sinks.length}`);
|
||||
@ -145,7 +182,16 @@ export class AudioManager {
|
||||
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
|
||||
}
|
||||
|
||||
getSinks() {
|
||||
return this.#sinks;
|
||||
}
|
||||
|
||||
removeSink(sink) {
|
||||
console.log(`Removing sink ${sink.getName()}`);
|
||||
if (!this.#running) {
|
||||
console.log("Audio manager not started, aborting...");
|
||||
return;
|
||||
}
|
||||
sink.disconnect();
|
||||
this.#sinks = this.#sinks.filter((v) => v != sink);
|
||||
let idx = 1;
|
||||
@ -155,16 +201,6 @@ export class AudioManager {
|
||||
document.dispatchEvent(new CustomEvent("audioSinksUpdated"));
|
||||
}
|
||||
|
||||
removeSource(source) {
|
||||
source.disconnect();
|
||||
this.#sources = this.#sources.filter((v) => v != source);
|
||||
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
|
||||
}
|
||||
|
||||
getSources() {
|
||||
return this.#sources;
|
||||
}
|
||||
|
||||
getGuid() {
|
||||
return this.#guid;
|
||||
}
|
||||
@ -181,6 +217,10 @@ export class AudioManager {
|
||||
return this.#SRSClientUnitIDs;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this.#running;
|
||||
}
|
||||
|
||||
#syncRadioSettings() {
|
||||
let message = {
|
||||
type: "Settings update",
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// TODO This code is in common with the backend, would be nice to share it */
|
||||
import { byteArrayToDouble, byteArrayToInteger, doubleToByteArray, integerToByteArray } from "../other/utils";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
@ -20,11 +21,6 @@ export class AudioPacket {
|
||||
#unitID: number = 0;
|
||||
#hops: number = 0;
|
||||
|
||||
/* Out of standard data (this is not compliant with SRS standard, used for external audio effects) */
|
||||
#latitude: number | null = null;
|
||||
#longitude: number | null = null;
|
||||
#altitude: number | null = null;
|
||||
|
||||
/* Usually internally set only */
|
||||
#packetID: number | null = null;
|
||||
|
||||
@ -138,18 +134,6 @@ export class AudioPacket {
|
||||
[...Buffer.from(this.#clientGUID, "utf-8")]
|
||||
);
|
||||
|
||||
if (
|
||||
this.#latitude !== undefined &&
|
||||
this.#longitude !== undefined &&
|
||||
this.#altitude !== undefined
|
||||
) {
|
||||
encodedData.concat(
|
||||
[...doubleToByteArray(this.#latitude)],
|
||||
[...doubleToByteArray(this.#longitude)],
|
||||
[...doubleToByteArray(this.#altitude)]
|
||||
);
|
||||
}
|
||||
|
||||
// Set the lengths of the parts
|
||||
let encPacketLen = integerToByteArray(encodedData.length, 2);
|
||||
encodedData[0] = encPacketLen[0];
|
||||
@ -223,28 +207,4 @@ export class AudioPacket {
|
||||
getHops() {
|
||||
return this.#hops;
|
||||
}
|
||||
|
||||
setLatitude(latitude: number) {
|
||||
this.#latitude = latitude;
|
||||
}
|
||||
|
||||
getLatitude() {
|
||||
return this.#latitude;
|
||||
}
|
||||
|
||||
setLongitude(longitude: number) {
|
||||
this.#longitude = longitude;
|
||||
}
|
||||
|
||||
getLongitude() {
|
||||
return this.#longitude;
|
||||
}
|
||||
|
||||
setAltitude(altitude: number) {
|
||||
this.#altitude = altitude;
|
||||
}
|
||||
|
||||
getAltitude() {
|
||||
return this.#altitude;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { getApp } from "../olympusapp";
|
||||
|
||||
export abstract class AudioSink {
|
||||
/* Base audio sink class */
|
||||
export class AudioSink {
|
||||
#name: string;
|
||||
#gainNode: GainNode;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { getApp } from "../olympusapp";
|
||||
import { AudioSink } from "./audiosink";
|
||||
import { WebAudioPeakMeter } from "web-audio-peak-meter";
|
||||
|
||||
/* Base abstract audio source class */
|
||||
export abstract class AudioSource {
|
||||
#connectedTo: AudioSink[] = [];
|
||||
#name = "";
|
||||
@ -11,7 +12,9 @@ export abstract class AudioSource {
|
||||
|
||||
constructor() {
|
||||
this.#gainNode = getApp().getAudioManager().getAudioContext().createGain();
|
||||
this.#meter = new WebAudioPeakMeter(this.#gainNode, document.createElement('div'));
|
||||
|
||||
/* This library requires a div element to initialize the object. Create a fake element, we will read the data and render it ourselves. */
|
||||
this.#meter = new WebAudioPeakMeter(this.#gainNode, document.createElement("div"));
|
||||
}
|
||||
|
||||
connect(sink: AudioSink) {
|
||||
@ -61,5 +64,6 @@ export abstract class AudioSource {
|
||||
return this.#gainNode;
|
||||
}
|
||||
|
||||
/* Play method must be implemented by child classes */
|
||||
abstract play(): void;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { Filter, Noise } from "./audiolibrary";
|
||||
import { AudioPacket } from "./audiopacket";
|
||||
|
||||
let packetID = 0;
|
||||
const MAX_DISTANCE = 1852; // Ignore clients that are further away than 1NM, to save performance.
|
||||
|
||||
export class AudioUnitPipeline {
|
||||
#inputNode: GainNode;
|
||||
@ -13,16 +14,15 @@ export class AudioUnitPipeline {
|
||||
#destinationNode: MediaStreamAudioDestinationNode;
|
||||
#audioTrackProcessor: any;
|
||||
#encoder: AudioEncoder;
|
||||
#distance: number = 0;
|
||||
|
||||
#convolver: ConvolverNode;
|
||||
#delay: DelayNode;
|
||||
#multitap: DelayNode[];
|
||||
#multitapGain: GainNode;
|
||||
#wet: GainNode;
|
||||
#convolverNode: ConvolverNode;
|
||||
#preDelayNode: DelayNode;
|
||||
#multitapNodes: DelayNode[];
|
||||
#multitapGainNode: GainNode;
|
||||
#wetGainNode: GainNode;
|
||||
#tailOsc: Noise;
|
||||
|
||||
#dataBuffer: number[] = [];
|
||||
#distance: number = 0;
|
||||
|
||||
constructor(sourceUnit: Unit, unitID: number, inputNode: GainNode) {
|
||||
this.#sourceUnit = sourceUnit;
|
||||
@ -67,33 +67,42 @@ export class AudioUnitPipeline {
|
||||
|
||||
/* Create the pipeline */
|
||||
this.#inputNode = inputNode;
|
||||
this.#inputNode.connect(this.#gainNode);
|
||||
this.#setupEffects();
|
||||
|
||||
/* Create the interval task to update the data */
|
||||
setInterval(() => {
|
||||
/* Get the destination unit and compute the distance to it */
|
||||
let destinationUnit = getApp().getUnitsManager().getUnitByID(this.#unitID);
|
||||
if (destinationUnit) {
|
||||
let distance = destinationUnit?.getPosition().distanceTo(this.#sourceUnit.getPosition());
|
||||
|
||||
/* The units positions are updated at a low frequency. Filter the distance to avoid sudden volume jumps */
|
||||
this.#distance = 0.9 * this.#distance + 0.1 * distance;
|
||||
|
||||
let newGain = 1.0 - Math.pow(this.#distance / 1000, 0.5); // Arbitrary
|
||||
/* Don't bother updating parameters if the client is too far away */
|
||||
if (this.#distance < MAX_DISTANCE) {
|
||||
/* Compute a new gain decreasing with distance. */
|
||||
let newGain = 1.0 - Math.pow(this.#distance / 1000, 0.5); // Arbitrary
|
||||
|
||||
this.#gainNode.gain.setValueAtTime(newGain, getApp().getAudioManager().getAudioContext().currentTime);
|
||||
this.#multitapGain.gain.setValueAtTime(newGain / 10, getApp().getAudioManager().getAudioContext().currentTime);
|
||||
/* Set the values of the main gain node and the multitap gain node, used for reverb effect */
|
||||
this.#gainNode.gain.setValueAtTime(newGain, getApp().getAudioManager().getAudioContext().currentTime);
|
||||
this.#multitapGainNode.gain.setValueAtTime(newGain / 10, getApp().getAudioManager().getAudioContext().currentTime);
|
||||
|
||||
let reverbTime = this.#distance / 1000 / 2; //Arbitrary
|
||||
let preDelay = this.#distance / 1000; // Arbitrary
|
||||
this.#delay.delayTime.setValueAtTime(preDelay, getApp().getAudioManager().getAudioContext().currentTime);
|
||||
this.#multitap.forEach((t, i) => {
|
||||
t.delayTime.setValueAtTime(0.001 + i * (preDelay / 2), getApp().getAudioManager().getAudioContext().currentTime);
|
||||
});
|
||||
this.#tailOsc.release = reverbTime / 3;
|
||||
/* Increase reverb and predelay with distance */
|
||||
let reverbTime = this.#distance / 1000 / 4; //Arbitrary
|
||||
let preDelay = this.#distance / 1000 / 2; // Arbitrary
|
||||
this.#preDelayNode.delayTime.setValueAtTime(preDelay, getApp().getAudioManager().getAudioContext().currentTime);
|
||||
this.#multitapNodes.forEach((t, i) => {
|
||||
t.delayTime.setValueAtTime(0.001 + i * (preDelay / 2), getApp().getAudioManager().getAudioContext().currentTime);
|
||||
});
|
||||
this.#tailOsc.release = reverbTime / 3;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
handleEncodedData(encodedAudioChunk, unitID) {
|
||||
/* Encode the data in SRS format and send it to the backend */
|
||||
let arrayBuffer = new ArrayBuffer(encodedAudioChunk.byteLength);
|
||||
encodedAudioChunk.copyTo(arrayBuffer);
|
||||
|
||||
@ -118,67 +127,67 @@ export class AudioUnitPipeline {
|
||||
|
||||
handleRawData(audioData) {
|
||||
/* Ignore players that are too far away */
|
||||
if (this.#distance < 1000) {
|
||||
if (this.#distance < MAX_DISTANCE) {
|
||||
this.#encoder.encode(audioData);
|
||||
|
||||
audioData.close();
|
||||
}
|
||||
}
|
||||
|
||||
#setupEffects() {
|
||||
let reverbTime = 0.1; //Arbitrary
|
||||
|
||||
this.#convolver = getApp().getAudioManager().getAudioContext().createConvolver();
|
||||
this.#delay = getApp().getAudioManager().getAudioContext().createDelay(1);
|
||||
|
||||
this.#multitap = [];
|
||||
/* Create the nodes necessary for the pipeline */
|
||||
this.#convolverNode = getApp().getAudioManager().getAudioContext().createConvolver();
|
||||
this.#preDelayNode = getApp().getAudioManager().getAudioContext().createDelay(1);
|
||||
this.#multitapGainNode = getApp().getAudioManager().getAudioContext().createGain();
|
||||
this.#wetGainNode = getApp().getAudioManager().getAudioContext().createGain();
|
||||
this.#multitapNodes = [];
|
||||
for (let i = 2; i > 0; i--) {
|
||||
this.#multitap.push(getApp().getAudioManager().getAudioContext().createDelay(1));
|
||||
this.#multitapNodes.push(getApp().getAudioManager().getAudioContext().createDelay(1));
|
||||
}
|
||||
this.#multitap.map((t, i) => {
|
||||
if (this.#multitap[i + 1]) {
|
||||
t.connect(this.#multitap[i + 1]);
|
||||
|
||||
/* Connect the nodes as follows
|
||||
/------> pre delay -> convolver ------\
|
||||
input -> main gain -> wet gain -< >-> destination
|
||||
\-> multitap[0] -> ... -> multitap[n]-/
|
||||
|
||||
The multitap nodes simulate distinct echoes coming from the original sound. Multitap[0] is the original sound.
|
||||
The predelay and convolver nodes simulate reverb.
|
||||
*/
|
||||
|
||||
this.#inputNode.connect(this.#gainNode);
|
||||
this.#gainNode.connect(this.#wetGainNode);
|
||||
this.#wetGainNode.connect(this.#preDelayNode);
|
||||
this.#wetGainNode.connect(this.#multitapNodes[0]);
|
||||
this.#multitapNodes.map((t, i) => {
|
||||
if (this.#multitapNodes[i + 1]) {
|
||||
t.connect(this.#multitapNodes[i + 1]);
|
||||
}
|
||||
});
|
||||
this.#multitapNodes[this.#multitapNodes.length - 1].connect(this.#multitapGainNode);
|
||||
this.#multitapGainNode.connect(this.#destinationNode);
|
||||
this.#preDelayNode.connect(this.#convolverNode);
|
||||
this.#convolverNode.connect(this.#destinationNode);
|
||||
|
||||
this.#multitapGain = getApp().getAudioManager().getAudioContext().createGain();
|
||||
this.#multitap[this.#multitap.length - 1].connect(this.#multitapGain);
|
||||
|
||||
this.#multitapGain.connect(this.#destinationNode);
|
||||
this.#wet = getApp().getAudioManager().getAudioContext().createGain();
|
||||
|
||||
this.#gainNode.connect(this.#wet);
|
||||
this.#wet.connect(this.#delay);
|
||||
this.#wet.connect(this.#multitap[0]);
|
||||
this.#delay.connect(this.#convolver);
|
||||
|
||||
getApp().getAudioManager().getAudioContext().audioWorklet.addModule("audiodopplerprocessor.js").then(() => {
|
||||
const randomNoiseNode = new AudioWorkletNode(
|
||||
getApp().getAudioManager().getAudioContext(),
|
||||
"audio-doppler-processor",
|
||||
);
|
||||
this.#convolver.connect(randomNoiseNode);
|
||||
randomNoiseNode.connect(this.#destinationNode);
|
||||
});
|
||||
|
||||
|
||||
this.#renderTail(reverbTime);
|
||||
/* Render the random noise needed for the convolver node to simulate reverb */
|
||||
this.#renderTail(0.1); //Arbitrary
|
||||
}
|
||||
|
||||
#renderTail(reverbTime) {
|
||||
let attack = 0;
|
||||
let decay = 0.0;
|
||||
|
||||
/* Generate an offline audio context to render the reverb noise */
|
||||
const tailContext = new OfflineAudioContext(
|
||||
2,
|
||||
getApp().getAudioManager().getAudioContext().sampleRate * reverbTime,
|
||||
getApp().getAudioManager().getAudioContext().sampleRate
|
||||
);
|
||||
|
||||
/* A noise oscillator and a two filters are added to smooth the reverb */
|
||||
this.#tailOsc = new Noise(tailContext, 1);
|
||||
const tailLPFilter = new Filter(tailContext, "lowpass", 5000, 1);
|
||||
const tailHPFilter = new Filter(tailContext, "highpass", 500, 1);
|
||||
|
||||
/* Initialize and connect the oscillator with the filters */
|
||||
this.#tailOsc.init();
|
||||
this.#tailOsc.connect(tailHPFilter.input);
|
||||
tailHPFilter.connect(tailLPFilter.input);
|
||||
@ -187,12 +196,13 @@ export class AudioUnitPipeline {
|
||||
this.#tailOsc.decay = decay;
|
||||
|
||||
setTimeout(() => {
|
||||
/* Set the buffer of the convolver node */
|
||||
tailContext.startRendering().then((buffer) => {
|
||||
this.#convolver.buffer = buffer;
|
||||
this.#convolverNode.buffer = buffer;
|
||||
});
|
||||
|
||||
this.#tailOsc.on({ frequency: 500, velocity: 127 });
|
||||
//tailOsc.off();
|
||||
//tailOsc.off(); // TODO In the original example I copied, this was turned off. No idea why but it seems to work correctly if left on. To investigate.
|
||||
}, 20);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { AudioSource } from "./audiosource";
|
||||
import { getApp } from "../olympusapp";
|
||||
import {WebAudioPeakMeter} from 'web-audio-peak-meter';
|
||||
|
||||
export class FileSource extends AudioSource {
|
||||
#file: File | null = null;
|
||||
#file: File;
|
||||
#source: AudioBufferSourceNode;
|
||||
#duration: number = 0;
|
||||
#currentPosition: number = 0;
|
||||
@ -19,11 +18,8 @@ export class FileSource extends AudioSource {
|
||||
this.#file = file;
|
||||
|
||||
this.setName(this.#file?.name ?? "N/A");
|
||||
|
||||
if (!this.#file) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Create the file reader and read the file from disk */
|
||||
var reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
var contents = e.target?.result;
|
||||
@ -31,6 +27,7 @@ export class FileSource extends AudioSource {
|
||||
getApp()
|
||||
.getAudioManager()
|
||||
.getAudioContext()
|
||||
/* Decode the audio file. This method takes care of codecs */
|
||||
.decodeAudioData(contents as ArrayBuffer, (audioBuffer) => {
|
||||
this.#audioBuffer = audioBuffer;
|
||||
this.#duration = audioBuffer.duration;
|
||||
@ -41,11 +38,13 @@ export class FileSource extends AudioSource {
|
||||
}
|
||||
|
||||
play() {
|
||||
/* A new buffer source must be created every time the file is played */
|
||||
this.#source = getApp().getAudioManager().getAudioContext().createBufferSource();
|
||||
this.#source.buffer = this.#audioBuffer;
|
||||
this.#source.connect(this.getOutputNode());
|
||||
this.#source.loop = this.#looping;
|
||||
|
||||
/* Start playing the file at the selected position */
|
||||
this.#source.start(0, this.#currentPosition);
|
||||
this.#playing = true;
|
||||
const now = Date.now() / 1000;
|
||||
@ -54,20 +53,22 @@ export class FileSource extends AudioSource {
|
||||
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
|
||||
|
||||
this.#updateInterval = setInterval(() => {
|
||||
/* Update the current position value every second */
|
||||
const now = Date.now() / 1000;
|
||||
this.#currentPosition += now - this.#lastUpdateTime;
|
||||
this.#lastUpdateTime = now;
|
||||
|
||||
if (this.#currentPosition > this.#duration) {
|
||||
this.#currentPosition = 0;
|
||||
if (!this.#looping) this.stop();
|
||||
if (!this.#looping) this.pause();
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent("audioSourcesUpdated"));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
pause() {
|
||||
/* Disconnect the source and update the position to the current time (precisely)*/
|
||||
this.#source.stop();
|
||||
this.#source.disconnect();
|
||||
this.#playing = false;
|
||||
@ -92,12 +93,17 @@ export class FileSource extends AudioSource {
|
||||
}
|
||||
|
||||
setCurrentPosition(percentPosition) {
|
||||
/* To change the current play position we must:
|
||||
1) pause the current playback;
|
||||
2) update the current position value;
|
||||
3) after some time, restart playing. The delay is needed to avoid immediately restarting many times if the user drags the position slider;
|
||||
*/
|
||||
if (this.#playing) {
|
||||
clearTimeout(this.#restartTimeout);
|
||||
this.#restartTimeout = setTimeout(() => this.play(), 1000);
|
||||
}
|
||||
|
||||
this.stop();
|
||||
this.pause();
|
||||
this.#currentPosition = (percentPosition / 100) * this.#duration;
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { getApp } from "../olympusapp";
|
||||
import { AudioSource } from "./audiosource";
|
||||
|
||||
export class MicrophoneSource extends AudioSource {
|
||||
#node: MediaStreamAudioSourceNode;
|
||||
#sourceNode: MediaStreamAudioSourceNode;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@ -10,12 +10,12 @@ export class MicrophoneSource extends AudioSource {
|
||||
this.setName("Microphone");
|
||||
}
|
||||
|
||||
/* Asynchronously initialize the microphone and connect it to the output node */
|
||||
async initialize() {
|
||||
const microphone = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (getApp().getAudioManager().getAudioContext()) {
|
||||
this.#node = getApp().getAudioManager().getAudioContext().createMediaStreamSource(microphone);
|
||||
|
||||
this.#node.connect(this.getOutputNode());
|
||||
this.#sourceNode = getApp().getAudioManager().getAudioContext().createMediaStreamSource(microphone);
|
||||
this.#sourceNode.connect(this.getOutputNode());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ export class PlaybackPipeline {
|
||||
this.#gainNode.connect(getApp().getAudioManager().getAudioContext().destination);
|
||||
}
|
||||
|
||||
play(arrayBuffer) {
|
||||
playBuffer(arrayBuffer) {
|
||||
const init = {
|
||||
type: "key",
|
||||
data: arrayBuffer,
|
||||
|
||||
@ -4,6 +4,7 @@ import { getApp } from "../olympusapp";
|
||||
|
||||
let packetID = 0;
|
||||
|
||||
/* Radio sink, basically implements a simple SRS Client in Olympus. Does not support encryption at this moment */
|
||||
export class RadioSink extends AudioSink {
|
||||
#encoder: AudioEncoder;
|
||||
#desinationNode: MediaStreamAudioDestinationNode;
|
||||
|
||||
@ -3,27 +3,43 @@ import { getApp } from "../olympusapp";
|
||||
import { Unit } from "../unit/unit";
|
||||
import { AudioUnitPipeline } from "./audiounitpipeline";
|
||||
|
||||
/* Unit sink to implement a "loudspeaker" external sound. Useful for stuff like 5MC calls, air sirens,
|
||||
scramble calls and so on. Ideally, one may want to move this code to the backend*/
|
||||
export class UnitSink extends AudioSink {
|
||||
#unit: Unit;
|
||||
#unitPipelines: {[key: string]: AudioUnitPipeline} = {};
|
||||
#unitPipelines: { [key: string]: AudioUnitPipeline } = {};
|
||||
|
||||
constructor(sourceUnit: Unit) {
|
||||
constructor(unit: Unit) {
|
||||
super();
|
||||
|
||||
this.#unit = sourceUnit;
|
||||
this.setName(`${sourceUnit.getUnitName()} - ${sourceUnit.getName()}`);
|
||||
this.#unit = unit;
|
||||
this.setName(`${unit.getUnitName()} - ${unit.getName()}`);
|
||||
|
||||
getApp()
|
||||
.getAudioManager()
|
||||
.getSRSClientsUnitIDs()
|
||||
.forEach((unitID) => {
|
||||
if (unitID !== 0) {
|
||||
this.#unitPipelines[unitID] = new AudioUnitPipeline(sourceUnit, unitID, this.getInputNode());
|
||||
}
|
||||
});
|
||||
document.addEventListener("SRSClientsUpdated", () => {
|
||||
this.#updatePipelines();
|
||||
});
|
||||
|
||||
this.#updatePipelines();
|
||||
}
|
||||
|
||||
getUnit() {
|
||||
return this.#unit;
|
||||
}
|
||||
|
||||
#updatePipelines() {
|
||||
getApp()
|
||||
.getAudioManager()
|
||||
.getSRSClientsUnitIDs()
|
||||
.forEach((unitID) => {
|
||||
if (unitID !== 0 && !(unitID in this.#unitPipelines)) {
|
||||
this.#unitPipelines[unitID] = new AudioUnitPipeline(this.#unit, unitID, this.getInputNode());
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(this.#unitPipelines).forEach((unitID) => {
|
||||
if (!(unitID in getApp().getAudioManager().getSRSClientsUnitIDs())) {
|
||||
delete this.#unitPipelines[unitID];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -237,6 +237,7 @@ export const defaultMapMirrors = {};
|
||||
export const defaultMapLayers = {};
|
||||
|
||||
/* Map constants */
|
||||
export const NOT_INITIALIZED = "Not initialized";
|
||||
export const IDLE = "Idle";
|
||||
export const SPAWN_UNIT = "Spawn unit";
|
||||
export const CONTEXT_ACTION = "Context action";
|
||||
|
||||
2
frontend/react/src/dom.d.ts
vendored
2
frontend/react/src/dom.d.ts
vendored
@ -28,6 +28,8 @@ interface CustomEventMap {
|
||||
hideUnitContextMenu: CustomEvent<any>;
|
||||
audioSourcesUpdated: CustomEvent<any>;
|
||||
audioSinksUpdated: CustomEvent<any>;
|
||||
audioManagerStateChanged: CustomEvent<any>;
|
||||
SRSClientsUpdated: CustomEvent<any>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
MAP_HIDDEN_TYPES_DEFAULTS,
|
||||
COALITIONAREA_EDIT,
|
||||
COALITIONAREA_DRAW_CIRCLE,
|
||||
NOT_INITIALIZED,
|
||||
} from "../constants/constants";
|
||||
import { CoalitionPolygon } from "./coalitionarea/coalitionpolygon";
|
||||
import { MapHiddenTypes, MapOptions } from "../types/types";
|
||||
@ -46,7 +47,7 @@ export class Map extends L.Map {
|
||||
#hiddenTypes: MapHiddenTypes = MAP_HIDDEN_TYPES_DEFAULTS;
|
||||
|
||||
/* State machine */
|
||||
#state: string;
|
||||
#state: string = NOT_INITIALIZED;
|
||||
|
||||
/* Map layers */
|
||||
#theatre: string = "";
|
||||
@ -140,7 +141,7 @@ export class Map extends L.Map {
|
||||
this.#miniMapPolyline.addTo(this.#miniMapLayerGroup);
|
||||
|
||||
/* Init the state machine */
|
||||
this.setState(IDLE);
|
||||
setTimeout(() => this.setState(IDLE), 100);
|
||||
|
||||
/* Register event handles */
|
||||
this.on("zoomstart", (e: any) => this.#onZoomStart(e));
|
||||
|
||||
@ -14,11 +14,13 @@ export class Airbase extends CustomMarker {
|
||||
#coalition: string = "";
|
||||
#properties: string[] = [];
|
||||
#parkings: string[] = [];
|
||||
#img: HTMLImageElement;
|
||||
|
||||
constructor(options: AirbaseOptions) {
|
||||
super(options.position, { riseOnHover: true });
|
||||
|
||||
this.#name = options.name;
|
||||
this.#img = document.createElement("img");
|
||||
}
|
||||
|
||||
createIcon() {
|
||||
@ -32,10 +34,10 @@ export class Airbase extends CustomMarker {
|
||||
var el = document.createElement("div");
|
||||
el.classList.add("airbase-icon");
|
||||
el.setAttribute("data-object", "airbase");
|
||||
var img = document.createElement("img");
|
||||
img.src = "/vite/images/markers/airbase.svg";
|
||||
img.onload = () => SVGInjector(img);
|
||||
el.appendChild(img);
|
||||
|
||||
this.#img.src = "/vite/images/markers/airbase.svg";
|
||||
this.#img.onload = () => SVGInjector(this.#img);
|
||||
el.appendChild(this.#img);
|
||||
this.getElement()?.appendChild(el);
|
||||
el.addEventListener("mouseover", (ev) => {
|
||||
document.dispatchEvent(new CustomEvent("airbasemouseover", { detail: this }));
|
||||
@ -86,4 +88,8 @@ export class Airbase extends CustomMarker {
|
||||
getParkings() {
|
||||
return this.#parkings;
|
||||
}
|
||||
|
||||
getImg() {
|
||||
return this.#img;
|
||||
}
|
||||
}
|
||||
|
||||
58
frontend/react/src/mission/carrier.ts
Normal file
58
frontend/react/src/mission/carrier.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { DivIcon, LatLng, Map } from "leaflet";
|
||||
import { Airbase } from "./airbase";
|
||||
|
||||
export class Carrier extends Airbase {
|
||||
#heading: number = 0;
|
||||
|
||||
createIcon() {
|
||||
var icon = new DivIcon({
|
||||
className: "leaflet-airbase-marker",
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20],
|
||||
}); // Set the marker, className must be set to avoid white square
|
||||
this.setIcon(icon);
|
||||
|
||||
var el = document.createElement("div");
|
||||
el.classList.add("airbase-icon");
|
||||
el.setAttribute("data-object", "airbase");
|
||||
|
||||
this.getImg().src = "/vite/images/carriers/nimitz.png";
|
||||
this.getImg().style.width = `0px`; // Make the image immediately small to avoid giant carriers
|
||||
el.appendChild(this.getImg());
|
||||
this.getElement()?.appendChild(el);
|
||||
el.addEventListener("mouseover", (ev) => {
|
||||
document.dispatchEvent(new CustomEvent("airbasemouseover", { detail: this }));
|
||||
});
|
||||
el.addEventListener("mouseout", (ev) => {
|
||||
document.dispatchEvent(new CustomEvent("airbasemouseout", { detail: this }));
|
||||
});
|
||||
el.dataset.coalition = this.getCoalition();
|
||||
}
|
||||
|
||||
onAdd(map: Map): this {
|
||||
super.onAdd(map);
|
||||
//this._map.on("zoomstart", (e: any) => this.updateSize());
|
||||
return this;
|
||||
}
|
||||
|
||||
onRemove(map: Map): this {
|
||||
super.onRemove(map);
|
||||
//this._map.off("zoomstart", (e: any) => this.updateSize());
|
||||
return this;
|
||||
}
|
||||
|
||||
setHeading(heading: number) {
|
||||
this.#heading = heading;
|
||||
this.getImg().style.transform = `rotate(${heading - 3.14 / 2}rad)`;
|
||||
}
|
||||
|
||||
updateSize() {
|
||||
if (this._map) {
|
||||
const y = this._map.getSize().y;
|
||||
const x = this._map.getSize().x;
|
||||
const maxMeters = this._map.containerPointToLatLng([0, y]).distanceTo(this._map.containerPointToLatLng([x, y]));
|
||||
const meterPerPixel = maxMeters / x;
|
||||
this.getImg().style.width = `${Math.round(333 / meterPerPixel)}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,11 +12,13 @@ import { navyUnitDatabase } from "../unit/databases/navyunitdatabase";
|
||||
//import { Popup } from "../popups/popup";
|
||||
import { AirbasesData, BullseyesData, CommandModeOptions, DateAndTime, MissionData } from "../interfaces";
|
||||
import { Coalition } from "../types/types";
|
||||
import { Carrier } from "./carrier";
|
||||
import { NavyUnit } from "../unit/unit";
|
||||
|
||||
/** The MissionManager */
|
||||
export class MissionManager {
|
||||
#bullseyes: { [name: string]: Bullseye } = {};
|
||||
#airbases: { [name: string]: Airbase } = {};
|
||||
#airbases: { [name: string]: Airbase | Carrier } = {};
|
||||
#theatre: string = "";
|
||||
#dateAndTime: DateAndTime = {
|
||||
date: { Year: 0, Month: 0, Day: 0 },
|
||||
@ -82,18 +84,21 @@ export class MissionManager {
|
||||
updateAirbases(data: AirbasesData) {
|
||||
for (let idx in data.airbases) {
|
||||
var airbase = data.airbases[idx];
|
||||
if (this.#airbases[airbase.callsign] === undefined && airbase.callsign != "") {
|
||||
this.#airbases[airbase.callsign] = new Airbase({
|
||||
position: new LatLng(airbase.latitude, airbase.longitude),
|
||||
name: airbase.callsign,
|
||||
}).addTo(getApp().getMap());
|
||||
this.#airbases[airbase.callsign].on("click", (e) => this.#onAirbaseClick(e));
|
||||
this.#loadAirbaseChartData(airbase.callsign);
|
||||
var airbaseCallsign = airbase.callsign !== ""? airbase.callsign: `carrier-${airbase.unitId}`
|
||||
if (this.#airbases[airbaseCallsign] === undefined) {
|
||||
if (airbase.callsign != "") {
|
||||
this.#airbases[airbaseCallsign] = new Airbase({
|
||||
position: new LatLng(airbase.latitude, airbase.longitude),
|
||||
name: airbaseCallsign,
|
||||
}).addTo(getApp().getMap());
|
||||
this.#airbases[airbaseCallsign].on("click", (e) => this.#onAirbaseClick(e));
|
||||
this.#loadAirbaseChartData(airbaseCallsign);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#airbases[airbase.callsign] != undefined && airbase.latitude && airbase.longitude && airbase.coalition) {
|
||||
this.#airbases[airbase.callsign].setLatLng(new LatLng(airbase.latitude, airbase.longitude));
|
||||
this.#airbases[airbase.callsign].setCoalition(airbase.coalition);
|
||||
if (this.#airbases[airbaseCallsign] != undefined && airbase.latitude && airbase.longitude && airbase.coalition) {
|
||||
this.#airbases[airbaseCallsign].setLatLng(new LatLng(airbase.latitude, airbase.longitude));
|
||||
this.#airbases[airbaseCallsign].setCoalition(airbase.coalition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ export class OlympusApp {
|
||||
getMissionManager() {
|
||||
return this.#missionManager as MissionManager;
|
||||
}
|
||||
|
||||
|
||||
getAudioManager() {
|
||||
return this.#audioManager as AudioManager;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { RadioSink } from "../audio/radiosink";
|
||||
import { DEFAULT_CONTEXT } from "../constants/constants";
|
||||
import { ShortcutKeyboardOptions, ShortcutMouseOptions } from "../interfaces";
|
||||
import { getApp } from "../olympusapp";
|
||||
@ -119,7 +120,45 @@ export class ShortcutManager {
|
||||
shiftKey: false,
|
||||
});
|
||||
|
||||
["KeyW", "KeyA", "KeyS", "KeyD", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].forEach((code) => {
|
||||
let PTTKeys = ["KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "KeyK", "KeyL"];
|
||||
PTTKeys.forEach((key, idx) => {
|
||||
this.addKeyboardShortcut(`PTT${idx}Active`, {
|
||||
altKey: false,
|
||||
callback: () => {
|
||||
getApp()
|
||||
.getAudioManager()
|
||||
.getSinks()
|
||||
.filter((sink) => {
|
||||
return sink instanceof RadioSink;
|
||||
})
|
||||
[idx]?.setPtt(true);
|
||||
},
|
||||
code: key,
|
||||
context: DEFAULT_CONTEXT,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
event: "keydown",
|
||||
}).addKeyboardShortcut(`PTT${idx}Active`, {
|
||||
altKey: false,
|
||||
callback: () => {
|
||||
getApp()
|
||||
.getAudioManager()
|
||||
.getSinks()
|
||||
.filter((sink) => {
|
||||
return sink instanceof RadioSink;
|
||||
})
|
||||
[idx]?.setPtt(false);
|
||||
},
|
||||
code: key,
|
||||
context: DEFAULT_CONTEXT,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
event: "keyup",
|
||||
});
|
||||
});
|
||||
|
||||
let panKeys = ["KeyW", "KeyA", "KeyS", "KeyD", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
|
||||
panKeys.forEach((code) => {
|
||||
this.addKeyboardShortcut(`pan${code}keydown`, {
|
||||
altKey: false,
|
||||
callback: (ev: KeyboardEvent) => {
|
||||
|
||||
@ -4,9 +4,14 @@ import { getApp } from "../../olympusapp";
|
||||
import { FaQuestionCircle } from "react-icons/fa";
|
||||
import { AudioSourcePanel } from "./components/sourcepanel";
|
||||
import { AudioSource } from "../../audio/audiosource";
|
||||
import { FaVolumeHigh, FaX } from "react-icons/fa6";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faClose } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export function AudioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) {
|
||||
const [sources, setSources] = useState([] as AudioSource[]);
|
||||
const [audioManagerEnabled, setAudioManagerEnabled] = useState(false);
|
||||
const [showTip, setShowTip] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
/* Force a rerender */
|
||||
@ -18,21 +23,66 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children?
|
||||
.map((source) => source)
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
document.addEventListener("audioManagerStateChanged", () => {
|
||||
setAudioManagerEnabled(getApp().getAudioManager().isRunning());
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Menu title="Audio sources" open={props.open} showBackButton={false} onClose={props.onClose}>
|
||||
<div className="p-4 text-sm text-gray-400">The audio source panel allows you to add and manage audio sources.</div>
|
||||
<div className="mx-6 flex rounded-lg bg-olympus-400 p-4 text-sm">
|
||||
<div>
|
||||
<FaQuestionCircle className="my-4 ml-2 mr-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-gray-100">Use the controls to apply effects and start/stop the playback of an audio source.</div>
|
||||
<div className="text-gray-400">Sources can be connected to your radios, or attached to a unit to be played on loudspeakers.</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{showTip && (
|
||||
<div className="mx-6 flex rounded-lg bg-olympus-400 p-4 text-sm">
|
||||
{audioManagerEnabled ? (
|
||||
<>
|
||||
<div className="my-auto">
|
||||
<FaQuestionCircle className="my-auto ml-2 mr-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-gray-100">Use the controls to apply effects and start/stop the playback of an audio source.</div>
|
||||
<div className="text-gray-400">Sources can be connected to your radios, or attached to a unit to be played on loudspeakers.</div>
|
||||
</div>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
onClick={() => setShowTip(false)}
|
||||
icon={faClose}
|
||||
className={`
|
||||
ml-2 flex cursor-pointer items-center justify-center
|
||||
rounded-md p-2 text-lg
|
||||
dark:text-gray-500 dark:hover:bg-gray-700
|
||||
dark:hover:text-white
|
||||
hover:bg-gray-200
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="my-auto">
|
||||
<FaQuestionCircle className="my-auto ml-2 mr-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-gray-100">
|
||||
To enable the audio menu, first start the audio backend with the{" "}
|
||||
<span
|
||||
className={`
|
||||
mx-1 mt-[-7px] inline-block translate-y-2 rounded-full
|
||||
border-[1px] border-white p-1
|
||||
`}
|
||||
>
|
||||
<FaVolumeHigh />
|
||||
</span>{" "}
|
||||
button on the navigation header.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
<div
|
||||
className={`
|
||||
flex flex-col gap-2 p-5 font-normal text-gray-800
|
||||
@ -40,35 +90,36 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children?
|
||||
`}
|
||||
>
|
||||
<>
|
||||
{sources
|
||||
.map((source) => {
|
||||
return <AudioSourcePanel source={source} />;
|
||||
})}
|
||||
{sources.map((source, idx) => {
|
||||
return <AudioSourcePanel key={idx} source={source} />;
|
||||
})}
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium
|
||||
text-white
|
||||
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
|
||||
focus:outline-none focus:ring-4 focus:ring-blue-300
|
||||
hover:bg-blue-800
|
||||
`}
|
||||
onClick={() => {
|
||||
var input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.click();
|
||||
input.onchange = (e: Event) => {
|
||||
let target = e.target as HTMLInputElement;
|
||||
if (target && target.files) {
|
||||
var file = target.files[0];
|
||||
getApp().getAudioManager().addFileSource(file);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
Add audio source
|
||||
</button>
|
||||
{audioManagerEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium
|
||||
text-white
|
||||
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
|
||||
focus:outline-none focus:ring-4 focus:ring-blue-300
|
||||
hover:bg-blue-800
|
||||
`}
|
||||
onClick={() => {
|
||||
var input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.click();
|
||||
input.onchange = (e: Event) => {
|
||||
let target = e.target as HTMLInputElement;
|
||||
if (target && target.files) {
|
||||
var file = target.files[0];
|
||||
getApp().getAudioManager().addFileSource(file);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
Add audio source
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@ -30,9 +30,10 @@ export function Menu(props: {
|
||||
<div
|
||||
data-hide={hide}
|
||||
className={`
|
||||
pointer-events-auto h-[calc(100vh-58px-2rem)] overflow-y-auto
|
||||
overflow-x-hidden no-scrollbar backdrop-blur-lg backdrop-grayscale
|
||||
transition-transform
|
||||
pointer-events-auto
|
||||
h-[calc(100vh-58px${props.canBeHidden ? "-2rem" : ""})]
|
||||
overflow-y-auto overflow-x-hidden no-scrollbar backdrop-blur-lg
|
||||
backdrop-grayscale transition-transform
|
||||
dark:bg-olympus-800/90
|
||||
data-[hide='true']:translate-y-[calc(100vh-58px)]
|
||||
`}
|
||||
@ -81,9 +82,7 @@ export function Menu(props: {
|
||||
<FaChevronUp className="mx-auto my-auto text-gray-400" />
|
||||
) : (
|
||||
<FaChevronDown
|
||||
className={`
|
||||
mx-auto my-auto text-gray-400
|
||||
`}
|
||||
className={`mx-auto my-auto text-gray-400`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@ import { faEarListen, faMicrophoneLines } from "@fortawesome/free-solid-svg-icon
|
||||
import { RadioSink } from "../../../audio/radiosink";
|
||||
import { getApp } from "../../../olympusapp";
|
||||
|
||||
export function RadioPanel(props: { radio: RadioSink }) {
|
||||
export function RadioPanel(props: { radio: RadioSink; shortcutKey: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
@ -17,14 +17,19 @@ export function RadioPanel(props: { radio: RadioSink }) {
|
||||
>
|
||||
<div className="flex content-center justify-between">
|
||||
<span className="my-auto">{props.radio.getName()}</span>
|
||||
<div className="cursor-pointer rounded-md bg-red-800 p-2" onClick={() => {getApp().getAudioManager().removeSink(props.radio);}}>
|
||||
<div
|
||||
className="cursor-pointer rounded-md bg-red-800 p-2"
|
||||
onClick={() => {
|
||||
getApp().getAudioManager().removeSink(props.radio);
|
||||
}}
|
||||
>
|
||||
<FaTrash className={`text-gray-50`}></FaTrash>
|
||||
</div>
|
||||
</div>
|
||||
<OlFrequencyInput
|
||||
value={props.radio.getFrequency()}
|
||||
onChange={(value) => {
|
||||
props.radio.setFrequency(value)
|
||||
props.radio.setFrequency(value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-row gap-2">
|
||||
@ -37,8 +42,17 @@ export function RadioPanel(props: { radio: RadioSink }) {
|
||||
}}
|
||||
></OlLabelToggle>
|
||||
|
||||
<kbd
|
||||
className={`
|
||||
my-auto ml-auto rounded-lg border border-gray-200 bg-gray-100 px-2
|
||||
py-1.5 text-xs font-semibold text-gray-800
|
||||
dark:border-gray-500 dark:bg-gray-600 dark:text-gray-100
|
||||
`}
|
||||
>
|
||||
{props.shortcutKey}
|
||||
</kbd>
|
||||
|
||||
<OlStateButton
|
||||
className="ml-auto"
|
||||
checked={props.radio.getPtt()}
|
||||
icon={faMicrophoneLines}
|
||||
onClick={() => {
|
||||
|
||||
@ -54,7 +54,7 @@ export function AudioSourcePanel(props: { source: AudioSource }) {
|
||||
checked={false}
|
||||
icon={props.source.getPlaying() ? faPause : faPlay}
|
||||
onClick={() => {
|
||||
if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.stop() : props.source.play();
|
||||
if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.pause() : props.source.play();
|
||||
}}
|
||||
tooltip="Play file"
|
||||
></OlStateButton>
|
||||
@ -106,9 +106,10 @@ export function AudioSourcePanel(props: { source: AudioSource }) {
|
||||
|
||||
<span className="text-sm">Connected to:</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{props.source.getConnectedTo().map((sink) => {
|
||||
{props.source.getConnectedTo().map((sink, idx) => {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`
|
||||
flex justify-start gap-2 rounded-full bg-olympus-400 px-4 py-1
|
||||
text-sm
|
||||
@ -123,9 +124,10 @@ export function AudioSourcePanel(props: { source: AudioSource }) {
|
||||
</div>
|
||||
{availabileSinks.length > 0 && (
|
||||
<OlDropdown label="Connect to:">
|
||||
{availabileSinks.map((sink) => {
|
||||
{availabileSinks.map((sink, idx) => {
|
||||
return (
|
||||
<OlDropdownItem
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
props.source.connect(sink);
|
||||
}}
|
||||
|
||||
@ -33,6 +33,7 @@ export function ControlsPanel(props: {}) {
|
||||
{controls.map((control) => {
|
||||
return (
|
||||
<div
|
||||
key={control.text}
|
||||
className={`
|
||||
flex w-full justify-between gap-2 rounded-full py-1 pl-4 pr-1
|
||||
backdrop-blur-lg
|
||||
@ -48,15 +49,15 @@ export function ControlsPanel(props: {}) {
|
||||
>
|
||||
{control.actions.map((action, idx) => {
|
||||
return (
|
||||
<>
|
||||
<div className={``}>
|
||||
<div key={idx} className="flex gap-1">
|
||||
<div>
|
||||
{typeof action === "string" || typeof action === "number" ? action : <FontAwesomeIcon icon={action} className={`
|
||||
my-auto ml-auto
|
||||
`} />}
|
||||
</div>
|
||||
{idx < control.actions.length - 1 && typeof control.actions[idx + 1] === "string" && <div>+</div>}
|
||||
{idx < control.actions.length - 1 && typeof control.actions[idx + 1] === "number" && <div>x</div>}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -27,22 +27,27 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) {
|
||||
const [erasSelection, setErasSelection] = useState({});
|
||||
const [rangesSelection, setRangesSelection] = useState({});
|
||||
|
||||
const [showPolygonTip, setShowPolygonTip] = useState(true);
|
||||
const [showCircleTip, setShowCircleTip] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
/* If we are not in polygon drawing mode, force the draw polygon button off */
|
||||
if (drawingPolygon && getApp().getMap().getState() !== COALITIONAREA_DRAW_POLYGON) setDrawingPolygon(false);
|
||||
if (getApp()) {
|
||||
/* If we are not in polygon drawing mode, force the draw polygon button off */
|
||||
if (drawingPolygon && getApp().getMap().getState() !== COALITIONAREA_DRAW_POLYGON) setDrawingPolygon(false);
|
||||
|
||||
/* If we are not in circle drawing mode, force the draw circle button off */
|
||||
if (drawingCircle && getApp().getMap().getState() !== COALITIONAREA_DRAW_CIRCLE) setDrawingCircle(false);
|
||||
/* If we are not in circle drawing mode, force the draw circle button off */
|
||||
if (drawingCircle && getApp().getMap().getState() !== COALITIONAREA_DRAW_CIRCLE) setDrawingCircle(false);
|
||||
|
||||
/* If we are not in any drawing mode, force the map in edit mode */
|
||||
if (props.open && !drawingPolygon && !drawingCircle) getApp().getMap().setState(COALITIONAREA_EDIT);
|
||||
/* If we are not in any drawing mode, force the map in edit mode */
|
||||
if (props.open && !drawingPolygon && !drawingCircle) getApp().getMap().setState(COALITIONAREA_EDIT);
|
||||
|
||||
/* Align the state of the coalition toggle to the coalition of the area */
|
||||
if (activeCoalitionArea && activeCoalitionArea?.getCoalition() !== areaCoalition) setAreaCoalition(activeCoalitionArea?.getCoalition());
|
||||
/* Align the state of the coalition toggle to the coalition of the area */
|
||||
if (activeCoalitionArea && activeCoalitionArea?.getCoalition() !== areaCoalition) setAreaCoalition(activeCoalitionArea?.getCoalition());
|
||||
|
||||
if (!props.open) {
|
||||
if ([COALITIONAREA_EDIT, COALITIONAREA_DRAW_CIRCLE, COALITIONAREA_DRAW_POLYGON].includes(getApp()?.getMap()?.getState()))
|
||||
getApp().getMap().setState(IDLE);
|
||||
if (!props.open) {
|
||||
if ([COALITIONAREA_EDIT, COALITIONAREA_DRAW_CIRCLE, COALITIONAREA_DRAW_POLYGON].includes(getApp().getMap().getState()))
|
||||
getApp().getMap().setState(IDLE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -79,8 +84,8 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) {
|
||||
The draw tool allows you to quickly draw areas on the map and use these areas to spawn units and activate triggers.
|
||||
</div>
|
||||
<div className="mx-6 flex rounded-lg bg-olympus-400 p-4 text-sm">
|
||||
<div>
|
||||
<FaQuestionCircle className="my-4 ml-2 mr-6 text-gray-400" />
|
||||
<div className="my-auto">
|
||||
<FaQuestionCircle className="my-auto ml-2 mr-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-gray-100">Use the polygon or circle tool to draw areas on the map.</div>
|
||||
@ -217,14 +222,14 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{getApp()
|
||||
.getGroundUnitDatabase()
|
||||
.getTypes()
|
||||
.map((type) => {
|
||||
.map((type, idx) => {
|
||||
if (!(type in typesSelection)) {
|
||||
typesSelection[type] = true;
|
||||
setTypesSelection(JSON.parse(JSON.stringify(typesSelection)));
|
||||
}
|
||||
|
||||
return (
|
||||
<OlDropdownItem className={`flex gap-4`}>
|
||||
<OlDropdownItem key={idx} className={`flex gap-4`}>
|
||||
<OlCheckbox
|
||||
checked={typesSelection[type]}
|
||||
onChange={(ev) => {
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Menu } from "./components/menu";
|
||||
import { getApp } from "../../olympusapp";
|
||||
import { OlToggle } from "../components/oltoggle";
|
||||
import { RadioPanel } from "./components/radiopanel";
|
||||
import { FaQuestionCircle } from "react-icons/fa";
|
||||
import { RadioSink } from "../../audio/radiosink";
|
||||
import { FaVolumeHigh } from "react-icons/fa6";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faClose } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
let shortcutKeys = ["Z", "X", "C", "V", "B", "N", "M", "K", "L"];
|
||||
|
||||
export function RadioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) {
|
||||
const [radios, setRadios] = useState([] as RadioSink[]);
|
||||
const [audioManagerEnabled, setAudioManagerEnabled] = useState(false);
|
||||
const [showTip, setShowTip] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
/* Force a rerender */
|
||||
@ -20,30 +26,76 @@ export function RadioMenu(props: { open: boolean; onClose: () => void; children?
|
||||
.map((radio) => radio)
|
||||
);
|
||||
});
|
||||
|
||||
document.addEventListener("audioManagerStateChanged", () => {
|
||||
setAudioManagerEnabled(getApp().getAudioManager().isRunning());
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Menu title="Radio" open={props.open} showBackButton={false} onClose={props.onClose}>
|
||||
<div className="p-4 text-sm text-gray-400">The radio menu allows you to talk on radio to the players online using SRS.</div>
|
||||
<div className="mx-6 flex rounded-lg bg-olympus-400 p-4 text-sm">
|
||||
<div>
|
||||
<FaQuestionCircle className="my-4 ml-2 mr-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-gray-100">Use the radio controls to tune to a frequency, then click on the PTT button to talk. </div>
|
||||
<div className="text-gray-400">You can add up to 10 radios. Use the audio effects menu to play audio tracks or to add background noises.</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{showTip && (
|
||||
<div className="mx-6 flex rounded-lg bg-olympus-400 p-4 text-sm">
|
||||
{audioManagerEnabled ? (
|
||||
<>
|
||||
<div className="my-auto">
|
||||
<FaQuestionCircle className="my-auto ml-2 mr-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-gray-100">Use the radio controls to tune to a frequency, then click on the PTT button to talk. </div>
|
||||
<div className="text-gray-400">You can add up to 10 radios. Use the audio effects menu to play audio tracks or to add background noises.</div>
|
||||
</div>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
onClick={() => setShowTip(false)}
|
||||
icon={faClose}
|
||||
className={`
|
||||
ml-2 flex cursor-pointer items-center justify-center
|
||||
rounded-md p-2 text-lg
|
||||
dark:text-gray-500 dark:hover:bg-gray-700
|
||||
dark:hover:text-white
|
||||
hover:bg-gray-200
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FaQuestionCircle className="my-4 ml-2 mr-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-gray-100">
|
||||
To enable the radio menu, first start the audio backend with the{" "}
|
||||
<span
|
||||
className={`
|
||||
mx-1 mt-[-7px] inline-block translate-y-2 rounded-full
|
||||
border-[1px] border-white p-1
|
||||
`}
|
||||
>
|
||||
<FaVolumeHigh />
|
||||
</span>{" "}
|
||||
button on the navigation header.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
<div
|
||||
className={`
|
||||
flex flex-col gap-2 p-5 font-normal text-gray-800
|
||||
dark:text-white
|
||||
`}
|
||||
>
|
||||
{radios.map((radio) => {
|
||||
return <RadioPanel radio={radio}></RadioPanel>;
|
||||
{radios.map((radio, idx) => {
|
||||
return <RadioPanel shortcutKey={shortcutKeys[idx]} key={radio.getName()} radio={radio}></RadioPanel>;
|
||||
})}
|
||||
{radios.length < 10 && (
|
||||
{audioManagerEnabled && radios.length < 10 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
|
||||
@ -23,8 +23,8 @@ export function SpawnMenu(props: { open: boolean; onClose: () => void; children?
|
||||
const [filteredAircraft, filteredHelicopters, filteredAirDefense, filteredGroundUnits, filteredNavyUnits] = getUnitsByLabel(filterString);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) {
|
||||
if (getApp()?.getMap()?.getState() === SPAWN_UNIT) getApp().getMap().setState(IDLE);
|
||||
if (!props.open && getApp()) {
|
||||
if (getApp().getMap().getState() === SPAWN_UNIT) getApp().getMap().setState(IDLE);
|
||||
if (blueprint !== null) setBlueprint(null);
|
||||
}
|
||||
});
|
||||
|
||||
@ -38,7 +38,7 @@ import {
|
||||
olButtonsVisibilityOlympus,
|
||||
} from "../components/olicons";
|
||||
import { Coalition } from "../../types/types";
|
||||
import { ftToM, getUnitDatabaseByCategory, getUnitsByLabel, knotsToMs, mToFt, msToKnots } from "../../other/utils";
|
||||
import { ftToM, getUnitsByLabel, knotsToMs, mToFt, msToKnots } from "../../other/utils";
|
||||
import { FaCog, FaGasPump, FaSignal, FaTag } from "react-icons/fa";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { OlSearchBar } from "../components/olsearchbar";
|
||||
@ -272,9 +272,9 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
human: ["Human", olButtonsVisibilityHuman],
|
||||
olympus: ["Olympus controlled", olButtonsVisibilityOlympus],
|
||||
dcs: ["From DCS mission", olButtonsVisibilityDcs],
|
||||
}).map((entry) => {
|
||||
}).map((entry, idx) => {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-between" key={idx}>
|
||||
<span className="font-light text-white">{entry[1][0] as string}</span>
|
||||
<OlToggle
|
||||
key={entry[0]}
|
||||
@ -297,81 +297,83 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
Types and coalitions
|
||||
</div>
|
||||
<table>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td className="pb-4 text-center font-bold text-blue-500">BLUE</td>
|
||||
<td className="pb-4 text-center font-bold text-gray-500">NEUTRAL</td>
|
||||
<td className="pb-4 text-center font-bold text-red-500">RED</td>
|
||||
</tr>
|
||||
{selectionBlueprint === null &&
|
||||
Object.entries({
|
||||
aircraft: olButtonsVisibilityAircraft,
|
||||
helicopter: olButtonsVisibilityHelicopter,
|
||||
"groundunit-sam": olButtonsVisibilityGroundunitSam,
|
||||
groundunit: olButtonsVisibilityGroundunit,
|
||||
navyunit: olButtonsVisibilityNavyunit,
|
||||
}).map((entry) => {
|
||||
return (
|
||||
<tr>
|
||||
<td className="text-lg text-gray-200">
|
||||
<FontAwesomeIcon icon={entry[1]} />
|
||||
</td>
|
||||
{["blue", "neutral", "red"].map((coalition) => {
|
||||
return (
|
||||
<td className="text-center">
|
||||
<OlCheckbox
|
||||
checked={selectionFilter[coalition][entry[0]]}
|
||||
disabled={selectionBlueprint !== null}
|
||||
onChange={() => {
|
||||
selectionFilter[coalition][entry[0]] = !selectionFilter[coalition][entry[0]];
|
||||
setSelectionFilter(JSON.parse(JSON.stringify(selectionFilter)));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td className="text-gray-200"></td>
|
||||
<td className="text-center">
|
||||
<OlCheckbox
|
||||
checked={Object.values(selectionFilter["blue"]).some((value) => value)}
|
||||
onChange={() => {
|
||||
const newValue = !Object.values(selectionFilter["blue"]).some((value) => value);
|
||||
Object.keys(selectionFilter["blue"]).forEach((key) => {
|
||||
selectionFilter["blue"][key] = newValue;
|
||||
});
|
||||
setSelectionFilter(JSON.parse(JSON.stringify(selectionFilter)));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<OlCheckbox
|
||||
checked={Object.values(selectionFilter["neutral"]).some((value) => value)}
|
||||
onChange={() => {
|
||||
const newValue = !Object.values(selectionFilter["neutral"]).some((value) => value);
|
||||
Object.keys(selectionFilter["neutral"]).forEach((key) => {
|
||||
selectionFilter["neutral"][key] = newValue;
|
||||
});
|
||||
setSelectionFilter(JSON.parse(JSON.stringify(selectionFilter)));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<OlCheckbox
|
||||
checked={Object.values(selectionFilter["red"]).some((value) => value)}
|
||||
onChange={() => {
|
||||
const newValue = !Object.values(selectionFilter["red"]).some((value) => value);
|
||||
Object.keys(selectionFilter["red"]).forEach((key) => {
|
||||
selectionFilter["red"][key] = newValue;
|
||||
});
|
||||
setSelectionFilter(JSON.parse(JSON.stringify(selectionFilter)));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td className="pb-4 text-center font-bold text-blue-500">BLUE</td>
|
||||
<td className="pb-4 text-center font-bold text-gray-500">NEUTRAL</td>
|
||||
<td className="pb-4 text-center font-bold text-red-500">RED</td>
|
||||
</tr>
|
||||
{selectionBlueprint === null &&
|
||||
Object.entries({
|
||||
aircraft: olButtonsVisibilityAircraft,
|
||||
helicopter: olButtonsVisibilityHelicopter,
|
||||
"groundunit-sam": olButtonsVisibilityGroundunitSam,
|
||||
groundunit: olButtonsVisibilityGroundunit,
|
||||
navyunit: olButtonsVisibilityNavyunit,
|
||||
}).map((entry, idx) => {
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td className="text-lg text-gray-200">
|
||||
<FontAwesomeIcon icon={entry[1]} />
|
||||
</td>
|
||||
{["blue", "neutral", "red"].map((coalition) => {
|
||||
return (
|
||||
<td className="text-center" key={coalition}>
|
||||
<OlCheckbox
|
||||
checked={selectionFilter[coalition][entry[0]]}
|
||||
disabled={selectionBlueprint !== null}
|
||||
onChange={() => {
|
||||
selectionFilter[coalition][entry[0]] = !selectionFilter[coalition][entry[0]];
|
||||
setSelectionFilter(JSON.parse(JSON.stringify(selectionFilter)));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td className="text-gray-200"></td>
|
||||
<td className="text-center">
|
||||
<OlCheckbox
|
||||
checked={Object.values(selectionFilter["blue"]).some((value) => value)}
|
||||
onChange={() => {
|
||||
const newValue = !Object.values(selectionFilter["blue"]).some((value) => value);
|
||||
Object.keys(selectionFilter["blue"]).forEach((key) => {
|
||||
selectionFilter["blue"][key] = newValue;
|
||||
});
|
||||
setSelectionFilter(JSON.parse(JSON.stringify(selectionFilter)));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<OlCheckbox
|
||||
checked={Object.values(selectionFilter["neutral"]).some((value) => value)}
|
||||
onChange={() => {
|
||||
const newValue = !Object.values(selectionFilter["neutral"]).some((value) => value);
|
||||
Object.keys(selectionFilter["neutral"]).forEach((key) => {
|
||||
selectionFilter["neutral"][key] = newValue;
|
||||
});
|
||||
setSelectionFilter(JSON.parse(JSON.stringify(selectionFilter)));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<OlCheckbox
|
||||
checked={Object.values(selectionFilter["red"]).some((value) => value)}
|
||||
onChange={() => {
|
||||
const newValue = !Object.values(selectionFilter["red"]).some((value) => value);
|
||||
Object.keys(selectionFilter["red"]).forEach((key) => {
|
||||
selectionFilter["red"][key] = newValue;
|
||||
});
|
||||
setSelectionFilter(JSON.parse(JSON.stringify(selectionFilter)));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<div ref={searchBarRef}>
|
||||
@ -466,9 +468,10 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{
|
||||
<>
|
||||
{["blue", "red", "neutral"].map((coalition) => {
|
||||
return Object.keys(unitOccurences[coalition]).map((name) => {
|
||||
return Object.keys(unitOccurences[coalition]).map((name, idx) => {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
data-coalition={coalition}
|
||||
className={`
|
||||
flex content-center justify-between border-l-4
|
||||
@ -648,6 +651,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{[olButtonsRoeHold, olButtonsRoeReturn, olButtonsRoeDesignated, olButtonsRoeFree].map((icon, idx) => {
|
||||
return (
|
||||
<OlButtonGroupItem
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
selectedUnits.forEach((unit) => {
|
||||
unit.setROE(ROEs[idx]);
|
||||
@ -685,6 +689,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{[olButtonsThreatNone, olButtonsThreatPassive, olButtonsThreatManoeuvre, olButtonsThreatEvade].map((icon, idx) => {
|
||||
return (
|
||||
<OlButtonGroupItem
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
selectedUnits.forEach((unit) => {
|
||||
unit.setReactionToThreat(reactionsToThreat[idx]);
|
||||
@ -716,6 +721,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{[olButtonsEmissionsSilent, olButtonsEmissionsDefend, olButtonsEmissionsAttack, olButtonsEmissionsFree].map((icon, idx) => {
|
||||
return (
|
||||
<OlButtonGroupItem
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
selectedUnits.forEach((unit) => {
|
||||
unit.setEmissionsCountermeasures(emissionsCountermeasures[idx]);
|
||||
@ -847,6 +853,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{[olButtonsScatter1, olButtonsScatter2, olButtonsScatter3].map((icon, idx) => {
|
||||
return (
|
||||
<OlButtonGroupItem
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
selectedUnits.forEach((unit) => {
|
||||
unit.setShotsScatter(idx + 1);
|
||||
@ -878,6 +885,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{[olButtonsIntensity1, olButtonsIntensity2, olButtonsIntensity3].map((icon, idx) => {
|
||||
return (
|
||||
<OlButtonGroupItem
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
selectedUnits.forEach((unit) => {
|
||||
unit.setShotsIntensity(idx + 1);
|
||||
@ -992,6 +1000,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{["Overlord", "Magic", "Wizard", "Focus", "Darkstar"].map((name, idx) => {
|
||||
return (
|
||||
<OlDropdownItem
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
if (activeAdvancedSettings) activeAdvancedSettings.radio.callsign = idx + 1;
|
||||
setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings)));
|
||||
@ -1010,6 +1019,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
{["Texaco", "Arco", "Shell"].map((name, idx) => {
|
||||
return (
|
||||
<OlDropdownItem
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
if (activeAdvancedSettings) activeAdvancedSettings.radio.callsign = idx + 1;
|
||||
setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings)));
|
||||
@ -1071,6 +1081,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
my-auto w-20
|
||||
`}>
|
||||
<OlDropdownItem
|
||||
key={"X"}
|
||||
onClick={() => {
|
||||
if (activeAdvancedSettings) activeAdvancedSettings.TACAN.XY = "X";
|
||||
setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings)));
|
||||
@ -1079,6 +1090,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
X
|
||||
</OlDropdownItem>
|
||||
<OlDropdownItem
|
||||
key={"Y"}
|
||||
onClick={() => {
|
||||
if (activeAdvancedSettings) activeAdvancedSettings.TACAN.XY = "Y";
|
||||
setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings)));
|
||||
@ -1113,12 +1125,15 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
|
||||
|
||||
<div className="text-sm text-gray-200">Radio frequency</div>
|
||||
<div className="flex content-center gap-2">
|
||||
<OlFrequencyInput value={activeAdvancedSettings? activeAdvancedSettings.radio.frequency: 251000000} onChange={(value) => {
|
||||
if (activeAdvancedSettings) {
|
||||
activeAdvancedSettings.radio.frequency = value;
|
||||
setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings)));
|
||||
}
|
||||
}}/>
|
||||
<OlFrequencyInput
|
||||
value={activeAdvancedSettings ? activeAdvancedSettings.radio.frequency : 251000000}
|
||||
onChange={(value) => {
|
||||
if (activeAdvancedSettings) {
|
||||
activeAdvancedSettings.radio.frequency = value;
|
||||
setActiveAdvancedSettings(JSON.parse(JSON.stringify(activeAdvancedSettings)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex pt-8">
|
||||
|
||||
@ -18,8 +18,7 @@ export function UnitMouseControlBar(props: {}) {
|
||||
/* Initialize the "scroll" position of the element */
|
||||
var scrollRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (scrollRef.current)
|
||||
onScroll(scrollRef.current);
|
||||
if (scrollRef.current) onScroll(scrollRef.current);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -94,9 +93,10 @@ export function UnitMouseControlBar(props: {}) {
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar p-2" onScroll={(ev) => onScroll(ev.target)} ref={scrollRef}>
|
||||
{Object.values(contextActionsSet.getContextActions()).map((contextAction) => {
|
||||
{Object.values(contextActionsSet.getContextActions()).map((contextAction: ContextAction) => {
|
||||
return (
|
||||
<OlStateButton
|
||||
key={contextAction.getId()}
|
||||
checked={contextAction === activeContextAction}
|
||||
icon={contextAction.getIcon()}
|
||||
tooltip={contextAction.getLabel()}
|
||||
@ -109,13 +109,13 @@ export function UnitMouseControlBar(props: {}) {
|
||||
setActiveContextAction(contextAction);
|
||||
getApp().getMap().setState(CONTEXT_ACTION, {
|
||||
contextAction: contextAction,
|
||||
defaultContextAction: contextActionsSet.getDefaultContextAction()
|
||||
defaultContextAction: contextActionsSet.getDefaultContextAction(),
|
||||
});
|
||||
} else {
|
||||
setActiveContextAction(null);
|
||||
getApp().getMap().setState(CONTEXT_ACTION, {
|
||||
contextAction: null,
|
||||
defaultContextAction: contextActionsSet.getDefaultContextAction()
|
||||
defaultContextAction: contextActionsSet.getDefaultContextAction(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,7 +56,7 @@ export function UnitSpawnMenu(props: { blueprint: UnitBlueprint; spawnAtLocation
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (getApp()?.getMap()?.getState() === SPAWN_UNIT) getApp().getMap().setState(IDLE);
|
||||
if (getApp().getMap().getState() === SPAWN_UNIT) getApp().getMap().setState(IDLE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -77,8 +77,7 @@ import {
|
||||
faVolumeHigh,
|
||||
faXmarksLines,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FaXmarksLines } from "react-icons/fa6";
|
||||
import { ContextAction } from "./contextaction";
|
||||
import { Carrier } from "../mission/carrier";
|
||||
|
||||
var pathIcon = new Icon({
|
||||
iconUrl: "/vite/images/markers/marker-icon.png",
|
||||
@ -850,7 +849,7 @@ export abstract class Unit extends CustomMarker {
|
||||
if (targetPosition) getApp().getUnitsManager().addDestination(targetPosition, false, 0, units);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
contextActionSet.addContextAction(
|
||||
this,
|
||||
"speaker",
|
||||
@ -864,20 +863,12 @@ export abstract class Unit extends CustomMarker {
|
||||
{ executeImmediately: true }
|
||||
);
|
||||
|
||||
contextActionSet.addDefaultContextAction(
|
||||
this,
|
||||
"default",
|
||||
"Set destination",
|
||||
"",
|
||||
faRoute,
|
||||
null,
|
||||
(units: Unit[], targetUnit, targetPosition) => {
|
||||
if (targetPosition) {
|
||||
getApp().getUnitsManager().clearDestinations(units);
|
||||
getApp().getUnitsManager().addDestination(targetPosition, false, 0, units);
|
||||
}
|
||||
contextActionSet.addDefaultContextAction(this, "default", "Set destination", "", faRoute, null, (units: Unit[], targetUnit, targetPosition) => {
|
||||
if (targetPosition) {
|
||||
getApp().getUnitsManager().clearDestinations(units);
|
||||
getApp().getUnitsManager().addDestination(targetPosition, false, 0, units);
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
drawLines() {
|
||||
@ -1335,7 +1326,8 @@ export abstract class Unit extends CustomMarker {
|
||||
"Line abreast (LH)",
|
||||
"Follow unit in line abreast left formation",
|
||||
olButtonsContextLineAbreast,
|
||||
null, () => this.applyFollowOptions("line-abreast-lh", units)
|
||||
null,
|
||||
() => this.applyFollowOptions("line-abreast-lh", units)
|
||||
);
|
||||
contextActionSet.addContextAction(
|
||||
this,
|
||||
@ -1343,9 +1335,12 @@ export abstract class Unit extends CustomMarker {
|
||||
"Line abreast (RH)",
|
||||
"Follow unit in line abreast right formation",
|
||||
olButtonsContextLineAbreast,
|
||||
null, () => this.applyFollowOptions("line-abreast-rh", units)
|
||||
null,
|
||||
() => this.applyFollowOptions("line-abreast-rh", units)
|
||||
);
|
||||
contextActionSet.addContextAction(this, "front", "Front", "Fly in front of unit", olButtonsContextFront, null, () =>
|
||||
this.applyFollowOptions("front", units)
|
||||
);
|
||||
contextActionSet.addContextAction(this, "front", "Front", "Fly in front of unit", olButtonsContextFront, null, () => this.applyFollowOptions("front", units));
|
||||
contextActionSet.addContextAction(this, "diamond", "Diamond", "Follow unit in diamond formation", olButtonsContextDiamond, null, () =>
|
||||
this.applyFollowOptions("diamond", units)
|
||||
);
|
||||
@ -1443,7 +1438,7 @@ export abstract class Unit extends CustomMarker {
|
||||
|
||||
#onLongPress(e: any) {
|
||||
console.log(`Long press on ${this.getUnitName()}`);
|
||||
|
||||
|
||||
if (e.originalEvent.button === 2) {
|
||||
document.dispatchEvent(new CustomEvent("showUnitContextMenu", { detail: e }));
|
||||
}
|
||||
@ -1866,7 +1861,7 @@ export abstract class AirUnit extends Unit {
|
||||
"Refuel at tanker",
|
||||
"Refuel units at the nearest AAR Tanker. If no tanker is available the unit will RTB",
|
||||
olButtonsContextRefuel,
|
||||
null,
|
||||
null,
|
||||
(units: Unit[]) => {
|
||||
getApp().getUnitsManager().refuel(units);
|
||||
},
|
||||
@ -1878,7 +1873,7 @@ export abstract class AirUnit extends Unit {
|
||||
"Center map",
|
||||
"Center the map on the unit and follow it",
|
||||
faMapLocation,
|
||||
null,
|
||||
null,
|
||||
(units: Unit[]) => {
|
||||
getApp().getMap().centerOnUnit(units[0]);
|
||||
},
|
||||
@ -2158,6 +2153,8 @@ export class GroundUnit extends Unit {
|
||||
}
|
||||
|
||||
export class NavyUnit extends Unit {
|
||||
#carrier: Carrier;
|
||||
|
||||
constructor(ID: number) {
|
||||
super(ID);
|
||||
}
|
||||
@ -2251,4 +2248,31 @@ export class NavyUnit extends Unit {
|
||||
getDefaultMarker() {
|
||||
return "navyunit";
|
||||
}
|
||||
|
||||
setData(dataExtractor: DataExtractor) {
|
||||
super.setData(dataExtractor);
|
||||
|
||||
if (this.#carrier) {
|
||||
this.#carrier.setLatLng(this.getPosition());
|
||||
this.#carrier.setHeading(this.getHeading());
|
||||
this.#carrier.updateSize();
|
||||
}
|
||||
}
|
||||
|
||||
onAdd(map: Map): this {
|
||||
super.onAdd(map);
|
||||
if (this.getBlueprint()?.type === "Aircraft Carrier")
|
||||
this.#carrier = new Carrier({
|
||||
position: this.getPosition(),
|
||||
name: this.getUnitName(),
|
||||
}).addTo(getApp().getMap());
|
||||
return this;
|
||||
}
|
||||
|
||||
onRemove(map: Map): this {
|
||||
super.onRemove(map);
|
||||
if (this.#carrier)
|
||||
this.#carrier.removeFrom(getApp().getMap())
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// TODO This code is in common with the frontend, would be nice to share it */
|
||||
import { byteArrayToDouble, byteArrayToInteger, doubleToByteArray, integerToByteArray } from "../utils";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
@ -20,11 +21,6 @@ export class AudioPacket {
|
||||
#unitID: number = 0;
|
||||
#hops: number = 0;
|
||||
|
||||
/* Out of standard data (this is not compliant with SRS standard, used for external audio effects) */
|
||||
#latitude: number | null = null;
|
||||
#longitude: number | null = null;
|
||||
#altitude: number | null = null;
|
||||
|
||||
/* Usually internally set only */
|
||||
#packetID: number | null = null;
|
||||
|
||||
@ -138,18 +134,6 @@ export class AudioPacket {
|
||||
[...Buffer.from(this.#clientGUID, "utf-8")]
|
||||
);
|
||||
|
||||
if (
|
||||
this.#latitude !== undefined &&
|
||||
this.#longitude !== undefined &&
|
||||
this.#altitude !== undefined
|
||||
) {
|
||||
encodedData.concat(
|
||||
[...doubleToByteArray(this.#latitude)],
|
||||
[...doubleToByteArray(this.#longitude)],
|
||||
[...doubleToByteArray(this.#altitude)]
|
||||
);
|
||||
}
|
||||
|
||||
// Set the lengths of the parts
|
||||
let encPacketLen = integerToByteArray(encodedData.length, 2);
|
||||
encodedData[0] = encPacketLen[0];
|
||||
@ -223,28 +207,4 @@ export class AudioPacket {
|
||||
getHops() {
|
||||
return this.#hops;
|
||||
}
|
||||
|
||||
setLatitude(latitude: number) {
|
||||
this.#latitude = latitude;
|
||||
}
|
||||
|
||||
getLatitude() {
|
||||
return this.#latitude;
|
||||
}
|
||||
|
||||
setLongitude(longitude: number) {
|
||||
this.#longitude = longitude;
|
||||
}
|
||||
|
||||
getLongitude() {
|
||||
return this.#longitude;
|
||||
}
|
||||
|
||||
setAltitude(altitude: number) {
|
||||
this.#altitude = altitude;
|
||||
}
|
||||
|
||||
getAltitude() {
|
||||
return this.#altitude;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"backend": {
|
||||
"address": "localhost",
|
||||
"port": 3001
|
||||
"address": "88.99.250.188",
|
||||
"port": 3000
|
||||
},
|
||||
"authentication": {
|
||||
"gameMasterPassword": "4b8823ed9e5c2392ab4a791913bb8ce41956ea32e308b760eefb97536746dd33",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user