diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d6a5e0..1b8ec6e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: mcheli-${{ github.sha }} - path: build/libs/*.jar + path: build/release/*.jar if-no-files-found: error retention-days: 14 @@ -39,4 +39,4 @@ jobs: if: failure() with: name: test-reports - path: build/reports/tests/ + path: "*/build/reports/tests/" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78b1d8c..7f0d6c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,8 @@ jobs: if [ "${{ steps.version.outputs.prerelease }}" = "true" ]; then flags+=(--prerelease) fi - gh release create "$GITHUB_REF_NAME" build/libs/*.jar \ + # One jar per Minecraft version, gathered by the releaseJars task. + gh release create "$GITHUB_REF_NAME" build/release/*.jar \ --title "MCHeli Rework ${{ steps.version.outputs.version }}" \ --notes-file release-notes.md \ "${flags[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed967b..fadfca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to this project are documented here. The format follows [Semantic Versioning](https://semver.org/). Pre-releases (`1.0.0-alpha.N`) mark the phases on the way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). +## [1.1.0-beta.1] - 2026-10-29 + +Phase 5 of the road to 1.1.0: the groundwork that content packs, older Minecraft versions and +the vehicle workshop build on. Betas are not uploaded to CurseForge; download them from the GitHub +releases. Worlds from 1.0.0 load with their vehicles and items. + +### Added + +- `/mcheli` shows where the vehicles were loaded from and how many there are; `/mcheli list + [helicopter|plane|tank|vehicle]` lists them. +- `/mcheli give [count]` for operators, with the vehicle names suggested. +- `/mcheli reload` for operators: reads the vehicle and weapon definitions again without + restarting. Vehicles already in the world switch to the new values on the spot and keep their + ammunition; models and textures edited on disk show up straight away, without F3 + T. +- Server config, `serverconfig/mcheli-server.toml` in each world: whether explosions break blocks + and start fires, a damage multiplier for vehicles, infinite ammunition, and a list of vehicles + that cannot be placed. Edits apply while the server runs. +- Boats destroyed on the water (by a torpedo or anything else) cook off: three explosions with + fireballs within about five seconds, the first from the fuel, the second from the ammunition + still aboard, the last and biggest sized by the fuel load like a crash. Then the burning wreck + slowly goes under, stern first and listing, and settles on the bottom. +- Content can now bring textures, models and sounds while the game runs, without a resource + reload. Nothing uses this yet beyond `/mcheli reload`; content packs (beta.2) will. + +### Changed + +- Vehicle items: there is now one item per vehicle kind (Helicopter, Plane, Tank, Ground Vehicle) + that remembers which vehicle it places, instead of one item per vehicle. They look and are named + as before. Items from 1.0.0 still work and turn into the new ones as soon as they are in a + player's inventory; items in chests change when taken out. To give one by command, use + `/mcheli give` (plain `/give mcheli:ah-64` no longer exists for new items). +- The imported content is converted again on the first start, since the crafting recipes now + make the new items. This takes a few seconds, once. +- The source is split into a version-free `common` module and a module per Minecraft version + (`forge-26.3` for now), and a release attaches one jar per Minecraft version. Nothing changes for + players. + +### Fixed + +- Boats and other floating vehicles sat about a third of a block too deep, so the Zodiac was + almost under water. They now float as high as in the original. +- Static launchers and guns that turn with the gunner's view, such as the Type 93 and Mk 32 + torpedo tubes, only fired straight ahead. They fire where the gunner looks again. +- On Windows, converting the content again could fail with "access denied" when another program + had the old `mcheli/imported` folder open, leaving the game without vehicles until the next + start. The old folder is now moved aside first, and the swap is retried. + ## [1.0.0] - 2026-10-28 First release. Everything from the alpha and beta versions, shipped as a code-only jar. The @@ -317,4 +364,5 @@ Phase 1: foundation and flyable helicopters. - Free look moved from Left Ctrl to Left Alt, because Left Ctrl is now the vanilla sprint key. - Roll damping runs per tick instead of per frame, so handling no longer depends on frame rate. +[1.1.0-beta.1]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.1.0-beta.1 [1.0.0]: https://github.com/JnH-Projects/MCHeli-Rework-Forge/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 243a40d..bd38278 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ Phase 4 clips (click a picture to open the video): ## Screenshots +Phase 5 in a development build (`1.1.0-beta.1`): a patrol boat hit by a torpedo cooks off and +sinks. + +| | | +|---|---| +| ![A patrol boat exploding on a river after a torpedo hit, burning debris arcing over the shore](docs/images/phase-5/patrol-boat-cook-off.png) | ![The wreck of the patrol boat resting on the riverbed, listing and still smoking](docs/images/phase-5/patrol-boat-wreck.png) | +| Torpedo hit: fuel and ammunition cook off in three blasts | The wreck settles on the bottom, still smoking | + Phase 4 in a development build: the original's scripted HUDs, pilot gunner mode and lock-on. | | | @@ -196,6 +204,26 @@ Each seat fires the weapons its vehicle assigns to it; the pilot also fires a gu while that seat has no player. Creative players have unlimited ammunition. A small readout at the bottom right shows hit points, the weapon, ammunition, reload, heat, lock and flares. +## Commands and server settings + +| Command | Who | What it does | +|---------|-----|--------------| +| `/mcheli` | everyone | Where the vehicles were loaded from, and how many of each kind | +| `/mcheli list [helicopter\|plane\|tank\|vehicle]` | everyone | The loaded vehicles by name | +| `/mcheli give [count]` | operators | Hands out vehicles, for example `/mcheli give @s ah-64` | +| `/mcheli reload` | operators | Reads the vehicle and weapon files again without a restart | + +Each world has `serverconfig/mcheli-server.toml` (created on first start, edits apply while the +server runs): + +| Setting | Default | Meaning | +|---------|---------|---------| +| `explosions.breakBlocks` | `true` | Weapons and crashes break blocks (the `mobGriefing` rule applies too) | +| `explosions.startFires` | `true` | Incendiary weapons and crashes start fires | +| `vehicles.damageFactor` | `1.0` | Multiplies the damage vehicles take; `0.5` makes them twice as tough | +| `vehicles.disabled` | `[]` | Vehicles that cannot be placed, by name, such as `["ah-64", "f-35b"]` | +| `weapons.infiniteAmmo` | `false` | Vehicle weapons never run out of ammunition | + ## Documentation * [Changelog](CHANGELOG.md) @@ -212,7 +240,8 @@ bottom right shows hit points, the weapon, ammunition, reload, heat, lock and fl ./gradlew build ``` -The jar ends up in `build/libs`. Java 25 is required; Gradle downloads one automatically if it is -not installed. `./gradlew runClient` and `runServer` import the content from +The jars, one per Minecraft version, end up in `build/release`. Java 25 is required; Gradle +downloads one automatically if it is not installed. `./gradlew :forge-26.3:runClient` and +`:forge-26.3:runServer` import the content from `Mod/MC1.7.10_mcheli_1.0.3.zip`, which you have to download yourself. See [DEVELOPMENT.md](docs/DEVELOPMENT.md) for details. diff --git a/build.gradle b/build.gradle index 579c0f9..e4aa2c3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,106 +1,18 @@ plugins { - id 'java' - id 'idea' - id 'eclipse' - id 'net.minecraftforge.gradle' version '[7.0.17,8)' + id 'base' + id 'net.minecraftforge.gradle' version '[7.0.17,8)' apply false } -version = mod_version -group = mod_group +def platforms = providers.gradleProperty('platforms').get().split(',').collect { it.trim() } -base { - archivesName = "mcheli-${minecraft_version}" +// One jar per Minecraft version, gathered in one folder for the release workflow. +def releaseJars = tasks.register('releaseJars', Sync) { + group = 'build' + description = 'Collects the jar of every platform module into build/release.' + platforms.each { platform -> from(project(":$platform").tasks.named('jar')) } + into layout.buildDirectory.dir('release') } -// Minecraft 26.x ships Java 25 to players, so the mod targets it as well. -java.toolchain.languageVersion = JavaLanguageVersion.of(25) - -// The original MCHeli 1.0.3 download. Players supply their own copy; development runs and the -// import test use this one (git-ignored, see docs/DEVELOPMENT.md). -def legacyZip = file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath - -minecraft { - runs { - configureEach { - workingDir = layout.projectDirectory.dir('run') - systemProperty 'eventbus.api.strictRuntimeChecks', 'true' - systemProperty 'mcheli.legacyZip', legacyZip - } - - register('client') - - register('server') { - args '--nogui' - } - } -} - -repositories { - minecraft.mavenizer(it) - maven fg.forgeMaven - maven fg.minecraftLibsMaven - mavenCentral() -} - -dependencies { - implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") - annotationProcessor "net.minecraftforge:eventbus-validator:${eventbus_version}" - - testImplementation platform("org.junit:junit-bom:${junit_version}") - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' - options.compilerArgs.addAll(['-Xlint:deprecation', '-Xlint:unchecked']) -} - -tasks.named('test', Test) { - useJUnitPlatform() - systemProperty 'mcheli.resources', file('src/main/resources').absolutePath - // The content tests import the original zip once into build/legacy-content (TestResources) - // and are skipped where the zip is absent, such as on CI. - systemProperty 'mcheli.legacyZip', legacyZip - systemProperty 'mcheli.testContent', layout.buildDirectory.dir('legacy-content').get().asFile.absolutePath -} - -def expandProps = [ - mod_id : mod_id, - mod_name : mod_name, - mod_version : version, - mod_authors : mod_authors, - mod_license : mod_license, - minecraft_version_range: minecraft_version_range, - forge_version_range : forge_version_range, - loader_version_range : loader_version_range, -] - -tasks.named('processResources', ProcessResources) { - inputs.properties(expandProps) - filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { - expand expandProps - } -} - -tasks.named('jar', Jar) { - // EMB4's content is never redistributed: players import it from their own copy of the original - // zip (LegacyContent). The repository no longer holds it, but the jar is packed from - // build/sourceSets/main, which can still have copies from older builds and from the IDE. - exclude { FileTreeElement element -> - String path = element.relativePath.pathString - !element.directory && path.startsWith('assets/mcheli/') && !path.startsWith('assets/mcheli/lang/') - } - exclude 'data/mcheli/recipe/**' - includeEmptyDirs = false - manifest { - attributes([ - 'Specification-Title' : mod_id, - 'Specification-Vendor' : 'JnH Projects', - 'Specification-Version' : '1', - 'Implementation-Title' : mod_name, - 'Implementation-Version': version, - 'Implementation-Vendor' : 'JnH Projects', - ]) - } +tasks.named('build') { + dependsOn releaseJars } diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..8dde66e --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,48 @@ +plugins { + id 'java-library' +} + +version = mod_version +group = mod_group + +// The platform modules compile these sources again with their own toolchain. Building them on +// their own here, with no Minecraft or loader on the classpath, is what keeps them version-free. +java.toolchain.languageVersion = JavaLanguageVersion.of(25) + +repositories { + // Minecraft's library mirror has everything the game ships; Maven Central is the fallback. + maven { url = 'https://libraries.minecraft.net/' } + mavenCentral() +} + +dependencies { + // Provided at runtime by Minecraft on every supported version. + compileOnly "org.slf4j:slf4j-api:${slf4j_version}" + compileOnly "org.jspecify:jspecify:${jspecify_version}" + compileOnly "org.joml:joml:${joml_version}" + + testImplementation "org.slf4j:slf4j-api:${slf4j_version}" + testImplementation "org.jspecify:jspecify:${jspecify_version}" + testImplementation "org.joml:joml:${joml_version}" + testImplementation platform("org.junit:junit-bom:${junit_version}") + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.compilerArgs.addAll(['-Xlint:deprecation', '-Xlint:unchecked']) +} + +// The original MCHeli 1.0.3 download, kept by developers in Mod/ (git-ignored, see docs/DEVELOPMENT.md). +def legacyZip = rootProject.file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath + +tasks.named('test', Test) { + useJUnitPlatform() + systemProperty 'mcheli.resources', file('src/main/resources').absolutePath + systemProperty 'mcheli.commonSources', file('src/main/java').absolutePath + // The content tests import the original zip once into build/legacy-content (TestResources) + // and are skipped where the zip is absent, such as on CI. + systemProperty 'mcheli.legacyZip', legacyZip + systemProperty 'mcheli.testContent', layout.buildDirectory.dir('legacy-content').get().asFile.absolutePath +} diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java new file mode 100644 index 0000000..deae35d --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java @@ -0,0 +1,304 @@ +package com.jnhprojects.mcheli.content; + +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; +import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.stream.Stream; + +/** + * Loaded vehicle and weapon definitions, keyed by file name. + * + *

The registry can change while the game runs: a definition can be added or replaced + * ({@link #add}), and the whole set can be swapped for a freshly loaded one ({@link #replaceWith}) + * when an operator reloads the content. Every change bumps {@link #generation()}, which is how + * vehicles already in the world notice that their definition object is stale. + * + *

Readers never lock. The maps live in an immutable snapshot that writers copy, change and + * publish in one volatile write, so the render thread and the server thread of a single player + * game always see a consistent set, even halfway through a reload. + */ +public final class ContentRegistry { + private static final Logger LOGGER = LoggerFactory.getLogger(ContentRegistry.class); + + private volatile Snapshot snapshot; + private volatile int generation; + + private ContentRegistry(Snapshot snapshot) { + this.snapshot = snapshot; + } + + /** A registry with nothing in it, for when no content could be found. */ + public static ContentRegistry empty() { + return new ContentRegistry(new Snapshot(Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Map.of())); + } + + /** + * @param assetRoot the {@code assets/mcheli} folder; when it does not exist, nothing is loaded + */ + public static ContentRegistry load(Path assetRoot) throws IOException { + Map> unhandled = new TreeMap<>(); + Map weapons = loadFolder(assetRoot.resolve(WeaponDefinition.FOLDER), WeaponDefinition::new, unhandled); + Map helicopters = + loadFolder(assetRoot.resolve(HelicopterDefinition.FOLDER), HelicopterDefinition::new, unhandled); + Map planes = loadFolder(assetRoot.resolve(PlaneDefinition.FOLDER), PlaneDefinition::new, unhandled); + Map tanks = loadFolder(assetRoot.resolve(TankDefinition.FOLDER), TankDefinition::new, unhandled); + Map vehicles = loadFolder(assetRoot.resolve(VehicleDefinition.FOLDER), VehicleDefinition::new, unhandled); + + for (Map kind : List.of(helicopters, planes, tanks, vehicles)) { + for (AircraftDefinition def : kind.values()) { + dropUnknownWeapons(def, weapons); + } + } + + LOGGER.info("Loaded {} helicopters, {} planes, {} tanks, {} vehicles and {} weapons", + helicopters.size(), planes.size(), tanks.size(), vehicles.size(), weapons.size()); + if (!unhandled.isEmpty()) { + LOGGER.debug("Definition keys not implemented yet: {}", unhandled.keySet()); + } + return new ContentRegistry(new Snapshot(helicopters, planes, tanks, vehicles, weapons, unhandled)); + } + + private static Map loadFolder( + Path folder, Function factory, Map> unhandled) throws IOException { + Map out = new LinkedHashMap<>(); + if (!Files.isDirectory(folder)) { + return out; + } + + List files; + try (Stream stream = Files.list(folder)) { + files = stream.filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".txt")) + .sorted() + .toList(); + } + + for (Path file : files) { + String fileName = file.getFileName().toString().toLowerCase(Locale.ROOT); + String name = fileName.substring(0, fileName.length() - 4); + DefinitionReader.Result result = DefinitionReader.read(file, factory.apply(name)); + for (String problem : result.problems()) { + LOGGER.warn("{}/{}: {}", folder.getFileName(), fileName, problem); + } + for (String key : result.unhandledKeys()) { + unhandled.computeIfAbsent(key, k -> new TreeSet<>()).add(name); + } + if (result.isValid()) { + out.put(name, result.definition()); + } + } + return out; + } + + /** + * The original silently skipped AddWeapon lines naming a weapon it had not loaded. The shipped + * AH-64, KV-2 and M26 do that, so it is only worth a debug line. + */ + private static void dropUnknownWeapons(AircraftDefinition def, Map weapons) { + List dropped = def.retainWeapons(weapons.keySet()); + if (!dropped.isEmpty()) { + LOGGER.debug("{}/{}: unknown weapons {}", def.folder(), def.name(), dropped); + } + } + + // Changes + + /** + * Adds a vehicle definition, or replaces the one with the same name. Names are unique across + * kinds, so a definition that changed kind leaves its old kind. Weapons the definition names + * must already be registered; unknown ones are dropped, as on load. + */ + public synchronized void add(AircraftDefinition def) { + dropUnknownWeapons(def, snapshot.weapons); + Snapshot s = snapshot.without(def.name()); + snapshot = switch (def) { + case HelicopterDefinition h -> s.withHelicopters(put(s.helicopters, h)); + case PlaneDefinition p -> s.withPlanes(put(s.planes, p)); + case TankDefinition t -> s.withTanks(put(s.tanks, t)); + case VehicleDefinition v -> s.withVehicles(put(s.vehicles, v)); + default -> throw new IllegalArgumentException("Unknown vehicle kind " + def.kind()); + }; + generation++; + } + + /** Adds a weapon definition, or replaces the one with the same name. */ + public synchronized void addWeapon(WeaponDefinition weapon) { + snapshot = snapshot.withWeapons(put(snapshot.weapons, weapon)); + generation++; + } + + /** Removes the vehicle definition with this name, of whatever kind. Returns whether one existed. */ + public synchronized boolean remove(String name) { + if (any(name) == null) { + return false; + } + snapshot = snapshot.without(name); + generation++; + return true; + } + + /** Takes over everything {@code other} holds, as one change. Used by the content reload. */ + public synchronized void replaceWith(ContentRegistry other) { + snapshot = other.snapshot; + generation++; + } + + /** + * Counts the changes made to this registry. Anything that caches a definition object compares + * this to notice a reload; the value means nothing on its own. + */ + public int generation() { + return generation; + } + + private static Map put(Map map, T def) { + Map copy = new LinkedHashMap<>(map); + copy.put(def.name(), def); + return copy; + } + + // Lookups + + public Collection helicopters() { + return snapshot.helicopters.values(); + } + + public @Nullable HelicopterDefinition helicopter(String name) { + return snapshot.helicopters.get(name); + } + + public Collection planes() { + return snapshot.planes.values(); + } + + public @Nullable PlaneDefinition plane(String name) { + return snapshot.planes.get(name); + } + + public Collection tanks() { + return snapshot.tanks.values(); + } + + public @Nullable TankDefinition tank(String name) { + return snapshot.tanks.get(name); + } + + public Collection vehicles() { + return snapshot.vehicles.values(); + } + + public @Nullable VehicleDefinition vehicle(String name) { + return snapshot.vehicles.get(name); + } + + public Collection weapons() { + return snapshot.weapons.values(); + } + + public @Nullable WeaponDefinition weapon(String name) { + return snapshot.weapons.get(name); + } + + /** Every definition of every kind: helicopters, planes, tanks, then vehicles. */ + public List all() { + Snapshot s = snapshot; + List all = new ArrayList<>(s.helicopters.size() + s.planes.size() + s.tanks.size() + s.vehicles.size()); + all.addAll(s.helicopters.values()); + all.addAll(s.planes.values()); + all.addAll(s.tanks.values()); + all.addAll(s.vehicles.values()); + return all; + } + + /** Looks a definition up by name in any kind. Names are unique across kinds. */ + public @Nullable AircraftDefinition any(String name) { + return snapshot.any(name); + } + + /** Keys found in the files that no handler understood, mapped to the files that use them. */ + public Map> unhandledKeys() { + return snapshot.unhandledKeys; + } + + /** One consistent state of the registry. Never changed once published. */ + private record Snapshot(Map helicopters, Map planes, + Map tanks, Map vehicles, + Map weapons, Map> unhandledKeys) { + Snapshot { + helicopters = Collections.unmodifiableMap(helicopters); + planes = Collections.unmodifiableMap(planes); + tanks = Collections.unmodifiableMap(tanks); + vehicles = Collections.unmodifiableMap(vehicles); + weapons = Collections.unmodifiableMap(weapons); + unhandledKeys = Collections.unmodifiableMap(unhandledKeys); + } + + @Nullable AircraftDefinition any(String name) { + AircraftDefinition def = helicopters.get(name); + if (def == null) { + def = planes.get(name); + } + if (def == null) { + def = tanks.get(name); + } + if (def == null) { + def = vehicles.get(name); + } + return def; + } + + Snapshot without(String name) { + return new Snapshot(remove(helicopters, name), remove(planes, name), remove(tanks, name), remove(vehicles, name), + weapons, unhandledKeys); + } + + Snapshot withHelicopters(Map map) { + return new Snapshot(map, planes, tanks, vehicles, weapons, unhandledKeys); + } + + Snapshot withPlanes(Map map) { + return new Snapshot(helicopters, map, tanks, vehicles, weapons, unhandledKeys); + } + + Snapshot withTanks(Map map) { + return new Snapshot(helicopters, planes, map, vehicles, weapons, unhandledKeys); + } + + Snapshot withVehicles(Map map) { + return new Snapshot(helicopters, planes, tanks, map, weapons, unhandledKeys); + } + + Snapshot withWeapons(Map map) { + return new Snapshot(helicopters, planes, tanks, vehicles, map, unhandledKeys); + } + + private static Map remove(Map map, String name) { + if (!map.containsKey(name)) { + return map; + } + Map copy = new LinkedHashMap<>(map); + copy.remove(name); + return copy; + } + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/content/Definition.java b/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/Definition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/Definition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/DefinitionReader.java b/common/src/main/java/com/jnhprojects/mcheli/content/DefinitionReader.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/DefinitionReader.java rename to common/src/main/java/com/jnhprojects/mcheli/content/DefinitionReader.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/DefinitionValues.java b/common/src/main/java/com/jnhprojects/mcheli/content/DefinitionValues.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/DefinitionValues.java rename to common/src/main/java/com/jnhprojects/mcheli/content/DefinitionValues.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/InvalidDefinitionException.java b/common/src/main/java/com/jnhprojects/mcheli/content/InvalidDefinitionException.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/InvalidDefinitionException.java rename to common/src/main/java/com/jnhprojects/mcheli/content/InvalidDefinitionException.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java b/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java similarity index 98% rename from src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java rename to common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java index b6d93a0..1daae4b 100644 --- a/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/LegacyImporter.java @@ -42,8 +42,11 @@ * written here. They ship in the mod jar, and Minecraft merges lang files across packs. */ public final class LegacyImporter { - /** Bump whenever the output changes, so existing installs import again. */ - public static final int FORMAT = 1; + /** + * Bump whenever the output changes, so existing installs import again. 2: recipes craft the + * generic vehicle items of 1.1.0. + */ + public static final int FORMAT = 2; private static final String ASSET_ROOT = "mcheli/assets/mcheli/"; private static final Set SKIPPED_EXTENSIONS = Set.of("xcf"); diff --git a/src/main/java/com/jnhprojects/mcheli/content/Vector3.java b/common/src/main/java/com/jnhprojects/mcheli/content/Vector3.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/Vector3.java rename to common/src/main/java/com/jnhprojects/mcheli/content/Vector3.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/AircraftDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/BoundingBoxDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/BoundingBoxDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/BoundingBoxDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/BoundingBoxDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/CameraPosition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/CameraPosition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/CameraPosition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/CameraPosition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/HelicopterDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/HelicopterDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/HelicopterDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/HelicopterDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/PlaneDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/PlaneDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/PlaneDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/PlaneDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/RackDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RackDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/RackDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RackDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/RideRack.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RideRack.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/RideRack.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RideRack.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/RotorDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RotorDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/RotorDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/RotorDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/SeatDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/SeatDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/SeatDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/SeatDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/TankDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/TankDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/TankDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/TankDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/VehicleDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/VehicleDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/VehicleDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/VehicleDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponGroup.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponGroup.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponGroup.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponGroup.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponMount.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponMount.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponMount.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/WeaponMount.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/CameraPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/CameraPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/CameraPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/CameraPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/CrawlerTrack.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/CrawlerTrack.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/CrawlerTrack.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/CrawlerTrack.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/LandingGearPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/LandingGearPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/LandingGearPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/LandingGearPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/MovingPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/MovingPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/MovingPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/MovingPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/PropellerPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/PropellerPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/PropellerPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/PropellerPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/RotatingPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/RotatingPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/RotatingPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/RotatingPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/ThrottlePart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/ThrottlePart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/ThrottlePart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/ThrottlePart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/TrackRoller.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/TrackRoller.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/TrackRoller.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/TrackRoller.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/VehiclePart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/VehiclePart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/VehiclePart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/VehiclePart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WeaponBayPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WeaponBayPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WeaponBayPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WeaponBayPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WeaponPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WeaponPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WeaponPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WeaponPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WheelPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WheelPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WheelPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WheelPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WingPart.java b/common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WingPart.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WingPart.java rename to common/src/main/java/com/jnhprojects/mcheli/content/aircraft/part/WingPart.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/recipe/InvalidRecipeException.java b/common/src/main/java/com/jnhprojects/mcheli/content/recipe/InvalidRecipeException.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/recipe/InvalidRecipeException.java rename to common/src/main/java/com/jnhprojects/mcheli/content/recipe/InvalidRecipeException.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyItems.java b/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyItems.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyItems.java rename to common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyItems.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java b/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java similarity index 76% rename from src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java rename to common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java index 0df6579..79accd8 100644 --- a/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/recipe/LegacyRecipe.java @@ -24,23 +24,40 @@ * {@link LegacyItems}. Unlike the original, a key letter the pattern does not use is dropped * (26.x refuses such recipes), and a recipe naming an item that no longer exists is reported * instead of crashing the game. + * + *

Every vehicle of a kind shares one item, told apart by the {@value #VEHICLE_COMPONENT} data + * component. Recipes craft that item with the component set, and a vehicle used as an ingredient + * (the AH-6X is made from an AH-6) becomes a {@value #VEHICLE_INGREDIENT} ingredient. */ public final class LegacyRecipe { private LegacyRecipe() { } + /** Data component holding the definition name on a vehicle item. */ + public static final String VEHICLE_COMPONENT = "mcheli:vehicle"; + /** Ingredient type matching one vehicle, since every vehicle of a kind shares an item. */ + public static final String VEHICLE_INGREDIENT = "mcheli:vehicle"; + /** - * @param result the crafted item, for example {@code mcheli:ah-6} - * @param modItems which {@code mcheli:} item ids exist, for recipes that use another vehicle + * @param result the crafted vehicle + * @param modItems which {@code mcheli:} names exist, for recipes that use another vehicle + * ({@code mcheli:ah-6} in the original files) * @return the recipe as JSON * @throws InvalidRecipeException with the reason when the line cannot become a recipe */ - public static String toJson(String result, String line, boolean shaped, Predicate modItems) { + public static String toJson(Vehicle result, String line, boolean shaped, Predicate modItems) { String[] s = line.trim().split("\\s*,\\s*"); return shaped ? shaped(result, s, modItems) : shapeless(result, s, modItems); } - private static String shaped(String result, String[] s, Predicate modItems) { + /** + * A crafted vehicle: the generic item of its kind ({@code mcheli:helicopter}) carrying the + * definition name, and the item model the import wrote for it. + */ + public record Vehicle(String kind, String name) { + } + + private static String shaped(Vehicle result, String[] s, Predicate modItems) { int idx = 0; int count = 1; if (s.length > 0 && isNumber(s[0])) { @@ -106,7 +123,7 @@ private static String shaped(String result, String[] s, Predicate modIte json.append("{\n \"type\": \"minecraft:crafting_shaped\",\n \"category\": \"misc\",\n \"key\": {\n"); int n = 0; for (Map.Entry e : key.entrySet()) { - json.append(" \"").append(e.getKey()).append("\": \"").append(e.getValue()).append('"') + json.append(" \"").append(e.getKey()).append("\": ").append(e.getValue()) .append(++n < key.size() ? ",\n" : "\n"); } json.append(" },\n \"pattern\": [\n"); @@ -118,7 +135,7 @@ private static String shaped(String result, String[] s, Predicate modIte return json.toString(); } - private static String shapeless(String result, String[] s, Predicate modItems) { + private static String shapeless(Vehicle result, String[] s, Predicate modItems) { List ingredients = new ArrayList<>(); for (int idx = 0; idx < s.length; idx++) { if (s[idx].isEmpty()) { @@ -137,34 +154,38 @@ private static String shapeless(String result, String[] s, Predicate mod StringBuilder json = new StringBuilder(); json.append("{\n \"type\": \"minecraft:crafting_shapeless\",\n \"category\": \"misc\",\n \"ingredients\": [\n"); for (int i = 0; i < ingredients.size(); i++) { - json.append(" \"").append(ingredients.get(i)).append('"').append(i + 1 < ingredients.size() ? ",\n" : "\n"); + json.append(" ").append(ingredients.get(i)).append(i + 1 < ingredients.size() ? ",\n" : "\n"); } json.append(" ],\n"); appendResult(json, result, 1); return json.toString(); } - private static void appendResult(StringBuilder json, String result, int count) { - json.append(" \"result\": {\n \"id\": \"").append(result).append('"'); + private static void appendResult(StringBuilder json, Vehicle result, int count) { + json.append(" \"result\": {\n \"id\": \"mcheli:").append(result.kind()).append('"'); if (count > 1) { json.append(",\n \"count\": ").append(count); } - json.append("\n }\n}\n"); + json.append(",\n \"components\": {\n") + .append(" \"").append(VEHICLE_COMPONENT).append("\": \"").append(result.name()).append("\",\n") + .append(" \"minecraft:item_model\": \"mcheli:").append(result.name()).append("\"\n") + .append(" }\n }\n}\n"); } + /** @return the ingredient as a JSON value: a quoted item id or tag, or an object for a vehicle */ private static String ingredient(String name, int damage, Predicate modItems) { String lower = name.trim().toLowerCase(Locale.ROOT); if (lower.startsWith("mcheli:")) { if (!modItems.test(lower)) { throw new InvalidRecipeException(lower + " does not exist (yet)"); } - return lower; + return "{\"type\": \"" + VEHICLE_INGREDIENT + "\", \"vehicle\": \"" + lower.substring("mcheli:".length()) + "\"}"; } String mapped = LegacyItems.ingredient(lower, damage); if (mapped == null) { throw new InvalidRecipeException("no modern item for " + lower + (damage != 0 ? " with damage " + damage : "")); } - return mapped; + return '"' + mapped + '"'; } private static boolean isNumber(String s) { @@ -205,7 +226,8 @@ public static Generated generate(ContentRegistry content) { for (AircraftDefinition def : content.all()) { for (Map.Entry e : fileNames(def.name(), def.recipes).entrySet()) { try { - files.put(e.getKey() + ".json", toJson("mcheli:" + def.name(), e.getValue(), def.shapedRecipe, items::contains)); + files.put(e.getKey() + ".json", + toJson(new Vehicle(def.kind(), def.name()), e.getValue(), def.shapedRecipe, items::contains)); } catch (InvalidRecipeException ex) { skipped.add(e.getKey() + ": " + ex.getMessage()); } diff --git a/src/main/java/com/jnhprojects/mcheli/content/weapon/Cartridge.java b/common/src/main/java/com/jnhprojects/mcheli/content/weapon/Cartridge.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/weapon/Cartridge.java rename to common/src/main/java/com/jnhprojects/mcheli/content/weapon/Cartridge.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/weapon/MuzzleFlash.java b/common/src/main/java/com/jnhprojects/mcheli/content/weapon/MuzzleFlash.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/weapon/MuzzleFlash.java rename to common/src/main/java/com/jnhprojects/mcheli/content/weapon/MuzzleFlash.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/weapon/RoundItem.java b/common/src/main/java/com/jnhprojects/mcheli/content/weapon/RoundItem.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/weapon/RoundItem.java rename to common/src/main/java/com/jnhprojects/mcheli/content/weapon/RoundItem.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponColor.java b/common/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponColor.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponColor.java rename to common/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponColor.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponDefinition.java b/common/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponDefinition.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponDefinition.java rename to common/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponDefinition.java diff --git a/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponType.java b/common/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponType.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponType.java rename to common/src/main/java/com/jnhprojects/mcheli/content/weapon/WeaponType.java diff --git a/src/main/java/com/jnhprojects/mcheli/hud/Expression.java b/common/src/main/java/com/jnhprojects/mcheli/hud/Expression.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/hud/Expression.java rename to common/src/main/java/com/jnhprojects/mcheli/hud/Expression.java diff --git a/src/main/java/com/jnhprojects/mcheli/hud/HudCommand.java b/common/src/main/java/com/jnhprojects/mcheli/hud/HudCommand.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/hud/HudCommand.java rename to common/src/main/java/com/jnhprojects/mcheli/hud/HudCommand.java diff --git a/src/main/java/com/jnhprojects/mcheli/hud/HudFormatException.java b/common/src/main/java/com/jnhprojects/mcheli/hud/HudFormatException.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/hud/HudFormatException.java rename to common/src/main/java/com/jnhprojects/mcheli/hud/HudFormatException.java diff --git a/src/main/java/com/jnhprojects/mcheli/hud/HudScript.java b/common/src/main/java/com/jnhprojects/mcheli/hud/HudScript.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/hud/HudScript.java rename to common/src/main/java/com/jnhprojects/mcheli/hud/HudScript.java diff --git a/src/main/java/com/jnhprojects/mcheli/hud/HudVariable.java b/common/src/main/java/com/jnhprojects/mcheli/hud/HudVariable.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/hud/HudVariable.java rename to common/src/main/java/com/jnhprojects/mcheli/hud/HudVariable.java diff --git a/src/main/java/com/jnhprojects/mcheli/hud/TextArg.java b/common/src/main/java/com/jnhprojects/mcheli/hud/TextArg.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/hud/TextArg.java rename to common/src/main/java/com/jnhprojects/mcheli/hud/TextArg.java diff --git a/src/main/java/com/jnhprojects/mcheli/model/Mesh.java b/common/src/main/java/com/jnhprojects/mcheli/model/Mesh.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/model/Mesh.java rename to common/src/main/java/com/jnhprojects/mcheli/model/Mesh.java diff --git a/src/main/java/com/jnhprojects/mcheli/model/MeshGroup.java b/common/src/main/java/com/jnhprojects/mcheli/model/MeshGroup.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/model/MeshGroup.java rename to common/src/main/java/com/jnhprojects/mcheli/model/MeshGroup.java diff --git a/src/main/java/com/jnhprojects/mcheli/model/MeshLoader.java b/common/src/main/java/com/jnhprojects/mcheli/model/MeshLoader.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/model/MeshLoader.java rename to common/src/main/java/com/jnhprojects/mcheli/model/MeshLoader.java diff --git a/src/main/java/com/jnhprojects/mcheli/model/ModelFormatException.java b/common/src/main/java/com/jnhprojects/mcheli/model/ModelFormatException.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/model/ModelFormatException.java rename to common/src/main/java/com/jnhprojects/mcheli/model/ModelFormatException.java diff --git a/src/main/java/com/jnhprojects/mcheli/model/MqoLoader.java b/common/src/main/java/com/jnhprojects/mcheli/model/MqoLoader.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/model/MqoLoader.java rename to common/src/main/java/com/jnhprojects/mcheli/model/MqoLoader.java diff --git a/src/main/java/com/jnhprojects/mcheli/model/ObjLoader.java b/common/src/main/java/com/jnhprojects/mcheli/model/ObjLoader.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/model/ObjLoader.java rename to common/src/main/java/com/jnhprojects/mcheli/model/ObjLoader.java diff --git a/src/main/java/com/jnhprojects/mcheli/model/TriangleBuilder.java b/common/src/main/java/com/jnhprojects/mcheli/model/TriangleBuilder.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/model/TriangleBuilder.java rename to common/src/main/java/com/jnhprojects/mcheli/model/TriangleBuilder.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/AircraftRotation.java b/common/src/main/java/com/jnhprojects/mcheli/physics/AircraftRotation.java similarity index 97% rename from src/main/java/com/jnhprojects/mcheli/entity/AircraftRotation.java rename to common/src/main/java/com/jnhprojects/mcheli/physics/AircraftRotation.java index 82ad8af..45cba21 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/AircraftRotation.java +++ b/common/src/main/java/com/jnhprojects/mcheli/physics/AircraftRotation.java @@ -1,4 +1,4 @@ -package com.jnhprojects.mcheli.entity; +package com.jnhprojects.mcheli.physics; import org.joml.Quaternionf; import org.joml.Vector3f; @@ -69,7 +69,7 @@ public static float cameraRoll(float yaw, float pitch, float roll, float viewYaw return (float) Math.toDegrees(Math.atan2(onLeft, onUp)); } - static float wrap(float degrees) { + public static float wrap(float degrees) { float d = degrees % 360.0F; if (d >= 180.0F) { d -= 360.0F; diff --git a/src/main/java/com/jnhprojects/mcheli/entity/GroundImpact.java b/common/src/main/java/com/jnhprojects/mcheli/physics/GroundImpact.java similarity index 73% rename from src/main/java/com/jnhprojects/mcheli/entity/GroundImpact.java rename to common/src/main/java/com/jnhprojects/mcheli/physics/GroundImpact.java index e32e7b0..67d0ec5 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/GroundImpact.java +++ b/common/src/main/java/com/jnhprojects/mcheli/physics/GroundImpact.java @@ -1,28 +1,28 @@ -package com.jnhprojects.mcheli.entity; +package com.jnhprojects.mcheli.physics; /** * Not in the original: how hard a wall or a long drop hits a tank or a car, as a share of its hit * points. Both grow with the square between a harmless and a fatal value, so a gentle bump costs * next to nothing and a hard one a lot. The vehicle's armour still applies afterwards. */ -final class GroundImpact { +public final class GroundImpact { /** Speed into a wall, in blocks per tick, that starts to hurt: about 6 m/s. */ - static final double WALL_SPEED = 0.3; + public static final double WALL_SPEED = 0.3; /** Speed into a wall that costs every hit point: about 24 m/s. */ - static final double WALL_FATAL_SPEED = 1.2; + public static final double WALL_FATAL_SPEED = 1.2; /** A drop in blocks that does no harm. */ - static final double SAFE_DROP = 4.0; + public static final double SAFE_DROP = 4.0; /** A drop that costs every hit point. */ - static final double FATAL_DROP = 24.0; + public static final double FATAL_DROP = 24.0; private GroundImpact() { } - static float wall(double speed) { + public static float wall(double speed) { return severity(speed, WALL_SPEED, WALL_FATAL_SPEED); } - static float drop(double height) { + public static float drop(double height) { return severity(height, SAFE_DROP, FATAL_DROP); } diff --git a/src/main/resources/assets/mcheli/lang/en_us.json b/common/src/main/resources/assets/mcheli/lang/en_us.json similarity index 77% rename from src/main/resources/assets/mcheli/lang/en_us.json rename to common/src/main/resources/assets/mcheli/lang/en_us.json index b7ea037..f4f2e9d 100644 --- a/src/main/resources/assets/mcheli/lang/en_us.json +++ b/common/src/main/resources/assets/mcheli/lang/en_us.json @@ -1,4 +1,10 @@ { + "commands.mcheli.give": "Gave %s %s to %s players", + "commands.mcheli.list": "%s vehicles: %s", + "commands.mcheli.reload.failed": "Could not reload the MCHeli content: %s. See logs/latest.log.", + "commands.mcheli.reload.success": "Reloaded %s vehicles and %s weapons", + "commands.mcheli.status": "MCHeli content: %s helicopters, %s planes, %s tanks, %s ground vehicles, %s weapons, from %s", + "commands.mcheli.unknown_vehicle": "No vehicle called %s", "death.attack.mcheli.bullet": "%1$s was shot down", "death.attack.mcheli.bullet.item": "%1$s was shot down by %2$s using %3$s", "death.attack.mcheli.bullet.player": "%1$s was shot down by %2$s", @@ -32,6 +38,10 @@ "hud.mcheli.preparing": "Preparing", "hud.mcheli.reloading": "Reloading", "hud.mcheli.resupply": "Empty, resupply with [%s]", + "item.mcheli.helicopter": "Helicopter", + "item.mcheli.plane": "Plane", + "item.mcheli.tank": "Tank", + "item.mcheli.vehicle": "Ground Vehicle", "itemGroup.mcheli.helicopters": "MCHeli Helicopters", "itemGroup.mcheli.planes": "MCHeli Planes", "itemGroup.mcheli.tanks": "MCHeli Tanks and Cars", @@ -51,6 +61,8 @@ "key.mcheli.weapon_mode": "Weapon Mode", "message.mcheli.content.import_failed": "MCHeli could not import the original vehicles: %s. See logs/latest.log.", "message.mcheli.content.missing": "MCHeli has no vehicles yet. Download %s from the MCHeli CurseForge page, put it in %s and restart the game.", + "message.mcheli.vehicle.disabled": "%s is disabled on this server", + "message.mcheli.vehicle.unknown": "This server has no vehicle called %s", "pack.mcheli.content": "MCHeli Content", "pack.mcheli.content.description": "Imported from your copy of MCHeli 1.0.3 by EMB4" } diff --git a/src/main/resources/assets/mcheli/lang/ja_jp.json b/common/src/main/resources/assets/mcheli/lang/ja_jp.json similarity index 89% rename from src/main/resources/assets/mcheli/lang/ja_jp.json rename to common/src/main/resources/assets/mcheli/lang/ja_jp.json index 9714e23..58d3ebd 100644 --- a/src/main/resources/assets/mcheli/lang/ja_jp.json +++ b/common/src/main/resources/assets/mcheli/lang/ja_jp.json @@ -3,6 +3,10 @@ "entity.mcheli.plane": "飛行機", "entity.mcheli.tank": "戦車", "entity.mcheli.vehicle": "地上兵器", + "item.mcheli.helicopter": "ヘリコプター", + "item.mcheli.plane": "飛行機", + "item.mcheli.tank": "戦車", + "item.mcheli.vehicle": "地上兵器", "itemGroup.mcheli.helicopters": "MCHeli ヘリコプター", "itemGroup.mcheli.planes": "MCHeli 飛行機", "itemGroup.mcheli.tanks": "MCHeli 戦車・車両", diff --git a/common/src/test/java/com/jnhprojects/mcheli/CommonBoundaryTest.java b/common/src/test/java/com/jnhprojects/mcheli/CommonBoundaryTest.java new file mode 100644 index 0000000..f5f407e --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/CommonBoundaryTest.java @@ -0,0 +1,49 @@ +package com.jnhprojects.mcheli; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The common sources are compiled into every platform module, so they may only use the JDK and + * the few libraries every supported Minecraft version ships (SLF4J, JSpecify, JOML). The common + * project already fails to compile otherwise; this test names the offending lines, and also + * catches fully qualified names, which an import check alone would miss. + */ +class CommonBoundaryTest { + private static final List FORBIDDEN = List.of( + "net.minecraft.", "net.minecraftforge.", "net.neoforged.", "net.fabricmc.", "com.mojang.", "org.lwjgl."); + + @Test + void commonSourcesStayFreeOfMinecraft() throws IOException { + Path root = Path.of(System.getProperty("mcheli.commonSources", "src/main/java")); + assertTrue(Files.isDirectory(root), "common sources not found at " + root.toAbsolutePath()); + + List offences = new ArrayList<>(); + try (Stream files = Files.walk(root)) { + for (Path file : files.filter(p -> p.toString().endsWith(".java")).sorted().toList()) { + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).trim(); + if (line.startsWith("//") || line.startsWith("*") || line.startsWith("/*")) { + continue; + } + for (String prefix : FORBIDDEN) { + if (line.contains(prefix)) { + offences.add(root.relativize(file) + ":" + (i + 1) + ": " + line); + } + } + } + } + } + assertEquals(List.of(), offences); + } +} diff --git a/src/test/java/com/jnhprojects/mcheli/TestResources.java b/common/src/test/java/com/jnhprojects/mcheli/TestResources.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/TestResources.java rename to common/src/test/java/com/jnhprojects/mcheli/TestResources.java diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/ContentRegistryTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/ContentRegistryTest.java new file mode 100644 index 0000000..4d774a5 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/content/ContentRegistryTest.java @@ -0,0 +1,90 @@ +package com.jnhprojects.mcheli.content; + +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Adding, replacing and reloading definitions while the game runs. Needs none of the original content. */ +class ContentRegistryTest { + + @Test + void addsAndReplacesByName() { + ContentRegistry registry = ContentRegistry.empty(); + HelicopterDefinition first = new HelicopterDefinition("scout"); + registry.add(first); + assertSame(first, registry.helicopter("scout")); + + HelicopterDefinition second = new HelicopterDefinition("scout"); + registry.add(second); + assertSame(second, registry.any("scout")); + assertEquals(1, registry.helicopters().size()); + } + + @Test + void aDefinitionThatChangesKindLeavesItsOldKind() { + ContentRegistry registry = ContentRegistry.empty(); + registry.add(new HelicopterDefinition("mule")); + registry.add(new TankDefinition("mule")); + + assertNull(registry.helicopter("mule")); + assertTrue(registry.any("mule") instanceof TankDefinition); + assertEquals(1, registry.all().size()); + } + + @Test + void everyChangeBumpsTheGeneration() { + ContentRegistry registry = ContentRegistry.empty(); + int start = registry.generation(); + registry.add(new HelicopterDefinition("scout")); + int afterAdd = registry.generation(); + assertNotEquals(start, afterAdd); + + assertTrue(registry.remove("scout")); + assertNotEquals(afterAdd, registry.generation()); + assertNull(registry.any("scout")); + + int beforeMiss = registry.generation(); + assertFalse(registry.remove("scout")); + assertEquals(beforeMiss, registry.generation()); + } + + @Test + void replaceWithSwapsEverythingAtOnce(@TempDir Path dir) throws IOException { + Path helicopters = Files.createDirectories(dir.resolve(HelicopterDefinition.FOLDER)); + Files.writeString(helicopters.resolve("Trainer.txt"), "DisplayName = Trainer\nAddSeat = 0, 1, 0\n"); + + ContentRegistry registry = ContentRegistry.empty(); + registry.add(new HelicopterDefinition("scout")); + int before = registry.generation(); + + registry.replaceWith(ContentRegistry.load(dir)); + + assertNotEquals(before, registry.generation()); + assertNull(registry.any("scout")); + assertEquals(List.of("trainer"), registry.all().stream().map(d -> d.name()).toList()); + } + + @Test + void readersKeepTheirViewDuringAChange() { + ContentRegistry registry = ContentRegistry.empty(); + registry.add(new HelicopterDefinition("a")); + var view = registry.helicopters(); + registry.add(new HelicopterDefinition("b")); + + assertEquals(1, view.size()); + assertEquals(2, registry.helicopters().size()); + } +} diff --git a/src/test/java/com/jnhprojects/mcheli/content/HelicopterDefinitionTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/HelicopterDefinitionTest.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/content/HelicopterDefinitionTest.java rename to common/src/test/java/com/jnhprojects/mcheli/content/HelicopterDefinitionTest.java diff --git a/src/test/java/com/jnhprojects/mcheli/content/LegacyImporterTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/LegacyImporterTest.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/content/LegacyImporterTest.java rename to common/src/test/java/com/jnhprojects/mcheli/content/LegacyImporterTest.java diff --git a/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java similarity index 66% rename from src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java rename to common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java index 8738bd8..41dafb3 100644 --- a/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/content/RecipeDataTest.java @@ -46,12 +46,40 @@ void ironFamiliesBecomeCommonTags() { "RYR" ], "result": { - "id": "mcheli:ah-6x" + "id": "mcheli:helicopter", + "components": { + "mcheli:vehicle": "ah-6x", + "minecraft:item_model": "mcheli:ah-6x" + } } } """, generated.files().get("ah-6x.json")); } + @Test + void aVehicleIngredientMatchesThatVehicleOnly() { + assertEquals(""" + { + "type": "minecraft:crafting_shaped", + "category": "misc", + "key": { + "R": "#c:dusts/redstone", + "X": {"type": "mcheli:vehicle", "vehicle": "ah-6"} + }, + "pattern": [ + "RXR" + ], + "result": { + "id": "mcheli:helicopter", + "components": { + "mcheli:vehicle": "ah-6x", + "minecraft:item_model": "mcheli:ah-6x" + } + } + } + """, generated.files().get("ah-6x_2.json")); + } + @Test void dyesKeepTheirColour() { assertEquals(""" @@ -72,7 +100,11 @@ void dyesKeepTheirColour() { "RXO" ], "result": { - "id": "mcheli:macchi_mc72" + "id": "mcheli:plane", + "components": { + "mcheli:vehicle": "macchi_mc72", + "minecraft:item_model": "mcheli:macchi_mc72" + } } } """, generated.files().get("macchi_mc72.json")); diff --git a/src/test/java/com/jnhprojects/mcheli/content/VehicleKindsTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/VehicleKindsTest.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/content/VehicleKindsTest.java rename to common/src/test/java/com/jnhprojects/mcheli/content/VehicleKindsTest.java diff --git a/src/test/java/com/jnhprojects/mcheli/content/WeaponDefinitionTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/WeaponDefinitionTest.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/content/WeaponDefinitionTest.java rename to common/src/test/java/com/jnhprojects/mcheli/content/WeaponDefinitionTest.java diff --git a/src/test/java/com/jnhprojects/mcheli/hud/ExpressionTest.java b/common/src/test/java/com/jnhprojects/mcheli/hud/ExpressionTest.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/hud/ExpressionTest.java rename to common/src/test/java/com/jnhprojects/mcheli/hud/ExpressionTest.java diff --git a/src/test/java/com/jnhprojects/mcheli/hud/HudScriptTest.java b/common/src/test/java/com/jnhprojects/mcheli/hud/HudScriptTest.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/hud/HudScriptTest.java rename to common/src/test/java/com/jnhprojects/mcheli/hud/HudScriptTest.java diff --git a/src/test/java/com/jnhprojects/mcheli/model/MeshLoaderTest.java b/common/src/test/java/com/jnhprojects/mcheli/model/MeshLoaderTest.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/model/MeshLoaderTest.java rename to common/src/test/java/com/jnhprojects/mcheli/model/MeshLoaderTest.java diff --git a/src/test/java/com/jnhprojects/mcheli/model/PartModelTest.java b/common/src/test/java/com/jnhprojects/mcheli/model/PartModelTest.java similarity index 100% rename from src/test/java/com/jnhprojects/mcheli/model/PartModelTest.java rename to common/src/test/java/com/jnhprojects/mcheli/model/PartModelTest.java diff --git a/src/test/java/com/jnhprojects/mcheli/entity/AircraftRotationTest.java b/common/src/test/java/com/jnhprojects/mcheli/physics/AircraftRotationTest.java similarity index 98% rename from src/test/java/com/jnhprojects/mcheli/entity/AircraftRotationTest.java rename to common/src/test/java/com/jnhprojects/mcheli/physics/AircraftRotationTest.java index 1eff9ff..2e8c383 100644 --- a/src/test/java/com/jnhprojects/mcheli/entity/AircraftRotationTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/physics/AircraftRotationTest.java @@ -1,4 +1,4 @@ -package com.jnhprojects.mcheli.entity; +package com.jnhprojects.mcheli.physics; import org.joml.Quaternionf; import org.joml.Vector3f; diff --git a/src/test/java/com/jnhprojects/mcheli/entity/GroundImpactTest.java b/common/src/test/java/com/jnhprojects/mcheli/physics/GroundImpactTest.java similarity index 95% rename from src/test/java/com/jnhprojects/mcheli/entity/GroundImpactTest.java rename to common/src/test/java/com/jnhprojects/mcheli/physics/GroundImpactTest.java index fa080fc..6c81d47 100644 --- a/src/test/java/com/jnhprojects/mcheli/entity/GroundImpactTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/physics/GroundImpactTest.java @@ -1,4 +1,4 @@ -package com.jnhprojects.mcheli.entity; +package com.jnhprojects.mcheli.physics; import org.junit.jupiter.api.Test; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 60e969f..57f06ca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Architecture This document explains how MCHeli Rework is organized and why. It reflects the code as of -`1.0.0-alpha.2`. +`1.1.0-beta.1`. ## Goals @@ -17,23 +17,30 @@ This document explains how MCHeli Rework is organized and why. It reflects the c ``` ┌────────────────────────────────────────────────────────────────┐ - │ client renderer, mesh cache, crew controller, sound, │ client only - │ HUD renderer │ + │ client renderer, mesh cache, runtime assets, crew │ client only + │ controller, sound, HUD renderer │ + ├────────────────────────────────────────────────────────────────┤ platform module + │ entity AircraftEntity and one subclass per kind │ (forge-26.3) + │ item AircraftItem, VehicleIngredient │ + │ network PilotStatePacket, CrewActionPacket, ... │ Minecraft + Forge + │ registry entities, items, components, creative tabs │ + │ (root) MCHeli, ServerConfig, McheliCommand, reload │ ├────────────────────────────────────────────────────────────────┤ - │ entity AircraftEntity and one subclass per kind │ - │ item AircraftItem │ common - │ network PilotStatePacket, CrewActionPacket │ (Minecraft + Forge) - │ registry entities, items, creative tabs │ - ├────────────────────────────────────────────────────────────────┤ - │ content Definition, DefinitionReader, ContentRegistry, │ - │ aircraft/* definitions │ pure Java - │ model Mesh, MqoLoader, ObjLoader │ + │ content Definition, DefinitionReader, ContentRegistry, │ common module + │ aircraft/* definitions, recipe conversion │ + │ model Mesh, MqoLoader, ObjLoader │ pure Java │ hud HudScript, Expression, HudVariable │ + │ physics AircraftRotation, GroundImpact │ └────────────────────────────────────────────────────────────────┘ ``` -Arrows only point down. `content`, `model` and `hud` import nothing from Minecraft or Forge (they use -their own `Vector3` and float arrays), which is what makes them unit testable. +Arrows only point down. The bottom half lives in the `common` Gradle module, which is compiled +and tested with nothing from Minecraft or Forge on its classpath (only the JDK, SLF4J, JSpecify +and JOML), so a stray import fails the build; `CommonBoundaryTest` also scans the sources. The top +half is a platform module, one per Minecraft version, which compiles `common`'s sources together +with its own into one jar. Ports to older versions (phase 7) add platform modules and leave +`common` alone; see [DEVELOPMENT.md](DEVELOPMENT.md#common-and-platform-modules). + `client` is only referenced from `MCHeli` behind a `Dist.CLIENT` check, so a dedicated server never loads a client class. @@ -47,17 +54,22 @@ MCHeli constructor ├─ LegacyContent.prepare import the original zip into mcheli/imported ├─ LegacyContent.registerPacks that folder as a required resource + data pack ├─ ContentRegistry.load(mcheli/imported/assets/mcheli) parse helicopters, planes, tanks, vehicles + ├─ registerConfig(SERVER, ServerConfig.SPEC) serverconfig/mcheli-server.toml + ├─ ModDataComponents.register mcheli:vehicle ├─ ModEntities.register one entity type per kind - ├─ ModItems.register(content) one item per definition + ├─ ModItems.register(content) one item per kind, plus the 1.0.0 items ├─ ModCreativeTabs.register + ├─ ModRecipes.register mcheli:vehicle ingredient, recipe book ├─ ModNetwork.register + ├─ McheliCommand.register /mcheli └─ ClientSetup.init (client only) renderers, keys, reload listener, controller ``` -Definitions must be parsed in the constructor because every vehicle gets its own item, and Forge -freezes the item registry before resources are available. The files are read straight from the -imported folder (see [Assets](#assets)). Models and textures, by contrast, go through the resource -manager, so resource packs can replace them. +Definitions are still parsed in the constructor, but only because the 1.0.0 items have to be +registered before Forge freezes the registries (see [Vehicle items](#vehicle-items)); nothing else +depends on them existing that early any more. The files are read straight from the imported +folder (see [Assets](#assets)). Models and textures, by contrast, go through the resource manager, +so resource packs can replace them. ## Content definitions @@ -83,7 +95,54 @@ Deliberate differences from the original loader: | Unknown keys were silently ignored | Unknown keys are collected and logged at debug | Shows which keys still need porting | | Read as UTF-8 only, mangling Shift-JIS | UTF-8 with a Windows-31J fallback | 15 shipped files are Shift-JIS | -`ContentRegistry` holds the loaded definitions. It is immutable after startup. +`ContentRegistry` holds the loaded definitions, and can change while the game runs: `add` +replaces or adds one definition, `remove` drops one, and `replaceWith` swaps in a whole freshly +loaded set. The maps sit in an immutable snapshot that writers copy and publish in one volatile +write, so readers never lock and the render and server threads of a single player game always +see a consistent set. Every change bumps `generation()`. `AircraftEntity.definition()` caches its +definition together with the generation it was looked up at, and looks it up again when the +number moves, rebuilding seats, parts and weapons while keeping the ammunition it carries. +`MCHeli.content()` is always the same object; reloads change what it holds. + +### Vehicle items + +1.0.0 registered one item per vehicle at startup, and Forge freezes the item registry before a +world loads, so a vehicle added later could never get an item. Since 1.1.0 there is one item per +kind (`mcheli:helicopter`, `mcheli:plane`, `mcheli:tank`, `mcheli:vehicle`). A stack names its +vehicle in the `mcheli:vehicle` data component and points its `minecraft:item_model` component at +the item model the import wrote for that vehicle, so it looks and reads as before. +`AircraftItem.stackOf(def)` is the one place that builds such a stack (creative tabs, `/mcheli +give`, pick block, packing a vehicle up). + +Existing worlds keep their items through a migration rather than a data fixer: the 1.0.0 ids +(`mcheli:ah-64` and so on) are still registered for the content present at startup, as +`LegacyAircraftItem`. They place vehicles as before, are not listed in any tab, and replace +themselves with the generic stack (count, name and other components kept) as soon as they tick in +a player's inventory. A stack in a chest converts when a player takes it out. + +Recipes craft the generic item with both components set. A vehicle used as an ingredient (the +AH-6X recipe takes an AH-6) becomes a `{"type": "mcheli:vehicle", "vehicle": "ah-6"}` ingredient, +`VehicleIngredient`, registered as a Forge ingredient serializer, which only matches stacks of that +vehicle. + +### Reloading content + +`/mcheli reload` (operators) runs `ContentReload`: it loads the imported folder again into a new +registry and, if that worked, `replaceWith`s it into the live one, then sends +`ContentReloadPacket` to every player. Vehicles in the world pick up their new definition on +their next tick. On the client, `ClientContentReload` reloads the client's own registry (except +in single player, where client and integrated server share it), clears the mesh cache and glass +splits, uploads every vehicle texture again from disk (`RuntimeAssets.refreshTextures`), and +rebuilds the creative tabs. Until content packs sync definitions (phase 6), a dedicated server and +its clients each read their own files. + +### Server config and command + +`ServerConfig` is a Forge `SERVER` config, `serverconfig/mcheli-server.toml` in each world: +explosions breaking blocks and starting fires, a damage factor for vehicles, infinite ammunition, +and a list of disabled vehicles. Only the server applies these rules; the getters return the +defaults (the 1.0.0 behaviour) when no world is loaded. `McheliCommand` registers `/mcheli` +(content status), `/mcheli list [kind]`, and for operators `/mcheli give` and `/mcheli reload`. Recipes are data in 26.x, so the definitions' `AddRecipe` and `AddShapelessRecipe` lines are turned into `data/mcheli/recipe/.json` during the import by `content.recipe.LegacyRecipe`, @@ -353,8 +412,29 @@ supply the original 1.0.3 zip, and the mod converts it on their machine: `content.LegacyImporter` is pure Java and does the conversion: lower-case paths (26.x identifiers forbid upper case and `#`), the singular `textures/item` and `textures/block` folders, JSON language files, `sounds.json` with the original grouping rule, an item model per item texture, and -the recipes. It writes into `imported.tmp` and swaps the folder in only when it succeeds. -Bumping `LegacyImporter.FORMAT` makes every install convert again. +the recipes. It writes into `imported.tmp` and swaps the folder in only when it succeeds: the +old import is first moved to `imported.old` and deleted last, and the renames are retried for a +few seconds, because on Windows a just-deleted folder keeps its name while a file watcher or +virus scanner still holds a handle in it. Bumping `LegacyImporter.FORMAT` makes every install +convert again (1.1.0-beta.1 bumped it to 2 for the generic item recipes). + +### Runtime assets + +Vehicle content can change while the game runs (`/mcheli reload` now, content packs and the +workshop later), and a resource reload is slow and does not exist on a server. The client +therefore reads vehicle assets through `client.RuntimeAssets`: the resource manager first, so +resource packs keep the last word, then any number of runtime roots, folders laid out like +`assets/mcheli` that content can register at any time. + +- **Models**: `MeshCache` opens them through `RuntimeAssets.open`; clearing the cache is enough + for edited or new files to load on the next frame. +- **Textures**: the renderer asks `RuntimeAssets.texture(id)` for every texture it binds. A + texture only a runtime root has is uploaded as a `DynamicTexture` on first use; + `refreshTextures` uploads every vehicle texture again from disk. A resource reload releases + ours so the texture manager takes over again. +- **Sounds**: `RuntimeSoundInstance` (the engine loop extends it) plays through the sound manager + when a resource pack defines the event, and otherwise streams the OGG file a runtime root holds + for it through Forge's `SoundInstance.getStream` hook, with no `sounds.json` entry and no reload. `LegacyContent` adds the folder through `AddPackFindersEvent` for both pack types, as a required pack at the bottom of the list: it is enabled in every world, cannot be turned off, and regular @@ -370,12 +450,13 @@ The repository holds none of the original files. The unit tests get them the way `TestResources` imports the zip from `Mod/` once into `build/legacy-content`, so the tests run against exactly what the importer produces. Where the zip is missing (CI), the content tests are skipped and only the tests that build their input in code run. As a guard, the `jar` task still -excludes everything under `assets/mcheli` except `lang`, since `build/sourceSets/main` can hold -copies from older builds. +excludes everything under `assets/mcheli` except `lang` and the generic items' fallback models, +since `build/sourceSets/main` can hold copies from older builds. ## Testing -Unit tests live in `src/test` and run with `./gradlew test`. Apart from targeted cases, they load +Unit tests live in `common/src/test` and run with `./gradlew :common:test` (or `build`). Platform +code has no unit tests; it is covered in game. Apart from targeted cases, the tests load every shipped helicopter definition and model and check that each rotor part referenced by a definition exists in its model. New content is validated simply by adding it. In-game behavior is covered by the manual plan in [TESTING.md](TESTING.md). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index da1e572..3421dcd 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -17,25 +17,54 @@ other process to finish and build again. ## Common tasks ``` -./gradlew build # compile, run unit tests, produce build/libs/mcheli-26.3-.jar -./gradlew test # unit tests only -./gradlew runClient # start a development client from ./run -./gradlew runServer # start a development dedicated server from ./run +./gradlew build # compile and test every module, one jar per Minecraft version in build/release +./gradlew :common:test # unit tests (they all live in common) +./gradlew :forge-26.3:runClient # start a development client from ./run +./gradlew :forge-26.3:runServer # start a development dedicated server from ./run ``` -Project properties live in `gradle.properties` (`mod_version`, Forge and Minecraft versions). +Shared properties live in the root `gradle.properties`: `mod_version`, the mod metadata, the list +of platform modules (`platforms`) and the library versions `common` compiles against. Each +platform module has its own `gradle.properties` with its Minecraft, loader and Java versions. `mods.toml` and `pack.mcmeta` are filled in from them at build time. ## Repository layout ``` -src/main/java/com/jnhprojects/mcheli mod sources, see ARCHITECTURE.md -src/main/resources the rework's own lang keys, damage type and metadata -src/test/java unit tests -docs/ documentation and content format guides -.github/workflows CI and release pipelines +common/ version-free code, built and tested without Minecraft + src/main/java content, model, hud, physics (see ARCHITECTURE.md) + src/main/resources lang files shared by every platform + src/test/java all unit tests, including the boundary test +forge-26.3/ the Minecraft 26.3 / Forge 66 platform module + src/main/java rendering, networking, registration, entities, events + src/main/resources mods.toml, pack.mcmeta, item models, damage type data + gradle.properties Minecraft, Forge and Java versions for this module +docs/ documentation and content format guides +.github/workflows CI and release pipelines ``` +### Common and platform modules + +1.1.0 brings older Minecraft versions, so the code is split in two: + +- **`common`** holds everything that does not depend on the game version: definition parsing, + the content registry, model loading, the HUD expression language, recipe conversion, rotation + and impact maths. Its classpath has only the JDK, SLF4J, JSpecify and JOML (all shipped by every + supported Minecraft version), so a stray Minecraft import fails to compile, and + `CommonBoundaryTest` names the offending line. +- **A platform module per Minecraft version** (`forge-26.3` today) holds the glue: entities, + rendering, networking, registration, config, commands. It compiles `common`'s sources together + with its own, so each version ships as one jar and runs as one module in development. + +A feature that is pure logic goes in `common` with a unit test; only the part that touches +Minecraft goes in the platform module. Adding a version means adding a module directory, listing +it in `platforms`, and porting the platform code; `common` should not need to change. The run +folder `./run` is shared for now; later modules will get their own. + +IDEs import the project as three Gradle projects (root, `common`, `forge-26.3`). After pulling the +split, re-import the Gradle project so the IDE picks up the new source folders and run +configurations; the old root-level `run*.launch` files point at a project that no longer exists. + `Mod/` holds the original `MC1.7.10_mcheli_1.0.3.zip`. It is git-ignored. Download it from the [MCHeli CurseForge page](https://www.curseforge.com/minecraft/mc-mods/mcheli-minecraft-helicopter-mod/files) and put it there: `runClient` and `runServer` import it into `run/mcheli/imported` through the @@ -54,8 +83,8 @@ locally with the zip before a release. ## Continuous integration `.github/workflows/build.yml` runs `./gradlew build` on every push to `main` and on every pull -request. The jar is attached to the run as an artifact for 14 days; test reports are uploaded -when the build fails. +request, which builds and tests every module. The jars from `build/release` are attached to the +run as an artifact for 14 days; test reports are uploaded when the build fails. ## Releasing @@ -75,7 +104,9 @@ Releases are cut from tags. The release workflow refuses to publish when the tag ``` `.github/workflows/release.yml` then builds and tests the tag, extracts the matching -`CHANGELOG.md` section as release notes, and publishes a GitHub release with the jar. Versions +`CHANGELOG.md` section as release notes, and publishes a GitHub release with one jar per +Minecraft version (`mcheli--.jar`, gathered by the `releaseJars` task into +`build/release`). One tag always releases every platform module at the same mod version. Versions containing a hyphen (`-alpha.N`, `-beta.N`, `-rc.N`) are marked as pre-releases. Every release, pre-releases included, is mirrored to the public repository and tagged there too. @@ -87,17 +118,18 @@ releases page. See [the release channels](ROADMAP.md#release-channels). Neither the repository nor the jar may contain EMB4's files. Don't commit anything from the zip or from `run/mcheli/imported`. As a guard, the `jar` task excludes everything under -`assets/mcheli` except `lang`. After a build, check with: +`assets/mcheli` except `lang` and the four fallback item models of the generic vehicle items. +After a build, check every jar with: ``` -unzip -l build/libs/mcheli-26.3-.jar | grep -v "com/" +for jar in build/release/*.jar; do unzip -l "$jar" | grep -v "com/"; done ``` -which should list only the manifest, `mods.toml`, `pack.mcmeta`, the two lang files and the -damage type data. +which should list only the manifest, `mods.toml`, `pack.mcmeta`, the two lang files, the four +generic item models and the damage type data. When the conversion changes, change `content.LegacyImporter` and bump its `FORMAT`, so existing installs, the dev run and the tests all convert again. Keys the rework adds go into the lang files -in `src/main/resources`; `LegacyImporterTest` fails if one reuses a key the import writes. +in `common/src/main/resources`; `LegacyImporterTest` fails if one reuses a key the import writes. -To force a fresh import, delete `run/mcheli/imported` (dev run) or `build/legacy-content` (tests). +To force a fresh import, delete `run/mcheli/imported` (dev run) or `common/build/legacy-content` (tests). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 017948a..b1b402c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -131,7 +131,7 @@ Rules that hold for every beta: | Phase | Version | Theme | State | |-------|---------|-------|-------| -| 5 | `1.1.0-beta.1` | Groundwork: runtime registry, config, multi-version build | Planned | +| 5 | `1.1.0-beta.1` | Groundwork: runtime registry, config, multi-version build | Released | | 6 | `1.1.0-beta.2` | Content packs and multiplayer sync | Planned | | 7 | `1.1.0-beta.3` | Older Minecraft versions | Planned | | 8 | `1.1.0-beta.4` | Equipment and the rest of phase 4 | Planned | @@ -150,18 +150,19 @@ of back-porting four phases of work at the end. Engine changes that packs, ports and the workshop all depend on. Players mostly see the config and the command. -- [ ] One generic vehicle item per kind, carrying the definition id in a data component, with a - data fixer so 1.0.0 worlds keep their items ([why](#one-generic-vehicle-item-per-kind)) -- [ ] `ContentRegistry` can add and replace definitions while the game runs -- [ ] Runtime client assets: textures through `DynamicTexture`, sounds through a streaming +- [x] One generic vehicle item per kind, carrying the definition id in a data component, with a + migration so 1.0.0 worlds keep their items ([why](#one-generic-vehicle-item-per-kind)): the + old item ids stay registered and convert themselves in a player's inventory +- [x] `ContentRegistry` can add and replace definitions while the game runs +- [x] Runtime client assets: textures through `DynamicTexture`, sounds through a streaming `SoundInstance`, models through `MeshCache`, all without a resource reload -- [ ] In-game definition reload, built on the runtime registry -- [ ] Server config (`mcheli.cfg` equivalent) and the `/mcheli` command -- [ ] Source split: a `common` source set with the version-free code (`content`, `model`, `hud`, +- [x] In-game definition reload, built on the runtime registry +- [x] Server config (`mcheli.cfg` equivalent) and the `/mcheli` command +- [x] Source split: a `common` source set with the version-free code (`content`, `model`, `hud`, the expression library, weapon and vehicle definitions, flight and damage maths) and one platform module per Minecraft version for rendering, networking, registration and events. Minecraft classes stay out of `common`, enforced by a test. -- [ ] Multi-version Gradle build: one tag builds every platform module, and the release attaches +- [x] Multi-version Gradle build: one tag builds every platform module, and the release attaches one jar per Minecraft version (`mcheli--.jar`). Release workflow and [DEVELOPMENT.md](DEVELOPMENT.md#releasing) updated; 26.3 is still the only module. @@ -260,7 +261,8 @@ No new features after beta.6; a `1.1.0-rc.N` follows only if beta.6 testing find Every vehicle currently registers its own item at startup, and Forge freezes the registry before a world loads, so a vehicle created or received later could never get an item. The item carries the definition id in a data component instead; the entities already work this way (one entity -type per kind, definition name synced). Existing worlds keep their items through a data fixer. +type per kind, definition name synced). Existing worlds keep their items through a migration +(see [ARCHITECTURE.md](ARCHITECTURE.md#vehicle-items)). ### Vehicle packages diff --git a/docs/TESTING.md b/docs/TESTING.md index de49c41..62607ef 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -5,9 +5,9 @@ hand with this plan before a pre-release is tagged. ## Setup -1. `./gradlew build`, then copy `build/libs/mcheli-26.3-.jar` into the `mods` folder of +1. `./gradlew build`, then copy `build/release/mcheli-26.3-.jar` into the `mods` folder of the Forge 26.3 profile and `MC1.7.10_mcheli_1.0.3.zip` into its `mcheli` folder. - Alternatively run `./gradlew runClient`, which imports the zip from `Mod/`. + Alternatively run `./gradlew :forge-26.3:runClient`, which imports the zip from `Mod/`. 2. Use a launcher profile with its own game directory. Mods for other loaders or Minecraft versions in the same `mods` folder will prevent Forge from starting. 3. Create a creative world with cheats enabled. @@ -59,7 +59,8 @@ hand with this plan before a pre-release is tagged. ### Loading - [ ] The log contains `Loaded 33 helicopters, 51 planes, 12 tanks and 16 vehicles` and no - `mcheli` errors or warnings. A dedicated server (`./gradlew runServer`) starts as well. + `mcheli` errors or warnings. A dedicated server (`./gradlew :forge-26.3:runServer`) starts as + well. - [ ] Four creative tabs: helicopters (33), planes (51), tanks and cars (12), ground vehicles (16). - [ ] The new key bindings show under Controls, category MCHeli, without conflicts. @@ -242,6 +243,68 @@ Use the built jar in a launcher profile, not `runClient`, since only that shows - [ ] A resource pack replacing `assets/mcheli/textures/helicopters/ah-64.png` wins over the import. - [ ] Dedicated server with the zip: clients with the zip join without a registry mismatch. +## Phase 5 checklist (`1.1.0-beta.1`) + +Run each section in single player and again on a dedicated server (`./gradlew +:forge-26.3:runServer`, a client joining it), unless it says otherwise. + +### Upgrading from 1.0.0 + +Keep a copy of a 1.0.0 world with vehicles parked in it, items in the hotbar and items in a chest. + +- [ ] The first start of the beta logs `Importing MCHeli content` once (importer format 2); the + next start imports nothing. No `imported.old` or `imported.tmp` folder is left behind. +- [ ] The parked vehicles load, fly and keep their damage and ammunition. +- [ ] Hotbar items keep their icon and name, and within a second turn into the new item (F3 + H + shows `mcheli:helicopter` and a `mcheli:vehicle` component). They still place the vehicle. +- [ ] Items in the chest are the old ones until taken out, then convert. A renamed item keeps its + name. + +### Vehicle items + +- [ ] Each creative tab lists every vehicle of its kind with the right icon and name, in both + English and Japanese. +- [ ] Placing, packing up (punch an empty vehicle) and pick block (middle click) all give back the + same vehicle. +- [ ] Crafting: the AH-6 recipe makes an AH-6; the AH-6X recipe from an AH-6 accepts only an AH-6, + not another helicopter, and the recipe book shows the AH-6 in that slot. +- [ ] Two different helicopters never stack together. + +### Command + +- [ ] `/mcheli` prints the counts (33 helicopters, 51 planes, 12 tanks, 16 ground vehicles, 229 + weapons) and the folder. `/mcheli list tank` lists the 12 tanks. +- [ ] `/mcheli give @s ah-64 2` gives two AH-64s; tab completion suggests vehicle names; an + unknown name is refused. A non-operator on the server cannot use `give` or `reload`. + +### Reload + +- [ ] Edit `run/mcheli/imported/assets/mcheli/helicopters/ah-64.txt` (for example + `MaxHp = 1000`), run `/mcheli reload`: chat confirms, and a placed AH-64 shows the new HP + maximum without being replaced, and keeps its loaded ammunition. +- [ ] Edit the AH-64 texture PNG, `/mcheli reload`: the new texture shows without F3 + T. F3 + T + afterwards still shows it, loaded the normal way. +- [ ] Add a copy of a helicopter definition under a new file name, `/mcheli reload`: it appears + in `/mcheli list` and `/mcheli give` (its creative tab entry too, after reopening the + inventory). Remove it again and reload: placed copies stay as wrecks of an unknown vehicle + and do not crash the game. +- [ ] A definition with a broken line still reloads; the line is reported in the log. + +### Server config + +- [ ] `serverconfig/mcheli-server.toml` is created in the world folder with comments. +- [ ] `explosions.breakBlocks = false`: rockets and crashes no longer break blocks, but still hurt. + `startFires = false`: incendiary hits leave no fire. +- [ ] `vehicles.damageFactor = 0.5`: a vehicle takes half the hits to destroy. +- [ ] `vehicles.disabled = ["ah-64"]`: placing an AH-64 fails with a message; other vehicles work. +- [ ] `weapons.infiniteAmmo = true`: a survival player never runs out. +- [ ] Edits to the file apply without restarting the server. + +### Build + +- [ ] `./gradlew build` produces `build/release/mcheli-26.3-1.1.0-beta.1.jar`; it holds no EMB4 + file (DEVELOPMENT.md, "Original content"). + ## Reporting problems Attach `logs/latest.log` (and the crash report, if any) with the helicopter name, what you did diff --git a/docs/images/phase-5/patrol-boat-cook-off.png b/docs/images/phase-5/patrol-boat-cook-off.png new file mode 100644 index 0000000..74c1389 Binary files /dev/null and b/docs/images/phase-5/patrol-boat-cook-off.png differ diff --git a/docs/images/phase-5/patrol-boat-wreck.png b/docs/images/phase-5/patrol-boat-wreck.png new file mode 100644 index 0000000..71e1976 Binary files /dev/null and b/docs/images/phase-5/patrol-boat-wreck.png differ diff --git a/forge-26.3/build.gradle b/forge-26.3/build.gradle new file mode 100644 index 0000000..8c43f64 --- /dev/null +++ b/forge-26.3/build.gradle @@ -0,0 +1,103 @@ +plugins { + id 'java' + id 'idea' + id 'eclipse' + id 'net.minecraftforge.gradle' +} + +version = mod_version +group = mod_group + +base { + archivesName = "mcheli-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(java_version as int) + +// The shared code is compiled into this module rather than depended on, so the mod stays one +// jar and one module in development runs. The common project checks on its own that these +// sources need nothing from Minecraft. +def common = project(':common') +sourceSets.main.java.srcDir(common.file('src/main/java')) +sourceSets.main.resources.srcDir(common.file('src/main/resources')) + +// The original MCHeli 1.0.3 download. Players supply their own copy; development runs use this +// one (git-ignored, see docs/DEVELOPMENT.md). +def legacyZip = rootProject.file('Mod/MC1.7.10_mcheli_1.0.3.zip').absolutePath + +minecraft { + runs { + configureEach { + workingDir = rootProject.layout.projectDirectory.dir('run') + systemProperty 'eventbus.api.strictRuntimeChecks', 'true' + systemProperty 'mcheli.legacyZip', legacyZip + } + + register('client') + + register('server') { + args '--nogui' + } + } +} + +repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + mavenCentral() +} + +dependencies { + implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") + annotationProcessor "net.minecraftforge:eventbus-validator:${eventbus_version}" +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.compilerArgs.addAll(['-Xlint:deprecation', '-Xlint:unchecked']) +} + +def expandProps = [ + mod_id : mod_id, + mod_name : mod_name, + mod_version : version, + mod_authors : mod_authors, + mod_license : mod_license, + minecraft_version_range: minecraft_version_range, + forge_version_range : forge_version_range, + loader_version_range : loader_version_range, +] + +tasks.named('processResources', ProcessResources) { + inputs.properties(expandProps) + filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { + expand expandProps + } +} + +tasks.named('jar', Jar) { + // EMB4's content is never redistributed: players import it from their own copy of the original + // zip (LegacyContent). The repository no longer holds it, but the jar is packed from + // build/sourceSets/main, which can still have copies from older builds and from the IDE. + def shipped = ['assets/mcheli/items/helicopter.json', 'assets/mcheli/items/plane.json', + 'assets/mcheli/items/tank.json', 'assets/mcheli/items/vehicle.json'] + exclude { FileTreeElement element -> + String path = element.relativePath.pathString + !element.directory && path.startsWith('assets/mcheli/') && !path.startsWith('assets/mcheli/lang/') && + !shipped.contains(path) + } + exclude 'data/mcheli/recipe/**' + includeEmptyDirs = false + manifest { + attributes([ + 'Specification-Title' : mod_id, + 'Specification-Vendor' : 'JnH Projects', + 'Specification-Version' : '1', + 'Implementation-Title' : mod_name, + 'Implementation-Version': version, + 'Implementation-Vendor' : 'JnH Projects', + 'Minecraft-Version' : minecraft_version, + ]) + } +} diff --git a/forge-26.3/gradle.properties b/forge-26.3/gradle.properties new file mode 100644 index 0000000..286e31a --- /dev/null +++ b/forge-26.3/gradle.properties @@ -0,0 +1,8 @@ +# Minecraft 26.3 on Forge +minecraft_version=26.3 +minecraft_version_range=[26.3,26.4) +forge_version=66.1.0 +forge_version_range=[66,) +loader_version_range=[66,) +eventbus_version=7.0.6 +java_version=25 diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java new file mode 100644 index 0000000..e451476 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java @@ -0,0 +1,33 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.network.ContentReloadPacket; +import com.jnhprojects.mcheli.network.ModNetwork; +import net.minecraft.server.MinecraftServer; + +import java.io.IOException; + +/** + * Reads the vehicle and weapon definitions from disk again while the game runs, for + * {@code /mcheli reload}. Vehicles in the world pick up their new definition on their next tick + * (see {@code AircraftEntity#definition}); clients are told to refresh models and textures. + * + *

Until content packs sync definitions (phase 6), each side reads its own copy of the imported + * folder: on a dedicated server, clients only see the change if their files changed the same way. + */ +public final class ContentReload { + private ContentReload() { + } + + /** + * @return the newly loaded content, already in use + * @throws IOException when the folder could not be read; the old content then stays in place + */ + public static ContentRegistry reload(MinecraftServer server) throws IOException { + ContentRegistry fresh = ContentRegistry.load(MCHeli.legacyContent().assets()); + MCHeli.content().replaceWith(fresh); + MCHeli.LOGGER.info("Content reloaded: {} vehicles, {} weapons", fresh.all().size(), fresh.weapons().size()); + ModNetwork.sendToAll(server, new ContentReloadPacket()); + return fresh; + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/LegacyContent.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java similarity index 80% rename from src/main/java/com/jnhprojects/mcheli/LegacyContent.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java index d3897b8..b98c15e 100644 --- a/src/main/java/com/jnhprojects/mcheli/LegacyContent.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/LegacyContent.java @@ -16,6 +16,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.UncheckedIOException; +import java.nio.file.FileSystemException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; @@ -118,8 +119,7 @@ private void importIfNeeded() throws IOException { try (OutputStream out = Files.newOutputStream(staging.resolve(MARKER))) { source.store(out, "Written by MCHeli Rework. Delete this folder to import again."); } - deleteTree(packRoot); - Files.move(staging, packRoot); + swapIn(staging); MCHeli.LOGGER.info("Imported {} files, {} sounds, {} item models and {} recipes into {}", summary.files(), summary.sounds(), summary.itemModels(), summary.recipes(), packRoot); @@ -128,6 +128,56 @@ private void importIfNeeded() throws IOException { } } + /** + * Replaces the previous import with the fresh one in {@code staging}. + * + *

On Windows a folder that was just deleted keeps its name for a moment while another + * program (a file watcher, a virus scanner) still has a handle in it, and renaming onto that + * name fails with "access denied". So the old import is moved out of the way rather than + * deleted, the renames are retried for a few seconds, and the old copy is deleted last, on a + * best effort basis; a copy left behind is removed on the next import. + */ + private void swapIn(Path staging) throws IOException { + Path old = folder.resolve("imported.old"); + deleteQuietly(old); + if (Files.exists(packRoot)) { + try { + moveWithRetry(packRoot, old); + } catch (IOException e) { + deleteTree(packRoot); + } + } + moveWithRetry(staging, packRoot); + deleteQuietly(old); + } + + private static void moveWithRetry(Path from, Path to) throws IOException { + for (int attempt = 1; ; attempt++) { + try { + Files.move(from, to); + return; + } catch (FileSystemException e) { + if (attempt >= 15) { + throw e; + } + try { + Thread.sleep(50L * attempt); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + } + + private static void deleteQuietly(Path root) { + try { + deleteTree(root); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not delete {}; it is removed on the next import", root, e); + } + } + private @Nullable Path findZip() throws IOException { String configured = System.getProperty("mcheli.legacyZip"); if (configured != null && !configured.isBlank()) { diff --git a/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java similarity index 81% rename from src/main/java/com/jnhprojects/mcheli/MCHeli.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java index b37d116..fd1a754 100644 --- a/src/main/java/com/jnhprojects/mcheli/MCHeli.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -4,12 +4,14 @@ import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.network.ModNetwork; import com.jnhprojects.mcheli.registry.ModCreativeTabs; +import com.jnhprojects.mcheli.registry.ModDataComponents; import com.jnhprojects.mcheli.registry.ModEntities; import com.jnhprojects.mcheli.registry.ModItems; import com.jnhprojects.mcheli.registry.ModRecipes; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.eventbus.api.bus.BusGroup; import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.config.ModConfig; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.loading.FMLEnvironment; import net.minecraftforge.fml.loading.FMLPaths; @@ -34,18 +36,24 @@ public MCHeli(FMLJavaModLoadingContext context) { legacyContent = LegacyContent.prepare(FMLPaths.GAMEDIR.get()); legacyContent.registerPacks(); content = loadContent(legacyContent.assets()); + context.registerConfig(ModConfig.Type.SERVER, ServerConfig.SPEC); + ModDataComponents.register(modBus); ModEntities.register(modBus); ModItems.register(modBus, content); ModCreativeTabs.register(modBus); - ModRecipes.register(); + ModRecipes.register(modBus); ModNetwork.register(); + McheliCommand.register(); if (FMLEnvironment.dist == Dist.CLIENT) { ClientSetup.init(modBus); } } - /** Vehicle definitions imported from the original zip. Available from mod construction onwards. */ + /** + * Vehicle and weapon definitions, imported from the original zip. Available from mod + * construction onwards. Always the same object; a reload changes what it holds. + */ public static ContentRegistry content() { return content; } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java new file mode 100644 index 0000000..79f8b97 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -0,0 +1,120 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Prediction; +import net.minecraftforge.event.RegisterCommandsEvent; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * {@code /mcheli}: what content is loaded, and operator tools to hand out vehicles and reload the + * definitions. + *

+ *   /mcheli                              content source and counts
+ *   /mcheli list [helicopter|plane|...]  loaded vehicles
+ *   /mcheli give <players> <vehicle> [count]   (operators)
+ *   /mcheli reload                       re-read the definitions (operators)
+ * 
+ */ +public final class McheliCommand { + private static final List KINDS = List.of("helicopter", "plane", "tank", "vehicle"); + private static final DynamicCommandExceptionType UNKNOWN_VEHICLE = + new DynamicCommandExceptionType(name -> Component.translatable("commands.mcheli.unknown_vehicle", name)); + + private McheliCommand() { + } + + public static void register() { + RegisterCommandsEvent.BUS.addListener(event -> register(event.getDispatcher())); + } + + private static void register(CommandDispatcher dispatcher) { + dispatcher.register(Commands.literal(MCHeli.MOD_ID) + .executes(McheliCommand::status) + .then(Commands.literal("list") + .executes(ctx -> list(ctx, null)) + .then(Commands.argument("kind", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(KINDS, builder)) + .executes(ctx -> list(ctx, StringArgumentType.getString(ctx, "kind"))))) + .then(Commands.literal("give") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.argument("targets", EntityArgument.players()) + .then(Commands.argument("vehicle", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest( + MCHeli.content().all().stream().map(AircraftDefinition::name), builder)) + .executes(ctx -> give(ctx, 1)) + .then(Commands.argument("count", IntegerArgumentType.integer(1, 64)) + .executes(ctx -> give(ctx, IntegerArgumentType.getInteger(ctx, "count"))))))) + .then(Commands.literal("reload") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(McheliCommand::reload))); + } + + private static int status(CommandContext ctx) { + ContentRegistry content = MCHeli.content(); + CommandSourceStack source = ctx.getSource(); + MCHeli.legacyContent().problem().ifPresent(source::sendFailure); + source.sendSuccess(() -> Component.translatable("commands.mcheli.status", + content.helicopters().size(), content.planes().size(), content.tanks().size(), content.vehicles().size(), + content.weapons().size(), MCHeli.legacyContent().assets().toString()), false); + return content.all().size(); + } + + private static int list(CommandContext ctx, String kind) { + List defs = MCHeli.content().all().stream() + .filter(def -> kind == null || def.kind().equals(kind)) + .toList(); + String names = defs.stream().map(AircraftDefinition::name).collect(Collectors.joining(", ")); + ctx.getSource().sendSuccess(() -> Component.translatable("commands.mcheli.list", defs.size(), names), false); + return defs.size(); + } + + private static int give(CommandContext ctx, int count) throws CommandSyntaxException { + String name = StringArgumentType.getString(ctx, "vehicle"); + AircraftDefinition def = MCHeli.content().any(name); + if (def == null) { + throw UNKNOWN_VEHICLE.create(name); + } + Collection targets = EntityArgument.getPlayers(ctx, "targets"); + for (ServerPlayer player : targets) { + // Vehicle items do not stack, so each one is a separate stack. + for (int i = 0; i < count; i++) { + player.getInventory().placeItemBackInInventory(AircraftItem.stackOf(def), Prediction.SERVER_ONLY); + } + } + Component vehicle = AircraftItem.stackOf(def).getHoverName(); + ctx.getSource().sendSuccess(() -> Component.translatable("commands.mcheli.give", count, vehicle, targets.size()), true); + return targets.size(); + } + + private static int reload(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + try { + ContentRegistry fresh = ContentReload.reload(source.getServer()); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reload.success", + fresh.all().size(), fresh.weapons().size()), true); + return fresh.all().size(); + } catch (IOException e) { + MCHeli.LOGGER.error("Could not reload the MCHeli content", e); + source.sendFailure(Component.translatable("commands.mcheli.reload.failed", e.getMessage())); + return 0; + } + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/ModSounds.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/ModSounds.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/ModSounds.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java new file mode 100644 index 0000000..364bccd --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -0,0 +1,88 @@ +package com.jnhprojects.mcheli; + +import net.minecraftforge.common.ForgeConfigSpec; + +import java.util.List; +import java.util.Locale; + +/** + * Server rules, the part of the original {@code mcheli.cfg} that decides how the game plays. + * Forge keeps the file per world in {@code serverconfig/mcheli-server.toml}, sends nothing to + * clients (only the server applies these rules) and picks up edits while the server runs. + * + *

The values only exist while a world is loaded. Before that, and on clients, the getters return + * the defaults, which match 1.0.0. + */ +public final class ServerConfig { + public static final ForgeConfigSpec SPEC; + + private static final ForgeConfigSpec.BooleanValue EXPLOSIONS_BREAK_BLOCKS; + private static final ForgeConfigSpec.BooleanValue EXPLOSIONS_START_FIRES; + private static final ForgeConfigSpec.DoubleValue VEHICLE_DAMAGE_FACTOR; + private static final ForgeConfigSpec.BooleanValue INFINITE_AMMO; + private static final ForgeConfigSpec.ConfigValue> DISABLED_VEHICLES; + + static { + ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); + + builder.push("explosions"); + EXPLOSIONS_BREAK_BLOCKS = builder + .comment("Whether weapons and crashing vehicles break blocks. The mobGriefing game rule also applies.", + "Original: Explosion_DestroyBlock") + .define("breakBlocks", true); + EXPLOSIONS_START_FIRES = builder + .comment("Whether incendiary weapons and crashes set blocks on fire. Original: Explosion_FlamingBlock") + .define("startFires", true); + builder.pop(); + + builder.push("vehicles"); + VEHICLE_DAMAGE_FACTOR = builder + .comment("Multiplies the damage every vehicle takes, after armour. 0.5 makes them twice as tough.") + .defineInRange("damageFactor", 1.0, 0.0, 100.0); + DISABLED_VEHICLES = builder + .comment("Vehicles that cannot be placed, by definition name (the file name, such as \"ah-64\").", + "Vehicles already in the world stay.") + .defineListAllowEmpty("disabled", List.of(), o -> o instanceof String); + builder.pop(); + + builder.push("weapons"); + INFINITE_AMMO = builder + .comment("Whether vehicle weapons never run out of ammunition. Original: InfinityAmmo") + .define("infiniteAmmo", false); + builder.pop(); + + SPEC = builder.build(); + } + + private ServerConfig() { + } + + public static boolean explosionsBreakBlocks() { + return SPEC.isLoaded() ? EXPLOSIONS_BREAK_BLOCKS.get() : EXPLOSIONS_BREAK_BLOCKS.getDefault(); + } + + public static boolean explosionsStartFires() { + return SPEC.isLoaded() ? EXPLOSIONS_START_FIRES.get() : EXPLOSIONS_START_FIRES.getDefault(); + } + + public static float vehicleDamageFactor() { + double factor = SPEC.isLoaded() ? VEHICLE_DAMAGE_FACTOR.get() : VEHICLE_DAMAGE_FACTOR.getDefault(); + return (float) factor; + } + + public static boolean infiniteAmmo() { + return SPEC.isLoaded() ? INFINITE_AMMO.get() : INFINITE_AMMO.getDefault(); + } + + public static boolean isDisabled(String vehicle) { + if (!SPEC.isLoaded()) { + return false; + } + for (String name : DISABLED_VEHICLES.get()) { + if (name.trim().toLowerCase(Locale.ROOT).equals(vehicle)) { + return true; + } + } + return false; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java new file mode 100644 index 0000000..3f0735a --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java @@ -0,0 +1,41 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.render.MeshCache; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.registry.ModCreativeTabs; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.CreativeModeTab; + +import java.io.IOException; + +/** The client half of {@code /mcheli reload}. */ +final class ClientContentReload { + private ClientContentReload() { + } + + static void run() { + Minecraft mc = Minecraft.getInstance(); + // In single player the integrated server already swapped the shared registry. + if (mc.getSingleplayerServer() == null) { + try { + MCHeli.content().replaceWith(ContentRegistry.load(MCHeli.legacyContent().assets())); + } catch (IOException e) { + MCHeli.LOGGER.error("Could not reload the MCHeli content", e); + if (mc.player != null) { + mc.player.sendSystemMessage(Component.translatable("commands.mcheli.reload.failed", e.getMessage())); + } + } + } + + MeshCache.INSTANCE.clear(); + RuntimeAssets.refreshTextures(); + if (mc.level != null && mc.player != null) { + boolean operator = mc.options.operatorItemsTab().get() && mc.player.canUseGameMasterBlocks(); + CreativeModeTab.ItemDisplayParameters params = + new CreativeModeTab.ItemDisplayParameters(mc.level.enabledFeatures(), operator, mc.level.registryAccess()); + ModCreativeTabs.all().forEach(tab -> tab.buildContents(params)); + } + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java similarity index 99% rename from src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java index 7220929..24c3ea7 100644 --- a/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java @@ -25,7 +25,7 @@ private ClientSetup() { } public static void init(BusGroup modBus) { - ClientAccess.install(() -> Minecraft.getInstance().player); + ClientAccess.install(() -> Minecraft.getInstance().player, ClientContentReload::run); EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> { event.registerEntityRenderer(ModEntities.HELICOPTER.get(), AircraftRenderer::new); event.registerEntityRenderer(ModEntities.PLANE.get(), AircraftRenderer::new); diff --git a/src/main/java/com/jnhprojects/mcheli/client/CrewController.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java similarity index 99% rename from src/main/java/com/jnhprojects/mcheli/client/CrewController.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java index 9c56157..25c6bfb 100644 --- a/src/main/java/com/jnhprojects/mcheli/client/CrewController.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/CrewController.java @@ -8,7 +8,7 @@ import com.jnhprojects.mcheli.content.aircraft.SeatDefinition; import com.jnhprojects.mcheli.content.weapon.WeaponType; import com.jnhprojects.mcheli.entity.AircraftEntity; -import com.jnhprojects.mcheli.entity.AircraftRotation; +import com.jnhprojects.mcheli.physics.AircraftRotation; import com.jnhprojects.mcheli.entity.GroundVehicleEntity; import com.jnhprojects.mcheli.entity.HelicopterEntity; import com.jnhprojects.mcheli.entity.TankEntity; diff --git a/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/KeyBindings.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java new file mode 100644 index 0000000..32e6b93 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -0,0 +1,139 @@ +package com.jnhprojects.mcheli.client; + +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Vehicle textures, models and sounds that can change while the game runs, without a resource + * reload (F3 + T), which would stall the game for every pack loaded and is not available on a + * server at all. + * + *

Lookups try the resource manager first, so resource packs keep the last word, then the + * runtime roots: folders laid out like {@code assets/mcheli} that content can add at any time. + * Content packs (phase 6) and the workshop register their folders here. + *

    + *
  • Textures found only in a runtime root are uploaded as a {@link DynamicTexture} the first + * time a vehicle draws them. {@link #refreshTextures} uploads every vehicle texture again + * from disk, which is how a content reload shows edited textures.
  • + *
  • Models are read through {@link #open} by the mesh cache, which a reload clears.
  • + *
  • Sounds found only in a runtime root are streamed from the file + * ({@link #soundFile}); see {@code RuntimeSoundInstance}.
  • + *
+ */ +public final class RuntimeAssets { + private static final Logger LOGGER = LoggerFactory.getLogger(RuntimeAssets.class); + + private static final List ROOTS = new CopyOnWriteArrayList<>(); + /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ + private static final Map TEXTURES = new ConcurrentHashMap<>(); + + private RuntimeAssets() { + } + + /** Adds a folder laid out like {@code assets/mcheli}, searched after the resource packs. */ + public static void addRoot(Path root) { + if (!ROOTS.contains(root)) { + ROOTS.add(root); + } + } + + public static void removeRoot(Path root) { + ROOTS.remove(root); + } + + /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ + public static @Nullable InputStream open(Identifier id) throws IOException { + Optional resource = Minecraft.getInstance().getResourceManager().getResource(id); + if (resource.isPresent()) { + return resource.get().open(); + } + Path file = find(id.getPath()); + return file != null ? Files.newInputStream(file) : null; + } + + /** + * A texture ready to bind. The first request for a texture no resource pack has, but a runtime + * root does, uploads it. Cheap after the first call, so renderers can call it every frame. + */ + public static Identifier texture(Identifier id) { + TEXTURES.computeIfAbsent(id, key -> { + if (Minecraft.getInstance().getResourceManager().getResource(key).isPresent()) { + return false; + } + return upload(key); + }); + return id; + } + + /** + * Uploads every texture vehicles have drawn so far again, read fresh from disk. Resource pack + * textures are replaced too, until the next resource reload loads them the normal way. + */ + public static void refreshTextures() { + for (Identifier id : TEXTURES.keySet()) { + TEXTURES.put(id, upload(id)); + } + } + + private static boolean upload(Identifier id) { + try (InputStream in = open(id)) { + if (in == null) { + return false; + } + NativeImage image = NativeImage.read(in); + Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, image)); + return true; + } catch (IOException e) { + LOGGER.warn("Could not load texture {}", id, e); + return false; + } + } + + /** + * Drops the textures we uploaded, so the texture manager loads them again the normal way. + * Called on resource reload. + */ + public static void clearTextures() { + TEXTURES.forEach((id, ours) -> { + if (ours) { + Minecraft.getInstance().getTextureManager().release(id); + } + }); + TEXTURES.clear(); + } + + /** + * The OGG file for a sound event of ours that no resource pack defines, such as {@code heli} + * from {@code sounds/heli.ogg} in a runtime root. Numbered variants ({@code heli1.ogg}) count. + */ + public static @Nullable Path soundFile(String event) { + Path file = find("sounds/" + event + ".ogg"); + return file != null ? file : find("sounds/" + event + "1.ogg"); + } + + private static @Nullable Path find(String path) { + for (Path root : ROOTS) { + Path file = root.resolve(path).normalize(); + if (file.startsWith(root) && Files.isRegularFile(file)) { + return file; + } + } + return null; + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/VehiclePicker.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/WeaponController.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponController.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/WeaponHud.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudCanvas.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudRenderer.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudScripts.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/hud/HudState.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/A10Renderer.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderState.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java similarity index 98% rename from src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java index 000f3bf..3f51aa5 100644 --- a/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/AircraftRenderer.java @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.client.render; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.aircraft.part.WeaponPart; import com.jnhprojects.mcheli.weapon.VehicleWeapons; @@ -215,6 +216,6 @@ public void submit(AircraftRenderState state, PoseStack poseStack, SubmitNodeCol } private static Identifier texture(String folder, String name) { - return Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + folder + "/" + name + ".png"); + return RuntimeAssets.texture(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "textures/" + folder + "/" + name + ".png")); } } diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java similarity index 92% rename from src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java index 245f3a0..c67df02 100644 --- a/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/GlassSplit.java @@ -1,10 +1,9 @@ package com.jnhprojects.mcheli.client.render; +import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.model.MeshGroup; import com.mojang.blaze3d.platform.NativeImage; -import net.minecraft.client.Minecraft; import net.minecraft.resources.Identifier; -import net.minecraft.server.packs.resources.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -119,11 +118,19 @@ private static MeshGroup pick(MeshGroup group, boolean[] isGlass, boolean wanted } private static Optional load(Identifier texture) { - Optional resource = Minecraft.getInstance().getResourceManager().getResource(texture); - if (resource.isEmpty()) { + try (InputStream in = RuntimeAssets.open(texture)) { + if (in == null) { + return Optional.empty(); + } + return Optional.of(read(in)); + } catch (IOException | RuntimeException e) { + LOGGER.warn("Could not read {} to find its glass; drawing it opaque", texture, e); return Optional.empty(); } - try (InputStream in = resource.get().open(); NativeImage image = NativeImage.read(in)) { + } + + private static Alpha read(InputStream in) throws IOException { + try (NativeImage image = NativeImage.read(in)) { int w = image.getWidth(); int h = image.getHeight(); byte[] values = new byte[w * h]; @@ -132,10 +139,7 @@ private static Optional load(Identifier texture) { values[y * w + x] = (byte) (image.getPixel(x, y) >>> 24); } } - return Optional.of(new Alpha(w, h, values)); - } catch (IOException | RuntimeException e) { - LOGGER.warn("Could not read {} to find its glass; drawing it opaque", texture, e); - return Optional.empty(); + return new Alpha(w, h, values); } } } diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java similarity index 80% rename from src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java index 1223190..57dad7e 100644 --- a/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshCache.java @@ -1,11 +1,10 @@ package com.jnhprojects.mcheli.client.render; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.client.RuntimeAssets; import com.jnhprojects.mcheli.model.Mesh; import com.jnhprojects.mcheli.model.MeshLoader; -import net.minecraft.client.Minecraft; import net.minecraft.resources.Identifier; -import net.minecraft.server.packs.resources.Resource; import net.minecraft.server.packs.resources.ResourceManager; import net.minecraft.server.packs.resources.ResourceManagerReloadListener; import org.jspecify.annotations.Nullable; @@ -19,8 +18,9 @@ import java.util.concurrent.ConcurrentHashMap; /** - * Lazily loads vehicle models through the resource manager, so resource packs can replace them. - * Models are parsed the first time a vehicle of that kind is drawn and dropped on resource reload. + * Lazily loads vehicle models, from the resource packs (so they can replace them) or else from the + * runtime roots of {@link RuntimeAssets}. Models are parsed the first time a vehicle of that kind + * is drawn, and dropped on resource reload and on content reload ({@link #clear}). */ public final class MeshCache implements ResourceManagerReloadListener { public static final MeshCache INSTANCE = new MeshCache(); @@ -47,15 +47,13 @@ private MeshCache() { } private static Optional load(String folder, String name, boolean required) { - ResourceManager resources = Minecraft.getInstance().getResourceManager(); for (String extension : EXTENSIONS) { Identifier id = Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, (folder.isEmpty() ? "models/" : "models/" + folder + "/") + name + extension); - Optional resource = resources.getResource(id); - if (resource.isEmpty()) { - continue; - } - try (InputStream in = resource.get().open()) { + try (InputStream in = RuntimeAssets.open(id)) { + if (in == null) { + continue; + } Mesh mesh = MeshLoader.load(id.toString(), in); LOGGER.debug("Loaded {}", mesh); return Optional.of(mesh); @@ -70,9 +68,15 @@ private static Optional load(String folder, String name, boolean required) return Optional.empty(); } - @Override - public void onResourceManagerReload(ResourceManager resourceManager) { + /** Forgets every model and glass split, so the next frame reads them again from disk. */ + public void clear() { meshes.clear(); GlassSplit.INSTANCE.clear(); } + + @Override + public void onResourceManagerReload(ResourceManager resourceManager) { + clear(); + RuntimeAssets.clearTextures(); + } } diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/MeshRenderer.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/PartRenderer.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/ProjectileRenderer.java diff --git a/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java similarity index 97% rename from src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java index 6b0569a..c297d9d 100644 --- a/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/render/RiderPose.java @@ -1,7 +1,7 @@ package com.jnhprojects.mcheli.client.render; import com.jnhprojects.mcheli.entity.AircraftEntity; -import com.jnhprojects.mcheli.entity.AircraftRotation; +import com.jnhprojects.mcheli.physics.AircraftRotation; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.client.renderer.entity.state.AvatarRenderState; diff --git a/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java similarity index 95% rename from src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java index dcd529b..8f91e64 100644 --- a/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSound.java @@ -6,7 +6,6 @@ import com.jnhprojects.mcheli.entity.EngineSoundSource; import net.minecraft.client.Minecraft; import net.minecraft.client.player.LocalPlayer; -import net.minecraft.client.resources.sounds.AbstractTickableSoundInstance; import net.minecraft.sounds.SoundSource; import net.minecraft.util.RandomSource; @@ -18,7 +17,7 @@ * stretches to the definition's {@code SoundRange}, and the pitch bends slightly as the vehicle * approaches or leaves, which is what the original did. */ -final class EngineSound extends AbstractTickableSoundInstance { +final class EngineSound extends RuntimeSoundInstance { private static final float MAX_DOPPLER = 0.2F; private final AircraftEntity vehicle; diff --git a/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/EngineSoundPlayer.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java new file mode 100644 index 0000000..64f39e7 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/sound/RuntimeSoundInstance.java @@ -0,0 +1,71 @@ +package com.jnhprojects.mcheli.client.sound; + +import com.jnhprojects.mcheli.client.RuntimeAssets; +import net.minecraft.client.resources.sounds.AbstractTickableSoundInstance; +import net.minecraft.client.resources.sounds.Sound; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.client.sounds.JOrbisAudioStream; +import net.minecraft.client.sounds.LoopingAudioStream; +import net.minecraft.client.sounds.SoundBufferLibrary; +import net.minecraft.client.sounds.SoundManager; +import net.minecraft.client.sounds.WeighedSoundEvents; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.util.Util; +import net.minecraft.util.valueproviders.ConstantFloat; +import org.jspecify.annotations.Nullable; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * A sound that plays through the sound manager when a resource pack defines its event, and + * otherwise streams the OGG file a runtime root holds for it ({@link RuntimeAssets#soundFile}). + * Content added while the game runs can bring sounds this way without touching + * {@code sounds.json} or reloading resources. + */ +abstract class RuntimeSoundInstance extends AbstractTickableSoundInstance { + private @Nullable Path file; + + protected RuntimeSoundInstance(SoundEvent event, SoundSource source, RandomSource random) { + super(event, source, random); + } + + @Override + public @Nullable WeighedSoundEvents getOrResolve(SoundManager manager) { + WeighedSoundEvents events = super.getOrResolve(manager); + if (events != null) { + return events; + } + file = RuntimeAssets.soundFile(identifier.getPath()); + if (file == null) { + return null; + } + // Streamed, so the file is read as it plays and never cached as a buffer. + sound = new Sound(identifier, new ConstantFloat(1.0F), new ConstantFloat(1.0F), 1, Sound.Type.FILE, true, false, 16); + soundEvent = new WeighedSoundEvents(identifier, null); + return soundEvent; + } + + @Override + public CompletableFuture getStream(SoundBufferLibrary buffers, Sound sound, boolean looping) { + Path source = file; + if (source == null) { + return buffers.getStream(sound.getPath(), looping); + } + return CompletableFuture.supplyAsync(() -> { + try { + InputStream in = new BufferedInputStream(Files.newInputStream(source)); + return looping ? new LoopingAudioStream(JOrbisAudioStream::new, in) : new JOrbisAudioStream(in); + } catch (IOException e) { + throw new CompletionException(e); + } + }, Util.nonCriticalIoPool()); + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java similarity index 89% rename from src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java index 17b1c81..a58e20d 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AircraftEntity.java @@ -1,6 +1,8 @@ package com.jnhprojects.mcheli.entity; +import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.ModSounds; +import com.jnhprojects.mcheli.ServerConfig; import com.jnhprojects.mcheli.content.Vector3; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.aircraft.BoundingBoxDefinition; @@ -9,9 +11,13 @@ import com.jnhprojects.mcheli.content.aircraft.SeatDefinition; import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; import com.jnhprojects.mcheli.entity.projectile.TvMissileEntity; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.physics.AircraftRotation; +import com.jnhprojects.mcheli.physics.GroundImpact; import com.jnhprojects.mcheli.registry.ModItems; import com.jnhprojects.mcheli.weapon.Explosions; import com.jnhprojects.mcheli.weapon.VehicleWeapons; +import com.jnhprojects.mcheli.weapon.WeaponSet; import com.mojang.serialization.Codec; import net.minecraft.core.BlockPos; import net.minecraft.core.particles.ParticleTypes; @@ -37,6 +43,7 @@ import net.minecraft.world.entity.InterpolationHandler; import net.minecraft.world.entity.LinearInterpolationHandler; import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.MoverType; import net.minecraft.world.entity.Pose; import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.vehicle.VehicleEntity; @@ -90,6 +97,7 @@ public abstract class AircraftEntity extends VehicleEntity { private static final EntityDataAccessor DATA_WEAPON_USE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); private static final EntityDataAccessor DATA_TV_MISSILE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.INT); private static final EntityDataAccessor DATA_FLARE = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_SINKING = SynchedEntityData.defineId(AircraftEntity.class, EntityDataSerializers.BOOLEAN); // Bits of DATA_PARTS, the same assignment the original used for its part status word. protected static final int PART_CANOPY = 0; @@ -99,6 +107,13 @@ public abstract class AircraftEntity extends VehicleEntity { protected static final int PART_HATCH = 4; private static final int EMPTY = -1; + /** How far above its hit box the original drew a vehicle's model; see {@link #waterDepth}. */ + private static final float LEGACY_MODEL_LIFT = 0.35F; + /** How fast a cooked-off boat goes under, in blocks per tick (about 0.4 blocks a second). */ + private static final double SINK_SPEED = 0.02; + /** The bow-up pitch and the list a sinking boat settles into, in degrees. */ + private static final float SINK_PITCH = 14.0F; + private static final float SINK_LIST = 12.0F; protected final ControlInput input = new ControlInput(); public final AnimationState animation = new AnimationState(); @@ -114,6 +129,7 @@ public abstract class AircraftEntity extends VehicleEntity { private @Nullable AircraftDefinition cachedDefinition; private String cachedName = ""; + private int cachedGeneration; protected @Nullable PartState canopy; protected @Nullable PartState landingGear; @@ -139,6 +155,10 @@ public abstract class AircraftEntity extends VehicleEntity { private float destroyedPitch; private float destroyedRoll; private boolean exploded; + /** Blasts still to come while a wrecked boat cooks off, and ticks until the next one. */ + private int cookOffBlasts; + private int cookOffDelay; + private @Nullable Entity cookOffCause; private int alertCount; // Crashes, measured from position so they work whichever side simulates the vehicle. @@ -174,17 +194,29 @@ protected void defineSynchedData(SynchedEntityData.Builder builder) { builder.define(DATA_WEAPON_USE, 0); builder.define(DATA_TV_MISSILE, 0); builder.define(DATA_FLARE, false); + builder.define(DATA_SINKING, false); } // Definition - /** Looks the definition up by name. Returns {@code null} for vehicles whose content was removed. */ + /** + * Looks the definition up by name. Returns {@code null} for vehicles whose content was removed. + * After a content reload the next call picks up the new definition object. + */ public @Nullable AircraftDefinition definition() { String name = typeName(); - if (!name.equals(cachedName)) { + int generation = MCHeli.content().generation(); + if (!name.equals(cachedName) || generation != cachedGeneration) { + boolean reloaded = name.equals(cachedName); cachedName = name; + cachedGeneration = generation; + AircraftDefinition previous = cachedDefinition; cachedDefinition = name.isEmpty() ? null : lookup(name); - if (cachedDefinition != null) { + if (cachedDefinition != null && cachedDefinition != previous) { + if (reloaded && weapons != null) { + // The same vehicle with new numbers: keep the rounds it carries. + savedAmmo = weapons.saveAmmo(); + } onDefinitionChanged(cachedDefinition); } } @@ -396,7 +428,11 @@ public void tick() { if (!(getControllingPassenger() instanceof Player) || isDestroyed()) { input.clear(); } - simulate(); + if (entityData.get(DATA_SINKING)) { + sink(); + } else { + simulate(); + } float[] kick = weapons != null ? weapons.recoilKick() : null; if (kick != null) { setXRot(getXRot() + kick[0]); @@ -1257,10 +1293,17 @@ protected boolean isOnSolidGround() { * Port of {@code getWaterDepth}: the share of the hit box that is in water, from 0 to 1, sampled * in five horizontal slices. {@code FloatOffset} raises the slices, so a hull with an offset rides * higher. The float physics only pushes up once this reaches 1, which sets the waterline. + * + *

The original drew the model {@value #LEGACY_MODEL_LIFT} above the bottom of the hit box + * (its {@code yOffset}, half the 0.7 default height); this port draws it at the bottom. The + * slices are lowered by the same amount so boats sit in the water as they did in 1.0.3. */ protected double waterDepth() { + if (entityData.get(DATA_SINKING)) { + return 0.0; + } AircraftDefinition def = definition(); - float offset = def != null ? def.floatOffset : 0.0F; + float offset = (def != null ? def.floatOffset : 0.0F) - LEGACY_MODEL_LIFT; AABB box = getBoundingBox(); double height = box.maxY - box.minY; double depth = 0.0; @@ -1328,9 +1371,18 @@ public int syncedWeaponUse() { return entityData.get(DATA_WEAPON_USE); } - /** {@code isInfinityAmmo}: creative players never run dry. */ + /** + * Whether {@code set} fires along the user's view instead of the hull: the shot direction and + * the muzzle positions are worked out as if the vehicle faced where the user looks. Static + * mounts do this; see {@link GroundVehicleEntity#aimsWithUser}. + */ + public boolean aimsWithUser(WeaponSet set) { + return false; + } + + /** {@code isInfinityAmmo}: creative players never run dry, nor does anyone when the server config says so. */ public boolean hasInfiniteAmmo(@Nullable Entity user) { - return user instanceof Player player && player.getAbilities().instabuild; + return ServerConfig.infiniteAmmo() || user instanceof Player player && player.getAbilities().instabuild; } /** Where the pilot looks relative to the hull: the turret's traverse. */ @@ -1477,6 +1529,7 @@ public boolean hurtServer(ServerLevel level, DamageSource source, float damage) damage *= boxFactor; } } + damage *= ServerConfig.vehicleDamageFactor(); entityData.set(DATA_HP, Math.max(hp() - (int) damage, 0)); } setHurtDir(-getHurtDir()); @@ -1487,7 +1540,9 @@ public boolean hurtServer(ServerLevel level, DamageSource source, float damage) entityData.set(DATA_HP, 0); destroyVehicle(); timeSinceHit = 20; - if (source.is(DamageTypes.IN_WALL) || source.is(DamageTypes.FLY_INTO_WALL) || source.is(DamageTypes.FALL)) { + if (isAfloat()) { + startCookOff(source.getEntity()); + } else if (source.is(DamageTypes.IN_WALL) || source.is(DamageTypes.FLY_INTO_WALL) || source.is(DamageTypes.FALL)) { crashExplosion(); } else { Explosions.explode(level, getX(), getY(), getZ(), @@ -1553,6 +1608,97 @@ private void crashExplosion() { } } + /** A floating vehicle sitting on the water, such as a patrol boat or a float plane at rest. */ + private boolean isAfloat() { + AircraftDefinition def = definition(); + return def != null && def.floats && waterDepth() > 0.0; + } + + /** + * A boat destroyed on the water does not go up in one crash fireball; its fuel and magazine + * cook off in three blasts over about five seconds, then the hull sinks. Not in the original, + * which only had the crash explosion. + */ + private void startCookOff(@Nullable Entity cause) { + exploded = true; + cookOffBlasts = 3; + cookOffDelay = 1; + cookOffCause = cause; + } + + private void tickCookOff(ServerLevel level) { + if (cookOffBlasts <= 0 || --cookOffDelay > 0) { + return; + } + AircraftDefinition def = definition(); + int blast = 4 - cookOffBlasts--; + float fuel = Mth.clamp(def != null ? def.maxFuel / 400.0F : 2.0F, 1.0F, 15.0F); + // The fuel tank first, then whatever the magazine still holds, then the hull goes up. + float size = switch (blast) { + case 1 -> fuel * 0.6F; + case 2 -> Math.max(munitionSize(), fuel * 0.4F); + default -> fuel; + }; + size = Mth.clamp(size, 1.0F, 15.0F); + Vec3 at = cookOffPoint(def); + Explosions.explode(level, at.x, at.y, at.z, + new Explosions.Params(null, cookOffCause, size, size >= 2.0F ? size * 0.5F : 1.0F, true, true, 5, false, null)); + // The blasts happen at the waterline, but the fire is above it, so show the full fireball. + Explosions.crashFireball(level, at.x, at.y, at.z, size); + if (cookOffBlasts > 0) { + // Three blasts inside five seconds: about 1.5 to 2.5 seconds apart. + cookOffDelay = 30 + random.nextInt(20); + } else { + entityData.set(DATA_SINKING, true); + cookOffCause = null; + } + } + + /** + * A cooked-off wreck going under, in place of the vehicle's own physics: it settles slowly, + * stern first with the bow raised and listing to one side, drifts to a stop and comes to rest + * on the bottom. Out of the water it simply falls. + */ + private void sink() { + Vec3 m = getDeltaMovement(); + double my = isInWater() ? Math.max(m.y - 0.003, -SINK_SPEED) : m.y - 0.04; + Vec3 motion = new Vec3(m.x * 0.9, my, m.z * 0.9); + move(MoverType.SELF, motion); + setDeltaMovement(onGround() ? motion.multiply(0.5, 0.0, 0.5) : motion); + if (!onGround()) { + // Which way it lists only has to be the same on every side, so take it from the id. + float list = (getId() & 1) == 0 ? SINK_LIST : -SINK_LIST; + setXRot(Mth.approach(getXRot(), -SINK_PITCH, 0.12F)); + roll = Mth.approach(roll, list, 0.15F); + } + } + + /** + * How big the ammunition aboard goes up: each weapon's {@code Explosion} scaled by how full its + * magazine and reserve still are. Guns and empty racks add nothing. + */ + private float munitionSize() { + if (weapons == null) { + return 0.0F; + } + float size = 0.0F; + for (WeaponSet set : weapons.sets()) { + int rounds = set.ammo() + set.restAmmo(); + if (rounds > 0 && set.definition().explosion > 0) { + size += set.definition().explosion * Math.min(1.0F, (float) rounds / Math.max(set.maxAmmo(), 1)); + } + } + return size; + } + + /** A random spot on the hull: one of its hit boxes, or the middle of the body. */ + private Vec3 cookOffPoint(@Nullable AircraftDefinition def) { + if (def == null || def.boundingBoxes.isEmpty() || random.nextInt(def.boundingBoxes.size() + 1) == 0) { + return position().add(0.0, def != null ? def.bodyHeight / 2.0 : 0.5, 0.0); + } + return toWorld(def.boundingBoxes.get(random.nextInt(def.boundingBoxes.size())).offset()); + } + /** Destroyed state each tick: the wreck spins, sets its crew alight and explodes on impact. */ private void tickDestroyed() { if (!isDestroyed()) { @@ -1570,9 +1716,10 @@ private void tickDestroyed() { roll += destroyedRoll; } } - if (level().isClientSide()) { + if (!(level() instanceof ServerLevel level)) { return; } + tickCookOff(level); if (ticksAlive % 20 == 0) { AircraftDefinition def = definition(); for (int seat = 0; seat < seatIds.length; seat++) { @@ -1611,14 +1758,14 @@ private void damageSmoke(AircraftDefinition def) { @Override protected Item getDropItem() { - Item item = ModItems.aircraft(typeName()); - return item != null ? item : Items.AIR; + AircraftDefinition def = definition(); + return def != null ? ModItems.forKind(def.kind()) : Items.AIR; } @Override public @Nullable ItemStack getPickResult() { - Item item = ModItems.aircraft(typeName()); - return item != null ? new ItemStack(item) : null; + AircraftDefinition def = definition(); + return def != null ? AircraftItem.stackOf(def) : null; } // Dimensions and syncing @@ -1657,6 +1804,8 @@ protected void addAdditionalSaveData(ValueOutput output) { output.putBoolean("GunnerMode", isGunnerMode()); output.putInt("Parts", partBits()); output.putInt("DespawnCount", despawnCount); + output.putInt("CookOff", cookOffBlasts); + output.putBoolean("Sinking", entityData.get(DATA_SINKING)); if (weapons != null) { output.putIntArray("WeaponsAmmo", weapons.saveAmmo()); } @@ -1681,6 +1830,10 @@ protected void readAdditionalSaveData(ValueInput input) { if (hp() <= 0 && despawnCount <= 0) { despawnCount = 500; } + cookOffBlasts = input.getIntOr("CookOff", 0); + cookOffDelay = cookOffBlasts > 0 ? 20 : 0; + entityData.set(DATA_SINKING, input.getBooleanOr("Sinking", false)); + exploded |= cookOffBlasts > 0 || entityData.get(DATA_SINKING); input.getIntArray("WeaponsAmmo").ifPresent(ammo -> { if (weapons != null) { weapons.loadAmmo(ammo); diff --git a/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/AnimationState.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java similarity index 70% rename from src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java index 49ed609..3118c97 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -12,15 +12,23 @@ */ public final class ClientAccess { private static Supplier<@Nullable Entity> localPlayer = () -> null; + private static Runnable contentReloaded = () -> { + }; private ClientAccess() { } - public static void install(Supplier<@Nullable Entity> player) { + public static void install(Supplier<@Nullable Entity> player, Runnable onContentReloaded) { localPlayer = player; + contentReloaded = onContentReloaded; } public static @Nullable Entity localPlayer() { return localPlayer.get(); } + + /** The server reloaded the content. Runs on the client's main thread. */ + public static void contentReloaded() { + contentReloaded.run(); + } } diff --git a/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/EngineSoundSource.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java similarity index 84% rename from src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java index c556b82..3029014 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/GroundVehicleEntity.java @@ -2,6 +2,8 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; +import com.jnhprojects.mcheli.content.aircraft.WeaponMount; +import com.jnhprojects.mcheli.weapon.WeaponSet; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MoverType; import net.minecraft.world.level.Level; @@ -32,6 +34,18 @@ public GroundVehicleEntity(EntityType type, Level return (VehicleDefinition) super.definition(); } + /** + * {@code MCH_EntityVehicle.useCurrentWeapon}: the mount stays put while its {@code AddPart} + * tree turns with the gunner's view, so a weapon fires where the gunner looks. Only weapons + * with both yaw limits set aim like turrets instead. The original checked the first + * {@code AddWeapon} line of that weapon, and so does this. + */ + @Override + public boolean aimsWithUser(WeaponSet set) { + WeaponMount mount = set.mounts().getFirst(); + return mount.minYaw() == 0.0F || mount.maxYaw() == 0.0F; + } + @Override protected void simulate() { VehicleDefinition def = definition(); diff --git a/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java similarity index 99% rename from src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java index 2189882..b140713 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/HelicopterEntity.java @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; import net.minecraft.util.Mth; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MoverType; diff --git a/src/main/java/com/jnhprojects/mcheli/entity/PartState.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PartState.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/PartState.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PartState.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java similarity index 99% rename from src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java index 5e8e8e5..b80171b 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/PlaneEntity.java @@ -3,6 +3,7 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; +import com.jnhprojects.mcheli.physics.AircraftRotation; import net.minecraft.util.Mth; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MoverType; diff --git a/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java similarity index 99% rename from src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java index a64120f..e729c7f 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TankEntity.java @@ -4,6 +4,7 @@ import com.jnhprojects.mcheli.content.Vector3; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import com.jnhprojects.mcheli.physics.GroundImpact; import net.minecraft.util.Mth; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MoverType; diff --git a/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java similarity index 99% rename from src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java index 30aa5c0..92e8e59 100644 --- a/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/TerrainFollower.java @@ -1,6 +1,7 @@ package com.jnhprojects.mcheli.entity; import com.jnhprojects.mcheli.content.Vector3; +import com.jnhprojects.mcheli.physics.AircraftRotation; import net.minecraft.util.Mth; import net.minecraft.world.entity.Entity; import net.minecraft.world.phys.AABB; diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/A10Entity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/AAMissileEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ASMissileEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ATMissileEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BombEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/BulletEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/DispensedItemEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/FlareEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/MarkerRocketEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/ProjectileEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/RocketEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TorpedoEntity.java diff --git a/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/projectile/TvMissileEntity.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java new file mode 100644 index 0000000..94d93fa --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -0,0 +1,118 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.ServerConfig; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.entity.AircraftEntity; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import com.jnhprojects.mcheli.registry.ModEntities; +import com.jnhprojects.mcheli.registry.ModItems; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.stats.Stats; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Places a vehicle, the same way a boat is placed. + * + *

There is one item per vehicle kind ({@code mcheli:helicopter}, {@code mcheli:plane} and so + * on). Which vehicle a stack places is stored in its {@link ModDataComponents#VEHICLE} component, + * and its look comes from the {@code item_model} component pointing at the model the import wrote + * for that vehicle. Items are registered once at startup, so this is what lets definitions be + * added or reloaded while the game runs. + */ +public class AircraftItem extends Item { + private final String kind; + + public AircraftItem(String kind, Properties properties) { + super(properties); + this.kind = kind; + } + + /** The vehicle kind this item holds, such as {@code helicopter}. */ + public String kind() { + return kind; + } + + /** A stack that places {@code def}. */ + public static ItemStack stackOf(AircraftDefinition def) { + ItemStack stack = new ItemStack(ModItems.forKind(def.kind())); + stack.set(ModDataComponents.VEHICLE.get(), def.name()); + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + return stack; + } + + /** The definition name a stack places, or {@code null} if it is not a vehicle item or has none. */ + public static @Nullable String vehicleOf(ItemStack stack) { + return stack.getItem() instanceof AircraftItem item ? item.definitionName(stack) : null; + } + + protected @Nullable String definitionName(ItemStack stack) { + return stack.get(ModDataComponents.VEHICLE.get()); + } + + @Override + public Component getName(ItemStack stack) { + String name = definitionName(stack); + if (name == null) { + return super.getName(stack); + } + AircraftDefinition def = MCHeli.content().any(name); + // Vehicles added at runtime may bring no lang entry; their DisplayName stands in. + return Component.translatableWithFallback("item.mcheli." + name, def != null ? def.displayName : name); + } + + @Override + public InteractionResult use(Level level, Player player, InteractionHand hand) { + ItemStack stack = player.getItemInHand(hand); + HitResult hit = getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY); + if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) { + return InteractionResult.PASS; + } + + String name = definitionName(stack); + AircraftDefinition def = name != null ? MCHeli.content().any(name) : null; + if (def == null) { + if (!level.isClientSide()) { + player.sendOverlayMessage(Component.translatable("message.mcheli.vehicle.unknown", String.valueOf(name))); + } + return InteractionResult.FAIL; + } + if (!level.isClientSide() && ServerConfig.isDisabled(def.name())) { + player.sendOverlayMessage(Component.translatable("message.mcheli.vehicle.disabled", stack.getHoverName())); + return InteractionResult.FAIL; + } + + AircraftEntity vehicle = ModEntities.typeFor(def).create(level, EntitySpawnReason.SPAWN_ITEM_USE); + if (vehicle == null) { + return InteractionResult.FAIL; + } + Vec3 at = blockHit.getLocation(); + vehicle.setType(def); + vehicle.snapTo(at.x, at.y, at.z, player.getYRot(), 0.0F); + if (!level.noCollision(vehicle, vehicle.getBoundingBox())) { + return InteractionResult.FAIL; + } + + if (!level.isClientSide()) { + level.addFreshEntity(vehicle); + level.gameEvent(player, GameEvent.ENTITY_PLACE, at); + stack.consume(1, player); + } + player.awardStat(Stats.ITEM_USED.get(this)); + return InteractionResult.SUCCESS; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java new file mode 100644 index 0000000..0a28683 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/LegacyAircraftItem.java @@ -0,0 +1,61 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.registry.ModDataComponents; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * A vehicle item as 1.0.0 saved it: one item per vehicle, such as {@code mcheli:ah-64}. + * + *

These are still registered for the vehicles imported at startup, so that stacks in 1.0.0 + * worlds load instead of vanishing. They work as before, are not listed anywhere, and turn into + * the generic item of their kind ({@link AircraftItem#stackOf}) as soon as they sit in a player's + * inventory. Stacks in chests convert once a player takes them out. + */ +public final class LegacyAircraftItem extends AircraftItem { + private final String definitionName; + + public LegacyAircraftItem(String kind, String definitionName, Properties properties) { + super(kind, properties); + this.definitionName = definitionName; + } + + @Override + protected @Nullable String definitionName(ItemStack stack) { + return definitionName; + } + + @Override + public void inventoryTick(ItemStack stack, ServerLevel level, Entity owner, @Nullable EquipmentSlot slot) { + if (!(owner instanceof Player player)) { + return; + } + AircraftDefinition def = MCHeli.content().any(definitionName); + if (def == null) { + return; + } + Inventory inventory = player.getInventory(); + for (int i = 0; i < inventory.getContainerSize(); i++) { + if (inventory.getItem(i) == stack) { + inventory.setItem(i, convert(stack, def)); + return; + } + } + } + + /** The generic stack for {@code def}, keeping count, custom name and anything else set on the old one. */ + static ItemStack convert(ItemStack legacy, AircraftDefinition def) { + ItemStack converted = AircraftItem.stackOf(def); + converted.applyComponents(legacy.getComponentsPatch()); + converted.set(ModDataComponents.VEHICLE.get(), def.name()); + converted.setCount(legacy.getCount()); + return converted; + } +} diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java new file mode 100644 index 0000000..2012f9b --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/VehicleIngredient.java @@ -0,0 +1,83 @@ +package com.jnhprojects.mcheli.item; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.registry.ModItems; +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.HolderSet; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemStackTemplate; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import net.minecraftforge.common.crafting.ingredients.AbstractIngredient; +import net.minecraftforge.common.crafting.ingredients.IIngredientSerializer; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +/** + * Matches one vehicle, such as an AH-6 in the recipe that turns it into an AH-6X. Every vehicle of + * a kind shares an item, so a plain item ingredient would accept any of them. In JSON: + *

{"type": "mcheli:vehicle", "vehicle": "ah-6"}
+ */ +public final class VehicleIngredient extends AbstractIngredient { + public static final MapCodec CODEC = + Codec.STRING.fieldOf("vehicle").xmap(VehicleIngredient::new, VehicleIngredient::vehicle); + public static final StreamCodec STREAM_CODEC = + ByteBufCodecs.STRING_UTF8.map(VehicleIngredient::new, VehicleIngredient::vehicle).cast(); + public static final IIngredientSerializer SERIALIZER = IIngredientSerializer.simple(CODEC, STREAM_CODEC); + + private final String vehicle; + + public VehicleIngredient(String vehicle) { + super(kindItems()); + this.vehicle = vehicle; + } + + @SuppressWarnings("deprecation") + private static HolderSet kindItems() { + return HolderSet.direct(Item::builtInRegistryHolder, + ModItems.HELICOPTER.get(), ModItems.PLANE.get(), ModItems.TANK.get(), ModItems.VEHICLE.get()); + } + + public String vehicle() { + return vehicle; + } + + @Override + public boolean test(@Nullable ItemStack stack) { + return stack != null && vehicle.equals(AircraftItem.vehicleOf(stack)); + } + + @Override + public SlotDisplay display() { + AircraftDefinition def = MCHeli.content().any(vehicle); + return def != null ? new SlotDisplay.ItemStackSlotDisplay(ItemStackTemplate.fromNonEmptyStack(AircraftItem.stackOf(def))) + : SlotDisplay.Empty.INSTANCE; + } + + @Override + public boolean isSimple() { + return false; + } + + @Override + public IIngredientSerializer serializer() { + return SERIALIZER; + } + + @Override + public boolean equals(Object o) { + return o instanceof VehicleIngredient other && vehicle.equals(other.vehicle); + } + + @Override + public int hashCode() { + return Objects.hash(vehicle); + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/network/BoardVehiclePacket.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ContentReloadPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ContentReloadPacket.java new file mode 100644 index 0000000..2759fe8 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ContentReloadPacket.java @@ -0,0 +1,24 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.event.network.CustomPayloadEvent; + +/** + * Server to every player: the definitions were reloaded with {@code /mcheli reload}. Clients + * reload theirs too (unless they share the server's, in single player) and drop cached models and + * textures so edited files show up. + */ +public record ContentReloadPacket() { + + public static void encode(ContentReloadPacket msg, FriendlyByteBuf buf) { + } + + public static ContentReloadPacket decode(FriendlyByteBuf buf) { + return new ContentReloadPacket(); + } + + public static void handle(ContentReloadPacket msg, CustomPayloadEvent.Context ctx) { + ClientAccess.contentReloaded(); + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/network/CrewActionPacket.java diff --git a/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ExplosionEffectPacket.java diff --git a/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/network/FireWeaponPacket.java diff --git a/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/network/LockWarningPacket.java diff --git a/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java similarity index 86% rename from src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java index d87f448..15dcf10 100644 --- a/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -4,6 +4,7 @@ import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraftforge.network.ChannelBuilder; @@ -12,7 +13,7 @@ public final class ModNetwork { /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ - private static final int PROTOCOL_VERSION = 4; + private static final int PROTOCOL_VERSION = 5; private static final SimpleChannel CHANNEL = ChannelBuilder .named(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "main")) @@ -42,6 +43,9 @@ public final class ModNetwork { .addMain(ExplosionEffectPacket.class, StreamCodec.ofMember(ExplosionEffectPacket::encode, ExplosionEffectPacket::decode), ExplosionEffectPacket::handle) + .addMain(ContentReloadPacket.class, + StreamCodec.ofMember(ContentReloadPacket::encode, ContentReloadPacket::decode), + ContentReloadPacket::handle) .build(); private ModNetwork() { @@ -60,6 +64,12 @@ public static void sendToPlayer(ServerPlayer player, Object message) { CHANNEL.send(message, PacketDistributor.PLAYER.with(player)); } + public static void sendToAll(MinecraftServer server, Object message) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + sendToPlayer(player, message); + } + } + /** Sends to every player in {@code level} within {@code range} blocks of the point. */ public static void sendNear(ServerLevel level, double x, double y, double z, double range, Object message) { for (ServerPlayer player : level.players()) { diff --git a/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PilotStatePacket.java diff --git a/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/network/WeaponStatePacket.java diff --git a/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java similarity index 62% rename from src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java index 8a8aa1d..fe1dc6f 100644 --- a/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModCreativeTabs.java @@ -1,19 +1,24 @@ package com.jnhprojects.mcheli.registry; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceKey; import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.CreativeModeTabs; -import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.Items; import net.minecraftforge.eventbus.api.bus.BusGroup; import net.minecraftforge.registries.DeferredRegister; import net.minecraftforge.registries.RegistryObject; -/** One tab per vehicle kind, each showing the items generated from that kind's definitions. */ +import java.util.List; + +/** + * One tab per vehicle kind, listing a stack for each loaded definition of that kind. The list is + * read from the content registry whenever a tab is built, so reloaded content shows up. + */ public final class ModCreativeTabs { private static final DeferredRegister TABS = DeferredRegister.create(Registries.CREATIVE_MODE_TAB, MCHeli.MOD_ID); @@ -31,13 +36,24 @@ private static RegistryObject register(String name, String kind .title(Component.translatable("itemGroup.mcheli." + name)) .withTabsBefore(after) .icon(() -> { - Item item = ModItems.aircraft(icon); - return new ItemStack(item != null ? item : Items.FEATHER); + AircraftDefinition def = MCHeli.content().any(icon); + return def != null ? AircraftItem.stackOf(def) : new ItemStack(ModItems.forKind(kind)); + }) + .displayItems((params, output) -> { + for (AircraftDefinition def : MCHeli.content().all()) { + if (def.kind().equals(kind)) { + output.accept(AircraftItem.stackOf(def)); + } + } }) - .displayItems((params, output) -> ModItems.ofKind(kind).forEach(item -> output.accept(item.get()))) .build()); } + /** Our tabs, for rebuilding their contents after a content reload. */ + public static List all() { + return List.of(HELICOPTERS.get(), PLANES.get(), TANKS.get(), VEHICLES.get()); + } + public static void register(BusGroup modBus) { TABS.register(modBus); } diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java new file mode 100644 index 0000000..2cb9b7f --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModDataComponents.java @@ -0,0 +1,32 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.MCHeli; +import com.mojang.serialization.Codec; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.RegistryObject; + +public final class ModDataComponents { + private static final DeferredRegister> COMPONENTS = + DeferredRegister.create(Registries.DATA_COMPONENT_TYPE, MCHeli.MOD_ID); + + /** + * The definition a vehicle item places, such as {@code ah-64}. Saved with the stack and sent + * to clients; the item itself only says which kind of vehicle it is. + */ + public static final RegistryObject> VEHICLE = COMPONENTS.register("vehicle", + () -> DataComponentType.builder() + .persistent(Codec.STRING) + .networkSynchronized(ByteBufCodecs.STRING_UTF8) + .build()); + + private ModDataComponents() { + } + + public static void register(BusGroup modBus) { + COMPONENTS.register(modBus); + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModEntities.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java new file mode 100644 index 0000000..d59afe0 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -0,0 +1,60 @@ +package com.jnhprojects.mcheli.registry; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.item.AircraftItem; +import com.jnhprojects.mcheli.item.LegacyAircraftItem; +import net.minecraft.world.item.Item; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +import java.util.Map; + +/** + * One item per vehicle kind; the stack says which vehicle (see {@link AircraftItem}). The 1.0.0 + * items, one per vehicle, are kept for the content present at startup so old worlds keep them. + */ +public final class ModItems { + private static final DeferredRegister ITEMS = DeferredRegister.create(ForgeRegistries.ITEMS, MCHeli.MOD_ID); + + public static final RegistryObject HELICOPTER = kind("helicopter"); + public static final RegistryObject PLANE = kind("plane"); + public static final RegistryObject TANK = kind("tank"); + public static final RegistryObject VEHICLE = kind("vehicle"); + + private static final Map> BY_KIND = + Map.of("helicopter", HELICOPTER, "plane", PLANE, "tank", TANK, "vehicle", VEHICLE); + + private ModItems() { + } + + private static RegistryObject kind(String kind) { + return ITEMS.register(kind, () -> new AircraftItem(kind, new Item.Properties().setId(ITEMS.key(kind)).stacksTo(1))); + } + + public static void register(BusGroup modBus, ContentRegistry content) { + for (AircraftDefinition def : content.all()) { + String name = def.name(); + if (BY_KIND.containsKey(name)) { + // 1.0.0 could not have had such an item either: it would have clashed the same way. + continue; + } + String kind = def.kind(); + ITEMS.register(name, () -> new LegacyAircraftItem(kind, name, + new Item.Properties().setId(ITEMS.key(name)).stacksTo(1))); + } + ITEMS.register(modBus); + } + + /** The generic item of a vehicle kind ({@code helicopter}, {@code plane}, {@code tank} or {@code vehicle}). */ + public static Item forKind(String kind) { + RegistryObject item = BY_KIND.get(kind); + if (item == null) { + throw new IllegalArgumentException("Unknown vehicle kind " + kind); + } + return item.get(); + } +} diff --git a/src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java similarity index 62% rename from src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java index ee87bfb..d96ba19 100644 --- a/src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModRecipes.java @@ -1,9 +1,14 @@ package com.jnhprojects.mcheli.registry; import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.item.VehicleIngredient; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraftforge.common.crafting.ingredients.IIngredientSerializer; import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; import java.util.List; @@ -13,10 +18,18 @@ * until that is ported, every MCHeli recipe goes into a player's recipe book when they join, so they can be found. */ public final class ModRecipes { + private static final DeferredRegister> INGREDIENTS = + DeferredRegister.create(ForgeRegistries.Keys.INGREDIENT_SERIALIZERS, MCHeli.MOD_ID); + + static { + INGREDIENTS.register("vehicle", () -> VehicleIngredient.SERIALIZER); + } + private ModRecipes() { } - public static void register() { + public static void register(BusGroup modBus) { + INGREDIENTS.register(modBus); PlayerEvent.PlayerLoggedInEvent.BUS.addListener(event -> { if (event.getEntity() instanceof ServerPlayer player) { List> ours = player.level().getServer().getRecipeManager().getRecipes().stream() diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/Combat.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Combat.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ExplosionEffect.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java similarity index 98% rename from src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java index d0dfb5d..6dcbd1a 100644 --- a/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Explosions.java @@ -1,5 +1,6 @@ package com.jnhprojects.mcheli.weapon; +import com.jnhprojects.mcheli.ServerConfig; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; import com.jnhprojects.mcheli.entity.projectile.FlareEntity; import com.jnhprojects.mcheli.entity.projectile.ProjectileEntity; @@ -57,10 +58,10 @@ public static boolean explode(ServerLevel level, double x, double y, double z, P Vec3 center = new Vec3(x, y, z); boolean hit = damageEntities(level, center, p); - boolean breakBlocks = p.destroyBlocks && p.blockSize > 0.0F; + boolean breakBlocks = p.destroyBlocks && p.blockSize > 0.0F && ServerConfig.explosionsBreakBlocks(); float radius = breakBlocks ? p.blockSize : Math.max(p.size, 0.5F); level.explode(p.exploder, explosionSource(level, p), new Calculator(p.inWater, level), - x, y, z, radius, p.flaming && breakBlocks, + x, y, z, radius, p.flaming && breakBlocks && ServerConfig.explosionsStartFires(), breakBlocks ? Level.ExplosionInteraction.MOB : Level.ExplosionInteraction.NONE); ModNetwork.sendNear(level, x, y, z, EFFECT_RANGE, new ExplosionEffectPacket(x, y, z, p.size, p.inWater)); if (!p.inWater) { diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/FlareDispenser.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Guidance.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/LegacyItems.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ModDamageTypes.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Resupply.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/ServerFiring.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/Shot.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/Shot.java diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java similarity index 96% rename from src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java index e285b03..179ac95 100644 --- a/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/VehicleWeapons.java @@ -247,13 +247,29 @@ public boolean use(Entity user, int seat, Shot shot, WeaponSet.Trigger trigger) } shot.infiniteAmmo = vehicle.hasInfiniteAmmo(user); int mount = set.currentMount(); - boolean fired = set.use(shot, (s, m, sh) -> { - Vec3 muzzle = muzzleOffset(s, m, 1.0F); - sh.x += muzzle.x; - sh.y += muzzle.y; - sh.z += muzzle.z; - return trigger.fire(s, m, sh); - }); + // As the original did, face the vehicle where the user looks for the length of the shot. + boolean followUser = vehicle.aimsWithUser(set); + float hullYaw = vehicle.getYRot(); + float hullPitch = vehicle.getXRot(); + if (followUser) { + vehicle.setYRot(user.getYRot()); + vehicle.setXRot(user.getXRot()); + } + boolean fired; + try { + fired = set.use(shot, (s, m, sh) -> { + Vec3 muzzle = muzzleOffset(s, m, 1.0F); + sh.x += muzzle.x; + sh.y += muzzle.y; + sh.z += muzzle.z; + return trigger.fire(s, m, sh); + }); + } finally { + if (followUser) { + vehicle.setYRot(hullYaw); + vehicle.setXRot(hullPitch); + } + } if (!fired) { return false; } diff --git a/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java similarity index 100% rename from src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java rename to forge-26.3/src/main/java/com/jnhprojects/mcheli/weapon/WeaponSet.java diff --git a/src/main/resources/META-INF/mods.toml b/forge-26.3/src/main/resources/META-INF/mods.toml similarity index 100% rename from src/main/resources/META-INF/mods.toml rename to forge-26.3/src/main/resources/META-INF/mods.toml diff --git a/forge-26.3/src/main/resources/assets/mcheli/items/helicopter.json b/forge-26.3/src/main/resources/assets/mcheli/items/helicopter.json new file mode 100644 index 0000000..24742eb --- /dev/null +++ b/forge-26.3/src/main/resources/assets/mcheli/items/helicopter.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/forge-26.3/src/main/resources/assets/mcheli/items/plane.json b/forge-26.3/src/main/resources/assets/mcheli/items/plane.json new file mode 100644 index 0000000..24742eb --- /dev/null +++ b/forge-26.3/src/main/resources/assets/mcheli/items/plane.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/forge-26.3/src/main/resources/assets/mcheli/items/tank.json b/forge-26.3/src/main/resources/assets/mcheli/items/tank.json new file mode 100644 index 0000000..24742eb --- /dev/null +++ b/forge-26.3/src/main/resources/assets/mcheli/items/tank.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/forge-26.3/src/main/resources/assets/mcheli/items/vehicle.json b/forge-26.3/src/main/resources/assets/mcheli/items/vehicle.json new file mode 100644 index 0000000..24742eb --- /dev/null +++ b/forge-26.3/src/main/resources/assets/mcheli/items/vehicle.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "minecraft:item/barrier" + } +} diff --git a/src/main/resources/data/mcheli/damage_type/bullet.json b/forge-26.3/src/main/resources/data/mcheli/damage_type/bullet.json similarity index 100% rename from src/main/resources/data/mcheli/damage_type/bullet.json rename to forge-26.3/src/main/resources/data/mcheli/damage_type/bullet.json diff --git a/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json b/forge-26.3/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json similarity index 100% rename from src/main/resources/data/minecraft/tags/damage_type/is_projectile.json rename to forge-26.3/src/main/resources/data/minecraft/tags/damage_type/is_projectile.json diff --git a/src/main/resources/pack.mcmeta b/forge-26.3/src/main/resources/pack.mcmeta similarity index 100% rename from src/main/resources/pack.mcmeta rename to forge-26.3/src/main/resources/pack.mcmeta diff --git a/gradle.properties b/gradle.properties index b0bde84..755d9be 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,19 +9,20 @@ org.gradle.configuration-cache.problems=warn net.minecraftforge.gradle.merge-source-sets=true -# Mod coordinates +# Mod coordinates, shared by every platform module mod_id=mcheli mod_name=MCHeli Rework mod_group=com.jnhprojects.mcheli -mod_version=1.0.0 +mod_version=1.1.0-beta.1 mod_authors=JayMar921 (rework), EMB4 (original author), Fureniku (CurseForge maintainer) mod_license=All Rights Reserved -# Toolchain -minecraft_version=26.3 -minecraft_version_range=[26.3,26.4) -forge_version=66.1.0 -forge_version_range=[66,) -loader_version_range=[66,) -eventbus_version=7.0.6 +# Platform modules built by this project, one per Minecraft version. Each has its own +# gradle.properties with the Minecraft, loader and Java versions. +platforms=forge-26.3 + +# Libraries the common module compiles against. Every supported Minecraft version ships them. +slf4j_version=2.0.17 +jspecify_version=1.0.1 +joml_version=1.10.9 junit_version=5.13.4 diff --git a/settings.gradle b/settings.gradle index eff3ad6..ad88f52 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,3 +3,12 @@ plugins { } rootProject.name = 'mcheli-rework' + +// Version-free code: content, models, HUD scripts, flight maths. No Minecraft on its classpath. +include 'common' + +// One module per Minecraft version, each compiling common's sources together with its own +// rendering, networking, registration and event code. See docs/DEVELOPMENT.md. +for (String platform : providers.gradleProperty('platforms').get().split(',')) { + include platform.trim() +} diff --git a/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java b/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java deleted file mode 100644 index 2341918..0000000 --- a/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java +++ /dev/null @@ -1,193 +0,0 @@ -package com.jnhprojects.mcheli.content; - -import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; -import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; -import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; -import com.jnhprojects.mcheli.content.aircraft.TankDefinition; -import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; -import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.function.Function; -import java.util.stream.Stream; - -/** - * Loaded vehicle definitions, keyed by file name. - * - *

Definitions have to exist before Forge freezes its registries, because every vehicle gets its - * own item. The mod constructor therefore loads them straight from the folder the original zip was - * imported into (see {@link LegacyImporter}), long before the resource manager is available. That - * also means a resource pack cannot add new vehicles; content packs will get their own loading - * path later. - */ -public final class ContentRegistry { - private static final Logger LOGGER = LoggerFactory.getLogger(ContentRegistry.class); - - private final Map helicopters; - private final Map planes; - private final Map tanks; - private final Map vehicles; - private final Map weapons; - private final Map> unhandledKeys; - - private ContentRegistry(Map helicopters, Map planes, - Map tanks, Map vehicles, - Map weapons, Map> unhandledKeys) { - this.helicopters = Collections.unmodifiableMap(helicopters); - this.planes = Collections.unmodifiableMap(planes); - this.tanks = Collections.unmodifiableMap(tanks); - this.vehicles = Collections.unmodifiableMap(vehicles); - this.weapons = Collections.unmodifiableMap(weapons); - this.unhandledKeys = Collections.unmodifiableMap(unhandledKeys); - } - - /** - * @param assetRoot the {@code assets/mcheli} folder; when it does not exist, nothing is loaded - */ - public static ContentRegistry load(Path assetRoot) throws IOException { - Map> unhandled = new TreeMap<>(); - Map weapons = loadFolder(assetRoot.resolve(WeaponDefinition.FOLDER), WeaponDefinition::new, unhandled); - Map helicopters = - loadFolder(assetRoot.resolve(HelicopterDefinition.FOLDER), HelicopterDefinition::new, unhandled); - Map planes = loadFolder(assetRoot.resolve(PlaneDefinition.FOLDER), PlaneDefinition::new, unhandled); - Map tanks = loadFolder(assetRoot.resolve(TankDefinition.FOLDER), TankDefinition::new, unhandled); - Map vehicles = loadFolder(assetRoot.resolve(VehicleDefinition.FOLDER), VehicleDefinition::new, unhandled); - - // The original silently skipped AddWeapon lines naming a weapon it had not loaded. The - // shipped AH-64, KV-2 and M26 do that, so it is only worth a debug line. - for (Map kind : List.of(helicopters, planes, tanks, vehicles)) { - for (AircraftDefinition def : kind.values()) { - List dropped = def.retainWeapons(weapons.keySet()); - if (!dropped.isEmpty()) { - LOGGER.debug("{}/{}: unknown weapons {}", def.folder(), def.name(), dropped); - } - } - } - - LOGGER.info("Loaded {} helicopters, {} planes, {} tanks, {} vehicles and {} weapons", - helicopters.size(), planes.size(), tanks.size(), vehicles.size(), weapons.size()); - if (!unhandled.isEmpty()) { - LOGGER.debug("Definition keys not implemented yet: {}", unhandled.keySet()); - } - return new ContentRegistry(helicopters, planes, tanks, vehicles, weapons, unhandled); - } - - private static Map loadFolder( - Path folder, Function factory, Map> unhandled) throws IOException { - Map out = new LinkedHashMap<>(); - if (!Files.isDirectory(folder)) { - return out; - } - - List files; - try (Stream stream = Files.list(folder)) { - files = stream.filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".txt")) - .sorted() - .toList(); - } - - for (Path file : files) { - String fileName = file.getFileName().toString().toLowerCase(Locale.ROOT); - String name = fileName.substring(0, fileName.length() - 4); - DefinitionReader.Result result = DefinitionReader.read(file, factory.apply(name)); - for (String problem : result.problems()) { - LOGGER.warn("{}/{}: {}", folder.getFileName(), fileName, problem); - } - for (String key : result.unhandledKeys()) { - unhandled.computeIfAbsent(key, k -> new TreeSet<>()).add(name); - } - if (result.isValid()) { - out.put(name, result.definition()); - } - } - return out; - } - - public Collection helicopters() { - return helicopters.values(); - } - - public HelicopterDefinition helicopter(String name) { - return helicopters.get(name); - } - - public Collection planes() { - return planes.values(); - } - - public PlaneDefinition plane(String name) { - return planes.get(name); - } - - public Collection tanks() { - return tanks.values(); - } - - public TankDefinition tank(String name) { - return tanks.get(name); - } - - public Collection vehicles() { - return vehicles.values(); - } - - public VehicleDefinition vehicle(String name) { - return vehicles.get(name); - } - - public Collection weapons() { - return weapons.values(); - } - - public @Nullable WeaponDefinition weapon(String name) { - return weapons.get(name); - } - - /** Every definition of every kind: helicopters, planes, tanks, then vehicles. */ - public List all() { - List all = new ArrayList<>(helicopters.size() + planes.size() + tanks.size() + vehicles.size()); - all.addAll(helicopters.values()); - all.addAll(planes.values()); - all.addAll(tanks.values()); - all.addAll(vehicles.values()); - return all; - } - - /** - * Looks a definition up by name in any kind. Names are unique across kinds in the bundled - * content because each one is also an item id. - */ - public AircraftDefinition any(String name) { - AircraftDefinition def = helicopters.get(name); - if (def == null) { - def = planes.get(name); - } - if (def == null) { - def = tanks.get(name); - } - if (def == null) { - def = vehicles.get(name); - } - return def; - } - - /** Keys found in the files that no handler understood, mapped to the files that use them. */ - public Map> unhandledKeys() { - return unhandledKeys; - } -} diff --git a/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java deleted file mode 100644 index e0bf429..0000000 --- a/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.jnhprojects.mcheli.item; - -import com.jnhprojects.mcheli.MCHeli; -import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; -import com.jnhprojects.mcheli.entity.AircraftEntity; -import com.jnhprojects.mcheli.registry.ModEntities; -import net.minecraft.stats.Stats; -import net.minecraft.world.InteractionHand; -import net.minecraft.world.InteractionResult; -import net.minecraft.world.entity.EntitySpawnReason; -import net.minecraft.world.entity.player.Player; -import net.minecraft.world.item.Item; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.ClipContext; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.gameevent.GameEvent; -import net.minecraft.world.phys.BlockHitResult; -import net.minecraft.world.phys.HitResult; -import net.minecraft.world.phys.Vec3; - -/** Places the vehicle it was generated for, the same way a boat is placed. */ -public class AircraftItem extends Item { - private final String definitionName; - - public AircraftItem(String definitionName, Properties properties) { - super(properties); - this.definitionName = definitionName; - } - - public String definitionName() { - return definitionName; - } - - @Override - public InteractionResult use(Level level, Player player, InteractionHand hand) { - ItemStack stack = player.getItemInHand(hand); - HitResult hit = getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY); - if (!(hit instanceof BlockHitResult blockHit) || hit.getType() != HitResult.Type.BLOCK) { - return InteractionResult.PASS; - } - - AircraftDefinition def = MCHeli.content().any(definitionName); - if (def == null) { - return InteractionResult.FAIL; - } - - AircraftEntity vehicle = ModEntities.typeFor(def).create(level, EntitySpawnReason.SPAWN_ITEM_USE); - if (vehicle == null) { - return InteractionResult.FAIL; - } - Vec3 at = blockHit.getLocation(); - vehicle.setType(def); - vehicle.snapTo(at.x, at.y, at.z, player.getYRot(), 0.0F); - if (!level.noCollision(vehicle, vehicle.getBoundingBox())) { - return InteractionResult.FAIL; - } - - if (!level.isClientSide()) { - level.addFreshEntity(vehicle); - level.gameEvent(player, GameEvent.ENTITY_PLACE, at); - stack.consume(1, player); - } - player.awardStat(Stats.ITEM_USED.get(this)); - return InteractionResult.SUCCESS; - } -} diff --git a/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java deleted file mode 100644 index c58d37e..0000000 --- a/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.jnhprojects.mcheli.registry; - -import com.jnhprojects.mcheli.MCHeli; -import com.jnhprojects.mcheli.content.ContentRegistry; -import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; -import com.jnhprojects.mcheli.item.AircraftItem; -import net.minecraft.world.item.Item; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.registries.DeferredRegister; -import net.minecraftforge.registries.ForgeRegistries; -import net.minecraftforge.registries.RegistryObject; -import org.jspecify.annotations.Nullable; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** Items are generated from the loaded content, one per vehicle definition. */ -public final class ModItems { - private static final DeferredRegister ITEMS = DeferredRegister.create(ForgeRegistries.ITEMS, MCHeli.MOD_ID); - private static final Map> AIRCRAFT = new LinkedHashMap<>(); - private static final Map>> BY_KIND = new LinkedHashMap<>(); - - private ModItems() { - } - - public static void register(BusGroup modBus, ContentRegistry content) { - for (AircraftDefinition def : content.all()) { - String name = def.name(); - RegistryObject item = ITEMS.register(name, () -> new AircraftItem(name, - new Item.Properties().setId(ITEMS.key(name)).stacksTo(1))); - AIRCRAFT.put(name, item); - BY_KIND.computeIfAbsent(def.kind(), k -> new ArrayList<>()).add(item); - } - ITEMS.register(modBus); - } - - public static @Nullable Item aircraft(String name) { - RegistryObject item = AIRCRAFT.get(name); - return item != null && item.isPresent() ? item.get() : null; - } - - public static Collection> aircraft() { - return Collections.unmodifiableCollection(AIRCRAFT.values()); - } - - /** Items of one kind ({@code helicopter}, {@code plane}, ...), in content order. */ - public static List> ofKind(String kind) { - return Collections.unmodifiableList(BY_KIND.getOrDefault(kind, List.of())); - } -}