Cells hold relative comb coordinates. A gap propagates through the horizontal pitch + * and the row step in a way that keeps all six neighbour gaps equal: for a comb of circumradius + * {@code r} with gap {@code g}, every neighbour centre sits {@code √3·r + g} away. + */ +public final class HiveGeometry { + + /** Number of hexagons in the comb. */ + public static final int HEX_COUNT = 7; + + public static final float SQRT_3 = (float) Math.sqrt(3); + + /** Progress value at which one full fade-in/fade-out cycle completes. */ + public static final int MAX_PROGRESS = 1450; + + /** + * Fraction of the content box used as the gap when no explicit spacing is supplied. Chosen to + * reproduce the gap this comb had before spacing became configurable. + */ + public static final float AUTO_SPACING_RATIO = 1f / 23f; + + /** Progress value at which every hexagon is fully faded in. */ + public static final float FULLY_VISIBLE_PROGRESS = 700f; + + private static final int FADE_OUT_START = 700; + private static final int FULLY_FADED = 1400; + + /** Column offset of each cell, counted in half a horizontal pitch. */ + private static final int[] CELL_HALF_COLUMN = {-1, 1, -2, 0, 2, -1, 1}; + + /** Row index of each cell, 0 (top) to 2 (bottom). */ + private static final int[] CELL_ROW = {0, 0, 1, 1, 1, 2, 2}; + + /** Position of each cell in the fade wave, preserving the original Overwatch ordering. */ + private static final int[] CELL_WAVE_ORDER = {1, 2, 6, 7, 3, 5, 4}; + + /** Per-cell tilt applied in image mode, in degrees. */ + private static final float[] CELL_IMAGE_TILT = {10, -10, -10, 5, -10, 20, 10}; + + private HiveGeometry() { + } + + /** Size-proportional gap used when the caller does not pin one. */ + public static float autoSpacing(float contentWidth, float contentHeight) { + return Math.min(contentWidth, contentHeight) * AUTO_SPACING_RATIO; + } + + /** + * Solves the hexagon circumradius from the space left over once the gaps are taken out. The comb + * occupies {@code 3√3·r + 2g} horizontally and {@code 5r + √3·g} vertically, so the radius is + * whichever of those two constraints binds first. + */ + public static float radiusFor(float contentWidth, float contentHeight, float gap) { + float fromWidth = (contentWidth - 2 * gap) / (3 * SQRT_3); + float fromHeight = (contentHeight - SQRT_3 * gap) / 5f; + return Math.max(0f, Math.min(fromWidth, fromHeight)); + } + + /** Centre-to-centre distance between two hexagons in the same row. */ + public static float pitch(float radius, float gap) { + return SQRT_3 * radius + gap; + } + + /** Vertical distance between two adjacent rows. */ + public static float rowStep(float radius, float gap) { + return 1.5f * radius + SQRT_3 * gap / 2f; + } + + /** Half the width of a pointy-top hexagon, i.e. the distance from centre to a vertical edge. */ + public static float halfWidth(float radius) { + return radius * SQRT_3 / 2f; + } + + public static float cellCenterX(int index, float originX, float radius, float gap) { + return originX + CELL_HALF_COLUMN[index] * pitch(radius, gap) / 2f; + } + + public static float cellCenterY(int index, float originY, float radius, float gap) { + return originY + (CELL_ROW[index] - 1) * rowStep(radius, gap); + } + + public static int waveOrder(int index) { + return CELL_WAVE_ORDER[index]; + } + + public static float imageTilt(int index) { + return CELL_IMAGE_TILT[index]; + } + + /** + * Opacity of a cell at a point in the wave, as a fraction in {@code [0, 1]}. Each cell fades in + * 100 progress units after the previous one, then the whole comb fades out in the same order. + * + * @param wave the cell's position in the wave, from {@link #waveOrder(int)} + */ + public static float alphaFractionAt(int wave, float progress) { + float fraction; + if (progress > wave * 100) { + fraction = 1f; + } else { + int min = (wave - 1) * 100; + fraction = Math.max(0f, progress - min) / 100f; + } + if (progress > FADE_OUT_START) { + float fadeProgress = progress - FADE_OUT_START; + if (fadeProgress > wave * 100) { + fraction = 0f; + } else { + int min = (wave - 1) * 100; + fraction = 1f - Math.max(0f, fadeProgress - min) / 100f; + } + } + if (progress > FULLY_FADED) { + fraction = 0f; + } + return fraction; + } + + /** Opacity of a cell scaled into the 0-255 range a {@code Paint} expects. */ + public static int alphaAt(int wave, float progress, int maxAlpha) { + return (int) (alphaFractionAt(wave, progress) * maxAlpha); + } + + /** + * Writes the six vertices of a pointy-top hexagon into {@code out}, as x,y pairs starting from + * the top vertex and running clockwise. + * + * @param out an array of at least 12 floats, overwritten in place + */ + public static void hexagonVertices(float cx, float cy, float r, float[] out) { + float edge = halfWidth(r); + out[0] = cx; + out[1] = cy - r; + out[2] = cx + edge; + out[3] = cy - r / 2f; + out[4] = cx + edge; + out[5] = cy + r / 2f; + out[6] = cx; + out[7] = cy + r; + out[8] = cx - edge; + out[9] = cy + r / 2f; + out[10] = cx - edge; + out[11] = cy - r / 2f; + } +} diff --git a/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java b/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java index dbd8bcc..5a3b9e8 100644 --- a/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java +++ b/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java @@ -16,48 +16,19 @@ public class HiveProgressView extends View { - /** Number of hexagons in the 2-3-2 comb. */ - private static final int HEX_COUNT = 7; - - private static final float SQRT_3 = (float) Math.sqrt(3); - private static final int[] RAINBOW_COLOR = { 0xFFFF0000, 0xFFFF7F00, 0xFFFFFF00, 0xFF00FF00, 0xFF0000FF, 0xFF4B0082, 0xFF9400D3 }; - /** - * Cells are stored as top-left, top-right, mid-left, mid-centre, mid-right, bottom-left, - * bottom-right. Column offsets are counted in half a horizontal pitch so the staggered - * rows stay integral. - */ - private static final int[] CELL_HALF_COLUMN = {-1, 1, -2, 0, 2, -1, 1}; - - private static final int[] CELL_ROW = {0, 0, 1, 1, 1, 2, 2}; - - /** Position of each cell in the fade wave, preserving the original Overwatch ordering. */ - private static final int[] CELL_WAVE_ORDER = {1, 2, 6, 7, 3, 5, 4}; - - /** Per-cell tilt applied in image mode, in degrees. */ - private static final float[] CELL_IMAGE_TILT = {10, -10, -10, 5, -10, 20, 10}; - - private static final int MAX_PROGRESS_VALUE = 1450; private static final int PROGRESS_TIME = 2000; private static final int MAX_ALPHA = 70; private static final int DEFAULT_SIZE_DP = 96; - /** - * Fraction of the content box used as the gap when {@link #hive_spacing} is not supplied. Chosen - * to reproduce the gap this view had before spacing became configurable. - */ - private static final float AUTO_SPACING_RATIO = 1f / 23f; - - /** Progress value at which every hexagon is fully faded in; used for layout previews. */ - private static final float PREVIEW_PROGRESS = 700f; - private final Paint paint = new Paint(); - private final Path[] hexPaths = new Path[HEX_COUNT]; - private final float[] cellX = new float[HEX_COUNT]; - private final float[] cellY = new float[HEX_COUNT]; + private final Path[] hexPaths = new Path[HiveGeometry.HEX_COUNT]; + private final float[] cellX = new float[HiveGeometry.HEX_COUNT]; + private final float[] cellY = new float[HiveGeometry.HEX_COUNT]; + private final float[] vertices = new float[12]; private float hexRadius; private float actualProgress = 0; @@ -85,14 +56,14 @@ public HiveProgressView(Context context, AttributeSet attrs) { public HiveProgressView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); - for (int i = 0; i < HEX_COUNT; i++) { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { hexPaths[i] = new Path(); } initAttributes(attrs, defStyle); initPaint(); initImage(); if (isInEditMode()) { - actualProgress = PREVIEW_PROGRESS; + actualProgress = HiveGeometry.FULLY_VISIBLE_PROGRESS; } } @@ -242,7 +213,7 @@ public void setVisibility(int visibility) { private void startAnimation() { if (isInEditMode()) { - actualProgress = PREVIEW_PROGRESS; + actualProgress = HiveGeometry.FULLY_VISIBLE_PROGRESS; invalidate(); return; } @@ -259,7 +230,7 @@ private void stopAnimation() { private void resetAnimator() { stopAnimation(); - ValueAnimator animator = ValueAnimator.ofFloat(0, MAX_PROGRESS_VALUE); + ValueAnimator animator = ValueAnimator.ofFloat(0, HiveGeometry.MAX_PROGRESS); animator.setDuration(animationTime); animator.setInterpolator(new LinearInterpolator()); // Looping via the animator itself rather than restarting from onAnimationEnd: the old @@ -317,20 +288,14 @@ private void updateGeometry(int viewWidth, int viewHeight) { return; } - float gap = spacing >= 0 ? spacing - : Math.min(contentWidth, contentHeight) * AUTO_SPACING_RATIO; - float radiusFromWidth = (contentWidth - 2 * gap) / (3 * SQRT_3); - float radiusFromHeight = (contentHeight - SQRT_3 * gap) / 5f; - hexRadius = Math.max(0f, Math.min(radiusFromWidth, radiusFromHeight)); + float gap = spacing >= 0 ? spacing : HiveGeometry.autoSpacing(contentWidth, contentHeight); + hexRadius = HiveGeometry.radiusFor(contentWidth, contentHeight, gap); - float pitch = SQRT_3 * hexRadius + gap; - float rowStep = 1.5f * hexRadius + SQRT_3 * gap / 2f; float originX = getPaddingLeft() + contentWidth / 2f; float originY = getPaddingTop() + contentHeight / 2f; - - for (int i = 0; i < HEX_COUNT; i++) { - cellX[i] = originX + CELL_HALF_COLUMN[i] * pitch / 2f; - cellY[i] = originY + (CELL_ROW[i] - 1) * rowStep; + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + cellX[i] = HiveGeometry.cellCenterX(i, originX, hexRadius, gap); + cellY[i] = HiveGeometry.cellCenterY(i, originY, hexRadius, gap); } } @@ -347,9 +312,9 @@ protected void onDraw(Canvas canvas) { } private void drawHexagons(Canvas canvas) { - for (int i = 0; i < HEX_COUNT; i++) { - int wave = CELL_WAVE_ORDER[i]; - int alpha = getCellAlpha(wave, actualProgress); + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + int alpha = HiveGeometry.alphaAt(wave, actualProgress, maxAlpha); if (alpha <= 0) { continue; } @@ -362,14 +327,14 @@ private void drawHexagons(Canvas canvas) { } private void drawImages(Canvas canvas) { - float halfWidth = hexRadius * SQRT_3 / 2f; - for (int i = 0; i < HEX_COUNT; i++) { - int alpha = getCellAlpha(CELL_WAVE_ORDER[i], actualProgress); + float halfWidth = HiveGeometry.halfWidth(hexRadius); + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int alpha = HiveGeometry.alphaAt(HiveGeometry.waveOrder(i), actualProgress, maxAlpha); if (alpha <= 0) { continue; } canvas.save(); - canvas.rotate(CELL_IMAGE_TILT[i], cellX[i], cellY[i]); + canvas.rotate(HiveGeometry.imageTilt(i), cellX[i], cellY[i]); imageRes.setBounds(Math.round(cellX[i] - halfWidth), Math.round(cellY[i] - hexRadius), Math.round(cellX[i] + halfWidth), Math.round(cellY[i] + hexRadius)); imageRes.setAlpha(alpha); @@ -386,41 +351,14 @@ private int getHexagonColor(int wave) { } } - private int getCellAlpha(int num, float progress) { - float alpha; - if (progress > num * 100) { - alpha = maxAlpha; - } else { - int min = (num - 1) * 100; - alpha = (progress - min) > 0 ? progress - min : 0; - alpha = alpha * maxAlpha / 100; - } - if (progress > 700) { - float fadeProgress = progress - 700; - if (fadeProgress > num * 100) { - alpha = 0; - } else { - int min = (num - 1) * 100; - alpha = (fadeProgress - min) > 0 ? fadeProgress - min : 0; - alpha = maxAlpha - alpha * maxAlpha / 100; - } - } - if (progress > 1400) { - alpha = 0; - } - return (int) alpha; - } - /** Builds a pointy-top hexagon of circumradius {@code r} centred on ({@code cx}, {@code cy}). */ private void buildHexPath(Path path, float cx, float cy, float r) { - float edge = r * SQRT_3 / 2f; + HiveGeometry.hexagonVertices(cx, cy, r, vertices); path.reset(); - path.moveTo(cx, cy - r); - path.lineTo(cx + edge, cy - r / 2f); - path.lineTo(cx + edge, cy + r / 2f); - path.lineTo(cx, cy + r); - path.lineTo(cx - edge, cy + r / 2f); - path.lineTo(cx - edge, cy - r / 2f); + path.moveTo(vertices[0], vertices[1]); + for (int v = 2; v < vertices.length; v += 2) { + path.lineTo(vertices[v], vertices[v + 1]); + } path.close(); } } diff --git a/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java b/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java new file mode 100644 index 0000000..bc2b323 --- /dev/null +++ b/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java @@ -0,0 +1,133 @@ +package com.comix.overwatch; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class HiveGeometryTest { + + private static final float EPSILON = 0.01f; + + /** Index of the cell in the middle of the comb, which borders all six others. */ + private static final int CENTRE_CELL = 3; + + /** + * The property the spacing parameter is supposed to guarantee: one scalar gap widens the comb by + * the same amount in every direction. Without this, spacing would only look right along one axis. + */ + @Test + public void everyNeighbourOfTheCentreCellIsEquidistant() { + float box = 480f; + float gap = 12f; + float radius = HiveGeometry.radiusFor(box, box, gap); + float centreX = HiveGeometry.cellCenterX(CENTRE_CELL, box / 2f, radius, gap); + float centreY = HiveGeometry.cellCenterY(CENTRE_CELL, box / 2f, radius, gap); + float expected = HiveGeometry.SQRT_3 * radius + gap; + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + if (i == CENTRE_CELL) { + continue; + } + float dx = HiveGeometry.cellCenterX(i, box / 2f, radius, gap) - centreX; + float dy = HiveGeometry.cellCenterY(i, box / 2f, radius, gap) - centreY; + assertEquals("cell " + i, expected, (float) Math.hypot(dx, dy), EPSILON); + } + } + + /** At zero gap the hexagons must tile exactly: touching, with no overlap. */ + @Test + public void zeroGapTilesExactly() { + float box = 300f; + float radius = HiveGeometry.radiusFor(box, box, 0f); + float centreX = HiveGeometry.cellCenterX(CENTRE_CELL, box / 2f, radius, 0f); + float centreY = HiveGeometry.cellCenterY(CENTRE_CELL, box / 2f, radius, 0f); + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + if (i == CENTRE_CELL) { + continue; + } + float dx = HiveGeometry.cellCenterX(i, box / 2f, radius, 0f) - centreX; + float dy = HiveGeometry.cellCenterY(i, box / 2f, radius, 0f) - centreY; + assertEquals(HiveGeometry.SQRT_3 * radius, (float) Math.hypot(dx, dy), EPSILON); + } + } + + /** The radius is solved so that the comb always fits, whatever the gap. */ + @Test + public void combStaysInsideTheContentBox() { + for (float gap : new float[] {0f, 8f, 40f, 90f}) { + float width = 300f; + float height = 260f; + float radius = HiveGeometry.radiusFor(width, height, gap); + float halfWidth = HiveGeometry.halfWidth(radius); + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + float x = HiveGeometry.cellCenterX(i, width / 2f, radius, gap); + float y = HiveGeometry.cellCenterY(i, height / 2f, radius, gap); + String cell = "gap " + gap + ", cell " + i; + assertTrue(cell, x - halfWidth >= -EPSILON); + assertTrue(cell, x + halfWidth <= width + EPSILON); + assertTrue(cell, y - radius >= -EPSILON); + assertTrue(cell, y + radius <= height + EPSILON); + } + } + } + + @Test + public void radiusCollapsesToZeroRatherThanGoingNegative() { + assertEquals(0f, HiveGeometry.radiusFor(100f, 100f, 500f), EPSILON); + assertEquals(0f, HiveGeometry.radiusFor(0f, 0f, 0f), EPSILON); + } + + @Test + public void cellsFadeInOneAfterAnother() { + assertEquals(0f, HiveGeometry.alphaFractionAt(1, 0f), EPSILON); + assertEquals(1f, HiveGeometry.alphaFractionAt(1, 100f), EPSILON); + // The last cell in the wave is still dark when the first one is already lit. + assertEquals(0f, HiveGeometry.alphaFractionAt(7, 100f), EPSILON); + assertEquals(1f, HiveGeometry.alphaFractionAt(7, HiveGeometry.FULLY_VISIBLE_PROGRESS), EPSILON); + } + + @Test + public void everyCellIsDarkAtBothEndsOfTheCycle() { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + assertEquals("cell " + i, 0f, HiveGeometry.alphaFractionAt(wave, 0f), EPSILON); + assertEquals("cell " + i, 0f, + HiveGeometry.alphaFractionAt(wave, HiveGeometry.MAX_PROGRESS), EPSILON); + } + } + + @Test + public void alphaStaysWithinRangeAcrossTheWholeCycle() { + for (float progress = 0f; progress <= HiveGeometry.MAX_PROGRESS; progress += 7f) { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + float fraction = HiveGeometry.alphaFractionAt(HiveGeometry.waveOrder(i), progress); + assertTrue("progress " + progress, fraction >= 0f && fraction <= 1f); + } + } + } + + /** Every cell appears exactly once in the wave, so no hexagon is skipped or doubled up. */ + @Test + public void waveOrderIsAPermutation() { + boolean[] seen = new boolean[HiveGeometry.HEX_COUNT + 1]; + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + assertTrue("wave " + wave + " out of range", wave >= 1 && wave <= HiveGeometry.HEX_COUNT); + assertTrue("wave " + wave + " repeated", !seen[wave]); + seen[wave] = true; + } + } + + @Test + public void hexagonVerticesLieOnTheCircumcircle() { + float[] vertices = new float[12]; + HiveGeometry.hexagonVertices(50f, 60f, 20f, vertices); + for (int v = 0; v < vertices.length; v += 2) { + double distance = Math.hypot(vertices[v] - 50f, vertices[v + 1] - 60f); + assertEquals(20f, (float) distance, EPSILON); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2183dfe..b9d3bf1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,4 +22,4 @@ dependencyResolutionManagement { rootProject.name = "OverwatchProgress" -include(":app", ":overwatch") +include(":app", ":overwatch", ":overwatch-compose")