/*
* ShootOFF - Software for Laser Dry Fire Training
* Copyright (C) 2016 phrack
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.shootoff.plugins;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.LineEvent;
import javax.sound.sampled.LineListener;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.UnsupportedAudioFileException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.shootoff.camera.CameraView;
import com.shootoff.camera.CamerasSupervisor;
import com.shootoff.camera.processors.ShotProcessor;
import com.shootoff.camera.processors.VirtualMagazineProcessor;
import com.shootoff.config.Configuration;
import com.shootoff.gui.DelayedStartListener;
import com.shootoff.gui.ParListener;
import com.shootoff.gui.ShotEntry;
import com.shootoff.targets.Target;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumn.CellDataFeatures;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.util.Callback;
/**
* This class implements common training exercise operations. All training
* exercises should extend it unless they are meant to be used solely on the
* projector arena. Projector arena exercises should extend
* {@link com.shootoff.plugins.ProjectorTrainingExerciseBase}.
*
* @author phrack
*/
public abstract class TrainingExerciseBase {
private static final Logger logger = LoggerFactory.getLogger(TrainingExerciseBase.class);
protected Configuration config;
private static boolean isSilenced = false;
@SuppressWarnings("unused") private List<Target> targets;
private CamerasSupervisor camerasSupervisor;
private TrainingExerciseView exerciseView;
private VBox buttonsContainer;
private Pane trainingExerciseContainer;
private TableView<ShotEntry> shotTimerTable;
private boolean changedRowColor = false;
private boolean haveDelayControls = false;
private boolean haveParControls = false;
private final Map<CameraView, Label> exerciseLabels = new HashMap<>();
private final List<Pane> exercisePanes = new ArrayList<>();
private final Map<String, TableColumn<ShotEntry, String>> exerciseColumns = new HashMap<>();
private final List<Button> exerciseButtons = new ArrayList<>();
// Only exists to make it easy to call getInfo without having
// to do a bunch of unnecessary setup
public TrainingExerciseBase() {}
public TrainingExerciseBase(List<Target> targets) {
this.targets = targets;
}
public void init(CamerasSupervisor camerasSupervisor, TrainingExerciseView exerciseView) {
config = Configuration.getConfig();
init(config, camerasSupervisor, exerciseView.getButtonsPane(), exerciseView.getShotEntryTable());
trainingExerciseContainer = exerciseView.getTrainingExerciseContainer();
this.exerciseView = exerciseView;
if (exerciseView.getArenaView().isPresent()) {
final Label exerciseLabel = new Label();
exerciseLabel.setTextFill(Color.WHITE);
final CameraView arenaView = exerciseView.getArenaView().get();
arenaView.addChild(exerciseLabel);
exerciseLabels.put(arenaView, exerciseLabel);
}
}
// This is only required for unit tests where we don't want to create a full
// ShootOFFController
public void init(final Configuration config, final CamerasSupervisor camerasSupervisor, final VBox buttonsPane,
final TableView<ShotEntry> shotEntryTable) {
this.config = config;
this.camerasSupervisor = camerasSupervisor;
buttonsContainer = buttonsPane;
shotTimerTable = shotEntryTable;
for (final CameraView cv : camerasSupervisor.getCameraViews()) {
final Label exerciseLabel = new Label();
exerciseLabel.setTextFill(Color.WHITE);
cv.addChild(exerciseLabel);
exerciseLabels.put(cv, exerciseLabel);
}
}
/**
* Allows sounds to be silenced or on. If silenced, instead of playing a
* sound the file name will be printed to stdout. This exists so that
* components can be easily tested even if they are reliant on sounds.
*
* @param isSilenced
* set to <tt>true</tt> if sound file names should instead be
* printed to stdout, <tt>false</tt> for normal operation.
*/
public static void silence(boolean isSilenced) {
TrainingExerciseBase.isSilenced = isSilenced;
}
/**
* Returns the current instance of this class. This method exists so that we
* can call methods in this class when in an internal class (e.g. to
* implement Callable) that doesn't have access to super.
*
* @return the current instance of this class
*/
public TrainingExerciseBase getInstance() {
return this;
}
private static class DelayPane extends GridPane {
public DelayPane(DelayedStartListener listener) {
getColumnConstraints().add(new ColumnConstraints(100));
setVgap(5);
final Label instructionsLabel = new Label("Set interval within which a beep will sound\n"
+ "to signal the start of a round.\nDefault: A round starts after a random wait\n"
+ "between 4 and 8 seconds in length.\n");
instructionsLabel.setPrefSize(300, 77);
this.add(instructionsLabel, 0, 0, 2, 3);
addRow(3, new Label("Min (s)"));
addRow(4, new Label("Max (s)"));
final TextField minTextField = new TextField("4");
this.add(minTextField, 1, 3);
final TextField maxTextField = new TextField("8");
this.add(maxTextField, 1, 4);
minTextField.textProperty().addListener((observable, oldValue, newValue) -> {
if (!newValue.matches("\\d*")) {
minTextField.setText(oldValue);
minTextField.positionCaret(minTextField.getLength());
} else {
listener.updatedDelayedStartInterval(Integer.parseInt(minTextField.getText()),
Integer.parseInt(maxTextField.getText()));
}
});
maxTextField.textProperty().addListener((observable, oldValue, newValue) -> {
if (!newValue.matches("\\d*")) {
maxTextField.setText(oldValue);
maxTextField.positionCaret(maxTextField.getLength());
} else {
listener.updatedDelayedStartInterval(Integer.parseInt(minTextField.getText()),
Integer.parseInt(maxTextField.getText()));
}
});
}
}
/**
* Shows controls that let the user set the interval for a random start
* delay in seconds. Notify interval points to a function that gets the min
* and max values for the interval as parameters.
*
* @param listener
* the object to notify when the user closes this window with a
* set value
*
* @since 1.4
*/
public void getDelayedStartInterval(final DelayedStartListener listener) {
if (listener == null) throw new IllegalArgumentException("Delayed start listener must be non-null");
if (haveDelayControls) return;
final DelayPane delayPane = new DelayPane(listener);
trainingExerciseContainer.getChildren().add(delayPane);
exercisePanes.add(delayPane);
haveDelayControls = true;
}
/**
* A copy of getDelayedStartInterval() plus setting the PAR time.
*
* @param listener
* the object to notify when a par time is set.
*/
public void getParInterval(final ParListener listener) {
if (listener == null) throw new IllegalArgumentException("Par listener must be non-null");
if (haveParControls) return;
final DelayPane parPane = new DelayPane(listener);
final TextField parTextField = new TextField("2.0");
parPane.addRow(5, new Label("PAR Time (s)"));
parPane.add(parTextField, 1, 5);
parTextField.textProperty().addListener(new ChangeListener<String>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if (!newValue.matches("^\\d*\\.?\\d*$")) {
parTextField.setText(oldValue);
parTextField.positionCaret(parTextField.getLength());
} else {
listener.updatedParInterval(Double.parseDouble(parTextField.getText()));
}
}
});
trainingExerciseContainer.getChildren().add(parPane);
exercisePanes.add(parPane);
haveParControls = true;
}
/**
* Add a pane with exercise-specific controls (e.g. to collect user
* settings) to the bottom of the main ShootOFF window.
*
* @param pane
* a pane with exercise-specific controls to add to the main
* ShootOFF window
*/
public void addExercisePane(Pane pane) {
trainingExerciseContainer.getChildren().add(pane);
exercisePanes.add(pane);
}
/**
* Adds a button to the right of the reset button on the main ShootOFF
* window with caption <tt>text</tt> and action handler
* <tt>eventHandler</tt>.
*
* @param text
* the caption to display on the new button
*
* @param eventHandler
* the event handler that performs actions when this new button
* is clicked
*
* @return the new button that was added to the main ShootOFF window
*/
public Button addShootOFFButton(final String text, final EventHandler<ActionEvent> eventHandler) {
final Button exerciseButton = new Button(text);
final Button resetButton = (Button) buttonsContainer.getChildren().get(0);
exerciseButton.setOnAction(eventHandler);
exerciseButton.setPrefSize(resetButton.getPrefWidth(), resetButton.getPrefHeight());
exerciseButtons.add(exerciseButton);
buttonsContainer.getChildren().add(exerciseButton);
return exerciseButton;
}
public void removeShootOFFButton(final Button exerciseButton) {
if (!exerciseButtons.contains(exerciseButton)) return;
buttonsContainer.getChildren().remove(exerciseButton);
exerciseButtons.remove(exerciseButton);
}
/**
* Adds a column to the shot timer table. The <tt>name</tt> is used to
* reference this column for the purposes of setting text and cleaning up.
*
* @param name
* both the text that will appear for name of the column and the
* name used to reference this column whenever it needs to be
* looked up
* @param width
* the width of the new column
*
* @since 1.3
*/
public void addShotTimerColumn(String name, int width) {
final TableColumn<ShotEntry, String> newCol = new TableColumn<>(name);
newCol.setPrefWidth(width);
newCol.setCellValueFactory(new Callback<CellDataFeatures<ShotEntry, String>, ObservableValue<String>>() {
@Override
public ObservableValue<String> call(CellDataFeatures<ShotEntry, String> p) {
return new SimpleStringProperty(p.getValue().getExerciseValue(name));
}
});
exerciseColumns.put(name, newCol);
shotTimerTable.getColumns().add(newCol);
}
/**
* Inserts text into the named column for the last entry in the shot timer
* table.
*
* @param name
* the name of the column to insert the text into
* @param value
* the text that should be inserted
*
* @since 1.3
*/
public void setShotTimerColumnText(final String name, final String value) {
if (shotTimerTable != null && shotTimerTable.getItems() != null) {
final Runnable shotTimerColumnTextSetter = () -> {
if (shotTimerTable.getItems().size() == 0) {
logger.error("Trying to set shot timer column text on an empty shot timer list",
new AssertionError("Shot timer table is empty"));
return;
}
shotTimerTable.getItems().get(shotTimerTable.getItems().size() - 1).setExerciseValue(name, value);
};
if (Platform.isFxApplicationThread()) {
shotTimerColumnTextSetter.run();
} else {
Platform.runLater(shotTimerColumnTextSetter);
}
}
}
/**
* Set the background color for rows for shots added to the shot timer after
* this method is called.
*
* @param c
* the color to use in the style string for the row. Set to null
* to return the row color to the default color
*/
public void setShotTimerRowColor(final Color c) {
changedRowColor = true;
config.setShotTimerRowColor(c);
}
/**
* Shows a message on every single webcam feed.
*
* @param message
* the message to show on every webcam feed
*
* @since 1.0
*/
public void showTextOnFeed(String message) {
if (config.inDebugMode()) System.out.println(message);
if (config.getSessionRecorder().isPresent()) {
config.getSessionRecorder().get().recordExerciseFeedMessage(message);
}
Platform.runLater(() -> {
for (final Label exerciseLabel : exerciseLabels.values()) {
exerciseLabel.setText(message);
}
});
}
/**
* Reset the round count in the virtual magazine to its configured capacity.
*
* @since 4.0
*/
public void reloadVirtualMagazine() {
if (!config.useVirtualMagazine()) return;
for (ShotProcessor processor : config.getShotProcessors()) {
if (processor instanceof VirtualMagazineProcessor) {
processor.reset();
}
}
}
/**
* Clear all present shots.
*/
public void clearShots() {
camerasSupervisor.clearShots();
}
/**
* Perform the equivalent of the user hitting the reset button.
*/
public void reset() {
camerasSupervisor.reset();
if (changedRowColor) {
config.setShotTimerRowColor(null);
changedRowColor = false;
}
if (config.getExercise().isPresent()) config.getExercise().get().reset(exerciseView.getTargets());
}
/**
* Get a list of all of the targets on every canvas manager
*
* @return a list of all targets that ShootOFF will detect hits (only lists
* targets known at the time this method was called)
*/
public List<Target> getCurrentTargets() {
return exerciseView.getTargets();
}
/**
* Sets whether or not shot detection is paused.
*
* @param isPaused
* <tt>true</tt> to temporarily stop detecting shots
*
* @since 1.4
*/
public void pauseShotDetection(final boolean isPaused) {
camerasSupervisor.setDetectingAll(!isPaused);
}
/**
* Plays an audio file asynchronously.
*
* @param soundFilePath
* the audio file to play (e.g. "sounds/metal_clang.wav")
*
* @since 1.1
*/
public static void playSound(final String soundFilePath) {
playSound(new File(soundFilePath));
}
public static void playSound(final File soundFile) {
playSound(soundFile, Optional.empty());
}
public static void playSound(final InputStream is) {
playSound(is, Optional.empty());
}
public static void playSound(final InputStream is, Optional<LineListener> listener) {
if (isSilenced) {
System.out.println("Playing audio for modular exercise.");
return;
}
try {
final AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(is);
playSound(audioInputStream, listener);
} catch (UnsupportedAudioFileException | IOException e) {
logger.error("Error reading sound stream to play", e);
}
}
private static void playSound(File soundFile, Optional<LineListener> listener) {
if (isSilenced) {
System.out.println(soundFile.getPath());
return;
}
if (!soundFile.isAbsolute()) {
soundFile = new File(System.getProperty("shootoff.home") + File.separator + soundFile.getPath());
}
try {
final AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(soundFile);
playSound(audioInputStream, listener);
} catch (UnsupportedAudioFileException | IOException e) {
logger.error(String.format("Error reading sound file to play: soundFile = %s", soundFile), e);
}
}
private static void playSound(AudioInputStream audioInputStream, Optional<LineListener> listener) {
final AudioFormat format = audioInputStream.getFormat();
final DataLine.Info info = new DataLine.Info(SourceDataLine.class, format);
SourceDataLine line = null;
try {
line = (SourceDataLine) AudioSystem.getLine(info);
line.open(format);
line.start();
if (listener.isPresent()) {
line.addLineListener(listener.get());
} else {
line.addLineListener((e) -> {
if (LineEvent.Type.STOP.equals(e.getType())) {
e.getLine().close();
try {
audioInputStream.close();
} catch (final Exception e1) {
logger.error("Error closing audio input stream", e1);
}
}
});
}
final SourceDataLine sourceLine = line;
new Thread(() -> {
int nBytesRead = 0;
final byte[] abData = new byte[1024];
while (nBytesRead != -1) {
try {
nBytesRead = audioInputStream.read(abData, 0, abData.length);
} catch (final IOException e) {
logger.error("Error playing sound clip", e);
}
if (nBytesRead >= 0) {
sourceLine.write(abData, 0, nBytesRead);
}
}
sourceLine.drain();
sourceLine.close();
}).start();
} catch (final LineUnavailableException e) {
if (line != null) line.close();
logger.error("Error playing sound clip", e);
}
}
public static void playSounds(final List<File> soundFiles) {
if (isSilenced) {
soundFiles.forEach(System.out::println);
} else {
final SoundQueue sq = new SoundQueue(soundFiles);
sq.play();
}
}
private static class SoundQueue implements LineListener {
private final List<File> soundFiles;
private int queueIndex = 0;
public SoundQueue(List<File> soundFiles) {
this.soundFiles = soundFiles;
}
public void play() {
playSound(soundFiles.get(queueIndex), Optional.of(this));
}
@Override
public void update(final LineEvent event) {
if (LineEvent.Type.STOP.equals(event.getType())) {
event.getLine().close();
queueIndex++;
if (queueIndex < soundFiles.size()) {
playSound(soundFiles.get(queueIndex), Optional.of(this));
}
}
}
}
/**
* Removes all objects the training exercise has added to the GUI.
*/
public void destroy() {
if (changedRowColor) {
config.setShotTimerRowColor(null);
changedRowColor = false;
}
if (shotTimerTable != null) {
for (final TableColumn<ShotEntry, String> column : exerciseColumns.values()) {
shotTimerTable.getColumns().remove(column);
}
}
for (final Entry<CameraView, Label> entry : exerciseLabels.entrySet()) {
entry.getKey().removeChild(entry.getValue());
}
exerciseLabels.clear();
final Iterator<Pane> itExercisePanes = exercisePanes.iterator();
while (itExercisePanes.hasNext()) {
trainingExerciseContainer.getChildren().remove(itExercisePanes.next());
itExercisePanes.remove();
}
final Iterator<Button> itExerciseButtons = exerciseButtons.iterator();
while (itExerciseButtons.hasNext()) {
buttonsContainer.getChildren().remove(itExerciseButtons.next());
itExerciseButtons.remove();
}
pauseShotDetection(false);
}
protected CamerasSupervisor getCamerasSupervisor() {
return camerasSupervisor;
}
}