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.mockito mockito-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 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 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 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