diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b4a5277
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.idea/
+target/
+*.iml
diff --git a/design/icon.svg b/design/icon.svg
new file mode 100644
index 0000000..a1f0762
--- /dev/null
+++ b/design/icon.svg
@@ -0,0 +1,111 @@
+
+
+
+
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..1af812c
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+ nl.andrewl
+ starship-arena
+ 1.0.0-SNAPSHOT
+
+
+ 17
+ 17
+ UTF-8
+
+
+
+
+
+ com.google.code.gson
+ gson
+ 2.9.0
+
+
+
+
+
+
+ maven-assembly-plugin
+
+
+
+ nl.andrewl.starship_arena.StarshipArena
+
+
+
+ jar-with-dependencies
+
+
+
+
+ make-assembly
+ package
+
+ single
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/nl/andrewl/starship_arena/StarshipArena.java b/src/main/java/nl/andrewl/starship_arena/StarshipArena.java
new file mode 100644
index 0000000..08b94a6
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/StarshipArena.java
@@ -0,0 +1,21 @@
+package nl.andrewl.starship_arena;
+
+import nl.andrewl.starship_arena.model.Arena;
+import nl.andrewl.starship_arena.model.Ship;
+import nl.andrewl.starship_arena.model.ShipModel;
+import nl.andrewl.starship_arena.util.ResourceUtils;
+import nl.andrewl.starship_arena.view.ArenaWindow;
+
+/**
+ * The main executable class which starts the program.
+ */
+public class StarshipArena {
+ public static void main(String[] args) {
+ ShipModel corvette = ShipModel.load(ResourceUtils.getString("/ships/corvette.json"));
+ Ship s = new Ship(corvette);
+ Arena arena = new Arena();
+ arena.getShips().add(s);
+ var window = new ArenaWindow(arena);
+ window.setVisible(true);
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/Arena.java b/src/main/java/nl/andrewl/starship_arena/model/Arena.java
new file mode 100644
index 0000000..2dd240e
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/Arena.java
@@ -0,0 +1,21 @@
+package nl.andrewl.starship_arena.model;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+/**
+ * Represents the top-level model containing all objects in an arena that can
+ * interact with each other.
+ */
+public class Arena {
+ private final Collection ships = new ArrayList<>();
+ private final Camera camera = new Camera();
+
+ public Collection getShips() {
+ return ships;
+ }
+
+ public Camera getCamera() {
+ return camera;
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/Camera.java b/src/main/java/nl/andrewl/starship_arena/model/Camera.java
new file mode 100644
index 0000000..827c3c4
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/Camera.java
@@ -0,0 +1,37 @@
+package nl.andrewl.starship_arena.model;
+
+import java.awt.geom.Point2D;
+
+public class Camera {
+ public static final double SCALE_INTERVAL = 50.0;
+
+ private Object focus;
+
+ private Point2D.Float position = new Point2D.Float();
+
+ private int scaleIncrement = 1;
+
+ public Object getFocus() {
+ return focus;
+ }
+
+ public void setFocus(Object focus) {
+ this.focus = focus;
+ }
+
+ public Point2D.Float getPosition() {
+ return position;
+ }
+
+ public void setPosition(Point2D.Float position) {
+ this.position = position;
+ }
+
+ public int getScaleIncrement() {
+ return scaleIncrement;
+ }
+
+ public void setScaleIncrement(int scaleIncrement) {
+ this.scaleIncrement = scaleIncrement;
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/PhysicsObject.java b/src/main/java/nl/andrewl/starship_arena/model/PhysicsObject.java
new file mode 100644
index 0000000..fa4b73d
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/PhysicsObject.java
@@ -0,0 +1,58 @@
+package nl.andrewl.starship_arena.model;
+
+import java.awt.geom.Point2D;
+
+public class PhysicsObject {
+ /**
+ * The position of this object in the scene, in meters from the origin.
+ * Positive x-axis goes to the right, and positive y-axis goes down.
+ */
+ private final Point2D.Float position = new Point2D.Float();
+
+ /**
+ * The object's rotation in radians, from 0 to 2 PI.
+ */
+ private float rotation;
+
+ /**
+ * The object's velocity, in meters per second.
+ */
+ private final Point2D.Float velocity = new Point2D.Float();
+
+ /**
+ * The object's rotational speed, in radians per second.
+ */
+ private float rotationSpeed;
+
+ public Point2D.Float getPosition() {
+ return position;
+ }
+
+ public float getRotation() {
+ return rotation;
+ }
+
+ public void setRotation(float rotation) {
+ while (rotation < 0) rotation += 2 * Math.PI;
+ while (rotation > 2 * Math.PI) rotation -= 2 * Math.PI;
+ this.rotation = rotation;
+ }
+
+ public Point2D.Float getVelocity() {
+ return velocity;
+ }
+
+ public float getRotationSpeed() {
+ return rotationSpeed;
+ }
+
+ public void setRotationSpeed(float rotationSpeed) {
+ this.rotationSpeed = rotationSpeed;
+ }
+
+ public void update(double delta) {
+ position.x += velocity.x * delta;
+ position.y += velocity.y * delta;
+ setRotation((float) (rotation + rotationSpeed * delta));
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/Ship.java b/src/main/java/nl/andrewl/starship_arena/model/Ship.java
new file mode 100644
index 0000000..347190e
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/Ship.java
@@ -0,0 +1,56 @@
+package nl.andrewl.starship_arena.model;
+
+import nl.andrewl.starship_arena.model.ship.Gun;
+import nl.andrewl.starship_arena.model.ship.Panel;
+import nl.andrewl.starship_arena.model.ship.ShipComponent;
+import nl.andrewl.starship_arena.util.ResourceUtils;
+
+import java.awt.*;
+import java.util.ArrayList;
+import java.util.Collection;
+
+public class Ship extends PhysicsObject {
+ private final String modelName;
+ private final Collection components;
+
+ private final Collection panels;
+ private final Collection guns;
+
+ private Color primaryColor = Color.GRAY;
+
+ public Ship(ShipModel model) {
+ this.modelName = model.getName();
+ this.components = model.getComponents();
+ this.panels = new ArrayList<>();
+ this.guns = new ArrayList<>();
+ for (var c : components) {
+ c.setShip(this);
+ if (c instanceof Panel p) panels.add(p);
+ if (c instanceof Gun g) guns.add(g);
+ }
+ }
+
+ public Ship(String modelResource) {
+ this(ShipModel.load(ResourceUtils.getString(modelResource)));
+ }
+
+ public String getModelName() {
+ return modelName;
+ }
+
+ public Collection getComponents() {
+ return components;
+ }
+
+ public Color getPrimaryColor() {
+ return primaryColor;
+ }
+
+ public float getMass() {
+ float m = 0;
+ for (var c : components) {
+ m += c.getMass();
+ }
+ return m;
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/ShipModel.java b/src/main/java/nl/andrewl/starship_arena/model/ShipModel.java
new file mode 100644
index 0000000..39db86f
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/ShipModel.java
@@ -0,0 +1,70 @@
+package nl.andrewl.starship_arena.model;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import nl.andrewl.starship_arena.model.ship.ComponentDeserializer;
+import nl.andrewl.starship_arena.model.ship.GeometricComponent;
+import nl.andrewl.starship_arena.model.ship.Gun;
+import nl.andrewl.starship_arena.model.ship.ShipComponent;
+
+import java.util.Collection;
+
+public class ShipModel {
+ private String name;
+ private Collection components;
+
+ public String getName() {
+ return name;
+ }
+
+ public Collection getComponents() {
+ return components;
+ }
+
+ /**
+ * Normalizes the geometric properties of the components such that the ship
+ * model's components are centered around (0, 0).
+ * TODO: Consider scaling?
+ */
+ private void normalizeComponents() {
+ float minX = Float.MAX_VALUE;
+ float maxX = Float.MIN_VALUE;
+ float minY = Float.MAX_VALUE;
+ float maxY = Float.MIN_VALUE;
+ for (var c : components) {
+ if (c instanceof GeometricComponent g) {
+ for (var p : g.getPoints()) {
+ minX = Math.min(minX, p.x);
+ maxX = Math.max(maxX, p.x);
+ minY = Math.min(minY, p.y);
+ maxY = Math.max(maxY, p.y);
+ }
+ }
+ }
+ final float width = maxX - minX;
+ final float height = maxY - minY;
+ final float offsetX = -minX - width / 2;
+ final float offsetY = -minY - height / 2;
+ // Shift all components to the top-left.
+ for (var c : components) {
+ if (c instanceof GeometricComponent g) {
+ for (var p : g.getPoints()) {
+ p.x += offsetX;
+ p.y += offsetY;
+ }
+ } else if (c instanceof Gun g) {
+ g.getLocation().x += offsetX;
+ g.getLocation().y += offsetY;
+ }
+ }
+ }
+
+ public static ShipModel load(String json) {
+ Gson gson = new GsonBuilder()
+ .registerTypeAdapter(ShipComponent.class, new ComponentDeserializer())
+ .create();
+ ShipModel model = gson.fromJson(json, ShipModel.class);
+ model.normalizeComponents();
+ return model;
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/ship/Cockpit.java b/src/main/java/nl/andrewl/starship_arena/model/ship/Cockpit.java
new file mode 100644
index 0000000..716d20e
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/ship/Cockpit.java
@@ -0,0 +1,7 @@
+package nl.andrewl.starship_arena.model.ship;
+
+/**
+ * A cockpit represents the control point of the ship.
+ */
+public class Cockpit extends GeometricComponent {
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/ship/ComponentDeserializer.java b/src/main/java/nl/andrewl/starship_arena/model/ship/ComponentDeserializer.java
new file mode 100644
index 0000000..8b30dd6
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/ship/ComponentDeserializer.java
@@ -0,0 +1,24 @@
+package nl.andrewl.starship_arena.model.ship;
+
+import com.google.gson.*;
+
+import java.lang.reflect.Type;
+
+/**
+ * Custom deserializer that's used to deserialize components based on their
+ * "type" property.
+ */
+public class ComponentDeserializer implements JsonDeserializer {
+ @Override
+ public ShipComponent deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext ctx) throws JsonParseException {
+ JsonObject obj = jsonElement.getAsJsonObject();
+ String componentTypeName = obj.get("type").getAsString();
+ Type componentType = switch (componentTypeName) {
+ case "panel" -> Panel.class;
+ case "cockpit" -> Cockpit.class;
+ case "gun" -> Gun.class;
+ default -> throw new JsonParseException("Invalid ship component type: " + componentTypeName);
+ };
+ return ctx.deserialize(obj, componentType);
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/ship/GeometricComponent.java b/src/main/java/nl/andrewl/starship_arena/model/ship/GeometricComponent.java
new file mode 100644
index 0000000..20eefc7
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/ship/GeometricComponent.java
@@ -0,0 +1,15 @@
+package nl.andrewl.starship_arena.model.ship;
+
+import java.awt.geom.Point2D;
+import java.util.List;
+
+/**
+ * Represents a component of a ship that can be drawn as a geometric primitive.
+ */
+public abstract class GeometricComponent extends ShipComponent {
+ private List points;
+
+ public List getPoints() {
+ return points;
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/ship/Gun.java b/src/main/java/nl/andrewl/starship_arena/model/ship/Gun.java
new file mode 100644
index 0000000..49231f2
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/ship/Gun.java
@@ -0,0 +1,16 @@
+package nl.andrewl.starship_arena.model.ship;
+
+import java.awt.geom.Point2D;
+
+public class Gun extends ShipComponent {
+ private String name;
+ private Point2D.Float location;
+
+ public String getName() {
+ return name;
+ }
+
+ public Point2D.Float getLocation() {
+ return location;
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/ship/Panel.java b/src/main/java/nl/andrewl/starship_arena/model/ship/Panel.java
new file mode 100644
index 0000000..d360e00
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/ship/Panel.java
@@ -0,0 +1,8 @@
+package nl.andrewl.starship_arena.model.ship;
+
+/**
+ * A simple structural panel that makes up all or part of a ship's body.
+ */
+public class Panel extends GeometricComponent {
+ private String name;
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/model/ship/ShipComponent.java b/src/main/java/nl/andrewl/starship_arena/model/ship/ShipComponent.java
new file mode 100644
index 0000000..852161f
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/model/ship/ShipComponent.java
@@ -0,0 +1,27 @@
+package nl.andrewl.starship_arena.model.ship;
+
+import nl.andrewl.starship_arena.model.Ship;
+
+/**
+ * Represents the top-level component information for any part of a ship.
+ */
+public class ShipComponent {
+ /**
+ * The ship that this component belongs to.
+ */
+ private transient Ship ship;
+
+ private float mass;
+
+ public Ship getShip() {
+ return ship;
+ }
+
+ public void setShip(Ship ship) {
+ this.ship = ship;
+ }
+
+ public float getMass() {
+ return mass;
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/util/ResourceUtils.java b/src/main/java/nl/andrewl/starship_arena/util/ResourceUtils.java
new file mode 100644
index 0000000..6d30fb0
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/util/ResourceUtils.java
@@ -0,0 +1,21 @@
+package nl.andrewl.starship_arena.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+
+public class ResourceUtils {
+ public static InputStream get(String name) {
+ InputStream in = ResourceUtils.class.getResourceAsStream(name);
+ if (in == null) throw new UncheckedIOException(new IOException("Could not load resource: " + name));
+ return in;
+ }
+
+ public static String getString(String name) {
+ try (var in = get(name)) {
+ return new String(in.readAllBytes());
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/view/ArenaPanel.java b/src/main/java/nl/andrewl/starship_arena/view/ArenaPanel.java
new file mode 100644
index 0000000..a151877
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/view/ArenaPanel.java
@@ -0,0 +1,64 @@
+package nl.andrewl.starship_arena.view;
+
+import nl.andrewl.starship_arena.model.Arena;
+import nl.andrewl.starship_arena.model.Camera;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.geom.AffineTransform;
+
+public class ArenaPanel extends JPanel {
+ private final Arena arena;
+
+ private final ShipRenderer shipRenderer = new ShipRenderer();
+
+ public ArenaPanel(Arena arena) {
+ this.arena = arena;
+ this.addMouseWheelListener(e -> {
+ arena.getCamera().setScaleIncrement(arena.getCamera().getScaleIncrement() + e.getWheelRotation());
+ repaint();
+ });
+ }
+
+ @Override
+ protected void paintComponent(Graphics g) {
+ super.paintComponent(g);
+ Graphics2D g2 = (Graphics2D) g;
+ g2.setColor(Color.BLACK);
+ g2.fillRect(0, 0, getWidth(), getHeight());
+
+ AffineTransform originalTx = g2.getTransform();
+ AffineTransform tx = new AffineTransform();
+
+ Camera cam = arena.getCamera();
+
+ double translateX = (double) getWidth() / 2;
+ double translateY = (double) getHeight() / 2;
+ if (cam.getFocus() == null) {
+ translateX += cam.getPosition().x;
+ translateY += cam.getPosition().y;
+ }
+
+ double scale = 1 * Camera.SCALE_INTERVAL;
+ if (cam.getScaleIncrement() > 0) {
+ scale = cam.getScaleIncrement() * Camera.SCALE_INTERVAL;
+ } else if (cam.getScaleIncrement() < 0) {
+ scale = 1.0 / Math.abs(cam.getScaleIncrement() * Camera.SCALE_INTERVAL);
+ }
+
+ tx.translate(translateX, translateY);
+ tx.scale(scale, scale);
+ g2.setTransform(tx);
+
+ for (var s : arena.getShips()) {
+ shipRenderer.render(s, g2);
+ }
+
+ g2.setTransform(originalTx);
+
+ g2.setColor(Color.GREEN);
+ g2.fillRect(0, 0, 20, 20);
+ g2.setColor(Color.BLUE);
+ g2.fillRect(getWidth() - 20, getHeight() - 20, 20, 20);
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/view/ArenaWindow.java b/src/main/java/nl/andrewl/starship_arena/view/ArenaWindow.java
new file mode 100644
index 0000000..ca3e92b
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/view/ArenaWindow.java
@@ -0,0 +1,39 @@
+package nl.andrewl.starship_arena.view;
+
+import nl.andrewl.starship_arena.model.Arena;
+import nl.andrewl.starship_arena.util.ResourceUtils;
+
+import javax.imageio.ImageIO;
+import javax.swing.*;
+import java.awt.*;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class ArenaWindow extends JFrame {
+ private final ArenaPanel arenaPanel;
+
+ public ArenaWindow(Arena arena) {
+ super("Starship Arena");
+ setDefaultCloseOperation(EXIT_ON_CLOSE);
+ setUndecorated(true);
+ try {
+ GraphicsDevice device = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
+ device.setFullScreenWindow(this);
+ setPreferredSize(new Dimension(device.getDisplayMode().getWidth(), device.getDisplayMode().getHeight()));
+ } catch (HeadlessException e) {
+ System.err.println("Cannot start the program on systems without a screen.");
+ System.exit(1);
+ }
+ try {
+ InputStream in = ResourceUtils.get("/img/icon.png");
+ setIconImage(ImageIO.read(in));
+ in.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ arenaPanel = new ArenaPanel(arena);
+ add(arenaPanel);
+ pack();
+ }
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/view/Renderer.java b/src/main/java/nl/andrewl/starship_arena/view/Renderer.java
new file mode 100644
index 0000000..1f30f88
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/view/Renderer.java
@@ -0,0 +1,7 @@
+package nl.andrewl.starship_arena.view;
+
+import java.awt.*;
+
+public interface Renderer {
+ void render(T obj, Graphics2D g);
+}
diff --git a/src/main/java/nl/andrewl/starship_arena/view/ShipRenderer.java b/src/main/java/nl/andrewl/starship_arena/view/ShipRenderer.java
new file mode 100644
index 0000000..d17b085
--- /dev/null
+++ b/src/main/java/nl/andrewl/starship_arena/view/ShipRenderer.java
@@ -0,0 +1,31 @@
+package nl.andrewl.starship_arena.view;
+
+import nl.andrewl.starship_arena.model.Ship;
+import nl.andrewl.starship_arena.model.ship.Cockpit;
+import nl.andrewl.starship_arena.model.ship.GeometricComponent;
+
+import java.awt.*;
+import java.awt.geom.Path2D;
+
+public class ShipRenderer implements Renderer {
+ @Override
+ public void render(Ship ship, Graphics2D g) {
+ for (var c : ship.getComponents()) {
+ if (c instanceof GeometricComponent geo) {
+ Path2D.Float path = new Path2D.Float();
+ var first = geo.getPoints().get(0);
+ path.moveTo(first.x, first.y);
+ for (int i = 0; i < geo.getPoints().size(); i++) {
+ var point = geo.getPoints().get(i);
+ path.lineTo(point.x, point.y);
+ }
+ if (geo instanceof Cockpit) {
+ g.setColor(new Color(0f, 0f, 0.5f, 0.5f));
+ } else {
+ g.setColor(ship.getPrimaryColor());
+ }
+ g.fill(path);
+ }
+ }
+ }
+}
diff --git a/src/main/resources/img/icon.png b/src/main/resources/img/icon.png
new file mode 100644
index 0000000..b21f0b7
Binary files /dev/null and b/src/main/resources/img/icon.png differ
diff --git a/src/main/resources/ships/corvette.json b/src/main/resources/ships/corvette.json
new file mode 100644
index 0000000..f7270ab
--- /dev/null
+++ b/src/main/resources/ships/corvette.json
@@ -0,0 +1,52 @@
+{
+ "name": "Corvette",
+ "components": [
+ {
+ "type": "panel",
+ "name": "Main Fuselage",
+ "mass": 5000,
+ "points": [
+ {"x": 0.3, "y": 0.6},
+ {"x": 0.2, "y": 0.1},
+ {"x": 0.1, "y": 0.5},
+ {"x": 0.2, "y": 0.8},
+ {"x": 0.8, "y": 0.8},
+ {"x": 0.9, "y": 0.5},
+ {"x": 0.8, "y": 0.1},
+ {"x": 0.7, "y": 0.6}
+ ]
+ },
+ {
+ "type": "panel",
+ "name": "Front Cargo Bay",
+ "mass": 1000,
+ "points": [
+ {"x": 0.4, "y": 0.2},
+ {"x": 0.35, "y": 0.6},
+ {"x": 0.65, "y": 0.6},
+ {"x": 0.6, "y": 0.2}
+ ]
+ },
+ {
+ "type": "cockpit",
+ "mass": 800,
+ "points": [
+ {"x": 0.5, "y": 0.0},
+ {"x": 0.4, "y": 0.2},
+ {"x": 0.6, "y": 0.2}
+ ]
+ },
+ {
+ "type": "gun",
+ "name": "Port-Side Machine Gun",
+ "mass": 500,
+ "location": {"x": 0.15, "y": 0.4}
+ },
+ {
+ "type": "gun",
+ "name": "Starboard-Side Machine Gun",
+ "mass": 500,
+ "location": {"x": 0.85, "y": 0.4}
+ }
+ ]
+}
\ No newline at end of file