feat(content): roster uplift — PGR 12 cars/6 tracks/18 events, Echoes 24 arenas, 8 bowling lanes
- PGR: four cars per class (12), three more code-spline city circuits (6), six more career events (18) with unlock/reward graph integrity tests; menu event count is dynamic now. - Echoes: 24 authored arenas (was 12), all corridor/crystal-validated for the radius-21 puck; existing indices unchanged. - Bowling: 8 lanes/balls/rivals at the existing 1:1:1 proportion with a setup layout that fits; per-lane determinism tested. - Labyrinth verified at device parity (112+4 mazes); documented. - Echoes and PGR entry goldens re-recorded for the new counts; specs updated. - Suite 1,598/0.
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -413,34 +412,34 @@ private fun BowlingSetup(
|
||||
text = "lane",
|
||||
style = TextStyle(fontFamily = Selawik, fontSize = DoradoTokens.TYPE_CAPTION.sp, color = colors.textSecondary),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
BowlingLane.entries.take(3).forEach { option ->
|
||||
BowlingChip(option.label, option == lane) { onLane(option) }
|
||||
BowlingLane.entries.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
row.forEach { option ->
|
||||
BowlingChip(option.label, option == lane) { onLane(option) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
BowlingLane.entries.drop(3).forEach { option ->
|
||||
BowlingChip(option.label, option == lane) { onLane(option) }
|
||||
BasicText(
|
||||
text = "ball",
|
||||
style = TextStyle(fontFamily = Selawik, fontSize = DoradoTokens.TYPE_CAPTION.sp, color = colors.textSecondary),
|
||||
)
|
||||
BowlingBall.entries.chunked(4).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
row.forEach { option ->
|
||||
BowlingChip(option.label, option == ball) { onBall(option) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (mode == BowlingMode.BLACKJACK) {
|
||||
BasicText(
|
||||
text = "ball",
|
||||
text = "dealer",
|
||||
style = TextStyle(fontFamily = Selawik, fontSize = DoradoTokens.TYPE_CAPTION.sp, color = colors.textSecondary),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
BowlingBall.entries.forEach { option ->
|
||||
BowlingChip(option.label, option == ball) { onBall(option) }
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (mode == BowlingMode.BLACKJACK) {
|
||||
BasicText(
|
||||
text = "dealer",
|
||||
style = TextStyle(fontFamily = Selawik, fontSize = DoradoTokens.TYPE_CAPTION.sp, color = colors.textSecondary),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
BowlingRival.entries.forEach { option ->
|
||||
BowlingChip(option.label, option == rival) { onRival(option) }
|
||||
BowlingRival.entries.chunked(4).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
row.forEach { option ->
|
||||
BowlingChip(option.label, option == rival) { onRival(option) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -941,6 +940,17 @@ private class BowlingStage {
|
||||
),
|
||||
)
|
||||
}
|
||||
BowlingLane.TUNDRA -> for (z in listOf(-580f, -380f, -180f)) {
|
||||
scene.add(nodeAt(postMesh, x = -28f, y = 12f, z = z, material = Material3d(shade(rgb(lane.glowRgb), 0.4f))))
|
||||
scene.add(nodeAt(postMesh, x = 28f, y = 12f, z = z, material = Material3d(shade(rgb(lane.glowRgb), 0.4f))))
|
||||
}
|
||||
BowlingLane.FOUNDRY -> for (z in listOf(-560f, -340f, -140f)) {
|
||||
scene.add(nodeAt(beamMesh, y = 24f, z = z, material = Material3d(shade(rgb(lane.glowRgb), 0.35f))))
|
||||
}
|
||||
BowlingLane.MONSOON -> for (z in listOf(-600f, -400f, -200f)) {
|
||||
scene.add(nodeAt(starMesh, x = -25f, y = 3f, z = z, material = glow))
|
||||
scene.add(nodeAt(starMesh, x = 25f, y = 3f, z = z, material = glow))
|
||||
}
|
||||
}
|
||||
|
||||
val shot = match.shot
|
||||
|
||||
@@ -86,7 +86,7 @@ enum class BowlingLength(val label: String, val frames: Int) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Our five original lanes. [friction] is longitudinal deceleration (u/s^2),
|
||||
* Our original lanes. [friction] is longitudinal deceleration (u/s^2),
|
||||
* [hook] the lateral acceleration scale (u/s^2), [oilLength] the fraction of
|
||||
* the lane before hook engages, [deck] the pin-deck restitution, and the rgb
|
||||
* fields drive the original decor palette.
|
||||
@@ -106,6 +106,9 @@ enum class BowlingLane(
|
||||
DUNES("sunset dunes", 38f, 26f, 0.76f, 0.44f, 0x96613A, 0x39220F, 0xFF8B45),
|
||||
GROTTO("tidal grotto", 24f, 64f, 0.48f, 0.56f, 0x1E4E48, 0x08211F, 0x5CF2C6),
|
||||
ORBIT("stardust station", 14f, 104f, 0.26f, 0.70f, 0x2C2B40, 0x0F0F1A, 0xC9A0FF),
|
||||
TUNDRA("frozen tundra", 34f, 40f, 0.70f, 0.48f, 0x5A6E82, 0x1C2630, 0xBFE3FF),
|
||||
FOUNDRY("iron foundry", 20f, 78f, 0.40f, 0.60f, 0x4A2E22, 0x180C08, 0xFF6B3D),
|
||||
MONSOON("monsoon alley", 26f, 58f, 0.54f, 0.58f, 0x264A5A, 0x0A1A24, 0x59D0E8),
|
||||
}
|
||||
|
||||
/** Our own ball roster; [grip] scales the hook the timing meter can impart. */
|
||||
@@ -115,6 +118,9 @@ enum class BowlingBall(val label: String, val rgb: Int, val grip: Float) {
|
||||
JADE("jade", 0x2CB57E, 1.0f),
|
||||
TIDE("tide", 0x3388E0, 0.95f),
|
||||
LASER("laser", 0xC9A0FF, 1.05f),
|
||||
ONYX("onyx", 0x2A2A2E, 0.85f),
|
||||
AMBER("amber", 0xE0A030, 1.1f),
|
||||
FROST("frost", 0x9FE8FF, 0.8f),
|
||||
}
|
||||
|
||||
/** Original CPU rivals; [skill] 0..1 drives the throw distribution. */
|
||||
@@ -124,6 +130,9 @@ enum class BowlingRival(val label: String, val skill: Float) {
|
||||
MORROW("morrow", 0.63f),
|
||||
KESTREL("kestrel", 0.73f),
|
||||
VESPER("vesper", 0.82f),
|
||||
ORION("orion", 0.50f),
|
||||
CINDER("cinder", 0.68f),
|
||||
HALCYON("halcyon", 0.90f),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -421,6 +421,246 @@ object EchoesArenas {
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val QUARRY = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#..............f.............#",
|
||||
"#..##....##....##....##......#",
|
||||
"#..##....##....##....##......#",
|
||||
"#............................#",
|
||||
"#.....*.....*.....*.....*....#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#..##....##....##....##......#",
|
||||
"#..##....##....##....##......#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#.....*.....*.....*.....*....#",
|
||||
"#..............s.............#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val RAVINE = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#...*....................*...#",
|
||||
"#.............f..............#",
|
||||
"#............................#",
|
||||
"#.###########.....#########..#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............*...............#",
|
||||
"#............................#",
|
||||
"#..########.....###########..#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#.....*................*.....#",
|
||||
"#..............s.............#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val TERRACE = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#..*.........................#",
|
||||
"#.....#.....#.....#.....#....#",
|
||||
"#.....#.....#.....#.....#....#",
|
||||
"#.....#.....#.....#.....#....#",
|
||||
"#.....#.....#.....#.....#....#",
|
||||
"#............................#",
|
||||
"#........*..f..*.....*.....*.#",
|
||||
"#............................#",
|
||||
"#.....#.....#.....#.....#....#",
|
||||
"#.....#.....#.....#.....#....#",
|
||||
"#.....#.....#.....#.....#....#",
|
||||
"#.....#.....#.....#.....#....#",
|
||||
"#...s..........*.............#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val PLAZA = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#..............*.............#",
|
||||
"#...##................##.....#",
|
||||
"#...##................##.....#",
|
||||
"#............................#",
|
||||
"#............####............#",
|
||||
"#............####............#",
|
||||
"#.......*....##r#....*.......#",
|
||||
"#............####............#",
|
||||
"#............................#",
|
||||
"#...##................##.....#",
|
||||
"#...##................##.....#",
|
||||
"#............................#",
|
||||
"#.....s........*.............#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val WARREN = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#.s..........................#",
|
||||
"#............................#",
|
||||
"#...#...#...#...#...#...#....#",
|
||||
"#............................#",
|
||||
"#.....*...f...*.......*......#",
|
||||
"#............................#",
|
||||
"#...#...#...#...#...#...#....#",
|
||||
"#............................#",
|
||||
"#.....*.......*.......*......#",
|
||||
"#............................#",
|
||||
"#...#...#...#...#...#...#....#",
|
||||
"#............................#",
|
||||
"#..............*.............#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val ATOLL = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#...*....................r...#",
|
||||
"#............................#",
|
||||
"#........############........#",
|
||||
"#........#..........#........#",
|
||||
"#........#..........#........#",
|
||||
"#..............*.............#",
|
||||
"#.............s..............#",
|
||||
"#.............*..............#",
|
||||
"#........#......*...#........#",
|
||||
"#........#..........#........#",
|
||||
"#........############........#",
|
||||
"#............................#",
|
||||
"#...f....................*...#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val SWITCHBACK = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#.....*......................#",
|
||||
"#............................#",
|
||||
"#.############...............#",
|
||||
"#............................#",
|
||||
"#.....f...............*......#",
|
||||
"#............................#",
|
||||
"#.............##############.#",
|
||||
"#............................#",
|
||||
"#......*.....................#",
|
||||
"#............................#",
|
||||
"#.############...............#",
|
||||
"#............................#",
|
||||
"#..............s......*......#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val CROSSROADS = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............####............#",
|
||||
"#.......*....####....*.......#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#...########......########...#",
|
||||
"#...########...r..########...#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............####............#",
|
||||
"#.......*....####....*.......#",
|
||||
"#............................#",
|
||||
"#..............s.............#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val BASIN = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#...###........*.......###...#",
|
||||
"#...###................###...#",
|
||||
"#...###................###...#",
|
||||
"#...###........f.......###...#",
|
||||
"#............................#",
|
||||
"#.*............*...........*.#",
|
||||
"#............................#",
|
||||
"#...###................###...#",
|
||||
"#...###................###...#",
|
||||
"#...###................###...#",
|
||||
"#............................#",
|
||||
"#.s............*.............#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val STEPS = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#.s............f.............#",
|
||||
"#...##.....##.....##.....##..#",
|
||||
"#...##.....##.....##.....##..#",
|
||||
"#............................#",
|
||||
"#......*......*......*.......#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#...##.....##.....##.....##..#",
|
||||
"#...##.....##.....##.....##..#",
|
||||
"#......*.............*.......#",
|
||||
"#............................#",
|
||||
"#...##.....##.....##.....##..#",
|
||||
"#...##.....##..*..##.....##..#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val GROTTO = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#..............*.............#",
|
||||
"#.....##....##....##....##...#",
|
||||
"#.....##....##....##....##...#",
|
||||
"#............................#",
|
||||
"#..*...........*...........*.#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#.....##................##...#",
|
||||
"#.....##................##...#",
|
||||
"#..............*.............#",
|
||||
"#............................#",
|
||||
"#...........##....##.........#",
|
||||
"#..r........##.*..##......s..#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
private val SUMMIT = listOf(
|
||||
"##############################",
|
||||
"#............................#",
|
||||
"#...*..........*.........*...#",
|
||||
"#............................#",
|
||||
"#.....##..............##.....#",
|
||||
"#.....##.......f......##.....#",
|
||||
"#.....##......##......##.....#",
|
||||
"#.............##.............#",
|
||||
"#.............##.............#",
|
||||
"#.....##......##......##.....#",
|
||||
"#.....##......##......##.....#",
|
||||
"#.....##..............##.....#",
|
||||
"#............................#",
|
||||
"#...*....................*...#",
|
||||
"#..............s.............#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
)
|
||||
|
||||
val ALL: List<EchoesArena> = listOf(
|
||||
RawArena("drumlin", DRUMLIN),
|
||||
RawArena("cairn", CAIRN),
|
||||
@@ -434,6 +674,18 @@ object EchoesArenas {
|
||||
RawArena("mesa", MESA),
|
||||
RawArena("notch", NOTCH),
|
||||
RawArena("oxbow", OXBOW),
|
||||
RawArena("quarry", QUARRY),
|
||||
RawArena("ravine", RAVINE),
|
||||
RawArena("terrace", TERRACE),
|
||||
RawArena("plaza", PLAZA),
|
||||
RawArena("warren", WARREN),
|
||||
RawArena("atoll", ATOLL),
|
||||
RawArena("switchback", SWITCHBACK),
|
||||
RawArena("crossroads", CROSSROADS),
|
||||
RawArena("basin", BASIN),
|
||||
RawArena("steps", STEPS),
|
||||
RawArena("grotto", GROTTO),
|
||||
RawArena("summit", SUMMIT),
|
||||
).mapIndexed { index, raw -> parseArena(index, raw) }
|
||||
|
||||
fun byId(id: Int): EchoesArena = ALL[id.coerceIn(0, ALL.lastIndex)]
|
||||
|
||||
@@ -479,7 +479,7 @@ private fun PgrMenu(
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PgrRow("quick race", "car · track · difficulty", onClick = onQuick)
|
||||
PgrRow("career", "12 events · medals unlock the field", onClick = onCareer)
|
||||
PgrRow("career", "${PgrEngine.PgrEvents.all.size} events · medals unlock the field", onClick = onCareer)
|
||||
PgrRow("garage", "${profile.cars.size} cars owned", onClick = onGarage)
|
||||
PgrRow("records", "lap, race and kudos bests", onClick = onRecords)
|
||||
PgrRow("options", "tilt, auto-gas, sound", onClick = onOptions)
|
||||
|
||||
@@ -256,6 +256,24 @@ object PgrEngine {
|
||||
trackWidth = 1.9f, areaFront = 0.8f, areaSide = 2.7f, brakeTorque = 7600f,
|
||||
maxSteerDeg = 35f, colorsArgb = listOf(0x2F7A4E, 0xE8B93A, 0x2A2A2E, 0xD8D8D8),
|
||||
),
|
||||
PgrCarDef(
|
||||
id = "lupo-v", name = "lupo v", classLevel = 1, creditCost = 22000, kudosCost = 0,
|
||||
medalsRequired = 0, accel = 5, speedStat = 5, handling = 5, weight = 5, brake = 5,
|
||||
massKg = 1195f, minTorque = 245f, maxTorque = 445f, lowRpm = 1120f, highRpm = 8300f,
|
||||
gears = listOf(3.5f, 2.45f, 1.9f, 1.55f, 1.3f, 1.09f), reverseRatio = 3.25f,
|
||||
finalDrive = 3.58f, radiusFront = 0.32f, radiusRear = 0.33f, wheelbase = 2.58f,
|
||||
trackWidth = 1.88f, areaFront = 0.79f, areaSide = 2.64f, brakeTorque = 7400f,
|
||||
maxSteerDeg = 34.5f, colorsArgb = listOf(0xD14B3C, 0x1E3E73, 0xF0E2C0, 0x28282C),
|
||||
),
|
||||
PgrCarDef(
|
||||
id = "monte-r", name = "monte r", classLevel = 1, creditCost = 32000, kudosCost = 0,
|
||||
medalsRequired = 1, accel = 5, speedStat = 5, handling = 6, weight = 5, brake = 5,
|
||||
massKg = 1235f, minTorque = 252f, maxTorque = 458f, lowRpm = 1140f, highRpm = 8450f,
|
||||
gears = listOf(3.55f, 2.48f, 1.91f, 1.53f, 1.26f), reverseRatio = 3.3f,
|
||||
finalDrive = 3.62f, radiusFront = 0.32f, radiusRear = 0.34f, wheelbase = 2.62f,
|
||||
trackWidth = 1.9f, areaFront = 0.8f, areaSide = 2.68f, brakeTorque = 7700f,
|
||||
maxSteerDeg = 35.5f, colorsArgb = listOf(0x2E6E8E, 0xE0B23A, 0x30302F, 0xE8E8E8),
|
||||
),
|
||||
PgrCarDef(
|
||||
id = "tigre-gts", name = "tigre gts", classLevel = 2, creditCost = 30000, kudosCost = 800,
|
||||
medalsRequired = 3, accel = 6, speedStat = 6, handling = 6, weight = 6, brake = 6,
|
||||
@@ -274,6 +292,24 @@ object PgrEngine {
|
||||
trackWidth = 1.96f, areaFront = 0.82f, areaSide = 2.9f, brakeTorque = 8600f,
|
||||
maxSteerDeg = 36f, colorsArgb = listOf(0xE07B2A, 0x2B2B30, 0x5A6B7A, 0xEFEFEF),
|
||||
),
|
||||
PgrCarDef(
|
||||
id = "tempesta-7", name = "tempesta 7", classLevel = 2, creditCost = 60000, kudosCost = 2200,
|
||||
medalsRequired = 7, accel = 7, speedStat = 7, handling = 6, weight = 6, brake = 7,
|
||||
massKg = 1345f, minTorque = 315f, maxTorque = 575f, lowRpm = 1220f, highRpm = 8700f,
|
||||
gears = listOf(3.52f, 2.48f, 1.9f, 1.55f, 1.31f, 1.09f), reverseRatio = 3.38f,
|
||||
finalDrive = 3.68f, radiusFront = 0.33f, radiusRear = 0.34f, wheelbase = 2.66f,
|
||||
trackWidth = 1.95f, areaFront = 0.83f, areaSide = 2.87f, brakeTorque = 8400f,
|
||||
maxSteerDeg = 35f, colorsArgb = listOf(0xB02A2A, 0x2A2E36, 0xE8C24A, 0xF0F0F0),
|
||||
),
|
||||
PgrCarDef(
|
||||
id = "vortex-s", name = "vortex s", classLevel = 2, creditCost = 72000, kudosCost = 2800,
|
||||
medalsRequired = 8, accel = 7, speedStat = 7, handling = 7, weight = 6, brake = 7,
|
||||
massKg = 1315f, minTorque = 330f, maxTorque = 590f, lowRpm = 1240f, highRpm = 8800f,
|
||||
gears = listOf(3.56f, 2.52f, 1.93f, 1.57f, 1.32f, 1.1f), reverseRatio = 3.42f,
|
||||
finalDrive = 3.72f, radiusFront = 0.33f, radiusRear = 0.35f, wheelbase = 2.69f,
|
||||
trackWidth = 1.97f, areaFront = 0.85f, areaSide = 2.92f, brakeTorque = 8800f,
|
||||
maxSteerDeg = 36.5f, colorsArgb = listOf(0x1E7A5A, 0x101820, 0xC8C8CE, 0xE04B2A),
|
||||
),
|
||||
PgrCarDef(
|
||||
id = "aquila-sc", name = "aquila sc", classLevel = 3, creditCost = 80000, kudosCost = 3000,
|
||||
medalsRequired = 9, accel = 8, speedStat = 8, handling = 8, weight = 7, brake = 8,
|
||||
@@ -292,6 +328,24 @@ object PgrEngine {
|
||||
trackWidth = 2.0f, areaFront = 0.9f, areaSide = 3.1f, brakeTorque = 9600f,
|
||||
maxSteerDeg = 38f, colorsArgb = listOf(0xC8A02E, 0x101014, 0x27443A, 0xD8D8D8),
|
||||
),
|
||||
PgrCarDef(
|
||||
id = "corsa-nera", name = "corsa nera", classLevel = 3, creditCost = 160000, kudosCost = 9000,
|
||||
medalsRequired = 14, accel = 9, speedStat = 9, handling = 9, weight = 8, brake = 9,
|
||||
massKg = 1220f, minTorque = 380f, maxTorque = 700f, lowRpm = 1320f, highRpm = 9100f,
|
||||
gears = listOf(3.62f, 2.58f, 1.96f, 1.59f, 1.34f, 1.11f), reverseRatio = 3.48f,
|
||||
finalDrive = 3.78f, radiusFront = 0.34f, radiusRear = 0.35f, wheelbase = 2.71f,
|
||||
trackWidth = 1.99f, areaFront = 0.89f, areaSide = 3.05f, brakeTorque = 9400f,
|
||||
maxSteerDeg = 37.5f, colorsArgb = listOf(0x141418, 0xC8A02E, 0x8E1C1C, 0xE8E8E8),
|
||||
),
|
||||
PgrCarDef(
|
||||
id = "aurora-one", name = "aurora one", classLevel = 3, creditCost = 200000, kudosCost = 12000,
|
||||
medalsRequired = 16, accel = 10, speedStat = 10, handling = 9, weight = 8, brake = 10,
|
||||
massKg = 1195f, minTorque = 410f, maxTorque = 750f, lowRpm = 1350f, highRpm = 9300f,
|
||||
gears = listOf(3.68f, 2.62f, 2.0f, 1.62f, 1.36f, 1.13f), reverseRatio = 3.52f,
|
||||
finalDrive = 3.82f, radiusFront = 0.34f, radiusRear = 0.36f, wheelbase = 2.74f,
|
||||
trackWidth = 2.02f, areaFront = 0.91f, areaSide = 3.12f, brakeTorque = 10000f,
|
||||
maxSteerDeg = 38.5f, colorsArgb = listOf(0xF0F0F0, 0x1A2A4A, 0x2FA08E, 0xE0B23A),
|
||||
),
|
||||
)
|
||||
|
||||
val starter: PgrCarDef get() = all.first()
|
||||
@@ -905,6 +959,42 @@ object PgrEngine {
|
||||
width = 9f,
|
||||
propSeed = 2026497481,
|
||||
),
|
||||
PgrTrackDef(
|
||||
id = "westminster-ring",
|
||||
name = "westminster ring",
|
||||
city = PgrCity.LONDON,
|
||||
controlPoints = listOf(
|
||||
PgrVec2(0f, 0f), PgrVec2(110f, -35f), PgrVec2(210f, -5f), PgrVec2(260f, 80f),
|
||||
PgrVec2(230f, 170f), PgrVec2(140f, 215f), PgrVec2(45f, 200f), PgrVec2(-30f, 140f),
|
||||
PgrVec2(-40f, 55f),
|
||||
),
|
||||
width = 12f,
|
||||
propSeed = 1118736411,
|
||||
),
|
||||
PgrTrackDef(
|
||||
id = "shibuya-cross",
|
||||
name = "shibuya cross",
|
||||
city = PgrCity.TOKYO,
|
||||
controlPoints = listOf(
|
||||
PgrVec2(0f, 0f), PgrVec2(90f, -40f), PgrVec2(185f, -25f), PgrVec2(240f, 40f),
|
||||
PgrVec2(215f, 110f), PgrVec2(250f, 175f), PgrVec2(175f, 225f), PgrVec2(90f, 195f),
|
||||
PgrVec2(35f, 235f), PgrVec2(-35f, 190f), PgrVec2(-45f, 95f), PgrVec2(-15f, 25f),
|
||||
),
|
||||
width = 10f,
|
||||
propSeed = 1338092767,
|
||||
),
|
||||
PgrTrackDef(
|
||||
id = "midtown-dash",
|
||||
name = "midtown dash",
|
||||
city = PgrCity.NEW_YORK,
|
||||
controlPoints = listOf(
|
||||
PgrVec2(0f, 0f), PgrVec2(70f, -20f), PgrVec2(150f, -15f), PgrVec2(200f, 25f),
|
||||
PgrVec2(230f, 90f), PgrVec2(185f, 150f), PgrVec2(120f, 170f), PgrVec2(60f, 150f),
|
||||
PgrVec2(20f, 185f), PgrVec2(-30f, 150f), PgrVec2(-40f, 75f), PgrVec2(-25f, 25f),
|
||||
),
|
||||
width = 11f,
|
||||
propSeed = 1717562309,
|
||||
),
|
||||
)
|
||||
|
||||
val all: List<PgrTrack> by lazy { defs.map { build(it) } }
|
||||
@@ -1100,6 +1190,43 @@ object PgrEngine {
|
||||
prerequisites = listOf("ev10", "ev11"), parMs = 340_000L,
|
||||
rewardCarId = "falco-stradale", blurb = "six laps for the falco stradale.",
|
||||
),
|
||||
PgrEventDef(
|
||||
id = "ev13", name = "westminster cup", type = PgrEventType.STREET_RACE,
|
||||
trackId = "westminster-ring", laps = 3, difficulty = PgrDifficulty.HARD,
|
||||
prerequisites = listOf("ev09"), parMs = 270_000L,
|
||||
blurb = "the capital's new ring. hold the racing line to the flag.",
|
||||
),
|
||||
PgrEventDef(
|
||||
id = "ev14", name = "shibuya drift", type = PgrEventType.KUDOS_CHALLENGE,
|
||||
trackId = "shibuya-cross", laps = 3, difficulty = PgrDifficulty.HARD,
|
||||
prerequisites = listOf("ev13"), targetKudos = 3000, parMs = 280_000L,
|
||||
rewardCarId = "tempesta-7", blurb = "chain three thousand kudos for the tempesta 7.",
|
||||
),
|
||||
PgrEventDef(
|
||||
id = "ev15", name = "midtown overtake", type = PgrEventType.OVERTAKE,
|
||||
trackId = "midtown-dash", laps = 3, difficulty = PgrDifficulty.HARD,
|
||||
prerequisites = listOf("ev13"), targetOvertakes = 6, parMs = 280_000L,
|
||||
rewardCarId = "vortex-s", blurb = "six clean passes unlock the vortex s.",
|
||||
),
|
||||
PgrEventDef(
|
||||
id = "ev16", name = "elite time trial", type = PgrEventType.TIME_VS_KUDOS,
|
||||
trackId = "westminster-ring", laps = 4, difficulty = PgrDifficulty.HARD,
|
||||
prerequisites = listOf("ev14", "ev15"), limitMs = 240_000L, targetKudos = 2500,
|
||||
parMs = 240_000L, rewardCarId = "corsa-nera",
|
||||
blurb = "beat the clock with kudos in hand for the corsa nera.",
|
||||
),
|
||||
PgrEventDef(
|
||||
id = "ev17", name = "grand masters", type = PgrEventType.ONE_ON_ONE,
|
||||
trackId = "shibuya-cross", laps = 4, difficulty = PgrDifficulty.HARD,
|
||||
prerequisites = listOf("ev16"), parMs = 300_000L,
|
||||
rewardCarId = "aurora-one", blurb = "one rival, four laps, the aurora one.",
|
||||
),
|
||||
PgrEventDef(
|
||||
id = "ev18", name = "final showdown", type = PgrEventType.STREET_RACE,
|
||||
trackId = "midtown-dash", laps = MAX_LAPS, difficulty = PgrDifficulty.HARD,
|
||||
prerequisites = listOf("ev16", "ev17"), parMs = 360_000L,
|
||||
blurb = "the last race on the streets of new york. first place only.",
|
||||
),
|
||||
)
|
||||
|
||||
fun byId(id: String): PgrEventDef? = all.firstOrNull { it.id == id }
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -378,16 +378,54 @@ class BowlingTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five original lanes carry distinct physics parameters`() {
|
||||
assertEquals(5, BowlingLane.entries.size)
|
||||
assertEquals(5, BowlingBall.entries.size)
|
||||
assertEquals(5, BowlingRival.entries.size)
|
||||
fun `eight original lanes carry distinct physics parameters`() {
|
||||
assertEquals(8, BowlingLane.entries.size)
|
||||
assertEquals(8, BowlingBall.entries.size)
|
||||
assertEquals(8, BowlingRival.entries.size)
|
||||
for (lane in BowlingLane.entries) {
|
||||
assertTrue(lane.friction > 0f)
|
||||
assertTrue(lane.hook > 0f)
|
||||
assertTrue(lane.oilLength in 0f..1f)
|
||||
assertTrue(lane.deck in 0f..1f)
|
||||
assertTrue(lane.surfaceRgb != lane.backRgb)
|
||||
}
|
||||
assertEquals(8, BowlingLane.entries.map { it.label }.toSet().size)
|
||||
assertEquals(8, BowlingBall.entries.map { it.label }.toSet().size)
|
||||
assertEquals(8, BowlingRival.entries.map { it.label }.toSet().size)
|
||||
assertEquals(8, BowlingBall.entries.map { it.rgb }.toSet().size)
|
||||
for (ball in BowlingBall.entries) assertTrue(ball.grip > 0f)
|
||||
for (rival in BowlingRival.entries) assertTrue(rival.skill in 0f..1f)
|
||||
val pairs = BowlingLane.entries.map { it.friction to it.hook }.toSet()
|
||||
assertEquals(8, pairs.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every lane and ball runs a deterministic valid shot`() {
|
||||
for (lane in BowlingLane.entries) {
|
||||
val a = BowlingEngine.simulateShot(lane, List(BOWLING_NUM_PINS) { true }, straight, BowlingBall.COMET, seed = 77)
|
||||
val b = BowlingEngine.simulateShot(lane, List(BOWLING_NUM_PINS) { true }, straight, BowlingBall.COMET, seed = 77)
|
||||
assertEquals(lane.label, a, b)
|
||||
assertTrue("${lane.label} never settled", a.done)
|
||||
assertEquals(BOWLING_NUM_PINS, a.pins.size)
|
||||
assertTrue(a.felledCount() in 0..BOWLING_NUM_PINS)
|
||||
assertFalse(a.ball.x.isNaN())
|
||||
}
|
||||
for (ball in BowlingBall.entries) {
|
||||
val shot = BowlingEngine.simulateShot(BowlingLane.PINERY, List(BOWLING_NUM_PINS) { true }, straight, ball, seed = 78)
|
||||
assertTrue("${ball.label} never settled", shot.done)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every rival throws deterministically and in range`() {
|
||||
for (rival in BowlingRival.entries) {
|
||||
val a = BowlingEngine.cpuThrow(rival, seed = 9, index = 0)
|
||||
val b = BowlingEngine.cpuThrow(rival, seed = 9, index = 0)
|
||||
assertEquals(rival.label, a, b)
|
||||
assertTrue(a.speed in 0.35f..0.95f)
|
||||
assertTrue(a.aim in -1f..1f)
|
||||
assertTrue(a.spin in -1f..1f)
|
||||
}
|
||||
assertEquals(5, BowlingLane.entries.map { it.label }.toSet().size)
|
||||
}
|
||||
|
||||
/* ------------------------------ match flow ------------------------------ */
|
||||
|
||||
@@ -369,7 +369,8 @@ class EchoesTest {
|
||||
|
||||
@Test
|
||||
fun `all authored arenas parse with equal rows and floor spawns`() {
|
||||
assertEquals(12, EchoesArenas.ALL.size)
|
||||
assertEquals(24, EchoesArenas.ALL.size)
|
||||
assertEquals(24, EchoesArenas.ALL.map { it.name }.toSet().size)
|
||||
for (arena in EchoesArenas.ALL) {
|
||||
assertEquals(30, arena.cols)
|
||||
assertEquals(17, arena.rows)
|
||||
@@ -380,6 +381,15 @@ class EchoesTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `arena lookup covers the whole campaign and clamps out of range`() {
|
||||
for (index in EchoesArenas.ALL.indices) {
|
||||
assertEquals(index, EchoesArenas.byId(index).id)
|
||||
}
|
||||
assertEquals(0, EchoesArenas.byId(-1).id)
|
||||
assertEquals(EchoesArenas.ALL.lastIndex, EchoesArenas.byId(9_999).id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every crystal is reachable by a radius twenty one puck`() {
|
||||
for (arena in EchoesArenas.ALL) {
|
||||
|
||||
@@ -618,4 +618,78 @@ class PgrTest {
|
||||
repeat(PgrEngine.TICS_PER_SECOND) { race = PgrEngine.tickRace(race, null) }
|
||||
assertTrue(race.phase == PgrEngine.PgrPhase.RACING)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Content roster (device parity counts)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `content roster meets the device counts`() {
|
||||
assertEquals(12, PgrEngine.PgrCars.all.size)
|
||||
assertEquals(6, PgrEngine.PgrTracks.all.size)
|
||||
assertEquals(18, PgrEngine.PgrEvents.all.size)
|
||||
assertEquals(4, PgrEngine.PgrCars.all.count { it.classLevel == 1 })
|
||||
assertEquals(4, PgrEngine.PgrCars.all.count { it.classLevel == 2 })
|
||||
assertEquals(4, PgrEngine.PgrCars.all.count { it.classLevel == 3 })
|
||||
assertEquals(
|
||||
PgrEngine.PgrCars.starter,
|
||||
PgrEngine.PgrCars.all.first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every authored car, track and event id is unique`() {
|
||||
val carIds = PgrEngine.PgrCars.all.map { it.id }
|
||||
assertEquals(carIds.size, carIds.toSet().size)
|
||||
val trackIds = PgrEngine.PgrTracks.all.map { it.def.id }
|
||||
assertEquals(trackIds.size, trackIds.toSet().size)
|
||||
val eventIds = PgrEngine.PgrEvents.all.map { it.id }
|
||||
assertEquals(eventIds.size, eventIds.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every car is unlockable through purchase or a career reward`() {
|
||||
val events = PgrEngine.PgrEvents.all
|
||||
val rewardCars = events.mapNotNull { it.rewardCarId }.toSet()
|
||||
for (car in PgrEngine.PgrCars.all) {
|
||||
val starter = car.id == PgrEngine.PgrCars.starter.id
|
||||
val reward = car.id in rewardCars
|
||||
val purchasable = car.creditCost > 0 && car.medalsRequired <= events.size
|
||||
assertTrue("car ${car.id} is not unlockable", starter || reward || purchasable)
|
||||
if (!starter) assertTrue("car ${car.id} costs nothing", car.creditCost > 0)
|
||||
assertTrue("car ${car.id} medal gate exceeds events", car.medalsRequired <= events.size)
|
||||
assertTrue("car ${car.id} kudos cost is negative", car.kudosCost >= 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every event references valid content and the unlock graph is acyclic`() {
|
||||
val events = PgrEngine.PgrEvents.all
|
||||
val ids = events.map { it.id }.toSet()
|
||||
val tracks = PgrEngine.PgrTracks.all.map { it.def.id }.toSet()
|
||||
val cars = PgrEngine.PgrCars.all.map { it.id }.toSet()
|
||||
for (event in events) {
|
||||
assertTrue("event ${event.id} has unknown track", event.trackId in tracks)
|
||||
event.rewardCarId?.let {
|
||||
assertTrue("event ${event.id} rewards unknown car $it", it in cars)
|
||||
}
|
||||
event.prerequisites.forEach {
|
||||
assertTrue("event ${event.id} prerequisite $it unknown", it in ids)
|
||||
}
|
||||
assertTrue("event ${event.id} lap count", event.laps in 1..PgrEngine.MAX_LAPS)
|
||||
}
|
||||
val reachable = HashSet<String>()
|
||||
var changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (event in events) {
|
||||
if (event.id in reachable) continue
|
||||
if (event.prerequisites.all { it in reachable }) {
|
||||
reachable += event.id
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals("career graph must be reachable from its roots", ids, reachable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +105,11 @@ as our own tile layouts.
|
||||
- Tests (`app/src/test/java/com/heretek/dorado_hd/`): snapshot replay
|
||||
determinism, collision/hit/invulnerability windows, power-up durations,
|
||||
mode win/continue rules, unlock ordering, achievement thresholds.
|
||||
- Fidelity target: full-parity mechanics with original arenas. Risks:
|
||||
echo-replay determinism across frame rates, the volume of arena content,
|
||||
and multiplayer over the modern stack (scope to local/co-op first).
|
||||
- Fidelity target: full-parity mechanics with original arenas. The
|
||||
Dorado-HD roster is closed at 24 original arenas (authored wall plans
|
||||
validated for the 21-unit puck radius). Risks: echo-replay determinism
|
||||
across frame rates, and multiplayer over the modern stack (scope to
|
||||
local/co-op first).
|
||||
|
||||
## 7. Citation log
|
||||
|
||||
|
||||
@@ -105,10 +105,10 @@ synthesized SFX.
|
||||
and tunneling guards at max speed, checkpoint rewind, pickup-open-goal
|
||||
rule, par-star math, act unlock, save/restore, tilt-to-acceleration
|
||||
mapping incl. landscape flip and 1.05 assist.
|
||||
- Fidelity target: full-parity mechanics; ship 5 acts of original mazes
|
||||
(target 116 levels, phased authoring). Risks: collision robustness at
|
||||
750 px/s, tilt feel/calibration across devices, and maze authoring
|
||||
volume.
|
||||
- Fidelity target: full-parity mechanics; the Dorado-HD roster ships five
|
||||
acts of original mazes — 116 levels (112 scenes + 4 bonus), at parity with
|
||||
the device counts. Risks: collision robustness at 750 px/s, tilt
|
||||
feel/calibration across devices, and maze authoring volume.
|
||||
|
||||
## 7. Citation log
|
||||
|
||||
|
||||
@@ -124,9 +124,10 @@ synthesized music/SFX/voices (no Microsoft assets).
|
||||
classifier clamps, deterministic pin simulation replay, unlock matrix,
|
||||
CPU turn order.
|
||||
- Fidelity target: full-parity mechanics with original content; the
|
||||
campaign ships the 5 original lane themes and a smaller character roster
|
||||
first. Risks: physics tuning for believable pin action, replay camera
|
||||
parity, and the animation/voiceover asset count.
|
||||
Dorado-HD campaign ships 8 original lane themes (each with its own
|
||||
friction/hook/oil parameter set) plus 8 balls and 8 CPU rivals. Risks:
|
||||
physics tuning for believable pin action, replay camera parity, and the
|
||||
animation/voiceover asset count.
|
||||
|
||||
## 7. Citation log
|
||||
|
||||
|
||||
@@ -122,9 +122,10 @@ and SFX, and our own ghost data.
|
||||
values and combo stash, event win conditions, ghost record/playback,
|
||||
credit/unlock rules.
|
||||
- Fidelity target: full-parity racing sim mechanics with original cars,
|
||||
cities and events (smaller initial car/event roster). Risks: physics
|
||||
feel parity, AI quality, and the content volume of four cars per class
|
||||
plus multi-segment cities.
|
||||
cities and events. The Dorado-HD roster is closed at the device content
|
||||
counts: 12 cars (four per class), 6 code-defined tracks (two per city) and
|
||||
18 career events. Risks: physics feel parity, AI quality, and track
|
||||
authoring volume.
|
||||
|
||||
## 7. Citation log
|
||||
|
||||
|
||||