Commits

zonski  committed 4a0ca14

Dropped out 'simple' sub-package

  • Participants
  • Parent commits 13f5f30

Comments (0)

Files changed (62)

File defender/src/main/java/com/fxexperience/games/defender/AnimatedImageNode.java

+/*
+ * Copyright (c) 2012, FX Experience. All rights  reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are
+ * permitted provided that the following conditions are met:
+ *
+ * Redistributions of source code must retain the above copyright notice, this list of
+ * conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * Neither the name of the author nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+ * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.fxexperience.games.defender;
+
+import javafx.animation.KeyFrame;
+import javafx.animation.Timeline;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.geometry.Rectangle2D;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.util.Duration;
+
+/**
+ *
+ * @author team
+ */
+public class AnimatedImageNode extends ImageView {
+
+    ///////////////////////////////////////////////////////////////////////////
+    //variables
+    private final int framesPerSec;
+    private final int frameWidth;
+    private final int frameHeight;
+    private final int frameCount;
+    private final int numColumns;
+    private int currentFrame = 0;
+    private Timeline animator = null;
+    private EventHandler<ActionEvent> animate = new EventHandler<ActionEvent>() {
+        @Override
+        public final void handle(ActionEvent arg0) {
+            currentFrame = currentFrame < (frameCount - 1) ? ++currentFrame : 0;
+            setViewport(getSpriteViewport());
+        }
+    };
+
+    ///////////////////////////////////////////////////////////////////////////
+    //constructors
+    public AnimatedImageNode(Image spriteSheet, int frameWidth, int frameHeight, int frameCount, int framesPerSec) {
+        super(spriteSheet);
+        this.frameWidth = frameWidth;
+        this.frameHeight = frameHeight;
+        this.frameCount = frameCount;
+        this.framesPerSec = framesPerSec;
+        this.numColumns = (int) getImage().getWidth() / frameWidth;
+        makeAnimator();
+        validate();
+        initialize();
+    }
+
+    ///////////////////////////////////////////////////////////////////////////
+    //private functions
+    private void validate() {
+        if (numColumns <= 0) {
+            throw new IllegalArgumentException("Sprite sheet width is smaller than frame width.  Sprite sheet width = " + (int) getImage().getWidth() + ".  Frame width = " + frameWidth);
+        }
+
+        int numRows = (int) getImage().getHeight() / frameHeight;
+        if (numRows <= 0) {
+            throw new IllegalArgumentException("Sprite sheet height is smaller than frame height.  Sprite sheet height = " + (int) getImage().getHeight() + ".  Frame height = " + frameHeight);
+        }
+
+        int numPossibleFrames = numRows * numColumns;
+        if (numPossibleFrames < frameCount) {
+            throw new IllegalArgumentException("Number of possible frames is less than frame count.  Number of possible frames = " + numPossibleFrames + ".  Frame count = " + frameCount);
+        }
+    }
+
+    private void initialize() {
+        currentFrame = 0;
+        setViewport(getSpriteViewport());
+    }
+
+    private Rectangle2D getSpriteViewport() {
+        int spriteRow = currentFrame / numColumns;
+        int spriteCol = currentFrame % numColumns;
+        return new Rectangle2D(spriteCol * frameWidth, spriteRow * frameHeight, frameWidth, frameHeight);
+    }
+
+    private void makeAnimator() {
+        final KeyFrame kf = new KeyFrame(Duration.seconds(1).divide(framesPerSec), animate);
+        animator = new Timeline(kf);
+        animator.setCycleCount(Timeline.INDEFINITE);
+    }
+
+    ///////////////////////////////////////////////////////////////////////////
+    //public functions
+    public void play() {
+        animator.play();
+    }
+
+    public void playFromStart() {
+        animator.playFromStart();
+    }
+
+    public void pause() {
+        animator.pause();
+    }
+
+    public void stop() {
+        animator.stop();
+    }
+}

File defender/src/main/java/com/fxexperience/games/defender/DefenderApp.java

  */
 package com.fxexperience.games.defender;
 
