Initialize Project Sacrifice backlog and source baseline

This commit is contained in:
2026-08-18 00:46:42 +03:00
parent d28ea8c3fd
commit 29d4a2d8d8
34 changed files with 4630 additions and 151 deletions
@@ -0,0 +1,29 @@
package com.projectsacrifice.game;
public final class GameMath {
private GameMath() {}
public static float clamp(float value, float min, float max) {
return Math.max(min, Math.min(max, value));
}
public static boolean intersects(
float ax, float ay, float aw, float ah,
float bx, float by, float bw, float bh
) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
public static float pingPong(float elapsedSeconds, float periodSeconds) {
if (periodSeconds <= 0f) return 0f;
double phase = (elapsedSeconds % periodSeconds) / periodSeconds;
return (float) ((1d - Math.cos(phase * Math.PI * 2d)) * 0.5d);
}
public static String formatTime(long elapsedMs) {
long totalSeconds = Math.max(0L, elapsedMs) / 1000L;
long minutes = totalSeconds / 60L;
long seconds = totalSeconds % 60L;
return String.format(java.util.Locale.US, "%02d:%02d", minutes, seconds);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
package com.projectsacrifice.game;
import static com.projectsacrifice.game.LevelDefinition.HazardType.ELECTRIC;
import static com.projectsacrifice.game.LevelDefinition.HazardType.FREEZER;
import static com.projectsacrifice.game.LevelDefinition.HazardType.PIT;
import static com.projectsacrifice.game.LevelDefinition.HazardType.SAW;
import static com.projectsacrifice.game.LevelDefinition.HazardType.SPIKES;
import static com.projectsacrifice.game.LevelDefinition.SwitchType.PRESSURE;
import static com.projectsacrifice.game.LevelDefinition.SwitchType.TIMED;
import static com.projectsacrifice.game.LevelDefinition.SwitchType.TOGGLE;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/** Ten original vertical-slice puzzles with a one-mechanic-at-a-time learning curve. */
public final class LevelCatalog {
private static final float FLOOR_Y = 880f;
private static final List<LevelDefinition> LEVELS = Collections.unmodifiableList(Arrays.asList(
level1(), level2(), level3(), level4(), level5(),
level6(), level7(), level8(), level9(), level10()
));
private LevelCatalog() {}
public static List<LevelDefinition> all() { return LEVELS; }
public static LevelDefinition get(int index) {
if (index < 0 || index >= LEVELS.size()) {
throw new IllegalArgumentException("Unknown level index: " + index);
}
return LEVELS.get(index);
}
private static LevelDefinition level1() {
return level(
1, "The Volunteer", "Reach the relic. Arrows move; the winged button jumps.",
1920, 130, 760, goal(1690, 770), 0, 30,
platforms(floor(0, 1920), ledge(640, 760, 230), ledge(1060, 690, 250)),
hazards(), switches(), doors()
);
}
private static LevelDefinition level2() {
return level(
2, "A Useful End", "The spikes keep every volunteer. Build a path from what remains.",
2050, 130, 760, goal(1830, 770), 3, 55,
platforms(floor(0, 2050), ledge(1180, 740, 240)),
hazards(hazard(SPIKES, 610, 850, 260, 30)), switches(), doors()
);
}
private static LevelDefinition level3() {
return level(
3, "Dead Weight", "Leave a body on the brass plate so the next volunteer can pass.",
2050, 130, 760, goal(1810, 770), 1, 50,
platforms(floor(0, 2050)),
hazards(hazard(SAW, 565, 760, 92, 92, null, 0, -150)),
switches(sw(PRESSURE, 540, 860, 150, 20, "gate", 0, 1)),
doors(door(1110, 570, 78, 310, "gate"))
);
}
private static LevelDefinition level4() {
return level(
4, "The Long Lift", "Touch the lever, then ride the moving platform to the upper gallery.",
2200, 130, 760, goal(1960, 390), 0, 60,
platforms(
floor(0, 1500), floor(1820, 380), ledge(1660, 500, 540),
moving(1040, 790, 260, 34, 0, -390, 5.2f, "lift")
),
hazards(hazard(PIT, 1500, 880, 320, 200)),
switches(sw(TOGGLE, 520, 820, 72, 60, "lift", 0, 1)), doors()
);
}
private static LevelDefinition level5() {
return level(
5, "Express Delivery", "The saw throws to the right. Deliver a volunteer to the distant plate.",
2300, 130, 760, goal(2080, 770), 1, 65,
platforms(floor(0, 2300), ledge(850, 800, 330)),
hazards(hazard(SAW, 570, 760, 96, 96, null, 500, -700)),
switches(sw(PRESSURE, 930, 780, 160, 20, "gate", 0, 1)),
doors(door(1390, 560, 82, 320, "gate"))
);
}
private static LevelDefinition level6() {
return level(
6, "Cold Comfort", "The frost makes a solid block. Use the frozen volunteer as a step.",
2150, 130, 760, goal(1880, 500), 1, 65,
platforms(floor(0, 1120), ledge(810, 590, 520), ledge(1430, 610, 570)),
hazards(hazard(FREEZER, 690, 750, 100, 130)), switches(), doors()
);
}
private static LevelDefinition level7() {
return level(
7, "Live Wire", "A fallen volunteer conducts the current and keeps the relay alive.",
2150, 130, 760, goal(1930, 770), 1, 55,
platforms(floor(0, 2150)),
hazards(hazard(ELECTRIC, 650, 780, 110, 100, "power", 0, 0)),
switches(), doors(door(1130, 560, 84, 320, "power"))
);
}
private static LevelDefinition level8() {
return level(
8, "Borrowed Time", "The blue switch lasts five seconds. Keep moving.",
2450, 130, 760, goal(2220, 600), 0, 38,
platforms(
floor(0, 900), floor(1510, 940), ledge(1980, 710, 470),
moving(990, 770, 240, 34, 380, 0, 4.0f, "clock")
),
hazards(hazard(PIT, 900, 880, 610, 200)),
switches(sw(TIMED, 560, 810, 90, 70, "clock", 5.5f, 1)),
doors(door(1770, 560, 82, 320, "clock"))
);
}
private static LevelDefinition level9() {
return level(
9, "Two Good Reasons", "The final gate needs weight and power at the same time.",
2550, 130, 760, goal(2310, 770), 2, 80,
platforms(floor(0, 2550)),
hazards(
hazard(SPIKES, 520, 850, 130, 30),
hazard(ELECTRIC, 910, 780, 110, 100, "power", 0, 0)
),
switches(sw(PRESSURE, 510, 842, 160, 38, "weight", 0, 1)),
doors(door(1510, 540, 86, 340, "weight", "power"))
);
}
private static LevelDefinition level10() {
return level(
10, "The Last Procession", "Launch, freeze, conduct, ascend. Every lesson opens the last relic.",
3100, 130, 760, goal(2880, 330), 3, 120,
platforms(
floor(0, 1880), floor(2140, 960), ledge(2420, 440, 680),
moving(1840, 790, 250, 34, 0, -410, 5.0f, "lift")
),
hazards(
hazard(SAW, 520, 760, 96, 96, null, 700, -240),
hazard(FREEZER, 1110, 750, 100, 130),
hazard(ELECTRIC, 1510, 780, 110, 100, "power", 0, 0),
hazard(PIT, 1880, 880, 260, 200)
),
switches(sw(PRESSURE, 860, 860, 160, 20, "lift", 0, 1)),
doors(door(2310, 310, 82, 570, "power"))
);
}
private static LevelDefinition level(
int number, String title, String hint, float width,
float spawnX, float spawnY, LevelDefinition.Box goal,
int targetDeaths, int targetSeconds,
List<LevelDefinition.Platform> platforms,
List<LevelDefinition.Hazard> hazards,
List<LevelDefinition.Switch> switches,
List<LevelDefinition.Door> doors
) {
return new LevelDefinition(number, title, hint, width, spawnX, spawnY, goal,
targetDeaths, targetSeconds, platforms, hazards, switches, doors);
}
private static LevelDefinition.Box box(float x, float y, float w, float h) {
return new LevelDefinition.Box(x, y, w, h);
}
private static LevelDefinition.Box goal(float x, float y) { return box(x, y, 86, 110); }
private static LevelDefinition.Platform floor(float x, float w) { return new LevelDefinition.Platform(box(x, FLOOR_Y, w, 200)); }
private static LevelDefinition.Platform ledge(float x, float y, float w) { return new LevelDefinition.Platform(box(x, y, w, 34)); }
private static LevelDefinition.Platform moving(float x, float y, float w, float h, float tx, float ty, float period, String control) {
return new LevelDefinition.Platform(box(x, y, w, h), tx, ty, period, control);
}
private static LevelDefinition.Hazard hazard(LevelDefinition.HazardType type, float x, float y, float w, float h) {
return new LevelDefinition.Hazard(type, box(x, y, w, h));
}
private static LevelDefinition.Hazard hazard(LevelDefinition.HazardType type, float x, float y, float w, float h, String relay, float ix, float iy) {
return new LevelDefinition.Hazard(type, box(x, y, w, h), relay, ix, iy);
}
private static LevelDefinition.Switch sw(LevelDefinition.SwitchType type, float x, float y, float w, float h, String id, float duration, int mass) {
return new LevelDefinition.Switch(type, box(x, y, w, h), id, duration, mass);
}
private static LevelDefinition.Door door(float x, float y, float w, float h, String... ids) {
return new LevelDefinition.Door(box(x, y, w, h), ids);
}
@SafeVarargs private static List<LevelDefinition.Platform> platforms(LevelDefinition.Platform... values) { return Arrays.asList(values); }
@SafeVarargs private static List<LevelDefinition.Hazard> hazards(LevelDefinition.Hazard... values) { return Arrays.asList(values); }
@SafeVarargs private static List<LevelDefinition.Switch> switches(LevelDefinition.Switch... values) { return Arrays.asList(values); }
@SafeVarargs private static List<LevelDefinition.Door> doors(LevelDefinition.Door... values) { return Arrays.asList(values); }
}
@@ -0,0 +1,161 @@
package com.projectsacrifice.game;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/** Immutable, Android-independent level data. */
public final class LevelDefinition {
public enum HazardType { SPIKES, SAW, FREEZER, ELECTRIC, PIT }
public enum SwitchType { PRESSURE, TOGGLE, TIMED }
public static final class Box {
public final float x;
public final float y;
public final float width;
public final float height;
public Box(float x, float y, float width, float height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public float right() { return x + width; }
public float bottom() { return y + height; }
}
public static final class Platform {
public final Box box;
public final float travelX;
public final float travelY;
public final float periodSeconds;
public final String controlId;
public Platform(Box box) {
this(box, 0f, 0f, 0f, null);
}
public Platform(Box box, float travelX, float travelY, float periodSeconds, String controlId) {
this.box = box;
this.travelX = travelX;
this.travelY = travelY;
this.periodSeconds = periodSeconds;
this.controlId = controlId;
}
public boolean isMoving() { return travelX != 0f || travelY != 0f; }
}
public static final class Hazard {
public final HazardType type;
public final Box box;
public final String relayId;
public final float impulseX;
public final float impulseY;
public Hazard(HazardType type, Box box) {
this(type, box, null, 0f, 0f);
}
public Hazard(HazardType type, Box box, String relayId, float impulseX, float impulseY) {
this.type = type;
this.box = box;
this.relayId = relayId;
this.impulseX = impulseX;
this.impulseY = impulseY;
}
}
public static final class Switch {
public final SwitchType type;
public final Box box;
public final String signalId;
public final float durationSeconds;
public final int requiredMass;
public Switch(SwitchType type, Box box, String signalId, float durationSeconds, int requiredMass) {
this.type = type;
this.box = box;
this.signalId = signalId;
this.durationSeconds = durationSeconds;
this.requiredMass = requiredMass;
}
}
public static final class Door {
public final Box box;
public final List<String> requiredSignals;
public Door(Box box, String... requiredSignals) {
this.box = box;
this.requiredSignals = Collections.unmodifiableList(Arrays.asList(requiredSignals));
}
}
public final int number;
public final String title;
public final String hint;
public final float worldWidth;
public final float spawnX;
public final float spawnY;
public final Box goal;
public final int targetDeaths;
public final int targetSeconds;
public final List<Platform> platforms;
public final List<Hazard> hazards;
public final List<Switch> switches;
public final List<Door> doors;
public LevelDefinition(
int number,
String title,
String hint,
float worldWidth,
float spawnX,
float spawnY,
Box goal,
int targetDeaths,
int targetSeconds,
List<Platform> platforms,
List<Hazard> hazards,
List<Switch> switches,
List<Door> doors
) {
this.number = number;
this.title = title;
this.hint = hint;
this.worldWidth = worldWidth;
this.spawnX = spawnX;
this.spawnY = spawnY;
this.goal = goal;
this.targetDeaths = targetDeaths;
this.targetSeconds = targetSeconds;
this.platforms = Collections.unmodifiableList(platforms);
this.hazards = Collections.unmodifiableList(hazards);
this.switches = Collections.unmodifiableList(switches);
this.doors = Collections.unmodifiableList(doors);
}
public boolean containsMechanic(HazardType type) {
for (Hazard hazard : hazards) {
if (hazard.type == type) return true;
}
return false;
}
public boolean containsMechanic(SwitchType type) {
for (Switch item : switches) {
if (item.type == type) return true;
}
return false;
}
public boolean hasMovingPlatform() {
for (Platform platform : platforms) {
if (platform.isMoving()) return true;
}
return false;
}
}
@@ -0,0 +1,91 @@
package com.projectsacrifice.game;
/**
* Black-box playtest bot for the fifth level. It operates through the same input
* flags used by touch controls and observes only player-visible session state.
*/
public final class LevelPlaytestBot {
public interface Driver {
void startLevel(int oneBasedLevelNumber);
void setInput(boolean moveLeft, boolean moveRight, boolean jump);
GameView.AutomationSnapshot snapshot();
void waitForNextDecision(long millis);
}
public static final class Result {
private final boolean completed;
private final boolean doorOpened;
private final int deaths;
private final int corpseCount;
private final String diagnostic;
Result(boolean completed, boolean doorOpened, int deaths, int corpseCount, String diagnostic) {
this.completed = completed;
this.doorOpened = doorOpened;
this.deaths = deaths;
this.corpseCount = corpseCount;
this.diagnostic = diagnostic;
}
public boolean completed() { return completed; }
public boolean doorOpened() { return doorOpened; }
public int deaths() { return deaths; }
public int corpseCount() { return corpseCount; }
public String diagnostic() { return diagnostic; }
}
public Result proveLevelFive(Driver driver, long timeoutMillis) {
driver.startLevel(5);
long deadline = System.currentTimeMillis() + timeoutMillis;
boolean doorOpened = false;
int jumpStage = 0;
int respawnsAfterSacrifice = 0;
boolean wasAlive = true;
GameView.AutomationSnapshot last = driver.snapshot();
while (System.currentTimeMillis() < deadline) {
last = driver.snapshot();
doorOpened |= last.allDoorsOpen();
if (last.completed()) {
driver.setInput(false, false, false);
return new Result(true, doorOpened, last.deaths(), last.corpseCount(),
"Level 5 completed by bot: deaths=" + last.deaths()
+ ", corpses=" + last.corpseCount() + ", doorOpened=" + doorOpened);
}
if (!last.playerAlive()) {
driver.setInput(false, false, false);
wasAlive = false;
} else if (last.deaths() == 0) {
driver.setInput(false, true, false);
} else if (!doorOpened) {
driver.setInput(false, false, false);
} else {
if (!wasAlive) {
respawnsAfterSacrifice++;
jumpStage = 0;
}
if (jumpStage == 0 && last.playerX() < 390f) {
driver.setInput(false, true, false);
} else if (jumpStage == 0) {
driver.setInput(false, true, true);
jumpStage = 1;
} else if (jumpStage == 1 && last.playerX() >= 750f) {
driver.setInput(false, true, true);
jumpStage = 2;
} else {
driver.setInput(false, true, false);
}
wasAlive = true;
}
driver.waitForNextDecision(32L);
}
driver.setInput(false, false, false);
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
"Bot timed out on level 5: x=" + last.playerX() + ", y=" + last.playerY()
+ ", alive=" + last.playerAlive() + ", deaths=" + last.deaths()
+ ", corpses=" + last.corpseCount() + ", doorOpened=" + doorOpened
+ ", respawnsAfterSacrifice=" + respawnsAfterSacrifice);
}
}
@@ -0,0 +1,70 @@
package com.projectsacrifice.game;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
public final class MainActivity extends Activity {
private GameView gameView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
gameView = new GameView(this);
setContentView(gameView);
hideSystemUi();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) hideSystemUi();
}
@Override
protected void onPause() {
super.onPause();
if (gameView != null) gameView.setPaused(true);
}
@Override
protected void onResume() {
super.onResume();
hideSystemUi();
}
@Override
public void onBackPressed() {
if (gameView != null && gameView.handleBack()) return;
super.onBackPressed();
}
private void hideSystemUi() {
if (android.os.Build.VERSION.SDK_INT >= 30) {
WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
controller.hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars());
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_FULLSCREEN |
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
}
}
public GameView gameViewForAutomation() {
return gameView;
}
}
@@ -0,0 +1,42 @@
package com.projectsacrifice.game;
import android.content.Context;
import android.content.SharedPreferences;
public final class ProgressStore {
private static final String FILE = "project_sacrifice_progress_v1";
private final SharedPreferences preferences;
public ProgressStore(Context context) {
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
}
public int unlockedCount() {
return Math.max(1, Math.min(LevelCatalog.all().size(), preferences.getInt("unlocked", 1)));
}
public boolean isCompleted(int levelNumber) {
return preferences.getBoolean(key(levelNumber, "completed"), false);
}
public int bestDeaths(int levelNumber) {
return preferences.getInt(key(levelNumber, "deaths"), Integer.MAX_VALUE);
}
public long bestTimeMs(int levelNumber) {
return preferences.getLong(key(levelNumber, "time"), Long.MAX_VALUE);
}
public void recordCompletion(int levelNumber, int deaths, long elapsedMs) {
SharedPreferences.Editor editor = preferences.edit();
editor.putBoolean(key(levelNumber, "completed"), true);
editor.putInt(key(levelNumber, "deaths"), Math.min(deaths, bestDeaths(levelNumber)));
editor.putLong(key(levelNumber, "time"), Math.min(elapsedMs, bestTimeMs(levelNumber)));
editor.putInt("unlocked", Math.min(LevelCatalog.all().size(), Math.max(unlockedCount(), levelNumber + 1)));
editor.apply();
}
private static String key(int levelNumber, String suffix) {
return "level_" + levelNumber + "_" + suffix;
}
}