Добавить production-каталог опасностей

This commit is contained in:
2026-08-19 05:30:56 +03:00
parent cc7f487cf7
commit dc2ebcbf8c
33 changed files with 1160 additions and 37 deletions
@@ -43,9 +43,12 @@ public final class GameView extends View implements Runnable {
private static final float JUMP_BUFFER_SECONDS = PHYSICS.jumpBufferSeconds();
private static final float RESPAWN_SECONDS = PHYSICS.respawnSeconds();
private static final int MAX_CORPSES = PHYSICS.maximumBodies();
private static final int LEVELS_PER_PAGE = 10;
private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE }
private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT }
private enum DeathType {
SPIKE, SAW, FREEZE, ELECTRIC, PIT, CRUSH, FIRE, PROJECTILE, EXPLOSION
}
public static final class AutomationSnapshot {
private final boolean completed;
@@ -69,6 +72,9 @@ public final class GameView extends View implements Runnable {
private final boolean playerOnGround;
private final float playerVelocityX;
private final float playerVelocityY;
private final boolean firstHazardActive;
private final float firstHazardX;
private final float firstHazardY;
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
@@ -137,6 +143,26 @@ public final class GameView extends View implements Runnable {
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround, float playerVelocityX,
float playerVelocityY) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround,
playerVelocityX, playerVelocityY, false, Float.NaN, Float.NaN);
}
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount,
float firstMovingPlatformX, float firstMovingPlatformY,
boolean playerOnMovingPlatform,
boolean firstMovingPlatformPowered,
boolean firstMovingPlatformMovingRight,
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround, float playerVelocityX,
float playerVelocityY, boolean firstHazardActive,
float firstHazardX, float firstHazardY) {
this.completed = completed;
this.playerAlive = playerAlive;
this.allDoorsOpen = allDoorsOpen;
@@ -158,6 +184,9 @@ public final class GameView extends View implements Runnable {
this.playerOnGround = playerOnGround;
this.playerVelocityX = playerVelocityX;
this.playerVelocityY = playerVelocityY;
this.firstHazardActive = firstHazardActive;
this.firstHazardX = firstHazardX;
this.firstHazardY = firstHazardY;
}
public boolean completed() { return completed; }
@@ -181,6 +210,9 @@ public final class GameView extends View implements Runnable {
public boolean playerOnGround() { return playerOnGround; }
public float playerVelocityX() { return playerVelocityX; }
public float playerVelocityY() { return playerVelocityY; }
public boolean firstHazardActive() { return firstHazardActive; }
public float firstHazardX() { return firstHazardX; }
public float firstHazardY() { return firstHazardY; }
}
private static final class Player {
@@ -229,6 +261,7 @@ public final class GameView extends View implements Runnable {
private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES);
private final List<Body> corpses = corpseManager.bodies();
private final ArrayList<PlatformState> platformStates = new ArrayList<>();
private final ArrayList<HazardRuntime> hazardRuntimes = new ArrayList<>();
private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
private final Map<LevelDefinition.Door, DoorController> doorControllers =
new IdentityHashMap<>();
@@ -240,6 +273,8 @@ public final class GameView extends View implements Runnable {
private final CameraController cameraController =
new CameraController(VIEW_WIDTH, VIEW_HEIGHT);
private final PerformanceMonitor performanceMonitor = new PerformanceMonitor(240);
private final LevelPage levelPage =
new LevelPage(LevelCatalog.all().size(), LEVELS_PER_PAGE);
private Screen screen = Screen.LEVELS;
private LevelDefinition level;
@@ -394,12 +429,16 @@ public final class GameView extends View implements Runnable {
deriveControls();
corpseManager.reset();
platformStates.clear();
hazardRuntimes.clear();
previousSwitchContact.clear();
configurePuzzleSystem();
completionResult = null;
configureAccessibilitySignals();
invalidateHudTextCache();
for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item));
for (LevelDefinition.Hazard item : level.hazards) {
hazardRuntimes.add(new HazardRuntime(item));
}
spawnPlayer();
lastFrameNanos = System.nanoTime();
refreshAccessibilityDescription(true);
@@ -428,6 +467,7 @@ public final class GameView extends View implements Runnable {
private void update(float dt) {
levelElapsed += dt;
audioEngine.updateMusic(levelElapsed, level.number);
for (HazardRuntime runtime : hazardRuntimes) runtime.update(dt);
updateSignals(dt);
updatePlatforms(dt);
@@ -769,14 +809,22 @@ public final class GameView extends View implements Runnable {
}
private void checkHazards() {
for (LevelDefinition.Hazard hazard : level.hazards) {
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, hazard.box)) continue;
for (int index = 0; index < level.hazards.size(); index++) {
LevelDefinition.Hazard hazard = level.hazards.get(index);
HazardRuntime runtime = hazardRuntimes.get(index);
if (!runtime.active()) continue;
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT,
runtime.currentBox())) continue;
switch (hazard.type) {
case SPIKES: kill(DeathType.SPIKE, hazard); return;
case SAW: kill(DeathType.SAW, hazard); return;
case FREEZER: kill(DeathType.FREEZE, hazard); return;
case ELECTRIC: kill(DeathType.ELECTRIC, hazard); return;
case PIT: kill(DeathType.PIT, hazard); return;
case CRUSHER: kill(DeathType.CRUSH, hazard); return;
case FIRE: kill(DeathType.FIRE, hazard); return;
case PROJECTILE: kill(DeathType.PROJECTILE, hazard); return;
case EXPLOSION: kill(DeathType.EXPLOSION, hazard); return;
}
}
}
@@ -792,7 +840,7 @@ public final class GameView extends View implements Runnable {
respawnTimer = RESPAWN_SECONDS;
emitHaptic(Build.VERSION.SDK_INT >= 27
? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP);
audioEngine.play(AudioCue.DEATH_IMPACT);
audioEngine.play(hazardAudioCue(source));
audioEngine.play(AudioCue.DEATH_VOICE);
cameraController.impact(type == DeathType.PIT ? 12f : 24f);
@@ -824,6 +872,22 @@ public final class GameView extends View implements Runnable {
}
}
private static AudioCue hazardAudioCue(LevelDefinition.Hazard source) {
if (source != null && "crusher-impact".equals(source.audioCue)) {
return AudioCue.CRUSHER_IMPACT;
}
if (source != null && "fire-burst".equals(source.audioCue)) {
return AudioCue.FIRE_BURST;
}
if (source != null && "projectile-hit".equals(source.audioCue)) {
return AudioCue.PROJECTILE_HIT;
}
if (source != null && "explosion-blast".equals(source.audioCue)) {
return AudioCue.EXPLOSION_BLAST;
}
return AudioCue.DEATH_IMPACT;
}
private void checkGoal() {
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return;
finishLevel(deaths, (long) (levelElapsed * 1000f));
@@ -1041,8 +1105,9 @@ public final class GameView extends View implements Runnable {
}
private void drawHazards(Canvas canvas) {
for (LevelDefinition.Hazard hazard : level.hazards) {
RectF box = rect(hazard.box);
for (int index = 0; index < level.hazards.size(); index++) {
LevelDefinition.Hazard hazard = level.hazards.get(index);
RectF box = rect(hazardRuntimes.get(index).currentBox());
switch (hazard.type) {
case SPIKES:
paint.setColor(Color.rgb(194, 201, 211));
@@ -1088,10 +1153,88 @@ public final class GameView extends View implements Runnable {
paint.setColor(Color.argb(80, 132, 87, 170));
for (float x = box.left + 30; x < box.right; x += 80) canvas.drawOval(new RectF(x, box.top + 35, x + 30, box.top + 80), paint);
break;
case CRUSHER:
drawCrusher(canvas, box, hazardRuntimes.get(index).active(),
hazard.visualStyle);
break;
case FIRE:
drawFire(canvas, box, hazard.visualStyle);
break;
case PROJECTILE:
drawProjectile(canvas, box, hazard.visualStyle);
break;
case EXPLOSION:
drawExplosion(canvas, box, hazardRuntimes.get(index).active(),
hazard.visualStyle);
break;
}
}
}
private void drawCrusher(Canvas canvas, RectF box, boolean active, String visualStyle) {
boolean brass = "brass-press".equals(visualStyle);
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.BLACK : (brass ? Color.rgb(91, 66, 38) : Color.rgb(72, 76, 88)));
canvas.drawRoundRect(box, 12f, 12f, paint);
float jawHeight = active ? box.height() * 0.48f : box.height() * 0.18f;
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : (brass ? Color.rgb(211, 157, 67) : Color.rgb(174, 180, 192)));
canvas.drawRect(box.left + 8f, box.top + 8f,
box.right - 8f, box.top + jawHeight, paint);
canvas.drawRect(box.left + 8f, box.bottom - jawHeight,
box.right - 8f, box.bottom - 8f, paint);
drawWorldLabel(canvas, active ? "CRUSH" : "WAIT", box.centerX(), box.top - 20f);
}
private void drawFire(Canvas canvas, RectF box, String visualStyle) {
boolean blue = "blue-flame".equals(visualStyle);
paint.setColor(Color.argb(110, blue ? 54 : 236, blue ? 182 : 90,
blue ? 255 : 45));
canvas.drawRoundRect(box, 16f, 16f, paint);
float wave = accessibilitySettings.reducedMotionEnabled()
? 0f : (float) Math.sin(levelElapsed * 7f) * 10f;
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : (blue ? Color.rgb(116, 229, 255) : Color.rgb(255, 190, 64)));
for (float x = box.left + 18f; x < box.right; x += 34f) {
path.reset();
path.moveTo(x - 14f, box.bottom - 6f);
path.lineTo(x + wave * 0.25f, box.top + 12f);
path.lineTo(x + 14f, box.bottom - 6f);
path.close();
canvas.drawPath(path, paint);
}
}
private void drawProjectile(Canvas canvas, RectF box, String visualStyle) {
boolean clockwork = "clockwork-bolt".equals(visualStyle);
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : (clockwork ? Color.rgb(233, 181, 76) : Color.rgb(204, 211, 221)));
canvas.drawCircle(box.centerX(), box.centerY(), box.width() * 0.46f, paint);
stroke.setColor(accessibilitySettings.highContrastEnabled()
? Color.BLACK : Color.rgb(72, 56, 42));
stroke.setStrokeWidth(5f);
canvas.drawLine(box.left + 8f, box.centerY(), box.right - 8f, box.centerY(), stroke);
canvas.drawLine(box.centerX(), box.top + 8f, box.centerX(), box.bottom - 8f, stroke);
}
private void drawExplosion(Canvas canvas, RectF box, boolean active, String visualStyle) {
boolean steam = "steam-blast".equals(visualStyle);
float pulse = accessibilitySettings.reducedMotionEnabled()
? 0f : (float) Math.sin(levelElapsed * 5f) * 8f;
paint.setColor(Color.argb(active ? 95 : 28, steam ? 195 : 255, steam ? 220 : 124,
steam ? 231 : 52));
canvas.drawCircle(box.centerX(), box.centerY(),
Math.max(12f, box.width() * (active ? 0.47f : 0.2f) + pulse), paint);
stroke.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : Color.rgb(255, 218, 118));
stroke.setStrokeWidth(7f);
canvas.drawLine(box.left + 12f, box.top + 12f,
box.right - 12f, box.bottom - 12f, stroke);
canvas.drawLine(box.right - 12f, box.top + 12f,
box.left + 12f, box.bottom - 12f, stroke);
drawWorldLabel(canvas, active ? "BLAST" : "REST", box.centerX(), box.top - 20f);
}
private void drawSaw(Canvas canvas, RectF box) {
float angle = levelElapsed * 4.5f;
@@ -1241,9 +1384,10 @@ public final class GameView extends View implements Runnable {
float cardW = 310f;
float cardH = 250f;
float gapX = 65f;
for (int i = 0; i < LevelCatalog.all().size(); i++) {
int col = i % 5;
int row = i / 5;
for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
int localIndex = i - levelPage.firstIndex();
int col = localIndex % 5;
int row = localIndex / 5;
float left = startX + col * (cardW + gapX);
float top = 260f + row * 310f;
RectF card = new RectF(left, top, left + cardW, top + cardH);
@@ -1278,7 +1422,16 @@ public final class GameView extends View implements Runnable {
canvas.restore();
}
drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false);
drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24,
if (levelPage.hasPrevious()) {
drawHudButton(canvas, new RectF(50, 920, 330, 1010), strings.previousPage(), false);
}
if (levelPage.hasNext()) {
drawHudButton(canvas, new RectF(1590, 920, 1870, 1010), strings.nextPage(), false);
}
drawCenteredText(canvas, strings.pageIndicator(levelPage.pageIndex() + 1,
levelPage.pageCount()), VIEW_WIDTH * 0.5f, 965, 25,
Color.rgb(248, 212, 126));
drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 1042, 22,
Color.argb(190, 230, 230, 236));
}
@@ -1651,13 +1804,26 @@ public final class GameView extends View implements Runnable {
refreshAccessibilityDescription(true);
return;
}
if (levelPage.hasPrevious() && contains(50, 920, 330, 1010, x, y)) {
levelPage.previous();
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
refreshAccessibilityDescription(true);
return;
}
if (levelPage.hasNext() && contains(1590, 920, 1870, 1010, x, y)) {
levelPage.next();
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
refreshAccessibilityDescription(true);
return;
}
float startX = 110f;
float cardW = 310f;
float cardH = 250f;
float gapX = 65f;
for (int i = 0; i < LevelCatalog.all().size(); i++) {
int col = i % 5;
int row = i / 5;
for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
int localIndex = i - levelPage.firstIndex();
int col = localIndex % 5;
int row = localIndex / 5;
float left = startX + col * (cardW + gapX);
float top = 260f + row * 310f;
if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) {
@@ -1695,6 +1861,7 @@ public final class GameView extends View implements Runnable {
private void levelsFromResult() {
if (screen != Screen.COMPLETE) return;
levelPage.selectIndex(levelIndex);
screen = Screen.LEVELS;
pointers.clear();
deriveControls();
@@ -1906,6 +2073,24 @@ public final class GameView extends View implements Runnable {
levelsFromResult();
}
public int levelPageIndexForAutomation() {
return levelPage.pageIndex();
}
public int levelPageCountForAutomation() {
return levelPage.pageCount();
}
public void nextLevelPageForAutomation() {
levelPage.next();
refreshAccessibilityDescription(true);
}
public void previousLevelPageForAutomation() {
levelPage.previous();
refreshAccessibilityDescription(true);
}
public void replayFromResultForAutomation() {
replayFromResult();
}
@@ -1936,7 +2121,7 @@ public final class GameView extends View implements Runnable {
if (screen == Screen.LEVELS) {
visible.add(strings.projectTitle());
visible.add(strings.tagline());
for (int i = 0; i < LevelCatalog.all().size(); i++) {
for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
boolean enabled = i < progress.unlockedCount();
visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked());
visible.add(strings.levelTitle(i + 1));
@@ -1948,6 +2133,10 @@ public final class GameView extends View implements Runnable {
}
}
visible.add(strings.settingsButton());
if (levelPage.hasPrevious()) visible.add(strings.previousPage());
if (levelPage.hasNext()) visible.add(strings.nextPage());
visible.add(strings.pageIndicator(levelPage.pageIndex() + 1,
levelPage.pageCount()));
visible.add(strings.levelSelectFooter());
} else if (screen == Screen.SETTINGS) {
visible.add(strings.settingsTitle());
@@ -2344,6 +2533,7 @@ public final class GameView extends View implements Runnable {
startLevel(index);
levelElapsed = snapshot.elapsedSeconds();
for (HazardRuntime runtime : hazardRuntimes) runtime.update(levelElapsed);
deaths = snapshot.deaths();
respawnTimer = snapshot.respawnSeconds();
previousSwitchContact.clear();
@@ -2436,6 +2626,16 @@ public final class GameView extends View implements Runnable {
&& player.supportIndex >= 0
&& player.supportIndex < platformStates.size()
&& platformStates.get(player.supportIndex).definition.isMoving();
boolean firstHazardActive = false;
float firstHazardX = Float.NaN;
float firstHazardY = Float.NaN;
if (!hazardRuntimes.isEmpty()) {
HazardRuntime firstHazard = hazardRuntimes.get(0);
LevelDefinition.Box firstHazardBox = firstHazard.currentBox();
firstHazardActive = firstHazard.active();
firstHazardX = firstHazardBox.x;
firstHazardY = firstHazardBox.y;
}
return new AutomationSnapshot(
screen == Screen.COMPLETE,
player.alive,
@@ -2457,7 +2657,10 @@ public final class GameView extends View implements Runnable {
corpses.isEmpty() ? Float.NaN : corpses.get(0).x,
player.onGround,
player.vx,
player.vy
player.vy,
firstHazardActive,
firstHazardX,
firstHazardY
);
}