App 2.38.0: Overview styles Visor and Empire

Visor, after Halo's helmet display: the battery as the shield bar, the
range as the ammunition counter, P R N D as grenade slots, the speed in
the reticle with power round it, and the power trail as blips on a
motion tracker that sweeps while moving. Original blue or the accent.

Empire, after Age of Empires: a wooden resource bar, an isometric world
that moves with the speed, the car as the selected unit, P R N D as
commands, a diamond minimap, and ages by odometer (EmpireAge, tested).
The app takes the player's blue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-26 21:00:50 +01:00
co-authored by Claude Opus 5.5
parent 96b9659bdd
commit 7d2c1e8bb4
18 changed files with 961 additions and 18 deletions
@@ -0,0 +1,322 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Visor Overview style, after the helmet display of a science-fiction shooter like Halo:
* thin light-blue lines on a dark visor, slanted panels and a reticle in the middle.
*
* Top centre, the shield bar: the battery, segmented, red and blinking below 20 % with
* "BATTERY LOW". Top right, the ammunition counter: the range as a number and as rounds, one
* per 10 km. Under it, four grenade slots for P R N D, the current one lit. Middle, the
* reticle with the speed inside it and power round it — clockwise while drawing, green and
* anticlockwise while regenerating. Bottom left, the motion tracker: its sweep turns while the
* car moves, and its blips are the last two minutes of power (red drawing, green regenerating), further out the more
* power (the rim is 60 kW, 40 kW regenerating).
* Top left the clock, bottom right the objective with trip, usage, climate and odometer.
* Unread values show "—" and light nothing.
*
* Two palettes: the original visor blue, or the chosen accent colour ({@link #setAccentPalette}).
* The tracker animates (about 30 frames a second) only while the car moves and the view is on
* screen; the low-battery blink rides on the readings that redraw it.
*/
final class VisorView extends ClusterCanvasView {
/** Shield segments, rounds in the counter (10 km each). */
private static final int SHIELD = 24, ROUNDS = 50;
private static final int RED = 0xFFFF3B30, REGEN = 0xFF4CFF7A, ENEMY = 0xFFFF4A3D;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint vignette = new Paint();
private final Path path = new Path();
private final RectF r = new RectF();
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
private final int accent, accentLight;
private int main, light, dim;
private float sweep;
private long lastFrame;
private int vignetteFor = -1;
VisorView(Context context) {
super(context);
accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE);
line.setStrokeJoin(Paint.Join.MITER);
setAccentPalette(false);
}
/** The chosen accent colour instead of the original visor blue. */
void setAccentPalette(boolean useAccent) {
main = useAccent ? accent : 0xFF5CC8FF;
light = useAccent ? accentLight : 0xFFD2F0FF;
dim = AccentColor.withAlpha(main, 0x40);
vignetteFor = -1;
invalidate();
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
advance(moving);
drawVisor(c, w, h, u);
drawClock(c, u);
drawShield(c, w, u);
drawAmmo(c, w, u);
drawReticle(c, w, h, u);
drawTracker(c, h, u);
drawObjective(c, w, h, u);
keepAnimating(moving, 33);
}
private void advance(boolean moving) {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (moving) sweep = (sweep + dt * 180f) % 360f;
}
// ---------- Visor ----------
/** Dark glass, the helmet's rim darkening the corners, and its edge lines at the top. */
private void drawVisor(Canvas c, float w, float h, float u) {
c.drawColor(0xFF03080D);
if (vignetteFor != (int) (w + h)) {
vignette.setShader(new RadialGradient(w / 2f, h * 0.48f, Math.max(w, h) * 0.62f,
new int[] { AccentColor.withAlpha(main, 0x16), 0x00000000, 0xE0000000 },
new float[] { 0f, 0.62f, 1f }, Shader.TileMode.CLAMP));
vignetteFor = (int) (w + h);
}
c.drawRect(0, 0, w, h, vignette);
line.setStrokeWidth(1.5f * u);
line.setColor(AccentColor.withAlpha(main, 0x50));
path.reset();
path.moveTo(0, 150f * u);
path.quadTo(w * 0.08f, 16f * u, w * 0.3f, 8f * u);
path.moveTo(w, 150f * u);
path.quadTo(w * 0.92f, 16f * u, w * 0.7f, 8f * u);
c.drawPath(path, line);
}
/** A slanted panel, as the display draws its frames. */
private void slant(Canvas c, float l, float t, float rr, float b, float lean, int stroke, int bg, float u) {
path.reset();
path.moveTo(l + lean, t);
path.lineTo(rr, t);
path.lineTo(rr - lean, b);
path.lineTo(l, b);
path.close();
fill.setColor(bg);
c.drawPath(path, fill);
line.setColor(stroke);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
}
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
text.setTypeface(tf);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(colour);
c.drawText(s, x, y, text);
}
// ---------- Clock ----------
private void drawClock(Canvas c, float u) {
float l = 28f * u, t = 26f * u;
slant(c, l, t, l + 150f * u, t + 44f * u, 12f * u, main, AccentColor.withAlpha(main, 0x18), u);
label(c, v.clock.isEmpty() ? "—" : v.clock, l + 75f * u, t + 33f * u, 30f * u, light, face, Paint.Align.CENTER);
label(c, "LOCAL TIME", l + 12f * u, t + 64f * u, 14f * u, main, face, Paint.Align.LEFT);
}
// ---------- Shield ----------
private void drawShield(Canvas c, float w, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0;
int colour = low ? RED : main;
float bw = Math.min(520f * u, w * 0.36f), l = (w - bw) / 2f, t = 28f * u, b = t + 30f * u;
slant(c, l - 14f * u, t - 8f * u, l + bw + 14f * u, b + 8f * u, 14f * u, AccentColor.withAlpha(colour, 0x90),
0x66000000, u);
int lit = v.socPercent == null ? 0 : Math.round(Math.max(0f, Math.min(100f, v.socPercent)) / 100f * SHIELD);
float gap = 3f * u, seg = (bw - (SHIELD - 1) * gap) / SHIELD;
for (int i = 0; i < SHIELD; i++) {
float x = l + i * (seg + gap);
path.reset();
path.moveTo(x + 6f * u, t);
path.lineTo(x + seg + 6f * u, t);
path.lineTo(x + seg, b);
path.lineTo(x, b);
path.close();
boolean on = i < lit && (!low || blinkOn);
fill.setColor(on ? colour : AccentColor.withAlpha(colour, 0x22));
c.drawPath(path, fill);
}
label(c, fmt(v.socPercent, "%.0f %%", "—"), w / 2f, b + 36f * u, 26f * u, low ? RED : light, face,
Paint.Align.CENTER);
if (low && blinkOn) {
label(c, "BATTERY LOW", w / 2f, b + 66f * u, 22f * u, RED, face, Paint.Align.CENTER);
}
}
// ---------- Ammunition and grenades ----------
private void drawAmmo(Canvas c, float w, float u) {
float rr = w - 30f * u, t = 26f * u;
// The count: the range.
label(c, fmt(v.rangeKm, "%.0f", "—"), rr, t + 52f * u, 60f * u, light, face, Paint.Align.RIGHT);
label(c, "KM RANGE", rr, t + 74f * u, 15f * u, main, face, Paint.Align.RIGHT);
// The rounds: two rows of 25, one per 10 km.
int lit = v.rangeKm == null ? 0 : Math.round(Math.max(0f, v.rangeKm) / 10f);
float rw = 5f * u, rh = 20f * u, gap = 3f * u, perRow = ROUNDS / 2f;
float rowW = perRow * rw + (perRow - 1) * gap, l = rr - 150f * u - rowW;
for (int i = 0; i < ROUNDS; i++) {
int row = i / (ROUNDS / 2), col = i % (ROUNDS / 2);
float x = l + col * (rw + gap), y = t + 8f * u + row * (rh + 8f * u);
r.set(x, y, x + rw, y + rh);
fill.setColor(i < lit ? light : AccentColor.withAlpha(main, 0x26));
c.drawRect(r, fill);
}
// The grenade slots: P R N D.
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float gs = 34f * u, gx = l, gy = t + 66f * u;
for (int i = 0; i < gears.length; i++) {
boolean on = gears[i].equals(now);
float x = gx + i * (gs + 6f * u);
path.reset();
path.moveTo(x + gs / 2f, gy);
path.lineTo(x + gs, gy + gs * 0.5f);
path.lineTo(x + gs / 2f, gy + gs);
path.lineTo(x, gy + gs * 0.5f);
path.close();
fill.setColor(on ? main : 0x55000000);
c.drawPath(path, fill);
line.setColor(on ? light : dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
label(c, gears[i], x + gs / 2f, gy + gs * 0.5f + 7f * u, 20f * u, on ? 0xFF03080D : main, face,
Paint.Align.CENTER);
}
}
// ---------- Reticle ----------
private void drawReticle(Canvas c, float w, float h, float u) {
float cx = w / 2f, cy = h * 0.53f, rad = 122f * u;
line.setStrokeWidth(2f * u);
line.setColor(AccentColor.withAlpha(main, 0x70));
c.drawCircle(cx, cy, rad, line);
// Four ticks.
line.setColor(main);
line.setStrokeWidth(3f * u);
c.drawLine(cx, cy - rad - 16f * u, cx, cy - rad + 10f * u, line);
c.drawLine(cx, cy + rad - 10f * u, cx, cy + rad + 16f * u, line);
c.drawLine(cx - rad - 16f * u, cy, cx - rad + 10f * u, cy, line);
c.drawLine(cx + rad - 10f * u, cy, cx + rad + 16f * u, cy, line);
// Power round it: 150 kW is a full turn, regeneration 50 kW the other way.
r.set(cx - rad - 10f * u, cy - rad - 10f * u, cx + rad + 10f * u, cy + rad + 10f * u);
line.setStrokeWidth(8f * u);
line.setStrokeCap(Paint.Cap.BUTT);
line.setColor(AccentColor.withAlpha(main, 0x1C));
c.drawArc(r, 0, 360, false, line);
if (v.powerKw != null && Math.abs(v.powerKw) > 0.1f) {
boolean regen = v.powerKw < 0;
float sweepDeg = regen ? Math.max(-360f, v.powerKw / 50f * 360f) : Math.min(360f, v.powerKw / 150f * 360f);
line.setColor(regen ? REGEN : light);
c.drawArc(r, -90f, sweepDeg, false, line);
}
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, cy + 34f * u, 104f * u, light, face, Paint.Align.CENTER);
label(c, "KM/H", cx, cy + 66f * u, 18f * u, main, face, Paint.Align.CENTER);
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, v.powerKw < -0.05f ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
label(c, power, cx, cy + rad + 48f * u, 24f * u,
v.powerKw != null && v.powerKw < -0.05f ? REGEN : light, face, Paint.Align.CENTER);
}
// ---------- Motion tracker ----------
/** The tracker: rings, the sweep while moving, and the power trail as blips round the car. */
private void drawTracker(Canvas c, float h, float u) {
float rad = 112f * u, cx = 40f * u + rad, cy = h - 34f * u - rad;
fill.setColor(AccentColor.withAlpha(main, 0x14));
c.drawCircle(cx, cy, rad, fill);
line.setStrokeWidth(1.5f * u);
line.setColor(AccentColor.withAlpha(main, 0x90));
c.drawCircle(cx, cy, rad, line);
line.setColor(AccentColor.withAlpha(main, 0x3A));
c.drawCircle(cx, cy, rad * 0.66f, line);
c.drawCircle(cx, cy, rad * 0.33f, line);
c.drawLine(cx - rad, cy, cx + rad, cy, line);
c.drawLine(cx, cy - rad, cx, cy + rad, line);
if (v.speedKmh != null && v.speedKmh > 0.5f) {
float a = (float) Math.toRadians(sweep - 90f);
line.setColor(AccentColor.withAlpha(light, 0x90));
line.setStrokeWidth(2f * u);
c.drawLine(cx, cy, cx + (float) Math.cos(a) * rad, cy + (float) Math.sin(a) * rad, line);
}
// Blips: the last two minutes, the newest nearest the top, each at the distance its power gives.
float[] trail = v.powerTrail;
if (trail != null && trail.length > 0) {
int step = Math.max(1, trail.length / 24);
for (int i = trail.length - 1, k = 0; i >= 0; i -= step, k++) {
float p = trail[i];
if (Float.isNaN(p) || Math.abs(p) < 1f) continue;
float dist = (0.18f + 0.78f * Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f))) * rad;
float ang = (float) Math.toRadians(-90f + k * 15f);
float bx = cx + (float) Math.cos(ang) * dist, by = cy + (float) Math.sin(ang) * dist;
int colour = p < 0 ? REGEN : ENEMY;
fill.setColor(AccentColor.withAlpha(colour, Math.max(0x40, 0xFF - k * 8)));
c.drawCircle(bx, by, 6f * u, fill);
}
}
// The car in the middle.
path.reset();
path.moveTo(cx, cy - 10f * u);
path.lineTo(cx + 7f * u, cy + 8f * u);
path.lineTo(cx - 7f * u, cy + 8f * u);
path.close();
fill.setColor(light);
c.drawPath(path, fill);
label(c, "60 kW", cx, cy - rad - 8f * u, 13f * u, main, thin, Paint.Align.CENTER);
}
// ---------- Objective ----------
private void drawObjective(Canvas c, float w, float h, float u) {
float rr = w - 30f * u, l = rr - 330f * u, t = h - 214f * u, b = h - 34f * u;
slant(c, l, t, rr, b, 18f * u, AccentColor.withAlpha(main, 0x80), 0x80000000, u);
label(c, "OBJECTIVE", l + 30f * u, t + 30f * u, 18f * u, main, face, Paint.Align.LEFT);
String[][] rows = {
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
for (int i = 0; i < rows.length; i++) {
float y = t + (62f + i * 27f) * u, x = l + 26f * u - i * 3f * u;
label(c, rows[i][0], x, y, 17f * u, main, thin, Paint.Align.LEFT);
label(c, rows[i][1], rr - 30f * u - i * 3f * u, y, 19f * u, light, face, Paint.Align.RIGHT);
}
}
}