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
+45
View File
@@ -0,0 +1,45 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.projectsacrifice.game'
compileSdk 34
defaultConfig {
applicationId 'com.projectsacrifice.game'
minSdk 23
targetSdk 34
versionCode 3
versionName '0.2.1-level5-proof'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
buildTypes {
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-debug'
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
testOptions {
unitTests.returnDefaultValues = true
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test:rules:1.5.0'
}
+1
View File
@@ -0,0 +1 @@
# No shrinking is enabled for this prototype.
@@ -0,0 +1,70 @@
package com.projectsacrifice.game;
import android.app.Instrumentation;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Acceptance test: a bot must finish the same level implementation shipped in the APK. */
@RunWith(AndroidJUnit4.class)
public final class LevelFivePlaythroughTest {
@Test
public void testBotCompletesLevelFive() {
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
final GameView[] view = new GameView[1];
scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation());
LevelPlaytestBot bot = new LevelPlaytestBot();
LevelPlaytestBot.Result result = bot.proveLevelFive(
new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]),
25_000L
);
org.junit.Assert.assertTrue(result.diagnostic(), result.completed());
org.junit.Assert.assertTrue("The solution must use at least one volunteer", result.deaths() >= 1);
org.junit.Assert.assertTrue("The delivered corpse must remain in the puzzle", result.corpseCount() >= 1);
org.junit.Assert.assertTrue("The pressure-plate door must open", result.doorOpened());
}
}
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView gameView;
AndroidDriver(Instrumentation instrumentation, GameView gameView) {
this.instrumentation = instrumentation;
this.gameView = gameView;
}
@Override
public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
}
@Override
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
instrumentation.runOnMainSync(() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
}
@Override
public GameView.AutomationSnapshot snapshot() {
final GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = gameView.automationSnapshot());
return value[0];
}
@Override
public void waitForNextDecision(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Bot interrupted", interrupted);
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:glEsVersion="0x00020000" android:required="false" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ProjectSacrifice">
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|uiMode"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#111827" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#F6C453" android:pathData="M54,14l31,18v36l-31,26l-31,-26v-36z" />
<path android:fillColor="#374151" android:pathData="M54,25l20,11v27l-20,17l-20,-17v-27z" />
<path android:fillColor="#F4E6C7" android:pathData="M48,33h12v35h-12zM38,45h32v10h-32z" />
</vector>
+5
View File
@@ -0,0 +1,5 @@
<resources>
<color name="deep_night">#111827</color>
<color name="parchment">#F4E6C7</color>
<color name="sacrifice_gold">#F6C453</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Project Sacrifice</string>
</resources>
+10
View File
@@ -0,0 +1,10 @@
<resources>
<style name="Theme.ProjectSacrifice" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:colorAccent">@color/sacrifice_gold</item>
<item name="android:navigationBarColor">@color/deep_night</item>
<item name="android:windowBackground">@color/parchment</item>
</style>
</resources>
@@ -0,0 +1,32 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class GameMathTest {
@Test
public void intersectionTreatsTouchingEdgesAsNonOverlap() {
assertFalse(GameMath.intersects(0, 0, 10, 10, 10, 0, 10, 10));
assertTrue(GameMath.intersects(0, 0, 10, 10, 9.9f, 0, 10, 10));
}
@Test
public void pingPongIsDeterministicAndBounded() {
assertEquals(0f, GameMath.pingPong(0f, 4f), 0.0001f);
assertEquals(1f, GameMath.pingPong(2f, 4f), 0.0001f);
assertEquals(0f, GameMath.pingPong(4f, 4f), 0.0001f);
for (int i = 0; i < 100; i++) {
float value = GameMath.pingPong(i * 0.13f, 3.7f);
assertTrue(value >= 0f && value <= 1f);
}
}
@Test
public void timeFormattingIsStable() {
assertEquals("00:00", GameMath.formatTime(999));
assertEquals("01:05", GameMath.formatTime(65_000));
}
}
@@ -0,0 +1,105 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public final class LevelCatalogTest {
@Test
public void verticalSliceContainsTenSequentialLevels() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(10, levels.size());
Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i);
assertEquals(i + 1, level.number);
assertTrue(titles.add(level.title));
assertFalse(level.hint.trim().isEmpty());
}
}
@Test
public void everyLevelHasValidSpawnGoalAndGeometry() {
for (LevelDefinition level : LevelCatalog.all()) {
assertTrue(level.worldWidth >= 1920f);
assertTrue(level.spawnX >= 0f && level.spawnX < level.worldWidth);
assertTrue(level.spawnY >= 0f && level.spawnY < 1080f);
assertTrue(level.goal.x >= 0f && level.goal.right() <= level.worldWidth);
assertTrue(level.goal.y >= 0f && level.goal.bottom() <= 1080f);
assertFalse(level.platforms.isEmpty());
assertTrue(level.targetDeaths >= 0);
assertTrue(level.targetSeconds > 0);
}
}
@Test
public void requestedMechanicsAreRepresentedInProgression() {
boolean spikes = false;
boolean saw = false;
boolean freezer = false;
boolean electricity = false;
boolean moving = false;
boolean pressure = false;
boolean toggle = false;
boolean timed = false;
boolean multiSignalDoor = false;
for (LevelDefinition level : LevelCatalog.all()) {
spikes |= level.containsMechanic(LevelDefinition.HazardType.SPIKES);
saw |= level.containsMechanic(LevelDefinition.HazardType.SAW);
freezer |= level.containsMechanic(LevelDefinition.HazardType.FREEZER);
electricity |= level.containsMechanic(LevelDefinition.HazardType.ELECTRIC);
moving |= level.hasMovingPlatform();
pressure |= level.containsMechanic(LevelDefinition.SwitchType.PRESSURE);
toggle |= level.containsMechanic(LevelDefinition.SwitchType.TOGGLE);
timed |= level.containsMechanic(LevelDefinition.SwitchType.TIMED);
for (LevelDefinition.Door door : level.doors) multiSignalDoor |= door.requiredSignals.size() > 1;
}
assertTrue(spikes);
assertTrue(saw);
assertTrue(freezer);
assertTrue(electricity);
assertTrue(moving);
assertTrue(pressure);
assertTrue(toggle);
assertTrue(timed);
assertTrue(multiSignalDoor);
}
@Test
public void masteryLevelCombinesAtLeastFourCoreMechanics() {
LevelDefinition finale = LevelCatalog.get(9);
int count = finale.hazards.size() + finale.switches.size();
assertTrue(finale.hasMovingPlatform());
assertTrue(count >= 5);
assertFalse(finale.doors.isEmpty());
}
@Test
public void everyDoorAndControlledPlatformHasADeclaredSignalSource() {
for (LevelDefinition level : LevelCatalog.all()) {
Set<String> sources = new HashSet<>();
for (LevelDefinition.Switch item : level.switches) sources.add(item.signalId);
for (LevelDefinition.Hazard hazard : level.hazards) {
if (hazard.relayId != null) sources.add(hazard.relayId);
}
for (LevelDefinition.Door door : level.doors) {
for (String required : door.requiredSignals) {
assertTrue("Level " + level.number + " has missing signal " + required, sources.contains(required));
}
}
for (LevelDefinition.Platform platform : level.platforms) {
if (platform.controlId != null) {
assertTrue("Level " + level.number + " has missing signal " + platform.controlId, sources.contains(platform.controlId));
}
}
}
}
}