Завершить контентную архитектуру этапа 4
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user