Initialize Project Sacrifice backlog and source baseline
This commit is contained in:
@@ -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'
|
||||
}
|
||||
Vendored
+1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<color name="deep_night">#111827</color>
|
||||
<color name="parchment">#F4E6C7</color>
|
||||
<color name="sacrifice_gold">#F6C453</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Project Sacrifice</string>
|
||||
</resources>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user