/**
* Stop
*
* A prototype game exploring the idea of a
* bullet hell space-shooter in which the
* player takes an entirely defensive role
* and must protect others in addition to
* themselves.
*
* Known issues/ things to ponder:
*
* - What is your motivation for NOT having the shield up at all times?
*   - make it run out of juice while taking hits/ recharge while retracted?
*   - make the player more mobile somehow when not shielding?
*   - tried keeping shield angle fixed while deployed- testers didn't like it.
* - Why/How does the player empathize with the things they are saving?
*
* TODO:
*
* - enlarge wave counter for constrast with timer
* - periodically spawn more people to defend
* - indicate oncoming enemies?
*
**/

import java.util.*;
import java.util.List; // awt is dumb.
import java.awt.*;
import java.awt.geom.*;
import java.awt.event.*;
import java.awt.image.*;
import javax.swing.*;

// just for the screen capture feature:
import javax.imageio.*;
import java.io.*;

public class Stop extends JPanel implements KeyListener {

  /**
	* Rendering engine bits and utilities:
	**/

	private static final int WIDTH    = 160;
	private static final int HEIGHT   = 120;
	private static final int GOAL_FPS =  50;
	static final BufferedImage buff = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_ARGB);

	public static void main(String[] args) {
		JFrame window = new JFrame("Stop");
		Stop app = new Stop();
		app.setPreferredSize(new Dimension(320*3, 240*3));

		window.setCursor(window.getToolkit().createCustomCursor(
			new BufferedImage(1,1, BufferedImage.TYPE_INT_ARGB),
			new Point(0, 0),
			"invisible"
		));

		window.addKeyListener(app);
		window.add(app);
		window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		window.setResizable(false);
		window.pack();
		window.setVisible(true);

		app.newgame();

		while(true) {

			long start = System.nanoTime();
			
			synchronized(buff) { app.tick(); }
			app.repaint();
			
			long total = System.nanoTime() - start;
			
			if ((total / 1000000) < (1000 / GOAL_FPS)) {
				try { Thread.sleep((1000 / GOAL_FPS) - (total / 1000000)); }
				catch(InterruptedException e) {}
			}
		}
	}

	public void keyTyped(KeyEvent e) {}
	public void keyPressed(KeyEvent e) {}

	private int screenshot = 0;
	public void keyReleased(KeyEvent e) {
		if (e.getKeyCode() == KeyEvent.VK_S) {
			synchronized(buff) {
				try { ImageIO.write(buff, "PNG", new File("shot"+(screenshot++)+".png")); }
				catch(IOException ioe) {}
			}
		}
	}

	public void paint(Graphics g) {
		synchronized(buff) {
			draw((Graphics2D)buff.getGraphics());
			g.drawImage(
				buff,
				0, 0, getWidth(), getHeight(),
				0, 0,      WIDTH,      HEIGHT,
				this
			);
		}
	}

	void outline(Graphics2D g, Shape s) {
		g.setColor(Color.WHITE);
		g.fill(s);
		g.setColor(Color.BLACK);
		g.draw(s);
	}

	private double mx(MouseEvent e) { return ((double)e.getX()) / (getWidth()  / WIDTH ); }
	private double my(MouseEvent e) { return ((double)e.getY()) / (getHeight() / HEIGHT); }
	
	private double dist(double x1, double y1, double x2, double y2) {
		double dx = x1 - x2;
		double dy = y1 - y2;
		return Math.sqrt((dx*dx) + (dy*dy));
	}

	private double dist(Entity a, Entity b) {
		return dist(a.x, a.y, b.x, b.y);
	}

	private <E extends Entity> E closest(List<E> sources, Entity target) {
		E best = sources.get(0);
		double bestd = Double.POSITIVE_INFINITY;
		for(E e : sources) {
			double dist = dist(target, e);
			if (dist < bestd) {
				bestd = dist;
				best  = e;
			}
		}
		return best;
	}

	private Shape trans(GeneralPath p, double x, double y, double r, double s) {
		AffineTransform a = new AffineTransform();
		a.translate(x, y);
		a.rotate(r);
		a.scale(s, s);
		return p.createTransformedShape(a);
	}

	private void tickAndCull(Collection<? extends Entity> entities, boolean boundCheck) {
		Set<Entity> killed = new HashSet<Entity>();
		for(Entity e : entities) {
			e.tick();
			if (boundCheck && (
				(e.x < -10) || (e.x > WIDTH  + 10) ||
			    (e.y < -10) || (e.y > HEIGHT + 10))) {
				e.dead = true;
			}
			if (e.dead) {
				killed.add(e);
			}
		}
		entities.removeAll(killed);
	}

	private void drawNumber(Graphics g, int x, int y, long num) {
		do {
			g.drawImage(Data.nums[(int)(num % 10)], x, y, null);
			x -= 4;
			num /= 10;
		} while(num > 0);
	}

	abstract class Entity implements MouseListener, MouseMotionListener {
		public void mouseEntered (MouseEvent e) {}
		public void mouseExited  (MouseEvent e) {}
		public void mouseClicked (MouseEvent e) {}
	
		public void mousePressed (MouseEvent e) { mouseDown(e.getButton() == MouseEvent.BUTTON1); }
		public void mouseReleased(MouseEvent e) { mouseUp  (e.getButton() == MouseEvent.BUTTON1); }
		public void mouseDragged (MouseEvent e) { mouseMoved(mx(e), my(e)); }
		public void mouseMoved   (MouseEvent e) { mouseMoved(mx(e), my(e)); }

		void mouseDown(boolean primary) {}
		void mouseUp  (boolean primary) {}
		void mouseMoved(double x, double y) {}

		boolean collide() { return false; }
		void move(double dx, double dy) {
			double dist = dist(0, 0, dx, dy);
			int steps = (int)(dist / 2);
			if (dist < .001) { return; }
			double ux = dx / steps;
			double uy = dy / steps;

			// do a zig-zag linear interpolation:
			int ax = steps;
			int ay = steps;
			while(!dead && (ax > 0 || ay > 0)) {
				if (ax > 0) {
					x += ux;
					if (collide()) { ax=0; x-= ux; }
					else           { ax--; }
				}
				if (ay > 0) {
					y += uy;
					if (collide()) { ay=0; y-= uy; }
					else           { ay--; }
				}
			}
		}

		void moveAngle(double angle, double mag) {
			move(mag * Math.cos(angle), mag * Math.sin(angle));
		}

		double face(Entity e)     { return Math.atan2(e.y - y, e.x - x); }
		double skew(double width) { return Math.random() * width - (width / 2); }

		double x;
		double y;
		boolean dead = false;

		abstract void tick();
		abstract void draw(Graphics2D g);
	}

	/**
	* Game logic and global state:
	**/

	int mode = 0;
	Ship player  = null;
	Shape shield = null;
	boolean hit = false;
	int hits  = 30;
	long time = 0;
	int waveno = 0;
	Random sequence = null;

	List<Bullet>   bullets   = new ArrayList<Bullet>();
	List<Particle> particles = new ArrayList<Particle>();
	List<Enemy>    enemies   = new ArrayList<Enemy>();
	List<Person>   people    = new ArrayList<Person>();

	List<Shape> walls = new ArrayList<Shape>(); {
		walls.add(new Rectangle(WIDTH / 4 * 1 - 8, HEIGHT / 4 * 1 - 8, 16, 16));
		walls.add(new Rectangle(WIDTH / 4 * 3 - 8, HEIGHT / 4 * 1 - 8, 16, 16));
		walls.add(new Rectangle(WIDTH / 4 * 1 - 8, HEIGHT / 4 * 3 - 8, 16, 16));
		walls.add(new Rectangle(WIDTH / 4 * 3 - 8, HEIGHT / 4 * 3 - 8, 16, 16));
	}

	void newgame() {
		mode   = 0;
		shield = null;
		hit    = false;
		hits   = 30;
		time   = 0;
		waveno = 0;
		bullets  .clear();
		particles.clear();
		enemies  .clear();
		people   .clear();
		people.add(new Person( 50, 50));
		people.add(new Person(100, 50));
		sequence = new Random(0xDEFACED);
		if (player != null) {
			removeMouseListener(player);
			removeMouseListener(player);
		}
		player = new Ship();
		addMouseListener(player);
		addMouseMotionListener(player);
	}

	void spawn(int type) {
		switch(type) {
			case  0 : enemies.add(new Hex());       break;
			case  1 : enemies.add(new Square(100)); break;
			case  2 : enemies.add(new Square(200)); break;
			case  3 : enemies.add(new Square(300)); break;
			case  4 : enemies.add(new Pent(0));     break;
			case  5 : enemies.add(new Pent(1));     break;
			case  6 : enemies.add(new Pent(2));     break;
			case  7 : enemies.add(new Pent(3));     break;
		}
	}

	void spawnWave() {
		while(enemies.size() < (waveno / 5) + 1) {
			switch(sequence.nextInt(19)) {
				case  0 : spawn(0);                     break;
				case  1 : spawn(1);                     break;
				case  2 : spawn(1); spawn(2);           break;
				case  3 : spawn(1); spawn(2); spawn(3); break;
				case  4 : spawn(4);                     break;
				case  5 : spawn(5);                     break;
				case  6 : spawn(6);                     break;
				case  7 : spawn(7);                     break;
				case  8 : spawn(4); spawn(4);           break;
				case  9 : spawn(5); spawn(5);           break;
				case 10 : spawn(6); spawn(6);           break;
				case 11 : spawn(7); spawn(7);           break;
				case 12 : spawn(0); spawn(0);           break;
				case 13 : spawn(0); spawn(2); spawn(3); break;
				case 14 : spawn(4); spawn(4);           break;
				case 15 : spawn(5); spawn(5);           break;
				case 16 : spawn(6); spawn(6);           break;
				case 17 : spawn(7); spawn(7);           break;
				case 18 : spawn(0);                     break;
			}
		}
		waveno++;
	}

	void tick() {
		if (mode == 0) {
			player.tick();
			for(Person p : people) { p.tick(); }
			tickAndCull(particles, true);
			tickAndCull(bullets,   true);
			tickAndCull(enemies,  false);
			if (hit == true) { hits--; }
			if (hits < 1) { mode = 50; }
			if (enemies.size() < 1) { spawnWave(); }
			time++;
		}
		else {
			if (mode > 1) { mode--; }
		}
	}

	void draw(Graphics2D g) {
		g.setColor(Color.WHITE);
		g.fillRect(0, 0, getWidth(), getHeight());
		g.setColor(Color.BLACK);

		{
			Graphics2D t = (Graphics2D)g.create();
			t.setPaint(Data.diagonal);
			for(Shape s : walls) { t.fill(s); }
		}

		for(Person p : people ) { p.draw(g); }
		for(Bullet b : bullets) { b.draw(g); }

		g.setXORMode(Color.WHITE);
		for(Particle p : particles) { p.draw(g); }
		g.setPaintMode();

		for(Enemy  e : enemies) { e.draw(g); }
		player.draw((Graphics2D)g.create());

		g.setXORMode(Color.WHITE);
		g.setColor(Color.BLACK);
		for(int z = 0; z < hits; z++) {
			g.fill(new Rectangle(
				(int)((WIDTH / 2) - (hits / 2 * 3) + (z * 3)),
				(int)(HEIGHT - 4),
				1,
				3
			));
		}

		drawNumber(g, WIDTH - 4, 1, time);
		drawNumber(g, WIDTH - 4, 6, waveno);

		if (hit) {
			g.fillRect(0, 0, getWidth(), getHeight());
			hit = false;
		}
	}

	/**
	* Game entities:
	**/

	class Ship extends Entity {
		Shape bounds    = Data.arrow;
		Muscle shieldup = new Muscle(0.1, 1.0, 1.5);
		Muscle gather   = new Muscle(1.0, 1.2, 1.0);
		Person picked   = null;
		boolean hit     = false;
		double dir;
		double ox, oy;

		Ship() {
			x = -100;
			y = -100;
		}
	
		void tick() {
			shieldup.step(0.1);
			gather  .step(0.1);

			if (gather.active()) {
				if (picked == null) {
					picked = closest(people, this);
					if (dist(picked, this) < 50) {
						picked.pick();
					}
					else {
						picked = null;
					}
				}
				else {
					double dist = dist(picked, this);
					if (dist > 10) {
						picked.move(
							(x - picked.x) * .125,
							(y - picked.y) * .125
						);
						picked.dir = picked.face(player);
					}
				}
			}
			else {
				if (picked != null) { picked.picked = false; }
				picked = null;
			}
		}
	
		void draw(Graphics2D g) {
			g.setColor(Color.BLACK);
			if (shieldup.active()) {
				shield = trans(Data.shield, x, y, dir, shieldup.v());
				if (hit) {
					outline(g, shield);
					hit = false;
				}
				else {
					g.setXORMode(Color.WHITE);
					g.fill(shield);
					g.setPaintMode();
				}
			}
			else {
				shield = null;
			}
			bounds = trans(Data.arrow, x, y, dir, gather.v());
			if (gather.active()) {
				outline(g, bounds);
			}
			else {
				g.fill(bounds);
			}
		}
	
		void mouseDown(boolean p) {
			if (mode == 0) {
				(p ? shieldup : gather).forward = true;
			}
			else {
				newgame();
			}
		}
		void mouseUp  (boolean p) {
			if (mode == 0) {
				(p ? shieldup : gather).forward = false;
			}
		}
	
		void mouseMoved(double mx, double my) {
			x = mx;
			y = my;
			if (dist(x, y, ox, oy) < 2) { return; }
			dir = Math.atan2(y - oy, x - ox);
			ox  = (x + ox)/2;
			oy  = (y + oy)/2;
		}
	}


	class Person extends Entity {
		Shape  bounds  = Data.arrow;
		Muscle breathe = new Muscle(0.7, 0.9, 3);
		Muscle blink   = new Muscle(0.9, 2.0, 1);
		double dir;
		boolean picked = false;

		Person(double x, double y) {
			this.x = x;
			this.y = y;
		}

		void tick() {
			if (blink.active() && blink.done()) {
				blink.forward = false;
				blink.i = 0;
			}
			blink.step(0.1);

			if (breathe.done()) { breathe.forward = !breathe.forward; }
			breathe.step(0.1);

			for(Person p : people) {
				if (p == this) { continue; }
				if (dist(this, p) < 8) {
					p.moveAngle(face(p), 2.5);
				}
			}

			if (!picked) {
				double goaldir = face(player);
				if (dir < goaldir) { dir += .05; }
				if (dir > goaldir) { dir -= .05; }
				dir %= Math.PI * 2;
			}
		}

		boolean collide() {
			for(Shape s : walls) {
				if (s.contains(x, y)) { return true; }
			}
			return (x <= 5) || (x >= WIDTH-5) || (y <= 5) || (y >= HEIGHT-5); 
		}

		void draw(Graphics2D g) {
			if (blink.active()) {
				outline(g, trans(Data.arrow, x, y, dir, blink.v()));
			}
			bounds = trans(Data.arrow, x, y, dir, breathe.v());
			outline(g, bounds);
		}

		void pick() {
			picked = true;
			blink  .forward = true;
			breathe.forward = false;
		}
	}

	class Bullet extends Entity {
		double a; // angle
		double v; // velocity (pixels/tick)
	
		Bullet(double x, double y, double a, double v) {
			this.x = x;
			this.y = y;
			this.a = a;
			this.v = v;
		}
	
		boolean collide() {
			if (shield != null && shield.contains(x, y)) {
				player.hit = true;
				player.shieldup.step(-.05);
				dead = true;
				return true;
			}
			for(Shape s : walls) {
				if (s.contains(x, y)) {
					dead = true;
					return true;
				}
			}
			for(Person p : people) {
				if (p.bounds.contains(x, y)) {
					p.moveAngle(a + skew(Math.PI / 4), 1.5 * v);
					p.blink.forward = true;
					hit  = true;
					dead = true;
					return true;
				}
			}
			if (player.bounds.contains(x, y)) {
				hit  = true;
				dead = true;
				return true;
			}
			return false;
		}
	
		void tick() {
			moveAngle(a, v);
			if (dead) {
				if (Math.random() > .9) {
					dead = false;
					v = -v;
					a += skew(Math.PI / 8);
				}
				else {
					for(int z = (int)(5 * Math.random()); z >= 0; z--) {
						particles.add(new Particle(
							x + 2 * Math.random() - 1,
							y + 2 * Math.random() - 1,
							a + skew(Math.PI / 4),
							-(v * 0.50 + (0.50 * Math.random())),
							5
						));
					}
				}
			}
		}
	
		void draw(Graphics2D g) {
			g.setColor(Color.BLACK);
			g.drawLine(
				(int)x,
				(int)y,
				(int)(-v * Math.cos(a) + x),
				(int)(-v * Math.sin(a) + y)
			);
		}
	}

	class Particle extends Bullet {
		int timer;

		Particle(double x, double y, double a, double v, int t) {
			super(x, y, a, v);
			this.timer = t;
		}

		boolean collide() {
			return false;
		}

		void tick() {
			moveAngle(a, v);
			timer--;
			dead = timer < 1;
		}
	}

	abstract class Enemy extends Entity {
		Muscle fire = new Muscle(0.8, 1.2, 1);
		double dir;
		double gx;
		double gy;

		Enemy() {
			dir = Math.random() * 2 * Math.PI;
			pickOrigin();
			this.x = gx;
			this.y = gy;
			pickGoal();
		}

		void pickOrigin() {
			double ang = Math.random() * 2 * Math.PI;
			gx = (WIDTH  / 2) + (WIDTH  * Math.cos(ang));
			gy = (HEIGHT / 2) + (HEIGHT * Math.sin(ang));
		}

		void pickGoal() {
			gx = Math.random() * (WIDTH  * .8) + (WIDTH  * .1);
			gy = Math.random() * (HEIGHT * .8) + (HEIGHT * .1);
		}

		void tick() {
			fire.step(.1);
			if (fire.done() && fire.forward) { fire.forward = false; }

			if (dist(x, y, gx, gy) < 10) { pickGoal(); }
			x += (gx - x) * .0125;
			y += (gy - y) * .0125;
		}

		void draw(Graphics2D g, GeneralPath s, boolean blink) {
			Shape t = trans(s, x, y, dir, fire.v());
			if (blink) { outline(g, t); }
			else { g.fill(trans(s, x, y, dir, fire.v())); }
		}
	}

	class Square extends Enemy {
		double vx;
		double vy;

		Square(double dist) {
			dir = Math.random() * 2 * Math.PI;
			this.x  = (WIDTH  / 2) - dist * Math.cos(dir);
			this.y  = (HEIGHT / 2) - dist * Math.sin(dir);
			this.gx = (WIDTH  / 2) + dist * Math.cos(dir);
			this.gy = (HEIGHT / 2) + dist * Math.sin(dir);
			this.vx = 1.2 * Math.cos(dir);
			this.vy = 1.2 * Math.sin(dir);
		}

		void tick() {
			fire.step(.1);
			if (fire.done() && fire.forward) { fire.forward = false; }
			if (Math.random() > .95) {
				bullets.add(new Bullet(x, y, face(player), 2.2));
				fire.forward = true;
			}

			dir += .3;
			x   += vx;
			y   += vy;
			if (dist(x, y, gx, gy) < 10) { dead = true; }
		}

		void draw(Graphics2D g) {
			draw(g, Data.square, true);
		}
	}

	class Pent extends Enemy {
		int timer   = 80;
		int pattern;
		// pattern 0 - fireburst
		// pattern 1 - cone
		// pattern 2 - spiral
		// pattern 3 - double spiral

		Pent(int pattern) {
			fire.a = 1.0;
			fire.b = 0.5;
			this.pattern = pattern;
		}

		void explode() {
			for(int z = 0; z < 100; z++) {
				bullets.add(new Bullet(x, y, Math.PI * 2 / 100 * z, 3.0));
			}
		}

		void cone() {
			double target = face(closest(people, this));
			for(int z = -10; z < 10; z++) {
				bullets.add(new Bullet(x, y, target + (z * Math.PI / 40), 2.3));
				bullets.add(new Bullet(x, y, target + (z * Math.PI / 40), 2.5));
				bullets.add(new Bullet(x, y, target + (z * Math.PI / 40), 3.0));
			}
		}

		void fire() {
			bullets.add(new Bullet(x, y, dir, 2.5));
			if (pattern == 3) {
				bullets.add(new Bullet(x, y, dir + Math.PI, 2.5));
			}
		}

		void tick() {
			if (dist(x, y, gx, gy) < 10) {
				dir += .05;
				if (pattern == 2 || pattern == 3) { fire(); }
				if (timer < 20) {
					dir += .05;
				}
				if (timer < 10) {
					fire.forward = true;
					fire.step(.1);
				}
				if (timer < 1) {
					if (pattern == 0) { explode(); }
					if (pattern == 1) { cone(); }
					dead = true;
				}
				else { timer--; }
			}
			else {
				super.tick();
			}
		}

		void draw(Graphics2D g) {
			boolean blink = ((timer < 40) ? (timer / 2) : (timer / 4)) % 2 == 0;
			draw(g, Data.pent, blink);
		}
	}

	class Hex extends Enemy {
		int hops = 6;

		void pickGoal() {
			super.pickGoal();
			hops--; 
			if (hops == 1) {
				pickOrigin();
			}
			if (hops == 0) {
				dead = true;
			}
		}

		void tick() {
			super.tick();

			Person p = closest(people, this);
			if (Math.random() > .8) {
				bullets.add(new Bullet(
					x, y,
					face(p) + skew(Math.PI / 8),
					4.0
				));
				fire.forward = true;
			}
			dir += .05;
		}

		void draw(Graphics2D g) {
			draw(g, Data.hex, true);
		}
	}
}

