diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt index ff191a1..2c6c3e3 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt @@ -35,13 +35,13 @@ import kotlin.math.abs import kotlin.math.sin /** - * Orb-Man (Paul, 5 November 2026): a maze chase starring the Tots mascot. Clear every star from Orbit's own maze while four - * space rocks give chase; a power hexagon makes the rocks take fright, the screen glows, and for a while they can be caught. + * Orb-Man (Paul, 5 November 2026): a maze chase starring the Tots mascot. Clear every star from Orbit's own maze while four of + * Orbit's sky characters give chase ([OrbCharacter]); a power hexagon makes them take fright, the screen glows, and for a while they can be caught. * Each cleared maze is a level, a little quicker than the last. The game itself has no screen in it, so it can be tested. */ internal const val ORB_TICK_MS = 20L -/** The maze's left half and middle column; the right half mirrors it. # wall, . star, o power hexagon, - the rocks' door, H their den, P start. */ +/** The maze's left half and middle column; the right half mirrors it. # wall, . star, o power hexagon, - the den door, H their den, P start. */ private val OrbMazeLeft = listOf( "############", "#o.........#", @@ -134,7 +134,7 @@ internal data class OrbGame( if (moved.pos in powers) { powers = powers - moved.pos; score += 50; events += OrbEvent.Power powerLeft = powerTicks; chain = 0 - // The rocks take fright and turn about. + // The cast take fright and turn about. chasers = chasers.map { c -> if (c.returning || !c.out) c else c.copy(scared = true, mover = c.mover.copy(dir = c.mover.dir.opposite)) } } g = g.copy(player = moved, stars = stars, powers = powers, score = score, chasers = chasers, powerLeft = powerLeft, chain = chain) @@ -197,13 +197,13 @@ internal data class OrbGame( private fun chaseTarget(index: Int, c: OrbChaser): OrbPos { val p = player.pos; val d = player.dir return when (index) { - // Straight for Orb-Man. + // Blaze: straight for Orb-Man. 0 -> p - // Cuts in three squares ahead of him. + // Zip: cuts in three squares ahead of him. 1 -> OrbPos(p.x + 3 * d.dx, p.y + 3 * d.dy) - // Hunts him close up, and drifts back to its corner when he's far away. + // Drift: hunts him close up, and floats back to its corner when he's far away. 2 -> if (abs(c.mover.pos.x - p.x) + abs(c.mover.pos.y - p.y) > 9) OrbPos(maze.width - 2, maze.height - 2) else p - // Guards the stars nearest to him. + // Luna: guards the stars nearest to him. else -> stars.minByOrNull { abs(it.x - p.x) + abs(it.y - p.y) } ?: p } } @@ -226,7 +226,6 @@ internal data class OrbGame( private val Marquee = FontFamily(Font(R.font.anton_regular)) private val Honey = Color(0xFFFFD84A) -internal val OrbChaserColours = listOf(Color(0xFFFF4FA3), Color(0xFF35D6F2), Color(0xFFFF8A3D), Color(0xFFA77BFF)) private val OrbWall = Color(0xFF2E4FE0) private val OrbWallLight = Color(0xFF7D95FF) private val OrbFloor = Color(0xFF0A1850) @@ -253,39 +252,87 @@ internal fun DrawScope.drawOrbMascot(centre: Offset, r: Float, dir: OrbDir = Orb drawArc(Outline, 0f, 180f, false, ringTop - Offset(0f, -r * .1f), Size(ringW, ringH), style = Stroke(r * .05f)) } -/** A space rock: lumpy, with eyes that follow its way. Frightened it turns pale and wobbles; caught, only its eyes go home. */ -internal fun DrawScope.drawOrbChaser(centre: Offset, r: Float, colour: Color, dir: OrbDir, scared: Boolean, returning: Boolean, blink: Boolean, wobble: Float) { +/** + * Orb-Man's cast (Paul, 5 November 2026: "our own mascots, part of the system itself"). Four of Orbit's sky characters, each with + * its own look and its own way of giving chase, in the order [OrbGame] hunts with them. + */ +internal enum class OrbCharacter(val title: String, val colour: Color, val line: String) { + Blaze("Blaze", Color(0xFFFF8A3D), "A hot-headed comet: comes straight for you"), + Zip("Zip", Color(0xFFFF4FA3), "A shooting star: cuts in ahead of you"), + Drift("Drift", Color(0xFF35D6F2), "A sleepy nebula: close up it chases, far off it floats home"), + Luna("Luna", Color(0xFFA77BFF), "A little moon: guards the stars nearest you") +} + +internal val OrbChaserColours = OrbCharacter.entries.map { it.colour } + +private fun starPath(centre: Offset, outer: Float, inner: Float, turn: Float) = Path().apply { + for (k in 0 until 10) { + val a = -PI / 2 + k * PI / 5 + turn + val rad = if (k % 2 == 0) outer else inner + val x = centre.x + (rad * kotlin.math.cos(a)).toFloat(); val y = centre.y + (rad * sin(a)).toFloat() + if (k == 0) moveTo(x, y) else lineTo(x, y) + } + close() +} + +/** + * One of the cast, with eyes that follow its way. Frightened, each keeps its shape but turns pale and worried, blinking white as + * the power wears off; caught, only its eyes hurry home. + */ +internal fun DrawScope.drawOrbChaser(centre: Offset, r: Float, who: OrbCharacter, dir: OrbDir, scared: Boolean, returning: Boolean, blink: Boolean, wobble: Float) { if (!returning) { - val body = if (scared) (if (blink) Color(0xFFE8F0FF) else Color(0xFF4D6BFF)) else colour - val rock = Path().apply { - val n = 9 - for (i in 0..n) { - val a = 2 * PI * i / n - val bump = 1f + .12f * sin(a * 3 + wobble).toFloat() - val x = centre.x + (r * bump * kotlin.math.cos(a)).toFloat(); val y = centre.y + (r * bump * sin(a)).toFloat() - if (i == 0) moveTo(x, y) else lineTo(x, y) + val body = if (scared) (if (blink) Color(0xFFE8F0FF) else Color(0xFF4D6BFF)) else who.colour + when (who) { + OrbCharacter.Blaze -> { + // Its flame trails behind it, flickering. + if (!scared) for (k in 3 downTo 1) { + val back = Offset(-dir.dx * r * .55f * k, -dir.dy * r * .55f * k) + val flick = 1f + .12f * sin(wobble * 2 + k).toFloat() + drawCircle((if (k == 1) Color(0xFFFFC23D) else Color(0xFFFFE066)).copy(alpha = if (k == 1) 1f else .85f / k), r * (.75f - .15f * k) * flick, centre + back) + } + drawCircle(body, r * .95f, centre) + drawCircle(Color.White.copy(alpha = .22f), r * .3f, centre + Offset(-r * .35f, -r * .4f)) + } + OrbCharacter.Zip -> { + drawPath(starPath(centre, r * 1.12f, r * .6f, .08f * sin(wobble).toFloat()), body) + drawCircle(body, r * .62f, centre) + } + OrbCharacter.Drift -> { + // A soft puff of cloud that breathes. + val puff = 1f + .05f * sin(wobble).toFloat() + listOf(Offset(0f, .12f) to .72f, Offset(-.52f, .22f) to .48f, Offset(.52f, .22f) to .48f, Offset(-.22f, -.32f) to .5f, Offset(.3f, -.26f) to .46f) + .forEach { (o, size) -> drawCircle(body, r * size * puff, centre + Offset(o.x * r, o.y * r)) } + } + OrbCharacter.Luna -> { + drawCircle(body, r * .95f, centre) + if (!scared) for ((o, size) in listOf(Offset(.4f, .35f) to .17f, Offset(-.45f, .4f) to .12f, Offset(.5f, -.45f) to .1f)) + drawCircle(Color.Black.copy(alpha = .16f), r * size, centre + Offset(o.x * r, o.y * r)) + drawCircle(Color.White.copy(alpha = .2f), r * .28f, centre + Offset(-r * .38f, -r * .42f)) } - close() } - drawPath(rock, body) - drawCircle(Color.White.copy(alpha = .18f), r * .3f, centre + Offset(-r * .35f, -r * .4f)) - drawCircle(Color.Black.copy(alpha = .12f), r * .18f, centre + Offset(r * .4f, r * .35f)) if (scared) { // A worried wavy mouth instead of eyes that hunt. + val ink = if (blink) Color(0xFF4D6BFF) else Color.White val w = r * .9f; val y = centre.y + r * .3f val p = Path().apply { moveTo(centre.x - w / 2, y); for (k in 1..4) lineTo(centre.x - w / 2 + k * w / 4, y + if (k % 2 == 1) -r * .14f else 0f) } - drawPath(p, if (blink) Color(0xFF4D6BFF) else Color.White, style = Stroke(r * .1f)) - drawCircle(if (blink) Color(0xFF4D6BFF) else Color.White, r * .1f, centre + Offset(-r * .3f, -r * .15f)) - drawCircle(if (blink) Color(0xFF4D6BFF) else Color.White, r * .1f, centre + Offset(r * .3f, -r * .15f)) + drawPath(p, ink, style = Stroke(r * .1f)) + drawCircle(ink, r * .1f, centre + Offset(-r * .3f, -r * .15f)) + drawCircle(ink, r * .1f, centre + Offset(r * .3f, -r * .15f)) return } } val look = Offset(dir.dx * r * .14f, dir.dy * r * .14f) for (side in listOf(-1f, 1f)) { - val eye = centre + Offset(side * r * .36f, -r * .12f) - drawCircle(Color.White, r * .27f, eye) + val eye = centre + Offset(side * r * .34f, -r * .1f) + drawCircle(Color.White, r * .26f, eye) drawCircle(Color(0xFF14213D), r * .13f, eye + look) } + // Luna's sleepy lids, Blaze's cross brows: a little character each. + if (!returning) when (who) { + OrbCharacter.Blaze -> for (side in listOf(-1f, 1f)) drawLine(Color(0xFF14213D), centre + Offset(side * r * .58f, -r * .48f), centre + Offset(side * r * .14f, -r * .32f), r * .1f, StrokeCap.Round) + OrbCharacter.Luna -> for (side in listOf(-1f, 1f)) drawArc(who.colour, 180f, 180f, true, centre + Offset(side * r * .34f - r * .27f, -r * .37f), Size(r * .54f, r * .5f)) + else -> {} + } } /** The maze and everyone in it. [phase] is how far between ticks we are, for smooth movement; [glow] the power glow, 0 to 1. */ @@ -330,7 +377,7 @@ internal fun DrawScope.drawOrbField(game: OrbGame, phase: Float = 0f, glow: Floa val blinking = game.powered && game.powerLeft < 100 && (game.powerLeft / 12) % 2 == 0 game.chasers.forEachIndexed { i, c -> val every = when { c.returning -> 4; c.scared -> game.chaserEvery + 6; !c.out -> game.chaserEvery + 4; else -> game.chaserEvery } - drawOrbChaser(drawn(c.mover, every), cell * .42f, OrbChaserColours[i], c.mover.dir, c.scared, c.returning, blinking, timeMs / 140f + i) + drawOrbChaser(drawn(c.mover, every), cell * .42f, OrbCharacter.entries[i], c.mover.dir, c.scared, c.returning, blinking, timeMs / 140f + i) } val chomp = if (game.readyLeft > 0) 0f else (.5f + .5f * sin(timeMs / 70.0).toFloat()) drawOrbMascot(drawn(game.player, game.playerEvery), cell * .38f, game.player.dir, chomp) @@ -429,6 +476,14 @@ internal fun OrbManScreen(best: Int, onBest: (Int) -> Unit, onLeave: () -> Unit) Text(maxOf(best, game.score).toString(), color = Color.White, fontFamily = Marquee, fontSize = 32.sp) Text("LEVEL ${game.level}", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) Canvas(Modifier.height(30.dp).fillMaxWidth()) { repeat(game.lives) { drawOrbMascot(Offset(16f + it * 44f, size.height / 2), 11f) } } + Spacer(Modifier.height(8.dp)) + Text("THE CAST", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + OrbCharacter.entries.forEach { who -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Canvas(Modifier.size(22.dp)) { drawOrbChaser(center, size.minDimension * .4f, who, OrbDir.Right, false, false, false, 0f) } + Text(who.title, color = who.colour, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + } + } Spacer(Modifier.weight(1f)) Text("◄►▲▼ Steer", color = Muted, fontSize = 13.sp) Text("OK or Back Pause", color = Muted, fontSize = 13.sp) diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt index 9602eaf..233e4cf 100644 --- a/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt +++ b/android/app/src/main/java/tv/orbit/classic/OrbitArcade.kt @@ -302,7 +302,7 @@ private fun OrbitHiveScreen(onLeave: () -> Unit) { } private enum class ArcadeGame(val title: String, val tagline: String, val blurb: String) { - OrbMan("Orb-Man", "Maze chase with the Tots mascot", "Chase the stars, dodge the space rocks, clear the maze."), + OrbMan("Orb-Man", "Maze chase with the Tots mascot", "Clear the stars and dodge Blaze, Zip, Drift and Luna."), Hive("OrbitHive", "Link, match, clear the hive", "Fire flowers into the honeycomb, and clear the hive before it reaches you."), Snake("Snake", "Grow, turn, beat your best", "Eat, grow, and don't hit the walls or yourself."), Blocks("Block Drop", "Stack the blocks, keep it tidy", "Turn and drop the falling blocks, and clear full rows before the stack reaches the top."), diff --git a/design/arcade/build-cast.py b/design/arcade/build-cast.py new file mode 100644 index 0000000..dd419f0 --- /dev/null +++ b/design/arcade/build-cast.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Orb-Man's cast (Paul, 5 November 2026): Orbit's own sky characters, drawn as the app draws them (ArcadeOrbMan.kt).""" +import math +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont +HERE = Path(__file__).resolve().parent +F = HERE.parent / 'channels' / 'network-fonts' +def font(n, s): return ImageFont.truetype(str(F / n), s) +anton, semi, reg = (lambda s: font('anton-regular.ttf', s)), (lambda s: font('barlow-semibold.ttf', s)), (lambda s: font('barlow-regular.ttf', s)) +W, H, SS = 1920, 1080, 2 +NAVY, PANEL, MUTED, YELLOW, WHITE, INK = (7, 22, 50), (18, 53, 107), (183, 203, 232), (255, 222, 89), (255, 255, 255), (20, 33, 61) +im = Image.new('RGB', (W * SS, H * SS), NAVY); d = ImageDraw.Draw(im) +def S(v): return v * SS +def circ(c, r, fill): d.ellipse((S(c[0] - r), S(c[1] - r), S(c[0] + r), S(c[1] + r)), fill=fill) +def eyes(c, r, look=(1, 0)): + for side in (-1, 1): + e = (c[0] + side * r * .34, c[1] - r * .1); circ(e, r * .26, WHITE); circ((e[0] + look[0] * r * .14, e[1] + look[1] * r * .14), r * .13, INK) +def mascot(c, r): + rw, rh = r * 2.7, r * .9; top = (c[0] - rw / 2, c[1] - rh / 2 + r * .15) + d.arc((S(top[0]), S(top[1]), S(top[0] + rw), S(top[1] + rh)), 180, 360, fill=WHITE, width=int(S(r * .2))) + circ(c, r * 1.08, INK); circ(c, r, (247, 169, 40)); circ((c[0] - r * .4, c[1] - r * .45), r * .32, (250, 200, 120)) + circ((c[0] - r * .33 + r * .16, c[1] - r * .2), r * .13, INK); circ((c[0] + r * .33 + r * .16, c[1] - r * .2), r * .13, INK) + m = r * .42; d.arc((S(c[0] - m + r * .16), S(c[1] - m * .55), S(c[0] + m + r * .16), S(c[1] + m * .55)), 15, 165, fill=INK, width=int(S(r * .14))) + d.arc((S(top[0]), S(top[1]), S(top[0] + rw), S(top[1] + rh)), 0, 180, fill=WHITE, width=int(S(r * .2))) +def blaze(c, r, col): + for k in (3, 2, 1): + a = 1.0 if k == 1 else .85 / k; base = (255, 194, 61) if k == 1 else (255, 224, 102) + fill = tuple(int(NAVY[i] + (base[i] - NAVY[i]) * a) for i in range(3)); circ((c[0] - r * .55 * k, c[1]), r * (.75 - .15 * k), fill) + circ(c, r * .95, col); circ((c[0] - r * .35, c[1] - r * .4), r * .3, tuple(min(255, v + 45) for v in col)); eyes(c, r) + for side in (-1, 1): d.line((S(c[0] + side * r * .58), S(c[1] - r * .48), S(c[0] + side * r * .14), S(c[1] - r * .32)), fill=INK, width=int(S(r * .1))) +def zip_(c, r, col): + pts = [] + for k in range(10): + a = -math.pi / 2 + k * math.pi / 5; rad = r * 1.12 if k % 2 == 0 else r * .6 + pts.append((S(c[0] + rad * math.cos(a)), S(c[1] + rad * math.sin(a)))) + d.polygon(pts, fill=col); circ(c, r * .62, col); eyes(c, r) +def drift(c, r, col): + for (ox, oy), sz in (((0, .12), .72), ((-.52, .22), .48), ((.52, .22), .48), ((-.22, -.32), .5), ((.3, -.26), .46)): circ((c[0] + ox * r, c[1] + oy * r), r * sz, col) + eyes(c, r) +def luna(c, r, col): + circ(c, r * .95, col) + for (ox, oy), sz in (((.4, .35), .17), ((-.45, .4), .12), ((.5, -.45), .1)): circ((c[0] + ox * r, c[1] + oy * r), r * sz, tuple(int(v * .84) for v in col)) + circ((c[0] - r * .38, c[1] - r * .42), r * .28, tuple(min(255, v + 40) for v in col)); eyes(c, r) + for side in (-1, 1): + x = c[0] + side * r * .34 - r * .27; y = c[1] - r * .37; d.pieslice((S(x), S(y), S(x + r * .54), S(y + r * .5)), 180, 360, fill=col) +def scared(c, r): + circ(c, r * .95, (77, 107, 255)); w = r * .9; y = c[1] + r * .3 + d.line([(S(c[0] - w / 2 + k * w / 4), S(y - (r * .14 if k % 2 else 0))) for k in range(5)], fill=WHITE, width=int(S(r * .1))) + circ((c[0] - r * .3, c[1] - r * .15), r * .1, WHITE); circ((c[0] + r * .3, c[1] - r * .15), r * .1, WHITE) +d.text((S(80), S(60)), 'ORB-MAN', font=anton(S(72)), fill=YELLOW) +d.text((S(84), S(160)), 'The cast: Orbit\'s own sky characters', font=semi(S(40)), fill=WHITE) +cards = [('Orb-Man', (247, 169, 40), 'The Tots planet, ring and all.', 'You steer it. Clears the stars.', mascot), + ('Blaze', (255, 138, 61), 'A hot-headed comet.', 'Comes straight for you.', blaze), + ('Zip', (255, 79, 163), 'A shooting star.', 'Cuts in ahead of you.', zip_), + ('Drift', (53, 214, 242), 'A sleepy nebula.', 'Chases close, drifts off far away.', drift), + ('Luna', (167, 123, 255), 'A little moon.', 'Guards the stars nearest you.', luna)] +for i, (name, col, l1, l2, draw) in enumerate(cards): + x = 80 + i * 356; y = 260 + d.rounded_rectangle((S(x), S(y), S(x + 330), S(y + 560)), S(14), fill=PANEL) + c = (x + 165, y + 190) + if draw is mascot: draw(c, 70) + else: draw(c, 82, col) + d.text((S(x + 28), S(y + 330)), name.upper(), font=anton(S(48)), fill=col) + d.text((S(x + 28), S(y + 410)), l1, font=semi(S(26)), fill=WHITE) + d.text((S(x + 28), S(y + 448)), l2, font=reg(S(24)), fill=MUTED) +scared((1500, 930), 44) +d.text((S(80), S(900)), 'Grab a power hexagon and the screen glows: the cast turn pale and worried, and can be caught.', font=reg(S(30)), fill=MUTED) +d.text((S(80), S(944)), 'With Reduce motion on, the glow holds steady instead of breathing.', font=reg(S(30)), fill=MUTED) +d.text((S(1560), S(912)), 'frightened', font=reg(S(26)), fill=MUTED) +im.resize((W, H), Image.LANCZOS).save(HERE / 'orb-man-cast.png'); print('cast done') diff --git a/design/arcade/orb-man-cast.png b/design/arcade/orb-man-cast.png new file mode 100644 index 0000000..fddff87 Binary files /dev/null and b/design/arcade/orb-man-cast.png differ