-import com.fxexperience.games.defender.games.simple.Game;
 import javafx.application.Application;
 import javafx.scene.Scene;
 import javafx.stage.Stage;
     public void start(Stage stage) throws Exception {
         Game game = new Game();
         Scene scene = new Scene(game, 1200, 700);
-        scene.getStylesheets().add("/styles/games/simple/styles.css");
+        scene.getStylesheets().add("/styles/styles.css");
         stage.setTitle("JavaFX Tower Defender");
         stage.setScene(scene);
         stage.show();

File defender/src/main/java/com/fxexperience/games/defender/Difficulty.java

+/*
+ * Copyright (c) 2012, FX Experience. All rights  reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are
+ * permitted provided that the following conditions are met:
+ *
+ * Redistributions of source code must retain the above copyright notice, this list of
+ * conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * Neither the name of the author nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+ * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.fxexperience.games.defender;
+
+/**
+ * Enumerates the different difficulty levels for the game. This may be used to
+ * determine enemy speed, hit points, numbers of waves, etc.
+ */
+public enum Difficulty {
+    EASY,
+    MEDIUM,
+    HARD,
+    INSANE,
+    APOCALYPTIC
+}

File defender/src/main/java/com/fxexperience/games/defender/Enemy.java

+/*
+ * Copyright (c) 2012, FX Experience. All rights  reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are
+ * permitted provided that the following conditions are met:
+ *
+ * Redistributions of source code must retain the above copyright notice, this list of
+ * conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * Neither the name of the author nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+ * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.fxexperience.games.defender;
+
+import javafx.animation.Interpolator;
+import javafx.animation.PathTransition;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.ReadOnlyDoubleProperty;
+import javafx.beans.property.ReadOnlyDoubleWrapper;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.geometry.Bounds;
+import javafx.geometry.Point2D;
+import javafx.scene.Parent;
+import javafx.scene.shape.Path;
+import javafx.util.Duration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An enemy. Has life, speed, and some path that it is to follow. It might have
+ * different buffs depending on what type of weapon hits it, proximity to other
+ * enemies (they might make each other stronger) or whatever else you might come
+ * up with.
+ */
+public class Enemy extends Parent implements GamePulseListener {
+
+    private static final Logger log = LoggerFactory.getLogger(Enemy.class);
+    /**
+     * The maximum life of this enemy.
+     */
+    private final double maximumLife;
+
+    public final double getMaximumLife() {
+        return maximumLife;
+    }
+    /**
+     * The Path that the enemy must follow. If the enemy reaches the end of the
+     * Path without being destroyed then the player loses a life and the enemy
+     * destroys itself.
+     */
+    private final ObjectProperty<Path> path = new SimpleObjectProperty<>(this, "path");
+    private PathTransition pathTransition;
+    private long startTime = 0;
+
+    public final Path getPath() {
+        return path.get();
+    }
+
+    public final void setPath(Path path, double distance) {
+        this.path.set(path);
+        pathTransition = new PathTransition(Duration.seconds(distance / speed.getValue()), path, this);
+        pathTransition.setInterpolator(Interpolator.LINEAR);
+        pathTransition.setOrientation(PathTransition.OrientationType.ORTHOGONAL_TO_TANGENT);
+    }
+
+    public final ObjectProperty<Path> pathProperty() {
+        return path;
+    }
+    /**
+     * The Path that the enemy must follow. If the enemy reaches the end of the
+     * Path without being destroyed then the player loses a life and the enemy
+     * destroys itself.
+     */
+    private final ObjectProperty<Level> level = new SimpleObjectProperty<>(this, "level");
+    public final Level getLevel() { return level.get(); }
+    public final void setLevel(Level level) { this.level.set(level); }
+    public final ObjectProperty<Level> levelProperty() { return level; }
+
+    /**
+     * When life reaches 0 (or goes negative), the enemy is destroyed.
+     */
+    private final ReadOnlyDoubleWrapper life = new ReadOnlyDoubleWrapper(this, "life", 100);
+
+    public final double getLife() {
+        return life.get();
+    }
+
+    public final ReadOnlyDoubleProperty lifeProperty() {
+        return life.getReadOnlyProperty();
+    }
+    /**
+     * The natural speed at which the enemy moves. This is the speed at which
+     * the enemy will move in the absence of any buffs.
+     */
+    private final double naturalSpeed;
+
+    public final double getNaturalSpeed() {
+        return naturalSpeed;
+    }
+    /**
+     * The speed at which the enemy is moving. This can go up or down depending
+     * on different game dynamics (for example, a tower with some "slow down"
+     * buff could cause the speed to decrease for some period of time).
+     */
+    private final ReadOnlyDoubleWrapper speed = new ReadOnlyDoubleWrapper(this, "speed");
+
+    public final double getSpeed() {
+        return speed.get();
+    }
+
+    public final ReadOnlyDoubleProperty speedProperty() {
+        return speed.getReadOnlyProperty();
+    }
+    /**
+     * Called when the enemy dies.
+     */
+    private ObjectProperty<EventHandler<ActionEvent>> onDeath = new SimpleObjectProperty<>(this, "onDeath");
+
+    public final EventHandler<ActionEvent> getOnDeath() {
+        return onDeath.get();
+    }
+
+    public final void setOnDeath(EventHandler<ActionEvent> value) {
+        onDeath.set(value);
+    }
+
+    public final ObjectProperty<EventHandler<ActionEvent>> onDeathProperty() {
+        return onDeath;
+    }
+
+    /**
+     * Creates a new Enemy. You must specify the natural speed of the enemy and
+     * its max life.
+     *
+     * @param maximumLife must be positive
+     * @param naturalSpeed must be positive. Measured in pixels per second.
+     */
+    public Enemy(double maximumLife, double naturalSpeed) {
+        this.maximumLife = maximumLife;
+        this.naturalSpeed = naturalSpeed;
+        this.speed.set(naturalSpeed);
+    }
+
+    public void damage(double amount) {
+        double l = getLife();
+        life.set(Math.max(0, l - amount));
+        if (getLife() == 0 && onDeath.get() != null) {
+            onDeath.get().handle(new ActionEvent(this, this));
+        }
+    }
+
+    public void heal(double amount) {
+        double l = getLife();
+        life.set(Math.min(maximumLife, l + amount));
+    }
+
+    // ... etc
+    @Override
+    public void pulse() {
+        if (this.startTime == 0) {
+            startTime = System.currentTimeMillis();
+            pathTransition.playFromStart();
+            pathTransition.pause();
+        }
+
+        double fraction = (System.currentTimeMillis() - startTime) / pathTransition.getDuration().toMillis();
+        pathTransition.interpolate(fraction);
+        if (fraction >= 1.0) {
+            destinationReached();
+        }
+    }
+
+    public Point2D getCenter() {
+        Bounds bounds = getBoundsInParent();
+        return new Point2D(
+                bounds.getMinX() + (bounds.getWidth() / 2.0),
+                bounds.getMinY() + (bounds.getHeight() / 2.0));
+    }
+
+    protected void destinationReached() {
+        // done
+        log.info("Enemy made it to the destination");
+        // todo handle this better
+        getLevel().removeEnemy(this);
+    }
+}

File defender/src/main/java/com/fxexperience/games/defender/Game.java

+/*
+ * Copyright (c) 2012, FX Experience. All rights  reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are
+ * permitted provided that the following conditions are met:
+ *
+ * Redistributions of source code must retain the above copyright notice, this list of
+ * conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * Neither the name of the author nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+ * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.fxexperience.games.defender;
+
+import com.fxexperience.games.defender.ui.GameControlPanel;
+import com.fxexperience.games.defender.ui.NewGamePanel;
+import com.fxexperience.games.defender.ui.WelcomePanel;
+import javafx.animation.AnimationTimer;
+import javafx.beans.property.*;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.StackPane;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class Game extends StackPane {
+    public final BooleanProperty paused = new SimpleBooleanProperty(this, "paused", true);
+    public final DoubleProperty money = new SimpleDoubleProperty(this, "money", 100);
+    public final IntegerProperty lives = new SimpleIntegerProperty(this, "lives", 10);
+
+    private final ObjectProperty<Level> currentLevel = new SimpleObjectProperty<>(this, "currentLevel");
+
+    public ObjectProperty<Level> currentLevelProperty() { return currentLevel; }
+    public Level getCurrentLevel() { return this.currentLevel.get(); }
+    public void setCurrentLevel(Level level) { this.currentLevel.set(level); }
+
+    private AnimationTimer animationTimer;
+    private List<GamePulseListener> gamePulseListeners;
+
+    private BorderPane gamePanel;
+    private final WelcomePanel welcomePanel;
+    private final NewGamePanel newGamePanel;
+
+    public Game() {
+
+        gamePanel = new BorderPane();
+        gamePanel.setRight(new GameControlPanel(this));
+
+        welcomePanel = new WelcomePanel(this);
+        newGamePanel = new com.fxexperience.games.defender.ui.NewGamePanel(this);
+
+        gamePulseListeners = new ArrayList<>();
+
+        animationTimer = new AnimationTimer() {
+            @Override
+            public void handle(long currentTime) {
+                pulse();
+            }
+        };
+
+        currentLevel.addListener(new ChangeListener<Level>() {
+            @Override
+            public void changed(ObservableValue<? extends Level> source, Level oldLevel, Level newLevel) {
+                if (oldLevel != null) {
+                    gamePulseListeners.remove(oldLevel);
+                    gamePanel.setCenter(null);
+                }
+                if (newLevel != null) {
+                    gamePulseListeners.add(newLevel);
+                    gamePanel.setCenter(newLevel);
+                }
+            }
+        });
+
+        paused.addListener(new ChangeListener<Boolean>() {
+            @Override
+            public void changed(ObservableValue<? extends Boolean> source, Boolean wasPaused, Boolean isPaused) {
+                if (isPaused) {
+                    animationTimer.stop();
+                } else {
+                    animationTimer.start();
+                }
+            }
+        });
+    }
+
+    public void showWelcomePanel() {
+        welcomePanel.reset();
+        getChildren().setAll(gamePanel, welcomePanel);
+        welcomePanel.playWelcome();
+    }
+
+    public void showNewGamePanel() {
+        paused.set(true);
+        newGamePanel.reset();
+        getChildren().setAll(gamePanel, newGamePanel);
+        newGamePanel.playEntryAnimation();
+    }
+
+    public void addGamePulseListener(GamePulseListener listener) {
+        gamePulseListeners.add(listener);
+    }
+
+    public void removeGamePulseListener(GamePulseListener listener) {
+        gamePulseListeners.remove(listener);
+    }
+
+    protected void pulse() {
+        for (GamePulseListener listener : gamePulseListeners) {
+            listener.pulse();
+        }
+    }
+
+}

File defender/src/main/java/com/fxexperience/games/defender/GamePulseListener.java

+/*
+* Copyright (c) 2012, FX Experience. All rights  reserved.
+*
+* Redistribution and use in source and binary forms, with or without modification, are
+* permitted provided that the following conditions are met:
+*
+* Redistributions of source code must retain the above copyright notice, this list of
+* conditions and the following disclaimer. 
+*
+* Redistributions in binary form must reproduce the above copyright notice, this list of 
+* conditions and the following disclaimer in the documentation and/or other materials provided 
+* with the distribution.    
+*
+* Neither the name of the author nor the names of its contributors may be used to endorse or
+* promote products derived from this software without specific prior written permission.
+*
+* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+* COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+* TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+package com.fxexperience.games.defender;
+
+public interface GamePulseListener {
+
+    void pulse();
+}

File defender/src/main/java/com/fxexperience/games/defender/Level.java

+/*
+ * Copyright (c) 2012, FX Experience. All rights  reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are
+ * permitted provided that the following conditions are met:
+ *
+ * Redistributions of source code must retain the above copyright notice, this list of
+ * conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * Neither the name of the author nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+ * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.fxexperience.games.defender;
+
+import javafx.geometry.Bounds;
+import javafx.geometry.Insets;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.layout.Pane;
+import javafx.scene.layout.Region;
+import javafx.scene.shape.Path;
+import javafx.scene.shape.Rectangle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * This UI represents the game board. It has all the tiles, towers, enemies,
+ * background, and so forth. The GameControlPanelUI is separate, and has the
+ * money values etc. A single Level has a single path (or set of paths) on which
+ * the enemies roam. Multiple waves of enemies will come following one or more
+ * of those paths.
+ */
+public class Level extends Region implements GamePulseListener {
+
+    private static final Logger log = LoggerFactory.getLogger(Level.class);
+
+    private static final double LEVEL_HEIGHT = 600;
+    private static final double LEVEL_WIDTH = 800;
+    private Node background;
+    private Rectangle clip;
+    private Pane enemyLayer;
+    private Pane towerLayer;
+    private Pane projectileLayer;
+    private LinkedList<Wave> waves;
+    private Wave wave;
+    private Path path;
+    private List<Enemy> enemies;
+    private List<Tower> towers;
+    private List<Projectile> projectiles;
+
+    public Level(final Path path, Node background, Wave... waves) {
+        getStyleClass().add("level");
+        this.path = path;
+        this.background = background;
+        this.waves = new LinkedList<>(Arrays.asList(waves));
+
+        enemies = new ArrayList<>();
+        enemyLayer = new Pane();
+        towers = new ArrayList<>();
+        projectiles = new ArrayList<>();
+        towerLayer = new Pane();
+        projectileLayer = new Pane();
+        getChildren().addAll(background, enemyLayer, towerLayer, projectileLayer);
+
+        clip = new Rectangle();
+        setClip(clip);
+    }
+
+    @Override
+    public void pulse() {
+
+        // process waves and spawning
+        if (wave != null && wave.hasMoreEnemies()) {
+            wave.pulse();
+        } else {
+            // todo need more smarts in here around detecting end of wave, level, etc
+            // probably would like to pause at the end of a wave and show a 'success' ui, then next wave only
+            // starts once the player says go
+            if (!waves.isEmpty()) {
+                wave = waves.remove();
+                wave.setLevel(this);
+            } else {
+                // todo spawning finished - when the remaining enemies are done we have finished the level...
+                log.info("No more enemies to spawn");
+                //
+                //        // The level is complete when the spawner has finished and all enemies have been destroyed
+                //        enemyLayer.getChildren().addListener(new ListChangeListener<Node>() {
+                //            @Override
+                //            public void onChanged(Change<? extends Node> change) {
+                //                if (enemyLayer.getChildren().isEmpty() && !complte) {
+                //                    System.out.println("WAVE COMPLETE");
+                //                    if (waves.isEmpty()) {
+                //                        System.out.println("LEVEL COMPLETE");
+                //                    } else {
+                //                        startWave();
+                //                    }
+                //                }
+                //            }
+                //        });
+            }
+        }
+
+        // pulse our sprites (cache to list to avoid concurrent modification problems - maybe could be done smarter)
+        List<GamePulseListener> pulsers = new ArrayList<>();
+        pulsers.addAll(enemies);
+        pulsers.addAll(towers);
+        pulsers.addAll(projectiles);
+        for (GamePulseListener pulser : pulsers) {
+            pulser.pulse();
+        }
+    }
+
+    public List<Enemy> findEnemiesInArea(Point2D center, int radius) {
+        List<Enemy> matches = new ArrayList<>();
+        for (Enemy enemy : enemies) {
+            Bounds bounds = enemy.getBoundsInParent();
+            if (center.getX() - radius <= bounds.getMaxX() && center.getX() + radius >= bounds.getMinX()
+                    && center.getY() - radius <= bounds.getMaxY() && center.getY() + radius >= bounds.getMinY()) {
+                matches.add(enemy);
+            }
+        }
+        return matches;
+    }
+
+    public void addEnemy(Enemy enemy) {
+        enemy.setLevel(this);
+        enemies.add(enemy);
+        enemyLayer.getChildren().add(enemy);
+    }
+
+    public void removeEnemy(Enemy enemy) {
+        enemy.setLevel(null);
+        enemies.remove(enemy);
+        enemyLayer.getChildren().remove(enemy);
+    }
+
+    public void addTower(Tower tower) {
+        tower.setLevel(this);
+        towers.add(tower);
+        towerLayer.getChildren().add(tower);
+    }
+
+    public void removeTower(Tower tower) {
+        tower.setLevel(null);
+        towers.remove(tower);
+        towerLayer.getChildren().remove(tower);
+    }
+
+    public void addProjectile(Projectile projectile) {
+        projectile.setLevel(this);
+        projectiles.add(projectile);
+        projectileLayer.getChildren().add(projectile);
+    }
+
+    public void removeProjectile(Projectile projectile) {
+        projectile.setLevel(null);
+        projectiles.remove(projectile);
+        projectileLayer.getChildren().remove(projectile);
+    }
+
+    protected final void setPath(Path path) {
+        this.path = path;
+    }
+
+    protected final void setWaves(Wave... waves) {
+        this.waves = new LinkedList<>(Arrays.asList(waves));
+    }
+
+    @Override
+    protected double computeMinWidth(double v) {
+        return computePrefWidth(v);
+    }
+
+    @Override
+    protected double computeMinHeight(double v) {
+        return computePrefHeight(v);
+    }
+
+    @Override
+    protected double computePrefWidth(double v) {
+        final Insets insets = getInsets();
+        return LEVEL_WIDTH + insets.getLeft() + insets.getRight();
+    }
+
+    @Override
+    protected double computePrefHeight(double v) {
+        final Insets insets = getInsets();
+        return LEVEL_HEIGHT + insets.getTop() + insets.getBottom();
+    }
+
+    @Override
+    protected double computeMaxWidth(double v) {
+        return computePrefWidth(v);
+    }
+
+    @Override
+    protected double computeMaxHeight(double v) {
+        return computePrefHeight(v);
+    }
+
+    @Override
+    protected void layoutChildren() {
+        final Insets insets = getInsets();
+        final double x = insets.getLeft();
+        final double y = insets.getTop();
+        final double width = getWidth() - insets.getLeft() - insets.getRight();
+        final double height = getHeight() - insets.getTop() - insets.getBottom();
+
+        background.resizeRelocate(x, y, width, height);
+        enemyLayer.resizeRelocate(x, y, width, height);
+        towerLayer.resizeRelocate(x, y, width, height);
+        projectileLayer.resizeRelocate(x, y, width, height);
+        clip.setX(x);
+        clip.setY(y);
+        clip.setWidth(width);
+        clip.setHeight(height);
+
+        for (Node n : getChildren()) {
+            if (n != background) {
+                n.autosize();
+            }
+        }
+    }
+}

File defender/src/main/java/com/fxexperience/games/defender/LevelFactory.java

+/*
+* Copyright (c) 2012, FX Experience. All rights  reserved.
+*
+* Redistribution and use in source and binary forms, with or without modification, are
+* permitted provided that the following conditions are met:
+*
+* Redistributions of source code must retain the above copyright notice, this list of
+* conditions and the following disclaimer. 
+*
+* Redistributions in binary form must reproduce the above copyright notice, this list of 
+* conditions and the following disclaimer in the documentation and/or other materials provided 
+* with the distribution.    
+*
+* Neither the name of the author nor the names of its contributors may be used to endorse or
+* promote products derived from this software without specific prior written permission.
+*
+* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+* COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+* TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+package com.fxexperience.games.defender;
+
+public interface LevelFactory {
+
+    String getName();
+
+    Level createLevel();
+}

File defender/src/main/java/com/fxexperience/games/defender/Projectile.java

+/*
+ * Copyright (c) 2012, FX Experience. All rights  reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are
+ * permitted provided that the following conditions are met:
+ *
+ * Redistributions of source code must retain the above copyright notice, this list of
+ * conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * Neither the name of the author nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+ * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.fxexperience.games.defender;
+
+import javafx.geometry.Bounds;
+import javafx.geometry.Point2D;
+import javafx.scene.Parent;
+
+public class Projectile extends Parent implements GamePulseListener {
+
+    protected Level level;
+
+    public void setLevel(Level level) {
+        this.level = level;
+    }
+
+    @Override
+    public void pulse() {
+
+    }
+
+    public Point2D getCenter() {
+        Bounds bounds = getBoundsInParent();
+        return new Point2D(
+                bounds.getMinX() + (bounds.getWidth()/2.0),
+                bounds.getMinY() + (bounds.getHeight()/2.0));
+    }
+}

File defender/src/main/java/com/fxexperience/games/defender/Tower.java

+/*
+ * Copyright (c) 2012, FX Experience. All rights  reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are
+ * permitted provided that the following conditions are met:
+ *
+ * Redistributions of source code must retain the above copyright notice, this list of
+ * conditions and the following disclaimer.
+ *
+ * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * Neither the name of the author nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+ * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.fxexperience.games.defender;
+
+import javafx.geometry.Bounds;
+import javafx.geometry.Point2D;
+import javafx.scene.Parent;
+
+/**
+ */
+public class Tower extends Parent implements GamePulseListener {
+
+    protected String name;
+    protected Level level;
+
+    public Tower(String name) {
+        this.name = name;
+    }
+
+    // Want to create an event that happens when the tower bullet hits an enemy, such that
+    // different types of towers can do different things depending on what kind of enemy
+    // is hit. For example, if the blue tower hits a blue enemy, it has 2x damage, but
+    // if the tower hits a green enemy, it has .5x damage.
+
+    public void setLevel(Level level) {
+        this.level = level;
+    }
+
+    @Override
+    public void pulse() {
+    }
+
+    public Point2D getCenter() {
+        Bounds bounds = getBoundsInParent();
+        return new Point2D(
+            bounds.getMinX() + (bounds.getWidth()/2.0),
+            bounds.getMinY() + (bounds.getHeight()/2.0));
+    }
+}

File defender/src/main/java/com/fxexperience/games/defender/TowerFactory.java

+/*
+* Copyright (c) 2012, FX Experience. All rights  reserved.
+*
+* Redistribution and use in source and binary forms, with or without modification, are
+* permitted provided that the following conditions are met:
+*
+* Redistributions of source code must retain the above copyright notice, this list of
+* conditions and the following disclaimer. 
+*
+* Redistributions in binary form must reproduce the above copyright notice, this list of 
+* conditions and the following disclaimer in the documentation and/or other materials provided 
+* with the distribution.    
+*
+* Neither the name of the author nor the names of its contributors may be used to endorse or
+* promote products derived from this software without specific prior written permission.
+*
+* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+* COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+* TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+package com.fxexperience.games.defender;
+
+public interface TowerFactory {
+
+    String getTowerName();
+
+    Tower createTower();
+}

File defender/src/main/java/com/fxexperience/games/defender/Wave.java

+package com.fxexperience.games.defender;
+
+import javafx.scene.shape.Path;
+import javafx.util.Duration;
+
+import java.util.Arrays;
+import java.util.LinkedList;
+
+public class Wave implements GamePulseListener {
+
+    private final LinkedList<Enemy> enemies = new LinkedList<>();
+    private Path path;
+    private final Duration delay;
+    private Level level;
+    private long lastSpawn;
+    private double pathDistance;
+
+    public Wave(Path path, Duration delay, double pathDistance, Enemy... enemies) {
+        this.path = path;
+        this.delay = delay;
+        this.pathDistance = pathDistance;
+        this.enemies.addAll(Arrays.asList(enemies));
+        for (Enemy e : enemies) {
+            e.setPath(path, pathDistance);
+        }
+    }
+
+    public void setLevel(Level level) {
+        this.level = level;
+    }
+
+    @Override
+    public void pulse() {
+        if (level != null) {
+            long now = System.currentTimeMillis();
+            if (now - lastSpawn > delay.toMillis()) {
+                lastSpawn = now;
+                spawn();
+            }
+        }
+    }
+
+    public boolean hasMoreEnemies() {
+        return !enemies.isEmpty();
+    }
+
+    protected void spawn() {
+        if (!enemies.isEmpty()) {
+            Enemy enemy = enemies.remove();
+            level.addEnemy(enemy);
+        }
+    }
+}

File defender/src/main/java/com/fxexperience/games/defender/animation/MorphTransition.java

+package com.fxexperience.games.defender.animation;
+
+import javafx.animation.Transition;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.ObjectPropertyBase;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.shape.*;
+import javafx.util.Duration;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Vector;
+
+/**
+* Morphs a path from one set of path elements to another. The consequence
+* of using this class is that the PathElements of the target Path node will
+* be replaced. If the PathElements of the target node are changed between
+* animations, then the resulting animation may not look right.
+*/
+public class MorphTransition extends Transition {
+    /**
+    * The target node of this {@code MorphTransition}.
+    * <p>
+    * It is not possible to change the target {@code node} of a running
+    * {@code MorphTransition}. If the value of {@code node} is changed for a
+    * running {@code MorphTransition}, the animation has to be stopped and
+    * started again to pick up the new value.
+    */
+    private ObjectProperty<Node> node;
+    private static final Node DEFAULT_NODE = null;
+
+    public final void setNode(Node value) {
+        if ((node != null) || (value != null /* DEFAULT_NODE */)) {
+            nodeProperty().set(value);
+        }
+    }
+
+    public final Node getNode() {
+        return (node == null)? DEFAULT_NODE : node.get();
+    }
+
+    public final ObjectProperty<Node> nodeProperty() {
+        if (node == null) {
+            node = new SimpleObjectProperty<>(this, "node", DEFAULT_NODE);
+        }
+        return node;
+    }
+
+    /**
+    * The duration of this {@code MorphTransition}.
+    * <p>
+    * It is not possible to change the {@code duration} of a running
+    * {@code MorphTransition}. If the value of {@code duration} is changed for
+    * a running {@code MorphTransition}, the animation has to be stopped and
+    * started again to pick up the new value.
+    * <p>
+    * Note: While the unit of {@code duration} is a millisecond, the
+    * granularity depends on the underlying operating system and will in
+    * general be larger. For example animations on desktop systems usually run
+    * with a maximum of 60fps which gives a granularity of ~17 ms.
+    *
+    * @defaultValue 400ms
+    */
+    private ObjectProperty<Duration> duration;
+    private static final Duration DEFAULT_DURATION = Duration.millis(400);
+
+    public final void setDuration(Duration value) {
+        if ((duration != null) || (!DEFAULT_DURATION.equals(value))) {
+            durationProperty().set(value);
+        }
+    }
+
+    public final Duration getDuration() {
+        return (duration == null)? DEFAULT_DURATION : duration.get();
+    }
+
+    public final ObjectProperty<Duration> durationProperty() {
+        if (duration == null) {
+            duration = new ObjectPropertyBase<Duration>(DEFAULT_DURATION) {
+
+                @Override
+                public void invalidated() {
+                    setCycleDuration(getDuration());
+                }
+
+                @Override
+                public Object getBean() {
+                    return MorphTransition.this;
+                }
+
+                @Override
+                public String getName() {
+                    return "duration";
+                }
+            };
+        }
+        return duration;
+    }
+
+    private final ObservableList<PathElement> fromElements = FXCollections.observableArrayList();
+    public final ObservableList<PathElement> getFromElements() { return fromElements; }
+
+    private final ObservableList<PathElement> toElements = FXCollections.observableArrayList();
+    public final ObservableList<PathElement> getToElements() { return toElements; }
+
+    private Path cachedNode;
+    private List<PathElement> elements;
+    private Geometry geom0;
+    private Geometry geom1;
+
+    /**
+    * The constructor of {@code MorphTransition}
+    *
+    * @param duration
+    *            The duration of the {@code MorphTransition}
+    * @param node
+    *            The {@code node} which will be rotated
+    */
+    public MorphTransition(Duration duration, Node node) {
+        setDuration(duration);
+        setNode(node);
+        setCycleDuration(duration);
+    }
+
+    /**
+    * The constructor of {@code MorphTransition}
+    *
+    * @param duration
+    *            The duration of the {@code MorphTransition}
+    */
+    public MorphTransition(Duration duration) {
+        this(duration, null);
+    }
+
+    /**
+    * The constructor of {@code MorphTransition}
+    *
+    */
+    public MorphTransition() {
+        this(DEFAULT_DURATION, null);
+    }
+
+    /**
+    * {@inheritDoc}
+    */
+    @Override
+    protected void interpolate(double t) {
+        // Now morph! All of my PathElements now are setup, and geom1 number of
+        // elements is a perfect match, so I just have to interpolate.
+        MoveTo moveTo = (MoveTo) elements.get(0);
+        moveTo.setX(interp(geom0.getCoord(0), geom1.getCoord(0), t));
+        moveTo.setY(interp(geom0.getCoord(1), geom1.getCoord(1), t));
+        int index = 2;
+        for (int i=1; i<elements.size()-1; i++) {
+            CubicCurveTo to = (CubicCurveTo) elements.get(i);
+            to.setControlX1(interp(geom0.getCoord(index), geom1.getCoord(index), t));
+            to.setControlY1(interp(geom0.getCoord(index + 1), geom1.getCoord(index + 1), t));
+            to.setControlX2(interp(geom0.getCoord(index + 2), geom1.getCoord(index + 2), t));
+            to.setControlY2(interp(geom0.getCoord(index + 3), geom1.getCoord(index + 3), t));
+            to.setX(interp(geom0.getCoord(index + 4), geom1.getCoord(index + 4), t));
+            to.setY(interp(geom0.getCoord(index + 5), geom1.getCoord(index + 5), t));
+            index += 6;
+        }
+    }
+
+    private Node getTargetNode() {
+        final Node n = getNode();
+        return (n != null) ? n : getParentTargetNode();
+    }
+
+    @Override
+    public void play() {
+        // Validate we can play
+        if (getTargetNode() == null) {
+            throw new IllegalStateException("Must specify the target node");
+        }
+        cachedNode = (Path) getTargetNode();
+
+        // Copy state
+        if (getFromElements().isEmpty()) {
+            getFromElements().setAll(cachedNode.getElements());
+        }
+        if (getToElements().isEmpty()) {
+            getToElements().setAll(cachedNode.getElements());
+        }
+
+        geom0 = new Geometry(getFromElements());
+        geom1 = new Geometry(getToElements());
+        double tvals0[] = geom0.getTvals();
+        double tvals1[] = geom1.getTvals();
+        double masterTvals[] = mergeTvals(tvals0, tvals1);
+        geom0.setTvals(masterTvals);
+        geom1.setTvals(masterTvals);
+
+        // Now set up my path elements. Note that all the path elements
+        // are of type CubicCurveTo, except for the first MoveTo and last ClosePath.
+        elements = new ArrayList<>(geom0.getNumCoords());
+        elements.add(new MoveTo(geom0.getCoord(0), geom0.getCoord(1)));
+        int index = 2;
+        while (index < geom0.getNumCoords()) {
+            elements.add(new CubicCurveTo(
+                    geom0.getCoord(index),
+                    geom0.getCoord(index+1),
+                    geom0.getCoord(index+2),
+                    geom0.getCoord(index+3),
+                    geom0.getCoord(index+4),
+                    geom0.getCoord(index+5)
+            ));
+            index += 6;
+        }
+        elements.add(new ClosePath());
+        cachedNode.getElements().setAll(elements);
+
+        super.play();
+    }
+
+    private static class Geometry {
+        static final double THIRD = (1.0 / 3.0);
+        static final double MIN_LEN = 0.001;
+        double bezierCoords[];
+        int numCoords;
+        double myTvals[];
+
+        public Geometry(ObservableList<PathElement> elements) {
+            // Multiple of 6 plus 2 more for initial move to
+            bezierCoords = new double[20];
+            if (elements.isEmpty()) {
+                // We will have 1 segment and it will be all zeros
+                // It will have 8 coordinates (2 for move to, 6 for cubic)
+                numCoords = 8;
+            }
+
+            final ListIterator<PathElement> pi = elements.listIterator();
+            PathElement e = pi.next();
+            if (!(e instanceof MoveTo)) {
+                // TODO or assume an implicit MoveTo of some kind? What does Path do?
+                throw new IllegalStateException("missing initial MoveTo");
+            }
+            double currentX, currentY, moveX, moveY;
+            MoveTo m = (MoveTo) e;
+            bezierCoords[0] = currentX = moveX = m.getX();
+            bezierCoords[1] = currentY = moveY = m.getY();
+            double newX, newY;
+            Vector<Point2D> savedPathEndPoints = new Vector<>();
+            numCoords = 2;
+            while (pi.hasNext()) {
+                e = pi.next();
+                if (e instanceof MoveTo) {
+                    if (currentX != moveX || currentY != moveY) {
+                        appendLineTo(currentX, currentY, moveX, moveY);
+                        currentX = moveX;
+                        currentY = moveY;
+                    }
+                    m = (MoveTo) e;
+                    newX = m.getX();
+                    newY = m.getY();
+                    if (currentX != newX || currentY != newY) {
+                        savedPathEndPoints.add(new Point2D(moveX, moveY));
+                        appendLineTo(currentX, currentY, newX, newY);
+                        currentX = moveX = newX;
+                        currentY = moveY = newY;
+                    }
+                } else if (e instanceof ClosePath) {
+                    if (currentX != moveX || currentY != moveY) {
+                        appendLineTo(currentX, currentY, moveX, moveY);
+                        currentX = moveX;
+                        currentY = moveY;
+                    }
+                } else if (e instanceof LineTo) {
+                    LineTo to = (LineTo) e;
+                    newX = to.getX();
+                    newY = to.getY();
+                    appendLineTo(currentX, currentY, newX, newY);
+                    currentX = newX;
+                    currentY = newY;
+                } else if (e instanceof QuadCurveTo) {
+                    QuadCurveTo to = (QuadCurveTo) e;
+                    double ctrlX = to.getControlX();
+                    double ctrlY = to.getControlY();
+                    newX = to.getX();
+                    newY = to.getY();
+                    appendQuadTo(currentX, currentY, ctrlX, ctrlY, newX, newY);
+                    currentX = newX;
+                    currentY = newY;
+                } else if (e instanceof CubicCurveTo) {
+                    CubicCurveTo to = (CubicCurveTo) e;
+                    appendCubicTo(to.getControlX1(), to.getControlY1(),
+                            to.getControlX2(), to.getControlY2(),
+                            to.getX(), to.getY());
+                }
+            }
+            // Add closing segment if either:
+            // - we only have initial moveto - expand it to an empty cubic
+            // - or we are not back to the starting point
+            if ((numCoords < 8) || currentX != moveX || currentY != moveY) {
+                appendLineTo(currentX, currentY, moveX, moveY);
+                currentX = moveX;
+                currentY = moveY;
+            }
+            // Now retrace our way back through all of the connecting
+            // inter-sub path segments
+            for (int i = savedPathEndPoints.size()-1; i >= 0; i--) {
+                Point2D p = savedPathEndPoints.get(i);
+                newX = p.getX();
+                newY = p.getY();
+                if (currentX != newX || currentY != newY) {
+                    appendLineTo(currentX, currentY, newX, newY);
+                    currentX = newX;
+                    currentY = newY;
+                }
+            }
+            // Now find the segment endpoint with the smallest Y coordinate
+            int minPt = 0;
+            double minX = bezierCoords[0];
+            double minY = bezierCoords[1];
+            for (int ci = 6; ci < numCoords; ci += 6) {
+                double x = bezierCoords[ci];
+                double y = bezierCoords[ci + 1];
+                if (y < minY || (y == minY && x < minX)) {
+                    minPt = ci;
+                    minX = x;
+                    minY = y;
+                }
+            }
+            // If the smallest Y coordinate is not the first coordinate,
+            // rotate the points so that it is...
+            if (minPt > 0) {
+                // Keep in mind that first 2 coords == last 2 coords
+                double newCoords[] = new double[numCoords];
+                // Copy all coordinates from minPt to the end of the
+                // array to the beginning of the new array
+                System.arraycopy(bezierCoords, minPt,
+                                newCoords, 0,
+                                numCoords - minPt);
+                // Now we do not want to copy 0,1 as they are duplicates
+                // of the last 2 coordinates which we just copied.  So
+                // we start the fromElements copy at index 2, but we still
+                // copy a full minPt coordinates which copies the two
+                // coordinates that were at minPt to the last two elements
+                // of the array, thus ensuring that thew new array starts
+                // and ends with the same pair of coordinates...
+                System.arraycopy(bezierCoords, 2,
+                                newCoords, numCoords - minPt,
+                                minPt);
+                bezierCoords = newCoords;
+            }
+            /* Clockwise enforcement:
+            * - This technique is based on the formula for calculating
+            *  the area of a Polygon.  The standard formula is:
+            *  Area(Poly) = 1/2 * sum(x[i]*y[i+1] - x[i+1]y[i])
+            * - The returned area is negative if the polygon is
+            *  "mostly clockwise" and positive if the polygon is
+            *  "mostly counter-clockwise".
+            * - One failure mode of the Area calculation is if the
+            *  Polygon is self-intersecting.  This is due to the
+            *  fact that the areas on each side of the self-intersection
+            *  are bounded by segments which have opposite winding
+            *  direction.  Thus, those areas will have opposite signs
+            *  on the acccumulation of their area summations and end
+            *  up canceling each other out partially.
+            * - This failure mode of the algorithm in determining the
+            *  exact magnitude of the area is not actually a big problem
+            *  for our needs here since we are only using the sign of
+            *  the resulting area to figure out the overall winding
+            *  direction of the path.  If self-intersections cause
+            *  different parts of the path to disagree as to the
+            *  local winding direction, that is no matter as we just
+            *  wait for the final answer to tell us which winding
+            *  direction had greater representation.  If the final
+            *  result is zero then the path was equal parts clockwise
+            *  and counter-clockwise and we do not care about which
+            *  way we order it as either way will require half of the
+            *  path to unwind and re-wind itself.
+            */
+            double area = 0;
+            // Note that first and last points are the same so we
+            // do not need to process coords[0,1] against coords[n-2,n-1]
+            currentX = bezierCoords[0];
+            currentY = bezierCoords[1];
+            for (int i = 2; i < numCoords; i += 2) {
+                newX = bezierCoords[i];
+                newY = bezierCoords[i + 1];
+                area += currentX * newY - newX * currentY;
+                currentX = newX;
+                currentY = newY;
+            }
+            if (area < 0) {
+                /* The area is negative so the shape was clockwise
+                * in a Euclidean sense.  But, our screen coordinate
+                * systems have the origin in the upper left so they
+                * are flipped.  Thus, this path "looks" ccw on the
+                * screen so we are flipping it to "look" clockwise.
+                * Note that the first and last points are the same
+                * so we do not need to swap them.
+                * (Not that it matters whether the paths end up cw
+                *  or ccw in the end as long as all of them are the
+                *  same, but above we called this section "Clockwise
+                *  Enforcement", so we do not want to be liars. ;-)
+                */
+                // Note that [0,1] do not need to be swapped with [n-2,n-1]
+                // So first pair to swap is [2,3] and [n-4,n-3]
+                int i = 2;
+                int j = numCoords - 4;
+                while (i < j) {
+                    currentX = bezierCoords[i];
+                    currentY = bezierCoords[i + 1];
+                    bezierCoords[i] = bezierCoords[j];
+                    bezierCoords[i + 1] = bezierCoords[j + 1];
+                    bezierCoords[j] = currentX;
+                    bezierCoords[j + 1] = currentY;
+                    i += 2;
+                    j -= 2;
+                }
+            }
+        }
+
+        private void appendLineTo(double x0, double y0,
+                                  double x1, double y1)
+        {
+            appendCubicTo(// A third of the way from xy0 to xy1:
+                        interp(x0, x1, THIRD),
+                        interp(y0, y1, THIRD),
+                        // A third of the way from xy1 back to xy0:
+                        interp(x1, x0, THIRD),
+                        interp(y1, y0, THIRD),
+                        x1, y1);
+        }
+
+        private void appendQuadTo(double x0, double y0,
+                                  double ctrlx, double ctrly,
+                                  double x1, double y1)
+        {
+            appendCubicTo(// A third of the way from ctrlxy back to xy0:
+                        interp(ctrlx, x0, THIRD),
+                        interp(ctrly, y0, THIRD),
+                        // A third of the way from ctrlxy to xy1:
+                        interp(ctrlx, x1, THIRD),
+                        interp(ctrly, y1, THIRD),
+                        x1, y1);
+        }
+
+        private void appendCubicTo(double ctrlx1, double ctrly1,
+                                  double ctrlx2, double ctrly2,
+                                  double x1, double y1)
+        {
+            if (numCoords + 6 > bezierCoords.length) {
+                // Keep array size to a multiple of 6 plus 2
+                int newsize = (numCoords - 2) * 2 + 2;
+                double newCoords[] = new double[newsize];
+                System.arraycopy(bezierCoords, 0, newCoords, 0, numCoords);
+                bezierCoords = newCoords;
+            }
+            bezierCoords[numCoords++] = ctrlx1;
+            bezierCoords[numCoords++] = ctrly1;
+            bezierCoords[numCoords++] = ctrlx2;
+            bezierCoords[numCoords++] = ctrly2;
+            bezierCoords[numCoords++] = x1;
+            bezierCoords[numCoords++] = y1;
+        }
+
+        public int getNumCoords() {
+            return numCoords;
+        }
+
+        public double getCoord(int i) {
+            return bezierCoords[i];
+        }
+
+        public double[] getTvals() {
+            if (myTvals != null) {
+                return myTvals;
+            }
+
+            // assert(numCoords >= 8);
+            // assert(((numCoords - 2) % 6) == 0);
+            double tvals[] = new double[(numCoords - 2) / 6 + 1];
+
+            // First calculate total "length" of path
+            // Length of each segment is averaged between
+            // the length between the endpoints (a lower bound for a cubic)
+            // and the length of the control polygon (an upper bound)
+            double segx = bezierCoords[0];
+            double segy = bezierCoords[1];
+            double tlen = 0;
+            int ci = 2;
+            int ti = 0;
+            while (ci < numCoords) {
+                double prevx, prevy, newx, newy;
+                prevx = segx;
+                prevy = segy;
+                newx = bezierCoords[ci++];
+                newy = bezierCoords[ci++];
+                prevx -= newx;
+                prevy -= newy;
+                double len = Math.sqrt(prevx * prevx + prevy * prevy);
+                prevx = newx;
+                prevy = newy;
+                newx = bezierCoords[ci++];
+                newy = bezierCoords[ci++];
+                prevx -= newx;
+                prevy -= newy;
+                len += Math.sqrt(prevx * prevx + prevy * prevy);
+                prevx = newx;
+                prevy = newy;
+                newx = bezierCoords[ci++];
+                newy = bezierCoords[ci++];
+                prevx -= newx;
+                prevy -= newy;
+                len += Math.sqrt(prevx * prevx + prevy * prevy);
+                // len is now the total length of the control polygon
+                segx -= newx;
+                segy -= newy;
+                len += Math.sqrt(segx * segx + segy * segy);
+                // len is now sum of linear length and control polygon length
+                len /= 2;
+                // len is now average of the two lengths
+
+                /* If the result is zero length then we will have problems
+                * below trying to do the math and bookkeeping to split
+                * the segment or pair it against the segments in the
+                * other shape.  Since these lengths are just estimates
+                * to map the segments of the two shapes onto corresponding
+                * segments of "approximately the same length", we will
+                * simply modify the length of this segment to be at least
+                * a minimum value and it will simply grow from zero or
+                * near zero length to a non-trivial size as it morphs.
+                */
+                if (len < MIN_LEN) {
+                    len = MIN_LEN;
+                }
+                tlen += len;
+                tvals[ti++] = tlen;
+                segx = newx;
+                segy = newy;
+            }
+
+            // Now set tvals for each segment to its proportional
+            // part of the length
+            double prevt = tvals[0];
+            tvals[0] = 0;
+            for (ti = 1; ti < tvals.length - 1; ti++) {
+                double nextt = tvals[ti];
+                tvals[ti] = prevt / tlen;
+                prevt = nextt;
+            }
+            tvals[ti] = 1;
+            return (myTvals = tvals);
+        }
+
+        public void setTvals(double newTvals[]) {
+            double oldCoords[] = bezierCoords;
+            double newCoords[] = new double[2 + (newTvals.length - 1) * 6];
+            double oldTvals[] = getTvals();
+            int oldci = 0;
+            double x0, xc0, xc1, x1;
+            double y0, yc0, yc1, y1;
+            x0 = xc0 = xc1 = x1 = oldCoords[oldci++];
+            y0 = yc0 = yc1 = y1 = oldCoords[oldci++];
+            int newci = 0;
+            newCoords[newci++] = x0;
+            newCoords[newci++] = y0;
+            double t0 = 0;
+            double t1 = 0;
+            int oldti = 1;
+            int newti = 1;
+            while (newti < newTvals.length) {
+                if (t0 >= t1) {
+                    x0 = x1;
+                    y0 = y1;
+                    xc0 = oldCoords[oldci++];
+                    yc0 = oldCoords[oldci++];
+                    xc1 = oldCoords[oldci++];
+                    yc1 = oldCoords[oldci++];
+                    x1 = oldCoords[oldci++];
+                    y1 = oldCoords[oldci++];
+                    t1 = oldTvals[oldti++];
+                }
+                double nt = newTvals[newti++];
+                // assert(nt > t0);
+                if (nt < t1) {
+                    // Make nt proportional to [t0 => t1] range
+                    double relt = (nt - t0) / (t1 - t0);
+                    newCoords[newci++] = x0 = interp(x0, xc0, relt);
+                    newCoords[newci++] = y0 = interp(y0, yc0, relt);