Завершить контентную архитектуру этапа 4

This commit is contained in:
2026-08-18 22:03:38 +03:00
parent 2ddac2775f
commit 5051f316fe
39 changed files with 1954 additions and 259 deletions
+43
View File
@@ -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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace("\"", "&quot;");
}
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\":[]}";
}
}