diff --git a/docs/uml-diagrams/imgs/plantuml_class_diagram.png b/docs/uml-diagrams/imgs/plantuml_class_diagram.png
index 15ed39a..4685b1a 100644
Binary files a/docs/uml-diagrams/imgs/plantuml_class_diagram.png and b/docs/uml-diagrams/imgs/plantuml_class_diagram.png differ
diff --git a/docs/uml-diagrams/imgs/plantuml_class_diagram.svg b/docs/uml-diagrams/imgs/plantuml_class_diagram.svg
index ca99eae..3e63618 100644
--- a/docs/uml-diagrams/imgs/plantuml_class_diagram.svg
+++ b/docs/uml-diagrams/imgs/plantuml_class_diagram.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/uml-diagrams/plantuml_class_diagram.puml b/docs/uml-diagrams/plantuml_class_diagram.puml
index da49c71..eec6af7 100644
--- a/docs/uml-diagrams/plantuml_class_diagram.puml
+++ b/docs/uml-diagrams/plantuml_class_diagram.puml
@@ -50,7 +50,8 @@ legend right
interfaces such as TurnProcessor and WaveManager
Observer - BattleEventPublisher + BattleEventListener
Factory - BattleSetupFactory and CombatantFactory
- SRP - ArenaScenePanel, ArenaSceneModel, ArenaSceneRenderer
+ SRP - ArenaScenePanel, ArenaSceneModel, ArenaSceneRenderer,
+ ArenaBackgroundRenderer
Boundary-Control - UI classes delegate battle flow to
BattleController and BattleEngine
endlegend
@@ -760,10 +761,18 @@ package "UI LAYER - Swing GUI boundary/control" {
}
class ArenaSceneRenderer <> {
+ - backgroundRenderer: ArenaBackgroundRenderer
- fighterRenderer: FighterSpriteRenderer
~ render(g: Graphics2D, model: ArenaSceneModel, width: int, height: int, now: long): void
}
+ class ArenaBackgroundRenderer <> {
+ {static} ~ BACKGROUND_RESOURCE: String
+ - source: BufferedImage
+ - scaledBackground: BufferedImage
+ ~ render(g: Graphics2D, width: int, height: int): void
+ }
+
class ArenaSceneLoop <> {
- tickCallback: Runnable
- tickTimer: Timer
@@ -1053,6 +1062,7 @@ ArenaScenePanel "1" *-- "1" ArenaSceneRenderer
ArenaScenePanel "1" *-- "1" ArenaSceneLoop
ArenaSceneModel "1" o-- "0..*" FighterSpriteDto : sprites
ArenaSceneModel "1" *-- "0..*" FloatingText : floatingTexts
+ArenaSceneRenderer "1" o-- "1" ArenaBackgroundRenderer
ArenaSceneRenderer "1" *-- "1" FighterSpriteRenderer
FighterBodyRenderer <|.. WarriorBodyRenderer
FighterBodyRenderer <|.. WizardBodyRenderer
diff --git a/src/main/java/sc2002/turnbased/ui/gui/view/ArenaBackgroundRenderer.java b/src/main/java/sc2002/turnbased/ui/gui/view/ArenaBackgroundRenderer.java
new file mode 100644
index 0000000..81e1c29
--- /dev/null
+++ b/src/main/java/sc2002/turnbased/ui/gui/view/ArenaBackgroundRenderer.java
@@ -0,0 +1,109 @@
+package sc2002.turnbased.ui.gui.view;
+
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+
+import javax.imageio.ImageIO;
+
+final class ArenaBackgroundRenderer {
+ static final String BACKGROUND_RESOURCE = "/sc2002/turnbased/ui/gui/assets/arena-background.png";
+
+ private final BufferedImage source;
+ private BufferedImage scaledBackground;
+ private int scaledWidth;
+ private int scaledHeight;
+
+ ArenaBackgroundRenderer() {
+ this(loadResource(BACKGROUND_RESOURCE));
+ }
+
+ ArenaBackgroundRenderer(BufferedImage source) {
+ this.source = Objects.requireNonNull(source, "source");
+ if (source.getWidth() <= 0 || source.getHeight() <= 0) {
+ throw new IllegalArgumentException("Background image must have positive dimensions.");
+ }
+ }
+
+ void render(Graphics2D g, int width, int height) {
+ Objects.requireNonNull(g, "g");
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ g.drawImage(scaledBackground(width, height), 0, 0, null);
+ }
+
+ private BufferedImage scaledBackground(int width, int height) {
+ if (scaledBackground == null || width != scaledWidth || height != scaledHeight) {
+ scaledBackground = createScaledBackground(width, height);
+ scaledWidth = width;
+ scaledHeight = height;
+ }
+ return scaledBackground;
+ }
+
+ private BufferedImage createScaledBackground(int width, int height) {
+ BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ Graphics2D backgroundGraphics = target.createGraphics();
+ try {
+ backgroundGraphics.setRenderingHint(
+ RenderingHints.KEY_INTERPOLATION,
+ RenderingHints.VALUE_INTERPOLATION_BILINEAR
+ );
+ backgroundGraphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+ SourceCrop crop = coverCrop(width, height);
+ backgroundGraphics.drawImage(
+ source,
+ 0,
+ 0,
+ width,
+ height,
+ crop.x(),
+ crop.y(),
+ crop.x() + crop.width(),
+ crop.y() + crop.height(),
+ null
+ );
+ } finally {
+ backgroundGraphics.dispose();
+ }
+ return target;
+ }
+
+ private SourceCrop coverCrop(int width, int height) {
+ int sourceWidth = source.getWidth();
+ int sourceHeight = source.getHeight();
+ double sourceRatio = sourceWidth / (double) sourceHeight;
+ double targetRatio = width / (double) height;
+ if (sourceRatio > targetRatio) {
+ int cropWidth = Math.max(1, (int) Math.round(sourceHeight * targetRatio));
+ int cropX = (sourceWidth - cropWidth) / 2;
+ return new SourceCrop(cropX, 0, cropWidth, sourceHeight);
+ }
+ int cropHeight = Math.max(1, (int) Math.round(sourceWidth / targetRatio));
+ int cropY = (sourceHeight - cropHeight) / 2;
+ return new SourceCrop(0, cropY, sourceWidth, cropHeight);
+ }
+
+ private static BufferedImage loadResource(String resourceName) {
+ try (InputStream inputStream = ArenaBackgroundRenderer.class.getResourceAsStream(resourceName)) {
+ if (inputStream == null) {
+ throw new IllegalStateException("Missing arena background resource: " + resourceName);
+ }
+ BufferedImage image = ImageIO.read(inputStream);
+ if (image == null) {
+ throw new IllegalStateException("Unreadable arena background resource: " + resourceName);
+ }
+ return image;
+ } catch (IOException exception) {
+ throw new UncheckedIOException("Unable to load arena background resource: " + resourceName, exception);
+ }
+ }
+
+ private record SourceCrop(int x, int y, int width, int height) {
+ }
+}
diff --git a/src/main/java/sc2002/turnbased/ui/gui/view/ArenaSceneRenderer.java b/src/main/java/sc2002/turnbased/ui/gui/view/ArenaSceneRenderer.java
index 6e7e17c..2c3faec 100644
--- a/src/main/java/sc2002/turnbased/ui/gui/view/ArenaSceneRenderer.java
+++ b/src/main/java/sc2002/turnbased/ui/gui/view/ArenaSceneRenderer.java
@@ -5,103 +5,34 @@
import java.awt.Color;
import java.awt.Composite;
import java.awt.Font;
-import java.awt.GradientPaint;
import java.awt.Graphics2D;
-import java.awt.Polygon;
import java.awt.RenderingHints;
-import java.awt.geom.Path2D;
+import java.util.Objects;
final class ArenaSceneRenderer {
private static final long FLOATING_TEXT_NANOS = 1_000_000_000L;
+ private final ArenaBackgroundRenderer backgroundRenderer;
private final FighterSpriteRenderer fighterRenderer = new FighterSpriteRenderer();
+ ArenaSceneRenderer() {
+ this(new ArenaBackgroundRenderer());
+ }
+
+ ArenaSceneRenderer(ArenaBackgroundRenderer backgroundRenderer) {
+ this.backgroundRenderer = Objects.requireNonNull(backgroundRenderer, "backgroundRenderer");
+ }
+
void render(Graphics2D g, ArenaSceneModel model, int width, int height, long now) {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
- drawBackground(g, width, height);
+ backgroundRenderer.render(g, width, height);
drawTargetPath(g, model);
drawSprites(g, model);
drawFloatingTexts(g, model, now);
drawOverlay(g, model, width);
}
- private void drawBackground(Graphics2D g, int width, int height) {
- int floorTop = floorTop(height);
- g.setPaint(new GradientPaint(0, 0, new Color(34, 107, 124), 0, height, new Color(232, 86, 76)));
- g.fillRect(0, 0, width, height);
-
- g.setColor(new Color(255, 210, 99, 185));
- g.fillOval(width - 178, 48, 86, 86);
-
- drawMountain(g, -40, floorTop - 160, 270, new Color(60, 102, 91));
- drawMountain(g, 190, floorTop - 145, 270, new Color(55, 84, 93));
- drawMountain(g, 470, floorTop - 152, 300, new Color(63, 105, 81));
- drawForest(g, width, floorTop);
- drawRuins(g, width, floorTop);
-
- g.setPaint(new GradientPaint(0, floorTop, new Color(58, 69, 58), 0, height, new Color(34, 48, 45)));
- g.fillRect(0, floorTop, width, height - floorTop);
-
- g.setColor(new Color(88, 116, 91, 140));
- for (int x = -40; x < width + 90; x += 86) {
- g.fillRoundRect(x, floorTop + 22, 58, 18, 8, 8);
- g.fillRoundRect(x + 28, floorTop + 116, 74, 22, 8, 8);
- }
- g.setColor(new Color(18, 29, 31, 90));
- for (int y = floorTop + 30; y < height; y += 48) {
- g.drawLine(0, y, width, y - 26);
- }
- }
-
- private void drawMountain(Graphics2D g, int x, int baseY, int size, Color color) {
- Polygon mountain = new Polygon();
- mountain.addPoint(x, baseY + size);
- mountain.addPoint(x + size / 2, baseY);
- mountain.addPoint(x + size, baseY + size);
- g.setColor(color);
- g.fillPolygon(mountain);
- g.setColor(new Color(235, 238, 214, 88));
- Polygon cap = new Polygon();
- cap.addPoint(x + size / 2, baseY);
- cap.addPoint(x + size / 2 - 32, baseY + 70);
- cap.addPoint(x + size / 2 + 12, baseY + 48);
- cap.addPoint(x + size / 2 + 42, baseY + 86);
- g.fillPolygon(cap);
- }
-
- private void drawForest(Graphics2D g, int width, int floorTop) {
- int horizon = floorTop - 40;
- for (int x = -20; x < width + 60; x += 42) {
- int height = 54 + Math.floorMod(x * 13, 48);
- g.setColor(new Color(34, 78, 57, 180));
- Path2D tree = new Path2D.Double();
- tree.moveTo(x, horizon);
- tree.lineTo(x + 20, horizon - height);
- tree.lineTo(x + 42, horizon);
- tree.closePath();
- g.fill(tree);
- g.setColor(new Color(50, 63, 48, 190));
- g.fillRect(x + 18, horizon - 10, 8, 18);
- }
- }
-
- private void drawRuins(Graphics2D g, int width, int floorTop) {
- int base = floorTop - 26;
- Color stoneColor = new Color(74, 77, 72, 155);
- Color stripeColor = new Color(120, 58, 58, 150);
- for (int x = 44; x < width; x += 245) {
- g.setColor(stoneColor);
- g.fillRect(x, base - 94, 26, 94);
- g.setColor(stoneColor);
- g.fillRect(x + 76, base - 118, 28, 118);
- g.setColor(stoneColor);
- g.fillRect(x - 10, base - 120, 128, 18);
- g.setColor(stripeColor);
- g.fillRect(x + 24, base - 108, 50, 12);
- }
- }
-
private void drawTargetPath(Graphics2D g, ArenaSceneModel model) {
FighterSpriteDto player = model.playerSprite();
FighterSpriteDto target = model.currentSelectedEnemySprite();
@@ -159,10 +90,6 @@ private void drawOverlay(Graphics2D g, ArenaSceneModel model, int width) {
}
}
- private static int floorTop(int height) {
- return (int) (height * 0.55);
- }
-
private static String fitText(Graphics2D g, String text, int maxWidth) {
if (g.getFontMetrics().stringWidth(text) <= maxWidth) {
return text;
diff --git a/src/main/resources/sc2002/turnbased/ui/gui/assets/arena-background.png b/src/main/resources/sc2002/turnbased/ui/gui/assets/arena-background.png
new file mode 100644
index 0000000..015dbce
Binary files /dev/null and b/src/main/resources/sc2002/turnbased/ui/gui/assets/arena-background.png differ
diff --git a/src/test/java/sc2002/turnbased/ui/gui/view/ArenaBackgroundRendererTest.java b/src/test/java/sc2002/turnbased/ui/gui/view/ArenaBackgroundRendererTest.java
new file mode 100644
index 0000000..482a7c3
--- /dev/null
+++ b/src/test/java/sc2002/turnbased/ui/gui/view/ArenaBackgroundRendererTest.java
@@ -0,0 +1,50 @@
+package sc2002.turnbased.ui.gui.view;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.imageio.ImageIO;
+
+import org.junit.jupiter.api.Test;
+
+class ArenaBackgroundRendererTest {
+ private static final byte[] PNG_SIGNATURE = new byte[] {
+ (byte) 0x89,
+ 'P',
+ 'N',
+ 'G',
+ '\r',
+ '\n',
+ 0x1A,
+ '\n'
+ };
+
+ @Test
+ void packagesBackgroundAsPngResource() throws IOException {
+ try (InputStream inputStream = ArenaBackgroundRenderer.class.getResourceAsStream(
+ ArenaBackgroundRenderer.BACKGROUND_RESOURCE
+ )) {
+ assertNotNull(inputStream);
+ assertArrayEquals(PNG_SIGNATURE, inputStream.readNBytes(PNG_SIGNATURE.length));
+ }
+ }
+
+ @Test
+ void decodesPackagedBackgroundResource() throws IOException {
+ try (InputStream inputStream = ArenaBackgroundRenderer.class.getResourceAsStream(
+ ArenaBackgroundRenderer.BACKGROUND_RESOURCE
+ )) {
+ assertNotNull(inputStream);
+ BufferedImage image = ImageIO.read(inputStream);
+
+ assertNotNull(image);
+ assertTrue(image.getWidth() > 0);
+ assertTrue(image.getHeight() > 0);
+ }
+ }
+}