Orb-Man's cast: Blaze, Zip, Drift and Luna (from 0.108.25), with the cast sheet
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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')
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
Reference in New Issue
Block a user