/**
* Game data:
**/

class Data {
	static GeneralPath ngon(int sides, double radius) {
		Polygon p = new Polygon();
		for(int z = 0; z < sides; z++) {
			p.addPoint(
				(int)(radius * Math.cos(z * Math.PI * 2 / sides)),
				(int)(radius * Math.sin(z * Math.PI * 2 / sides))
			);
		}
		return new GeneralPath(p);
	}

	static BufferedImage render(int w, int h, int... data) {
		BufferedImage ret = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
		Graphics g = ret.getGraphics();
		for(int z = 0; z < w*h; z++) {
			g.setColor(data[z] == 0 ? Color.WHITE : Color.BLACK);
			g.drawLine(z%w,z/w,z%w,z/w);
		}
		return ret;
	}

	static final GeneralPath arrow = new GeneralPath(new Polygon(
		new int[] { 4,-4,-4},
		new int[] { 0, 3,-3},
		3
	));

	static final GeneralPath hex    = ngon(6, 7.0);
	static final GeneralPath pent   = ngon(5, 6.5);
	static final GeneralPath square = ngon(4, 5.0);

	static final GeneralPath shield; static {
		Polygon p = new Polygon();
		for(int z = -5; z <=  5; z++) {
			p.addPoint(
				(int)(16 * Math.cos(z * Math.PI/16)),
				(int)(16 * Math.sin(z * Math.PI/16))
			);
		}
		for(int z =  5; z >= -5; z--) {
			p.addPoint(
				(int)(12 * Math.cos(z * Math.PI/16)),
				(int)(12 * Math.sin(z * Math.PI/16))
			);
		}
		shield = new GeneralPath(p);
	}

