diff --git a/pom.xml b/pom.xml
index fd89b0cc..dd181624 100644
--- a/pom.xml
+++ b/pom.xml
@@ -77,6 +77,11 @@
javafx-controls${javafx.version}
+
+ net.jafama
+ jafama
+ 2.3.2
+ org.mockitomockito-junit-jupiter
diff --git a/src/main/java/project_16x16/Audio.java b/src/main/java/project_16x16/Audio.java
index 37aa437a..91eb872a 100644
--- a/src/main/java/project_16x16/Audio.java
+++ b/src/main/java/project_16x16/Audio.java
@@ -17,35 +17,51 @@ public final class Audio {
private static final String BGM_PATH = "Audio/BGM/";
private static final String SFX_PATH = "Audio/SFX/";
- private static float gainBGM = 0;
+ private static float gainMenuBGM = 0;
+ private static float gainGameBGM = 0;
private static float gainSFX = 0;
private static Minim minim;
/**
- * Background music, which are referenced as enums.
+ * Background music, which are referenced as enums. Each track is either menu
+ * or game music; the two have separate volumes.
*/
public enum BGM {
- TEST0("first_theme.mp3"), // TODO
- TEST1("cyberSynthwave.mp3"), // TODO
- TEST2("First_level_take_1.mp3"), TEST3("Intro_Title_Pause_Screen_Take1_Loopable.mp3");
+ TEST0("first_theme.mp3", false), // TODO
+ TEST1("cyberSynthwave.mp3", false), // TODO
+ TEST2("First_level_take_1.mp3", false),
+ TEST3("Intro_Title_Pause_Screen_Take1_Loopable.mp3", true), //
+ TEST4("Intro_Sax.mp3", true) //
+ ;
private String filename;
+ private final boolean menu;
- private BGM(String filename) {
+ private BGM(String filename, boolean menu) {
this.filename = filename;
+ this.menu = menu;
}
public String getPath() {
return BGM_PATH + filename;
}
+
+ /** @return true for menu music, false for in-game music */
+ public boolean isMenu() {
+ return menu;
+ }
+
+ private float gain() {
+ return menu ? gainMenuBGM : gainGameBGM;
+ }
}
/**
* Sound effects, which are referenced as enums.
*/
public enum SFX {
- JUMP("jump.wav"), STEP("walk_single.wav"), ATTACK("melee_attack.wav");
+ JUMP("jump.wav"), STEP("walk_single.wav"), ATTACK("melee_attack.wav"), UI_HOVER("ui_hover.wav"), UI_CLICK("ui_click.wav");
private String filename;
@@ -84,7 +100,8 @@ public static void assignApplet(SideScroller sideScroller) {
System.err.println(bgm.getPath() + " not found.");
}
}
- setGainBGM(Options.gainBGM);
+ setGainMenuBGM(Options.gainMenuBGM);
+ setGainGameBGM(Options.gainGameBGM);
setGainSFX(Options.gainSFX);
}
@@ -141,7 +158,7 @@ public static void play(BGM sound) {
}
}
if (BGM_MAP.containsKey(sound)) {
- BGM_MAP.get(sound).setGain(gainBGM);
+ BGM_MAP.get(sound).setGain(sound.gain());
BGM_MAP.get(sound).loop();
} else {
System.err.println(sound.getPath() + " not found.");
@@ -167,13 +184,31 @@ public static void play(BGM sound, float gain) {
}
/**
- * Sets gain (volume) for background music.
+ * Sets gain (volume) for menu background music.
+ *
+ * @param gain gain, in decibels (where negative is quieter). Default = 0.
+ */
+ public static void setGainMenuBGM(float gain) {
+ gainMenuBGM = gain;
+ BGM_MAP.forEach((bgm, sound) -> {
+ if (bgm.isMenu()) {
+ sound.setGain(gain);
+ }
+ });
+ }
+
+ /**
+ * Sets gain (volume) for in-game background music.
*
* @param gain gain, in decibels (where negative is quieter). Default = 0.
*/
- public static void setGainBGM(float gain) {
- gainBGM = gain;
- BGM_MAP.values().forEach(sound -> sound.setGain(gainBGM));
+ public static void setGainGameBGM(float gain) {
+ gainGameBGM = gain;
+ BGM_MAP.forEach((bgm, sound) -> {
+ if (!bgm.isMenu()) {
+ sound.setGain(gain);
+ }
+ });
}
/**
diff --git a/src/main/java/project_16x16/Camera.java b/src/main/java/project_16x16/Camera.java
index ef42da5c..1cc7822c 100644
--- a/src/main/java/project_16x16/Camera.java
+++ b/src/main/java/project_16x16/Camera.java
@@ -13,8 +13,8 @@
/**
* Camera class. Extends {@link org.gicentre.utils.move.ZoomPan ZoomPan},
* offering some bespoke methods relating to Project-16x16. At the moment, the
- * camera uses {@link PApplet#lerp(float, float, float) lerp()} to follow
- * objects or go to target position.
+ * camera uses frame-rate independent exponential smoothing
+ * ({@link Time#smoothing(float)}) to follow objects or go to target position.
*
* @todo deadzone mode can be choppy when tracking; zoom-to-fit (multiple
* entities); setting position to mouse when camera is rotated [bugged]
@@ -26,10 +26,10 @@ public final class Camera extends ZoomPan {
private SideScroller applet;
/**
- * Lerp constant for motion. Used for all motion easing (zoom, position and
- * rotation).
+ * Smoothing rate (per second) for motion. Used for all motion easing (zoom,
+ * position and rotation).
*/
- private float lerpSpeed = Constants.CAMERA_LERP;
+ private float smoothing = Constants.CAMERA_SMOOTHING;
private float zoom = 1.0f;
/**
* Target variables are used as the target for lerping.
@@ -54,10 +54,11 @@ public final class Camera extends ZoomPan {
private PVector offset;
private PVector followObjectOffset = new PVector(0, 0);
/**
- * Specifies a camera translation offset when shaking (where it is randomised
- * each frame). Used internally.
+ * Specifies a camera translation offset when shaking. It's applied on top of
+ * the (smoothed) pan offset, and removed again before the next frame's
+ * smoothing, so shaking never drifts the camera. Used internally.
*/
- private PVector shakeOffset = new PVector(0, 0);
+ private final PVector shakeOffset = new PVector(0, 0);
/**
* Deadzone coordinate points. Can correspond to either screen or world
* coordinates, depending on which method was used to set them
@@ -76,9 +77,14 @@ public final class Camera extends ZoomPan {
private EditableObject followObject;
private float zoomMax = 100, zoomMin = 0;
/**
- * Trauma is used internally to inform the magnitude of camera shake.
+ * Trauma is used internally to inform the magnitude of camera shake. Decays at
+ * traumaDecay per second.
*/
- private float trauma = 0, traumaDecay = 0.015f;
+ private float trauma = 0, traumaDecay = 0.9f;
+ /** Shake at full trauma: max translation (px) and rotation (radians). */
+ private static final float SHAKE_MAX_OFFSET = 150, SHAKE_MAX_ROTATION = 0.35f;
+ /** How quickly the shake moves (noise units per second). */
+ private static final float SHAKE_FREQUENCY = 20;
/**
* The most basic constructor. Initialises the camera at position (0, 0).
@@ -201,45 +207,61 @@ public void release() {
*/
private void update() {
offset = new PVector(applet.width / 2, applet.height / 2);
+ final float lerpAmount = Time.smoothing(smoothing);
if (zoom != zoomTarget) {
- zoom = PApplet.lerp(zoom, zoomTarget, lerpSpeed);
+ zoom = PApplet.lerp(zoom, zoomTarget, lerpAmount);
if (PApplet.abs(zoom - zoomTarget) < 0.0025) { //
zoom = zoomTarget;
}
}
- rotation = PApplet.lerp(rotation, rotationTarget, lerpSpeed);
+ rotation = PApplet.lerp(rotation, rotationTarget, lerpAmount);
applet.translate(offset.x, offset.y);
applet.rotate(rotation + shakeRotationOffset);
applet.translate(-offset.x, -offset.y);
transform();
- float scale = PApplet.lerp((float) getZoomScaleX(), zoom, lerpSpeed);
+ float scale = PApplet.lerp((float) getZoomScaleX(), zoom, lerpAmount);
setZoomScaleX(scale);
setZoomScaleY(scale);
+ // smooth the un-shaken pan offset (last frame's shake is removed first)
+ float panX = getPanOffset().x + shakeOffset.x;
+ float panY = getPanOffset().y + shakeOffset.y;
if (following && ((deadZoneScreen && !withinScreenDeadZone()) || ((deadZoneWorld && !withinWorldDeadZone())) || (!deadZoneScreen && !deadZoneWorld))) {
- setPanOffset(PApplet.lerp(getPanOffset().x, ((-followObject.position.x - followObjectOffset.x + offset.x) * zoom), lerpSpeed) - shakeOffset.x,
- PApplet.lerp(getPanOffset().y, ((-followObject.position.y - followObjectOffset.y + offset.y) * zoom), lerpSpeed) - shakeOffset.y);
+ panX = PApplet.lerp(panX, ((-followObject.position.x - followObjectOffset.x + offset.x) * zoom), lerpAmount);
+ panY = PApplet.lerp(panY, ((-followObject.position.y - followObjectOffset.y + offset.y) * zoom), lerpAmount);
} else if (!following) {
- setPanOffset(PApplet.lerp(getPanOffset().x, ((targetPosition.x + offset.x) * zoom), lerpSpeed) - shakeOffset.x,
- PApplet.lerp(getPanOffset().y, ((targetPosition.y + offset.y) * zoom), lerpSpeed) - shakeOffset.y);
+ panX = PApplet.lerp(panX, ((targetPosition.x + offset.x) * zoom), lerpAmount);
+ panY = PApplet.lerp(panY, ((targetPosition.y + offset.y) * zoom), lerpAmount);
}
+ updateShake();
+ setPanOffset(panX - shakeOffset.x, panY - shakeOffset.y);
logicalPosition = PVector.mult(PVector.sub(getPanOffset(), new PVector(applet.width / 2 * zoom, applet.height / 2 * zoom)), -1 / zoom);
+ }
- if (trauma > 0) { // 50 and 0.35 seem suitable values
- trauma -= traumaDecay;
+ /**
+ * Decays trauma and computes this frame's shake offsets. Shake follows smooth
+ * noise sampled over (game) time, so it looks the same at any frame rate.
+ */
+ private void updateShake() {
+ trauma = Math.max(0, trauma - traumaDecay * Time.delta());
+ final float magnitude = trauma * trauma;
+ final float t = Time.millis() / 1000f * SHAKE_FREQUENCY;
+ shakeOffset.set(magnitude * shakeNoise(0, t) * SHAKE_MAX_OFFSET, magnitude * shakeNoise(1, t) * SHAKE_MAX_OFFSET);
+ shakeRotationOffset = magnitude * shakeNoise(2, t) * SHAKE_MAX_ROTATION;
+ }
- float x = (trauma * trauma) * applet.random(-1, 1) * 50;
- float y = (trauma * trauma) * applet.random(-1, 1) * 50;
- shakeOffset = new PVector(x, y);
- shakeRotationOffset = (trauma * trauma) * applet.random(-1, 1) * 0.35f;
- if (trauma == 0) {
- shakeOffset = new PVector(0, 0);
- shakeRotationOffset = 0;
- }
- }
+ /**
+ * Smooth noise centred on 0. Scaled so its spread roughly matches a uniform
+ * random value in [-1, 1].
+ *
+ * @param channel independent noise stream (one per shake axis)
+ * @param t noise time
+ */
+ private float shakeNoise(int channel, float t) {
+ return (applet.noise(channel * 100, t) - 0.5f) * 4;
}
/**
@@ -392,8 +414,9 @@ public void setCameraPosition(PVector position) {
*/
public void setCameraPositionNoLerp(PVector position) {
following = false;
- PVector temp = PVector.sub(position, offset);
+ PVector temp = PVector.sub(position, new PVector(applet.width / 2, applet.height / 2)); // offset may not be set yet
this.setPanOffset(-temp.x, -temp.y);
+ shakeOffset.set(0, 0); // pan offset is now un-shaken
this.targetPosition = new PVector(-position.x, -position.y);
}
@@ -440,16 +463,17 @@ public void zoomOut(float amount) {
}
/**
- * Specify lerp (linear interpolation) speed for camera motion. Default = 0.05.
- * Since the lerp is calculated per-frame (after prior motion), the camera
- * motion is effectively non-linear. Smaller values provide a smoother, less
+ * Specify the smoothing rate for camera motion (per second). Default =
+ * {@link Constants#CAMERA_SMOOTHING}. The camera closes the fraction
+ * {@code 1 - e^(-rate * t)} of the distance to its target after {@code t}
+ * seconds, regardless of frame rate. Smaller values provide a smoother, less
* snappy, slower camera.
*
- * @param lerpSpeed Range = [0-1.0]
- * @see {@link PApplet#lerp(float, float, float) lerp()}
+ * @param smoothing smoothing rate (per second)
+ * @see Time#smoothing(float)
*/
- public void setLerpSpeed(float lerpSpeed) {
- this.lerpSpeed = lerpSpeed;
+ public void setSmoothing(float smoothing) {
+ this.smoothing = smoothing;
}
/**
diff --git a/src/main/java/project_16x16/Constants.java b/src/main/java/project_16x16/Constants.java
index 5fdc85c7..520006f8 100644
--- a/src/main/java/project_16x16/Constants.java
+++ b/src/main/java/project_16x16/Constants.java
@@ -14,10 +14,17 @@
*/
public final class Constants {
- public static final float CAMERA_LERP = 0.05f;
+ /** Camera motion smoothing rate (per second); see {@link Time#smoothing(float)}. */
+ public static final float CAMERA_SMOOTHING = 3.08f;
public static final float CAMERA_ZOOM_MAX = 3.0f;
public static final float CAMERA_ZOOM_MIN = 0.3f;
- public static final float GAME_GRAVITY = 1;
+ /** Downwards acceleration of entities (px/s²). */
+ public static final float GAME_GRAVITY = 3600;
+ /**
+ * Longest physics step (seconds). Longer frames are integrated in several
+ * sub-steps so fast-moving entities can't pass through collisions.
+ */
+ public static final float PHYSICS_MAX_STEP = 1 / 120f;
public static final String GAME_FONT = "Font/font-pixel-48.vlw";
@@ -35,6 +42,7 @@ public final class Constants {
public static class Colors {
public static final int MENU_GREY = colorToRGB(29, 33, 45);
+ public static final int BLACK = colorToRGB(0, 0, 0);
}
diff --git a/src/main/java/project_16x16/Options.java b/src/main/java/project_16x16/Options.java
index 1e428532..0c55e007 100644
--- a/src/main/java/project_16x16/Options.java
+++ b/src/main/java/project_16x16/Options.java
@@ -57,9 +57,15 @@ public static enum Option {
// Graphics
TARGET_FPS,
UI_SCALE,
+ FULLSCREEN,
+ WINDOW_WIDTH,
+ WINDOW_HEIGHT,
+ MENU_PARTICLES,
// Audio
- GAIN_BGM,
+ GAIN_BGM, // legacy (single music volume); seeds the menu/game music volumes
+ GAIN_MENU_BGM,
+ GAIN_GAME_BGM,
GAIN_SFX,
MUTE_BGM,
MUTE_SFX,
@@ -139,13 +145,20 @@ public static class DefaultKeys {
// Graphics settings
public static float uiScale = options.getInt(Option.UI_SCALE.toString(), 1);
public static int targetFrameRate = options.getInt(Option.TARGET_FPS.toString(), 60);
+ public static boolean fullscreen = options.getBoolean(Option.FULLSCREEN.toString(), false);
+ /** Windowed-mode window size (px); 0 = sized automatically from the display scale. */
+ public static int windowWidth = options.getInt(Option.WINDOW_WIDTH.toString(), 0);
+ public static int windowHeight = options.getInt(Option.WINDOW_HEIGHT.toString(), 0);
+ /** Density of the menu background particles: 0 = low, 1 = medium, 2 = high. */
+ public static int menuParticles = options.getInt(Option.MENU_PARTICLES.toString(), 1);
// Game settings
public static int snapSize = options.getInt(Option.SNAP_SIZE.toString(), 32);
public static int debugMode = options.getInt(Option.DEBUG_MODE.toString(), 2);
// Audio settings
- public static float gainBGM = options.getFloat(Option.GAIN_BGM.toString(), 0);
+ public static float gainMenuBGM = options.getFloat(Option.GAIN_MENU_BGM.toString(), options.getFloat(Option.GAIN_BGM.toString(), 0));
+ public static float gainGameBGM = options.getFloat(Option.GAIN_GAME_BGM.toString(), options.getFloat(Option.GAIN_BGM.toString(), 0));
public static float gainSFX = options.getFloat(Option.GAIN_SFX.toString(), 0);
public static boolean muteBGM = options.getBoolean(Option.MUTE_BGM.toString(), false);
public static boolean muteSFX = options.getBoolean(Option.MUTE_SFX.toString(), false);
diff --git a/src/main/java/project_16x16/SideScroller.java b/src/main/java/project_16x16/SideScroller.java
index fd517a63..0431a420 100644
--- a/src/main/java/project_16x16/SideScroller.java
+++ b/src/main/java/project_16x16/SideScroller.java
@@ -4,22 +4,28 @@
import java.util.HashSet;
import javafx.application.Platform;
+import javafx.event.EventHandler;
import javafx.geometry.Rectangle2D;
+import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
+import javafx.scene.canvas.GraphicsContext;
+import javafx.scene.image.PixelFormat;
+import javafx.scene.image.WritableImage;
import javafx.scene.input.KeyCombination;
-import javafx.scene.transform.Scale;
+import javafx.scene.layout.Pane;
+import javafx.scene.paint.Color;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import processing.core.PApplet;
import processing.core.PFont;
+import processing.core.PImage;
import processing.core.PSurface;
import processing.core.PVector;
import processing.event.MouseEvent;
import processing.javafx.PSurfaceFX;
import project_16x16.Options.Option;
-import project_16x16.components.AnimationComponent;
import project_16x16.entities.Player;
import project_16x16.multiplayer.Multiplayer;
import project_16x16.scene.AudioSettings;
@@ -35,6 +41,7 @@
import project_16x16.scene.PScene;
import project_16x16.scene.PauseMenu;
import project_16x16.scene.Settings;
+import project_16x16.ui.MenuBackground;
import project_16x16.ui.Notifications;
/**
@@ -67,11 +74,21 @@ public static DebugType get(int value) {
public static final boolean SNAP = true; // snap objects to grid when moving; TODO move to options
public static int snapSize;
- public static long startTime;
// Game Rendering
- private PVector windowSize = new PVector(1280, 720); // Game window size -- to be set via options
- public PVector gameResolution = new PVector(1280, 720); // Game rendering resolution
+ private PVector windowSize = new PVector(1280, 720); // Base window size, before display scaling
+ /** Windowed-mode window (content) size, restored when leaving fullscreen. */
+ private int windowedWidth = 1280, windowedHeight = 720;
+ /**
+ * The game's logical resolution: all drawing and mouse coordinates are in these
+ * units, whatever the window size. Rendering is scaled up (or down) from it to
+ * the window's own resolution -- see {@link #scaleResolution()}.
+ */
+ public PVector gameResolution = new PVector(1280, 720);
+ /** Window pixels per game-resolution unit. */
+ private float renderScale = 1;
+ /** Offset (window pixels) of the game area within the window, if letterboxed. */
+ private float renderOffsetX = 0, renderOffsetY = 0;
/** Framerate target/cap; the actual framerate's limit. */
public static float targetFramerate;
// Font Resources
@@ -79,7 +96,11 @@ public static DebugType get(int value) {
// Scenes
private ArrayDeque sceneHistory;
- private int sceneSwapTime = 0;
+ /** When the scene last changed (game time, ms). */
+ private long sceneSwapTime = 0;
+ /** Minimum time between scene changes (ms) -- debounces repeated input. */
+ private static final long SCENE_SWAP_DEBOUNCE_MILLIS = 100;
+ private static final float SCENE_FADE_MILLIS = 230;
private static MainMenu menu;
private static GameplayScene game;
@@ -171,12 +192,28 @@ protected PSurface initSurface() {
stage.setFullScreenExitHint(""); // disable fullscreen toggle hint
stage.setFullScreenExitKeyCombination(KeyCombination.NO_MATCH); // prevent ESC toggling fullscreen
scene.getWindow().addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, this::closeWindowEvent);
+ scene.setFill(Color.BLACK); // letterbox bars
+ // keep the game scaled to fill the window as its size changes (resize, fullscreen)
+ scene.widthProperty().addListener(o -> scaleResolution());
+ scene.heightProperty().addListener(o -> scaleResolution());
+ stage.fullScreenProperty().addListener((o, wasFullscreen, isFullscreen) -> {
+ if (!isFullscreen) { // restore the chosen window size (it may have changed while fullscreen)
+ setWindowSize(windowedWidth, windowedHeight);
+ }
+ });
- Screen screen = Screen.getPrimary();
- double scaleX = screen.getOutputScaleX();
- double scaleY = screen.getOutputScaleY();
- double scale = Double.parseDouble(System.getProperty("ui.scale", String.valueOf(Math.min(scaleX, scaleY))));
- changeScale((float) scale);
+ if (Options.windowWidth > 0 && Options.windowHeight > 0) {
+ setWindowSize(Options.windowWidth, Options.windowHeight);
+ } else {
+ Screen screen = Screen.getPrimary();
+ double scaleX = screen.getOutputScaleX();
+ double scaleY = screen.getOutputScaleY();
+ double scale = Double.parseDouble(System.getProperty("ui.scale", String.valueOf(Math.min(scaleX, scaleY))));
+ changeScale((float) scale);
+ }
+ if (Options.fullscreen) {
+ setFullscreen(true);
+ }
return surface;
}
@@ -217,8 +254,8 @@ public void setup() {
// Main Load
load();
- AnimationComponent.assignApplet(this);
Notifications.assignApplet(this);
+ MenuBackground.assignApplet(this);
Audio.assignApplet(this);
// Create scenes
@@ -240,11 +277,13 @@ public void setup() {
camera.setMouseMask(CONTROL);
camera.setMinZoomScale(Constants.CAMERA_ZOOM_MIN);
camera.setMaxZoomScale(Constants.CAMERA_ZOOM_MAX);
+ camera.setCameraPositionNoLerp(game.getPlayer().position); // start on the player...
+ camera.setFollowObject(game.getPlayer()); // ...and track them
scaleResolution();
launchIntoMultiplayer(); // multi is conditional on program args
- startTime = System.currentTimeMillis(); // game starttime occurs at setup end
+ Time.reset(); // game time starts once loading is complete
}
/**
@@ -265,14 +304,15 @@ private void load() {
* @see #returnScene()
*/
public void swapToScene(GameScenes newScene) {
- if (frameCount - sceneSwapTime > 6 || frameCount == 0) {
+ final boolean inSetup = frameCount == 0; // no debounce while setting up
+ if (inSetup || Time.millis() - sceneSwapTime > SCENE_SWAP_DEBOUNCE_MILLIS) {
if (!newScene.equals(sceneHistory.peek())) { // if different
if (!sceneHistory.isEmpty()) {
sceneHistory.peek().getScene().switchFrom(); // switch from
}
sceneHistory.push(newScene);
newScene.getScene().switchTo();
- sceneSwapTime = frameCount;
+ sceneSwapTime = Time.millis();
}
}
}
@@ -286,8 +326,24 @@ public void returnScene() {
if (sceneHistory.size() > 1) {
sceneHistory.pop().getScene().switchFrom();
sceneHistory.peek().getScene().switchTo();
- sceneSwapTime = frameCount;
+ sceneSwapTime = Time.millis();
+ }
+ }
+
+ /**
+ * @return true if the current scene belongs to a game in progress (such as the
+ * pause menu, or settings opened from it), rather than to the main menu
+ */
+ public boolean isGameInProgress() {
+ for (GameScenes scene : sceneHistory) { // most recent first
+ if (scene == GameScenes.MAIN_MENU) {
+ return false;
+ }
+ if (scene == GameScenes.GAME || scene == GameScenes.PAUSE_MENU) {
+ return true;
+ }
}
+ return false;
}
/**
@@ -296,11 +352,22 @@ public void returnScene() {
*/
@Override
public void draw() {
+ Time.tick();
frameRate(targetFramerate);
+
+ // draw in game-resolution units, scaled to the window
+ translate(renderOffsetX, renderOffsetY);
+ scale(renderScale);
+ final GraphicsContext context = canvas.getGraphicsContext2D();
+ // keep the pixel-art world crisp when magnified; smooth it when shrunk (else it shimmers)
+ context.setImageSmoothing(renderScale * camera.getZoomScale() < 1);
camera.hook();
drawBelowCamera();
camera.release();
+ context.setImageSmoothing(true); // UI text is drawn from bitmap glyphs, which need smoothing
drawAboveCamera();
+ drawLetterbox();
+ MenuBackground.endFrame();
rectMode(CENTER);
@@ -343,6 +410,7 @@ private void drawAboveCamera() {
return;
}
sceneHistory.peek().getScene().drawUI();
+ drawSceneFade();
Notifications.run();
if (debug == DebugType.ALL) {
camera.post();
@@ -353,6 +421,64 @@ private void drawAboveCamera() {
}
}
+ /**
+ * Blacks out the window outside the game area (when the window's aspect ratio
+ * differs from the game's), covering anything drawn beyond the game's edges.
+ */
+ private void drawLetterbox() {
+ if (renderOffsetX < 0.5f && renderOffsetY < 0.5f) {
+ return;
+ }
+ pushStyle();
+ resetMatrix(); // window pixels
+ noStroke();
+ fill(0);
+ rectMode(CORNER);
+ final float gameW = gameResolution.x * renderScale;
+ final float gameH = gameResolution.y * renderScale;
+ rect(0, 0, renderOffsetX, g.height); // left
+ rect(renderOffsetX + gameW, 0, g.width, g.height); // right
+ rect(0, 0, g.width, renderOffsetY); // top
+ rect(0, renderOffsetY + gameH, g.width, g.height); // bottom
+ popStyle();
+ }
+
+ /**
+ * Fades in from black after a scene change.
+ */
+ private void drawSceneFade() {
+ float progress = (Time.millis() - sceneSwapTime) / SCENE_FADE_MILLIS;
+ if (progress >= 1 || progress < 0) {
+ return;
+ }
+ pushStyle();
+ noStroke();
+ fill(0, 255 * (1 - progress));
+ rectMode(CORNER);
+ rect(0, 0, width, height);
+ popStyle();
+ }
+
+ /**
+ * Captures what has been drawn so far this frame, at game resolution. Unlike
+ * {@link #get()}, this is the game area only (excluding any letterboxing),
+ * scaled from the window's resolution to game resolution.
+ */
+ public PImage captureFrame() {
+ final WritableImage snapshot = canvas.snapshot(null, null);
+ final int w = Math.min((int) snapshot.getWidth(), Math.round(gameResolution.x * renderScale));
+ final int h = Math.min((int) snapshot.getHeight(), Math.round(gameResolution.y * renderScale));
+ final PImage frame = createImage(w, h, ARGB);
+ frame.loadPixels();
+ snapshot.getPixelReader().getPixels(Math.round(renderOffsetX), Math.round(renderOffsetY), w, h, PixelFormat.getIntArgbInstance(), frame.pixels, 0,
+ w);
+ frame.updatePixels();
+ if (w != width || h != height) {
+ frame.resize(width, height);
+ }
+ return frame;
+ }
+
/**
* keyPressed decides if the key that has been pressed is a valid key. if it is,
* it is then added to the keys ArrayList, and the keyPressedEvent flag is set.
@@ -395,10 +521,9 @@ public void keyReleased(processing.event.KeyEvent event) {
} else if (keyCode == Options.notifyKey) {
Notifications.addNotification("Hello", "World");
} else if (keyCode == Options.toggleFullscreenKey) {
- noLoop();
- stage.setFullScreen(!stage.isFullScreen());
- scaleResolution();
- loop();
+ Options.fullscreen = !isFullscreen();
+ Options.save(Option.FULLSCREEN, Options.fullscreen);
+ setFullscreen(Options.fullscreen);
} else if (keyCode == Options.toggleDebugKey) {
debug = debug.next();
Options.save(Option.DEBUG_MODE, debug.ordinal());
@@ -422,6 +547,17 @@ public void mouseReleased() {
mouseReleaseEvent = true;
}
+ /**
+ * Converts mouse positions from window pixels to game-resolution units, so
+ * mouseX/mouseY (and the events scenes receive) match what is drawn.
+ */
+ @Override
+ protected void handleMouseEvent(MouseEvent e) {
+ final int x = (int) Math.floor((e.getX() - renderOffsetX) / renderScale);
+ final int y = (int) Math.floor((e.getY() - renderOffsetY) / renderScale);
+ super.handleMouseEvent(new MouseEvent(e.getNative(), e.getMillis(), e.getAction(), e.getModifiers(), x, y, e.getButton(), e.getCount()));
+ }
+
/**
* Handles scrolling events.
*/
@@ -476,12 +612,100 @@ public PVector getMouseCoordScreen() {
return new PVector(mouseX, mouseY);
}
- public void resizeWindow(int width, int height) {
- windowSize = new PVector(width, height);
- gameResolution = windowSize.copy();
- stage.setWidth(width); // sceneWidth is not bound, so doesn't change
- stage.setHeight(height);
-// stage.setScene(new Scene(new StackPane(canvas), width, height)); // TODO
+ /** @return window pixels per game-resolution unit */
+ public float getRenderScale() {
+ return renderScale;
+ }
+
+ /** @return horizontal offset (window pixels) of the game area, if letterboxed */
+ public float getRenderOffsetX() {
+ return renderOffsetX;
+ }
+
+ /** @return vertical offset (window pixels) of the game area, if letterboxed */
+ public float getRenderOffsetY() {
+ return renderOffsetY;
+ }
+
+ /**
+ * Adds a JavaFX node behind the game's canvas. It shows wherever the canvas is
+ * transparent (see {@link #clearCanvas()}).
+ */
+ public void addUnderlay(Node node) {
+ ((Pane) scene.getRoot()).getChildren().add(0, node);
+ }
+
+ /** Clears the whole canvas to transparent, revealing any underlay. */
+ public void clearCanvas() {
+ final GraphicsContext context = canvas.getGraphicsContext2D();
+ context.save();
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ context.clearRect(0, 0, canvas.getWidth(), canvas.getHeight());
+ context.restore();
+ }
+
+ public boolean isFullscreen() {
+ return stage.isFullScreen();
+ }
+
+ /**
+ * Enters or leaves fullscreen mode, once the current frame has finished.
+ */
+ public void setFullscreen(boolean fullscreen) {
+ afterFrame(() -> stage.setFullScreen(fullscreen));
+ }
+
+ /** @return the width of the window in windowed mode (px) */
+ public int getWindowWidth() {
+ return windowedWidth;
+ }
+
+ /** @return the height of the window in windowed mode (px) */
+ public int getWindowHeight() {
+ return windowedHeight;
+ }
+
+ /**
+ * Sets the size of the window's content area in windowed mode, shrinking it
+ * (keeping its aspect ratio) if needed to fit the screen. If the game is
+ * fullscreen, the size applies once it leaves fullscreen. The game is scaled
+ * to fill the window. Takes effect once the current frame has finished.
+ */
+ public void setWindowSize(int width, int height) {
+ windowedWidth = width;
+ windowedHeight = height;
+ afterFrame(() -> {
+ if (stage.isFullScreen()) {
+ return;
+ }
+ Rectangle2D screen = Screen.getPrimary().getVisualBounds();
+ // window decorations (titlebar/borders) are unscaled and unknown until shown
+ double decoW = Double.isNaN(stage.getWidth()) ? 0 : stage.getWidth() - scene.getWidth();
+ double decoH = Double.isNaN(stage.getHeight()) ? 0 : stage.getHeight() - scene.getHeight();
+ double fit = Math.min(1, Math.min((screen.getWidth() - decoW) / width, (screen.getHeight() - decoH) / height));
+ stage.setWidth(Math.round(width * fit) + decoW);
+ stage.setHeight(Math.round(height * fit) + decoH);
+ stage.centerOnScreen();
+ });
+ }
+
+ /**
+ * Runs a change to the window after the current frame (or once the window is
+ * first shown). Changing the window can process further frames before
+ * returning, and Processing exits if a frame starts within another frame.
+ */
+ private void afterFrame(Runnable action) {
+ if (stage.isShowing()) {
+ Platform.runLater(action);
+ return;
+ }
+ stage.addEventHandler(WindowEvent.WINDOW_SHOWN, new EventHandler() {
+ @Override
+ public void handle(WindowEvent e) {
+ stage.removeEventHandler(WindowEvent.WINDOW_SHOWN, this);
+ action.run();
+ }
+ });
}
public void resizeGameResolution(int width, int height) {
@@ -490,54 +714,42 @@ public void resizeGameResolution(int width, int height) {
}
/**
- * Scales the game rendering (as defined by gameResolution) to fill the current
- * stage size. Should be called whenever stage size or game resolution is
- * changed - currently called only when toggling fullscreen mode.
+ * Fits the game to the current window: the canvas takes the window's full
+ * resolution, and drawing is scaled from game resolution to fit it (see
+ * {@link #draw()}), preserving the aspect ratio and letterboxing any spare
+ * space. Rendering at the window's resolution (rather than rendering at game
+ * resolution and scaling the image) keeps large windows sharp. Called
+ * automatically whenever the window size changes.
*/
private void scaleResolution() {
- if (canvas == null || scene == null) {
+ if (canvas == null || scene == null || scene.getWidth() <= 0 || scene.getHeight() <= 0) {
return;
}
+ final int w = (int) Math.round(scene.getWidth());
+ final int h = (int) Math.round(scene.getHeight());
canvas.getTransforms().clear();
- canvas.setTranslateX(-scene.getWidth() / 2 + gameResolution.x / 2); // recenters after scale
- canvas.setTranslateY(-scene.getHeight() / 2 + gameResolution.y / 2); // recenters after scale
- if (!(scene.getWidth() == gameResolution.x && scene.getHeight() == gameResolution.y)) {
- canvas.setWidth(gameResolution.x);
- canvas.setHeight(gameResolution.y);
- width = (int) gameResolution.x;
- height = (int) gameResolution.y;
- final double scaleX = scene.getWidth() / gameResolution.x;
- final double scaleY = scene.getHeight() / gameResolution.y;
- canvas.getTransforms().setAll(new Scale(scaleX, scaleY)); // scale canvas
- }
+ canvas.setTranslateX(0);
+ canvas.setTranslateY(0);
+ canvas.setWidth(w);
+ canvas.setHeight(h);
+ // Processing resizes the sketch along with the canvas, but sketch code works
+ // in game-resolution units -- only the renderer covers the whole canvas
+ g.setSize(w, h);
+ width = (int) gameResolution.x;
+ height = (int) gameResolution.y;
+ renderScale = Math.min(w / gameResolution.x, h / gameResolution.y);
+ renderOffsetX = (w - gameResolution.x * renderScale) / 2;
+ renderOffsetY = (h - gameResolution.y * renderScale) / 2;
}
-
+
/**
- * Changes game UI scaling. Akin to 'glass.win.uiScale' system property, but
- * adjustable during runtime. The window is always sized from the (unscaled)
- * window size so the content keeps its aspect ratio, and is shrunk if needed to
- * fit the screen.
+ * Sizes the window from the base window size and a display scale factor (akin
+ * to the 'glass.win.uiScale' system property), shrinking it if needed to fit
+ * the screen.
*/
private void changeScale(float newScale) {
- Rectangle2D screen = Screen.getPrimary().getVisualBounds();
- // window decorations (titlebar/borders) are unscaled and unknown until shown
- Runnable apply = () -> {
- double decoW = Double.isNaN(stage.getWidth()) ? 0 : stage.getWidth() - scene.getWidth();
- double decoH = Double.isNaN(stage.getHeight()) ? 0 : stage.getHeight() - scene.getHeight();
- float fit = (float) Math.min(newScale, Math.min((screen.getWidth() - decoW) / windowSize.x, (screen.getHeight() - decoH) / windowSize.y));
- stage.setWidth(windowSize.x * fit + decoW);
- stage.setHeight(windowSize.y * fit + decoH);
- stage.centerOnScreen();
- // canvas is scaled to fill the scene by scaleResolution(), not by scaling the root
- Platform.runLater(this::scaleResolution);
- Options.uiScale = fit;
- Options.save(Option.UI_SCALE, fit);
- };
- if (stage.isShowing()) {
- apply.run();
- } else {
- stage.setOnShown(e -> apply.run());
- }
+ Options.uiScale = newScale;
+ setWindowSize(Math.round(windowSize.x * newScale), Math.round(windowSize.y * newScale));
}
private void displayDebugInfo() {
@@ -610,7 +822,7 @@ private void displayDebugInfo() {
text("['F11']", width - ip, lineOffset * 23 + yOffset);
text("['TAB']", width - ip, lineOffset * 24 + yOffset);
- if (frameRate >= 59.5) {
+ if (frameRate >= targetFramerate - 0.5f) {
fill(0, 255, 0);
} else {
fill(255, 0, 0);
diff --git a/src/main/java/project_16x16/Time.java b/src/main/java/project_16x16/Time.java
new file mode 100644
index 00000000..5ce22794
--- /dev/null
+++ b/src/main/java/project_16x16/Time.java
@@ -0,0 +1,126 @@
+package project_16x16;
+
+/**
+ * Global game clock. {@link #tick()} is called once at the start of every frame
+ * (see {@link SideScroller#draw()}), so every system sees the same frame time
+ * regardless of the frame rate.
+ *
+ * Conventions:
+ *
+ *
Timestamps and durations are in milliseconds ({@link #millis()},
+ * {@link #deltaMillis()}).
+ *
Rates (speeds, accelerations, smoothing) are per second, and are
+ * integrated with {@link #delta()} (seconds).
+ *
+ *
+ * The frame delta is clamped to {@link #MAX_DELTA_MILLIS}, so a stall (window
+ * drag, level load, GC pause) advances the game by one bounded step rather
+ * than teleporting objects. Consequently {@link #millis()} is game time, and
+ * may fall behind wall-clock time.
+ *
+ * @author micycle1
+ */
+public final class Time {
+
+ /**
+ * Longest frame the game will simulate; longer frames are treated as this long
+ * (i.e. below 10 FPS the game runs in slow motion).
+ */
+ static final long MAX_DELTA_MILLIS = 100;
+ private static final long MAX_DELTA_NANOS = MAX_DELTA_MILLIS * 1_000_000;
+
+ private static long lastNanos = System.nanoTime();
+ private static long gameNanos = 0;
+ private static long deltaNanos = 0;
+ private static float deltaSeconds = 0;
+
+ private Time() {
+ }
+
+ /**
+ * Restarts the clock at zero. Call once loading is complete, so that the time
+ * spent loading isn't counted as the first frame.
+ */
+ public static void reset() {
+ lastNanos = System.nanoTime();
+ gameNanos = 0;
+ deltaNanos = 0;
+ deltaSeconds = 0;
+ }
+
+ /**
+ * Advances the clock by the real time elapsed since the previous tick. Call
+ * exactly once per frame, before any game logic.
+ */
+ public static void tick() {
+ final long now = System.nanoTime();
+ advance(now - lastNanos);
+ lastNanos = now;
+ }
+
+ static void advance(long elapsedNanos) {
+ deltaNanos = Math.max(0, Math.min(elapsedNanos, MAX_DELTA_NANOS));
+ deltaSeconds = deltaNanos / 1e9f;
+ gameNanos += deltaNanos;
+ }
+
+ /**
+ * @return game time elapsed since the clock was {@link #reset()} (ms)
+ */
+ public static long millis() {
+ return gameNanos / 1_000_000;
+ }
+
+ /**
+ * @return duration of the current frame (ms)
+ */
+ public static float deltaMillis() {
+ return deltaNanos / 1e6f;
+ }
+
+ /**
+ * @return duration of the current frame (seconds). Multiply per-second rates by
+ * this to get the per-frame amount.
+ */
+ public static float delta() {
+ return deltaSeconds;
+ }
+
+ /**
+ * Frame-rate independent replacement for a fixed per-frame lerp amount. Moving
+ * a value towards its target by this amount every frame closes the fraction
+ * {@code 1 - e^(-rate * t)} of the gap after {@code t} seconds, whatever the
+ * frame rate.
+ *
+ * To convert a lerp amount {@code a} tuned at 60 FPS:
+ * {@code rate = -ln(1 - a) * 60}.
+ *
+ * @param rate smoothing rate (per second); higher is snappier
+ * @return lerp amount [0, 1) for this frame
+ */
+ public static float smoothing(float rate) {
+ return 1 - (float) Math.exp(-rate * deltaSeconds);
+ }
+
+ /**
+ * Smoothly moves {@code current} towards {@code target}.
+ *
+ * @param rate smoothing rate (per second)
+ * @see #smoothing(float)
+ */
+ public static float damp(float current, float target, float rate) {
+ return current + (target - current) * smoothing(rate);
+ }
+
+ /**
+ * Number of equal sub-steps to split this frame into, such that no sub-step
+ * exceeds {@code maxStep}. Used to keep physics (collision detection in
+ * particular) stable when the frame rate is low.
+ *
+ * @param maxStep longest allowed step (seconds)
+ * @return number of steps (at least 1); each lasts {@code delta() / steps}
+ */
+ public static int substeps(float maxStep) {
+ return Math.max(1, (int) Math.ceil(deltaSeconds / maxStep));
+ }
+}
diff --git a/src/main/java/project_16x16/components/AnimationComponent.java b/src/main/java/project_16x16/components/AnimationComponent.java
index 5a732b0f..bf2d7d52 100644
--- a/src/main/java/project_16x16/components/AnimationComponent.java
+++ b/src/main/java/project_16x16/components/AnimationComponent.java
@@ -8,21 +8,24 @@
import processing.core.PImage;
import project_16x16.Audio;
import project_16x16.Audio.SFX;
-import project_16x16.SideScroller;
+import project_16x16.Time;
/**
- * The Animation Class
+ * The Animation Class. Owners advance it with {@link #update()} from their
+ * update logic, and draw {@link #getFrame()}. Animations are purely visual:
+ * gameplay timing (attack duration, etc.) should be kept by the owner, not
+ * derived from when an animation ends.
*/
public class AnimationComponent {
- private static SideScroller applet;
private ArrayList frames;
private boolean loop;
private int length;
- private int rate;
- private int start;
- private int firstFrame;
- private float currentFrame;
+ /** How long each image is displayed (ms). */
+ private float frameMillis;
+ /** Time the current image has been displayed for (ms). */
+ private float frameElapsed;
+ private int currentFrame;
public String name;
public boolean ended;
private final Map> sounds = new HashMap<>();
@@ -30,70 +33,64 @@ public class AnimationComponent {
public AnimationComponent() {
}
- public static void assignApplet(SideScroller applet) {
- AnimationComponent.applet = applet;
- }
-
/**
* The most simple method to change current animation sequence.
*
- * @param frames PImage frame sequence.
- * @param loop Whether the animation should loop.
- * @param rate Every x frames the next frame is loaded.
+ * @param frames PImage frame sequence.
+ * @param loop Whether the animation should loop.
+ * @param frameMillis How long each frame is displayed (ms).
*/
- public void changeAnimation(ArrayList frames, boolean loop, int rate) {
- changeAnimation(frames, loop, rate, frames.size() - 1);
+ public void changeAnimation(ArrayList frames, boolean loop, float frameMillis) {
+ changeAnimation(frames, loop, frameMillis, frames.size() - 1);
}
/**
* A method to change current animation sequence. Can specify animation frame
* length.
*
- * @param frames PImage frame sequence.
- * @param loop Whether the animation should loop.
- * @param rate Every x frames the next frame is loaded.
- * @param length Set a custom anim length
+ * @param frames PImage frame sequence.
+ * @param loop Whether the animation should loop.
+ * @param frameMillis How long each frame is displayed (ms).
+ * @param length Set a custom anim length
*/
- public void changeAnimation(ArrayList frames, boolean loop, int rate, int length) {
+ public void changeAnimation(ArrayList frames, boolean loop, float frameMillis, int length) {
this.frames = frames;
this.loop = loop;
- this.rate = rate;
+ this.frameMillis = frameMillis;
this.length = length;
- start = 0;
- currentFrame = start;
- firstFrame = applet.frameCount;
+ currentFrame = 0;
+ frameElapsed = 0;
+ ended = false;
}
/**
- * This method controls the animation of elements (cycles through frames).
- *
- * @return PImage image
+ * Advances the animation by the current frame's duration. Call once per game
+ * update (not from drawing code).
*/
- public PImage animate() {
- PImage frame = frames.get((int) currentFrame);
- if ((applet.frameCount - firstFrame) % rate == 0) {
- currentFrame++;
- if (currentFrame > length) {
- if (!loop) {
- ended = true;
- }
+ public void update() {
+ frameElapsed += Time.deltaMillis();
+ while (frameElapsed >= frameMillis && !ended) {
+ frameElapsed -= frameMillis;
+ if (currentFrame < length) {
+ currentFrame++;
+ } else if (loop) {
currentFrame = 0;
+ } else {
+ ended = true; // hold the last frame
+ break;
+ }
+ List coll = sounds.get(currentFrame);
+ if (coll != null) {
+ coll.forEach(Audio::play);
}
}
- List coll = sounds.get((int) currentFrame);
- if (coll != null) {
- coll.forEach(sound -> Audio.play(sound));
- }
- return frame;
}
/**
- * Return current frame without animating further.
- *
- * @return
+ * @return the image to draw for the current animation frame
*/
public PImage getFrame() {
- return frames.get((int) currentFrame);
+ return frames.get(currentFrame);
}
/**
@@ -102,16 +99,16 @@ public PImage getFrame() {
* @return The number of remaining frames as an int
*/
public int remainingFrames() {
- return (int) (length - currentFrame);
+ return length - currentFrame;
}
/**
* Retrieves the current frame ID
*
- * @return the current frame as a float
+ * @return the current frame
**/
public int getFrameID() {
- return (int) currentFrame;
+ return currentFrame;
}
/**
@@ -122,6 +119,7 @@ public int getFrameID() {
public void setFrame(int frame) {
if (frame >= 0 && frame <= frames.size() - 1) {
currentFrame = frame;
+ frameElapsed = 0;
}
}
@@ -135,7 +133,7 @@ public int getAnimLength() {
}
/**
- * Set a SFX to play trigger at a given animation frame. TODO frames param
+ * Set a SFX to play when the animation advances to a given frame.
*
* @param sound
* @param frameNumber
diff --git a/src/main/java/project_16x16/entities/CleanerRobot.java b/src/main/java/project_16x16/entities/CleanerRobot.java
index 710db334..e3cb2b2a 100644
--- a/src/main/java/project_16x16/entities/CleanerRobot.java
+++ b/src/main/java/project_16x16/entities/CleanerRobot.java
@@ -2,6 +2,7 @@
import processing.core.PVector;
import project_16x16.SideScroller;
+import project_16x16.Time;
import project_16x16.scene.GameplayScene;
/**
@@ -30,8 +31,8 @@ public CleanerRobot(SideScroller sideScroller, GameplayScene gameplayScene, PVec
public void update() {
super.update();
- velocity.set(velocity.x, velocity.y + gravity);
- if (getDistance(target, position) < 10) {
+ // arrival radius covers a whole frame's movement, so low frame rates can't overshoot it
+ if (getDistance(target, position) < Math.max(10, speedWalk * Time.delta())) {
if (target == posA) {
target = posB;
} else {
diff --git a/src/main/java/project_16x16/entities/Enemy.java b/src/main/java/project_16x16/entities/Enemy.java
index 4ba21327..ea926b82 100644
--- a/src/main/java/project_16x16/entities/Enemy.java
+++ b/src/main/java/project_16x16/entities/Enemy.java
@@ -5,9 +5,11 @@
import processing.core.PImage;
import processing.core.PVector;
import processing.data.JSONObject;
+import project_16x16.Constants;
import project_16x16.SideScroller;
import project_16x16.SideScroller.DebugType;
import project_16x16.Tileset;
+import project_16x16.Time;
import project_16x16.Utility;
import project_16x16.objects.CollidableObject;
import project_16x16.objects.EditableObject;
@@ -23,14 +25,17 @@ public class Enemy extends CollidableObject {
private PImage image;
- float gravity;
+ float gravity; // px/s²
+ /** Velocity (px/s). */
final PVector velocity = new PVector(0, 0);
+ /** Displacement over the current physics step (px); reused to avoid allocation. */
+ private final PVector displacement = new PVector(0, 0);
private static final int collisionRange = 145;
- final int speedWalk;
- private final int speedJump;
+ final float speedWalk; // px/s
+ private final float speedJump; // px/s
public int health;
@@ -43,11 +48,11 @@ public class Enemy extends CollidableObject {
*/
public Enemy(SideScroller sideScroller, GameplayScene gameplayScene) {
super(sideScroller, gameplayScene);
- gravity = 1;
+ gravity = Constants.GAME_GRAVITY;
image = Tileset.getTile(0, 258, 14, 14, 4);
health = 2;
- speedWalk = 7;
- speedJump = 18;
+ speedWalk = 420;
+ speedJump = 1050;
width = 14 * 4;
height = 10 * 4;
enemyState = new EnemyState();
@@ -79,13 +84,11 @@ public void display() {
* The update method handles updating the character.
*/
public void update() {
- // velocity.set(0, velocity.y + gravity);
-
- checkEnemyCollision();
- if (velocity.y != 0) {
- enemyState.flying = true;
+ final int steps = Time.substeps(Constants.PHYSICS_MAX_STEP);
+ final float dt = Time.delta() / steps;
+ for (int i = 0; i < steps; i++) {
+ step(dt);
}
- position.add(velocity);
if (position.y > 2000) { // out of bounds check
// Destroy(gameObject);
}
@@ -107,6 +110,22 @@ public EnemyState getState() {
return enemyState;
}
+ /**
+ * Advances the enemy's motion by one physics step, resolving collisions.
+ *
+ * @param dt step duration (seconds)
+ */
+ private void step(float dt) {
+ displacement.set(velocity.x * dt, velocity.y * dt + 0.5f * gravity * dt * dt);
+ velocity.y += gravity * dt;
+
+ checkEnemyCollision();
+ if (velocity.y != 0) {
+ enemyState.flying = true;
+ }
+ position.add(displacement);
+ }
+
private void checkEnemyCollision() {
for (EditableObject o : gameplayScene.objects) {
if (o.equals(this)) {
@@ -122,7 +141,7 @@ private void checkEnemyCollision() {
applet.ellipse(collision.position.x, collision.position.y, 5, 5);
applet.noFill();
}
- if (collidesFuturX(collision)) {
+ if (collidesAfterMove(collision, displacement.x, 0)) {
// enemy left of collision
if (position.x < collision.position.x) {
position.x = collision.position.x - collision.width / 2 - width / 2;
@@ -131,9 +150,10 @@ private void checkEnemyCollision() {
position.x = collision.position.x + collision.width / 2 + width / 2;
}
velocity.x = 0;
+ displacement.x = 0;
enemyState.dashing = false;
}
- if (collidesFuturY(collision)) {
+ if (collidesAfterMove(collision, 0, displacement.y)) {
// enemy above collision
if (position.y < collision.position.y) {
if (enemyState.flying) {
@@ -147,6 +167,7 @@ private void checkEnemyCollision() {
enemyState.jumping = false;
}
velocity.y = 0;
+ displacement.y = 0;
}
}
}
@@ -173,25 +194,15 @@ private boolean collidesEqual(CollidableObject collision) {
&& position.y - height / 2 <= collision.position.y + collision.height / 2);
}
- private boolean collidesFutur(CollidableObject collision) {
- return (position.x + velocity.x + width / 2 > collision.position.x - collision.width / 2
- && position.x + velocity.x - width / 2 < collision.position.x + collision.width / 2)
- && (position.y + velocity.y + height / 2 > collision.position.y - collision.height / 2
- && position.y + velocity.y - height / 2 < collision.position.y + collision.height / 2);
- }
-
- private boolean collidesFuturX(CollidableObject collision) {
- return (position.x + velocity.x + width / 2 > collision.position.x - collision.width / 2
- && position.x + velocity.x - width / 2 < collision.position.x + collision.width / 2)
- && (position.y + 0 + height / 2 > collision.position.y - collision.height / 2
- && position.y + 0 - height / 2 < collision.position.y + collision.height / 2);
- }
-
- private boolean collidesFuturY(CollidableObject collision) {
- return (position.x + 0 + width / 2 > collision.position.x - collision.width / 2
- && position.x + 0 - width / 2 < collision.position.x + collision.width / 2)
- && (position.y + velocity.y + height / 2 > collision.position.y - collision.height / 2
- && position.y + velocity.y - height / 2 < collision.position.y + collision.height / 2);
+ /**
+ * Determines whether the enemy would collide with an object after moving by
+ * (dx, dy).
+ */
+ private boolean collidesAfterMove(CollidableObject collision, float dx, float dy) {
+ return (position.x + dx + width / 2 > collision.position.x - collision.width / 2
+ && position.x + dx - width / 2 < collision.position.x + collision.width / 2)
+ && (position.y + dy + height / 2 > collision.position.y - collision.height / 2
+ && position.y + dy - height / 2 < collision.position.y + collision.height / 2);
}
public class EnemyState {
diff --git a/src/main/java/project_16x16/entities/Player.java b/src/main/java/project_16x16/entities/Player.java
index 6153ba30..3c840aea 100644
--- a/src/main/java/project_16x16/entities/Player.java
+++ b/src/main/java/project_16x16/entities/Player.java
@@ -13,6 +13,7 @@
import project_16x16.SideScroller;
import project_16x16.SideScroller.DebugType;
import project_16x16.Tileset;
+import project_16x16.Time;
import project_16x16.Utility;
import project_16x16.components.AnimationComponent;
import project_16x16.objects.CollidableObject;
@@ -36,13 +37,20 @@ public final class Player extends EditableObject {
private final PImage lifeOn;
private final PImage lifeOff;
+ /** Velocity (px/s). */
private final PVector velocity = new PVector(0, 0);
+ /** Displacement over the current physics step (px); reused to avoid allocation. */
+ private final PVector displacement = new PVector(0, 0);
private static final int COLLISION_RANGE = 145;
private static final float DASH_MULTIPLIER = 1.5f; // Movement multiplier from holding dash key
+ private static final float JUMP_HEIGHT = 153; // px
+ /** How long an attack lasts (ms); the player can't attack again until it ends. */
+ private static final float ATTACK_MILLIS = 150;
+ private static final float DASH_ATTACK_MILLIS = 83; // attacks recover faster while dashing
- private final int speedWalk;
- private final int speedJump;
+ private final float speedWalk; // px/s
+ private final float speedJump; // px/s
private final boolean isMultiplayerPlayer;
@@ -52,6 +60,11 @@ public final class Player extends EditableObject {
public ArrayList swings; // Player Projectile TODO make private
public AnimationComponent animation; // Animation Component. TODO make private
+ /** Time left in the current attack (ms). */
+ private float attackRemaining = 0;
+ /** Animation of the current attack, fixed when the attack starts. */
+ private ACTION attackAction = ACTION.ATTACK;
+
/**
* Cache PImage animation sequences (rather than loading from JSON)
*/
@@ -99,8 +112,8 @@ public Player(SideScroller sideScroller, GameplayScene gameplayScene, boolean is
lifeCapacity = 6;
life = 3;
- speedWalk = 7;
- speedJump = 18;
+ speedWalk = 420;
+ speedJump = (float) Math.sqrt(2 * Constants.GAME_GRAVITY * JUMP_HEIGHT); // launch speed that peaks at JUMP_HEIGHT
width = 14 * 4;
height = 16 * 4;
@@ -129,10 +142,8 @@ public void display() {
}
if (isMultiplayerPlayer) {
applet.tint(255, 125, 0);
- image = animation.getFrame();
- } else {
- image = animation.animate();
}
+ image = animation.getFrame();
applet.image(image, 0, 0);
applet.noTint();
applet.popMatrix();
@@ -148,17 +159,18 @@ public void display() {
* The update method handles updating the character.
*/
public void update() {
- velocity.set(0, velocity.y + Constants.GAME_GRAVITY);
+ velocity.x = 0;
handleKeyboardInput();
handleMouseInput();
- checkPlayerCollision();
- if (velocity.y != 0) {
- state.flying = true;
+ final int steps = Time.substeps(Constants.PHYSICS_MAX_STEP);
+ final float dt = Time.delta() / steps;
+ for (int i = 0; i < steps; i++) {
+ step(dt);
}
- position.add(velocity);
+ animation.update();
chooseAnimation();
if (position.y > 2000) { // out of bounds check
position.set(0, -100); // TODO set to spawn loc PVector
@@ -222,17 +234,43 @@ private void handleKeyboardInput() {
}
private void handleMouseInput() {
+ attackRemaining = Math.max(0, attackRemaining - Time.deltaMillis());
+ state.attacking = attackRemaining > 0;
if (applet.mousePressed && applet.mouseButton == LEFT && !state.attacking) { // Attack
state.attacking = true;
+ attackRemaining = state.dashing ? DASH_ATTACK_MILLIS : ATTACK_MILLIS;
+ attackAction = state.dashing ? ACTION.DASH_ATTACK : ACTION.ATTACK;
// Create Swing Projectile
swings.add(new Swing(applet, gameplayScene, (int) position.x, (int) position.y, state.facingDir));
}
for (Swing swing : swings) { // Update Swing Projectiles
swing.update();
}
- swings.removeIf(swing -> swing.animationEnded());
+ swings.removeIf(Swing::expired);
+ }
+
+ /**
+ * Advances the player's motion by one physics step, resolving collisions.
+ *
+ * @param dt step duration (seconds)
+ */
+ private void step(float dt) {
+ // exact for constant acceleration, so jump arcs don't depend on step size
+ displacement.set(velocity.x * dt, velocity.y * dt + 0.5f * Constants.GAME_GRAVITY * dt * dt);
+ velocity.y += Constants.GAME_GRAVITY * dt;
+
+ checkPlayerCollision();
+ if (velocity.y != 0) {
+ state.flying = true;
+ }
+ position.add(displacement);
}
+ /**
+ * Checks whether the player's next {@link #displacement} collides with nearby
+ * collision objects; if so, snaps the player to the object's edge and cancels
+ * motion along that axis.
+ */
private void checkPlayerCollision() {
for (EditableObject o : gameplayScene.objects) {
if (o instanceof CollidableObject) {
@@ -245,7 +283,7 @@ private void checkPlayerCollision() {
applet.ellipse(collision.position.x, collision.position.y, 5, 5);
applet.noFill();
}
- if (collidesFuturX(collision)) {
+ if (collidesAfterMove(collision, displacement.x, 0)) {
// player left of collision
if (position.x < collision.position.x) {
position.x = collision.position.x - collision.width / 2 - width / 2;
@@ -254,9 +292,10 @@ private void checkPlayerCollision() {
position.x = collision.position.x + collision.width / 2 + width / 2;
}
velocity.x = 0;
+ displacement.x = 0;
state.dashing = false;
}
- if (collidesFuturY(collision)) {
+ if (collidesAfterMove(collision, 0, displacement.y)) {
// player above collision
if (position.y < collision.position.y) {
if (state.flying) {
@@ -270,33 +309,24 @@ private void checkPlayerCollision() {
state.jumping = false;
}
velocity.y = 0;
+ displacement.y = 0;
}
}
}
}
}
+ /**
+ * Picks the animation that reflects the player's current state.
+ */
private void chooseAnimation() {
- // End animations
+ // The jump/land squashes are purely visual: they play once, then give way to
+ // the state-based animation. Gameplay state never waits on an animation.
if (animation.ended) {
- switch (animation.name) {
- case "DASH":
- state.dashing = false;
- break;
- case "DASH_ATTACK":
- state.dashing = false;
- state.attacking = false;
- break;
- case "ATTACK":
- state.attacking = false;
- break;
- case "JUMP":
- state.jumping = false;
- break;
- case "LAND":
- state.landing = false;
- default:
- break;
+ if (animation.name.equals(ACTION.JUMP.name())) {
+ state.jumping = false;
+ } else if (animation.name.equals(ACTION.LAND.name())) {
+ state.landing = false;
}
}
if (state.jumping) {
@@ -304,11 +334,7 @@ private void chooseAnimation() {
} else if (state.landing) {
setAnimation(ACTION.LAND);
} else if (state.attacking) {
- if (state.dashing) {
- setAnimation(ACTION.DASH_ATTACK);
- } else {
- setAnimation(ACTION.ATTACK);
- }
+ setAnimation(attackAction);
} else if (state.flying) {
setAnimation(ACTION.FALL);
} else if (velocity.x != 0) {
@@ -342,25 +368,15 @@ private boolean collidesEqual(CollidableObject collision) {
&& position.y - height / 2 <= collision.position.y + collision.height / 2);
}
- private boolean collidesFutur(CollidableObject collision) {
- return (position.x + velocity.x + width / 2 > collision.position.x - collision.width / 2
- && position.x + velocity.x - width / 2 < collision.position.x + collision.width / 2)
- && (position.y + velocity.y + height / 2 > collision.position.y - collision.height / 2
- && position.y + velocity.y - height / 2 < collision.position.y + collision.height / 2);
- }
-
- private boolean collidesFuturX(CollidableObject collision) {
- return (position.x + velocity.x + width / 2 > collision.position.x - collision.width / 2
- && position.x + velocity.x - width / 2 < collision.position.x + collision.width / 2)
- && (position.y + 0 + height / 2 > collision.position.y - collision.height / 2
- && position.y + 0 - height / 2 < collision.position.y + collision.height / 2);
- }
-
- private boolean collidesFuturY(CollidableObject collision) {
- return (position.x + 0 + width / 2 > collision.position.x - collision.width / 2
- && position.x + 0 - width / 2 < collision.position.x + collision.width / 2)
- && (position.y + velocity.y + height / 2 > collision.position.y - collision.height / 2
- && position.y + velocity.y - height / 2 < collision.position.y + collision.height / 2);
+ /**
+ * Determines whether the character would collide with an object after moving
+ * by (dx, dy).
+ */
+ private boolean collidesAfterMove(CollidableObject collision, float dx, float dy) {
+ return (position.x + dx + width / 2 > collision.position.x - collision.width / 2
+ && position.x + dx - width / 2 < collision.position.x + collision.width / 2)
+ && (position.y + dy + height / 2 > collision.position.y - collision.height / 2
+ && position.y + dy - height / 2 < collision.position.y + collision.height / 2);
}
public void setAnimation(String anim) {
@@ -380,28 +396,28 @@ private void setAnimation(ACTION anim) {
ArrayList animSequence = playerAnimationSequences.get(anim);
switch (anim) {
case WALK:
- animation.changeAnimation(animSequence, true, 6);
+ animation.changeAnimation(animSequence, true, 100);
break;
case IDLE:
- animation.changeAnimation(animSequence, true, 20);
+ animation.changeAnimation(animSequence, true, 333);
break;
case JUMP:
- animation.changeAnimation(animSequence, false, 4);
+ animation.changeAnimation(animSequence, false, 67);
break;
case LAND:
- animation.changeAnimation(animSequence, false, 2);
+ animation.changeAnimation(animSequence, false, 33);
break;
case FALL:
- animation.changeAnimation(animSequence, true, 20);
+ animation.changeAnimation(animSequence, true, 333);
break;
- case ATTACK:
- animation.changeAnimation(animSequence, false, 4);
+ case ATTACK: // spans the attack
+ animation.changeAnimation(animSequence, false, ATTACK_MILLIS / animSequence.size());
break;
case DASH:
- animation.changeAnimation(animSequence, false, 6);
+ animation.changeAnimation(animSequence, true, 100);
break;
case DASH_ATTACK:
- animation.changeAnimation(animSequence, false, 2);
+ animation.changeAnimation(animSequence, false, DASH_ATTACK_MILLIS / animSequence.size());
break;
}
animation.ended = false;
diff --git a/src/main/java/project_16x16/objects/EditableObject.java b/src/main/java/project_16x16/objects/EditableObject.java
index 1d54a450..59501ea4 100644
--- a/src/main/java/project_16x16/objects/EditableObject.java
+++ b/src/main/java/project_16x16/objects/EditableObject.java
@@ -43,6 +43,12 @@ enum ObjectType {
protected PVector editOffset;
+ /**
+ * Position when the current drag began; null if the object was created
+ * mid-drag (duplicated).
+ */
+ public PVector dragOrigin;
+
public EditableObject(SideScroller sideScroller, GameplayScene gameplayScene) {
super(sideScroller);
@@ -59,14 +65,19 @@ public EditableObject(SideScroller sideScroller, GameplayScene gameplayScene) {
/**
* Draws position edit arrows and bounding box if the object is selected
- * (focused) in MODIFY mode.
+ * (focused) in MODIFY mode. The box is red if the object overlaps another
+ * solid object (it'll return to where it was if released there).
*/
public void displayEdit() {
if (focus) { // focus = held by player
// draw border around object
applet.strokeWeight(10);
applet.noFill();
- applet.stroke(0, 255, 200);
+ if (gameplayScene.isBlocked(this)) {
+ applet.stroke(255, 60, 60);
+ } else {
+ applet.stroke(0, 255, 200);
+ }
applet.rect(position.x, position.y, width, height);
applet.strokeWeight(4); // reset style
}
@@ -121,7 +132,7 @@ public void updateEdit() {
try {
Class extends GameObject> gameObjectClass = Tileset.getObjectClass(id);
Constructor> ctor = gameObjectClass.getDeclaredConstructors()[0];
- copy = (GameObject) ctor.newInstance(new Object[] { applet, this });
+ copy = (GameObject) ctor.newInstance(new Object[] { applet, gameplayScene });
copy.focus = true;
copy.position = position.copy();
copy.editOffset = editOffset.copy();
@@ -151,6 +162,7 @@ public void updateEdit() {
public void focus() {
editOffset = PVector.sub(position, applet.getMouseCoordGame());
+ dragOrigin = position.copy();
focus = true;
}
@@ -163,7 +175,7 @@ public boolean isFocused() {
}
public boolean mouseHover() {
- if (applet.mouseX < 400 && applet.mouseY < 100) { // Over Inventory Bar -- rough approximation
+ if (gameplayScene.isOverUI(applet.getMouseCoordScreen())) {
return false;
}
return Utility.hoverGame(position.x, position.y, width, height);
diff --git a/src/main/java/project_16x16/objects/EditorItem.java b/src/main/java/project_16x16/objects/EditorItem.java
index 938d4474..8fa4d223 100644
--- a/src/main/java/project_16x16/objects/EditorItem.java
+++ b/src/main/java/project_16x16/objects/EditorItem.java
@@ -11,6 +11,11 @@
import project_16x16.components.Tile.TileType;
import project_16x16.scene.GameplayScene;
+/**
+ * A tile being dragged in the level editor (from the tile palette). Dropping it
+ * in the level places it there (unless it would overlap a solid object);
+ * dropping it on editor UI hands it to that UI (eg. the palette's favourites).
+ */
public class EditorItem extends PClass {
private PVector position;
@@ -21,8 +26,6 @@ public class EditorItem extends PClass {
public String id;
public TileType type;
- private String mode;
-
private GameplayScene gameplayScene;
public EditorItem(SideScroller sideScroller, GameplayScene gameplayScene) {
@@ -31,7 +34,6 @@ public EditorItem(SideScroller sideScroller, GameplayScene gameplayScene) {
this.gameplayScene = gameplayScene;
setTile("BOX");
- setMode("CREATE");
position = new PVector(0, 0);
}
@@ -47,55 +49,80 @@ public void update() {
position = applet.getMouseCoordScreen();
if (applet.mouseReleaseEvent) {
focus = false;
- if (mode.equals("CREATE")) {
- // transform from screen mouse pos to game pos
- // Create new instance from dragged icon
-
- PVector realPos = applet.camera.getDispToCoord(new PVector(Utility.roundToNearest(applet.getMouseCoordScreen().x, SideScroller.snapSize),
- Utility.roundToNearest(applet.getMouseCoordScreen().y, SideScroller.snapSize)));
- EditableObject c = null;
- switch (type) {
- case COLLISION:
- c = new CollidableObject(applet, gameplayScene, id, 0, 0);
- c.position.set(realPos);
- break;
- case BACKGROUND:
- c = new BackgroundObject(applet, gameplayScene, id, 0, 0);
- break;
- case OBJECT:
- try {
- Class extends GameObject> gameObjectClass = Tileset.getObjectClass(id);
- Constructor> ctor = gameObjectClass.getDeclaredConstructors()[0];
- c = (GameObject) ctor.newInstance(new Object[] { applet, gameplayScene });
- break;
- } catch (Exception e) {
- e.printStackTrace();
- }
- break;
- default:
- break;
- }
- if (c != null) {
- c.position.set(realPos);
- c.focus();
- gameplayScene.objects.add(c);
- }
- }
+ drop();
}
}
}
- public void displayDestination() {
- if (focus) {
- applet.strokeWeight(1);
- applet.stroke(0, 255, 200);
- applet.noFill();
- applet.rect(round((applet.getMouseCoordGame().x) / 4) * 4, round((applet.getMouseCoordGame().y) / 4) * 4, image.width, image.height);
+ private void drop() {
+ final PVector mouse = applet.getMouseCoordScreen();
+ if (gameplayScene.currentMode.dropTile(id, mouse) || gameplayScene.isOverUI(mouse) || !canPlace()) {
+ return; // cancelled
+ }
+
+ final PVector realPos = placement();
+ EditableObject c = null;
+ switch (type) {
+ case COLLISION:
+ c = new CollidableObject(applet, gameplayScene, id, 0, 0);
+ break;
+ case BACKGROUND:
+ c = new BackgroundObject(applet, gameplayScene, id, 0, 0);
+ break;
+ case OBJECT:
+ try {
+ Class extends GameObject> gameObjectClass = Tileset.getObjectClass(id);
+ Constructor> ctor = gameObjectClass.getDeclaredConstructors()[0];
+ c = (GameObject) ctor.newInstance(new Object[] { applet, gameplayScene });
+ break;
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ break;
+ default:
+ break;
+ }
+ if (c != null) {
+ c.position.set(realPos);
+ c.focus();
+ gameplayScene.objects.add(c);
}
}
- public void setMode(String m) {
- mode = m;
+ /**
+ * @return the (grid-snapped) world position the item would be placed at
+ */
+ private PVector placement() {
+ final PVector mouse = applet.getMouseCoordGame();
+ return new PVector(Utility.roundToNearest(mouse.x, SideScroller.snapSize), Utility.roundToNearest(mouse.y, SideScroller.snapSize));
+ }
+
+ /**
+ * Solid items can't be placed over other solid objects.
+ */
+ private boolean canPlace() {
+ if (type == TileType.BACKGROUND) {
+ return true;
+ }
+ final PVector p = placement();
+ return gameplayScene.isAreaFree(p.x, p.y, image.width, image.height, o -> false);
+ }
+
+ /**
+ * Outlines where the item would be placed: red if it can't be placed there.
+ */
+ public void displayDestination() {
+ if (focus && !gameplayScene.isOverUI(applet.getMouseCoordScreen())) {
+ final PVector p = placement();
+ applet.strokeWeight(2);
+ if (canPlace()) {
+ applet.stroke(0, 255, 200);
+ } else {
+ applet.stroke(255, 60, 60);
+ }
+ applet.noFill();
+ applet.rect(p.x, p.y, image.width, image.height);
+ }
}
public void setTile(String name) {
diff --git a/src/main/java/project_16x16/objects/MagicSourceObject.java b/src/main/java/project_16x16/objects/MagicSourceObject.java
index 4439ec8e..e5d9abf0 100644
--- a/src/main/java/project_16x16/objects/MagicSourceObject.java
+++ b/src/main/java/project_16x16/objects/MagicSourceObject.java
@@ -6,6 +6,7 @@
import processing.core.PImage;
import project_16x16.SideScroller;
import project_16x16.Tileset;
+import project_16x16.Time;
import project_16x16.Utility;
import project_16x16.particleSystem.ParticleSystem;
import project_16x16.particleSystem.emissions.AreaEmission;
@@ -33,7 +34,7 @@ public MagicSourceObject(SideScroller sideScroller, GameplayScene gameplayScene)
}
trail = new ParticleSystem(sideScroller, image, 5, 1, 0.4f);
- trail.setEmission(new AreaEmission(position, 1f, -0.01f, 5));
+ trail.setEmission(new AreaEmission(position, 60, -36, 5));
trail.addEventListener(new ParticleAnimationController(particleAnimation, -1));
width = 48;
@@ -48,10 +49,10 @@ public void display() {
trail.run();
}
- // oldMillis is used to calculate the difference in time between shots.
+ // lastShotTime is the game time (ms) of the previous shot.
// shotDelay denotes the "fire rate" of the MagicSource in milliseconds.
- int oldMillis = 0;
- int shotDelay = 500;
+ long lastShotTime = -shotDelay;
+ static final int shotDelay = 500;
@Override
public void update() {
@@ -60,8 +61,8 @@ public void update() {
Swing swing = gameplayScene.getPlayer().swings.get(i);
if (collidesWithSwing(swing)) {
if (!swing.activated) {
- if (applet.millis() > oldMillis + shotDelay) {
- oldMillis = applet.millis();
+ if (Time.millis() > lastShotTime + shotDelay) {
+ lastShotTime = Time.millis();
gameplayScene.projectileObjects.add(new MagicProjectile(applet, gameplayScene, (int) position.x, (int) position.y, swing.direction));
diff --git a/src/main/java/project_16x16/objects/MirrorBoxObject.java b/src/main/java/project_16x16/objects/MirrorBoxObject.java
index 11bb36ad..f7e37075 100644
--- a/src/main/java/project_16x16/objects/MirrorBoxObject.java
+++ b/src/main/java/project_16x16/objects/MirrorBoxObject.java
@@ -1,8 +1,12 @@
package project_16x16.objects;
+import java.util.ArrayList;
+
import processing.core.PApplet;
+import processing.core.PImage;
import project_16x16.SideScroller;
import project_16x16.Tileset;
+import project_16x16.Time;
import project_16x16.projectiles.MagicProjectile;
import project_16x16.projectiles.ProjectileObject;
import project_16x16.projectiles.Swing;
@@ -20,6 +24,9 @@ public class MirrorBoxObject extends GameObject {
final int BOX_UP = 3;
final int BOX_W = 64;
final int BOX_H = 64;
+ /** How long a rotation takes (ms); the new direction applies once it completes. */
+ static final float ROTATE_MILLIS = 117;
+ private float rotateRemaining = 0;
public MirrorBoxObject(SideScroller sideScroller, GameplayScene gameplayScene) {
super(sideScroller, gameplayScene);
@@ -60,7 +67,9 @@ public void display() {
@Override
public void update() {
if (rotating) {
- image = animation.animate();
+ rotateRemaining -= Time.deltaMillis();
+ animation.update();
+ image = animation.getFrame();
}
collision.position = position;
@@ -70,8 +79,10 @@ public void update() {
if (collidesWithSwing(swing)) {
if (!swing.activated) {
rotating = true;
- // Setup Animation
- animation.changeAnimation(Tileset.getAnimation("MIRROR_BOX::ROTATE"), false, 1);
+ rotateRemaining = ROTATE_MILLIS;
+ // Setup Animation (spans the rotation)
+ ArrayList frames = Tileset.getAnimation("MIRROR_BOX::ROTATE");
+ animation.changeAnimation(frames, false, ROTATE_MILLIS / frames.size());
swing.activated = true;
}
}
@@ -102,11 +113,10 @@ public void activateMirrorBox(boolean checkHit) {
}
public void setMirrorDirection() {
- if (animation.ended && rotating) {
+ if (rotating && rotateRemaining <= 0) {
image = Tileset.getTile("MIRROR_BOX");
rotating = false;
direction = (direction + 1) % 4;// Allow rotation with the use of modulus
- animation.ended = false;
}
}
diff --git a/src/main/java/project_16x16/particleSystem/Particle.java b/src/main/java/project_16x16/particleSystem/Particle.java
index 4df5e160..9ba2c5ad 100644
--- a/src/main/java/project_16x16/particleSystem/Particle.java
+++ b/src/main/java/project_16x16/particleSystem/Particle.java
@@ -5,6 +5,7 @@
import processing.core.PImage;
import processing.core.PVector;
import project_16x16.SideScroller;
+import project_16x16.Time;
/**
* Particle
@@ -19,20 +20,21 @@ public class Particle {
public PImage image;
public PVector position;
+ /** px/s */
public PVector velocity;
+ /** px/s² */
public PVector acceleration;
public float size = 40; // TODO: create better way to control
public boolean useCustomeSize = false;
- public float maxLifespan; // lifespan of particle when it was spawned
- public float lifespan;
- public int frameCount;
+ public float maxLifespan; // lifespan of particle when it was spawned (seconds)
+ public float lifespan; // remaining lifespan (seconds)
+ public float age; // time since spawn (seconds)
public Particle(SideScroller applet, PImage image) {
this.applet = applet;
this.image = image;
- frameCount = 0;
}
public void spawn(Consumer consumer, float lifespan) {
@@ -52,10 +54,11 @@ public void run() {
}
private void update() {
- velocity.add(acceleration);
- position.add(velocity);
- lifespan -= 1.0;
- frameCount++;
+ final float dt = Math.min(Time.delta(), lifespan); // don't simulate beyond death
+ position.add(velocity.x * dt + 0.5f * acceleration.x * dt * dt, velocity.y * dt + 0.5f * acceleration.y * dt * dt);
+ velocity.add(acceleration.x * dt, acceleration.y * dt);
+ lifespan -= dt;
+ age += dt;
}
private void draw() {
@@ -74,5 +77,6 @@ private void draw() {
private void setLifespan(float lifespan) {
maxLifespan = lifespan;
this.lifespan = lifespan;
+ age = 0;
}
}
diff --git a/src/main/java/project_16x16/particleSystem/ParticlePreloadSystem.java b/src/main/java/project_16x16/particleSystem/ParticlePreloadSystem.java
index 8af70c11..32462115 100644
--- a/src/main/java/project_16x16/particleSystem/ParticlePreloadSystem.java
+++ b/src/main/java/project_16x16/particleSystem/ParticlePreloadSystem.java
@@ -7,7 +7,7 @@
/**
* Particle Preload System
*
- * Preloads the particles position, velocity, lifespan and frameCount. It only
+ * Preloads the particles position, velocity, lifespan and age. It only
* takes into account the particles spawn position, velocity and acceleration.
* Runtime changes like collision and outside forces will not be taken into
* affect.
@@ -16,20 +16,23 @@
*/
public class ParticlePreloadSystem {
- public static Consumer preload(int frames) {
+ /**
+ * @param seconds how long the particle should appear to have been alive for
+ */
+ public static Consumer preload(float seconds) {
return p -> {
- p.lifespan -= frames;
- p.frameCount = frames;
+ p.lifespan -= seconds;
+ p.age = seconds;
if (!p.isDead()) {
- p.position.add(positionDeltaIntegral(p, frames));
- p.velocity.add(p.acceleration.copy().mult(frames));
+ p.position.add(positionDelta(p, seconds));
+ p.velocity.add(PVector.mult(p.acceleration, seconds));
}
};
}
- private static PVector positionDeltaIntegral(Particle particle, int frames) {
- float deltaX = (float) (particle.position.x + particle.velocity.x * frames + 0.5 * particle.acceleration.x * frames * frames);
- float deltaY = (float) (particle.position.y + particle.velocity.y * frames + 0.5 * particle.acceleration.y * frames * frames);
+ private static PVector positionDelta(Particle particle, float t) {
+ float deltaX = particle.velocity.x * t + 0.5f * particle.acceleration.x * t * t;
+ float deltaY = particle.velocity.y * t + 0.5f * particle.acceleration.y * t * t;
return new PVector(deltaX, deltaY);
}
}
diff --git a/src/main/java/project_16x16/particleSystem/ParticleSystem.java b/src/main/java/project_16x16/particleSystem/ParticleSystem.java
index f1375ab6..dc5cfc5b 100644
--- a/src/main/java/project_16x16/particleSystem/ParticleSystem.java
+++ b/src/main/java/project_16x16/particleSystem/ParticleSystem.java
@@ -22,8 +22,6 @@
*/
public class ParticleSystem {
- public static final int FRAMERATE = 60;
-
private SideScroller applet;
public PImage image;
public ParticleEmission emission;
@@ -76,9 +74,9 @@ public void run() {
}
public void preLoad() {
- for (int i = 0; i < lifespan * FRAMERATE; i += FRAMERATE / spawnRate) {
+ for (float t = 0; t < lifespan; t += 1f / spawnRate) {
for (int k = 0; k < spawnAmount; k++) {
- ParticlePreloadSystem.preload(i).accept(particles.newParticle());
+ ParticlePreloadSystem.preload(t).accept(particles.newParticle());
}
}
}
diff --git a/src/main/java/project_16x16/particleSystem/Particles.java b/src/main/java/project_16x16/particleSystem/Particles.java
index f41c3b11..b17779d8 100644
--- a/src/main/java/project_16x16/particleSystem/Particles.java
+++ b/src/main/java/project_16x16/particleSystem/Particles.java
@@ -3,6 +3,7 @@
import java.util.ArrayList;
import project_16x16.SideScroller;
+import project_16x16.Time;
/**
* Particles
@@ -20,6 +21,9 @@ public class Particles {
public ArrayList activeParticles;
public ArrayList inactiveParticles;
+ /** Time until the next spawn (seconds); spawns immediately at first. */
+ private float untilNextSpawn = 0;
+
public Particles(ParticleSystem particleSystem, SideScroller applet) {
this.applet = applet;
this.particleSystem = particleSystem;
@@ -30,8 +34,13 @@ public Particles(ParticleSystem particleSystem, SideScroller applet) {
public void run() {
runParticles();
- if (nextTick()) {
- spawnParticles(particleSystem.spawnAmount);
+ if (particleSystem.spawn) {
+ untilNextSpawn -= Time.delta();
+ // catch up on every spawn due this frame (possibly several at low frame rates)
+ while (particleSystem.spawn && untilNextSpawn <= 0) {
+ spawnParticles(particleSystem.spawnAmount);
+ untilNextSpawn += 1f / particleSystem.spawnRate;
+ }
}
particleSystem.onUpdateEvent();
@@ -43,7 +52,7 @@ public boolean hasActiveParticles() {
public Particle newParticle() {
Particle particle = new Particle(applet, particleSystem.image);
- particle.spawn(particleSystem.getEmissionConsumer(), particleSystem.lifespan * ParticleSystem.FRAMERATE);
+ particle.spawn(particleSystem.getEmissionConsumer(), particleSystem.lifespan);
particleSystem.onParticleSpawnEvent(particle);
activeParticles.add(particle);
return particle;
@@ -88,11 +97,7 @@ private int loopParticles(int amount) {
}
private void respawnParticle(Particle particle) {
- particle.spawn(particleSystem.getEmissionConsumer(), particleSystem.lifespan * ParticleSystem.FRAMERATE);
+ particle.spawn(particleSystem.getEmissionConsumer(), particleSystem.lifespan);
particleSystem.onParticleSpawnEvent(particle);
}
-
- private boolean nextTick() {
- return particleSystem.spawn && applet.frameCount % (ParticleSystem.FRAMERATE / particleSystem.spawnRate) == 0;
- }
}
diff --git a/src/main/java/project_16x16/particleSystem/emissions/ArchEmission.java b/src/main/java/project_16x16/particleSystem/emissions/ArchEmission.java
index 5172a365..4eb42d23 100644
--- a/src/main/java/project_16x16/particleSystem/emissions/ArchEmission.java
+++ b/src/main/java/project_16x16/particleSystem/emissions/ArchEmission.java
@@ -32,8 +32,8 @@ public class ArchEmission implements ParticleEmission {
*
* @param position PVector position, set to a active entities PVector for
* the particle system to follow
- * @param velocity Start velocity of particle in the arch direction;
- * @param acceleration Start acceleration of particle in the arch direction;
+ * @param velocity Start velocity of particle in the arch direction (px/s).
+ * @param acceleration Start acceleration of particle in the arch direction (px/s²).
* @param spread Deviation from spawn position
* @param minAngle minAngle (radians)
* @param maxAngle maxAngle (radians)
diff --git a/src/main/java/project_16x16/particleSystem/emissions/AreaEmission.java b/src/main/java/project_16x16/particleSystem/emissions/AreaEmission.java
index 86021246..544e7c63 100644
--- a/src/main/java/project_16x16/particleSystem/emissions/AreaEmission.java
+++ b/src/main/java/project_16x16/particleSystem/emissions/AreaEmission.java
@@ -29,8 +29,8 @@ public class AreaEmission implements ParticleEmission {
*
* @param position PVector position, set to a active entities PVector for
* the particle system to follow
- * @param velocity Start velocity of particle in random direction;
- * @param acceleration Start acceleration of particle in random direction;
+ * @param velocity Start velocity of particle in random direction (px/s).
+ * @param acceleration Start acceleration of particle in random direction (px/s²).
* @param spread Deviation from spawn position
*/
public AreaEmission(PVector position, float velocity, float acceleration, float spread) {
diff --git a/src/main/java/project_16x16/particleSystem/emissions/DirectionalEmission.java b/src/main/java/project_16x16/particleSystem/emissions/DirectionalEmission.java
index bf649250..430023ad 100644
--- a/src/main/java/project_16x16/particleSystem/emissions/DirectionalEmission.java
+++ b/src/main/java/project_16x16/particleSystem/emissions/DirectionalEmission.java
@@ -31,8 +31,8 @@ public class DirectionalEmission implements ParticleEmission {
*
* @param position PVector position, set to a active entities PVector for
* the particle system to follow
- * @param velocity Start velocity of particle in random direction;
- * @param acceleration Start acceleration of particle in random direction;
+ * @param velocity Start velocity of particle in random direction (px/s).
+ * @param acceleration Start acceleration of particle in random direction (px/s²).
* @param spread Deviation from spawn position
* @param angle direction angle (radians)
*/
diff --git a/src/main/java/project_16x16/particleSystem/emissions/RectEmission.java b/src/main/java/project_16x16/particleSystem/emissions/RectEmission.java
index 2e845844..fdc152f8 100644
--- a/src/main/java/project_16x16/particleSystem/emissions/RectEmission.java
+++ b/src/main/java/project_16x16/particleSystem/emissions/RectEmission.java
@@ -30,8 +30,8 @@ public class RectEmission implements ParticleEmission {
*
* @param position PVector center position, set to a active entities PVector
* for the particle system to follow
- * @param velocity Start velocity of particle in random direction;
- * @param acceleration Start acceleration of particle in random direction;
+ * @param velocity Start velocity of particle in random direction (px/s).
+ * @param acceleration Start acceleration of particle in random direction (px/s²).
* @param width width of rect
* @param height height of rect
*/
diff --git a/src/main/java/project_16x16/particleSystem/emissions/RotationEmission.java b/src/main/java/project_16x16/particleSystem/emissions/RotationEmission.java
index 6614e219..ab2327ca 100644
--- a/src/main/java/project_16x16/particleSystem/emissions/RotationEmission.java
+++ b/src/main/java/project_16x16/particleSystem/emissions/RotationEmission.java
@@ -32,8 +32,8 @@ public class RotationEmission implements ParticleEmission {
*
* @param position PVector position, set to a active entities PVector for
* the particle system to follow
- * @param velocity Start velocity of particle in facing direction.
- * @param acceleration Start acceleration of particle in facing direction.
+ * @param velocity Start velocity of particle in facing direction (px/s).
+ * @param acceleration Start acceleration of particle in facing direction (px/s²).
* @param spread Deviation from spawn position
* @param div Angle increase for each particle (radians)
*/
diff --git a/src/main/java/project_16x16/particleSystem/events/ParticalVelocityController.java b/src/main/java/project_16x16/particleSystem/events/ParticalVelocityController.java
index ec13ce60..92e49069 100644
--- a/src/main/java/project_16x16/particleSystem/events/ParticalVelocityController.java
+++ b/src/main/java/project_16x16/particleSystem/events/ParticalVelocityController.java
@@ -19,7 +19,7 @@ public class ParticalVelocityController implements ParticleEventListener {
* Adds velocity to a particle on spawn. Useful is using a emission that only
* adds random or directional velocity
*
- * @param velocity PVector velocity
+ * @param velocity PVector velocity (px/s)
*/
public ParticalVelocityController(PVector velocity) {
this.velocity = velocity;
diff --git a/src/main/java/project_16x16/particleSystem/events/ParticleAccelerationController.java b/src/main/java/project_16x16/particleSystem/events/ParticleAccelerationController.java
index 756d0ce3..f6eed9d9 100644
--- a/src/main/java/project_16x16/particleSystem/events/ParticleAccelerationController.java
+++ b/src/main/java/project_16x16/particleSystem/events/ParticleAccelerationController.java
@@ -19,7 +19,7 @@ public class ParticleAccelerationController implements ParticleEventListener {
* Adds acceleration to a particle on spawn. Useful is using a emission that
* only adds random or directional acceleration.
*
- * @param acceleration PVector acceleration
+ * @param acceleration PVector acceleration (px/s²)
*/
public ParticleAccelerationController(PVector acceleration) {
this.acceleration = acceleration;
diff --git a/src/main/java/project_16x16/particleSystem/events/ParticleAnimationController.java b/src/main/java/project_16x16/particleSystem/events/ParticleAnimationController.java
index 53ccf706..8d280a42 100644
--- a/src/main/java/project_16x16/particleSystem/events/ParticleAnimationController.java
+++ b/src/main/java/project_16x16/particleSystem/events/ParticleAnimationController.java
@@ -17,29 +17,30 @@
public class ParticleAnimationController implements ParticleEventListener {
private ArrayList images;
- private int rate;
+ /** How long each image is displayed (ms), or -1 to span the particle's life. */
+ private int frameMillis;
/**
* Add animation to particle
*
* @param animationName animation name
- * @param rate animation speed, high value = slow speed, -1 = match
- * life span of particle
+ * @param frameMillis how long each image is displayed (ms), -1 = match life
+ * span of particle
*/
- public ParticleAnimationController(String animationName, int rate) {
- this(Tileset.getAnimation(animationName), rate);
+ public ParticleAnimationController(String animationName, int frameMillis) {
+ this(Tileset.getAnimation(animationName), frameMillis);
}
/**
* Add animation to particle
*
- * @param images animation ArrayList
- * @param rate animation speed, high value = slow speed, -1 = match life span
- * of particle
+ * @param images animation ArrayList
+ * @param frameMillis how long each image is displayed (ms), -1 = match life
+ * span of particle
*/
- public ParticleAnimationController(ArrayList images, int rate) {
+ public ParticleAnimationController(ArrayList images, int frameMillis) {
this.images = images;
- this.rate = rate;
+ this.frameMillis = frameMillis;
}
@Override
@@ -54,19 +55,19 @@ public void onParticleRunEvent(Particle particle) {
@Override
public ParticleEventListener copy() {
- return new ParticleAnimationController(images, rate);
+ return new ParticleAnimationController(images, frameMillis);
}
private void setParticle(Particle particle) {
- if (rate == -1) {
+ if (frameMillis == -1) {
particle.image = getImage(particle.maxLifespan, particle.lifespan);
} else {
- particle.image = getImage(particle.frameCount);
+ particle.image = getImage(particle.age);
}
}
- private PImage getImage(int frameCount) {
- int id = (frameCount / rate) % images.size();
+ private PImage getImage(float age) {
+ int id = (int) (age * 1000 / frameMillis) % images.size();
return images.get(id);
}
diff --git a/src/main/java/project_16x16/particleSystem/events/ParticleChildController.java b/src/main/java/project_16x16/particleSystem/events/ParticleChildController.java
index 1b36d11c..1e6d4d0d 100644
--- a/src/main/java/project_16x16/particleSystem/events/ParticleChildController.java
+++ b/src/main/java/project_16x16/particleSystem/events/ParticleChildController.java
@@ -3,6 +3,7 @@
import java.util.ArrayList;
import project_16x16.particleSystem.Particle;
+import project_16x16.Time;
import project_16x16.particleSystem.ParticleSystem;
/**
@@ -17,14 +18,14 @@ public class ParticleChildController implements ParticleEventListener {
private boolean hasDelay;
private boolean spawnOnDeath;
- private int delay;
+ private int delay; // ms
private ParticleSystem copySystem;
private ArrayList particleSystems;
/**
* @param particleSystem particle system need to have a no loop controller
- * @param delay how many frames till active
+ * @param delay particle age at which the child is spawned (ms)
*/
public ParticleChildController(ParticleSystem particleSystem, int delay) {
this.delay = delay;
@@ -36,7 +37,7 @@ public ParticleChildController(ParticleSystem particleSystem, int delay) {
/**
* @param particleSystem particle system need to have a no loop controller
- * @param delay how many frames till active
+ * @param delay particle age at which the child is spawned (ms)
* @param spawnOnDeath active when particle dies
*/
public ParticleChildController(ParticleSystem particleSystem, int delay, boolean spawnOnDeath) {
@@ -73,7 +74,8 @@ public void onUpdateEvent() {
@Override
public void onParticleRunEvent(Particle particle) {
- if (hasDelay && particle.frameCount == delay) {
+ final float ageMillis = particle.age * 1000;
+ if (hasDelay && ageMillis >= delay && ageMillis - Time.deltaMillis() < delay) { // crossed delay this frame
newChild(particle);
}
}
diff --git a/src/main/java/project_16x16/projectiles/MagicProjectile.java b/src/main/java/project_16x16/projectiles/MagicProjectile.java
index b9fb10b0..36012fb6 100644
--- a/src/main/java/project_16x16/projectiles/MagicProjectile.java
+++ b/src/main/java/project_16x16/projectiles/MagicProjectile.java
@@ -7,6 +7,7 @@
import processing.core.PVector;
import project_16x16.SideScroller;
import project_16x16.Tileset;
+import project_16x16.Time;
import project_16x16.Utility;
import project_16x16.objects.CollidableObject;
import project_16x16.objects.EditableObject;
@@ -19,7 +20,7 @@
public class MagicProjectile extends ProjectileObject {
final int SCALE = 4;
- final int PROJECTILE_SPEED = 10;
+ final int PROJECTILE_SPEED = 600; // px/s
final int PROJECTILE_IDLE_SIZE = 8;
private static ArrayList particleAnimation;
@@ -41,12 +42,12 @@ public MagicProjectile(SideScroller sideScroller, GameplayScene gameplayScene, i
setParticleAnimation(sideScroller);
}
- trail = new ParticleSystem(sideScroller, image, 40, 1, 0.2f);
- trail.setEmission(new AreaEmission(position, 0.8f, -0.01f, 8));
+ trail = new ParticleSystem(sideScroller, image, 60, 1, 0.2f);
+ trail.setEmission(new AreaEmission(position, 48, -36, 8));
trail.addEventListener(new ParticleAnimationController(particleAnimation, -1));
explode = new ParticleSystem(sideScroller, image, 15, 5, 0.4f);
- explode.setEmission(new AreaEmission(position, 3, -0.13f, 10));
+ explode.setEmission(new AreaEmission(position, 180, -468, 10));
explode.addEventListener(new ParticleAnimationController(particleAnimation, -1));
explode.addEventListener(new ParticleNoLoopController(10));
}
@@ -86,18 +87,19 @@ public void destroyProjectile() {
}
public void moveProjectile() {
+ final float distance = speed * Time.delta();
switch (direction) {
case LEFT:
- position.x -= speed;
+ position.x -= distance;
break;
case RIGHT:
- position.x += speed;
+ position.x += distance;
break;
case UP:
- position.y -= speed;
+ position.y -= distance;
break;
case DOWN:
- position.y += speed;
+ position.y += distance;
break;
}
}
diff --git a/src/main/java/project_16x16/projectiles/ProjectileObject.java b/src/main/java/project_16x16/projectiles/ProjectileObject.java
index a6c1e783..79e6ca9d 100644
--- a/src/main/java/project_16x16/projectiles/ProjectileObject.java
+++ b/src/main/java/project_16x16/projectiles/ProjectileObject.java
@@ -7,6 +7,7 @@
import processing.data.JSONObject;
import project_16x16.SideScroller;
import project_16x16.Tileset;
+import project_16x16.Time;
import project_16x16.components.AnimationComponent;
import project_16x16.objects.CollidableObject;
import project_16x16.objects.EditableObject;
@@ -14,6 +15,9 @@
public class ProjectileObject extends EditableObject {
+ /** How long projectiles live before being removed (ms). */
+ public static final long LIFETIME_MILLIS = 10_000;
+
public AnimationComponent animation;
public PVector position;
@@ -25,9 +29,11 @@ public class ProjectileObject extends EditableObject {
public int width;
public int height;
- public int spawnTime;
+ /** Game time at which the projectile was created (ms). */
+ public long spawnTime;
- public int speed;
+ /** px/s */
+ public float speed;
// Identification
public String id;
@@ -38,7 +44,7 @@ public ProjectileObject(SideScroller sideScroller, GameplayScene gameplayScene)
super(sideScroller, gameplayScene);
id = "";
- spawnTime = applet.frameCount;
+ spawnTime = Time.millis();
animation = new AnimationComponent();
position = new PVector(0, 0);
}
diff --git a/src/main/java/project_16x16/projectiles/Swing.java b/src/main/java/project_16x16/projectiles/Swing.java
index 4870ca3b..7b0e56e8 100644
--- a/src/main/java/project_16x16/projectiles/Swing.java
+++ b/src/main/java/project_16x16/projectiles/Swing.java
@@ -1,8 +1,12 @@
package project_16x16.projectiles;
+import java.util.ArrayList;
+
+import processing.core.PImage;
import processing.core.PVector;
import project_16x16.SideScroller;
import project_16x16.Tileset;
+import project_16x16.Time;
import project_16x16.components.AnimationComponent;
import project_16x16.scene.GameplayScene;
@@ -12,6 +16,10 @@ public class Swing extends ProjectileObject { // PClass
public boolean activated;
+ /** How long the swing's hitbox exists (ms). */
+ private static final float LIFETIME_MILLIS = 200;
+ private float age = 0; // ms
+
public Swing(SideScroller sideScroller, GameplayScene gameplayScene, int x, int y, int dir) {
super(sideScroller, gameplayScene);
animation = new AnimationComponent();
@@ -30,9 +38,10 @@ public Swing(SideScroller sideScroller, GameplayScene gameplayScene, int x, int
width = 28 * 4;
height = 9 * 4;
- // Setup Animation
- animation.changeAnimation(Tileset.getAnimation("Swing"), false, 4);
- update();
+ // Setup Animation (spans the swing's lifetime)
+ ArrayList frames = Tileset.getAnimation("Swing");
+ animation.changeAnimation(frames, false, LIFETIME_MILLIS / frames.size());
+ image = animation.getFrame();
}
@Override
@@ -42,16 +51,21 @@ public void display() {
if (direction == LEFT) {
applet.scale(-1, 1);
}
+ image = animation.getFrame();
applet.image(image, 0, 0);
applet.popMatrix();
}
@Override
public void update() {
- image = animation.animate();
+ age += Time.deltaMillis();
+ animation.update();
}
-
- public boolean animationEnded() {
- return animation.ended;
+
+ /**
+ * @return whether the swing has finished and should be removed
+ */
+ public boolean expired() {
+ return age >= LIFETIME_MILLIS;
}
}
diff --git a/src/main/java/project_16x16/scene/AudioSettings.java b/src/main/java/project_16x16/scene/AudioSettings.java
index 0febf4e7..d156a4e2 100644
--- a/src/main/java/project_16x16/scene/AudioSettings.java
+++ b/src/main/java/project_16x16/scene/AudioSettings.java
@@ -7,7 +7,8 @@
import project_16x16.Options;
import project_16x16.Options.Option;
import project_16x16.SideScroller;
-import project_16x16.ui.Button;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
import project_16x16.ui.Notifications;
import project_16x16.ui.Slider;
@@ -19,110 +20,133 @@
public final class AudioSettings extends PScene {
- private SideScroller game;
+ /** Gain (dB) used for a slider value of zero (effectively silent). */
+ private static final float MIN_GAIN = -80;
- private Button quit;
- private Button apply;
- private Slider volumeBGM;
- private Slider volumeSFX;
+ private final SideScroller game;
+ private final MenuNav nav;
+ private final Slider volumeMenuBGM;
+ private final Slider volumeGameBGM;
+ private final Slider volumeSFX;
- private float originalVolumeBGM;
+ private float originalVolumeMenuBGM;
+ private float originalVolumeGameBGM;
private float originalVolumeSFX;
public AudioSettings(SideScroller a) {
super(a);
-
game = a;
- apply = new Button(applet);
- apply.setText("Apply");
- apply.setPosition(a.width / 2, 500);
+ final int cx = a.width / 2;
+ nav = new MenuNav(a);
+ volumeMenuBGM = slider("Menu Music", cx, 230);
+ volumeGameBGM = slider("Game Music", cx, 305);
+ volumeSFX = slider("Effects", cx, 380);
+
+ nav.button("Apply", cx, 500, 360, 70, 32, this::apply);
+ nav.button("Back", cx, 590, 360, 70, 32, this::cancel);
+ }
+
+ private Slider slider(String text, int x, int y) {
+ Slider s = new Slider(game, 1);
+ s.setText(text);
+ s.setPosition(x, y);
+ nav.add(s, () -> {
+ });
+ return s;
+ }
+
+ private static float sliderToGain(float v) {
+ return v <= 0.001f ? MIN_GAIN : Math.max(MIN_GAIN, 20 * (float) Math.log10(v));
+ }
- quit = new Button(a);
- quit.setText("Quit");
- quit.setPosition(a.width / 2, 600);
+ private static float gainToSlider(float gain) {
+ return gain <= MIN_GAIN ? 0 : (float) Math.pow(10, gain / 20);
+ }
- volumeBGM = new Slider(game, 0.75f);
- volumeBGM.setText("BGM");
- volumeBGM.setPosition(a.width / 2, 300);
+ private void previewGain() {
+ Audio.setGainMenuBGM(sliderToGain(volumeMenuBGM.getValue()));
+ Audio.setGainGameBGM(sliderToGain(volumeGameBGM.getValue()));
+ Audio.setGainSFX(sliderToGain(volumeSFX.getValue()));
+ }
- volumeSFX = new Slider(game, 0.75f);
- volumeSFX.setText("SFX");
- volumeSFX.setPosition(a.width / 2, 350);
+ private void apply() {
+ float volMenuBGM = sliderToGain(volumeMenuBGM.getValue());
+ float volGameBGM = sliderToGain(volumeGameBGM.getValue());
+ float volSFX = sliderToGain(volumeSFX.getValue());
+ Options.save(Option.GAIN_MENU_BGM, volMenuBGM);
+ Options.save(Option.GAIN_GAME_BGM, volGameBGM);
+ Options.save(Option.GAIN_SFX, volSFX);
+ Options.gainMenuBGM = volMenuBGM;
+ Options.gainGameBGM = volGameBGM;
+ Options.gainSFX = volSFX;
+ Notifications.addNotification("Sound Settings Applied", "Your configuration has been successfully applied.");
+ game.returnScene();
+ }
+ /** Reverts any previewed volume changes and leaves. */
+ private void cancel() {
+ Audio.setGainMenuBGM(originalVolumeMenuBGM);
+ Audio.setGainGameBGM(originalVolumeGameBGM);
+ Audio.setGainSFX(originalVolumeSFX);
+ game.returnScene();
}
@Override
public void switchTo() {
- originalVolumeBGM = Options.gainBGM;
+ originalVolumeMenuBGM = Options.gainMenuBGM;
+ originalVolumeGameBGM = Options.gainGameBGM;
originalVolumeSFX = Options.gainSFX;
- // TODO properly align audio value and slider position
- // volumeBGM.setValue(PApplet.map(originalVolumeBGM, -60, 0, 0, 1));
- // volumeSFX.setValue(PApplet.map(originalVolumeSFX, -60, 0, 0, 1));
+ volumeMenuBGM.setValue(gainToSlider(originalVolumeMenuBGM));
+ volumeGameBGM.setValue(gainToSlider(originalVolumeGameBGM));
+ volumeSFX.setValue(gainToSlider(originalVolumeSFX));
super.switchTo();
}
@Override
public void drawUI() {
- displayWindow();
- apply.display();
- quit.display();
- volumeBGM.display();
- volumeSFX.display();
-
+ MenuStyle.panel(game, "AUDIO");
+ nav.display();
}
- private void displayWindow() {
- background(19, 23, 35);
- applet.fill(29, 33, 45);
- applet.stroke(47, 54, 73);
- applet.strokeWeight(8);
- applet.rect(applet.gameResolution.x / 2, applet.gameResolution.y / 2, applet.gameResolution.x * 0.66f - 8, applet.gameResolution.y - 8);
+ @Override
+ void mousePressed(MouseEvent e) {
+ volumeMenuBGM.press();
+ volumeGameBGM.press();
+ volumeSFX.press();
+ previewGain();
}
@Override
void mouseDragged(MouseEvent e) {
- volumeBGM.update();
- volumeSFX.update();
- float volBGM = 20 * (float) Math.log(volumeBGM.getValue());
- float volSFX = 20 * (float) Math.log(volumeSFX.getValue());
- Audio.setGainBGM(volBGM);
- Audio.setGainSFX(volSFX);
+ volumeMenuBGM.drag();
+ volumeGameBGM.drag();
+ volumeSFX.drag();
+ previewGain();
}
@Override
void mouseReleased(MouseEvent e) {
- apply.update();
- quit.update();
-
- if (quit.hover()) {
- // revert sound changes if menu is quit
- Audio.setGainBGM(originalVolumeBGM);
- Audio.setGainSFX(originalVolumeSFX);
- game.returnScene();
- return;
- }
- if (apply.hover()) {
- float volBGM = 20 * (float) Math.log(volumeBGM.getValue());
- float volSFX = 20 * (float) Math.log(volumeSFX.getValue());
- Options.save(Option.GAIN_BGM, volBGM);
- Options.save(Option.GAIN_SFX, volSFX);
- Options.gainBGM = volBGM;
- Options.gainSFX = volSFX;
- Notifications.addNotification("Sound Settings Applied", "Your configuration has been successfully applied.");
- game.returnScene();
- }
-
+ volumeMenuBGM.release();
+ volumeGameBGM.release();
+ volumeSFX.release();
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
- switch (e.getKeyCode()) {
- case PConstants.ESC: // Pause
- game.returnScene();
- break;
- default:
- break;
+ if (e.getKeyCode() == PConstants.LEFT || e.getKeyCode() == PConstants.RIGHT) {
+ if (nav.selectedButton() instanceof Slider) {
+ ((Slider) nav.selectedButton()).nudge(e.getKeyCode() == PConstants.LEFT ? -1 : 1);
+ previewGain();
+ }
+ return;
+ }
+ if (nav.keyReleased(e)) {
+ return;
+ }
+ if (e.getKeyCode() == PConstants.ESC) {
+ cancel();
}
}
diff --git a/src/main/java/project_16x16/scene/ConfirmationMenu.java b/src/main/java/project_16x16/scene/ConfirmationMenu.java
index deedf039..99635ce8 100644
--- a/src/main/java/project_16x16/scene/ConfirmationMenu.java
+++ b/src/main/java/project_16x16/scene/ConfirmationMenu.java
@@ -1,10 +1,15 @@
package project_16x16.scene;
+import processing.core.PConstants;
import processing.core.PImage;
+import processing.event.KeyEvent;
import processing.event.MouseEvent;
import project_16x16.SideScroller;
import project_16x16.Utility;
import project_16x16.ui.Button;
+import project_16x16.ui.MenuBackground;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
/**
* Confirmation menus offer the user a chance to confirm or cancel their
@@ -24,6 +29,7 @@ public class ConfirmationMenu extends PScene {
private Button yes;
private Button no;
+ private final MenuNav nav;
/**
*
@@ -37,16 +43,23 @@ public ConfirmationMenu(SideScroller sideScroller, Runnable onConfirm, String me
this.menutext = menutext;
yes = new Button(applet);
- yes.setText("Yes!");
- yes.setPosition(sideScroller.width / 2 - 100, sideScroller.height / 2);
- yes.setSize(300, 100);
+ yes.setText("Yes");
+ yes.setPosition(sideScroller.width / 2 - 190, sideScroller.height / 2 + 40);
+ yes.setSize(340, 90);
yes.setTextSize(40);
no = new Button(applet);
- no.setText("No!");
- no.setPosition(sideScroller.width / 2 + 100, sideScroller.height / 2);
- no.setSize(300, 100);
+ no.setText("No");
+ no.setPosition(sideScroller.width / 2 + 190, sideScroller.height / 2 + 40);
+ no.setSize(340, 90);
no.setTextSize(40);
+
+ nav = new MenuNav(sideScroller);
+ nav.add(yes, () -> {
+ onConfirm.run();
+ applet.returnScene();
+ });
+ nav.add(no, () -> applet.returnScene());
}
public ConfirmationMenu(SideScroller sideScroller, Runnable onConfirm) {
@@ -55,44 +68,39 @@ public ConfirmationMenu(SideScroller sideScroller, Runnable onConfirm) {
@Override
public void drawUI() {
- applet.image(cache, applet.width / 2, applet.height / 2); // draw cached & blurred game
-
- applet.textSize(60);
- if (menutext != null) {
- applet.text(menutext, applet.width / 2, 200);
- applet.textSize(30);
- applet.text("Are you sure?", applet.width / 2, 250);
+ if (cache != null) {
+ applet.image(cache, applet.width / 2, applet.height / 2); // draw cached & blurred game
} else {
- applet.text("Are you sure?", applet.width / 2, 200);
+ MenuBackground.draw(false);
}
- yes.display();
- no.display();
+ MenuStyle.dim(applet, 110);
+ MenuStyle.title(applet, menutext != null ? menutext : "Are you sure?", applet.height / 2f - 130);
+ if (menutext != null) {
+ MenuStyle.caption(applet, "Are you sure?", applet.width / 2f, applet.height / 2f - 40);
+ }
+ nav.display();
}
@Override
void mouseReleased(MouseEvent e) {
- yes.update();
- no.update();
-
- if (yes.hover()) {
- onConfirm.run();
- applet.returnScene();
- return;
- }
+ nav.mouseReleased();
+ }
- if (no.hover()) {
+ @Override
+ void keyReleased(KeyEvent e) {
+ if (!nav.keyReleased(e) && e.getKeyCode() == PConstants.ESC) {
applet.returnScene();
- return;
}
-
}
@Override
public void switchTo() {
super.switchTo();
- cache = applet.get(); // when game is paused, cache the game screen.
- cache = Utility.blur(cache, 6, 2); // blur game screen
+ cache = null;
+ if (applet.isGameInProgress()) { // cache the (paused) game screen, blurred
+ cache = Utility.blur(applet.captureFrame(), 6, 2);
+ }
}
}
diff --git a/src/main/java/project_16x16/scene/ControlsSettings.java b/src/main/java/project_16x16/scene/ControlsSettings.java
index e44ebfe1..fb787eaa 100644
--- a/src/main/java/project_16x16/scene/ControlsSettings.java
+++ b/src/main/java/project_16x16/scene/ControlsSettings.java
@@ -9,198 +9,141 @@
import project_16x16.Options.Option;
import project_16x16.SideScroller;
import project_16x16.ui.Button;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
import project_16x16.ui.Notifications;
public final class ControlsSettings extends PScene {
- private SideScroller game;
+ private final SideScroller game;
+ private final MenuNav nav;
- private Button quit;
- private Button apply;
- private Button reset;
private Button changeJumpKey;
private Button changeDashKey;
private Button changeMoveLeftKey;
private Button changeMoveRightKey;
- // Stores key configurations
+ // Pending (not yet applied) key configuration
private int newJumpKey;
private int newDashKey;
private int newMoveLeftKey;
private int newMoveRightKey;
- private int originalJumpKey;
- private int originalDashKey;
- private int originalMoveLeftKey;
- private int originalMoveRightKey;
-
+ /** The key button currently waiting for a key press, if any. */
private Button activeButton;
public ControlsSettings(SideScroller a) {
super(a);
game = a;
- initButtons();
+ final int cx = a.width / 2;
+ final int top = 190;
+ final int step = 62;
+ nav = new MenuNav(a);
+ changeJumpKey = nav.button("", cx, top, 520, 52, 28, () -> activeButton = changeJumpKey);
+ changeDashKey = nav.button("", cx, top + step, 520, 52, 28, () -> activeButton = changeDashKey);
+ changeMoveLeftKey = nav.button("", cx, top + 2 * step, 520, 52, 28, () -> activeButton = changeMoveLeftKey);
+ changeMoveRightKey = nav.button("", cx, top + 3 * step, 520, 52, 28, () -> activeButton = changeMoveRightKey);
+ nav.button("Reset All", cx, top + 4 * step + 15, 520, 52, 28, this::confirmReset);
+ nav.button("Apply", cx, top + 5 * step + 15, 520, 52, 28, this::apply);
+ nav.button("Back", cx, top + 6 * step + 15, 520, 52, 28, game::returnScene);
+ loadFromOptions();
+ }
+ private void loadFromOptions() {
newJumpKey = Options.jumpKey;
newDashKey = Options.dashKey;
newMoveLeftKey = Options.moveLeftKey;
newMoveRightKey = Options.moveRightKey;
+ activeButton = null;
+ refreshLabels();
}
- void initButtons() {
- apply = new Button(applet);
- apply.setText("Apply");
- apply.setPosition(applet.width / 2, 500);
-
- quit = new Button(applet);
- quit.setText("Quit");
- quit.setPosition(applet.width / 2, 600);
-
- changeJumpKey = new Button(applet);
- changeJumpKey.setText("Change Jump Key: " + charToStr(Options.jumpKey));
- changeJumpKey.setPosition(applet.width / 2, 150);
-
- changeDashKey = new Button(applet);
- changeDashKey.setText("Change Dash Key: " + charToStr(Options.dashKey));
- changeDashKey.setPosition(applet.width / 2, 200);
-
- changeMoveLeftKey = new Button(applet);
- changeMoveLeftKey.setText("Change Move Left Key: " + charToStr(Options.moveLeftKey));
- changeMoveLeftKey.setPosition(applet.width / 2, 250);
+ private void refreshLabels() {
+ label(changeJumpKey, "Jump", newJumpKey);
+ label(changeDashKey, "Dash", newDashKey);
+ label(changeMoveLeftKey, "Move Left", newMoveLeftKey);
+ label(changeMoveRightKey, "Move Right", newMoveRightKey);
+ }
- changeMoveRightKey = new Button(applet);
- changeMoveRightKey.setText("Change Move Right Key: " + charToStr(Options.moveRightKey));
- changeMoveRightKey.setPosition(applet.width / 2, 300);
+ private void label(Button b, String action, int key) {
+ b.setText(action + ": " + (b == activeButton ? "press a key..." : charToStr(key)));
+ }
- reset = new Button(applet);
- reset.setText("Reset All");
- reset.setPosition(applet.width / 2, 400);
+ private void apply() {
+ Options.save(Option.JUMP_KEY, newJumpKey);
+ Options.save(Option.DASH_KEY, newDashKey);
+ Options.save(Option.MOVE_LEFT_KEY, newMoveLeftKey);
+ Options.save(Option.MOVE_RIGHT_KEY, newMoveRightKey);
+ Options.jumpKey = newJumpKey;
+ Options.dashKey = newDashKey;
+ Options.moveLeftKey = newMoveLeftKey;
+ Options.moveRightKey = newMoveRightKey;
+
+ Notifications.addNotification("Controls Settings Applied", "Your configuration has been successfully applied.");
+ game.returnScene();
+ }
- activeButton = null;
+ private void confirmReset() {
+ ConfirmationMenu confirmReset = new ConfirmationMenu(game, () -> {
+ Options.save(Option.JUMP_KEY, Options.DefaultKeys.JUMP);
+ Options.save(Option.DASH_KEY, Options.DefaultKeys.DASH);
+ Options.save(Option.MOVE_LEFT_KEY, Options.DefaultKeys.MOVE_LEFT);
+ Options.save(Option.MOVE_RIGHT_KEY, Options.DefaultKeys.MOVE_RIGHT);
+ Options.jumpKey = Options.DefaultKeys.JUMP;
+ Options.dashKey = Options.DefaultKeys.DASH;
+ Options.moveLeftKey = Options.DefaultKeys.MOVE_LEFT;
+ Options.moveRightKey = Options.DefaultKeys.MOVE_RIGHT;
+ loadFromOptions();
+ Notifications.addNotification("Control Settings Reset", "Reset all control settings to default");
+ }, "Reset all controls?");
+ game.swapToScene(SideScroller.GameScenes.makeConfirmation(confirmReset));
}
@Override
public void switchTo() {
- originalJumpKey = Options.jumpKey;
- originalDashKey = Options.dashKey;
- originalMoveLeftKey = Options.moveLeftKey;
- originalMoveRightKey = Options.moveRightKey;
+ loadFromOptions(); // discard any unapplied changes
super.switchTo();
}
@Override
public void drawUI() {
- displayWindow();
- apply.display();
- quit.display();
- reset.display();
-
- changeJumpKey.display();
- changeDashKey.display();
- changeMoveLeftKey.display();
- changeMoveRightKey.display();
-
+ MenuStyle.panel(game, "CONTROLS");
+ refreshLabels();
+ nav.display();
if (activeButton != null) {
- game.fill(255, 0, 0);
- game.textAlign(PConstants.CENTER);
- game.text("Press a key to change the control!", game.width / 2, 100);
+ MenuStyle.caption(game, "Press the new key (Esc to cancel)", game.width / 2f, 150);
}
}
- private void displayWindow() {
- background(19, 23, 35);
- applet.fill(29, 33, 45);
- applet.stroke(47, 54, 73);
- applet.strokeWeight(8);
- applet.rect(applet.gameResolution.x / 2, applet.gameResolution.y / 2, applet.gameResolution.x * 0.66f - 8, applet.gameResolution.y - 8);
- }
-
@Override
void mouseReleased(MouseEvent e) {
- apply.update();
- quit.update();
- reset.update();
- changeJumpKey.update();
- changeDashKey.update();
- changeMoveLeftKey.update();
- changeMoveRightKey.update();
-
- if (quit.hover()) {
- newJumpKey = originalJumpKey;
- newDashKey = originalDashKey;
- newMoveLeftKey = originalMoveLeftKey;
- newMoveRightKey = originalMoveRightKey;
- changeJumpKey.setText("Change Jump Key: " + charToStr(originalJumpKey));
- changeDashKey.setText("Change Dash Key: " + charToStr(originalDashKey));
- changeMoveLeftKey.setText("Change Move Left Key: " + charToStr(originalMoveLeftKey));
- changeMoveRightKey.setText("Change Move Right Key: " + charToStr(originalMoveRightKey));
-
- game.returnScene();
- return;
- }
- if (apply.hover()) {
- Options.save(Option.JUMP_KEY, newJumpKey);
- Options.save(Option.DASH_KEY, newDashKey);
- Options.save(Option.MOVE_LEFT_KEY, newMoveLeftKey);
- Options.save(Option.MOVE_RIGHT_KEY, newMoveRightKey);
- Options.jumpKey = newJumpKey;
- Options.dashKey = newDashKey;
- Options.moveLeftKey = newMoveLeftKey;
- Options.moveRightKey = newMoveRightKey;
-
- Notifications.addNotification("Controls Settings Applied", "Your configuration has been successfully applied.");
- game.returnScene();
- }
- if (reset.hover()) {
- ConfirmationMenu confirmReset = new ConfirmationMenu(game, () -> {
- Options.save(Option.JUMP_KEY, Options.DefaultKeys.JUMP);
- Options.save(Option.DASH_KEY, Options.DefaultKeys.DASH);
- Options.save(Option.MOVE_LEFT_KEY, Options.DefaultKeys.MOVE_LEFT);
- Options.save(Option.MOVE_RIGHT_KEY, Options.DefaultKeys.MOVE_RIGHT);
- Options.jumpKey = Options.DefaultKeys.JUMP;
- Options.dashKey = Options.DefaultKeys.DASH;
- Options.moveLeftKey = Options.DefaultKeys.MOVE_LEFT;
- Options.moveRightKey = Options.DefaultKeys.MOVE_RIGHT;
- initButtons(); // remake buttons with new labels
- Notifications.addNotification("Control Settings Reset", "Reset all control settings to default");
- });
- var c = SideScroller.GameScenes.makeConfirmation(confirmReset);
- game.swapToScene(c);
- }
-
- if (changeJumpKey.hover()) {
- activeButton = changeJumpKey;
- } else if (changeDashKey.hover()) {
- activeButton = changeDashKey;
- } else if (changeMoveLeftKey.hover()) {
- activeButton = changeMoveLeftKey;
- } else if (changeMoveRightKey.hover()) {
- activeButton = changeMoveRightKey;
- } else {
- activeButton = null;
- }
+ activeButton = null; // clicking elsewhere cancels; a key button click re-sets it below
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
+ int key = e.getKeyCode();
if (activeButton != null) {
- int key = e.getKeyCode();
- if (activeButton == changeJumpKey) {
- newJumpKey = key;
- changeJumpKey.setText("Change Jump Key: " + charToStr(key));
- } else if (activeButton == changeDashKey) {
- newDashKey = key;
- changeDashKey.setText("Change Dash Key: " + charToStr(key));
- } else if (activeButton == changeMoveLeftKey) {
- newMoveLeftKey = key;
- changeMoveLeftKey.setText("Change Move Left Key: " + charToStr(key));
- } else if (activeButton == changeMoveRightKey) {
- newMoveRightKey = key;
- changeMoveRightKey.setText("Change Move Right Key: " + charToStr(key));
+ if (key != PConstants.ESC) {
+ if (activeButton == changeJumpKey) {
+ newJumpKey = key;
+ } else if (activeButton == changeDashKey) {
+ newDashKey = key;
+ } else if (activeButton == changeMoveLeftKey) {
+ newMoveLeftKey = key;
+ } else if (activeButton == changeMoveRightKey) {
+ newMoveRightKey = key;
+ }
}
activeButton = null;
- } else if (e.getKeyCode() == PConstants.ESC) {
+ return;
+ }
+ if (nav.keyReleased(e)) {
+ return;
+ }
+ if (key == PConstants.ESC) {
game.returnScene();
}
}
diff --git a/src/main/java/project_16x16/scene/GameplayScene.java b/src/main/java/project_16x16/scene/GameplayScene.java
index b3b689af..6d180de2 100644
--- a/src/main/java/project_16x16/scene/GameplayScene.java
+++ b/src/main/java/project_16x16/scene/GameplayScene.java
@@ -4,6 +4,8 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
+import java.util.List;
+import java.util.function.Predicate;
import processing.core.PApplet;
import processing.core.PConstants;
@@ -18,9 +20,8 @@
import project_16x16.SideScroller;
import project_16x16.SideScroller.GameScenes;
import project_16x16.Tileset;
+import project_16x16.Time;
import project_16x16.Utility;
-import project_16x16.components.Tile;
-import project_16x16.components.Tile.TileType;
import project_16x16.entities.Player;
import project_16x16.multiplayer.Multiplayer;
import project_16x16.objects.BackgroundObject;
@@ -38,8 +39,6 @@
import project_16x16.scene.gameplaymodes.PlayGameMode;
import project_16x16.scene.gameplaymodes.SaveGameMode;
import project_16x16.scene.gameplaymodes.TestGameMode;
-import project_16x16.ui.Anchor;
-import project_16x16.ui.ScrollBarVertical;
import project_16x16.ui.Tab;
import project_16x16.windows.ImportLevelWindow;
import project_16x16.windows.LoadLevelWindow;
@@ -56,9 +55,15 @@ public class GameplayScene extends PScene {
// Multiplayer
private Multiplayer multiplayer;
- // Graphics Slots
- private PImage slot;
- private PImage slotEditor;
+ // Mode icon layout (screen space)
+ private static final int ICON_X = 42; // centre of the first icon
+ private static final int ICON_Y = 36;
+ private static final int ICON_PITCH = 48;
+ private static final int ICON_SIZE = 36;
+ /** Extent of the mode icons, from the top-left corner. */
+ private static final int ICONS_RIGHT = 220, ICONS_BOTTOM = 60;
+
+ private static final int EDITOR_ACCENT = 0xFFFFB84D;
private final String levelString;
@@ -92,9 +97,6 @@ public class GameplayScene extends PScene {
// Editor Item
private EditorItem editorItem;
- // Scroll Bar
- private ScrollBarVertical scrollBar;
-
private HashMap modesMap;
public enum GameModes {
@@ -103,13 +105,15 @@ public enum GameModes {
public GameplayMode currentMode;
- private ArrayList inventory;
-
public EditableObject focusedObject = null;
public boolean edit;
- private int scrollInventory;
+ /**
+ * Set when the game should pause at the end of this frame's UI (so the pause
+ * backdrop captures the frame without debug/notification overlays).
+ */
+ private boolean pauseRequested;
/**
* Local Player
@@ -135,22 +139,9 @@ private void setup() {
objects = new ArrayList<>();
- // Create Inventory
- inventory = new ArrayList<>();
- inventory.add("Metal");
- inventory.add("Metal_Walk_Left:0");
- inventory.add("Metal_Walk_Middle:0");
- inventory.add("Metal_Walk_Middle:1");
- inventory.add("Metal_Walk_Right:0");
- inventory.add("XBox");
-
// Init Editor Components
editorItem = new EditorItem(applet, this);
- // Get Slots Graphics
- slot = Tileset.getTile(289, 256, 20, 21, 4);
- slotEditor = Tileset.getTile(310, 256, 20, 21, 4);
-
// Get Icon Graphics
iconModify = Tileset.getTile(279, 301, 9, 9, 4);
iconInventory = Tileset.getTile(289, 301, 9, 9, 4);
@@ -169,13 +160,6 @@ private void setup() {
// window_test = new TestWindow(applet);
windowLoadLevel = new LoadLevelWindow(applet, this);
- // Init ScollBar
- Anchor scrollBarAnchor = new Anchor(applet, -20, 102, 20, 50);
- scrollBarAnchor.anchorOrigin = Anchor.AnchorOrigin.TopRight;
- scrollBarAnchor.stretch = Anchor.Stretch.Vertical;
- scrollBar = new ScrollBarVertical(scrollBarAnchor);
- scrollBar.setBarRatio(getBarRatio(getTotalInventoryItems() / 6, 50, 3f));
-
// Init Player
localPlayer = new Player(applet, this, false);
localPlayer.position.set(0, -100); // TODO spawn location
@@ -184,7 +168,7 @@ private void setup() {
modesMap = new HashMap<>();
modesMap.put(GameModes.MODIFY, new ModifyGameMode(this, editorItem));
modesMap.put(GameModes.PLAY, new PlayGameMode(this, localPlayer));
- modesMap.put(GameModes.INVENTORY, new InventoryGameMode(this));
+ modesMap.put(GameModes.INVENTORY, new InventoryGameMode(this, editorItem));
modesMap.put(GameModes.SAVE, new SaveGameMode(this));
modesMap.put(GameModes.IMPORT, new ImportGameMode(this));
modesMap.put(GameModes.LOADEXAMPLE, new LoadExampleGameMode(this));
@@ -201,7 +185,6 @@ private void setup() {
@Override
public void switchTo() {
super.switchTo();
- ((PauseMenu) GameScenes.PAUSE_MENU.getScene()).switched = false;
Audio.play(BGM.TEST1);
}
@@ -222,8 +205,8 @@ public void draw() {
Iterator i = projectileObjects.iterator();
while (i.hasNext()) {
ProjectileObject o = i.next();
- if (applet.frameCount - o.spawnTime > 600) {
- i.remove(); // kill projectile after 10s
+ if (Time.millis() - o.spawnTime > ProjectileObject.LIFETIME_MILLIS) {
+ i.remove();
} else {
o.update();
o.display();
@@ -281,21 +264,64 @@ private void drawPlayer() {
*/
@Override
public void drawUI() {
- currentMode.displayGUISlots();
+ if (currentMode.getModeType() != GameModes.PLAY) {
+ displayEditorFrame();
+ }
- int xAnchor = 42;
- int offset = 48;
// GUI Icons
- currentMode.updateGUIButton(xAnchor, iconModifyActive, iconModify, GameModes.MODIFY, Utility.hoverScreen(xAnchor, 120, 36, 36));
- currentMode.updateGUIButton(xAnchor + offset, iconInventoryActive, iconInventory, GameModes.INVENTORY,
- Utility.hoverScreen(xAnchor + offset, 120, 36, 36));
- currentMode.updateGUIButton(xAnchor + offset * 2, iconPlayActive, iconPlay, GameModes.PLAY, Utility.hoverScreen(xAnchor + offset * 2, 120, 36, 36));
- currentMode.updateGUIButton(xAnchor + offset * 3, iconSaveActive, iconSave, GameModes.SAVE, Utility.hoverScreen(xAnchor + offset * 3, 120, 36, 36));
+ displayModeIcon(0, iconModifyActive, iconModify, GameModes.MODIFY);
+ displayModeIcon(1, iconInventoryActive, iconInventory, GameModes.INVENTORY);
+ displayModeIcon(2, iconPlayActive, iconPlay, GameModes.PLAY);
+ displayModeIcon(3, iconSaveActive, iconSave, GameModes.SAVE);
currentMode.updateGUI();
if (selectionBox != null) {
selectionBox.draw();
}
+
+ if (pauseRequested) {
+ pauseRequested = false;
+ ((PauseMenu) GameScenes.PAUSE_MENU.getScene()).setBackdrop(applet.captureFrame());
+ applet.swapToScene(GameScenes.PAUSE_MENU);
+ }
+ }
+
+ private void displayModeIcon(int index, PImage activeIcon, PImage inactiveIcon, GameModes mode) {
+ final int x = ICON_X + index * ICON_PITCH;
+ currentMode.updateGUIButton(x, ICON_Y, activeIcon, inactiveIcon, mode, Utility.hoverScreen(x, ICON_Y, ICON_SIZE, ICON_SIZE));
+ }
+
+ /**
+ * Frames the screen and labels it, so it's obvious the level editor (rather
+ * than the game) is running.
+ */
+ private void displayEditorFrame() {
+ applet.pushStyle();
+ applet.rectMode(CORNER);
+ applet.noFill();
+ applet.stroke(EDITOR_ACCENT);
+ applet.strokeWeight(4);
+ applet.rect(2, 2, applet.width - 4, applet.height - 4);
+
+ final String label = "EDIT MODE";
+ final String hint = "press 3 to play";
+ applet.textSize(22);
+ final float labelWidth = applet.textWidth(label);
+ applet.textSize(18);
+ final float w = labelWidth + applet.textWidth(hint) + 48;
+ final float h = 34;
+ final float x = Math.max(applet.width / 2f - w / 2, ICONS_RIGHT + 16); // clear of the icons
+ applet.noStroke();
+ applet.fill(EDITOR_ACCENT);
+ applet.rect(x, 0, w, h, 0, 0, 8, 8);
+ applet.textAlign(LEFT, CENTER);
+ applet.fill(29, 33, 45);
+ applet.textSize(22);
+ applet.text(label, x + 16, h / 2 - 3);
+ applet.fill(29, 33, 45, 170);
+ applet.textSize(18);
+ applet.text(hint, x + 32 + labelWidth, h / 2 - 3);
+ applet.popStyle();
}
/**
@@ -303,6 +329,9 @@ public void drawUI() {
*/
@Override
public void debug() {
+ if (pauseRequested) {
+ return; // keep debug outlines out of the pause backdrop
+ }
for (EditableObject o : objects) {
o.debug();
}
@@ -324,96 +353,6 @@ public void exit() {
}
}
- public void displayCreativeInventory() {
- // complete creative inventory
-
- // Display Background
- applet.stroke(50);
- applet.fill(0, 100);
- applet.rect(applet.width / 2, applet.height / 2, applet.width, applet.height);
-
- // Display Editor Mode Items
- int x = 0;
- int y = 1;
- int index = 0;
- TileType[] tiles = { TileType.COLLISION, TileType.BACKGROUND, TileType.OBJECT };
- ArrayList inventoryTiles = Tileset.getAllTiles(tiles);
- for (Tile tile : inventoryTiles) {
- PImage img = tile.getPImage();
- if (index % 6 == 0) { // show 6 items per row
- x = 0;
- y++;
- } else {
- x++;
- }
- applet.image(slotEditor, 20 * 4 / 2 + 10 + x * (20 * 4 + 10), y * (20 * 4 + 10) + scrollInventory);
- if (img.width > 20 * 4 || img.height > 20 * 4) {
- applet.image(img, 20 * 4 / 2 + 10 + x * (20 * 4 + 10), y * (20 * 4 + 10) + scrollInventory, img.width / 4, img.height / 4);
- } else {
- applet.image(img, 20 * 4 / 2 + 10 + x * (20 * 4 + 10), y * (20 * 4 + 10) + scrollInventory, img.width / 2, img.height / 2);
- }
-
- // Detect hover over item
- float xx = 20 * 4 / 2 + 10 + x * (20 * 4 + 10);
- float yy = y * (20 * 4 + 10) + scrollInventory;
- if (applet.getMouseCoordScreen().y > 100) {
- if (applet.getMouseCoordScreen().x > xx - (20 * 4) / 2 && applet.getMouseCoordScreen().x < xx + (20 * 4) / 2
- && applet.getMouseCoordScreen().y > yy - (20 * 4) / 2 && applet.getMouseCoordScreen().y < yy + (20 * 4) / 2) {
- // Grab Item
- if (applet.mousePressEvent) {
- editorItem.focus = true;
- editorItem.setTile(tile.getName());
- }
- // Display item name
- applet.textSize(20);
- applet.fill(255);
- applet.text(tile.getName(), applet.mouseX, applet.mouseY);
- }
- }
- index++;
- }
-
- // Display ScrollBar
- scrollBar.display();
- scrollBar.update();
- scrollInventory = (int) PApplet.map(scrollBar.barLocation, 1, 0, -getInventorySize() + applet.height - 8, 0);
-
- // Display Top Bar TODO
-// applet.noStroke();
-// applet.fill(29, 33, 45);
-// applet.rect(applet.width / 2, 50, applet.width, 100);
-
- // Display Line Separator
- applet.strokeWeight(4);
- applet.stroke(74, 81, 99);
- applet.line(0, 100, applet.width, 100);
-
- // Display Inventory Slots
- for (int i = 0; i < 6; i++) {
- // Display Slot
- image(slot, 20 * 4 / 2 + 10 + i * (20 * 4 + 10), 20 * 4 / 2 + 10);
-
- // Display Item
- PImage img = Tileset.getTile(inventory.get(i));
- applet.image(img, 20 * 4 / 2 + 10 + i * (20 * 4 + 10), 20 * 4 / 2 + 10, img.width * (float) 0.5, img.height * (float) 0.5);
-
- // Focus Event
- if (applet.mouseReleaseEvent) {
- float xx = 20 * 4 / 2 + 10 + i * (20 * 4 + 10);
- float yy = 20 * 4 / 2 + 10;
- if (editorItem.focus && applet.getMouseCoordScreen().x > xx - (20 * 4) / 2 && applet.getMouseCoordScreen().x < xx + (20 * 4) / 2
- && applet.getMouseCoordScreen().y > yy - (20 * 4) / 2 && applet.getMouseCoordScreen().y < yy + (20 * 4) / 2) {
- editorItem.focus = false;
- inventory.set(i, editorItem.id);
- }
- }
- }
-
- // Display Editor Object
- editorItem.update();
- editorItem.display();
- }
-
private void displayGrid() {// world edit grid
applet.strokeWeight(1);
applet.stroke(0, 155, 155);
@@ -426,45 +365,8 @@ private void displayGrid() {// world edit grid
}
}
- private float getInventorySize() {
- int y = 1;
-
- TileType[] tiles = { TileType.COLLISION, TileType.BACKGROUND, TileType.OBJECT };
- ArrayList inventoryTiles = Tileset.getAllTiles(tiles);
- for (int i = 0; i < inventoryTiles.size(); i++) {
- if (i % 6 == 0) {
- y++;
- } else {
- }
- }
- return 20 * 4 + 10 + y * (20 * 4 + 10);
- }
-
public boolean isZoomable() {
- return zoomable;
- }
-
- /**
- * Lists the amount of items currently on the inventory. Can be refactored in
- * the future to support the actual gameplay inventory.
- *
- * @return amount of items currently on the inventory
- */
- private int getTotalInventoryItems() {
- TileType[] tiles = { TileType.COLLISION, TileType.BACKGROUND, TileType.OBJECT };
- return Tileset.getAllTiles(tiles).size();
- }
-
- /**
- * Calculates the ratio for a scrollbar.
- *
- * @param bodyToScroll Approximate relative size of the scrollable body
- * @param containerSize Size of the bar container
- * @param sizeMultiplier Multiplier for the relative size of the bar
- * @return Bar ratio for that specific bar
- */
- private float getBarRatio(float bodyToScroll, int containerSize, float sizeMultiplier) {
- return bodyToScroll / (containerSize * sizeMultiplier);
+ return zoomable && !isOverUI(applet.getMouseCoordScreen());
}
@Override
@@ -488,9 +390,7 @@ void mousePressed(MouseEvent e) {
}
break;
case RIGHT:
- if (currentMode.getModeType().equals(GameModes.MODIFY)) { // As the SelectionBox class is private, this
- // has to remain as a type-check and cannot
- // delegate to the currentMode
+ if (currentMode.allowsWorldEditing() && !isOverUI(mouseDown)) {
selectionBox = new SelectionBox(mouseDown);
}
break;
@@ -503,6 +403,9 @@ void mousePressed(MouseEvent e) {
void mouseReleased(MouseEvent e) {
switch (e.getButton()) {
case LEFT:
+ if (currentMode.allowsWorldEditing()) {
+ endDrag();
+ }
break;
case RIGHT:
selectionBox = null;
@@ -529,7 +432,11 @@ public void mouseWheel(MouseEvent event) {
protected void keyReleased(processing.event.KeyEvent e) {
final int keyCode = e.getKeyCode();
if (keyCode == PConstants.ESC) {
- applet.swapToScene(GameScenes.PAUSE_MENU);
+ if (currentMode.getModeType() == GameModes.INVENTORY) {
+ changeMode(GameModes.MODIFY); // close the palette
+ } else {
+ pauseRequested = true;
+ }
} else if (keyCode == Options.lifeCapIncreaseKey) {
localPlayer.lifeCapacity++;
} else if (keyCode == Options.lifeCapDecreaseKey) {
@@ -544,19 +451,21 @@ protected void keyReleased(processing.event.KeyEvent e) {
}
public void switchModeOnKeyEvent(processing.event.KeyEvent event) {
- editorItem.setMode("CREATE");
editorItem.focus = false;
switch (event.getKeyCode()) {
case 49: // 1
changeMode(GameModes.MODIFY);
break;
case 50: // 2
- changeMode(GameModes.INVENTORY);
- scrollInventory = 0;
+ case 69: // 'e'
+ if (currentMode.getModeType() == GameModes.INVENTORY) {
+ changeMode(GameModes.MODIFY);
+ } else {
+ changeMode(GameModes.INVENTORY);
+ }
break;
case 51: // 3
changeMode(GameModes.PLAY);
- applet.camera.setFollowObject(localPlayer);
break;
case 52: // 4
changeMode(GameModes.SAVE);
@@ -564,22 +473,9 @@ public void switchModeOnKeyEvent(processing.event.KeyEvent event) {
case 54: // 6
changeMode(GameModes.IMPORT);
break;
- case 69: // 'e' TODO remove?
- if (currentMode.getModeType().equals(GameModes.INVENTORY)) {
- } else {
- changeMode(GameModes.INVENTORY);
- editorItem.setMode("ITEM");
- scrollInventory = 0;
- }
- break;
case 8: // BACKSPACE
case 46: // DEL
- for (Iterator iterator = objects.iterator(); iterator.hasNext();) {
- EditableObject o = iterator.next();
- if (o.isFocused()) {
- iterator.remove();
- }
- }
+ objects.stream().filter(EditableObject::isFocused).toList().forEach(this::removeObject);
break;
default:
break;
@@ -683,27 +579,72 @@ public void displayWorldEdit() {
}
}
- public void displayGUISlots() {
- for (int i = 0; i < 6; i++) {
- // Display Slot
- image(slot, 20 * 4 / 2 + 10 + i * (20 * 4 + 10), 20 * 4 / 2 + 10);
-
- // Display Item
- PImage img = Tileset.getTile(inventory.get(i));
- applet.image(img, 20 * 4 / 2 + 10 + i * (20 * 4 + 10), 20 * 4 / 2 + 10, img.width * (float) 0.5, img.height * (float) 0.5);
-
- // Focus Event
- if (applet.mousePressEvent) {
- float x = 20 * 4 / 2 + 10 + i * (20 * 4 + 10);
- float y = 20 * 4 / 2 + 10;
- if (applet.getMouseCoordScreen().x > x - (20 * 4) / 2 && applet.getMouseCoordScreen().x < x + (20 * 4) / 2
- && applet.getMouseCoordScreen().y > y - (20 * 4) / 2 && applet.getMouseCoordScreen().y < y + (20 * 4) / 2) {
- editorItem.focus = true;
- editorItem.setTile(inventory.get(i));
- editorItem.type = Tileset.getTileType(inventory.get(i));
- }
+ /**
+ * @return whether the screen position is over editor/game UI (rather than the
+ * level)
+ */
+ public boolean isOverUI(PVector screen) {
+ return (screen.x < ICONS_RIGHT && screen.y < ICONS_BOTTOM) || currentMode.isOverUI(screen);
+ }
+
+ /**
+ * Solid objects (blocks and game objects) can't overlap each other in the
+ * editor. Child collision boxes are skipped: they share their parent's
+ * bounds.
+ */
+ private static boolean isSolid(EditableObject o) {
+ return (o instanceof CollidableObject || o instanceof GameObject) && !o.child;
+ }
+
+ /**
+ * Whether a solid object with the given (world space, centred) bounds would
+ * overlap no solid objects (besides those ignored). Touching edges don't count
+ * as overlapping.
+ */
+ public boolean isAreaFree(float x, float y, float w, float h, Predicate ignore) {
+ for (EditableObject o : objects) {
+ if (isSolid(o) && !ignore.test(o) && Math.abs(x - o.position.x) * 2 < w + o.width && Math.abs(y - o.position.y) * 2 < h + o.height) {
+ return false;
}
}
+ return true;
+ }
+
+ /**
+ * @return whether the (solid) object overlaps a solid object that isn't
+ * selected along with it
+ */
+ public boolean isBlocked(EditableObject o) {
+ return isSolid(o) && !isAreaFree(o.position.x, o.position.y, o.width, o.height, EditableObject::isFocused);
+ }
+
+ /**
+ * Ends a drag of the selected objects. If any of them was dropped over another
+ * solid object, the whole selection returns to where the drag started
+ * (objects duplicated during the drag are discarded).
+ */
+ private void endDrag() {
+ final List dragged = objects.stream().filter(o -> o.isFocused() && !o.child).toList();
+ if (dragged.stream().noneMatch(this::isBlocked)) {
+ return;
+ }
+ for (EditableObject o : dragged) {
+ if (o.dragOrigin == null) {
+ removeObject(o);
+ } else {
+ o.position.set(o.dragOrigin);
+ }
+ }
+ }
+
+ /**
+ * Removes an object from the level, along with its collision box (if any).
+ */
+ private void removeObject(EditableObject o) {
+ objects.remove(o);
+ if (o instanceof GameObject g && g.collision != null) {
+ objects.remove(g.collision);
+ }
}
public void changeMode(GameModes mode) {
@@ -731,11 +672,6 @@ public LoadLevelWindow getWindowLoadLevel() {
return windowLoadLevel;
}
- public void scrollInventoryBar(MouseEvent event) {
- scrollBar.mouseWheel(event);
- scrollInventory = (int) PApplet.map(scrollBar.barLocation, 1, 0, -getInventorySize() + applet.height - 8, 0);
- }
-
/**
*
* @author micycle1
diff --git a/src/main/java/project_16x16/scene/GraphicsSettings.java b/src/main/java/project_16x16/scene/GraphicsSettings.java
index 3ae4af5e..d073180a 100644
--- a/src/main/java/project_16x16/scene/GraphicsSettings.java
+++ b/src/main/java/project_16x16/scene/GraphicsSettings.java
@@ -1,85 +1,173 @@
package project_16x16.scene;
+import java.util.ArrayList;
+import java.util.List;
+
+import javafx.geometry.Rectangle2D;
+import javafx.stage.Screen;
import processing.core.PConstants;
import processing.event.KeyEvent;
import processing.event.MouseEvent;
+import project_16x16.Options;
+import project_16x16.Options.Option;
import project_16x16.SideScroller;
-import project_16x16.ui.Button;
+import project_16x16.ui.Dropdown;
+import project_16x16.ui.MenuBackground;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
import project_16x16.ui.Notifications;
public final class GraphicsSettings extends PScene {
- private SideScroller game;
-
- private Button quit;
- private Button apply;
+ private static final int[] FPS_OPTIONS = { 30, 60, 90, 120, 144, 165, 240 };
+ /** Windowed-mode sizes offered (16:9, like the game), if they fit the screen. */
+ private static final int[][] WINDOW_SIZES = { { 960, 540 }, { 1280, 720 }, { 1600, 900 }, { 1920, 1080 }, { 2560, 1440 }, { 3200, 1800 },
+ { 3840, 2160 } };
+
+ private final SideScroller game;
+ private final MenuNav nav;
+ private final Dropdown displayMode;
+ private final Dropdown windowSize;
+ private final Dropdown fps;
+ private final Dropdown particles;
+ private final Dropdown[] dropdowns;
+ /** The window sizes offered by {@link #windowSize}. */
+ private final List windowSizes = new ArrayList<>();
public GraphicsSettings(SideScroller a) {
super(a);
-
game = a;
- apply = new Button(applet);
- apply.setText("Apply");
- apply.setPosition(a.width / 2, 500);
+ Rectangle2D screen = Screen.getPrimary().getBounds();
+ for (int[] size : WINDOW_SIZES) {
+ if (windowSizes.isEmpty() || (size[0] <= screen.getWidth() && size[1] <= screen.getHeight())) {
+ windowSizes.add(size);
+ }
+ }
+ String[] sizeLabels = new String[windowSizes.size()];
+ for (int i = 0; i < sizeLabels.length; i++) {
+ sizeLabels[i] = windowSizes.get(i)[0] + " x " + windowSizes.get(i)[1];
+ }
+ String[] fpsLabels = new String[FPS_OPTIONS.length];
+ for (int i = 0; i < fpsLabels.length; i++) {
+ fpsLabels[i] = FPS_OPTIONS[i] + " FPS";
+ }
- quit = new Button(a);
- quit.setText("Quit");
- quit.setPosition(a.width / 2, 600);
+ final int cx = a.width / 2;
+ nav = new MenuNav(a);
+ displayMode = dropdown("Display", new String[] { "Windowed", "Fullscreen" }, cx, 195);
+ windowSize = dropdown("Window Size", sizeLabels, cx, 270);
+ fps = dropdown("Frame Rate", fpsLabels, cx, 345);
+ particles = dropdown("Menu Particles", MenuBackground.DENSITY_NAMES, cx, 420);
+ dropdowns = new Dropdown[] { displayMode, windowSize, fps, particles };
+ nav.button("Apply", cx, 525, 360, 66, 32, this::apply);
+ nav.button("Back", cx, 610, 360, 66, 32, game::returnScene);
}
- @Override
- public void switchTo() {
- super.switchTo();
+ private Dropdown dropdown(String label, String[] options, int x, int y) {
+ Dropdown d = new Dropdown(game, label, options);
+ d.setPosition(x, y);
+ d.setSize(600, 56);
+ d.setTextSize(28);
+ nav.add(d, d::toggle);
+ return d;
+ }
+
+ private void apply() {
+ int value = FPS_OPTIONS[fps.getSelectedIndex()];
+ Options.save(Option.TARGET_FPS, value);
+ Options.targetFrameRate = value;
+ SideScroller.targetFramerate = value;
+
+ int[] size = windowSizes.get(windowSize.getSelectedIndex());
+ Options.windowWidth = size[0];
+ Options.windowHeight = size[1];
+ Options.save(Option.WINDOW_WIDTH, size[0]);
+ Options.save(Option.WINDOW_HEIGHT, size[1]);
+ if (size[0] != game.getWindowWidth() || size[1] != game.getWindowHeight()) {
+ game.setWindowSize(size[0], size[1]); // if fullscreen, applies on returning to windowed mode
+ }
+
+ Options.fullscreen = displayMode.getSelectedIndex() == 1;
+ Options.save(Option.FULLSCREEN, Options.fullscreen);
+ game.setFullscreen(Options.fullscreen);
+
+ Options.menuParticles = particles.getSelectedIndex();
+ Options.save(Option.MENU_PARTICLES, Options.menuParticles);
+ MenuBackground.setDensity(Options.menuParticles);
+
+ Notifications.addNotification("Graphics Settings Applied", "Your configuration has been successfully applied.");
+ game.returnScene();
}
@Override
- public void drawUI() {
- displayWindow();
- apply.display();
- quit.display();
+ public void switchTo() {
+ displayMode.setSelectedIndex(game.isFullscreen() ? 1 : 0);
+
+ // select the options closest to the current window size and frame rate
+ int best = 0;
+ for (int i = 0; i < windowSizes.size(); i++) {
+ if (Math.abs(windowSizes.get(i)[0] - game.getWindowWidth()) < Math.abs(windowSizes.get(best)[0] - game.getWindowWidth())) {
+ best = i;
+ }
+ }
+ windowSize.setSelectedIndex(best);
+
+ best = 0;
+ for (int i = 0; i < FPS_OPTIONS.length; i++) {
+ if (Math.abs(FPS_OPTIONS[i] - SideScroller.targetFramerate) < Math.abs(FPS_OPTIONS[best] - SideScroller.targetFramerate)) {
+ best = i;
+ }
+ }
+ fps.setSelectedIndex(best);
+
+ particles.setSelectedIndex(Options.menuParticles);
+ super.switchTo();
}
- private void displayWindow() {
- background(19, 23, 35);
- applet.fill(29, 33, 45);
- applet.stroke(47, 54, 73);
- applet.strokeWeight(8);
- applet.rect(applet.gameResolution.x / 2, applet.gameResolution.y / 2, applet.gameResolution.x * 0.66f - 8, applet.gameResolution.y - 8);
+ /** @return the dropdown whose list is open, or null */
+ private Dropdown openDropdown() {
+ for (Dropdown d : dropdowns) {
+ if (d.isOpen()) {
+ return d;
+ }
+ }
+ return null;
}
@Override
- void mouseDragged(MouseEvent e) {
-
+ public void drawUI() {
+ MenuStyle.panel(game, "GRAPHICS");
+ nav.display();
+ Dropdown open = openDropdown();
+ if (open != null) {
+ open.drawList(); // overlays the buttons below
+ }
}
@Override
void mouseReleased(MouseEvent e) {
- apply.update();
- quit.update();
-
- if (quit.hover()) {
- // revert sound changes if menu is quit
- game.returnScene();
+ Dropdown open = openDropdown();
+ if (open != null) {
+ open.click();
return;
}
- if (apply.hover()) {
- Notifications.addNotification("Graphics Settings Applied", "Your configuration has been successfully applied.");
- game.returnScene();
- }
-
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
- switch (e.getKeyCode()) {
- case PConstants.ESC: // Pause
- game.returnScene();
- break;
- default:
- break;
+ Dropdown open = openDropdown();
+ if (open != null) {
+ open.key(e.getKeyCode());
+ return;
+ }
+ if (nav.keyReleased(e)) {
+ return;
+ }
+ if (e.getKeyCode() == PConstants.ESC) {
+ game.returnScene();
}
}
-
}
diff --git a/src/main/java/project_16x16/scene/MainMenu.java b/src/main/java/project_16x16/scene/MainMenu.java
index 82cd06da..361c5273 100644
--- a/src/main/java/project_16x16/scene/MainMenu.java
+++ b/src/main/java/project_16x16/scene/MainMenu.java
@@ -1,21 +1,16 @@
package project_16x16.scene;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
-import processing.core.PApplet;
import processing.core.PConstants;
-import processing.core.PGraphics;
import processing.event.KeyEvent;
import processing.event.MouseEvent;
import project_16x16.Audio;
import project_16x16.Audio.BGM;
-import project_16x16.Constants;
import project_16x16.SideScroller;
import project_16x16.SideScroller.GameScenes;
-import project_16x16.Utility;
import project_16x16.ui.Button;
+import project_16x16.ui.MenuBackground;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
/**
*
@@ -30,254 +25,78 @@ public final class MainMenu extends PScene {
public Button pressMultiplayer;
private SideScroller game;
-
- private PGraphics background;
+ private final MenuNav nav;
public MainMenu(SideScroller a) {
super(a);
game = a;
- background = game.createGraphics((int) game.gameResolution.x, (int) game.gameResolution.x);
- background.noSmooth();
- Particles.assignApplet(a);
- Particles.populate(1250);
-
pressStart = new Button(a);
pressMultiplayer = new Button(a);
pressQuit = new Button(a);
pressSettings = new Button(a);
pressStart.setText("Start Game");
- pressStart.setPosition(applet.width / 2, applet.height / 2 - 240);
- pressStart.setSize(300, 100);
+ pressStart.setPosition(applet.width / 2, applet.height / 2 - 150);
+ pressStart.setSize(360, 90);
pressStart.setTextSize(40);
pressMultiplayer.setText("Multiplayer");
- pressMultiplayer.setPosition(applet.width / 2, applet.height / 2 - 80);
- pressMultiplayer.setSize(300, 100);
+ pressMultiplayer.setPosition(applet.width / 2, applet.height / 2 - 30);
+ pressMultiplayer.setSize(360, 90);
pressMultiplayer.setTextSize(40);
pressSettings.setText("Settings");
- pressSettings.setPosition(applet.width / 2, applet.height / 2 + 80);
- pressSettings.setSize(300, 100);
+ pressSettings.setPosition(applet.width / 2, applet.height / 2 + 90);
+ pressSettings.setSize(360, 90);
pressSettings.setTextSize(40);
pressQuit.setText("Quit Game");
- pressQuit.setPosition(applet.width / 2, applet.height / 2 + 240);
- pressQuit.setSize(300, 100);
+ pressQuit.setPosition(applet.width / 2, applet.height / 2 + 210);
+ pressQuit.setSize(360, 90);
pressQuit.setTextSize(40);
+
+ nav = new MenuNav(a);
+ nav.add(pressStart, () -> {
+ ((GameplayScene) GameScenes.GAME.getScene()).setSingleplayer(true);
+ game.swapToScene(GameScenes.GAME);
+ });
+ nav.add(pressMultiplayer, () -> game.swapToScene(GameScenes.MULTIPLAYER_MENU));
+ nav.add(pressSettings, () -> game.swapToScene(GameScenes.SETTINGS_MENU));
+ nav.add(pressQuit, () -> System.exit(0));
}
@Override
public void switchTo() {
super.switchTo();
- Audio.play(BGM.TEST3);
+ Audio.play(BGM.TEST4);
}
@Override
public void drawUI() {
- game.fill(Constants.Colors.MENU_GREY, 40);
- game.noStroke();
- game.rectMode(CORNER);
- game.rect(0, 0, game.gameResolution.x, game.gameResolution.y);
- game.rectMode(CENTER);
- Particles.run();
-
- pressStart.manDisplay();
- pressMultiplayer.manDisplay();
- pressSettings.manDisplay();
- pressQuit.manDisplay();
- }
-
- private void update() {
- pressStart.update();
- if (pressStart.hover()) {
- ((GameplayScene) GameScenes.GAME.getScene()).setSingleplayer(true);
- game.swapToScene(GameScenes.GAME);
- }
-
- pressMultiplayer.update();
- if (pressMultiplayer.hover()) {
- game.swapToScene(GameScenes.MULTIPLAYER_MENU);
- }
-
- pressSettings.update();
- if (pressSettings.hover()) {
- game.swapToScene(GameScenes.SETTINGS_MENU);
- }
+ MenuBackground.draw(true);
- pressQuit.update();
- if (pressQuit.hover()) {
- System.exit(0);
- }
+ MenuStyle.title(game, "PROJECT 16x16", game.height / 2f - 300);
+ nav.display();
}
@Override
void mouseReleased(MouseEvent e) {
- update();
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
+ if (nav.keyReleased(e)) {
+ return;
+ }
switch (e.getKeyCode()) {
- case 8: // BACKSPACE
- case PConstants.ESC: // Pause
+ case 8 : // BACKSPACE
+ case PConstants.ESC : // Pause
game.returnScene();
break;
- default:
+ default :
break;
}
}
-
- /**
- * Adapted from https://www.openprocessing.org/sketch/762850
- *
- * @author micycle1
- *
- */
- private static class Particles {
-
- private static SideScroller game;
-
- private static List particles;
-
- private static int function = 0;
- private static int centerX, centerY;
- private static int scaleX, scaleY;
-
- private static long timeAccumulator = 0;
- private static final int TRANSITION_INTERVAL = 5000; // 5000 milliseconds = 5 seconds
-
- static void assignApplet(SideScroller s) {
- particles = new ArrayList<>();
- game = s;
- centerX = (int) (game.gameResolution.x / 2);
- centerY = (int) (game.gameResolution.y / 2);
- scaleX = (int) (game.gameResolution.x / 20);
- scaleY = (int) (game.gameResolution.y / 20 * (game.gameResolution.x / game.gameResolution.y));
- }
-
- static void populate(int n) {
- for (int i = 0; i < n; i++) {
- float x = getXPos(game.random(0, game.gameResolution.x));
- float y = getYPos(game.random(0, game.gameResolution.y));
- int color = Utility.colorToRGB((int) game.random(0, 50), (int) game.random(150, 255), (int) game.random(150, 255));
- Particle p = new Particle(x, y, (int) game.random(2, 8), color);
- particles.add(p);
- }
- }
-
- static void run() {
- if (timeAccumulator >= TRANSITION_INTERVAL) {
- function++;
- function %= 12; // cycle movement function
- timeAccumulator %= TRANSITION_INTERVAL;
- }
-
- int repopulate = 0;
- for (Iterator iterator = particles.iterator(); iterator.hasNext();) {
- Particle p = iterator.next();
- p.update(3 / SideScroller.targetFramerate);
- float x = getXPrint(p.x);
- float y = getYPrint(p.y);
-
- game.stroke(p.color);
- game.strokeWeight(p.size);
- game.line(PApplet.lerp(x, p.lastX, 0.15f), PApplet.lerp(y, p.lastY, 0.15f), p.lastX, p.lastY);
-
- p.lastX = x;
- p.lastY = y;
- if (!Utility.withinRegion(p.lastX, p.lastY, -100, -100, game.gameResolution.x + 100, game.gameResolution.y + 100)) {
- iterator.remove();
- repopulate++;
- }
- }
- populate(repopulate);
-
- timeAccumulator += 1000 / game.frameRate;
- }
-
- private static double getSlopeX(float x, float y) {
- switch (function) {
- // @formatter:off
- case 0: return Math.cos(y);
- case 1: return Math.cos(y*5)*x*0.3;
- case 2:
- case 3:
- case 4:
- case 5:
- case 6: return 1;
- case 7: return Math.sin(y*0.1)*3; //orbit
- case 8: return y/3; //two orbits
- case 9: return -y;
- case 10: return -1.5*y;
- case 11: return Math.sin(y)*Math.cos(x);
- default : return 1;
- // @formatter:on
- }
- }
-
- private static double getSlopeY(float x, float y) {
- switch (function) {
- // @formatter:off
- case 0: return Math.sin(x);
- case 1: return Math.sin(x*5)*y*0.3;
- case 2: return Math.cos(x*y);
- case 3: return Math.sin(x)*Math.cos(y);
- case 4: return Math.cos(x)*y*y;
- case 5: return Math.log(Math.abs(x))*Math.log(Math.abs(y));
- case 6: return Math.tan(x)*Math.cos(y*y);
- case 7: return -Math.sin(x*0.1)*3; //orbit
- case 8: return (x-x*x*x)*0.01; //two orbits
- case 9: return -Math.sin(x);
- case 10: return -y-Math.sin(1.5*x) + .75;
- case 11: return Math.sin(x)*Math.cos(y);
- default : return 1;
- // @formatter:on
- }
- }
-
- private static float getXPos(float x) {
- return (x - centerX) / scaleX;
- }
-
- private static float getYPos(float y) {
- return (y - centerY) / scaleY;
- }
-
- private static float getXPrint(float x) {
- return (scaleX * x + centerX);
- }
-
- private static float getYPrint(float y) {
- return (scaleY * y + centerY);
- }
-
- static class Particle {
-
- private float x, y;
- private float lastX, lastY;
- private final int size;
- private final int color;
- private final float direction;
-
- public Particle(float x, float y, int size, int color) {
- this.x = x;
- this.y = y;
- this.color = color;
- this.size = size;
- this.lastX = getXPrint(x);
- this.lastY = getYPrint(y);
- this.direction = (game.random(0.1f, 1) * (game.random(1) > 0.5f ? 1 : -1));
-
- }
-
- void update(float step) {
- float xDelta = (float) getSlopeX(x, y);
- float yDelta = (float) getSlopeY(x, y);
- x += direction * xDelta * step;
- y += direction * yDelta * step;
- }
- }
- }
}
diff --git a/src/main/java/project_16x16/scene/MultiplayerClientMenu.java b/src/main/java/project_16x16/scene/MultiplayerClientMenu.java
index 219ca5a3..3992bf9e 100644
--- a/src/main/java/project_16x16/scene/MultiplayerClientMenu.java
+++ b/src/main/java/project_16x16/scene/MultiplayerClientMenu.java
@@ -5,21 +5,19 @@
import processing.core.PConstants;
import processing.event.KeyEvent;
import processing.event.MouseEvent;
-import project_16x16.Constants;
import project_16x16.SideScroller;
import project_16x16.SideScroller.GameScenes;
import project_16x16.multiplayer.Multiplayer;
-import project_16x16.ui.Button;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
import project_16x16.ui.Notifications;
import project_16x16.ui.TextInputField;
public class MultiplayerClientMenu extends PScene {
- public TextInputField ipInput;
- public Button pressMenu;
- public Button pressConnect;
-
- private SideScroller game;
+ private final TextInputField ipInput;
+ private final MenuNav nav;
+ private final SideScroller game;
private static final Pattern pattern;
@@ -31,71 +29,57 @@ public MultiplayerClientMenu(SideScroller sideScroller) {
super(sideScroller);
game = sideScroller;
- pressMenu = new Button(sideScroller);
- pressConnect = new Button(sideScroller);
+ final int cx = game.width / 2;
ipInput = new TextInputField(sideScroller);
+ ipInput.set(cx, game.height / 2 - 40, 440, 44);
+ ipInput.setPlaceholder("127.0.0.1:8080");
- ipInput.setPosition(applet.width / 2, applet.height / 2 - 240);
- ipInput.setWidth(300);
-
- pressConnect.setText("Connect");
- pressConnect.setPosition(applet.width / 2, applet.height / 2 - 80);
- pressConnect.setTextSize(40);
- pressConnect.setSize(300, 100);
+ nav = new MenuNav(sideScroller).setLetterKeys(false); // letters are typed into the text field
+ nav.button("Connect", cx, game.height / 2 + 50, 440, 80, 36, this::submit);
+ nav.button("Back", cx, game.height / 2 + 150, 440, 80, 36, () -> game.swapToScene(GameScenes.MAIN_MENU));
+ }
- pressMenu.setText("Back to menu");
- pressMenu.setPosition(applet.width / 2, applet.height / 2 + 240);
- pressMenu.setTextSize(40);
- pressMenu.setSize(300, 100);
+ private void submit() {
+ if (pattern.matcher(ipInput.getText()).matches()) {
+ String ip = ipInput.getText().split(":")[0];
+ int port = Integer.valueOf(ipInput.getText().split(":")[1]);
+ try {
+ Multiplayer m = new Multiplayer(game, ip, port, false);
+ ((GameplayScene) (GameScenes.GAME.getScene())).setupMultiplayer(m);
+ game.swapToScene(GameScenes.GAME);
+ } catch (Exception e) {
+ Notifications.addNotification("ERROR", "todo"); // TODO
+ }
+ } else {
+ Notifications.addNotification("Invalid IP", "Include IP and port, eg:\n127.0.0.1:8080");
+ }
}
@Override
public void drawUI() {
- background(Constants.Colors.MENU_GREY);
+ MenuStyle.panel(game, "JOIN GAME");
+ MenuStyle.caption(game, "Address of the host (ip:port)", game.width / 2f, game.height / 2f - 90);
ipInput.update();
ipInput.display();
- pressMenu.manDisplay();
- pressConnect.manDisplay();
-
- }
-
- public void update() {
- pressMenu.update();
- if (pressMenu.hover()) {
- game.swapToScene(GameScenes.MAIN_MENU);
- }
-
- pressConnect.update();
- if (pressConnect.hover()) {
- if (pattern.matcher(ipInput.getText()).matches()) {
- String ip = ipInput.getText().split(":")[0];
- int port = Integer.valueOf(ipInput.getText().split(":")[1]);
- try {
- Multiplayer m = new Multiplayer(game, ip, port, false);
- ((GameplayScene) (GameScenes.GAME.getScene())).setupMultiplayer(m);
- game.swapToScene(GameScenes.GAME);
- } catch (Exception e) {
- Notifications.addNotification("ERROR", "todo"); // TODO
- }
- } else {
- Notifications.addNotification("Invalid IP", "Include IP and port, eg:\n127.0.0.1:8080");
- }
- }
+ nav.display();
}
@Override
void mouseReleased(MouseEvent e) {
- update();
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
- switch (e.getKeyCode()) {
- case PConstants.ESC: // Pause
- game.returnScene();
- break;
- default:
- break;
+ if (e.getKeyCode() == PConstants.ENTER && nav.selectedButton() == null) {
+ submit();
+ return;
+ }
+ if (nav.keyReleased(e)) {
+ return;
+ }
+ if (e.getKeyCode() == PConstants.ESC) {
+ game.returnScene();
}
}
}
diff --git a/src/main/java/project_16x16/scene/MultiplayerHostMenu.java b/src/main/java/project_16x16/scene/MultiplayerHostMenu.java
index de2bcd8c..e2acc18e 100644
--- a/src/main/java/project_16x16/scene/MultiplayerHostMenu.java
+++ b/src/main/java/project_16x16/scene/MultiplayerHostMenu.java
@@ -5,21 +5,19 @@
import processing.core.PConstants;
import processing.event.KeyEvent;
import processing.event.MouseEvent;
-import project_16x16.Constants;
import project_16x16.SideScroller;
import project_16x16.SideScroller.GameScenes;
import project_16x16.multiplayer.Multiplayer;
-import project_16x16.ui.Button;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
import project_16x16.ui.Notifications;
import project_16x16.ui.TextInputField;
public class MultiplayerHostMenu extends PScene {
- public TextInputField ipInput;
- public Button pressMenu;
- public Button pressHost;
-
- private SideScroller game;
+ private final TextInputField ipInput;
+ private final MenuNav nav;
+ private final SideScroller game;
private static final Pattern pattern;
@@ -31,72 +29,57 @@ public MultiplayerHostMenu(SideScroller sideScroller) {
super(sideScroller);
game = sideScroller;
- pressMenu = new Button(sideScroller);
+ final int cx = game.width / 2;
ipInput = new TextInputField(sideScroller);
- pressHost = new Button(sideScroller);
-
- ipInput.setPosition(applet.width / 2, applet.height / 2 - 240);
- ipInput.setWidth(300);
+ ipInput.set(cx, game.height / 2 - 40, 440, 44);
+ ipInput.setPlaceholder("127.0.0.1:8080");
- pressHost.setText("Host");
- pressHost.setPosition(applet.width / 2, applet.height / 2 - 80);
- pressHost.setTextSize(40);
- pressHost.setSize(300, 100);
-
- pressMenu.setText("Back to menu");
- pressMenu.setPosition(applet.width / 2, applet.height / 2 + 240);
- pressMenu.setTextSize(40);
- pressMenu.setSize(300, 100);
+ nav = new MenuNav(sideScroller).setLetterKeys(false); // letters are typed into the text field
+ nav.button("Host", cx, game.height / 2 + 50, 440, 80, 36, this::submit);
+ nav.button("Back", cx, game.height / 2 + 150, 440, 80, 36, () -> game.swapToScene(GameScenes.MAIN_MENU));
}
- @Override
- public void draw() {
+ private void submit() {
+ if (pattern.matcher(ipInput.getText()).matches()) {
+ String ip = ipInput.getText().split(":")[0]; // TODO does host need to give IP?
+ int port = Integer.valueOf(ipInput.getText().split(":")[1]);
+ try {
+ Multiplayer m = new Multiplayer(game, ip, port, true);
+ ((GameplayScene) GameScenes.GAME.getScene()).setupMultiplayer(m);
+ game.swapToScene(GameScenes.GAME);
+ } catch (Exception e) {
+ Notifications.addNotification("ERROR", "todo"); // TODO
+ }
+ } else {
+ Notifications.addNotification("Invalid IP", "Include IP and port, eg:\n127.0.0.1:8080");
+ }
}
@Override
public void drawUI() {
- background(Constants.Colors.MENU_GREY);
+ MenuStyle.panel(game, "HOST GAME");
+ MenuStyle.caption(game, "Address to host on (ip:port)", game.width / 2f, game.height / 2f - 90);
ipInput.update();
ipInput.display();
- pressMenu.manDisplay();
- pressHost.manDisplay();
- }
-
- public void update() {
- pressMenu.update();
- if (pressMenu.hover()) {
- game.swapToScene(GameScenes.MAIN_MENU);
- }
- if (pressHost.hover()) {
- if (pattern.matcher(ipInput.getText()).matches()) {
- String ip = ipInput.getText().split(":")[0]; // TODO does host need to give IP?
- int port = Integer.valueOf(ipInput.getText().split(":")[1]);
- try {
- Multiplayer m = new Multiplayer(game, ip, port, true);
- ((GameplayScene) GameScenes.GAME.getScene()).setupMultiplayer(m);
- game.swapToScene(GameScenes.GAME);
- } catch (Exception e) {
- Notifications.addNotification("ERROR", "todo"); // TODO
- }
- } else {
- Notifications.addNotification("Invalid IP", "Include IP and port, eg:\n127.0.0.1:8080");
- }
- }
+ nav.display();
}
@Override
void mouseReleased(MouseEvent e) {
- update();
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
- switch (e.getKeyCode()) {
- case PConstants.ESC: // Pause
- game.returnScene();
- break;
- default:
- break;
+ if (e.getKeyCode() == PConstants.ENTER && nav.selectedButton() == null) {
+ submit();
+ return;
+ }
+ if (nav.keyReleased(e)) {
+ return;
+ }
+ if (e.getKeyCode() == PConstants.ESC) {
+ game.returnScene();
}
}
}
diff --git a/src/main/java/project_16x16/scene/MultiplayerMenu.java b/src/main/java/project_16x16/scene/MultiplayerMenu.java
index 5d4715f4..28727e64 100644
--- a/src/main/java/project_16x16/scene/MultiplayerMenu.java
+++ b/src/main/java/project_16x16/scene/MultiplayerMenu.java
@@ -3,10 +3,11 @@
import processing.core.PConstants;
import processing.event.KeyEvent;
import processing.event.MouseEvent;
-import project_16x16.Constants;
import project_16x16.SideScroller;
import project_16x16.SideScroller.GameScenes;
import project_16x16.ui.Button;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
public class MultiplayerMenu extends PScene {
@@ -15,6 +16,7 @@ public class MultiplayerMenu extends PScene {
public Button pressMenu;
private SideScroller game;
+ private final MenuNav nav;
public MultiplayerMenu(SideScroller sideScroller) {
super(sideScroller);
@@ -25,53 +27,43 @@ public MultiplayerMenu(SideScroller sideScroller) {
pressMenu = new Button(sideScroller);
pressHost.setText("Host a game");
- pressHost.setPosition(applet.width / 2, applet.height / 2 - 240);
+ pressHost.setPosition(applet.width / 2, applet.height / 2 - 90);
pressHost.setTextSize(40);
- pressHost.setSize(300, 100);
+ pressHost.setSize(440, 90);
pressClient.setText("Connect to a game");
- pressClient.setPosition(applet.width / 2, applet.height / 2 - 80);
+ pressClient.setPosition(applet.width / 2, applet.height / 2 + 30);
pressClient.setTextSize(40);
- pressClient.setSize(400, 100);
+ pressClient.setSize(440, 90);
pressMenu.setText("Back to menu");
- pressMenu.setPosition(applet.width / 2, applet.height / 2 + 240);
+ pressMenu.setPosition(applet.width / 2, applet.height / 2 + 150);
pressMenu.setTextSize(40);
- pressMenu.setSize(300, 100);
+ pressMenu.setSize(440, 90);
+
+ nav = new MenuNav(sideScroller);
+ nav.add(pressHost, () -> game.swapToScene(GameScenes.HOST_MENU));
+ nav.add(pressClient, () -> game.swapToScene(GameScenes.CLIENT_MENU));
+ nav.add(pressMenu, () -> game.swapToScene(GameScenes.MAIN_MENU));
}
@Override
public void drawUI() {
- background(Constants.Colors.MENU_GREY);
- pressHost.manDisplay();
- pressClient.manDisplay();
- pressMenu.manDisplay();
- }
-
- public void update() {
- pressHost.update();
- if (pressHost.hover()) {
- game.swapToScene(GameScenes.HOST_MENU);
- }
-
- pressClient.update();
- if (pressClient.hover()) {
- game.swapToScene(GameScenes.CLIENT_MENU);
- }
-
- pressMenu.update();
- if (pressMenu.hover()) {
- game.swapToScene(GameScenes.MAIN_MENU);
- }
+ MenuStyle.backdrop(game);
+ MenuStyle.title(game, "MULTIPLAYER", game.height / 2f - 210);
+ nav.display();
}
@Override
void mouseReleased(MouseEvent e) {
- update();
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
+ if (nav.keyReleased(e)) {
+ return;
+ }
switch (e.getKeyCode()) {
case PConstants.ESC: // Pause
game.returnScene();
diff --git a/src/main/java/project_16x16/scene/PauseMenu.java b/src/main/java/project_16x16/scene/PauseMenu.java
index 2e7a83a8..16d87362 100644
--- a/src/main/java/project_16x16/scene/PauseMenu.java
+++ b/src/main/java/project_16x16/scene/PauseMenu.java
@@ -11,6 +11,8 @@
import project_16x16.SideScroller.GameScenes;
import project_16x16.Utility;
import project_16x16.ui.Button;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
/**
* @author Quillbert182
@@ -23,9 +25,7 @@ public class PauseMenu extends PScene {
public Button pressSettings; // TODO add settings menu
private SideScroller game;
- private PImage cache;
-
- protected boolean switched = false;
+ private final MenuNav nav;
public PauseMenu(SideScroller sideScroller) {
super(sideScroller);
@@ -36,63 +36,51 @@ public PauseMenu(SideScroller sideScroller) {
pressMenu = new Button(sideScroller);
pressResume.setText("Resume Game");
- pressResume.setPosition(applet.width / 2, applet.height / 2 - 150);
+ pressResume.setPosition(applet.width / 2, applet.height / 2 - 90);
pressResume.setTextSize(40);
- pressResume.setSize(300, 100);
+ pressResume.setSize(360, 90);
pressSettings.setText("Settings");
- pressSettings.setPosition(applet.width / 2, applet.height / 2);
+ pressSettings.setPosition(applet.width / 2, applet.height / 2 + 30);
pressSettings.setTextSize(40);
- pressSettings.setSize(300, 100);
+ pressSettings.setSize(360, 90);
pressMenu.setText("Main Menu");
pressMenu.setPosition(applet.width / 2, applet.height / 2 + 150);
pressMenu.setTextSize(40);
- pressMenu.setSize(300, 100);
+ pressMenu.setSize(360, 90);
+
+ nav = new MenuNav(sideScroller);
+ nav.add(pressResume, () -> game.returnScene());
+ nav.add(pressSettings, () -> game.swapToScene(GameScenes.SETTINGS_MENU));
+ nav.add(pressMenu, () -> game.swapToScene(GameScenes.MAIN_MENU));
}
- @Override
- public void switchTo() {
- super.switchTo();
- if (!switched) {
- cache = applet.get(); // when game is paused, cache the game screen.
- cache = Utility.blur(cache, 3, 2); // blur game screen
- }
- switched = true;
+ /**
+ * Sets the game frame shown (blurred) behind the menu, and the settings menus
+ * opened from it. Called by the game when it pauses.
+ */
+ public void setBackdrop(PImage frame) {
+ MenuStyle.setGameBackdrop(Utility.blur(frame, 3, 2));
}
@Override
public void drawUI() {
- applet.image(cache, applet.width / 2, applet.height / 2); // draw cached & blurred game
- pressResume.manDisplay();
- pressSettings.manDisplay();
- pressMenu.manDisplay();
- }
-
- private void update() {
- pressResume.update();
- if (pressResume.hover()) {
- game.returnScene();
- }
-
- pressSettings.update();
- if (pressSettings.hover()) {
- game.swapToScene(GameScenes.SETTINGS_MENU);
- }
-
- pressMenu.update();
- if (pressMenu.hover()) {
- game.swapToScene(GameScenes.MAIN_MENU);
- }
+ MenuStyle.backdrop(game);
+ MenuStyle.title(game, "PAUSED", game.height / 2f - 210);
+ nav.display();
}
@Override
void mouseReleased(MouseEvent e) {
- update();
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
+ if (nav.keyReleased(e)) {
+ return;
+ }
switch (e.getKeyCode()) {
case PConstants.ESC: // Pause
game.returnScene();
diff --git a/src/main/java/project_16x16/scene/Settings.java b/src/main/java/project_16x16/scene/Settings.java
index 4714c608..c827c870 100644
--- a/src/main/java/project_16x16/scene/Settings.java
+++ b/src/main/java/project_16x16/scene/Settings.java
@@ -5,157 +5,52 @@
import processing.event.MouseEvent;
import project_16x16.SideScroller;
import project_16x16.SideScroller.GameScenes;
-import project_16x16.ui.Button;
-import project_16x16.ui.Notifications;
-import project_16x16.ui.NumberInputField;
+import project_16x16.ui.MenuNav;
+import project_16x16.ui.MenuStyle;
/**
- * WIP - the settings menu
+ * The settings hub, linking to each settings category.
*
* @author micycle1
*
*/
public class Settings extends PScene {
- // Window
- private final int WINDOW_X_POS = (int) applet.gameResolution.x / 2;
- private final int WINDOW_Y_POS = (int) applet.gameResolution.y / 2;
- // Buttons
- private final int GRAPHICS_Y_OFFSET = 10;
- private final int SOUND_Y_OFFSET = 50;
- private final int CONTROL_Y_OFFSET = 90;
- private final int WINDOWSIZE_Y_OFFSET = 130;
- private final int QUIT_Y_OFFSET = 320;
- private final int APPLY_Y_OFFSET = 280;
- // Text Display
- private Button quit;
- private Button apply;
- private Button pressGraphicsOptions;
- private Button pressSoundOptions;
- private Button pressControlsOptions;
- private Button pressMiscOptions;
-
- private NumberInputField windowSizeX;
-
- private SideScroller game;
+ private final SideScroller game;
+ private final MenuNav nav;
public Settings(SideScroller sideScroller) {
super(sideScroller);
game = sideScroller;
- quit = new Button(sideScroller);
- quit.setText("Quit");
- quit.setPosition(WINDOW_X_POS, WINDOW_Y_POS + QUIT_Y_OFFSET);
-
- apply = new Button(applet);
- apply.setText("Apply");
- apply.setPosition(WINDOW_X_POS, WINDOW_Y_POS + APPLY_Y_OFFSET);
-
- pressGraphicsOptions = new Button(applet);
- pressGraphicsOptions.setText("Configure Graphics");
- pressGraphicsOptions.setPosition(WINDOW_X_POS, WINDOW_Y_POS + GRAPHICS_Y_OFFSET);
-
- pressSoundOptions = new Button(applet);
- pressSoundOptions.setText("Configure Audio and Volume");
- pressSoundOptions.setPosition(WINDOW_X_POS, WINDOW_Y_POS + SOUND_Y_OFFSET);
-
- pressControlsOptions = new Button(applet);
- pressControlsOptions.setText("Configure Controls");
- pressControlsOptions.setPosition(WINDOW_X_POS, WINDOW_Y_POS + CONTROL_Y_OFFSET);
-
- windowSizeX = new NumberInputField(sideScroller);
- windowSizeX.setPosition(WINDOW_X_POS, WINDOW_Y_POS + WINDOWSIZE_Y_OFFSET);
+ final int cx = game.width / 2;
+ final int top = 230;
+ final int step = 100;
+ nav = new MenuNav(game);
+ nav.button("Graphics", cx, top, 440, 80, 36, () -> game.swapToScene(GameScenes.GRAPHICS_SETTINGS));
+ nav.button("Audio", cx, top + step, 440, 80, 36, () -> game.swapToScene(GameScenes.AUDIO_SETTINGS));
+ nav.button("Controls", cx, top + 2 * step, 440, 80, 36, () -> game.swapToScene(GameScenes.CONTROLS_SETTINGS));
+ nav.button("Back", cx, top + 3 * step + 20, 440, 80, 36, game::returnScene);
}
@Override
public void drawUI() {
- displayWindow();
-// // Display Window Title
- applet.fill(255);
- applet.textSize(60);
- applet.textAlign(CENTER, TOP);
- applet.text("Settings", applet.gameResolution.x / 2, 20);
-
- quit.display();
- apply.display();
- pressGraphicsOptions.display();
- pressSoundOptions.display();
- pressControlsOptions.display();
-
- OptionText("Graphics Options:", -WINDOW_X_POS / 2 + 50, -WINDOW_Y_POS / 2);
- OptionText(dynamicPadding("Resolution -", 22), -WINDOW_X_POS / 2 + 50, -WINDOW_Y_POS / 2 + 50);
- OptionText(dynamicPadding("Aspect Ratio -", 20), -WINDOW_X_POS / 2 + 50, -WINDOW_Y_POS / 2 + 70);
- OptionText(dynamicPadding("Display Mode -", 20), -WINDOW_X_POS / 2 + 50, -WINDOW_Y_POS / 2 + 90);
-
- windowSizeX.update();
- windowSizeX.display();
+ MenuStyle.panel(game, "SETTINGS");
+ nav.display();
}
@Override
void mouseReleased(MouseEvent e) {
- update();
- }
-
- private void update() {
- quit.update();
- apply.update();
- pressGraphicsOptions.update();
- pressSoundOptions.update();
- pressControlsOptions.update();
- if (quit.hover()) {
- game.returnScene();
- return;
- }
- if (apply.hover()) {
- // game.resizeWindow(windowSizeX.getValue(), 720); // TODO change
- Notifications.addNotification("Options Applied", "Your configuration has been successfully applied.");
- return;
- }
- if (pressGraphicsOptions.hover()) {
- game.swapToScene(GameScenes.GRAPHICS_SETTINGS);
- }
- if (pressSoundOptions.hover()) {
- game.swapToScene(GameScenes.AUDIO_SETTINGS);
- }
- if (pressControlsOptions.hover()) {
- game.swapToScene(GameScenes.CONTROLS_SETTINGS);
- }
- }
-
- private void displayWindow() {
- background(19, 23, 35);
- // Display Window
- applet.fill(29, 33, 45);
- applet.stroke(47, 54, 73);
- applet.strokeWeight(8);
- applet.rect(WINDOW_X_POS, WINDOW_Y_POS, applet.gameResolution.x * 0.66f - 8, applet.gameResolution.y - 8);
- }
-
- private void OptionText(String toDisplay, int x, int y) { // Display the text at the bottom
- applet.fill(255);
- applet.textSize(30);
- applet.textAlign(CENTER, CENTER);
- applet.text(toDisplay, WINDOW_X_POS + x, WINDOW_Y_POS + y);
- }
-
- private String dynamicPadding(String textToPad, int charLimit) {
- int paddingAmount = Math.abs(textToPad.length() - charLimit);
- String textPad = textToPad;
- for (int i = 0; i < paddingAmount; i++) {
- textPad = " " + textPad;
- }
- return textPad;
+ nav.mouseReleased();
}
@Override
void keyReleased(KeyEvent e) {
- switch (e.getKeyCode()) {
- case PConstants.ESC: // Pause
- game.returnScene();
- break;
- default:
- break;
+ if (nav.keyReleased(e)) {
+ return;
+ }
+ if (e.getKeyCode() == PConstants.ESC) {
+ game.returnScene();
}
}
-
}
diff --git a/src/main/java/project_16x16/scene/TilePalette.java b/src/main/java/project_16x16/scene/TilePalette.java
new file mode 100644
index 00000000..b1944fb6
--- /dev/null
+++ b/src/main/java/project_16x16/scene/TilePalette.java
@@ -0,0 +1,375 @@
+package project_16x16.scene;
+
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+
+import processing.core.PApplet;
+import processing.core.PConstants;
+import processing.core.PImage;
+import processing.core.PVector;
+import processing.event.MouseEvent;
+import project_16x16.PClass;
+import project_16x16.SideScroller;
+import project_16x16.Tileset;
+import project_16x16.components.Tile;
+import project_16x16.components.Tile.TileType;
+import project_16x16.objects.EditorItem;
+
+/**
+ * The level editor's tile palette: every placeable tile, grouped by type into
+ * tabs, in a panel docked to the bottom third of the screen. Tiles are dragged
+ * out of it (as the {@link EditorItem}) into the level. The Favourites tab is a
+ * user-curated shortlist: tiles are added to it by dropping them on its tab (or
+ * on its grid, while it's open), and removed by right-clicking them.
+ */
+public class TilePalette extends PClass {
+
+ private enum Category {
+ FAVOURITES("Favourites", null), BLOCKS("Blocks", TileType.COLLISION), DECOR("Decor", TileType.BACKGROUND), OBJECTS("Objects", TileType.OBJECT);
+
+ private final String label;
+ private final TileType type;
+
+ Category(String label, TileType type) {
+ this.label = label;
+ this.type = type;
+ }
+ }
+
+ private static final int MARGIN = 12; // panel inset from the screen edges
+ private static final int PADDING = 14; // panel contents inset
+ private static final int HEADER_HEIGHT = 44;
+ private static final int CELL = 64;
+ private static final int CELL_GAP = 6;
+ private static final int PITCH = CELL + CELL_GAP;
+ private static final int SCROLLBAR_WIDTH = 6;
+ private static final int CLOSE_SIZE = 28;
+
+ private static final int PANEL_FILL = 0xFF1D212D;
+ private static final int PANEL_STROKE = 0xFF4A5163;
+ private static final int CELL_FILL = 0xFF2A3042;
+ private static final int ACCENT = 0xFF7CC8FF;
+
+ private static final String[] DEFAULT_FAVOURITES = { "Metal", "Metal_Walk_Left:0", "Metal_Walk_Middle:0", "Metal_Walk_Middle:1",
+ "Metal_Walk_Right:0", "XBox" };
+
+ private final EditorItem editorItem;
+ private final Runnable onClose;
+ private final EnumMap> tiles = new EnumMap<>(Category.class);
+
+ private Category category = Category.FAVOURITES;
+ private int scrollRow;
+ private boolean draggingScrollbar;
+
+ // Layout (screen space), recomputed each frame since the resolution can change
+ private float left, top, right, bottom;
+ private float gridLeft, gridTop;
+ private int columns, rows;
+ private float favouritesLeft, favouritesRight, tabTop, tabBottom; // Favourites tab (drop target)
+
+ /**
+ * @param onClose called when the palette's close button is clicked
+ */
+ public TilePalette(SideScroller sideScroller, EditorItem editorItem, Runnable onClose) {
+ super(sideScroller);
+ this.editorItem = editorItem;
+ this.onClose = onClose;
+ for (Category c : Category.values()) {
+ if (c != Category.FAVOURITES) {
+ tiles.put(c, Tileset.getAllTiles(c.type));
+ }
+ }
+ final List favourites = new ArrayList<>();
+ for (String name : DEFAULT_FAVOURITES) {
+ final Tile tile = findTile(name);
+ if (tile != null) {
+ favourites.add(tile);
+ }
+ }
+ tiles.put(Category.FAVOURITES, favourites);
+ }
+
+ private Tile findTile(String name) {
+ for (Category c : Category.values()) {
+ if (c != Category.FAVOURITES) {
+ for (Tile tile : tiles.get(c)) {
+ if (tile.getName().equals(name)) {
+ return tile;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Takes a tile dropped from a drag: one dropped on the Favourites tab (or its
+ * grid, while open) is added to the favourites.
+ *
+ * @return whether the palette took the tile
+ */
+ public boolean drop(String tileName, PVector screen) {
+ final boolean onTab = within(screen, favouritesLeft, tabTop, favouritesRight, tabBottom);
+ final boolean onGrid = category == Category.FAVOURITES && contains(screen) && screen.y > top + HEADER_HEIGHT;
+ if (!onTab && !onGrid) {
+ return false;
+ }
+ final List favourites = tiles.get(Category.FAVOURITES);
+ final Tile tile = findTile(tileName);
+ if (tile != null && !favourites.contains(tile)) {
+ favourites.add(tile);
+ }
+ return true;
+ }
+
+ /**
+ * @return whether the screen position is over the palette
+ */
+ public boolean contains(PVector screen) {
+ layout();
+ return screen.x >= left && screen.x <= right && screen.y >= top && screen.y <= bottom;
+ }
+
+ public void mouseWheel(MouseEvent event) {
+ scrollRow += event.getCount();
+ }
+
+ /**
+ * Draws the palette and handles its mouse input.
+ */
+ public void display() {
+ layout();
+ final PVector mouse = applet.getMouseCoordScreen();
+
+ applet.pushStyle();
+ applet.rectMode(PConstants.CORNERS);
+ applet.imageMode(PConstants.CENTER);
+
+ // Panel
+ applet.strokeWeight(4);
+ applet.stroke(PANEL_STROKE);
+ applet.fill(PANEL_FILL);
+ applet.rect(left, top, right, bottom, 10);
+
+ displayHeader(mouse);
+ final Tile hovered = displayGrid(mouse);
+
+ if (hovered != null && !editorItem.focus) {
+ displayTooltip(hovered.getName(), mouse);
+ }
+ applet.popStyle();
+ }
+
+ private void layout() {
+ left = MARGIN;
+ right = applet.width - MARGIN;
+ bottom = applet.height - MARGIN;
+ top = applet.height * 2 / 3f;
+
+ gridLeft = left + PADDING;
+ gridTop = top + HEADER_HEIGHT + 10;
+ final float gridWidth = right - PADDING - SCROLLBAR_WIDTH - 10 - gridLeft;
+ columns = Math.max(1, (int) ((gridWidth + CELL_GAP) / PITCH));
+ rows = Math.max(1, (int) ((bottom - PADDING - gridTop + CELL_GAP) / PITCH));
+ }
+
+ /**
+ * Category tabs, usage hint and close button.
+ */
+ private void displayHeader(PVector mouse) {
+ tabTop = top + 8;
+ tabBottom = top + HEADER_HEIGHT - 4;
+ applet.textSize(22);
+ applet.textAlign(PConstants.CENTER, PConstants.CENTER);
+ float x = left + PADDING;
+ for (Category c : Category.values()) {
+ final String label = c.label + " " + tiles.get(c).size();
+ final float w = applet.textWidth(label) + 28;
+ final boolean hover = within(mouse, x, tabTop, x + w, tabBottom);
+ if (hover && applet.mousePressEvent && c != category) {
+ category = c;
+ scrollRow = 0;
+ }
+ applet.noStroke();
+ applet.fill(c == category ? 0xFF323A50 : hover ? 0xFF282E40 : PANEL_FILL);
+ if (c == Category.FAVOURITES) {
+ favouritesLeft = x;
+ favouritesRight = x + w;
+ if (editorItem.focus) { // a tile is being dragged: show this tab accepts it
+ applet.stroke(ACCENT);
+ applet.strokeWeight(2);
+ if (hover) {
+ applet.fill(0xFF2E4A63);
+ }
+ }
+ }
+ applet.rect(x, tabTop, x + w, tabBottom, 6);
+ if (c == category) {
+ applet.fill(ACCENT);
+ applet.rect(x + 8, tabBottom - 3, x + w - 8, tabBottom);
+ }
+ applet.fill(c == category ? 255 : 160);
+ applet.text(label, x + w / 2, (tabTop + tabBottom) / 2 - 2);
+ x += w + 6;
+ }
+
+ // Close button
+ final float cx = right - PADDING - CLOSE_SIZE / 2f;
+ final float cy = (tabTop + tabBottom) / 2;
+ final boolean closeHover = within(mouse, cx - CLOSE_SIZE / 2f, cy - CLOSE_SIZE / 2f, cx + CLOSE_SIZE / 2f, cy + CLOSE_SIZE / 2f);
+ applet.noStroke();
+ applet.fill(closeHover ? 0xFF8C3A3A : 0xFF323A50);
+ applet.rect(cx - CLOSE_SIZE / 2f, cy - CLOSE_SIZE / 2f, cx + CLOSE_SIZE / 2f, cy + CLOSE_SIZE / 2f, 6);
+ applet.stroke(255, closeHover ? 255 : 190);
+ applet.strokeWeight(3);
+ final float r = CLOSE_SIZE / 2f - 9;
+ applet.line(cx - r, cy - r, cx + r, cy + r);
+ applet.line(cx - r, cy + r, cx + r, cy - r);
+ if (closeHover && applet.mousePressEvent) {
+ onClose.run();
+ }
+
+ // Usage hint (only if there's room)
+ applet.textSize(18);
+ applet.textAlign(PConstants.RIGHT, PConstants.CENTER);
+ final String hint = category == Category.FAVOURITES ? "drop tiles on this tab to add - right-click to remove"
+ : "drag a tile into the level, or onto Favourites";
+ if (cx - CLOSE_SIZE - applet.textWidth(hint) > x + 12) {
+ applet.fill(255, 110);
+ applet.text(hint, cx - CLOSE_SIZE, cy - 2);
+ }
+
+ applet.stroke(PANEL_STROKE);
+ applet.strokeWeight(2);
+ applet.line(left + PADDING, top + HEADER_HEIGHT + 2, right - PADDING, top + HEADER_HEIGHT + 2);
+ }
+
+ /**
+ * Tiles of the current category, and the scrollbar.
+ *
+ * @return the tile under the mouse, if any
+ */
+ private Tile displayGrid(PVector mouse) {
+ final List list = tiles.get(category);
+ final int totalRows = (list.size() + columns - 1) / columns;
+ final int maxScroll = Math.max(0, totalRows - rows);
+ updateScrollbar(mouse, totalRows, maxScroll);
+ scrollRow = PApplet.constrain(scrollRow, 0, maxScroll);
+
+ Tile hovered = null;
+ Tile removed = null;
+ for (int r = 0; r < rows; r++) {
+ for (int c = 0; c < columns; c++) {
+ final int index = (scrollRow + r) * columns + c;
+ if (index >= list.size()) {
+ break;
+ }
+ final Tile tile = list.get(index);
+ final float x = gridLeft + c * PITCH;
+ final float y = gridTop + r * PITCH;
+ final boolean hover = within(mouse, x, y, x + CELL, y + CELL);
+ final boolean held = editorItem.focus && tile.getName().equals(editorItem.id);
+
+ applet.fill(CELL_FILL);
+ if (hover || held) {
+ applet.stroke(ACCENT);
+ applet.strokeWeight(2);
+ } else {
+ applet.noStroke();
+ }
+ applet.rect(x, y, x + CELL, y + CELL, 6);
+ drawTile(tile.getPImage(), x + CELL / 2f, y + CELL / 2f);
+
+ if (hover) {
+ hovered = tile;
+ if (applet.mousePressEvent && applet.mouseButton == PConstants.LEFT) { // pick up
+ editorItem.setTile(tile.getName());
+ editorItem.focus = true;
+ } else if (applet.mousePressEvent && applet.mouseButton == PConstants.RIGHT && category == Category.FAVOURITES) {
+ removed = tile;
+ }
+ }
+ }
+ }
+ if (removed != null) {
+ list.remove(removed);
+ }
+ if (list.isEmpty()) {
+ applet.textSize(20);
+ applet.textAlign(PConstants.LEFT, PConstants.TOP);
+ applet.fill(255, 110);
+ applet.text("No favourites yet: drag tiles from the other tabs onto the Favourites tab.", gridLeft, gridTop + 8);
+ }
+ return hovered;
+ }
+
+ private void updateScrollbar(PVector mouse, int totalRows, int maxScroll) {
+ if (maxScroll == 0) {
+ draggingScrollbar = false;
+ return;
+ }
+ final float x = right - PADDING - SCROLLBAR_WIDTH;
+ final float trackTop = gridTop;
+ final float trackBottom = gridTop + rows * PITCH - CELL_GAP;
+ final float thumbHeight = Math.max(20, (trackBottom - trackTop) * rows / totalRows);
+
+ if (applet.mousePressEvent && within(mouse, x - 8, trackTop, x + SCROLLBAR_WIDTH + 8, trackBottom)) {
+ draggingScrollbar = true;
+ }
+ if (!applet.mousePressed) {
+ draggingScrollbar = false;
+ }
+ if (draggingScrollbar) {
+ final float t = PApplet.map(mouse.y, trackTop + thumbHeight / 2, trackBottom - thumbHeight / 2, 0, 1);
+ scrollRow = Math.round(PApplet.constrain(t, 0, 1) * maxScroll);
+ }
+ scrollRow = PApplet.constrain(scrollRow, 0, maxScroll);
+
+ final float thumbTop = trackTop + (trackBottom - trackTop - thumbHeight) * scrollRow / maxScroll;
+ applet.noStroke();
+ applet.fill(0xFF2A3042);
+ applet.rect(x, trackTop, x + SCROLLBAR_WIDTH, trackBottom, 3);
+ applet.fill(draggingScrollbar ? ACCENT : 0xFF6A7390);
+ applet.rect(x, thumbTop, x + SCROLLBAR_WIDTH, thumbTop + thumbHeight, 3);
+ }
+
+ /**
+ * Draws a tile scaled to fit its cell. Tiles are stored at 4x, so scaling in
+ * quarter steps keeps their pixels square.
+ */
+ private void drawTile(PImage image, float x, float y) {
+ final float size = Math.max(image.width, image.height);
+ final float fit = CELL - 12;
+ float scale = 0.75f;
+ while (scale > 0.25f && size * scale > fit) {
+ scale -= 0.25f;
+ }
+ if (size * scale > fit) {
+ scale = fit / size;
+ }
+ applet.image(image, x, y, image.width * scale, image.height * scale);
+ }
+
+ /**
+ * Drawn last, so it's above every tile.
+ */
+ private void displayTooltip(String text, PVector mouse) {
+ applet.textSize(18);
+ applet.textAlign(PConstants.LEFT, PConstants.CENTER);
+ final float w = applet.textWidth(text) + 16;
+ final float h = 28;
+ final float x = Math.min(mouse.x + 14, applet.width - w - 4);
+ final float y = mouse.y - h - 8; // above the cursor
+ applet.fill(12, 14, 20, 235);
+ applet.stroke(PANEL_STROKE);
+ applet.strokeWeight(2);
+ applet.rect(x, y, x + w, y + h, 4);
+ applet.fill(255);
+ applet.text(text, x + 8, y + h / 2 - 2);
+ }
+
+ private static boolean within(PVector p, float x1, float y1, float x2, float y2) {
+ return p.x >= x1 && p.x <= x2 && p.y >= y1 && p.y <= y2;
+ }
+}
diff --git a/src/main/java/project_16x16/scene/gameplaymodes/GameplayMode.java b/src/main/java/project_16x16/scene/gameplaymodes/GameplayMode.java
index d9e1e7c4..d0dee218 100644
--- a/src/main/java/project_16x16/scene/gameplaymodes/GameplayMode.java
+++ b/src/main/java/project_16x16/scene/gameplaymodes/GameplayMode.java
@@ -32,20 +32,16 @@ public void displayDestination() {
public void updateLocalPlayer(Player localPlayer) {
}
- public void displayGUISlots() {
- scene.displayGUISlots();
- }
-
- public void updateGUIButton(int xAnchor, PImage activeIcon, PImage inactiveIcon, GameModes mode, boolean isHighlighted) {
+ public void updateGUIButton(int x, int y, PImage activeIcon, PImage inactiveIcon, GameModes mode, boolean isHighlighted) {
if (getModeType().equals(mode)) {
- drawGUIButton(activeIcon, xAnchor, 120);
+ drawGUIButton(activeIcon, x, y);
} else if (isNotInvalidGUIButtonMode() && isHighlighted) {
if (scene.applet.mousePressEvent) {
scene.changeMode(mode);
}
- drawGUIButton(activeIcon, xAnchor, 120);
+ drawGUIButton(activeIcon, x, y);
} else {
- drawGUIButton(inactiveIcon, xAnchor, 120);
+ drawGUIButton(inactiveIcon, x, y);
}
}
@@ -59,6 +55,30 @@ protected void drawGUIButton(PImage icon, int x, int y) {
public abstract GameModes getModeType();
+ /**
+ * @return whether level objects can be selected and moved in this mode
+ */
+ public boolean allowsWorldEditing() {
+ return false;
+ }
+
+ /**
+ * @return whether the screen position is over UI belonging to this mode
+ */
+ public boolean isOverUI(PVector screen) {
+ return false;
+ }
+
+ /**
+ * Offers a tile dragged from the editor to this mode's UI.
+ *
+ * @return whether the UI took the tile (so it shouldn't be placed in the
+ * level)
+ */
+ public boolean dropTile(String tileName, PVector screen) {
+ return false;
+ }
+
public void updateGUI() {
}
diff --git a/src/main/java/project_16x16/scene/gameplaymodes/InventoryGameMode.java b/src/main/java/project_16x16/scene/gameplaymodes/InventoryGameMode.java
index 1567a035..693958c2 100644
--- a/src/main/java/project_16x16/scene/gameplaymodes/InventoryGameMode.java
+++ b/src/main/java/project_16x16/scene/gameplaymodes/InventoryGameMode.java
@@ -1,18 +1,24 @@
package project_16x16.scene.gameplaymodes;
+import processing.core.PImage;
+import processing.core.PVector;
import processing.event.MouseEvent;
+import project_16x16.objects.EditorItem;
import project_16x16.scene.GameplayScene;
import project_16x16.scene.GameplayScene.GameModes;
+import project_16x16.scene.TilePalette;
-public class InventoryGameMode extends GameplayMode {
+/**
+ * Level editing with the tile palette open. The level can still be edited
+ * around the palette.
+ */
+public class InventoryGameMode extends ModifyGameMode {
- public InventoryGameMode(GameplayScene gameplayScene) {
- super(gameplayScene);
- }
+ private final TilePalette palette;
- @Override
- public void enter() {
- scene.setZoomable(false);
+ public InventoryGameMode(GameplayScene gameplayScene, EditorItem editorItem) {
+ super(gameplayScene, editorItem);
+ palette = new TilePalette(gameplayScene.applet, editorItem, () -> gameplayScene.changeMode(GameModes.MODIFY));
}
@Override
@@ -21,21 +27,33 @@ public GameModes getModeType() {
}
@Override
- public void displayGUISlots() {
+ public boolean isOverUI(PVector screen) {
+ return palette.contains(screen);
+ }
+
+ @Override
+ public void updateGUIButton(int x, int y, PImage activeIcon, PImage inactiveIcon, GameModes mode, boolean isHighlighted) {
+ if (mode == GameModes.INVENTORY && isHighlighted && scene.applet.mousePressEvent) {
+ scene.changeMode(GameModes.MODIFY); // the palette's icon toggles it
+ }
+ super.updateGUIButton(x, y, activeIcon, inactiveIcon, mode, isHighlighted);
}
@Override
- protected boolean isNotInvalidGUIButtonMode() {
- return false;
+ public boolean dropTile(String tileName, PVector screen) {
+ return palette.drop(tileName, screen);
}
@Override
public void updateGUI() {
- scene.displayCreativeInventory();
+ palette.display();
+ super.updateGUI(); // dragged item is drawn over the palette
}
@Override
public void mouseWheelEvent(MouseEvent event) {
- scene.scrollInventoryBar(event);
+ if (palette.contains(scene.applet.getMouseCoordScreen())) {
+ palette.mouseWheel(event);
+ }
}
}
diff --git a/src/main/java/project_16x16/scene/gameplaymodes/ModifyGameMode.java b/src/main/java/project_16x16/scene/gameplaymodes/ModifyGameMode.java
index 710042aa..a626bcf4 100644
--- a/src/main/java/project_16x16/scene/gameplaymodes/ModifyGameMode.java
+++ b/src/main/java/project_16x16/scene/gameplaymodes/ModifyGameMode.java
@@ -28,6 +28,11 @@ public GameModes getModeType() {
return GameModes.MODIFY;
}
+ @Override
+ public boolean allowsWorldEditing() {
+ return true;
+ }
+
@Override
public void displayWorldEdit() {
scene.displayWorldEdit();
diff --git a/src/main/java/project_16x16/scene/gameplaymodes/PlayGameMode.java b/src/main/java/project_16x16/scene/gameplaymodes/PlayGameMode.java
index 6ff2d6de..d42d9857 100644
--- a/src/main/java/project_16x16/scene/gameplaymodes/PlayGameMode.java
+++ b/src/main/java/project_16x16/scene/gameplaymodes/PlayGameMode.java
@@ -18,6 +18,7 @@ public PlayGameMode(GameplayScene gameplayScene, Player localPlayer) {
@Override
public void enter() {
scene.setZoomable(true);
+ scene.applet.camera.setFollowObject(localPlayer);
}
@Override
diff --git a/src/main/java/project_16x16/ui/Button.java b/src/main/java/project_16x16/ui/Button.java
index b071e639..ea4ee1f3 100644
--- a/src/main/java/project_16x16/ui/Button.java
+++ b/src/main/java/project_16x16/ui/Button.java
@@ -2,6 +2,7 @@
import project_16x16.PClass;
import project_16x16.SideScroller;
+import project_16x16.Time;
import project_16x16.Utility;
/**
@@ -21,6 +22,15 @@ public class Button extends PClass {
protected boolean press;
public boolean blocked;
+ /** Highlighted via keyboard navigation (rendered like a hover). */
+ private boolean selected;
+ /** Eased 0..1 hover/selection amount, for animated transitions. */
+ private float hoverAmount;
+
+ private static final float HOVER_SMOOTHING = 17.3f; // per second; see Time.smoothing()
+ private static final float HOVER_GROWTH = 0.04f;
+ private static final int ACCENT = 0xFF7CC8FF;
+
int colorValues[];
/**
@@ -95,14 +105,38 @@ public void updateOnPress() {
}
private void displayColors() {
- if (hover()) {
- applet.stroke(colorValues[2]);
- applet.fill(colorValues[3]);
- } else {
- applet.stroke(colorValues[0]);
- applet.fill(colorValues[1]);
+ float target = (hover() || selected) ? 1 : 0;
+ hoverAmount = Time.damp(hoverAmount, target, HOVER_SMOOTHING);
+ if (Math.abs(target - hoverAmount) < 0.01f) {
+ hoverAmount = target;
}
- applet.rect(x, y, width, height);
+ float t = hoverAmount;
+ float grow = 1 + HOVER_GROWTH * t - (focus && hover() ? 0.03f : 0);
+ float w = width * grow;
+ float h = height * grow;
+ float radius = Math.min(14, Math.min(w, h) / 3);
+
+ // drop shadow
+ applet.noStroke();
+ applet.fill(0, 70);
+ applet.rect(x, y + 4, w, h, radius);
+
+ applet.stroke(applet.lerpColor(colorValues[0], hoverStroke(), t));
+ applet.fill(applet.lerpColor(colorValues[1], colorValues[3], t));
+ applet.rect(x, y, w, h, radius);
+ }
+
+ /** Hover outline is accented when the button uses its default palette. */
+ private int hoverStroke() {
+ return colorValues[2] == applet.color(47, 54, 73) ? ACCENT : colorValues[2];
+ }
+
+ public void setSelected(boolean selected) {
+ this.selected = selected;
+ }
+
+ public boolean isSelected() {
+ return selected;
}
public void displayTextColors() {
diff --git a/src/main/java/project_16x16/ui/Dropdown.java b/src/main/java/project_16x16/ui/Dropdown.java
new file mode 100644
index 00000000..39064904
--- /dev/null
+++ b/src/main/java/project_16x16/ui/Dropdown.java
@@ -0,0 +1,149 @@
+package project_16x16.ui;
+
+import processing.core.PConstants;
+import project_16x16.SideScroller;
+import project_16x16.Utility;
+
+/**
+ * A button that opens a list of choices. Add it to a {@link MenuNav} with an
+ * action of {@link #toggle()}, call {@link #drawList()} after the rest of the
+ * menu (so the list overlays other buttons), and route clicks and keys through
+ * {@link #click()} and {@link #key(int)} while {@link #isOpen()}.
+ */
+public final class Dropdown extends Button {
+
+ private static final int ACCENT = 0xFF7CC8FF;
+ private static final int ITEM_HEIGHT = 44;
+
+ private final String label;
+ private final String[] options;
+ private int selectedIndex;
+ private int highlight;
+ private boolean open;
+
+ public Dropdown(SideScroller sideScroller, String label, String[] options) {
+ super(sideScroller);
+ this.label = label;
+ this.options = options;
+ }
+
+ public int getSelectedIndex() {
+ return selectedIndex;
+ }
+
+ public void setSelectedIndex(int index) {
+ selectedIndex = Math.max(0, Math.min(options.length - 1, index));
+ highlight = selectedIndex;
+ }
+
+ public boolean isOpen() {
+ return open;
+ }
+
+ public void toggle() {
+ open = !open;
+ highlight = selectedIndex;
+ }
+
+ @Override
+ public void manDisplay() {
+ setText(label + ": " + options[selectedIndex]);
+ super.manDisplay();
+ // chevron
+ float cx = x + width / 2f - 30;
+ applet.noStroke();
+ applet.fill(255);
+ if (open) {
+ applet.triangle(cx - 8, y + 4, cx + 8, y + 4, cx, y - 6);
+ } else {
+ applet.triangle(cx - 8, y - 4, cx + 8, y - 4, cx, y + 6);
+ }
+ }
+
+ private float itemY(int i) {
+ return y + height / 2f + 8 + ITEM_HEIGHT * (i + 0.5f);
+ }
+
+ /** Draws the option list on top of everything drawn so far. */
+ public void drawList() {
+ if (!open) {
+ return;
+ }
+ float top = itemY(0) - ITEM_HEIGHT / 2f;
+ applet.rectMode(PConstants.CENTER);
+ applet.noStroke();
+ applet.fill(0, 90);
+ applet.rect(x, top + options.length * ITEM_HEIGHT / 2f + 4, width + 8, options.length * ITEM_HEIGHT + 8, 10);
+ applet.strokeWeight(3);
+ applet.stroke(ACCENT);
+ applet.fill(19, 23, 35);
+ applet.rect(x, top + options.length * ITEM_HEIGHT / 2f, width, options.length * ITEM_HEIGHT, 10);
+
+ applet.textAlign(PConstants.CENTER, PConstants.CENTER);
+ applet.textSize(26);
+ for (int i = 0; i < options.length; i++) {
+ if (Utility.hoverScreen(x, itemY(i), width, ITEM_HEIGHT)) {
+ highlight = i;
+ }
+ if (i == highlight) {
+ applet.noStroke();
+ applet.fill(74, 81, 99);
+ applet.rect(x, itemY(i), width - 10, ITEM_HEIGHT - 6, 6);
+ }
+ applet.fill(i == selectedIndex ? ACCENT : 255);
+ applet.text(options[i], x, itemY(i));
+ }
+ }
+
+ /**
+ * Handles a click while open: picks the option under the mouse (if any) and
+ * closes.
+ *
+ * @return true if the click was consumed (the list was open)
+ */
+ public boolean click() {
+ if (!open) {
+ return false;
+ }
+ for (int i = 0; i < options.length; i++) {
+ if (Utility.hoverScreen(x, itemY(i), width, ITEM_HEIGHT)) {
+ selectedIndex = i;
+ break;
+ }
+ }
+ open = false;
+ return true;
+ }
+
+ /**
+ * Handles a key while open.
+ *
+ * @return true if the key was consumed (the list was open)
+ */
+ public boolean key(int keyCode) {
+ if (!open) {
+ return false;
+ }
+ switch (keyCode) {
+ case PConstants.UP:
+ case 'W':
+ highlight = (highlight + options.length - 1) % options.length;
+ break;
+ case PConstants.DOWN:
+ case 'S':
+ highlight = (highlight + 1) % options.length;
+ break;
+ case PConstants.ENTER:
+ case ' ':
+ selectedIndex = highlight;
+ open = false;
+ break;
+ case PConstants.ESC:
+ open = false;
+ break;
+ default:
+ break;
+ }
+ return true;
+ }
+}
diff --git a/src/main/java/project_16x16/ui/MenuBackground.java b/src/main/java/project_16x16/ui/MenuBackground.java
new file mode 100644
index 00000000..5ede6994
--- /dev/null
+++ b/src/main/java/project_16x16/ui/MenuBackground.java
@@ -0,0 +1,399 @@
+package project_16x16.ui;
+
+import static net.jafama.FastMath.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javafx.scene.canvas.Canvas;
+import javafx.scene.canvas.GraphicsContext;
+import javafx.scene.effect.GaussianBlur;
+import javafx.scene.layout.Background;
+import javafx.scene.layout.BackgroundFill;
+import javafx.scene.layout.StackPane;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.StrokeLineCap;
+import net.jafama.DoubleWrapper;
+import processing.core.PApplet;
+import project_16x16.Options;
+import project_16x16.SideScroller;
+import project_16x16.Time;
+
+/**
+ * The animated flow-field particles drawn behind the menus. Particles stream
+ * along one of several vector fields, which morph into one another every few
+ * seconds. A few purple particles flicker among the cyan ones. In the main menu
+ * the cursor gently stirs the particles.
+ *
+ * The particles are drawn on their own JavaFX canvas, layered behind the game's
+ * canvas (which menus clear to transparent rather than paint over). Trails are
+ * made by not clearing the particle canvas: each frame it is faded towards the
+ * background colour. The layer's opacity and blur are applied by JavaFX when it
+ * composites the layers (on the GPU), so particles are faded as a whole --
+ * overlapping strokes don't build up -- and the background colour shows exactly
+ * where there are none.
+ *
+ * The field is shared by all menus, so it flows on uninterrupted between them;
+ * behind other menus it is drawn defocused (blurred and dimmer) -- see
+ * {@link #draw(boolean)}.
+ *
+ * Flow fields adapted from https://www.openprocessing.org/sketch/762850
+ *
+ * @author micycle1
+ */
+public final class MenuBackground {
+
+ /** Particle counts for each density setting ({@link Options#menuParticles}). */
+ private static final int[] DENSITY_COUNTS = { 500, 1100, 1800 };
+ public static final String[] DENSITY_NAMES = { "Low", "Medium", "High" };
+
+ private static final Color BACKGROUND = Color.rgb(29, 33, 45);
+
+ /**
+ * How quickly particle trails fade into the background (per second); see
+ * {@link Time#smoothing(float)}.
+ */
+ private static final float TRAIL_FADE_RATE = 5f;
+ /**
+ * Fading by tiny per-frame amounts at high frame rates leaves ghost trails
+ * (8-bit colour can't represent them), so fade at most this often (seconds).
+ */
+ private static final float TRAIL_FADE_INTERVAL = 1 / 30f;
+
+ private static final int FUNCTIONS = 12;
+ /** Time spent on each flow field before morphing into the next (seconds). */
+ private static final float HOLD_SECONDS = 5;
+ /** Duration of the morph between consecutive flow fields (seconds). */
+ private static final float MORPH_SECONDS = 1.6f;
+ private static final float FLOW_SPEED = 3; // flow-field units per second
+
+ /** Defocus transition rate (per second). */
+ private static final float FOCUS_RATE = 5;
+ /** Particle layer opacity in the main menu. */
+ private static final float SHARP_ALPHA = 0.8f;
+ /** Particle layer opacity when fully defocused. */
+ private static final float DEFOCUS_ALPHA = 0.5f;
+ /** Stroke weight multiplier when fully defocused. */
+ private static final float DEFOCUS_WEIGHT = 1.8f;
+ /** Blur radius when fully defocused (game-resolution px). */
+ private static final float DEFOCUS_BLUR = 7;
+
+ /** Cursor stirring: radius (px) and peak speed (px/s). Kept subtle. */
+ private static final float STIR_RADIUS = 110;
+ private static final float STIR_SPEED = 160;
+
+ /** Fraction of particles that are purple (and flicker). */
+ private static final float PURPLE_CHANCE = 0.08f;
+
+ private static SideScroller game;
+ private static final List particles = new ArrayList<>();
+ private static int targetCount;
+
+ /** The particle layer: a canvas over the background colour. */
+ private static StackPane layer;
+ private static Canvas canvas;
+ private static final GaussianBlur blur = new GaussianBlur(0);
+
+ private static int function = 0;
+ private static float phaseTime = 0; // seconds since the current field started
+ private static float morph = 0; // eased progress of the morph into the next field [0, 1]
+ private static float focus = 1;
+ private static float trailFadePending = 0; // seconds of fade not yet applied
+ private static int lastFrame = -1;
+
+ private static float centerX, centerY;
+ private static float scaleX, scaleY;
+
+ // reused between calls to avoid allocation (single render thread)
+ private static final double[] SLOPES = new double[2];
+ private static final DoubleWrapper COS_RESULT = new DoubleWrapper();
+
+ private MenuBackground() {
+ }
+
+ public static void assignApplet(SideScroller s) {
+ game = s;
+ centerX = game.gameResolution.x / 2;
+ centerY = game.gameResolution.y / 2;
+ scaleX = game.gameResolution.x / 20;
+ scaleY = game.gameResolution.y / 20 * (game.gameResolution.x / game.gameResolution.y);
+ setDensity(Options.menuParticles);
+
+ canvas = new Canvas();
+ layer = new StackPane(canvas);
+ layer.setBackground(new Background(new BackgroundFill(BACKGROUND, null, null)));
+ layer.setMouseTransparent(true);
+ layer.setVisible(false);
+ game.addUnderlay(layer);
+ }
+
+ /**
+ * @param level 0 = low, 1 = medium, 2 = high (see {@link #DENSITY_NAMES})
+ */
+ public static void setDensity(int level) {
+ targetCount = DENSITY_COUNTS[Math.max(0, Math.min(DENSITY_COUNTS.length - 1, level))];
+ while (particles.size() > targetCount) {
+ particles.remove(particles.size() - 1);
+ }
+ while (particles.size() < targetCount) {
+ particles.add(new Particle());
+ }
+ }
+
+ /**
+ * Advances and draws the particles, and clears the game canvas so they show
+ * through it. Call at the start of a menu's {@code drawUI()}, before drawing
+ * anything else; extra calls in the same frame are ignored.
+ *
+ * @param sharp true for the main menu (crisp, bright and gently stirred by
+ * the cursor); false to draw the field defocused behind another menu
+ */
+ public static void draw(boolean sharp) {
+ if (game.frameCount == lastFrame) {
+ return;
+ }
+ lastFrame = game.frameCount;
+ game.clearCanvas();
+ layer.setVisible(true);
+
+ final GraphicsContext gc = canvas.getGraphicsContext2D();
+ if (canvas.getWidth() != game.g.width || canvas.getHeight() != game.g.height) {
+ canvas.setWidth(game.g.width); // match the window's resolution
+ canvas.setHeight(game.g.height);
+ gc.setFill(BACKGROUND);
+ gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
+ }
+
+ final float dt = Time.delta();
+ focus = Time.damp(focus, sharp ? 1 : 0, FOCUS_RATE);
+ fadeTrails(gc, dt);
+ advanceFlow(dt);
+
+ final float s = game.getRenderScale();
+ gc.setTransform(s, 0, 0, s, game.getRenderOffsetX(), game.getRenderOffsetY()); // game-resolution units
+ gc.setLineCap(StrokeLineCap.ROUND);
+ final float weightScale = (float) PApplet.lerp(DEFOCUS_WEIGHT, 1, focus);
+ final float stir = game.focused ? focus : 0;
+
+ for (Particle p : particles) {
+ p.update(dt, stir);
+ final float x = toScreenX(p.x);
+ final float y = toScreenY(p.y);
+ if (p.age > p.lifespan || !(x > -100 && x < game.gameResolution.x + 100 && y > -100 && y < game.gameResolution.y + 100)) {
+ p.spawn(); // also catches NaN positions, which some fields produce
+ continue;
+ }
+
+ float life = Math.min(1, Math.min(p.age / 0.5f, (p.lifespan - p.age) / 0.8f));
+ if (p.flickerRate > 0) { // brief bright flashes
+ final float wave = 0.5f + 0.5f * (float) sin(p.age * p.flickerRate + p.flickerPhase);
+ life *= 0.2f + 0.8f * wave * wave * wave;
+ }
+ gc.setGlobalAlpha(life);
+ gc.setStroke(p.color);
+ gc.setLineWidth(p.size * weightScale);
+ gc.strokeLine(p.lastX, p.lastY, x, y);
+
+ p.lastX = x;
+ p.lastY = y;
+ }
+ gc.setGlobalAlpha(1);
+
+ // composited by JavaFX
+ canvas.setOpacity(PApplet.lerp(DEFOCUS_ALPHA, SHARP_ALPHA, focus));
+ final double radius = (1 - focus) * DEFOCUS_BLUR * s;
+ if (radius < 0.5) {
+ canvas.setEffect(null);
+ } else {
+ blur.setRadius(Math.min(63, radius));
+ canvas.setEffect(blur);
+ }
+ }
+
+ /**
+ * Hides the particle layer if it wasn't drawn this frame. Call at the end of
+ * every frame.
+ */
+ public static void endFrame() {
+ if (layer != null && lastFrame != game.frameCount) {
+ layer.setVisible(false);
+ }
+ }
+
+ /** Fades what was drawn previously towards the background colour. */
+ private static void fadeTrails(GraphicsContext gc, float dt) {
+ trailFadePending += dt;
+ if (trailFadePending >= TRAIL_FADE_INTERVAL) {
+ gc.setTransform(1, 0, 0, 1, 0, 0);
+ gc.setGlobalAlpha(1 - Math.exp(-TRAIL_FADE_RATE * trailFadePending));
+ gc.setFill(BACKGROUND);
+ gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
+ gc.setGlobalAlpha(1);
+ trailFadePending = 0;
+ }
+ }
+
+ /** Steps the flow-field cycle. */
+ private static void advanceFlow(float dt) {
+ phaseTime += dt;
+ morph = 0;
+ if (phaseTime > HOLD_SECONDS) {
+ final float m = Math.min(1, (phaseTime - HOLD_SECONDS) / MORPH_SECONDS);
+ morph = m * m * (3 - 2 * m); // smoothstep
+ if (m >= 1) {
+ function = nextFunction();
+ phaseTime = 0;
+ morph = 0;
+ }
+ }
+
+ }
+
+ private static int nextFunction() {
+ return (function + 1) % FUNCTIONS;
+ }
+
+ /**
+ * Computes the flow-field slope at (x, y) for the given function.
+ *
+ * @param out out[0] = slope X, out[1] = slope Y
+ * @param cosResult reusable holder for sinAndCos()
+ */
+ private static void getSlopes(int function, float x, float y, double[] out, DoubleWrapper cosResult) {
+ double sx = 1;
+ double sy = 1;
+
+ switch (function) {
+ case 0 -> {
+ sx = cos(y);
+ sy = sin(x);
+ }
+ case 1 -> {
+ sx = cos(y * 5) * x * 0.3;
+ sy = sin(x * 5) * y * 0.3;
+ }
+ case 2 -> sy = cos(x * y);
+ case 3 -> sy = sin(x) * cos(y);
+ case 4 -> sy = cos(x) * y * y;
+ case 5 -> sy = log(abs(x)) * log(abs(y));
+ case 6 -> sy = tan(x) * cos(y * y);
+ case 7 -> { // orbit
+ sx = sin(y * 0.1) * 3;
+ sy = -sin(x * 0.1) * 3;
+ }
+ case 8 -> { // two orbits
+ sx = y / 3;
+ sy = (x - x * x * x) * 0.01;
+ }
+ case 9 -> {
+ sx = -y;
+ sy = -sin(x);
+ }
+ case 10 -> {
+ sx = -1.5 * y;
+ sy = -y - sin(1.5 * x) + 0.75;
+ }
+ case 11 -> {
+ double sinX = sinAndCos(x, cosResult);
+ double cosX = cosResult.value;
+
+ double sinY = sinAndCos(y, cosResult);
+ double cosY = cosResult.value;
+
+ sx = sinY * cosX;
+ sy = sinX * cosY;
+ }
+ default -> {
+ }
+ }
+
+ out[0] = sx;
+ out[1] = sy;
+ }
+
+ private static float toFieldX(float x) {
+ return (x - centerX) / scaleX;
+ }
+
+ private static float toFieldY(float y) {
+ return (y - centerY) / scaleY;
+ }
+
+ private static float toScreenX(float x) {
+ return scaleX * x + centerX;
+ }
+
+ private static float toScreenY(float y) {
+ return scaleY * y + centerY;
+ }
+
+ private static final class Particle {
+
+ /** Position, in flow-field units. */
+ private float x, y;
+ /** Last drawn position, in screen units. */
+ private float lastX, lastY;
+ private int size;
+ /** Signed speed multiplier: some particles run against the flow. */
+ private float direction;
+ private Color color;
+ /** Flicker speed (radians/s); 0 for particles that don't flicker. */
+ private float flickerRate, flickerPhase;
+ private float age, lifespan; // seconds
+
+ Particle() {
+ spawn();
+ }
+
+ /** (Re)starts this particle at a random position. */
+ void spawn() {
+ x = toFieldX(game.random(0, game.gameResolution.x));
+ y = toFieldY(game.random(0, game.gameResolution.y));
+ lastX = toScreenX(x);
+ lastY = toScreenY(y);
+ size = (int) game.random(2, 7);
+ direction = game.random(0.1f, 1) * (game.random(1) > 0.5f ? 1 : -1);
+ if (game.random(1) < PURPLE_CHANCE) {
+ color = Color.rgb((int) game.random(150, 210), (int) game.random(50, 110), (int) game.random(220, 255));
+ flickerRate = game.random(5, 12);
+ flickerPhase = game.random(PApplet.TWO_PI);
+ } else {
+ color = Color.rgb((int) game.random(0, 50), (int) game.random(150, 255), (int) game.random(150, 255));
+ flickerRate = 0;
+ }
+ age = 0;
+ lifespan = game.random(3, 10);
+ }
+
+ /**
+ * @param stir strength of the cursor's influence [0, 1]
+ */
+ void update(float dt, float stir) {
+ age += dt;
+ getSlopes(function, x, y, SLOPES, COS_RESULT);
+ double sx = SLOPES[0];
+ double sy = SLOPES[1];
+ if (morph > 0) {
+ getSlopes(nextFunction(), x, y, SLOPES, COS_RESULT);
+ sx += (SLOPES[0] - sx) * morph;
+ sy += (SLOPES[1] - sy) * morph;
+ }
+ final float step = direction * FLOW_SPEED * dt;
+ x += (float) sx * step;
+ y += (float) sy * step;
+
+ if (stir > 0) { // a gentle swirl around the cursor
+ final float dx = toScreenX(x) - game.mouseX;
+ final float dy = toScreenY(y) - game.mouseY;
+ final float d2 = dx * dx + dy * dy;
+ if (d2 < STIR_RADIUS * STIR_RADIUS && d2 > 1) {
+ final float d = (float) sqrt(d2);
+ final float falloff = 1 - d / STIR_RADIUS;
+ final float push = STIR_SPEED * stir * falloff * falloff * dt / d;
+ x += (-dy + 0.2f * dx) * push / scaleX;
+ y += (dx + 0.2f * dy) * push / scaleY;
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/project_16x16/ui/MenuNav.java b/src/main/java/project_16x16/ui/MenuNav.java
new file mode 100644
index 00000000..3c3a17b3
--- /dev/null
+++ b/src/main/java/project_16x16/ui/MenuNav.java
@@ -0,0 +1,146 @@
+package project_16x16.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import processing.core.PConstants;
+import processing.event.KeyEvent;
+import project_16x16.Audio;
+import project_16x16.Audio.SFX;
+import project_16x16.SideScroller;
+
+/**
+ * Groups the buttons of a menu, drawing them and wiring each to an action.
+ * Supports mouse clicks and keyboard navigation (up/down or W/S to move,
+ * enter/space to activate).
+ *
+ * @author micycle1
+ *
+ */
+public final class MenuNav {
+
+ private final SideScroller applet;
+ private final List