-
Notifications
You must be signed in to change notification settings - Fork 70
Tutorial: Creating a Simple Training Exercise
In this tutorial we will make a simple training exercise that walks users through an isometric exercise that strengthens arm and hand muscles that are important for accurately deploying a handgun. The complete version is located in a GitHub repository: https://github.com/phrack/ShootOFF-Pistol-Isometrics. When making your own exercise, you should create a new repository where your exercise's code, resources, and metadata will be managed. Exercises should live in repositories created specifically for each exercise, not in forks of phrack/ShootOFF
.
To prepare our project we'll use a build.gradle
file with the contents depicted below. This file tells gradle that we want to compile to Java 1.8, we want to be able to create an Eclipse project for our exercise, and there is a libs directory that will contain ShootOFF.jar. ShootOFF.jar holds our plugin's dependencies, thus you must manually place it in the libs directory before you can compile or package the exercise. When making your own exercise, you can use any version of ShootOFF.jar you want, but we recommend you use either the version from the current stable release or one built from a clone of phrack/ShootOFF
. You can build a JAR from a clone using gradle fxJar -x test
. This gives your exercise access to any new API methods or bug fixes that are not yet released. Gradle implements configuration by convention, thus source code files must appear in src/main/java
and resources (e.g. sound files) in src/main/resources
.
apply plugin: 'java'
apply plugin: 'eclipse'
eclipse {
jdt {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
}
repositories {
flatDir {
dirs 'libs'
}
}
dependencies {
compile name: 'ShootOFF'
}
The following code is the standard skeleton for any training exercise (although you should extend ProjectorTrainingExerciseBase
if you are planning to make a projector exercise. We place this code in com.shootoff.plugins.PistolIsometrics.java
. The overridden methods are declared in com.shootoff.plugins.TrainingExercise
. The api
field is used to ensure we can still access exercise API methods from internal classes (e.g. if we need to use the API on a thread we create).
package com.shootoff.plugins;
import java.util.List;
import java.util.Optional;
import com.shootoff.camera.Shot;
import com.shootoff.targets.TargetRegion;
import javafx.scene.Group;
public class PistolIsometrics extends TrainingExerciseBase implements TrainingExercise {
private TrainingExerciseBase api;
public PistolIsometrics() {}
public PistolIsometrics(List<Group> targets) {
super(targets);
api = super.getInstance();
}
@Override
public ExerciseMetadata getInfo() {
return null;
}
@Override
public void init() {
}
@Override
public void reset(List<Group> targets) {
}
@Override
public void shotListener(Shot shot, Optional<Hit> hit) {
}
}
First, let's implement getInfo
to collect information about this exercise including its name, version, author, and description. The name is used when adding the exercise to the Training menu in ShootOFF.
@Override
public ExerciseMetadata getInfo() {
return new ExerciseMetadata("Pistol Isometrics", "1.0", "phrack",
"This exercise walks you through hold exercises to strengthen "
+ "arm and hand muscles that help you shoot a pistol accurately. "
+ "You will be asked to shoot, then you must shoot until you hit "
+ "any target. Once you hit a target, hold your exact position for "
+ "60 seconds. The exercise will tell you how much longer there is "
+ "every 30 seconds. After you've held for 60 seconds, you will "
+ "get two minutes to rest before you must fire again.");
}
Next, let's fill in the init
method, which acts as our exercise's main
method. In this case, we want to tell the shooter to get ready, pause for five seconds, then ask the user to fire. We'll use a scheduled thread pool to implement the delays and we'll create constants for the different potential time intervals our exercise will use. We give the thread pool a name so that it can be easily spotted in a profiler or debugger.
One gotcha of using our own thread pool is that we must be sure to clean up after it ourselves. To do this, we must override the destroy
method inherited from TrainingExerciseBase
to be sure we do this clean-up every time the exercise is destroyed (e.g. ShootOFF closes, user turns off exercises, user switches to a different exercise, etc.). However, we must be sure to call destroy in the super class as well (the method we are overriding), otherwise the API will not perform its own clean-up routines.
The repeatExercise
field is used to ensure operations are not mistakenly performed while the exercise is being destroyed or reset. We pause shot detection in between the command to setup and the command to fire. If we did not do this the user could fire at any time, which would call shotListener
and potentially mess up the operation of our exercise.
private static final int START_DELAY = 5; // s
private static final int NOTICE_INTERVAL = 30; // s
private static final int CORE_POOL_SIZE = 4;
private ScheduledExecutorService executorService = Executors.newScheduledThreadPool(CORE_POOL_SIZE,
new NamedThreadFactory("PistolIsometricsExercise"));
private boolean repeatExercise = true;
// ...
@Override
public void init() {
startRound();
}
private void startRound() {
super.pauseShotDetection(true);
// Standard sound file shipped with ShootOFF
playSound(new File("sounds/voice/shootoff-makeready.wav"));
executorService.schedule(new Fire(), START_DELAY, TimeUnit.SECONDS);
}
/*
* Load a sound file from the exercise's JAR file into a
* BufferedInputStream. This specific type of stream is required to play
* audio.
*/
private InputStream getSoundStream(String soundResource) {
return new BufferedInputStream(PistolIsometrics.class.getResourceAsStream(soundResource));
}
private class Fire implements Runnable {
@Override
public void run() {
if (!repeatExercise) return;
api.pauseShotDetection(false);
InputStream fire = getSoundStream("/sounds/fire.wav");
TrainingExerciseBase.playSound(fire);
}
}
...
@Override
public void destroy() {
repeatExercise = false;
executorService.shutdownNow();
super.destroy();
}
Next, we'll implement the shotListener method. Remember from the description above that if the shooter didn't hit a target we want them to keep trying until they do, otherwise we want them to hold. To see if they hit a target we simply need to check it a Hit
is present. In a more advanced exercise we could use the Hit
to see what target the shooter hit, which region they hit on the target, and where precisely on that region was shot. Once the shooter hit a target, we'll ask them to hold and count down 60 seconds before giving her a break. We pause shot detection when a hit is achieved to ensure shotListener
will not be called again while the user is holding (they should not be shooting during this time anyway!).
@Override
public void shotListener(Shot shot, Optional<Hit> hit) {
if (!hit.isPresent()) {
new Fire().run();
} else {
super.pauseShotDetection(true);
InputStream hold = getSoundStream("/sounds/hold60.wav");
TrainingExerciseBase.playSound(hold);
executorService.schedule(new TimeNotice(30, true), NOTICE_INTERVAL, TimeUnit.SECONDS);
}
}
private class TimeNotice implements Runnable {
private int timeLeft;
private boolean justShot;
public TimeNotice(int timeLeft, boolean justShot) {
this.timeLeft = timeLeft;
this.justShot = justShot;
}
@Override
public void run() {
if (!repeatExercise) return;
InputStream notice;
switch (timeLeft) {
case 30:
notice = getSoundStream("/sounds/30remaining.wav");
break;
case 60:
notice = getSoundStream("/sounds/60remaining.wav");
break;
case 90:
notice = getSoundStream("/sounds/90remaining.wav");
break;
default: // happens when timeLeft = 0 in this
if (justShot) {
notice = getSoundStream("/sounds/relax120.wav");
timeLeft = 120;
justShot = false;
} else {
notice = getSoundStream("/sounds/congrats-complete.wav");
executorService.schedule(() -> startRound(), START_DELAY, TimeUnit.SECONDS);
}
}
TrainingExerciseBase.playSound(notice);
executorService.schedule(new TimeNotice(timeLeft - NOTICE_INTERVAL, justShot), NOTICE_INTERVAL,
TimeUnit.SECONDS);
}
}
Finally, we implement the reset method by cleaning up our current thread pool and starting a new one from scratch so that we can start the exercise again as if it was run for the first time. The reset method supplies us with a list of all targets the user is currently using in case we need those targets to initialize a new round for our exercise. This comes in handy because we do not know about targets added after the exercise was first started unless the user resets.
@Override
public void reset(List<Group> targets) {
repeatExercise = false;
executorService.shutdownNow();
repeatExercise = true;
executorService = Executors.newScheduledThreadPool(CORE_POOL_SIZE,
new NamedThreadFactory("PistolIsometricsExercise"));
startRound();
}
Now that our code is complete, we need to create a metadata file that will appear at the root of our exercise's JAR file to tell ShootOFF which class implements the TrainingExercise
interface. Create shootoff.xml
in src/main/resources and set its contents to:
<?xml version="1.0" encoding="UTF-8"?>
<shootoffExercise exerciseClass="com.shootoff.plugins.PistolIsometrics" />
To create the JAR file run gradle jar
. The JAR file will appear in build/lib. Place it in ShootOFF's exercises
directory and start strengthening those muscles!
If you want to make your exercise available to other users in the Exercise Manager, send us a pull request that adds a plugin tag for your exercise to the plugin metadata file:
https://github.com/phrack/ShootOFF/blob/gh-pages/shootoff-plugins.xml