Завершить контентную архитектуру этапа 4
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
|
||||
def levelsDirectory = layout.projectDirectory.dir('src/main/resources/levels')
|
||||
def configuredLevelsDirectory = providers.gradleProperty('levelDirectory')
|
||||
.map { file(it) }
|
||||
.orElse(levelsDirectory.asFile)
|
||||
|
||||
tasks.register('validateLevels', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Validates every versioned level resource before Android packaging.'
|
||||
dependsOn classes
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
mainClass = 'com.projectsacrifice.game.LevelContentTool'
|
||||
inputs.dir configuredLevelsDirectory
|
||||
doFirst {
|
||||
setArgs(['validate', configuredLevelsDirectory.get().absolutePath])
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('previewLevels', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Exports deterministic SVG and TSV previews for every level.'
|
||||
dependsOn classes
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
mainClass = 'com.projectsacrifice.game.LevelContentTool'
|
||||
inputs.dir configuredLevelsDirectory
|
||||
outputs.dir layout.buildDirectory.dir('level-previews')
|
||||
doFirst {
|
||||
setArgs(['preview', configuredLevelsDirectory.get().absolutePath,
|
||||
layout.buildDirectory.dir('level-previews').get().asFile.absolutePath])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/** Immutable production catalog loaded from validated versioned resources. */
|
||||
public final class LevelCatalog {
|
||||
private static final String INDEX_RESOURCE = "levels/index.json";
|
||||
private static final List<LevelDefinition> LEVELS = loadBundled();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public static int formatVersion() {
|
||||
return LevelFormat.CURRENT_FORMAT_VERSION;
|
||||
}
|
||||
|
||||
public static int physicsVersion() {
|
||||
return LevelFormat.CURRENT_PHYSICS_VERSION;
|
||||
}
|
||||
|
||||
private static List<LevelDefinition> loadBundled() {
|
||||
ClassLoader classLoader = LevelCatalog.class.getClassLoader();
|
||||
return LevelCatalogLoader.load(path -> {
|
||||
InputStream stream = classLoader.getResourceAsStream(path);
|
||||
return stream == null ? null : new InputStreamReader(stream, StandardCharsets.UTF_8);
|
||||
}, INDEX_RESOURCE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Loads a complete ordered catalog from an index and per-level resources. */
|
||||
public final class LevelCatalogLoader {
|
||||
private LevelCatalogLoader() {}
|
||||
|
||||
public static List<LevelDefinition> load(LevelResourceSource source, String indexPath) {
|
||||
if (source == null) throw new IllegalArgumentException("level resource source is required");
|
||||
String index = requiredPath(indexPath);
|
||||
List<String> entries;
|
||||
try (Reader reader = open(source, index)) {
|
||||
entries = LevelJsonLoader.loadIndex(reader, index);
|
||||
} catch (IOException error) {
|
||||
throw new IllegalArgumentException(index + " could not be closed", error);
|
||||
}
|
||||
|
||||
List<LevelDefinition> result = new ArrayList<>();
|
||||
Set<Integer> numbers = new HashSet<>();
|
||||
String directory = directory(index);
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
String path = directory + entries.get(i);
|
||||
LevelDefinition level;
|
||||
try (Reader reader = open(source, path)) {
|
||||
level = LevelJsonLoader.load(reader, path);
|
||||
} catch (IOException error) {
|
||||
throw new IllegalArgumentException(path + " could not be closed", error);
|
||||
}
|
||||
if (!numbers.add(level.number)) {
|
||||
throw new IllegalArgumentException(index + ".levels[" + i
|
||||
+ "] duplicates level number " + level.number);
|
||||
}
|
||||
int expected = i + 1;
|
||||
if (level.number != expected) {
|
||||
throw new IllegalArgumentException(index + ".levels[" + i + "] expected level number "
|
||||
+ expected + " but was " + level.number);
|
||||
}
|
||||
result.add(level);
|
||||
}
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
private static Reader open(LevelResourceSource source, String path) {
|
||||
try {
|
||||
Reader reader = source.open(path);
|
||||
if (reader == null) throw new IllegalArgumentException(path + " resource is missing");
|
||||
return reader;
|
||||
} catch (IOException error) {
|
||||
throw new IllegalArgumentException(path + " could not be opened", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requiredPath(String path) {
|
||||
if (path == null || path.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("level index path is required");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static String directory(String path) {
|
||||
int slash = path.lastIndexOf('/');
|
||||
return slash < 0 ? "" : path.substring(0, slash + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.io.Reader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
/** Desktop entrypoint for deterministic validation and content export. */
|
||||
public final class LevelContentTool {
|
||||
private LevelContentTool() {}
|
||||
|
||||
public static void main(String[] args) {
|
||||
int exitCode = run(args, System.out, System.err);
|
||||
if (exitCode != 0) throw new IllegalStateException("Level content tool failed with exit " + exitCode);
|
||||
}
|
||||
|
||||
public static int run(String[] args, PrintStream output, PrintStream errors) {
|
||||
if (output == null || errors == null) throw new IllegalArgumentException("output streams are required");
|
||||
try {
|
||||
if (args != null && args.length == 2 && "validate".equals(args[0])) {
|
||||
List<LevelDefinition> levels = loadDirectory(args[1]);
|
||||
output.println("Validated " + levels.size() + " level(s); format="
|
||||
+ LevelFormat.CURRENT_FORMAT_VERSION + "; physics="
|
||||
+ LevelFormat.CURRENT_PHYSICS_VERSION);
|
||||
return 0;
|
||||
}
|
||||
if (args != null && args.length == 3 && "preview".equals(args[0])) {
|
||||
List<LevelDefinition> levels = loadDirectory(args[1]);
|
||||
Path destination = Paths.get(args[2]).toAbsolutePath().normalize();
|
||||
writePreview(levels, destination);
|
||||
output.println("Exported " + levels.size() + " level preview(s) to " + destination);
|
||||
return 0;
|
||||
}
|
||||
errors.println("Usage: validate <levels-directory> | preview <levels-directory> <output-directory>");
|
||||
return 2;
|
||||
} catch (IllegalArgumentException | IOException error) {
|
||||
errors.println(error.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<LevelDefinition> loadDirectory(String value) {
|
||||
Path directory = Paths.get(value).toAbsolutePath().normalize();
|
||||
return LevelCatalogLoader.load(new DirectorySource(directory), "index.json");
|
||||
}
|
||||
|
||||
private static void writePreview(List<LevelDefinition> levels, Path destination) throws IOException {
|
||||
Files.createDirectories(destination);
|
||||
StringBuilder coordinates = new StringBuilder(
|
||||
"level\tkind\tindex\ttype\tx\ty\twidth\theight\ttravelX\ttravelY\tcontrolOrSignals\n");
|
||||
for (LevelDefinition level : levels) {
|
||||
Files.writeString(destination.resolve(String.format("level-%02d.svg", level.number)),
|
||||
svg(level), StandardCharsets.UTF_8);
|
||||
coordinates.append(coordinates(level));
|
||||
}
|
||||
Files.writeString(destination.resolve("catalog.tsv"), coordinates.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String svg(LevelDefinition level) {
|
||||
StringBuilder value = new StringBuilder();
|
||||
value.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||
.append("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ")
|
||||
.append(number(level.worldWidth)).append(' ').append(number(level.worldHeight))
|
||||
.append("\" data-format-version=\"").append(level.formatVersion)
|
||||
.append("\" data-physics-version=\"").append(level.physicsVersion).append("\">\n")
|
||||
.append(" <title>").append(xml(level.number + " — " + level.title)).append("</title>\n")
|
||||
.append(" <rect width=\"100%\" height=\"100%\" fill=\"#111827\"/>\n");
|
||||
for (int i = 0; i < level.platforms.size(); i++) {
|
||||
rect(value, "platform", i, level.platforms.get(i).box, "#64748b");
|
||||
}
|
||||
for (int i = 0; i < level.hazards.size(); i++) {
|
||||
rect(value, "hazard-" + level.hazards.get(i).type, i,
|
||||
level.hazards.get(i).box, "#ef4444");
|
||||
}
|
||||
for (int i = 0; i < level.switches.size(); i++) {
|
||||
rect(value, "switch-" + level.switches.get(i).type, i,
|
||||
level.switches.get(i).box, "#eab308");
|
||||
}
|
||||
for (int i = 0; i < level.doors.size(); i++) {
|
||||
rect(value, "door", i, level.doors.get(i).box, "#a855f7");
|
||||
}
|
||||
rect(value, "spawn", 0, new LevelDefinition.Box(level.spawnX, level.spawnY, 62f, 100f), "#22c55e");
|
||||
rect(value, "goal", 0, level.goal, "#38bdf8");
|
||||
value.append("</svg>\n");
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
private static void rect(StringBuilder output, String kind, int index,
|
||||
LevelDefinition.Box box, String color) {
|
||||
output.append(" <rect data-kind=\"").append(kind).append("\" data-index=\"")
|
||||
.append(index).append("\" x=\"").append(number(box.x)).append("\" y=\"")
|
||||
.append(number(box.y)).append("\" width=\"").append(number(box.width))
|
||||
.append("\" height=\"").append(number(box.height)).append("\" fill=\"")
|
||||
.append(color).append("\" fill-opacity=\"0.78\"/>\n");
|
||||
}
|
||||
|
||||
private static String coordinates(LevelDefinition level) {
|
||||
StringBuilder value = new StringBuilder();
|
||||
row(value, level.number, "spawn", 0, "PLAYER", new LevelDefinition.Box(
|
||||
level.spawnX, level.spawnY, 62f, 100f), 0f, 0f, "");
|
||||
row(value, level.number, "goal", 0, "GOAL", level.goal, 0f, 0f, "");
|
||||
for (int i = 0; i < level.platforms.size(); i++) {
|
||||
LevelDefinition.Platform item = level.platforms.get(i);
|
||||
row(value, level.number, "platform", i, item.isMoving() ? "MOVING" : "STATIC",
|
||||
item.box, item.travelX, item.travelY, item.controlId);
|
||||
}
|
||||
for (int i = 0; i < level.hazards.size(); i++) {
|
||||
LevelDefinition.Hazard item = level.hazards.get(i);
|
||||
row(value, level.number, "hazard", i, item.type.name(), item.box,
|
||||
item.impulseX, item.impulseY, item.relayId);
|
||||
}
|
||||
for (int i = 0; i < level.switches.size(); i++) {
|
||||
LevelDefinition.Switch item = level.switches.get(i);
|
||||
row(value, level.number, "switch", i, item.type.name(), item.box,
|
||||
0f, 0f, item.signalId);
|
||||
}
|
||||
for (int i = 0; i < level.doors.size(); i++) {
|
||||
LevelDefinition.Door item = level.doors.get(i);
|
||||
row(value, level.number, "door", i, "DOOR", item.box,
|
||||
0f, 0f, String.join("+", item.requiredSignals));
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
private static void row(StringBuilder output, int level, String kind, int index, String type,
|
||||
LevelDefinition.Box box, float travelX, float travelY, String control) {
|
||||
output.append(level).append('\t').append(kind).append('\t').append(index).append('\t')
|
||||
.append(type).append('\t').append(number(box.x)).append('\t')
|
||||
.append(number(box.y)).append('\t').append(number(box.width)).append('\t')
|
||||
.append(number(box.height)).append('\t').append(number(travelX)).append('\t')
|
||||
.append(number(travelY)).append('\t').append(control == null ? "" : control).append('\n');
|
||||
}
|
||||
|
||||
private static String number(float value) {
|
||||
if (value == (long) value) return Long.toString((long) value);
|
||||
return Float.toString(value);
|
||||
}
|
||||
|
||||
private static String xml(String value) {
|
||||
return value.replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace("\"", """);
|
||||
}
|
||||
|
||||
private static final class DirectorySource implements LevelResourceSource {
|
||||
private final Path directory;
|
||||
|
||||
DirectorySource(Path directory) {
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Reader open(String path) throws IOException {
|
||||
Path resolved = directory.resolve(path).normalize();
|
||||
if (!resolved.startsWith(directory) || !Files.isRegularFile(resolved)) return null;
|
||||
return Files.newBufferedReader(resolved, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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 int formatVersion;
|
||||
public final int physicsVersion;
|
||||
public final String title;
|
||||
public final String hint;
|
||||
public final String titleRu;
|
||||
public final String hintRu;
|
||||
public final float worldWidth;
|
||||
public final float worldHeight;
|
||||
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(1, 1, number, title, hint, title, hint, worldWidth, 1080f,
|
||||
spawnX, spawnY, goal, targetDeaths, targetSeconds,
|
||||
platforms, hazards, switches, doors);
|
||||
}
|
||||
|
||||
public LevelDefinition(
|
||||
int formatVersion,
|
||||
int physicsVersion,
|
||||
int number,
|
||||
String title,
|
||||
String hint,
|
||||
String titleRu,
|
||||
String hintRu,
|
||||
float worldWidth,
|
||||
float worldHeight,
|
||||
float spawnX,
|
||||
float spawnY,
|
||||
Box goal,
|
||||
int targetDeaths,
|
||||
int targetSeconds,
|
||||
List<Platform> platforms,
|
||||
List<Hazard> hazards,
|
||||
List<Switch> switches,
|
||||
List<Door> doors
|
||||
) {
|
||||
this.formatVersion = formatVersion;
|
||||
this.physicsVersion = physicsVersion;
|
||||
this.number = number;
|
||||
this.title = title;
|
||||
this.hint = hint;
|
||||
this.titleRu = titleRu;
|
||||
this.hintRu = hintRu;
|
||||
this.worldWidth = worldWidth;
|
||||
this.worldHeight = worldHeight;
|
||||
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,9 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Version boundary shared by level content, validation, previews, and bot traces. */
|
||||
public final class LevelFormat {
|
||||
public static final int CURRENT_FORMAT_VERSION = 1;
|
||||
public static final int CURRENT_PHYSICS_VERSION = 1;
|
||||
|
||||
private LevelFormat() {}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** Loads one immutable level from the versioned Stage 4 JSON contract. */
|
||||
public final class LevelJsonLoader {
|
||||
private LevelJsonLoader() {}
|
||||
|
||||
public static LevelDefinition load(Reader reader, String sourceName) {
|
||||
String source = sourceName == null ? "<level>" : sourceName;
|
||||
Map<String, Object> root = object(new JsonValueParser(reader, source).parse(), source);
|
||||
Map<String, Object> copy = object(required(root, "copy", source), source + ".copy");
|
||||
Map<String, Object> english = object(required(copy, "en", source + ".copy"), source + ".copy.en");
|
||||
Map<String, Object> russian = object(required(copy, "ru", source + ".copy"), source + ".copy.ru");
|
||||
Map<String, Object> world = object(required(root, "world", source), source + ".world");
|
||||
Map<String, Object> spawn = object(required(root, "spawn", source), source + ".spawn");
|
||||
Map<String, Object> targets = object(required(root, "targets", source), source + ".targets");
|
||||
|
||||
List<LevelDefinition.Platform> platforms = new ArrayList<>();
|
||||
List<Object> platformValues = array(required(root, "platforms", source), source + ".platforms");
|
||||
for (int i = 0; i < platformValues.size(); i++) {
|
||||
String path = source + ".platforms[" + i + "]";
|
||||
Map<String, Object> value = object(platformValues.get(i), path);
|
||||
platforms.add(new LevelDefinition.Platform(
|
||||
box(required(value, "box", path), path + ".box"),
|
||||
optionalDecimal(value, "travelX", path, 0f),
|
||||
optionalDecimal(value, "travelY", path, 0f),
|
||||
optionalDecimal(value, "periodSeconds", path, 0f),
|
||||
optionalText(value, "controlId", path)));
|
||||
}
|
||||
|
||||
List<LevelDefinition.Hazard> hazards = hazards(root, source);
|
||||
List<LevelDefinition.Switch> switches = switches(root, source);
|
||||
List<LevelDefinition.Door> doors = doors(root, source);
|
||||
|
||||
return LevelValidator.validate(new LevelDefinition(
|
||||
integer(root, "formatVersion", source),
|
||||
integer(root, "physicsVersion", source),
|
||||
integer(root, "number", source),
|
||||
text(english, "title", source + ".copy.en"),
|
||||
text(english, "hint", source + ".copy.en"),
|
||||
text(russian, "title", source + ".copy.ru"),
|
||||
text(russian, "hint", source + ".copy.ru"),
|
||||
decimal(world, "width", source + ".world"),
|
||||
decimal(world, "height", source + ".world"),
|
||||
decimal(spawn, "x", source + ".spawn"),
|
||||
decimal(spawn, "y", source + ".spawn"),
|
||||
box(required(root, "goal", source), source + ".goal"),
|
||||
integer(targets, "deaths", source + ".targets"),
|
||||
integer(targets, "seconds", source + ".targets"),
|
||||
platforms,
|
||||
hazards,
|
||||
switches,
|
||||
doors), source);
|
||||
}
|
||||
|
||||
static List<String> loadIndex(Reader reader, String sourceName) {
|
||||
String source = sourceName == null ? "<levels-index>" : sourceName;
|
||||
Map<String, Object> root = object(new JsonValueParser(reader, source).parse(), source);
|
||||
int formatVersion = integer(root, "formatVersion", source);
|
||||
if (formatVersion != LevelFormat.CURRENT_FORMAT_VERSION) {
|
||||
throw invalid(source + ".formatVersion", "expected "
|
||||
+ LevelFormat.CURRENT_FORMAT_VERSION + " but was " + formatVersion);
|
||||
}
|
||||
int physicsVersion = integer(root, "physicsVersion", source);
|
||||
if (physicsVersion != LevelFormat.CURRENT_PHYSICS_VERSION) {
|
||||
throw invalid(source + ".physicsVersion", "expected "
|
||||
+ LevelFormat.CURRENT_PHYSICS_VERSION + " but was " + physicsVersion);
|
||||
}
|
||||
List<Object> values = array(required(root, "levels", source), source + ".levels");
|
||||
if (values.isEmpty()) throw invalid(source + ".levels", "must not be empty");
|
||||
List<String> result = new ArrayList<>();
|
||||
Set<String> unique = new HashSet<>();
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
Object value = values.get(i);
|
||||
String path = source + ".levels[" + i + "]";
|
||||
if (!(value instanceof String) || ((String) value).trim().isEmpty()) {
|
||||
throw invalid(path, "must be a non-empty string");
|
||||
}
|
||||
String entry = (String) value;
|
||||
if (entry.contains("/") || entry.contains("\\") || entry.equals("..")) {
|
||||
throw invalid(path, "must be a file name relative to the index");
|
||||
}
|
||||
if (!unique.add(entry)) throw invalid(path, "duplicates resource '" + entry + "'");
|
||||
result.add(entry);
|
||||
}
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
private static List<LevelDefinition.Hazard> hazards(Map<String, Object> root, String source) {
|
||||
List<Object> values = array(required(root, "hazards", source), source + ".hazards");
|
||||
List<LevelDefinition.Hazard> result = new ArrayList<>();
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
String path = source + ".hazards[" + i + "]";
|
||||
Map<String, Object> value = object(values.get(i), path);
|
||||
result.add(new LevelDefinition.Hazard(
|
||||
hazardType(text(value, "type", path), path + ".type"),
|
||||
box(required(value, "box", path), path + ".box"),
|
||||
optionalText(value, "relayId", path),
|
||||
optionalDecimal(value, "impulseX", path, 0f),
|
||||
optionalDecimal(value, "impulseY", path, 0f)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<LevelDefinition.Switch> switches(Map<String, Object> root, String source) {
|
||||
List<Object> values = array(required(root, "switches", source), source + ".switches");
|
||||
List<LevelDefinition.Switch> result = new ArrayList<>();
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
String path = source + ".switches[" + i + "]";
|
||||
Map<String, Object> value = object(values.get(i), path);
|
||||
result.add(new LevelDefinition.Switch(
|
||||
switchType(text(value, "type", path), path + ".type"),
|
||||
box(required(value, "box", path), path + ".box"),
|
||||
text(value, "signalId", path),
|
||||
decimal(value, "durationSeconds", path),
|
||||
integer(value, "requiredMass", path)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<LevelDefinition.Door> doors(Map<String, Object> root, String source) {
|
||||
List<Object> values = array(required(root, "doors", source), source + ".doors");
|
||||
List<LevelDefinition.Door> result = new ArrayList<>();
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
String path = source + ".doors[" + i + "]";
|
||||
Map<String, Object> value = object(values.get(i), path);
|
||||
List<Object> rawSignals = array(required(value, "requiredSignals", path),
|
||||
path + ".requiredSignals");
|
||||
String[] signals = new String[rawSignals.size()];
|
||||
for (int signal = 0; signal < rawSignals.size(); signal++) {
|
||||
Object raw = rawSignals.get(signal);
|
||||
if (!(raw instanceof String)) {
|
||||
throw invalid(path + ".requiredSignals[" + signal + "]", "must be a string");
|
||||
}
|
||||
signals[signal] = (String) raw;
|
||||
}
|
||||
result.add(new LevelDefinition.Door(
|
||||
box(required(value, "box", path), path + ".box"), signals));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static LevelDefinition.SwitchType switchType(String value, String path) {
|
||||
try {
|
||||
return LevelDefinition.SwitchType.valueOf(value);
|
||||
} catch (IllegalArgumentException error) {
|
||||
throw invalid(path, "has unknown switch type '" + value + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static LevelDefinition.HazardType hazardType(String value, String path) {
|
||||
try {
|
||||
return LevelDefinition.HazardType.valueOf(value);
|
||||
} catch (IllegalArgumentException error) {
|
||||
throw invalid(path, "has unknown hazard type '" + value + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireEmptyArray(Map<String, Object> parent, String key, String path) {
|
||||
List<Object> values = array(required(parent, key, path), path + "." + key);
|
||||
if (!values.isEmpty()) {
|
||||
throw invalid(path + "." + key, "is not supported by this format slice yet");
|
||||
}
|
||||
}
|
||||
|
||||
private static LevelDefinition.Box box(Object value, String path) {
|
||||
Map<String, Object> box = object(value, path);
|
||||
return new LevelDefinition.Box(
|
||||
decimal(box, "x", path),
|
||||
decimal(box, "y", path),
|
||||
decimal(box, "width", path),
|
||||
decimal(box, "height", path));
|
||||
}
|
||||
|
||||
private static Object required(Map<String, Object> value, String key, String path) {
|
||||
if (!value.containsKey(key)) throw invalid(path + "." + key, "is required");
|
||||
return value.get(key);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> object(Object value, String path) {
|
||||
if (!(value instanceof Map)) throw invalid(path, "must be an object");
|
||||
return (Map<String, Object>) value;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Object> array(Object value, String path) {
|
||||
if (!(value instanceof List)) throw invalid(path, "must be an array");
|
||||
return (List<Object>) value;
|
||||
}
|
||||
|
||||
private static String text(Map<String, Object> value, String key, String path) {
|
||||
Object item = required(value, key, path);
|
||||
if (!(item instanceof String)) throw invalid(path + "." + key, "must be a string");
|
||||
return (String) item;
|
||||
}
|
||||
|
||||
private static int integer(Map<String, Object> value, String key, String path) {
|
||||
double number = number(required(value, key, path), path + "." + key);
|
||||
if (number != Math.rint(number) || number < Integer.MIN_VALUE || number > Integer.MAX_VALUE) {
|
||||
throw invalid(path + "." + key, "must be an integer");
|
||||
}
|
||||
return (int) number;
|
||||
}
|
||||
|
||||
private static float decimal(Map<String, Object> value, String key, String path) {
|
||||
return (float) number(required(value, key, path), path + "." + key);
|
||||
}
|
||||
|
||||
private static float optionalDecimal(Map<String, Object> value, String key, String path,
|
||||
float fallback) {
|
||||
return value.containsKey(key) ? (float) number(value.get(key), path + "." + key) : fallback;
|
||||
}
|
||||
|
||||
private static String optionalText(Map<String, Object> value, String key, String path) {
|
||||
if (!value.containsKey(key) || value.get(key) == null) return null;
|
||||
Object item = value.get(key);
|
||||
if (!(item instanceof String)) throw invalid(path + "." + key, "must be a string or null");
|
||||
return (String) item;
|
||||
}
|
||||
|
||||
private static double number(Object value, String path) {
|
||||
if (!(value instanceof Number)) throw invalid(path, "must be a number");
|
||||
double result = ((Number) value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw invalid(path, "must be finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IllegalArgumentException invalid(String path, String message) {
|
||||
return new IllegalArgumentException(path + " " + message);
|
||||
}
|
||||
|
||||
private static final class JsonValueParser {
|
||||
private final String input;
|
||||
private final String source;
|
||||
private int index;
|
||||
|
||||
JsonValueParser(Reader reader, String source) {
|
||||
this.input = read(reader, source);
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
Object parse() {
|
||||
Object value = value();
|
||||
whitespace();
|
||||
if (index != input.length()) fail("unexpected trailing content");
|
||||
return value;
|
||||
}
|
||||
|
||||
private Object value() {
|
||||
whitespace();
|
||||
if (index >= input.length()) fail("unexpected end of input");
|
||||
char c = input.charAt(index);
|
||||
if (c == '{') return objectValue();
|
||||
if (c == '[') return arrayValue();
|
||||
if (c == '"') return string();
|
||||
if (c == '-' || (c >= '0' && c <= '9')) return numberValue();
|
||||
if (input.startsWith("true", index)) { index += 4; return Boolean.TRUE; }
|
||||
if (input.startsWith("false", index)) { index += 5; return Boolean.FALSE; }
|
||||
if (input.startsWith("null", index)) { index += 4; return null; }
|
||||
fail("unexpected token");
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Object> objectValue() {
|
||||
index++;
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
whitespace();
|
||||
if (consume('}')) return result;
|
||||
while (true) {
|
||||
whitespace();
|
||||
if (index >= input.length() || input.charAt(index) != '"') fail("object key must be a string");
|
||||
String key = string();
|
||||
if (result.containsKey(key)) fail("duplicate object key '" + key + "'");
|
||||
whitespace();
|
||||
expect(':');
|
||||
result.put(key, value());
|
||||
whitespace();
|
||||
if (consume('}')) return result;
|
||||
expect(',');
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> arrayValue() {
|
||||
index++;
|
||||
List<Object> result = new ArrayList<>();
|
||||
whitespace();
|
||||
if (consume(']')) return result;
|
||||
while (true) {
|
||||
result.add(value());
|
||||
whitespace();
|
||||
if (consume(']')) return result;
|
||||
expect(',');
|
||||
}
|
||||
}
|
||||
|
||||
private String string() {
|
||||
expect('"');
|
||||
StringBuilder result = new StringBuilder();
|
||||
while (index < input.length()) {
|
||||
char c = input.charAt(index++);
|
||||
if (c == '"') return result.toString();
|
||||
if (c == '\\') {
|
||||
if (index >= input.length()) fail("unfinished escape");
|
||||
char escape = input.charAt(index++);
|
||||
switch (escape) {
|
||||
case '"': result.append('"'); break;
|
||||
case '\\': result.append('\\'); break;
|
||||
case '/': result.append('/'); break;
|
||||
case 'b': result.append('\b'); break;
|
||||
case 'f': result.append('\f'); break;
|
||||
case 'n': result.append('\n'); break;
|
||||
case 'r': result.append('\r'); break;
|
||||
case 't': result.append('\t'); break;
|
||||
case 'u': result.append(unicode()); break;
|
||||
default: fail("unsupported escape");
|
||||
}
|
||||
} else {
|
||||
if (c < 0x20) fail("control character in string");
|
||||
result.append(c);
|
||||
}
|
||||
}
|
||||
fail("unterminated string");
|
||||
return null;
|
||||
}
|
||||
|
||||
private char unicode() {
|
||||
if (index + 4 > input.length()) fail("unfinished unicode escape");
|
||||
int value = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int digit = Character.digit(input.charAt(index++), 16);
|
||||
if (digit < 0) fail("invalid unicode escape");
|
||||
value = value * 16 + digit;
|
||||
}
|
||||
return (char) value;
|
||||
}
|
||||
|
||||
private Number numberValue() {
|
||||
int start = index;
|
||||
if (consume('-') && index >= input.length()) fail("invalid number");
|
||||
if (consume('0')) {
|
||||
if (index < input.length() && Character.isDigit(input.charAt(index))) fail("leading zero in number");
|
||||
} else {
|
||||
digits();
|
||||
}
|
||||
if (consume('.')) digits();
|
||||
if (consume('e') || consume('E')) {
|
||||
consume('+');
|
||||
consume('-');
|
||||
digits();
|
||||
}
|
||||
try {
|
||||
return Double.valueOf(input.substring(start, index));
|
||||
} catch (NumberFormatException error) {
|
||||
fail("invalid number");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void digits() {
|
||||
int start = index;
|
||||
while (index < input.length() && Character.isDigit(input.charAt(index))) index++;
|
||||
if (start == index) fail("expected digit");
|
||||
}
|
||||
|
||||
private void whitespace() {
|
||||
while (index < input.length() && Character.isWhitespace(input.charAt(index))) index++;
|
||||
}
|
||||
|
||||
private void expect(char expected) {
|
||||
if (!consume(expected)) fail("expected '" + expected + "'");
|
||||
}
|
||||
|
||||
private boolean consume(char expected) {
|
||||
if (index < input.length() && input.charAt(index) == expected) {
|
||||
index++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void fail(String message) {
|
||||
throw invalid(source + "@" + index, message);
|
||||
}
|
||||
|
||||
private static String read(Reader reader, String source) {
|
||||
if (reader == null) throw invalid(source, "reader is required");
|
||||
StringBuilder result = new StringBuilder();
|
||||
char[] buffer = new char[2048];
|
||||
try {
|
||||
int count;
|
||||
while ((count = reader.read(buffer)) != -1) result.append(buffer, 0, count);
|
||||
} catch (IOException error) {
|
||||
throw new IllegalArgumentException(source + " could not be read", error);
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
|
||||
/** Boundary used by Android resources, desktop files, and in-memory tests. */
|
||||
@FunctionalInterface
|
||||
public interface LevelResourceSource {
|
||||
Reader open(String path) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** Applies the fail-closed domain contract to loaded level content. */
|
||||
public final class LevelValidator {
|
||||
private LevelValidator() {}
|
||||
|
||||
public static LevelDefinition validate(LevelDefinition level, String sourceName) {
|
||||
String source = sourceName == null ? "<level>" : sourceName;
|
||||
if (level.formatVersion != LevelFormat.CURRENT_FORMAT_VERSION) {
|
||||
throw invalid(source + ".formatVersion", "expected "
|
||||
+ LevelFormat.CURRENT_FORMAT_VERSION + " but was " + level.formatVersion);
|
||||
}
|
||||
if (level.physicsVersion != LevelFormat.CURRENT_PHYSICS_VERSION) {
|
||||
throw invalid(source + ".physicsVersion", "expected "
|
||||
+ LevelFormat.CURRENT_PHYSICS_VERSION + " but was " + level.physicsVersion);
|
||||
}
|
||||
positive(level.worldWidth, source + ".world.width");
|
||||
positive(level.worldHeight, source + ".world.height");
|
||||
coordinate(level.spawnX, level.worldWidth, source + ".spawn.x");
|
||||
coordinate(level.spawnY, level.worldHeight, source + ".spawn.y");
|
||||
box(level.goal, level, source + ".goal");
|
||||
if (level.platforms.isEmpty()) {
|
||||
throw invalid(source + ".platforms", "must contain at least one platform");
|
||||
}
|
||||
for (int i = 0; i < level.platforms.size(); i++) {
|
||||
LevelDefinition.Platform platform = level.platforms.get(i);
|
||||
String path = source + ".platforms[" + i + "]";
|
||||
box(platform.box, level, path + ".box");
|
||||
if (platform.isMoving()) {
|
||||
positive(platform.periodSeconds, path + ".periodSeconds");
|
||||
LevelDefinition.Box destination = new LevelDefinition.Box(
|
||||
platform.box.x + platform.travelX,
|
||||
platform.box.y + platform.travelY,
|
||||
platform.box.width,
|
||||
platform.box.height);
|
||||
box(destination, level, path + ".destinationBox");
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < level.hazards.size(); i++) {
|
||||
box(level.hazards.get(i).box, level, source + ".hazards[" + i + "].box");
|
||||
}
|
||||
for (int i = 0; i < level.switches.size(); i++) {
|
||||
LevelDefinition.Switch item = level.switches.get(i);
|
||||
String path = source + ".switches[" + i + "]";
|
||||
box(item.box, level, path + ".box");
|
||||
if (item.signalId == null || item.signalId.trim().isEmpty()) {
|
||||
throw invalid(path + ".signalId", "must not be blank");
|
||||
}
|
||||
if (item.requiredMass <= 0) {
|
||||
throw invalid(path + ".requiredMass", "must be positive");
|
||||
}
|
||||
if (item.type == LevelDefinition.SwitchType.TIMED && item.durationSeconds <= 0f) {
|
||||
throw invalid(path + ".durationSeconds", "must be positive for TIMED switch");
|
||||
}
|
||||
if (item.type != LevelDefinition.SwitchType.TIMED && item.durationSeconds < 0f) {
|
||||
throw invalid(path + ".durationSeconds", "must not be negative");
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < level.doors.size(); i++) {
|
||||
box(level.doors.get(i).box, level, source + ".doors[" + i + "].box");
|
||||
}
|
||||
if (level.targetDeaths < 0) throw invalid(source + ".targets.deaths", "must not be negative");
|
||||
if (level.targetSeconds <= 0) throw invalid(source + ".targets.seconds", "must be positive");
|
||||
Set<String> signalSources = new HashSet<>();
|
||||
for (LevelDefinition.Switch item : level.switches) signalSources.add(item.signalId);
|
||||
for (LevelDefinition.Hazard hazard : level.hazards) {
|
||||
if (hazard.relayId != null) signalSources.add(hazard.relayId);
|
||||
}
|
||||
for (int doorIndex = 0; doorIndex < level.doors.size(); doorIndex++) {
|
||||
LevelDefinition.Door door = level.doors.get(doorIndex);
|
||||
for (int signalIndex = 0; signalIndex < door.requiredSignals.size(); signalIndex++) {
|
||||
String required = door.requiredSignals.get(signalIndex);
|
||||
if (!signalSources.contains(required)) {
|
||||
throw invalid(source + ".doors[" + doorIndex + "].requiredSignals["
|
||||
+ signalIndex + "]", "references missing signal '" + required + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int platformIndex = 0; platformIndex < level.platforms.size(); platformIndex++) {
|
||||
String controlId = level.platforms.get(platformIndex).controlId;
|
||||
if (controlId != null && !signalSources.contains(controlId)) {
|
||||
throw invalid(source + ".platforms[" + platformIndex + "].controlId",
|
||||
"references missing signal '" + controlId + "'");
|
||||
}
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
private static void box(LevelDefinition.Box box, LevelDefinition level, String path) {
|
||||
coordinate(box.x, level.worldWidth, path + ".x");
|
||||
coordinate(box.y, level.worldHeight, path + ".y");
|
||||
positive(box.width, path + ".width");
|
||||
positive(box.height, path + ".height");
|
||||
if (box.right() > level.worldWidth) {
|
||||
throw invalid(path + ".width", "extends beyond world width " + level.worldWidth);
|
||||
}
|
||||
if (box.bottom() > level.worldHeight) {
|
||||
throw invalid(path + ".height", "extends beyond world height " + level.worldHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void coordinate(float value, float limit, String path) {
|
||||
if (!Float.isFinite(value) || value < 0f || value >= limit) {
|
||||
throw invalid(path, "must be finite and inside [0," + limit + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private static void positive(float value, String path) {
|
||||
if (!Float.isFinite(value) || value <= 0f) throw invalid(path, "must be positive and finite");
|
||||
}
|
||||
|
||||
private static IllegalArgumentException invalid(String path, String message) {
|
||||
return new IllegalArgumentException(path + " " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json"]}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":1,
|
||||
"copy":{"en":{"title":"The Volunteer","hint":"Reach the relic. Arrows move; the winged button jumps."},"ru":{"title":"Рыцарь-доброволец","hint":"Доберитесь до реликвии. Стрелки двигают; кнопка с крылом — прыжок."}},
|
||||
"world":{"width":1920,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":1690,"y":770,"width":86,"height":110},"targets":{"deaths":0,"seconds":30},
|
||||
"platforms":[
|
||||
{"box":{"x":0,"y":880,"width":1920,"height":200}},
|
||||
{"box":{"x":640,"y":760,"width":230,"height":34}},
|
||||
{"box":{"x":1060,"y":690,"width":250,"height":34}}
|
||||
],"hazards":[],"switches":[],"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":2,
|
||||
"copy":{"en":{"title":"A Useful End","hint":"The spikes keep every volunteer. Build a path from what remains."},"ru":{"title":"Полезный конец","hint":"Шипы сохраняют каждого добровольца. Проложите путь из того, что осталось."}},
|
||||
"world":{"width":2050,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":1830,"y":770,"width":86,"height":110},"targets":{"deaths":3,"seconds":55},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2050,"height":200}},{"box":{"x":1180,"y":740,"width":240,"height":34}}],
|
||||
"hazards":[{"type":"SPIKES","box":{"x":610,"y":850,"width":260,"height":30}}],"switches":[],"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":3,
|
||||
"copy":{"en":{"title":"Dead Weight","hint":"Leave a body on the brass plate so the next volunteer can pass."},"ru":{"title":"Мёртвый груз","hint":"Оставьте тело на латунной плите, чтобы следующий доброволец прошёл."}},
|
||||
"world":{"width":2050,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":1810,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":50},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2050,"height":200}}],
|
||||
"hazards":[{"type":"SAW","box":{"x":565,"y":760,"width":92,"height":92},"impulseX":0,"impulseY":-150}],
|
||||
"switches":[{"type":"PRESSURE","box":{"x":540,"y":860,"width":150,"height":20},"signalId":"gate","durationSeconds":0,"requiredMass":1}],
|
||||
"doors":[{"box":{"x":1110,"y":570,"width":78,"height":310},"requiredSignals":["gate"]}]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":4,
|
||||
"copy":{"en":{"title":"The Long Lift","hint":"Touch the lever, then ride the moving platform to the upper gallery."},"ru":{"title":"Долгий подъём","hint":"Коснитесь рычага, затем поднимитесь на платформе к верхней галерее."}},
|
||||
"world":{"width":2200,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":1960,"y":390,"width":86,"height":110},"targets":{"deaths":0,"seconds":60},
|
||||
"platforms":[
|
||||
{"box":{"x":0,"y":880,"width":1500,"height":200}},
|
||||
{"box":{"x":1820,"y":880,"width":380,"height":200}},
|
||||
{"box":{"x":1660,"y":500,"width":540,"height":34}},
|
||||
{"box":{"x":1040,"y":790,"width":260,"height":34},"travelX":0,"travelY":-390,"periodSeconds":5.2,"controlId":"lift"}
|
||||
],
|
||||
"hazards":[{"type":"PIT","box":{"x":1500,"y":880,"width":320,"height":200}}],
|
||||
"switches":[{"type":"TOGGLE","box":{"x":520,"y":820,"width":72,"height":60},"signalId":"lift","durationSeconds":0,"requiredMass":1}],"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":5,
|
||||
"copy":{"en":{"title":"Express Delivery","hint":"The saw throws to the right. Deliver a volunteer to the distant plate."},"ru":{"title":"Срочная доставка","hint":"Пила бросает вправо. Доставьте добровольца на дальнюю плиту."}},
|
||||
"world":{"width":2300,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2080,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":65},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2300,"height":200}},{"box":{"x":850,"y":800,"width":330,"height":34}}],
|
||||
"hazards":[{"type":"SAW","box":{"x":570,"y":760,"width":96,"height":96},"impulseX":500,"impulseY":-700}],
|
||||
"switches":[{"type":"PRESSURE","box":{"x":930,"y":780,"width":160,"height":20},"signalId":"gate","durationSeconds":0,"requiredMass":1}],
|
||||
"doors":[{"box":{"x":1390,"y":560,"width":82,"height":320},"requiredSignals":["gate"]}]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":6,
|
||||
"copy":{"en":{"title":"Cold Comfort","hint":"The frost makes a solid block. Use the frozen volunteer as a step."},"ru":{"title":"Холодное утешение","hint":"Мороз создаёт твёрдый блок. Используйте замёрзшего добровольца как ступень."}},
|
||||
"world":{"width":2150,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":1880,"y":500,"width":86,"height":110},"targets":{"deaths":1,"seconds":65},
|
||||
"platforms":[
|
||||
{"box":{"x":0,"y":880,"width":1120,"height":200}},
|
||||
{"box":{"x":810,"y":590,"width":520,"height":34}},
|
||||
{"box":{"x":1430,"y":610,"width":570,"height":34}}
|
||||
],
|
||||
"hazards":[{"type":"FREEZER","box":{"x":690,"y":776,"width":100,"height":104}}],"switches":[],"doors":[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":7,
|
||||
"copy":{"en":{"title":"Live Wire","hint":"A fallen volunteer conducts the current and keeps the relay alive."},"ru":{"title":"Живой провод","hint":"Павший доброволец проводит ток и поддерживает реле."}},
|
||||
"world":{"width":2150,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":1930,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":55},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2150,"height":200}}],
|
||||
"hazards":[{"type":"ELECTRIC","box":{"x":650,"y":780,"width":110,"height":100},"relayId":"power"}],"switches":[],
|
||||
"doors":[{"box":{"x":1130,"y":560,"width":84,"height":320},"requiredSignals":["power"]}]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":8,
|
||||
"copy":{"en":{"title":"Borrowed Time","hint":"Jump for the far blue relay if the timed gate closes."},"ru":{"title":"Время взаймы","hint":"Прыгните к дальнему синему реле, если ворота по таймеру закроются."}},
|
||||
"world":{"width":2450,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2220,"y":600,"width":86,"height":110},"targets":{"deaths":0,"seconds":38},
|
||||
"platforms":[
|
||||
{"box":{"x":0,"y":880,"width":1040,"height":200}},
|
||||
{"box":{"x":1510,"y":880,"width":940,"height":200}},
|
||||
{"box":{"x":1980,"y":710,"width":470,"height":34}},
|
||||
{"box":{"x":910,"y":770,"width":320,"height":34},"travelX":380,"travelY":0,"periodSeconds":4.0,"controlId":"clock"}
|
||||
],
|
||||
"hazards":[{"type":"PIT","box":{"x":1040,"y":880,"width":470,"height":200}}],
|
||||
"switches":[
|
||||
{"type":"TIMED","box":{"x":560,"y":810,"width":90,"height":70},"signalId":"clock","durationSeconds":8.0,"requiredMass":1},
|
||||
{"type":"TIMED","box":{"x":1600,"y":650,"width":90,"height":70},"signalId":"clock","durationSeconds":3.5,"requiredMass":1}
|
||||
],
|
||||
"doors":[{"box":{"x":1770,"y":560,"width":82,"height":320},"requiredSignals":["clock"]}]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":9,
|
||||
"copy":{"en":{"title":"Two Good Reasons","hint":"The final gate needs weight and power at the same time."},"ru":{"title":"Две веские причины","hint":"Последним воротам одновременно нужны вес и питание."}},
|
||||
"world":{"width":2550,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2310,"y":770,"width":86,"height":110},"targets":{"deaths":2,"seconds":80},
|
||||
"platforms":[{"box":{"x":0,"y":880,"width":2550,"height":200}}],
|
||||
"hazards":[
|
||||
{"type":"SPIKES","box":{"x":520,"y":850,"width":130,"height":30}},
|
||||
{"type":"ELECTRIC","box":{"x":910,"y":780,"width":110,"height":100},"relayId":"power"}
|
||||
],
|
||||
"switches":[{"type":"PRESSURE","box":{"x":510,"y":842,"width":160,"height":38},"signalId":"weight","durationSeconds":0,"requiredMass":1}],
|
||||
"doors":[{"box":{"x":1510,"y":540,"width":86,"height":340},"requiredSignals":["weight","power"]}]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"formatVersion":1,"physicsVersion":1,"number":10,
|
||||
"copy":{"en":{"title":"The Last Procession","hint":"Launch, freeze, conduct, ascend. Every lesson opens the last relic."},"ru":{"title":"Последнее шествие","hint":"Разгонитесь, замёрзните, проведите ток, поднимитесь. Каждый урок открывает последнюю реликвию."}},
|
||||
"world":{"width":3100,"height":1080},"spawn":{"x":130,"y":760},
|
||||
"goal":{"x":2880,"y":330,"width":86,"height":110},"targets":{"deaths":3,"seconds":120},
|
||||
"platforms":[
|
||||
{"box":{"x":0,"y":880,"width":1940,"height":200}},
|
||||
{"box":{"x":2140,"y":880,"width":960,"height":200}},
|
||||
{"box":{"x":2420,"y":440,"width":680,"height":34}},
|
||||
{"box":{"x":1800,"y":790,"width":290,"height":34},"travelX":0,"travelY":-410,"periodSeconds":10.0,"controlId":"lift"}
|
||||
],
|
||||
"hazards":[
|
||||
{"type":"SAW","box":{"x":520,"y":760,"width":96,"height":96},"impulseX":700,"impulseY":-240},
|
||||
{"type":"FREEZER","box":{"x":1110,"y":776,"width":100,"height":104}},
|
||||
{"type":"ELECTRIC","box":{"x":1510,"y":780,"width":110,"height":100},"relayId":"power"},
|
||||
{"type":"PIT","box":{"x":1940,"y":880,"width":200,"height":200}}
|
||||
],
|
||||
"switches":[{"type":"PRESSURE","box":{"x":860,"y":860,"width":160,"height":20},"signalId":"lift","durationSeconds":0,"requiredMass":1}],
|
||||
"doors":[{"box":{"x":2310,"y":310,"width":82,"height":570},"requiredSignals":["power"]}]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public final class LevelContentToolTest {
|
||||
@Rule public final TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void validateCommandLoadsARealDirectoryAndReportsVersions() throws Exception {
|
||||
Path levels = temporary.newFolder("levels").toPath();
|
||||
writeFixture(levels);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
ByteArrayOutputStream errors = new ByteArrayOutputStream();
|
||||
|
||||
int exitCode = LevelContentTool.run(
|
||||
new String[]{"validate", levels.toString()},
|
||||
new PrintStream(output, true, StandardCharsets.UTF_8),
|
||||
new PrintStream(errors, true, StandardCharsets.UTF_8));
|
||||
|
||||
assertEquals(0, exitCode);
|
||||
assertEquals("Validated 1 level(s); format=1; physics=1\n",
|
||||
output.toString(StandardCharsets.UTF_8));
|
||||
assertEquals("", errors.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void previewCommandProducesByteIdenticalSvgAndCoordinateExport() throws Exception {
|
||||
Path levels = temporary.newFolder("preview-levels").toPath();
|
||||
Path first = temporary.newFolder("preview-first").toPath();
|
||||
Path second = temporary.newFolder("preview-second").toPath();
|
||||
writeFixture(levels);
|
||||
|
||||
assertEquals(0, LevelContentTool.run(
|
||||
new String[]{"preview", levels.toString(), first.toString()},
|
||||
new PrintStream(new ByteArrayOutputStream()),
|
||||
new PrintStream(new ByteArrayOutputStream())));
|
||||
assertEquals(0, LevelContentTool.run(
|
||||
new String[]{"preview", levels.toString(), second.toString()},
|
||||
new PrintStream(new ByteArrayOutputStream()),
|
||||
new PrintStream(new ByteArrayOutputStream())));
|
||||
|
||||
byte[] firstSvg = Files.readAllBytes(first.resolve("level-01.svg"));
|
||||
byte[] secondSvg = Files.readAllBytes(second.resolve("level-01.svg"));
|
||||
assertArrayEquals(firstSvg, secondSvg);
|
||||
assertArrayEquals(Files.readAllBytes(first.resolve("catalog.tsv")),
|
||||
Files.readAllBytes(second.resolve("catalog.tsv")));
|
||||
String svg = new String(firstSvg, StandardCharsets.UTF_8);
|
||||
assertTrue(svg, svg.contains("viewBox=\"0 0 1920 1080\""));
|
||||
assertTrue(svg, svg.contains("data-kind=\"spawn\""));
|
||||
assertTrue(svg, svg.contains("data-kind=\"goal\""));
|
||||
}
|
||||
|
||||
private static void writeFixture(Path levels) throws Exception {
|
||||
Files.writeString(levels.resolve("index.json"),
|
||||
"{\"formatVersion\":1,\"physicsVersion\":1,\"levels\":[\"level-01.json\"]}");
|
||||
Files.writeString(levels.resolve("level-01.json"), validLevelJson());
|
||||
}
|
||||
|
||||
private static String validLevelJson() {
|
||||
return "{\"formatVersion\":1,\"physicsVersion\":1,\"number\":1,"
|
||||
+ "\"copy\":{\"en\":{\"title\":\"Test\",\"hint\":\"Go.\"},"
|
||||
+ "\"ru\":{\"title\":\"Тест\",\"hint\":\"Идите.\"}},"
|
||||
+ "\"world\":{\"width\":1920,\"height\":1080},"
|
||||
+ "\"spawn\":{\"x\":130,\"y\":760},"
|
||||
+ "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110},"
|
||||
+ "\"targets\":{\"deaths\":0,\"seconds\":30},"
|
||||
+ "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}],"
|
||||
+ "\"hazards\":[],\"switches\":[],\"doors\":[]}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user