	static final Image[] nums = {
		render(3,4, 1,1,1, 1,0,1, 1,0,1, 1,1,1 ),
		render(3,4, 0,1,0, 0,1,0, 0,1,0, 0,1,0 ),
		render(3,4, 1,1,1, 0,0,1, 0,1,0, 1,1,1 ),
		render(3,4, 1,1,1, 0,1,1, 0,0,1, 1,1,1 ),
		render(3,4, 1,0,1, 1,0,1, 1,1,1, 0,0,1 ),
		render(3,4, 1,1,1, 1,1,0, 0,0,1, 1,1,0 ),
		render(3,4, 1,0,0, 1,1,1, 1,0,1, 1,1,1 ),
		render(3,4, 1,1,1, 0,0,1, 0,1,0, 0,1,0 ),
		render(3,4, 1,1,1, 1,0,1, 1,1,1, 1,1,1 ),
		render(3,4, 1,1,1, 1,0,1, 1,1,1, 0,0,1 ),
	};

	static final Paint diagonal = new TexturePaint(
		render(4,4, 1,0,0,1, 1,1,0,0, 0,1,1,0, 0,0,1,1 ),
		new Rectangle(4,4)
	);
}

class Muscle {
	double a; // start position
	double b; // end   position
	double t; // tween duration
	double i; // tween index
	boolean forward = false; // moving forward?

	Muscle(double start, double end, double time) {
		a = start;
		b = end;
		t = time;
	}

	void step(double time) {
		i = forward ? (i + time) : (i - time);
		i = Math.max(i, 0);
		i = Math.min(i, t);
	}

	boolean active() {
		return !done() || forward;
	}

	boolean done() {
		return forward ? (i == t) : (i == 0);
	}

	double v() {
		return a * tween(1 - (i/t)) +
		       b * tween(    (i/t));
	}

	double tween(double index) {
		// a hand-tuned sigmoid eased tween:
		return 1/(1 + Math.pow(Math.E, -12*(index-.5)));
	}
}