first commit
This commit is contained in:
commit
07047c6ee4
53 files changed
+6267
No files matched your search
+32
@@ -0,0 +1,32 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
out/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Minecraft dev runs (worlds, logs, screenshots, configs)
|
||||
run/
|
||||
run-server/
|
||||
runs/
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.vscode/
|
||||
.settings/
|
||||
.project
|
||||
.classpath
|
||||
bin/
|
||||
|
||||
# Third-party binaries used only as reference (large, not ours to redistribute)
|
||||
ressources/*.jar
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -0,0 +1,65 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
@@ -0,0 +1,190 @@
|
||||
# NeoLitematica
|
||||
|
||||
Client-seitiger Schematic-Viewer und Bauassistent für **Minecraft 1.21.1 / NeoForge 21.1.265**. Er liest das
|
||||
originale **`.litematic`-Format** von Litematica, zeigt Blueprints als transparente Vorschau in der Welt und
|
||||
vergleicht sie mit dem tatsächlich Gebauten. Er ist eine eigenständige Neuimplementierung: Fabric, Malilib und
|
||||
Code von Litematica werden nicht verwendet.
|
||||
|
||||
## Installation
|
||||
|
||||
1. `build/libs/neolitematica-1.21.1-1.0.0.jar` in den `mods`-Ordner der ATM10-Instanz kopieren.
|
||||
2. Minecraft starten. **Auf dem Server muss nichts installiert werden**, auch nicht auf Multiplayer-Servern.
|
||||
|
||||
Schematics liegen in `.minecraft/schematics/` (bzw. `<Instanz>/schematics/`). Das ist derselbe Ordner wie bei
|
||||
Litematica, vorhandene Sammlungen funktionieren also direkt.
|
||||
|
||||
## Schnellstart
|
||||
|
||||
1. In einer Welt **Alt+L** drücken (oder **Alt+M** → „Schematic laden…").
|
||||
2. **Importieren…** wählen (Windows-Dateidialog) oder `.litematic`-Dateien ins Minecraft-Fenster ziehen.
|
||||
3. Schematic auswählen → **Hier platzieren** (oder Doppelklick). Die minimale Ecke liegt an deiner Position.
|
||||
4. Verschieben: **Pfeiltasten** (relativ zur Blickrichtung), **Bild↑/Bild↓** für hoch/runter,
|
||||
mit **Strg** in 10er-Schritten. Exakte Koordinaten: **Alt+E** (Placement-Editor).
|
||||
5. Drehen mit **Alt+R**, Spiegeln im Placement-Editor.
|
||||
6. Bauen: Im Standardmodus „Nur fehlende" verschwinden korrekt gesetzte Blöcke sofort aus der Vorschau.
|
||||
Falsche Blöcke werden rot markiert, falsche Ausrichtung/Zustand orange.
|
||||
|
||||
## Tastenbelegung
|
||||
|
||||
Alle Tasten lassen sich unter Optionen → Steuerung → „NeoLitematica" ändern (auch mit dem ATM10-Mod *Controlling*).
|
||||
Die Standardbelegung nutzt Alt-Kombinationen, weil `M` in ATM10 bereits von FTB Chunks belegt ist.
|
||||
|
||||
| Taste | Funktion |
|
||||
|---|---|
|
||||
| Alt+M | Hauptmenü (Placements, Rendering-Einstellungen) |
|
||||
| Alt+L | Schematic-Übersicht (Import, Laden, Umbenennen, Löschen) |
|
||||
| Alt+E | Ausgewähltes Placement bearbeiten |
|
||||
| Pfeiltasten | Placement vor/zurück/links/rechts (relativ zur Blickrichtung), Strg = ×10 |
|
||||
| Bild↑ / Bild↓ | Placement hoch / runter |
|
||||
| Alt+R | Im Uhrzeigersinn drehen |
|
||||
| Alt+P | Placement an Spielerposition setzen |
|
||||
| Alt+N | Nächstes Placement auswählen |
|
||||
| Alt+H | Alle Schematics ein-/ausblenden |
|
||||
| Alt+V | Rendering-Modus wechseln |
|
||||
| *(nicht belegt)* | Gegen Uhrzeigersinn drehen, Spiegeln X/Y/Z, Placement ein-/ausblenden, Position zurücksetzen |
|
||||
|
||||
## Oberfläche
|
||||
|
||||
- **Hauptmenü (Alt+M):** Liste aller Placements der aktuellen Dimension mit Position, Größe und Drehung.
|
||||
Bearbeiten, ein-/ausblenden, an eigene Position setzen, entfernen. Dazu Rendering an/aus, Modus,
|
||||
Deckkraft, Render-Distanz sowie Links zu den Einstellungen und zur Steuerung.
|
||||
- **Schematic-Übersicht (Alt+L):** alle `.litematic`-Dateien (auch in Unterordnern) mit Name, Autor,
|
||||
Abmessungen, Blockanzahl, Volumen, Regionen, Version und Dateigröße. Import per Dialog oder Drag & Drop,
|
||||
Ordner öffnen, umbenennen, löschen.
|
||||
- **Placement-Editor (Alt+E):** X/Y/Z-Felder mit −10/−1/+1/+10 (Umschalt = ×10), „Zu meiner Position",
|
||||
„Position zurücksetzen", „Als Ursprung merken", Drehung 0/90/180/270°, Spiegeln X/Z/Y, Sichtbarkeit,
|
||||
Blockvergleich. Die Welt wird dabei weder pausiert noch weichgezeichnet, Änderungen sind live sichtbar.
|
||||
- **HUD (oben links):** ausgewähltes Placement, Position, Größe, Drehung/Spiegelung, fehlende/falsche/korrekte Blöcke.
|
||||
- **Einstellungen:** Mods → NeoLitematica → Konfiguration, oder Hauptmenü → „Einstellungen…".
|
||||
Gespeichert in `config/neolitematica-client.toml`.
|
||||
|
||||
## Rendering-Modi und Farben
|
||||
|
||||
| Modus | Verhalten |
|
||||
|---|---|
|
||||
| Alle Blöcke | Alle Blöcke als Geisterblöcke, ohne Vergleich mit der Welt |
|
||||
| Nur fehlende *(Standard)* | Geisterblöcke nur für fehlende Blöcke; korrekte Blöcke werden ausgeblendet |
|
||||
| Prüfen | Wie „Nur fehlende", dazu korrekt = grün, überzählig = magenta |
|
||||
|
||||
Rot = falscher Block, orange = richtiger Block mit falschem Zustand (z. B. Treppenrichtung), violett = Block-Typ
|
||||
nicht installiert. Ersetzbare Blöcke wie Gras, Schneeschicht oder Wasser zählen als „fehlend", nicht als „falsch".
|
||||
|
||||
## Placements und Speicherung
|
||||
|
||||
- Beliebig viele Placements gleichzeitig, jedes unabhängig positionierbar und ausblendbar.
|
||||
- Gespeichert pro Welt bzw. Server in `config/neolitematica/placements/<welt>.json`, inklusive Dimension,
|
||||
Position, ursprünglicher Position, Drehung, Spiegelung und Sichtbarkeit. Nach einem Neustart wird alles
|
||||
wiederhergestellt.
|
||||
- Die `.litematic`-Datei wird direkt verwendet, es gibt keine Konvertierung.
|
||||
|
||||
## Technische Umsetzung
|
||||
|
||||
```
|
||||
de.lucas.litematica
|
||||
├── NeoLitematica Einstieg, @Mod(dist = CLIENT), Event-Registrierung
|
||||
├── schematic/ .litematic lesen (NBT, Bit-Array, Paletten, DataFixer), Dateiverwaltung, Cache
|
||||
├── placement/ Placements, Transformation (Drehung/Spiegelung), Persistenz, Vergleichsstatistik
|
||||
├── render/ Chunk-basiertes Mesh-Rendering, Blockvergleich, Block-Entity-Geister
|
||||
├── gui/ Hauptmenü, Schematic-Übersicht, Placement-Editor, HUD
|
||||
├── input/ Tastenbelegung und Hotkey-Verarbeitung
|
||||
├── config/ NeoForge ModConfigSpec (Client)
|
||||
├── mixin/ genau ein Mixin (siehe unten)
|
||||
└── dev/ automatischer Ingame-Test, nur mit System-Property aktiv
|
||||
```
|
||||
|
||||
**Dateiformat.** `.litematic` ist gzip-komprimiertes NBT: `Version`, `MinecraftDataVersion`, `Metadata`, und
|
||||
`Regions` mit je `Position`, `Size` (auch negativ), `BlockStatePalette`, `BlockStates` (dicht gepacktes
|
||||
`long[]`, Einträge können über Long-Grenzen reichen, `max(2, ceil(log2(Palettengröße)))` Bit) und
|
||||
`TileEntities`. Index = `(y·sizeZ + z)·sizeX + x`. Dateien älterer Minecraft-Versionen werden über
|
||||
Minecrafts eigenen DataFixer aktualisiert. Unbekannte Blöcke werden erkannt, gezählt, angezeigt und violett
|
||||
markiert. Ungültige oder beschädigte Dateien liefern eine verständliche Fehlermeldung, kein Absturz.
|
||||
|
||||
**Rendering.** Ein Placement wird in 16×16×16-Welt-Sektionen zerlegt. Jede Sektion wird **einmal auf
|
||||
Hintergrund-Threads** zu GPU-Vertex-Buffern gemesht (vanillas `BlockRenderDispatcher` mit echten Blockmodellen,
|
||||
Biom-Farben, Flüssigkeiten) und nur neu gebaut, wenn sich das Placement oder ein Block in genau dieser
|
||||
Sektion ändert. Pro Frame werden nur noch gecachte Buffer gebunden, plus Frustum-Culling und Render-Distanz.
|
||||
Flächen zwischen benachbarten Schematic-Blöcken werden weggelassen. Connected Textures (CTM, Fusion,
|
||||
Connected Glass) verbinden sich innerhalb des Schematics. Die Weltdaten für den Vergleich werden auf dem
|
||||
Main-Thread kopiert, sodass Worker nie auf live Chunk-Daten zugreifen (wie vanillas Chunk-Builder).
|
||||
Gezeichnet wird in `RenderLevelStageEvent.AFTER_TRANSLUCENT_BLOCKS` mit vanilla-Shadern
|
||||
(`rendertype_cutout` + Alpha-Blending über `ColorModulator`). Geisterblöcke sind voll beleuchtet und damit
|
||||
auch nachts und unter Tage sichtbar.
|
||||
|
||||
**Warum ein Mixin?** NeoForge hat kein clientseitiges Event für Blockänderungen. Damit die Vorschau sofort
|
||||
reagiert, wenn ein Block gesetzt oder abgebaut wird, hängt sich ein einzelner `@Inject` an
|
||||
`LevelRenderer#setSectionDirty`, über den vanilla alle Neuzeichnungs-Anforderungen schickt. Das Mixin ist
|
||||
mit `require = 0` markiert: Ersetzt ein anderer Mod die Methode, startet das Spiel trotzdem, und Updates
|
||||
kommen dann nur noch über `ChunkEvent.Load`.
|
||||
|
||||
**Client-only.** `@Mod(dist = Dist.CLIENT)` (die Mod-Klasse wird auf dedizierten Servern nie geladen),
|
||||
`displayTest = "IGNORE_ALL_VERSION"`, das Mixin nur im `client`-Abschnitt, keine Netzwerk-Payloads, und alle
|
||||
Abhängigkeiten in der `neoforge.mods.toml` mit `side = "CLIENT"`. Außer NeoForge gibt es keine Abhängigkeiten.
|
||||
|
||||
**Transformation.** Reihenfolge: Spiegeln Y → X → Z → Drehen um Y, wie bei vanillas Strukturplatzierung.
|
||||
Blockzustände nutzen vanillas `BlockState.mirror/rotate`, das alle Vanilla- und korrekt implementierten
|
||||
Mod-Blöcke unterstützt (Treppen, Türen, Öfen, Truhen, Schienen, …). Ein Unit-Test prüft für alle 32
|
||||
Kombinationen, dass Positions- und Zustandstransformation übereinstimmen.
|
||||
|
||||
## Kompatibilität mit ATM10
|
||||
|
||||
Grundlage ist eine Analyse von `mods-atm10.txt` (464 Mods):
|
||||
|
||||
| Mod(s) | Bewertung |
|
||||
|---|---|
|
||||
| Sodium / Embeddium / Iris / Oculus | **Nicht in ATM10.** Damit läuft die Welt über vanillas Renderer, dessen Schnittstellen hier genutzt werden. |
|
||||
| ImmediatelyFast, ModernFix, FerriteCore | Unkritisch: Es werden nur öffentliche Render-APIs genutzt, Blockmodelle lädt der Mod wie vanilla. |
|
||||
| CTM, Fusion, Connected Glass, Chisel, Rechiseled, Chipped | Unterstützt: Verbindungen werden aus den Schematic-Nachbarn berechnet. |
|
||||
| Macaw's (mcw-*), Supplementaries, viele Deko-Mods | Normale Blockmodelle, werden korrekt dargestellt und gedreht. |
|
||||
| FramedBlocks, Mekanism Covers | **Einschränkung:** Das Tarn-Aussehen hängt an Block-Entity-Daten der echten Welt; in der Vorschau erscheint der leere Rahmen. Der Blockvergleich funktioniert trotzdem. |
|
||||
| Create (+Addons), AE2, Refined Storage, Mekanism, Pipez, LaserIO | **Einschränkung:** Teile, die über Block-Entity-Renderer oder Flywheel gezeichnet werden (Wellen, Zahnräder, Kabelverbindungen), fehlen in der Vorschau teilweise. Der Grundblock und der Vergleich funktionieren. |
|
||||
| FTB Chunks, JourneyMap | Belegen `M` bzw. `J` → Standardtasten von NeoLitematica nutzen Alt+… |
|
||||
| Building Gadgets 2, Construction Sticks, Mining Gadgets | Zeichnen eigene Vorschauen, ohne Konflikt. |
|
||||
| Controlling, KeybindsPurger | Zum Umbelegen der Tasten nutzbar. |
|
||||
|
||||
Kein Mod wird verändert, und Serverkommunikation findet nicht statt. Wer Embeddium oder Oculus nachinstalliert:
|
||||
Das Rendering sollte weiter funktionieren, aber mit aktiven Shaderpacks sind Darstellungsfehler möglich, und
|
||||
Embeddium ersetzt eventuell die Methode des Mixins (dann Updates nur bei Chunk-Ladevorgängen). Getestet ist
|
||||
das nicht.
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- **Drehung um X- und Z-Achse wird nicht angeboten.** Minecraft-Blockzustände können „seitlich gekippte"
|
||||
Treppen, Türen usw. nicht abbilden; eine solche Drehung würde zwangsläufig falsche Blöcke erzeugen.
|
||||
Angeboten werden Drehung um Y in 90°-Schritten sowie Spiegeln an X, Y und Z.
|
||||
- **Spiegeln an Y ist eine Annäherung:** Halbe Blöcke, Stufen, Türen, oben/unten-Ausrichtung,
|
||||
Boden/Decke-Befestigung und Tropfstein werden umgedreht. Blöcke ohne vertikales Gegenstück (z. B.
|
||||
hängende Laternen) behalten ihren Zustand.
|
||||
- **Entities** (Gemälde, Rahmen, Rüstungsständer) im Schematic werden nicht angezeigt.
|
||||
- **Block-Entities** werden für Vanilla-Blöcke (Truhen, Schilder, Betten, Banner, Köpfe, Töpfe, …) transparent
|
||||
dargestellt. Mod-Block-Entities bekommen absichtlich keine Welt-Referenz; schlägt ihr Renderer fehl, wird
|
||||
der Typ für die Sitzung übersprungen und geloggt.
|
||||
- **Blockvergleich** gilt nur für Sektionen innerhalb der Render-Distanz und für geladene Chunks. Die
|
||||
Statistik weist „In der Nähe" aus, solange nicht alles geprüft ist.
|
||||
- **Umbenennen oder Löschen** einer Schematic-Datei trennt bestehende Placements dieser Datei. Beim nächsten
|
||||
Betreten der Welt erscheint eine Fehlermeldung; neu platzieren behebt das.
|
||||
- Kein Speichern neuer Schematics aus der Welt, keine Materialliste und kein automatisches Platzieren
|
||||
(Litematicas „Easy Place"). Die Architektur ist darauf vorbereitet; die Funktionen fehlen aber noch.
|
||||
|
||||
## Aus dem Quellcode bauen
|
||||
|
||||
Voraussetzung: JDK 21.
|
||||
|
||||
```
|
||||
gradlew build # Kompilieren + Unit-Tests → build/libs/neolitematica-1.21.1-1.0.0.jar
|
||||
gradlew test # Nur Unit-Tests (Reader gegen "Another castle", Transformationen)
|
||||
gradlew runClient # Entwicklungs-Client
|
||||
gradlew runSmokeTest # Automatischer Ingame-Test, siehe unten
|
||||
gradlew runServer # Dedizierter Server (vorher EULA in run-server/eula.txt selbst akzeptieren)
|
||||
```
|
||||
|
||||
**Automatischer Ingame-Test** (`runSmokeTest`): erstellt eine Flachwelt, platziert
|
||||
`ressources/Another castle.litematic`, baut über den integrierten Server einen Teil korrekt, falsch und
|
||||
überzählig nach, prüft die Vergleichszahlen in allen Modi, dreht das Placement, öffnet alle Bildschirme,
|
||||
speichert Screenshots nach `run/screenshots/smoke_*.png` und beendet das Spiel. Letztes Ergebnis:
|
||||
`correct=3452 missing=11355 wrongBlock=78 wrongState=20 extra=25` bei 14905 Blöcken, was genau den gesetzten
|
||||
Blöcken entspricht.
|
||||
|
||||
## Lizenz und Herkunft
|
||||
|
||||
MIT. Litematica (LGPLv3, masa) diente nur als funktionale Referenz und für das Dateiformat; es wurde kein
|
||||
Code übernommen.
|
||||
@@ -0,0 +1,92 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'net.neoforged.moddev' version '2.0.148'
|
||||
}
|
||||
|
||||
version = mod_version
|
||||
group = mod_group_id
|
||||
|
||||
base {
|
||||
archivesName = "${mod_id}-${minecraft_version}"
|
||||
}
|
||||
|
||||
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
|
||||
|
||||
neoForge {
|
||||
version = project.neo_version
|
||||
|
||||
parchment {
|
||||
mappingsVersion = project.parchment_mappings_version
|
||||
minecraftVersion = project.parchment_minecraft_version
|
||||
}
|
||||
|
||||
runs {
|
||||
client {
|
||||
client()
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
}
|
||||
// Dedicated server, to verify the client-only mod is harmless there.
|
||||
server {
|
||||
server()
|
||||
programArgument '--nogui'
|
||||
gameDirectory = project.file('run-server')
|
||||
}
|
||||
// Automated smoke test: creates a flat world, loads the sample schematic,
|
||||
// takes a screenshot and quits. See de.lucas.litematica.dev.DevSmokeTest.
|
||||
smokeTest {
|
||||
client()
|
||||
systemProperty 'neolitematica.smoketest', project.file('ressources/Another castle.litematic').absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
mods {
|
||||
"${mod_id}" {
|
||||
sourceSet(sourceSets.main)
|
||||
}
|
||||
}
|
||||
|
||||
unitTest {
|
||||
enable()
|
||||
testedMod = mods."${mod_id}"
|
||||
}
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
systemProperty 'neolitematica.testSchematic', file('ressources/Another castle.litematic').absolutePath
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
|
||||
var replaceProperties = [
|
||||
minecraft_version : minecraft_version,
|
||||
minecraft_version_range: minecraft_version_range,
|
||||
neo_version : neo_version,
|
||||
neo_version_range : neo_version_range,
|
||||
loader_version_range : loader_version_range,
|
||||
mod_id : mod_id,
|
||||
mod_name : mod_name,
|
||||
mod_license : mod_license,
|
||||
mod_version : mod_version,
|
||||
mod_authors : mod_authors,
|
||||
mod_description : mod_description
|
||||
]
|
||||
inputs.properties replaceProperties
|
||||
expand replaceProperties
|
||||
from "src/main/templates"
|
||||
into "build/generated/sources/modMetadata"
|
||||
}
|
||||
sourceSets.main.resources.srcDir generateModMetadata
|
||||
neoForge.ideSyncTask generateModMetadata
|
||||
@@ -0,0 +1,21 @@
|
||||
org.gradle.jvmargs=-Xmx3G
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=false
|
||||
|
||||
minecraft_version=1.21.1
|
||||
minecraft_version_range=[1.21.1]
|
||||
neo_version=21.1.265
|
||||
neo_version_range=[21.1.0,)
|
||||
loader_version_range=[4,)
|
||||
parchment_minecraft_version=1.21.1
|
||||
parchment_mappings_version=2024.11.17
|
||||
|
||||
mod_id=neolitematica
|
||||
mod_name=NeoLitematica
|
||||
mod_license=MIT
|
||||
mod_version=1.0.0
|
||||
mod_group_id=de.lucas.litematica
|
||||
mod_authors=Lucas
|
||||
mod_description=Client-only schematic (.litematic) viewer and building aid for NeoForge.
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
Binary file not shown.
@@ -0,0 +1,464 @@
|
||||
"/mods/[1.21.1] SecurityCraft v1.10.2.1.jar"
|
||||
/mods/[Neoforge]ctov-3.6.3.jar
|
||||
/mods/accelerated-decay-neoforge-21.0.0.jar
|
||||
/mods/actuallyadditions-1.3.26+mc1.21.1.jar
|
||||
/mods/adastra-1.21.1-1.16.26-neoforge.jar
|
||||
/mods/Ad-Astra-Giselle-Addon-neoforge-1.21.1-8.4.jar
|
||||
/mods/additional_lights-neoforge-1.21-2.1.10.jar
|
||||
/mods/AdditionalEnchantedMiner-1.21.1-neoforge-21.1.164.jar
|
||||
/mods/AdvancedAE-1.6.12-1.21.1.jar
|
||||
/mods/AdvancedPeripherals-1.21.1-0.8.1a.jar
|
||||
/mods/ae2ct-1.21.1-1.1.1.jar
|
||||
/mods/ae2importexportcard-1.21.1-1.9.0.jar
|
||||
/mods/ae2jeiintegration-1.2.1.jar
|
||||
/mods/AE2NetworkAnalyzer-1.21-2.1.5-neoforge.jar
|
||||
/mods/ae2wtlib-19.5.1.jar
|
||||
/mods/aeinfinitybooster-neoforge-1.21.1-1.0.0.58.jar
|
||||
/mods/aether-1.21.1-1.5.10-neoforge.jar
|
||||
/mods/AI-Improvements-1.21-0.5.3.jar
|
||||
/mods/AkashicTome-1.8-30.jar
|
||||
/mods/allthearcanistgear-1.21.1-21.3.2.jar
|
||||
/mods/allthecompressed-1.21.1-4.4.0.jar
|
||||
/mods/alltheleaks-1.1.13+1.21.1-neoforge.jar
|
||||
/mods/allthemodium-3.0.1_mc_1.21.1.jar
|
||||
/mods/alltheores-3.2.0_neoforge_1.21.1.jar
|
||||
/mods/allthetweaks-1.21.1-2.10.0.jar
|
||||
/mods/allthewizardgear-1.21.1-1.2.0.jar
|
||||
/mods/almostunified-neoforge-1.21.1-1.4.2.jar
|
||||
/mods/amendments-1.21-2.1.10-neoforge.jar
|
||||
/mods/Apotheosis-1.21.1-8.8.0.jar
|
||||
/mods/ApothicAttributes-1.21.1-2.10.1.jar
|
||||
/mods/ApothicEnchanting-1.21.1-1.6.2.jar
|
||||
/mods/ApothicSpawners-1.21.1-1.4.0.jar
|
||||
/mods/appleskin-neoforge-mc1.21-3.0.9.jar
|
||||
/mods/appliedenergistics2-19.2.17.jar
|
||||
/mods/AppliedFlux-1.21-2.1.5-neoforge.jar
|
||||
/mods/Applied-Mekanistics-1.6.3.jar
|
||||
/mods/Aquaculture-1.21.1-2.7.21.jar
|
||||
/mods/architectury-13.0.11-neoforge.jar
|
||||
/mods/ars_additions-1.21.1-21.3.0.jar
|
||||
/mods/ars_controle-1.21.1-1.6.16.jar
|
||||
/mods/ars_creo-1.21.1-5.4.0.jar
|
||||
/mods/ars_elemancy-1.21.1-1.18.3.jar
|
||||
/mods/ars_elemental-1.21.1-0.7.10.1.jar
|
||||
/mods/ars_nouveau-1.21.1-5.13.1.jar
|
||||
/mods/ars_ocultas-1.21.1-2.6.1.jar
|
||||
/mods/ars_polymorphia-1.0.3.jar
|
||||
/mods/ars_technica-1.21.1-2.7.6.jar
|
||||
/mods/ars_unification-1.2.21.jar
|
||||
/mods/arseng-2.1.1-beta.jar
|
||||
/mods/artifacts-neoforge-13.2.3.jar
|
||||
/mods/athena-neoforge-1.21.1-4.0.6.jar
|
||||
/mods/atlas_api-1.21.1-1.2.0.jar
|
||||
/mods/attributefix-neoforge-1.21.1-21.1.3.jar
|
||||
/mods/auroral-1.21.1-1.1.2.jar
|
||||
/mods/balm-neoforge-1.21.1-21.0.65.jar
|
||||
/mods/baubley-heart-canisters-1.21.1-1.4.2.jar
|
||||
/mods/bellsandwhistles-0.4.7-1.21.1.jar
|
||||
/mods/better-advanced-tooltips-2101.1.0-build.5.jar
|
||||
/mods/better-compatability-checker-neoforge-21.1.8.jar
|
||||
/mods/bibliobiomes-1.21.1-1.6.1.jar
|
||||
/mods/bibliocraft-1.21.1-1.6.5.jar
|
||||
/mods/bibliowoods-1.21.1-1.6.2.jar
|
||||
/mods/blockui-1.0.212-1.21.1-snapshot.jar
|
||||
/mods/blueflame-1.21.1-1.1.1.jar
|
||||
/mods/bookshelf-neoforge-1.21.1-21.1.81.jar
|
||||
/mods/borderless-neoforge-1.21.1-1.7.5_1-all.jar
|
||||
/mods/botanypotsmystical-neoforge-1.21.1-21.1.12.jar
|
||||
/mods/botanypots-neoforge-1.21.1-21.1.44.jar
|
||||
/mods/botanytrees-neoforge-1.21.1-21.1.7.jar
|
||||
/mods/BrandonsCore-1.21.1-3.2.1.309.jar
|
||||
/mods/BridgingMod-2.6.2+1.21.1.neoforge-release.jar
|
||||
/mods/buildinggadgets2-1.3.9.jar
|
||||
/mods/bwncr-neoforge-1.21.1-3.20.4.jar
|
||||
/mods/Byzantine-1.21.1-51.jar
|
||||
/mods/cabletiers-neoforge-1.21.1-0.6.14.jar
|
||||
/mods/caelus-neoforge-7.0.1+1.21.1.jar
|
||||
/mods/camol-1.21.1-0.3.1.jar
|
||||
/mods/catjammies-1.21-1.7.1.jar
|
||||
/mods/cc-tweaked-1.21.1-forge-1.113.1.jar
|
||||
/mods/cc-tweaked-1.21.1-forge-1.120.2.jar
|
||||
/mods/charginggadgets-1.14.1.jar
|
||||
/mods/chipped-neoforge-1.21.1-4.0.2.jar
|
||||
/mods/chisel-neoforge-2.0.1+mc1.21.1.jar
|
||||
/mods/chromacarvings-1.21.1-0.2.0.jar
|
||||
/mods/cleanswing-1.10-1.21.jar
|
||||
/mods/cloth-config-15.0.140-neoforge.jar
|
||||
/mods/CloudGlass-1.21.1-1.0.2.jar
|
||||
/mods/Clumps-neoforge-1.21.1-19.0.0.1.jar
|
||||
/mods/cobblegengalore-1.21.1-0.2.9.jar
|
||||
/mods/cobweb-neoforge-1.21-1.4.0.jar
|
||||
/mods/CodeChickenLib-1.21.1-4.6.1.529.jar
|
||||
/mods/comforts-neoforge-9.0.5+1.21.1.jar
|
||||
/mods/commoncapabilities-1.21.1-neoforge-2.11.6.jar
|
||||
/mods/common-storage-lib-neoforge-1.21.1-0.0.10.jar
|
||||
/mods/compactmachines-neoforge-7.0.81.jar
|
||||
/mods/connectedglass-1.1.14-neoforge-mc1.21.jar
|
||||
/mods/connectivity-1.21.1-7.6.jar
|
||||
/mods/ConstructionSticks-1.21.1-1.5.0.jar
|
||||
/mods/Controlling-neoforge-1.21.1-19.0.5.jar
|
||||
/mods/cookingforblockheads-neoforge-1.21.1-21.1.24.jar
|
||||
/mods/copycats-3.0.9+mc.1.21.1-neoforge.jar
|
||||
/mods/Corgilib-NeoForge-1.21.1-5.0.0.9.jar
|
||||
/mods/cosmeticarmorreworked-1.21.1-v1-neoforge.jar
|
||||
/mods/crafting_on_a_stick-1.21.0.6.jar
|
||||
/mods/craftingtweaks-neoforge-1.21.1-21.1.11.jar
|
||||
/mods/CrashAssistant-neoforge-1.20.6-1.21.4-1.11.12.jar
|
||||
/mods/crashutilities-9.0.4.jar
|
||||
/mods/create_aquatic_ambitions-1.21.1-2.0.4.jar
|
||||
/mods/create_hypertube-0.6.0-NEOFORGE.jar
|
||||
/mods/create-1.21.1-6.0.10.jar
|
||||
/mods/createaddition-1.7.1.jar
|
||||
/mods/CreateDragonsPlus-1.11.9.jar
|
||||
/mods/create-enchantment-industry-2.5.4.jar
|
||||
/mods/CreeperOverhaul-neoforge-1.21.1-4.0.6.jar
|
||||
/mods/cristellib-neoforge-1.21.1-3.1.7.jar
|
||||
/mods/cryonicconfig-neoforge-1.0.0+mc1.21.11.jar
|
||||
/mods/crystalix-3.0.1.jar
|
||||
/mods/CTM-1.21-1.2.1+3.jar
|
||||
/mods/Cucumber-1.21.1-8.0.16.jar
|
||||
/mods/cupboard-1.21.1-4.2.jar
|
||||
/mods/curios-neoforge-9.5.1+1.21.1.jar
|
||||
/mods/cyclopscore-1.21.1-neoforge-1.30.0.jar
|
||||
/mods/deeperdarker-neoforge-1.21.1-1.4.1.jar
|
||||
/mods/deimos-1.21.1-neoforge-2.7.jar
|
||||
/mods/DimStorage-1.21.1-9.1.1.jar
|
||||
/mods/disenchanting_table-merged-1.21.1-5.0.2.jar
|
||||
/mods/domum-ornamentum-1.0.236-snapshot-main.jar
|
||||
/mods/Draconic-Evolution-1.21.1-3.1.4.633.jar
|
||||
/mods/dummmmmmy-1.21-2.1.2-neoforge.jar
|
||||
/mods/DungeonCrawl-NeoForge-1.21-2.3.17.jar
|
||||
/mods/DungeonsArise-1.21.1-2.1.68-release.jar
|
||||
/mods/dyenamics-1.21.1-3.4.2.jar
|
||||
/mods/dyenamicsandfriends-1.21.1-2.2.2.jar
|
||||
/mods/dysoncubeproject-1.0.5.jar
|
||||
/mods/easy-villagers-neoforge-1.21.1-1.1.42.jar
|
||||
/mods/EdivadLib-1.21-3.0.0.jar
|
||||
/mods/elevatorid-neoforge-1.21.1-1.11.4.jar
|
||||
/mods/enchdesc-neoforge-1.21.1-21.1.11.jar
|
||||
/mods/enderdrives-neoforge-1.21.1-1.5.23.jar
|
||||
/mods/enderio-8.2.12-beta.jar
|
||||
/mods/endermanoverhaul-neoforge-1.21.1-2.0.3.jar
|
||||
/mods/EnderStorage-1.21.1-2.13.0.191.jar
|
||||
/mods/energymeter-neoforge-1.21.1-0.5.2.jar
|
||||
/mods/entangled-1.3.21-neoforge-mc1.21.jar
|
||||
/mods/eternalstarlight-0.9.1+1.21.1+neoforge.jar
|
||||
/mods/everythingcopper-1.21.1-2.4.5.jar
|
||||
/mods/evilcraft-1.21.1-neoforge-1.2.96-1106.jar
|
||||
/mods/expandedae-2.1.3.jar
|
||||
/mods/experiencelib-1.21.1-1.2.1.jar
|
||||
/mods/ExplorersCompass-1.21.1-3.4.0-neoforge.jar
|
||||
"/mods/Explorify v1.6.5.mod.jar"
|
||||
/mods/ExtendedAE-1.21-2.2.38-neoforge.jar
|
||||
/mods/extended-industrialization-1.16.2-1.21.1.jar
|
||||
/mods/ExtraDisks-1.21.1-4.0.15.jar
|
||||
/mods/ExtraStorage-1.21.1-5.0.10.jar
|
||||
/mods/ExtremeReactors2-1.21.1-2.4.28.jar
|
||||
/mods/factory_blocks-neoforge-1.4.0+mc1.21.1.jar
|
||||
/mods/FarmersDelight-1.21.1-1.3.4.jar
|
||||
/mods/farmingforblockheads-neoforge-1.21.1-21.1.13.jar
|
||||
/mods/FastFurnace-1.21.1-9.0.1.jar
|
||||
/mods/FastSuite-1.21.1-6.0.7.jar
|
||||
/mods/FastWorkbench-1.21.1-9.1.3.jar
|
||||
/mods/ferritecore-7.0.3-neoforge.jar
|
||||
/mods/fireproofboats-1.21.1-1.0.4.jar
|
||||
/mods/flickerfix-1.21.1-6.1.0.jar
|
||||
/mods/FluxNetworks-1.21.1-8.0.0.jar
|
||||
/mods/forbidden_arcanus-2.6.1.jar
|
||||
/mods/formations-1.0.4-neoforge-mc1.21.jar
|
||||
/mods/formationsnether-1.0.5b-mc1.21+.jar
|
||||
/mods/formationsoverworld-1.0.5c-mc1.21+.jar
|
||||
/mods/FramedBlocks-10.6.2.jar
|
||||
/mods/framework-neoforge-1.21.1-0.13.11.jar
|
||||
/mods/ftb-chunks-neoforge-2101.1.22.jar
|
||||
/mods/ftb-essentials-neoforge-2101.1.10.jar
|
||||
/mods/ftb-filter-system-neoforge-21.1.4.jar
|
||||
/mods/ftb-jei-extras-21.1.7.jar
|
||||
/mods/ftb-library-neoforge-2101.1.36.jar
|
||||
/mods/ftbquestslangsplitter-1.0.7.jar
|
||||
/mods/ftb-quests-neoforge-2101.1.36.jar
|
||||
/mods/ftb-ranks-neoforge-2101.1.5.jar
|
||||
/mods/ftb-teams-neoforge-2101.1.11.jar
|
||||
/mods/ftb-ultimine-neoforge-2101.1.15.jar
|
||||
/mods/ftb-xmod-compat-neoforge-21.1.12.jar
|
||||
/mods/fuelgoeshere-1.21.1-1.2.0.jar
|
||||
/mods/functionalstorage-1.21.1-1.5.8.jar
|
||||
/mods/fusion-1.3.15b-neoforge-mc1.21.1.jar
|
||||
/mods/fzzy_config-0.7.7+1.21+neoforge.jar
|
||||
/mods/GatewaysToEternity-1.21.1-5.1.0.jar
|
||||
/mods/geckolib-neoforge-1.21.1-4.9.3.jar
|
||||
/mods/generatorgalore-1.21.1-1.7.0.jar
|
||||
/mods/getittogetherdrops-neoforge-1.21.5-1.4.jar
|
||||
/mods/Glassential-renewed-1.21.1-3.4.7.jar
|
||||
/mods/Glodium-1.21-2.2-neoforge.jar
|
||||
/mods/GravitationalModulatingAdditionalUnit-1.21.1-6.4.jar
|
||||
/mods/guideme-21.1.19.jar
|
||||
/mods/handcrafted-neoforge-1.21.1-4.0.3.jar
|
||||
/mods/hardenedarmadillos-1.21.1-0.2.0.jar
|
||||
/mods/HellishTrials-neoforge-1.0.5.jar
|
||||
/mods/herbsandharvest-1.21.1-21.jar
|
||||
/mods/heyberryshutup-1.21.0-2.0.4.jar
|
||||
/mods/HostileNeuralNetworks-1.21.1-6.5.1.jar
|
||||
/mods/iceandfire-2.0-beta.17.jar
|
||||
/mods/illagerwarship-1.0.1-neoforge-1.21.1.jar
|
||||
/mods/imfast-NEOFORGE-1.21.1-1.0.3.jar
|
||||
/mods/ImmediatelyFast-NeoForge-1.6.14+1.21.1.jar
|
||||
/mods/Immersive-Energistics-1.1.0-beta.jar
|
||||
/mods/ImmersiveEngineering-1.21.1-12.4.2-194.jar
|
||||
/mods/incontrol-1.21-10.3.0.jar
|
||||
/mods/industrialforegoing-1.21-3.6.39.jar
|
||||
/mods/industrial-foregoing-souls-1.21.1-1.10.7.jar
|
||||
/mods/industrialization_overdrive-1.14.0+1.21.1.jar
|
||||
/mods/integratedcrafting-1.21.1-neoforge-1.7.2.jar
|
||||
/mods/integrateddynamics-1.21.1-neoforge-1.37.1-2160.jar
|
||||
/mods/integratedscripting-1.21.1-neoforge-1.0.27.jar
|
||||
/mods/integratedterminals-1.21.1-neoforge-1.10.0.jar
|
||||
/mods/integratedtunnels-1.21.1-neoforge-1.12.0-792.jar
|
||||
/mods/interdimensionalwirelesstransmitter-neoforge-1.21.1-0.1.5.jar
|
||||
/mods/invasiveopts-1.0.24.jar
|
||||
/mods/invtweaks-1.21.1-1.3.2.jar
|
||||
/mods/IrisSearch-1.8.1-neoforge.jar
|
||||
/mods/ironfurnaces-neoforge-1.21.1-4.3.2.jar
|
||||
/mods/IronJetpacks-1.21.1-8.0.11.jar
|
||||
/mods/irons_jewelry-1.21.1-2.0.2.jar
|
||||
/mods/irons_lib-1.21.1-2.1.1.jar
|
||||
/mods/irons_spellbooks-1.21.1-3.16.3.jar
|
||||
/mods/itemcollectors-1.1.10-neoforge-mc1.21.jar
|
||||
/mods/Jade-1.21.1-NeoForge-15.10.6.jar
|
||||
/mods/jearchaeology-1.21.1-1.2.0.jar
|
||||
/mods/jei-1.21.1-neoforge-19.57.0.446.jar
|
||||
/mods/jeistuff-1.21.1-1.2.1.jar
|
||||
/mods/journeymap-neoforge-1.21.1-6.0.9.jar
|
||||
/mods/jumpboat-1.21.0-1.0.5.jar
|
||||
/mods/jupiter-2.3.7-1.21.1-neoforge.jar
|
||||
/mods/justdirethings-1.5.7.jar
|
||||
/mods/JustEnoughMekanismMultiblocks-1.21.1-7.21.jar
|
||||
/mods/JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar
|
||||
/mods/KeybindsPurger-1.4.0-neoforge-1.21.1.jar
|
||||
/mods/kotlinforforge-5.12.0-all.jar
|
||||
/mods/kubejs-neoforge-2101.7.2-build.374.jar
|
||||
/mods/kubejstweaks-1.0.6.jar
|
||||
"/mods/L_Ender's Cataclysm 1.21.1-3.33.jar"
|
||||
/mods/laserbridges-1.21.1-neoforge-6.jar
|
||||
/mods/laserio-1.9.11.jar
|
||||
/mods/lionfishapi-3.1.jar
|
||||
/mods/lithostitched-1.8.0-neoforge-21.1.jar
|
||||
/mods/little-big-redstone-1.9.11-1.21.1.jar
|
||||
/mods/livingthings-neoforge-1.21.1-2.3.2.jar
|
||||
/mods/lmft-1.1.1+1.21.9-neoforge.jar
|
||||
/mods/logisticsnetworks-1.21.1-1.16.3.jar
|
||||
/mods/lootr-neoforge-1.21.1-1.11.38.126.jar
|
||||
/mods/luminax-1.3.1_neoforge_1.21.1.jar
|
||||
/mods/mahoutsukai-1.21.1-v1.36.28.jar
|
||||
/mods/mcjtylib-1.21-9.0.21.jar
|
||||
/mods/mcw-bridges-3.1.2-mc1.21.1neoforge.jar
|
||||
/mods/mcw-doors-1.1.5-mc1.21.1neoforge.jar
|
||||
/mods/mcw-furniture-3.4.1-mc1.21.1neoforge.jar
|
||||
/mods/mcw-holidays-1.1.2-mc1.21.1neoforge.jar
|
||||
/mods/mcw-lights-1.1.5-mc1.21.1neoforge.jar
|
||||
/mods/mcw-mcwfences-1.2.1-mc1.21.1neoforge.jar
|
||||
/mods/mcw-mcwpaths-1.1.1-mc1.21.1neoforge.jar
|
||||
/mods/mcw-mcwstairs-1.0.2-mc1.21.1neoforge.jar
|
||||
/mods/mcw-mcwwindows-2.4.2-mc1.21.1neoforge.jar
|
||||
/mods/mcw-roofs-2.3.2-mc1.21.1neoforge.jar
|
||||
/mods/mcw-trapdoors-1.1.5-mc1.21.1neoforge.jar
|
||||
/mods/Measurements-neoforge-1.21.1-3.0.3.jar
|
||||
/mods/megacells-4.11.0.jar
|
||||
/mods/Mekanism-1.21.1-10.7.19.85.jar
|
||||
/mods/mekanismcovers-1.3-BETA+1.21.jar
|
||||
/mods/MekanismGenerators-1.21.1-10.7.19.85.jar
|
||||
/mods/MekanismTools-1.21.1-10.7.19.85.jar
|
||||
/mods/mekanisticrouters-1.2.0.jar
|
||||
/mods/mekmm-1.21.1-1.4.1.jar
|
||||
/mods/melody_neoforge_1.0.10_MC_1.21.jar
|
||||
/mods/memorysettings-1.21-6.0.jar
|
||||
/mods/merequester-neoforge-1.21.1-1.5.0.jar
|
||||
/mods/merrymaking-1.21.1-16.jar
|
||||
/mods/mezz_config-1.21.1-neoforge-0.6.3.jar
|
||||
/mods/mffs-5.4.44.jar
|
||||
/mods/mifa-neoforge-1.21.x-2.1.0.jar
|
||||
/mods/minecolonies-1.1.1396-1.21.1-snapshot.jar
|
||||
/mods/mininggadgets-1.18.8.jar
|
||||
/mods/mob_grinding_utils-1.1.10+mc1.21.1.jar
|
||||
/mods/Modern-Dynamics-0.9.6.jar
|
||||
/mods/modernfix-neoforge-5.27.24+mc1.21.1.jar
|
||||
/mods/Modern-Industrialization-2.5.8.jar
|
||||
/mods/modonomicon-1.21.1-neoforge-1.120.4.jar
|
||||
/mods/ModularBees-1.21.1-3.4-neoforge.jar
|
||||
/mods/modular-routers-13.2.7+mc1.21.1.jar
|
||||
/mods/monolib-neoforge-1.21.1-4.1.0.jar
|
||||
/mods/MoogsEndStructures-universal-1.21-2.1.1.jar
|
||||
/mods/MoogsNetherStructures-universal-1.21-3.1.1.jar
|
||||
/mods/MoogsSoaringStructures-universal-1.21-2.2.0.jar
|
||||
/mods/MoogsStructureLib-neoforge-1.21.1-3.3.1.jar
|
||||
/mods/MoogsVoyagerStructures-universal-1.21-5.1.2.jar
|
||||
/mods/moonlight-1.21.1-3.6.8-neoforge.jar
|
||||
/mods/moredragoneggs-5.0.jar
|
||||
/mods/morered-1.21.1-6.0.0.3.jar
|
||||
/mods/MoreRed-CCT-Compat-1.21.1-1.3.0.jar
|
||||
/mods/mostructures-neoforge-1.6.0+1.21.1.jar
|
||||
/mods/MouseTweaks-neoforge-mc1.21-2.26.1.jar
|
||||
/mods/multipiston-1.2.58-1.21.1.jar
|
||||
/mods/MysticalAgradditions-1.21.1-8.0.14.jar
|
||||
/mods/MysticalAgriculture-1.21.1-8.0.28.jar
|
||||
/mods/MysticalCustomization-1.21.1-6.0.2.jar
|
||||
/mods/NaturesAura-41.10.jar
|
||||
/mods/NaturesCompass-1.21.1-3.4.0-neoforge.jar
|
||||
/mods/neovitae-1.21.1-1.1.26.jar
|
||||
/mods/netherportalfix-neoforge-1.21.1-21.1.1.jar
|
||||
/mods/NoChatReports-NEOFORGE-1.21.1-v2.9.1.jar
|
||||
/mods/not_enough_glyphs-1.21.1-4.6.2.jar
|
||||
/mods/novillagerdm-1.21.1-6.0.0.jar
|
||||
/mods/Nullscape_1.21.x_v1.2.14.jar
|
||||
/mods/observable-5.4.4.jar
|
||||
/mods/occultism_kubejs-1.21.1-neoforge-1.11.0.jar
|
||||
/mods/occultism-1.21.1-neoforge-1.224.4.jar
|
||||
/mods/OctoLib-NEOFORGE-0.6.2+1.21.jar
|
||||
/mods/Oh-The-Biomes-Weve-Gone-NeoForge-2.6.0.jar
|
||||
/mods/Oh-The-Trees-Youll-Grow-neoforge-1.21.1-5.3.2.jar
|
||||
/mods/omegaconfig-neoforge-1.5.1.jar
|
||||
/mods/OpenLoader-neoforge-1.21.1-21.1.5.jar
|
||||
/mods/oracle_index-neoforge-1.4.0.jar
|
||||
/mods/oritech-neoforge-1.21.1-1.2.12.jar
|
||||
/mods/overlapless-1.21.1-1.2.0.jar
|
||||
/mods/overloadedarmorbar-neoforge-1.21-2.jar
|
||||
/mods/owo-lib-neoforge-0.12.15.5-beta.1+1.21.jar
|
||||
/mods/pamhc2crops-NEOFORGE-1.21.1-1.0.9.jar
|
||||
/mods/pamhc2foodcore-NEOFORGE-1.21.1-1.0.4.jar
|
||||
/mods/pamhc2foodextended-NEOFORGE-1.21.1-1.0.0.jar
|
||||
/mods/pamhc2trees-NEOFORGE-1.21.1-1.0.9.jar
|
||||
/mods/Patchouli-1.21.1-93-NEOFORGE.jar
|
||||
/mods/pipez-neoforge-1.21.1-1.2.31.jar
|
||||
/mods/Placebo-1.21.1-9.9.2.jar
|
||||
/mods/player-animation-lib-forge-2.0.4+1.21.1.jar
|
||||
/mods/pneumaticcraft-repressurized-8.2.23+mc1.21.1.jar
|
||||
/mods/pocketstorage-1.2.5+1.21.1-b3.jar
|
||||
/mods/polyeng-0.4.1.jar
|
||||
/mods/polylib-2100.1.0-build.183-neoforge.jar
|
||||
/mods/polymorph-neoforge-1.2.0+1.21.1.jar
|
||||
/mods/ponderjs-neoforge-1.21.1-2.4.0.jar
|
||||
/mods/potionsmaster-21.1.113-1.0.1.jar
|
||||
/mods/Powah-6.2.10.jar
|
||||
/mods/prickle-neoforge-1.21.1-21.1.11.jar
|
||||
/mods/productivebees-1.21.1-13.13.5.jar
|
||||
/mods/productivemetalworks-1.21.1-1.15.1.jar
|
||||
/mods/productivetrees-1.21.1-1.1.1.jar
|
||||
/mods/pylons-1.21.1-5.4.2.jar
|
||||
/mods/railcraft-reborn-1.21.1-1.2.10.jar
|
||||
/mods/rangedpumps-1.3.0.jar
|
||||
/mods/rebind_narrator-1.21.1-neoforge-2026.08.31.jar
|
||||
/mods/rechiseled_chipped-2.0-1.21.1.jar
|
||||
/mods/rechiseled-1.2.6-neoforge-mc1.21.jar
|
||||
/mods/rechiseledcreate-1.1.1-neoforge-mc1.21.jar
|
||||
/mods/redstonepen-1.21-neoforge-1.11.42.jar
|
||||
/mods/refinedstorage-curios-integration-1.0.0.jar
|
||||
/mods/refinedstorage-jei-integration-neoforge-1.0.0.jar
|
||||
/mods/refinedstorage-mekanism-integration-1.1.1.jar
|
||||
/mods/refinedstorage-neoforge-2.0.9.jar
|
||||
/mods/refinedstorage-quartz-arsenal-neoforge-1.0.8.jar
|
||||
/mods/refined-types-1.21.1-0.3.2.jar
|
||||
/mods/refurbished_furniture-neoforge-1.21.1-1.0.22.jar
|
||||
/mods/regions_unexplored-neoforge-1.21.1-0.5.9.jar
|
||||
/mods/relics-1.21.1-0.12.8.jar
|
||||
/mods/reliquary-1.21.1-2.0.80.1570.jar
|
||||
/mods/reliquified_artifacts-1.21.1-1.0.8.jar
|
||||
/mods/repeatable_trial_vaults-neoforge-1.21-1.0.3.jar
|
||||
/mods/repurposed_structures-7.5.22+1.21.1-neoforge.jar
|
||||
/mods/resourcefulconfig-neoforge-1.21-3.0.11.jar
|
||||
/mods/resourcefullib-neoforge-1.21-3.0.12.jar
|
||||
/mods/restrictions-1.21-7.0.3.jar
|
||||
/mods/rftoolsbase-1.21-6.0.11.jar
|
||||
/mods/rftoolsbuilder-1.21-7.0.6.jar
|
||||
/mods/rftoolspower-1.21-7.0.6.jar
|
||||
/mods/rftoolsstorage-1.21-6.0.5.jar
|
||||
/mods/rftoolsutility-1.21-7.0.12.jar
|
||||
/mods/rhino-2101.2.8-build.91.jar
|
||||
/mods/RootsClassic-1.21.1-1.5.10.jar
|
||||
/mods/sawmill-1.21-1.8.1-neoforge.jar
|
||||
/mods/ScalableCatsForce-NeoForge-3.7.1-build-11-with-library.jar
|
||||
/mods/Searchables-neoforge-1.21.1-1.0.2.jar
|
||||
/mods/sgearmetalworks-1.21.1-1.5.0.jar
|
||||
/mods/shiny-1.21.1-1.3.0.jar
|
||||
/mods/shrink-2.0.1.47-neoforge.jar
|
||||
/mods/silent-gear-1.21.1-neoforge-4.2.1.1.jar
|
||||
/mods/silentgems-1.21.1-neoforge-5.1.3.jar
|
||||
/mods/silent-lib-1.21.1-neoforge-10.6.0.jar
|
||||
/mods/simplemagnets-1.1.12c-neoforge-mc1.21.jar
|
||||
/mods/simplylight-1.5.3+1.21.1-b4.jar
|
||||
/mods/SmartBrainLib-neoforge-1.21.1-1.16.11.jar
|
||||
/mods/solcarrot-1.21.1-1.16.6.jar
|
||||
/mods/sophisticatedbackpacks-1.21.1-3.26.3.2158.jar
|
||||
/mods/sophisticatedbackpackscreateintegration-1.21.1-0.2.0.168.jar
|
||||
/mods/sophisticatedcore-1.21.1-1.5.1.2341.jar
|
||||
/mods/sophisticatedstorage-1.21.1-1.5.91.2127.jar
|
||||
/mods/sophisticatedstoragecreateintegration-1.21.1-0.1.21.209.jar
|
||||
/mods/sophisticatedstorageinmotion-1.21.1-0.10.34.358.jar
|
||||
/mods/soulplied_energistics-1.0.3.jar
|
||||
/mods/spark-1.10.124-neoforge.jar
|
||||
/mods/starbunclemania-1.21.1-1.5.8.jar
|
||||
/mods/stepcrafter-neoforge-1.21.1-0.1.9.jar
|
||||
/mods/stevescarts-1.21-1.2.18-neoforge.jar
|
||||
/mods/storagedelight-26.09.17-1.21-neoforge.jar
|
||||
/mods/Structory_26.2_v1.3.7.jar
|
||||
/mods/Structory_Towers_26.1_v1.0.16.jar
|
||||
/mods/structurize-1.0.833-1.21.1-snapshot.jar
|
||||
/mods/stylecolonies-1.15.59-1.21.1.jar
|
||||
"/mods/Super Factory Manager (SFM)-MC1.21.1-4.34.0.jar"
|
||||
/mods/supermartijn642configlib-1.1.8-neoforge-mc1.21.jar
|
||||
/mods/supermartijn642corelib-1.1.24a-neoforge-mc1.21.jar
|
||||
/mods/supplementaries-1.21.1-3.9.9-neoforge.jar
|
||||
/mods/sushigocrafting-1.21-0.6.6.jar
|
||||
/mods/sussysniffers-1.21.1-0.1.3.jar
|
||||
/mods/t_and_t-fabric-neoforge-1.13.11.jar
|
||||
/mods/tempad-1.21.1-3.0.4-all.jar
|
||||
/mods/TerraBlender-neoforge-1.21.1-4.1.0.8.jar
|
||||
/mods/tesseract-api-neoforge-1.12.16-1.21.1.jar
|
||||
/mods/the_bumblezone-7.16.1+1.21.1-neoforge.jar
|
||||
/mods/The_Undergarden-1.21.1-0.9.6.jar
|
||||
/mods/theurgy_kubejs-1.21.1-neoforge-1.6.0.jar
|
||||
/mods/theurgy-1.21.1-neoforge-1.76.1.jar
|
||||
/mods/tiab-neoforge-6.5.4.jar
|
||||
/mods/titanium-1.21-4.0.50.jar
|
||||
/mods/ToastControl-1.21.1-9.0.1.jar
|
||||
/mods/tombstone-neoforge-1.21.1-9.5.6.jar
|
||||
/mods/ToolBelt-1.21.1-2.2.10.jar
|
||||
/mods/torchmaster-neoforge-1.21.1-21.1.12.jar
|
||||
/mods/towntalk-1.2.0.jar
|
||||
/mods/transfer_labels-0.1.9.jar
|
||||
/mods/trashcans-1.1.0-neoforge-mc1.21.jar
|
||||
/mods/trashslot-neoforge-1.21.1-21.1.11.jar
|
||||
/mods/treetap-1.21.1-0.4.6.jar
|
||||
/mods/trophymanager-1.21.1-3.0.0.jar
|
||||
/mods/twilightforest-1.21.1-4.8.3345-universal.jar
|
||||
/mods/UndergroundVillages-neoforge-1.21.1-4.0.3.jar
|
||||
/mods/universalgrid-neoforge-1.21.1-0.3.2.jar
|
||||
/mods/uranus-2.4.1-bugfix-1.21.1-neoforge.jar
|
||||
/mods/utilitarian-1.21.1-0.19.2.jar
|
||||
/mods/utilityvest-1.3.0.jar
|
||||
/mods/valhelsia_core-neoforge-1.21.1-1.1.5.jar
|
||||
/mods/variantsandventures-neoforge-1.0.26+mc1.21.1.jar
|
||||
/mods/villagesandpillages-neoforge-mc1.21.1-1.0.3.jar
|
||||
/mods/waystones-neoforge-1.21.1-21.1.45.jar
|
||||
/mods/wirelesschargers-1.0.9a-neoforge-mc1.21.jar
|
||||
/mods/WitherSkeletonTweaks-1.21.1-10.1.1.jar
|
||||
/mods/wits-neoforge-1.3.1.jar
|
||||
/mods/xnet-1.21-7.0.7.jar
|
||||
/mods/xtonesreworked-1.1.1-1.21.1.jar
|
||||
/mods/xycraft_core-0.7.53-all.jar
|
||||
/mods/xycraft_machines-0.7.53.jar
|
||||
/mods/xycraft_override-0.7.53.jar
|
||||
/mods/xycraft_world-0.7.53.jar
|
||||
/mods/yeetusexperimentus-neoforge-87.0.0.jar
|
||||
/mods/yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar
|
||||
/mods/YungsApi-1.21.1-NeoForge-5.1.9.jar
|
||||
/mods/YungsBetterDesertTemples-1.21.1-NeoForge-4.1.5.jar
|
||||
/mods/YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar
|
||||
/mods/YungsBetterEndIsland-1.21.1-NeoForge-3.1.2.jar
|
||||
/mods/YungsBetterJungleTemples-1.21.1-NeoForge-3.1.2.jar
|
||||
/mods/YungsBetterMineshafts-1.21.1-NeoForge-5.1.1.jar
|
||||
/mods/YungsBetterNetherFortresses-1.21.1-NeoForge-3.1.5.jar
|
||||
/mods/YungsBetterOceanMonuments-1.21.1-NeoForge-4.1.2.jar
|
||||
/mods/YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar
|
||||
/mods/YungsBetterWitchHuts-1.21.1-NeoForge-4.1.1.jar
|
||||
/mods/YungsExtras-1.21.1-NeoForge-5.1.1.jar
|
||||
/mods/ZeroCore2-1.21.1-2.4.21.jar
|
||||
@@ -0,0 +1,12 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
maven { url = 'https://maven.neoforged.net/releases' }
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0'
|
||||
}
|
||||
|
||||
rootProject.name = 'neolitematica'
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.lucas.litematica;
|
||||
|
||||
import de.lucas.litematica.config.ModConfig;
|
||||
import de.lucas.litematica.dev.DevSmokeTest;
|
||||
import de.lucas.litematica.gui.HudOverlay;
|
||||
import de.lucas.litematica.input.InputHandler;
|
||||
import de.lucas.litematica.input.KeyBindings;
|
||||
import de.lucas.litematica.placement.PlacementManager;
|
||||
import de.lucas.litematica.render.SchematicRenderer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.ResourceManagerReloadListener;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
import net.neoforged.fml.ModContainer;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent;
|
||||
import net.neoforged.neoforge.client.event.ClientTickEvent;
|
||||
import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent;
|
||||
import net.neoforged.neoforge.client.event.RegisterGuiLayersEvent;
|
||||
import net.neoforged.neoforge.client.event.RenderLevelStageEvent;
|
||||
import net.neoforged.neoforge.client.gui.ConfigurationScreen;
|
||||
import net.neoforged.neoforge.client.gui.IConfigScreenFactory;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.event.level.ChunkEvent;
|
||||
|
||||
/**
|
||||
* Entry point. {@code dist = Dist.CLIENT} makes FML skip this class entirely on dedicated servers, and the mod
|
||||
* registers no network payloads, so servers never need it installed.
|
||||
*/
|
||||
@Mod(value = NeoLitematica.MOD_ID, dist = Dist.CLIENT)
|
||||
public final class NeoLitematica {
|
||||
public static final String MOD_ID = "neolitematica";
|
||||
|
||||
public NeoLitematica(IEventBus modBus, ModContainer container) {
|
||||
container.registerConfig(net.neoforged.fml.config.ModConfig.Type.CLIENT, ModConfig.SPEC);
|
||||
container.registerExtensionPoint(IConfigScreenFactory.class, ConfigurationScreen::new);
|
||||
|
||||
modBus.addListener(KeyBindings::register);
|
||||
modBus.addListener((RegisterGuiLayersEvent event) -> event.registerAboveAll(id("hud"), new HudOverlay()));
|
||||
// Block models may change with resource packs; rebuild meshes afterwards.
|
||||
modBus.addListener((RegisterClientReloadListenersEvent event) -> event.registerReloadListener(
|
||||
(ResourceManagerReloadListener) resourceManager -> SchematicRenderer.get().markAllDirty()));
|
||||
|
||||
NeoForge.EVENT_BUS.addListener((ClientTickEvent.Post event) -> {
|
||||
InputHandler.tick();
|
||||
PlacementManager.get().tick();
|
||||
SchematicRenderer.get().tick();
|
||||
});
|
||||
NeoForge.EVENT_BUS.addListener((RenderLevelStageEvent event) -> SchematicRenderer.get().render(event));
|
||||
NeoForge.EVENT_BUS.addListener((ClientPlayerNetworkEvent.LoggingIn event) -> PlacementManager.get().onLogin());
|
||||
NeoForge.EVENT_BUS.addListener((ClientPlayerNetworkEvent.LoggingOut event) -> {
|
||||
PlacementManager.get().onLogout();
|
||||
SchematicRenderer.get().clear();
|
||||
});
|
||||
NeoForge.EVENT_BUS.addListener((ChunkEvent.Load event) -> {
|
||||
if (event.getLevel().isClientSide()) {
|
||||
SchematicRenderer.get().onChunkLoaded(event.getChunk().getPos().x, event.getChunk().getPos().z);
|
||||
}
|
||||
});
|
||||
|
||||
if (DevSmokeTest.isEnabled()) {
|
||||
DevSmokeTest.register();
|
||||
}
|
||||
}
|
||||
|
||||
public static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath(MOD_ID, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.lucas.litematica.config;
|
||||
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/**
|
||||
* Client configuration, stored by NeoForge in {@code config/neolitematica-client.toml}. Editable in-game through
|
||||
* the mod's own screens and NeoForge's generic configuration screen (Mods list → NeoLitematica → Config).
|
||||
*/
|
||||
public final class ModConfig {
|
||||
public static final ModConfigSpec SPEC;
|
||||
|
||||
public static final ModConfigSpec.BooleanValue RENDERING_ENABLED;
|
||||
public static final ModConfigSpec.EnumValue<RenderMode> RENDER_MODE;
|
||||
public static final ModConfigSpec.DoubleValue GHOST_ALPHA;
|
||||
public static final ModConfigSpec.DoubleValue OVERLAY_ALPHA;
|
||||
public static final ModConfigSpec.IntValue RENDER_DISTANCE;
|
||||
public static final ModConfigSpec.BooleanValue RENDER_BLOCK_ENTITIES;
|
||||
public static final ModConfigSpec.BooleanValue SHOW_BOUNDING_BOX;
|
||||
public static final ModConfigSpec.BooleanValue SHOW_HUD;
|
||||
public static final ModConfigSpec.IntValue BUILDER_THREADS;
|
||||
|
||||
static {
|
||||
ModConfigSpec.Builder b = new ModConfigSpec.Builder();
|
||||
RENDERING_ENABLED = b.comment("Show schematic placements in the world.")
|
||||
.define("renderingEnabled", true);
|
||||
RENDER_MODE = b.comment("ALL: show every block. MISSING_ONLY: hide correctly placed blocks, mark wrong ones.",
|
||||
"VERIFY: additionally mark extra and correct blocks.")
|
||||
.defineEnum("renderMode", RenderMode.MISSING_ONLY);
|
||||
GHOST_ALPHA = b.comment("Opacity of ghost blocks.")
|
||||
.defineInRange("ghostAlpha", 0.5, 0.15, 1.0);
|
||||
OVERLAY_ALPHA = b.comment("Opacity of the coloured verification overlay.")
|
||||
.defineInRange("overlayAlpha", 0.35, 0.05, 1.0);
|
||||
RENDER_DISTANCE = b.comment("Schematic render distance in chunks.")
|
||||
.defineInRange("renderDistance", 12, 2, 48);
|
||||
RENDER_BLOCK_ENTITIES = b.comment("Render block entities such as chests, signs and beds as ghosts.")
|
||||
.define("renderBlockEntities", true);
|
||||
SHOW_BOUNDING_BOX = b.comment("Outline the selected placement.")
|
||||
.define("showBoundingBox", true);
|
||||
SHOW_HUD = b.comment("Show placement position and verification summary on screen.")
|
||||
.define("showHud", true);
|
||||
BUILDER_THREADS = b.comment("Background threads that build schematic meshes. Requires restart.")
|
||||
.defineInRange("builderThreads", 2, 1, 8);
|
||||
SPEC = b.build();
|
||||
}
|
||||
|
||||
private ModConfig() {
|
||||
}
|
||||
|
||||
/** Updates a value and writes the config file. */
|
||||
public static <T> void set(ModConfigSpec.ConfigValue<T> value, T newValue) {
|
||||
value.set(newValue);
|
||||
value.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.lucas.litematica.config;
|
||||
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.neoforged.neoforge.common.TranslatableEnum;
|
||||
|
||||
/** How placements are compared with the world while rendering. */
|
||||
public enum RenderMode implements TranslatableEnum {
|
||||
/** Every schematic block is drawn as a ghost; no comparison with the world. */
|
||||
ALL,
|
||||
/** Ghosts only where blocks are missing; wrong blocks are highlighted. Default building aid. */
|
||||
MISSING_ONLY,
|
||||
/** Like {@link #MISSING_ONLY}, but also highlights extra and already correct blocks. */
|
||||
VERIFY;
|
||||
|
||||
public boolean comparesWorld() {
|
||||
return this != ALL;
|
||||
}
|
||||
|
||||
public RenderMode next() {
|
||||
return values()[(this.ordinal() + 1) % values().length];
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTranslatedName() {
|
||||
return Component.translatable("neolitematica.render_mode." + this.name().toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package de.lucas.litematica.dev;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import de.lucas.litematica.config.ModConfig;
|
||||
import de.lucas.litematica.config.RenderMode;
|
||||
import de.lucas.litematica.gui.MainScreen;
|
||||
import de.lucas.litematica.gui.PlacementScreen;
|
||||
import de.lucas.litematica.gui.SchematicBrowserScreen;
|
||||
import de.lucas.litematica.placement.PlacementManager;
|
||||
import de.lucas.litematica.placement.PlacementView;
|
||||
import de.lucas.litematica.placement.SchematicPlacement;
|
||||
import de.lucas.litematica.render.SchematicRenderer;
|
||||
import de.lucas.litematica.schematic.SchematicFiles;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.Screenshot;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.Difficulty;
|
||||
import net.minecraft.world.level.GameRules;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.LevelSettings;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.WorldOptions;
|
||||
import net.minecraft.world.level.levelgen.presets.WorldPresets;
|
||||
import net.neoforged.neoforge.client.event.ClientTickEvent;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Development-only end-to-end test, enabled by the {@code neolitematica.smoketest} system property (Gradle task
|
||||
* {@code runSmokeTest}). It creates a flat creative world, places the given schematic, edits the world to produce
|
||||
* correct and wrong blocks, takes screenshots of the rendering and every screen, logs the comparison results and
|
||||
* quits. Never active in normal play.
|
||||
*/
|
||||
public final class DevSmokeTest {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final String PROPERTY = "neolitematica.smoketest";
|
||||
private static final String WORLD = "neolitematica_smoketest";
|
||||
|
||||
private static final int TIMEOUT_TICKS = 20 * 60 * 4;
|
||||
|
||||
private static int totalTicks;
|
||||
private static boolean worldRequested;
|
||||
private static int step;
|
||||
private static int wait;
|
||||
private static SchematicPlacement placement;
|
||||
|
||||
private DevSmokeTest() {
|
||||
}
|
||||
|
||||
public static boolean isEnabled() {
|
||||
return System.getProperty(PROPERTY) != null;
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
NeoForge.EVENT_BUS.addListener((ClientTickEvent.Post event) -> {
|
||||
try {
|
||||
tick();
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("SMOKETEST FAILED with exception", e);
|
||||
Minecraft.getInstance().stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void tick() throws IOException {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (++totalTicks > TIMEOUT_TICKS && step < 100) {
|
||||
LOGGER.error("SMOKETEST FAILED: timed out in step {} (screen {}, player {})", step, mc.screen, mc.player);
|
||||
step = 100;
|
||||
mc.stop();
|
||||
return;
|
||||
}
|
||||
if (!worldRequested) {
|
||||
// The first launch shows the accessibility onboarding screen instead of the title screen.
|
||||
if (mc.screen != null && mc.level == null && mc.getOverlay() == null) {
|
||||
worldRequested = true;
|
||||
createWorld(mc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mc.player == null || mc.level == null) {
|
||||
return;
|
||||
}
|
||||
if (!mc.player.getAbilities().flying) {
|
||||
fly(mc);
|
||||
}
|
||||
if (wait > 0) {
|
||||
wait--;
|
||||
return;
|
||||
}
|
||||
PlacementManager manager = PlacementManager.get();
|
||||
switch (step) {
|
||||
case 0 -> {
|
||||
Path source = Path.of(System.getProperty(PROPERTY));
|
||||
Path target = SchematicFiles.directory().resolve(source.getFileName());
|
||||
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
ModConfig.set(ModConfig.RENDER_MODE, RenderMode.MISSING_ONLY);
|
||||
BlockPos feet = mc.player.blockPosition();
|
||||
placement = manager.place(target, feet.offset(8, 0, 8));
|
||||
LOGGER.info("SMOKETEST placed {} at {}", target.getFileName(), placement.origin());
|
||||
command(mc, "time set day");
|
||||
command(mc, "gamerule doDaylightCycle false");
|
||||
command(mc, "weather clear");
|
||||
BlockPos o = placement.origin();
|
||||
fly(mc);
|
||||
command(mc, String.format("tp @s %d %d %d -45 30", o.getX() - 24, o.getY() + 40, o.getZ() - 24));
|
||||
next(60);
|
||||
}
|
||||
case 1 -> {
|
||||
if (placement.schematic() == null || SchematicRenderer.get().pendingSections() > 0) return;
|
||||
logStats("initial (empty world)");
|
||||
screenshot(mc, "smoke_1_missing_only");
|
||||
next(5);
|
||||
}
|
||||
case 2 -> {
|
||||
buildPartOfSchematic(mc);
|
||||
BlockPos o = placement.origin();
|
||||
command(mc, String.format("tp @s %d %d %d -45 35", o.getX() - 12, o.getY() + 30, o.getZ() - 12));
|
||||
next(40);
|
||||
}
|
||||
case 3 -> {
|
||||
if (SchematicRenderer.get().pendingSections() > 0) return;
|
||||
logStats("after building part");
|
||||
screenshot(mc, "smoke_2_missing_only_partly_built");
|
||||
ModConfig.set(ModConfig.RENDER_MODE, RenderMode.VERIFY);
|
||||
next(40);
|
||||
}
|
||||
case 4 -> {
|
||||
if (SchematicRenderer.get().pendingSections() > 0) return;
|
||||
logStats("verify mode");
|
||||
screenshot(mc, "smoke_3_verify");
|
||||
ModConfig.set(ModConfig.RENDER_MODE, RenderMode.ALL);
|
||||
manager.setTransform(placement, placement.transform().rotatedClockwise());
|
||||
next(40);
|
||||
}
|
||||
case 5 -> {
|
||||
if (SchematicRenderer.get().pendingSections() > 0) return;
|
||||
screenshot(mc, "smoke_4_all_rotated90");
|
||||
fly(mc);
|
||||
command(mc, String.format("tp @s %d %d %d 160 20", placement.origin().getX() + 52, placement.origin().getY() + 22,
|
||||
placement.origin().getZ() + 96));
|
||||
next(60);
|
||||
}
|
||||
case 6 -> {
|
||||
if (SchematicRenderer.get().pendingSections() > 0) return;
|
||||
screenshot(mc, "smoke_5_closeup");
|
||||
mc.setScreen(new MainScreen(null));
|
||||
next(20);
|
||||
}
|
||||
case 7 -> {
|
||||
screenshot(mc, "smoke_6_main_screen");
|
||||
mc.setScreen(new PlacementScreen(null, placement));
|
||||
next(20);
|
||||
}
|
||||
case 8 -> {
|
||||
screenshot(mc, "smoke_7_placement_screen");
|
||||
mc.setScreen(new SchematicBrowserScreen(null));
|
||||
next(20);
|
||||
}
|
||||
case 9 -> {
|
||||
screenshot(mc, "smoke_8_browser_screen");
|
||||
mc.setScreen(null);
|
||||
LOGGER.info("SMOKETEST PASSED");
|
||||
mc.stop();
|
||||
step = 100;
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void next(int waitTicks) {
|
||||
step++;
|
||||
wait = waitTicks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the bottom four layers of the schematic correctly through the integrated server, and puts glass where
|
||||
* the next layer's blocks belong in one quarter, to produce correct and wrong blocks.
|
||||
*/
|
||||
private static void buildPartOfSchematic(Minecraft mc) {
|
||||
PlacementView view = placement.view();
|
||||
ServerLevel level = mc.getSingleplayerServer().getLevel(Level.OVERWORLD);
|
||||
BlockPos.MutableBlockPos scratch = new BlockPos.MutableBlockPos();
|
||||
var box = view.box();
|
||||
int lowest = box.minY();
|
||||
search:
|
||||
for (; lowest <= box.maxY(); lowest++) {
|
||||
for (int z = box.minZ(); z <= box.maxZ(); z++) {
|
||||
for (int x = box.minX(); x <= box.maxX(); x++) {
|
||||
if (!view.getState(x, lowest, z, scratch).isAir()) break search;
|
||||
}
|
||||
}
|
||||
}
|
||||
int base = lowest;
|
||||
mc.getSingleplayerServer().execute(() -> {
|
||||
int correct = 0, wrong = 0, wrongState = 0, extra = 0;
|
||||
for (int y = base; y < base + 5; y++) {
|
||||
for (int z = box.minZ(); z <= box.maxZ(); z++) {
|
||||
for (int x = box.minX(); x <= box.maxX(); x++) {
|
||||
BlockPos pos = new BlockPos(x, y, z);
|
||||
BlockState state = view.getState(x, y, z, scratch);
|
||||
if (state.isAir()) continue;
|
||||
if (y < base + 4) {
|
||||
BlockState rotated = state.rotate(net.minecraft.world.level.block.Rotation.CLOCKWISE_90);
|
||||
if (wrongState < 20 && rotated != state) {
|
||||
level.setBlock(pos, rotated, 2 | 16);
|
||||
wrongState++;
|
||||
} else {
|
||||
level.setBlock(pos, state, 2 | 16);
|
||||
correct++;
|
||||
}
|
||||
} else if (x < box.minX() + 32 && z < box.minZ() + 40) {
|
||||
level.setBlock(pos, Blocks.GLASS.defaultBlockState(), 2 | 16);
|
||||
wrong++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Blocks where the schematic has air: a small stone platform below the building.
|
||||
for (int z = box.minZ(); z < box.minZ() + 5; z++) {
|
||||
for (int x = box.minX(); x < box.minX() + 5; x++) {
|
||||
if (view.getState(x, box.minY() + 1, z, scratch).isAir()) {
|
||||
level.setBlock(new BlockPos(x, box.minY() + 1, z), Blocks.STONE.defaultBlockState(), 2 | 16);
|
||||
extra++;
|
||||
}
|
||||
}
|
||||
}
|
||||
LOGGER.info("SMOKETEST built from y={}: {} correct, {} wrong block, {} wrong state, {} extra", base, correct, wrong, wrongState, extra);
|
||||
});
|
||||
}
|
||||
|
||||
private static void logStats(String label) {
|
||||
var s = placement.stats();
|
||||
LOGGER.info("SMOKETEST stats [{}]: correct={} missing={} wrongBlock={} wrongState={} extra={} unknown={} complete={} blocks={}",
|
||||
label, s.correct(), s.missing(), s.wrongBlock(), s.wrongState(), s.extra(), s.unknown(), s.complete(),
|
||||
placement.schematic().nonAirBlocks());
|
||||
}
|
||||
|
||||
private static void screenshot(Minecraft mc, String name) {
|
||||
Screenshot.grab(mc.gameDirectory, name + ".png", mc.getMainRenderTarget(),
|
||||
message -> LOGGER.info("SMOKETEST screenshot {}: {}", name, message.getString()));
|
||||
}
|
||||
|
||||
/** Starts creative flight and tells the server, so the player does not fall after teleporting. */
|
||||
private static void fly(Minecraft mc) {
|
||||
mc.player.getAbilities().flying = true;
|
||||
mc.player.onUpdateAbilities();
|
||||
}
|
||||
|
||||
private static void command(Minecraft mc, String command) {
|
||||
mc.player.connection.sendCommand(command);
|
||||
}
|
||||
|
||||
private static void createWorld(Minecraft mc) throws IOException {
|
||||
Path dir = mc.gameDirectory.toPath().resolve("saves").resolve(WORLD);
|
||||
if (Files.exists(dir)) {
|
||||
try (Stream<Path> walk = Files.walk(dir)) {
|
||||
for (Path p : walk.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
GameRules rules = new GameRules();
|
||||
rules.getRule(GameRules.RULE_DOMOBSPAWNING).set(false, null);
|
||||
LevelSettings settings = new LevelSettings(WORLD, GameType.CREATIVE, false, Difficulty.PEACEFUL, true, rules, WorldDataConfiguration.DEFAULT);
|
||||
WorldOptions options = new WorldOptions(12345L, false, false);
|
||||
LOGGER.info("SMOKETEST creating world {}", WORLD);
|
||||
mc.createWorldOpenFlows().createFreshLevel(WORLD, settings, options,
|
||||
registries -> registries.registryOrThrow(Registries.WORLD_PRESET).getHolderOrThrow(WorldPresets.FLAT).value().createWorldDimensions(),
|
||||
mc.screen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.lucas.litematica.gui;
|
||||
|
||||
import de.lucas.litematica.config.ModConfig;
|
||||
import de.lucas.litematica.input.TransformText;
|
||||
import de.lucas.litematica.placement.PlacementManager;
|
||||
import de.lucas.litematica.placement.SchematicPlacement;
|
||||
import de.lucas.litematica.placement.VerificationStats;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.LayeredDraw;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Small info panel (top left) for the selected placement: position, size, transform and comparison results. */
|
||||
public final class HudOverlay implements LayeredDraw.Layer {
|
||||
@Override
|
||||
public void render(GuiGraphics graphics, DeltaTracker deltaTracker) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (!ModConfig.SHOW_HUD.get() || mc.options.hideGui || mc.getDebugOverlay().showDebugScreen() || mc.level == null) {
|
||||
return;
|
||||
}
|
||||
SchematicPlacement p = PlacementManager.get().selected();
|
||||
if (p == null || !p.dimension().equals(mc.level.dimension().location())) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Component> lines = new ArrayList<>();
|
||||
lines.add(Component.translatable("neolitematica.hud.title", p.name()).withStyle(s -> s.withColor(0x7FD8FF)));
|
||||
if (!ModConfig.RENDERING_ENABLED.get()) {
|
||||
lines.add(Component.translatable("neolitematica.hud.hidden").withStyle(s -> s.withColor(0xAAAAAA)));
|
||||
} else if (!p.enabled()) {
|
||||
lines.add(Component.translatable("neolitematica.hud.disabled").withStyle(s -> s.withColor(0xAAAAAA)));
|
||||
}
|
||||
if (p.loadError() != null) {
|
||||
lines.add(Component.literal(p.loadError()).withStyle(s -> s.withColor(0xFF5555)));
|
||||
} else if (p.schematic() == null) {
|
||||
lines.add(Component.translatable("neolitematica.hud.loading"));
|
||||
} else {
|
||||
BlockPos o = p.origin();
|
||||
Vec3i size = p.placedSize();
|
||||
lines.add(Component.translatable("neolitematica.hud.position", o.getX(), o.getY(), o.getZ()));
|
||||
lines.add(Component.translatable("neolitematica.hud.size", size.getX(), size.getY(), size.getZ(),
|
||||
p.transform().degrees(), TransformText.mirrors(p.transform())));
|
||||
VerificationStats s = p.stats();
|
||||
if (ModConfig.RENDER_MODE.get().comparesWorld() && s.total() > 0) {
|
||||
lines.add(Component.translatable("neolitematica.hud.stats", s.missing(), s.wrongBlock() + s.wrongState(),
|
||||
s.correct(), s.total()));
|
||||
}
|
||||
}
|
||||
|
||||
int y = 4;
|
||||
for (Component line : lines) {
|
||||
int width = mc.font.width(line);
|
||||
graphics.fill(2, y - 1, 6 + width, y + 9, 0x90000000);
|
||||
graphics.drawString(mc.font, line, 4, y, 0xFFFFFF, false);
|
||||
y += 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package de.lucas.litematica.gui;
|
||||
|
||||
import de.lucas.litematica.NeoLitematica;
|
||||
import de.lucas.litematica.config.ModConfig;
|
||||
import de.lucas.litematica.config.RenderMode;
|
||||
import de.lucas.litematica.placement.PlacementManager;
|
||||
import de.lucas.litematica.placement.SchematicPlacement;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractSliderButton;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.CycleButton;
|
||||
import net.minecraft.client.gui.components.ObjectSelectionList;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.client.gui.screens.ConfirmScreen;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.gui.screens.options.controls.KeyBindsScreen;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.client.gui.ConfigurationScreen;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/** Overview of all placements in this dimension plus quick render settings. Opened with Alt+M. */
|
||||
public class MainScreen extends Screen {
|
||||
private static final int SIDE_WIDTH = 150;
|
||||
|
||||
@Nullable
|
||||
private final Screen parent;
|
||||
private PlacementList list;
|
||||
private Button editButton;
|
||||
private Button toggleButton;
|
||||
private Button moveHereButton;
|
||||
private Button removeButton;
|
||||
|
||||
public MainScreen(@Nullable Screen parent) {
|
||||
super(Component.translatable("neolitematica.screen.main.title"));
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
int bottom = this.height - 80;
|
||||
int listWidth = this.width - SIDE_WIDTH - 30;
|
||||
this.list = new PlacementList(this.minecraft, listWidth, bottom - 26, 24);
|
||||
this.list.setX(10);
|
||||
this.addRenderableWidget(this.list);
|
||||
|
||||
int sx = this.width - SIDE_WIDTH - 10;
|
||||
int y = 24;
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.load_schematic"),
|
||||
b -> this.minecraft.setScreen(new SchematicBrowserScreen(this))).bounds(sx, y, SIDE_WIDTH, 20).build());
|
||||
y += 28;
|
||||
this.editButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.edit"),
|
||||
b -> this.withSelected(p -> this.minecraft.setScreen(new PlacementScreen(this, p)))).bounds(sx, y, SIDE_WIDTH, 20).build());
|
||||
y += 22;
|
||||
this.toggleButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.toggle_placement"),
|
||||
b -> this.withSelected(p -> PlacementManager.get().setEnabled(p, !p.enabled()))).bounds(sx, y, SIDE_WIDTH, 20).build());
|
||||
y += 22;
|
||||
this.moveHereButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.move_here"),
|
||||
b -> this.withSelected(p -> PlacementManager.get().setOrigin(p, this.minecraft.player.blockPosition())))
|
||||
.tooltip(Tooltip.create(Component.translatable("neolitematica.button.move_here.tooltip")))
|
||||
.bounds(sx, y, SIDE_WIDTH, 20).build());
|
||||
y += 22;
|
||||
this.removeButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.remove"),
|
||||
b -> this.withSelected(this::confirmRemove)).bounds(sx, y, SIDE_WIDTH, 20).build());
|
||||
|
||||
int cx = this.width / 2;
|
||||
int row = bottom + 6;
|
||||
this.addRenderableWidget(CycleButton.onOffBuilder(ModConfig.RENDERING_ENABLED.get())
|
||||
.create(cx - 155, row, 150, 20, Component.translatable("neolitematica.option.rendering"),
|
||||
(b, value) -> ModConfig.set(ModConfig.RENDERING_ENABLED, value)));
|
||||
this.addRenderableWidget(CycleButton.<RenderMode>builder(RenderMode::getTranslatedName)
|
||||
.withValues(RenderMode.values())
|
||||
.withInitialValue(ModConfig.RENDER_MODE.get())
|
||||
.withTooltip(mode -> Tooltip.create(Component.translatable("neolitematica.render_mode." + mode.name().toLowerCase() + ".tooltip")))
|
||||
.create(cx + 5, row, 150, 20, Component.translatable("neolitematica.option.render_mode"),
|
||||
(b, value) -> ModConfig.set(ModConfig.RENDER_MODE, value)));
|
||||
row += 22;
|
||||
this.addRenderableWidget(new ConfigSlider(cx - 155, row, "neolitematica.option.ghost_alpha",
|
||||
0.15, 1.0, ModConfig.GHOST_ALPHA.get(), true) {
|
||||
@Override
|
||||
protected void apply(double value) {
|
||||
ModConfig.GHOST_ALPHA.set(value);
|
||||
}
|
||||
});
|
||||
this.addRenderableWidget(new ConfigSlider(cx + 5, row, "neolitematica.option.render_distance",
|
||||
2, 48, ModConfig.RENDER_DISTANCE.get(), false) {
|
||||
@Override
|
||||
protected void apply(double value) {
|
||||
ModConfig.RENDER_DISTANCE.set((int) Math.round(value));
|
||||
}
|
||||
});
|
||||
row += 22;
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.more_settings"), b -> this.openConfig())
|
||||
.bounds(cx - 155, row, 100, 20).build());
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.controls"),
|
||||
b -> this.minecraft.setScreen(new KeyBindsScreen(this, this.minecraft.options))).bounds(cx - 50, row, 100, 20).build());
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> this.onClose())
|
||||
.bounds(cx + 55, row, 100, 20).build());
|
||||
|
||||
this.list.refresh();
|
||||
this.updateButtons();
|
||||
}
|
||||
|
||||
private void openConfig() {
|
||||
ModList.get().getModContainerById(NeoLitematica.MOD_ID).ifPresent(container ->
|
||||
this.minecraft.setScreen(new ConfigurationScreen(container, this)));
|
||||
}
|
||||
|
||||
private void confirmRemove(SchematicPlacement placement) {
|
||||
this.minecraft.setScreen(new ConfirmScreen(confirmed -> {
|
||||
if (confirmed) {
|
||||
PlacementManager.get().remove(placement);
|
||||
}
|
||||
this.minecraft.setScreen(this);
|
||||
}, Component.translatable("neolitematica.screen.remove.title"),
|
||||
Component.translatable("neolitematica.screen.remove.message", placement.name())));
|
||||
}
|
||||
|
||||
private void withSelected(java.util.function.Consumer<SchematicPlacement> action) {
|
||||
PlacementList.Entry entry = this.list.getSelected();
|
||||
if (entry != null) {
|
||||
action.accept(entry.placement);
|
||||
this.updateButtons();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateButtons() {
|
||||
boolean selected = this.list.getSelected() != null;
|
||||
this.editButton.active = selected;
|
||||
this.toggleButton.active = selected;
|
||||
this.moveHereButton.active = selected && this.minecraft.player != null;
|
||||
this.removeButton.active = selected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
this.updateButtons();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
graphics.drawCenteredString(this.font, this.title, this.width / 2, 8, 0xFFFFFF);
|
||||
if (this.list.children().isEmpty()) {
|
||||
graphics.drawCenteredString(this.font, Component.translatable("neolitematica.screen.main.empty"),
|
||||
10 + this.list.getWidth() / 2, 60, 0xA0A0A0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
this.minecraft.setScreen(this.parent);
|
||||
}
|
||||
|
||||
/** List of placements in the current dimension. */
|
||||
class PlacementList extends ObjectSelectionList<PlacementList.Entry> {
|
||||
PlacementList(Minecraft minecraft, int width, int height, int y) {
|
||||
super(minecraft, width, height, y, 24);
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
this.clearEntries();
|
||||
SchematicPlacement selected = PlacementManager.get().selected();
|
||||
for (SchematicPlacement p : PlacementManager.get().inCurrentDimension()) {
|
||||
Entry entry = new Entry(p);
|
||||
this.addEntry(entry);
|
||||
if (p == selected) {
|
||||
this.setSelected(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSelected(@Nullable Entry entry) {
|
||||
super.setSelected(entry);
|
||||
if (entry != null) {
|
||||
PlacementManager.get().select(entry.placement);
|
||||
}
|
||||
MainScreen.this.updateButtons();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowWidth() {
|
||||
return this.width - 16;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getScrollbarPosition() {
|
||||
return this.getX() + this.width - 6;
|
||||
}
|
||||
|
||||
class Entry extends ObjectSelectionList.Entry<Entry> {
|
||||
final SchematicPlacement placement;
|
||||
private long lastClick;
|
||||
|
||||
Entry(SchematicPlacement placement) {
|
||||
this.placement = placement;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics g, int index, int top, int left, int width, int height, int mouseX, int mouseY,
|
||||
boolean hovering, float partialTick) {
|
||||
SchematicPlacement p = this.placement;
|
||||
int nameColor = p.enabled() ? 0xFFFFFF : 0x808080;
|
||||
String marker = p.enabled() ? "● " : "○ ";
|
||||
g.drawString(MainScreen.this.font, marker + p.name(), left + 2, top + 2, nameColor, false);
|
||||
Component detail;
|
||||
if (p.loadError() != null) {
|
||||
detail = Component.literal(p.loadError()).withStyle(ChatFormatting.RED);
|
||||
} else if (p.schematic() == null) {
|
||||
detail = Component.translatable("neolitematica.hud.loading").withStyle(ChatFormatting.GRAY);
|
||||
} else {
|
||||
BlockPos o = p.origin();
|
||||
Vec3i s = p.placedSize();
|
||||
detail = Component.translatable("neolitematica.list.placement_detail", o.getX(), o.getY(), o.getZ(),
|
||||
s.getX(), s.getY(), s.getZ(), p.transform().degrees()).withStyle(ChatFormatting.GRAY);
|
||||
}
|
||||
g.drawString(MainScreen.this.font, MainScreen.this.font.substrByWidth(detail, width - 6).getString(),
|
||||
left + 2, top + 12, 0xA0A0A0, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
long now = net.minecraft.Util.getMillis();
|
||||
if (now - this.lastClick < 250L && PlacementList.this.getSelected() == this) {
|
||||
MainScreen.this.minecraft.setScreen(new PlacementScreen(MainScreen.this, this.placement));
|
||||
}
|
||||
this.lastClick = now;
|
||||
PlacementList.this.setSelected(this);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getNarration() {
|
||||
return Component.literal(this.placement.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Slider bound to a numeric config value; applies live while dragging and writes the file when released. */
|
||||
abstract static class ConfigSlider extends AbstractSliderButton {
|
||||
private final String key;
|
||||
private final double min;
|
||||
private final double max;
|
||||
private final boolean percent;
|
||||
|
||||
ConfigSlider(int x, int y, String key, double min, double max, double value, boolean percent) {
|
||||
super(x, y, 150, 20, Component.empty(), (value - min) / (max - min));
|
||||
this.key = key;
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
this.percent = percent;
|
||||
this.updateMessage();
|
||||
}
|
||||
|
||||
private double current() {
|
||||
return this.min + Mth.clamp(this.value, 0.0, 1.0) * (this.max - this.min);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateMessage() {
|
||||
double v = this.current();
|
||||
String text = this.percent ? Math.round(v * 100) + "%" : String.valueOf(Math.round(v));
|
||||
this.setMessage(Component.translatable(this.key, text));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void applyValue() {
|
||||
this.apply(this.current());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRelease(double mouseX, double mouseY) {
|
||||
super.onRelease(mouseX, mouseY);
|
||||
ModConfig.SPEC.save();
|
||||
}
|
||||
|
||||
protected abstract void apply(double value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package de.lucas.litematica.gui;
|
||||
|
||||
import de.lucas.litematica.placement.PlacementManager;
|
||||
import de.lucas.litematica.placement.PlacementTransform;
|
||||
import de.lucas.litematica.placement.SchematicPlacement;
|
||||
import de.lucas.litematica.placement.VerificationStats;
|
||||
import de.lucas.litematica.schematic.LitematicSchematic;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.CycleButton;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Edits one placement: position, rotation, mirroring, name. Changes apply immediately and the screen does not
|
||||
* blur or pause the game, so the blueprint can be watched moving in the world.
|
||||
*/
|
||||
public class PlacementScreen extends Screen {
|
||||
private static final int COLUMN = 150;
|
||||
|
||||
@Nullable
|
||||
private final Screen parent;
|
||||
private final SchematicPlacement placement;
|
||||
private final EditBox[] coords = new EditBox[3];
|
||||
private EditBox nameBox;
|
||||
private int panelTop;
|
||||
private int panelBottom;
|
||||
|
||||
public PlacementScreen(@Nullable Screen parent, SchematicPlacement placement) {
|
||||
super(Component.translatable("neolitematica.screen.placement.title"));
|
||||
this.parent = parent;
|
||||
this.placement = placement;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
PlacementManager manager = PlacementManager.get();
|
||||
int left = this.width / 2 - COLUMN - 5;
|
||||
int right = this.width / 2 + 5;
|
||||
this.panelTop = Math.max(4, this.height / 2 - 118);
|
||||
int y = this.panelTop + 18;
|
||||
|
||||
// Left column: name and position.
|
||||
this.nameBox = new EditBox(this.font, left, y, COLUMN, 20, Component.translatable("neolitematica.label.name"));
|
||||
this.nameBox.setMaxLength(64);
|
||||
this.nameBox.setValue(this.placement.name());
|
||||
this.nameBox.setResponder(name -> manager.rename(this.placement, name));
|
||||
this.addRenderableWidget(this.nameBox);
|
||||
|
||||
Direction.Axis[] axes = {Direction.Axis.X, Direction.Axis.Y, Direction.Axis.Z};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
this.addCoordinateRow(axes[i], i, left, y + 24 + i * 22);
|
||||
}
|
||||
int ly = y + 24 + 3 * 22;
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.move_here"), b -> {
|
||||
if (this.minecraft.player != null) manager.setOrigin(this.placement, this.minecraft.player.blockPosition());
|
||||
}).tooltip(Tooltip.create(Component.translatable("neolitematica.button.move_here.tooltip"))).bounds(left, ly, COLUMN, 20).build());
|
||||
ly += 22;
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.reset_position"),
|
||||
b -> manager.resetOrigin(this.placement))
|
||||
.tooltip(Tooltip.create(Component.translatable("neolitematica.button.reset_position.tooltip")))
|
||||
.bounds(left, ly, COLUMN, 20).build());
|
||||
ly += 22;
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.store_origin"),
|
||||
b -> manager.storeOriginAsOriginal(this.placement))
|
||||
.tooltip(Tooltip.create(Component.translatable("neolitematica.button.store_origin.tooltip")))
|
||||
.bounds(left, ly, COLUMN, 20).build());
|
||||
|
||||
// Right column: transform.
|
||||
int ry = y;
|
||||
PlacementTransform t = this.placement.transform();
|
||||
this.addRenderableWidget(CycleButton.<Rotation>builder(r -> Component.literal(degrees(r) + "°"))
|
||||
.withValues(Rotation.NONE, Rotation.CLOCKWISE_90, Rotation.CLOCKWISE_180, Rotation.COUNTERCLOCKWISE_90)
|
||||
.withInitialValue(t.rotation())
|
||||
.create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.rotation"),
|
||||
(b, rotation) -> manager.setTransform(this.placement, this.placement.transform().withRotation(rotation))));
|
||||
ry += 24;
|
||||
this.addRenderableWidget(CycleButton.onOffBuilder(t.mirrorX())
|
||||
.withTooltip(v -> Tooltip.create(Component.translatable("neolitematica.option.mirror_x.tooltip")))
|
||||
.create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.mirror_x"),
|
||||
(b, v) -> manager.setTransform(this.placement, this.placement.transform().withMirrorX(v))));
|
||||
ry += 22;
|
||||
this.addRenderableWidget(CycleButton.onOffBuilder(t.mirrorZ())
|
||||
.withTooltip(v -> Tooltip.create(Component.translatable("neolitematica.option.mirror_z.tooltip")))
|
||||
.create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.mirror_z"),
|
||||
(b, v) -> manager.setTransform(this.placement, this.placement.transform().withMirrorZ(v))));
|
||||
ry += 22;
|
||||
this.addRenderableWidget(CycleButton.onOffBuilder(t.mirrorY())
|
||||
.withTooltip(v -> Tooltip.create(Component.translatable("neolitematica.option.mirror_y.tooltip")))
|
||||
.create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.mirror_y"),
|
||||
(b, v) -> manager.setTransform(this.placement, this.placement.transform().withMirrorY(v))));
|
||||
ry += 22;
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.reset_transform"), b -> {
|
||||
manager.setTransform(this.placement, PlacementTransform.IDENTITY);
|
||||
this.rebuildWidgets();
|
||||
}).bounds(right, ry, COLUMN, 20).build());
|
||||
ry += 24;
|
||||
this.addRenderableWidget(CycleButton.onOffBuilder(this.placement.enabled())
|
||||
.create(right, ry, COLUMN, 20, Component.translatable("neolitematica.option.visible"),
|
||||
(b, v) -> manager.setEnabled(this.placement, v)));
|
||||
|
||||
this.panelBottom = Math.max(ly, ry) + 24 + 4 * 10 + 6;
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> this.onClose())
|
||||
.bounds(this.width / 2 - 100, Math.min(this.height - 24, this.panelBottom + 4), 200, 20).build());
|
||||
}
|
||||
|
||||
private void addCoordinateRow(Direction.Axis axis, int index, int x, int y) {
|
||||
int[] steps = {-10, -1, 1, 10};
|
||||
int[] widths = {26, 20, 20, 26};
|
||||
int boxWidth = COLUMN - 12 - (26 + 20 + 20 + 26) - 4;
|
||||
int cx = x + 12;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
int step = steps[i];
|
||||
this.addRenderableWidget(Button.builder(Component.literal(String.valueOf(step)), b -> this.nudge(axis, step))
|
||||
.bounds(cx, y, widths[i], 20).build());
|
||||
cx += widths[i];
|
||||
}
|
||||
EditBox box = new EditBox(this.font, cx + 2, y, boxWidth, 20, Component.literal(axis.getName()));
|
||||
box.setFilter(s -> s.matches("-?\\d{0,8}"));
|
||||
box.setValue(String.valueOf(axis.choose(this.placement.origin().getX(), this.placement.origin().getY(), this.placement.origin().getZ())));
|
||||
box.setResponder(text -> this.applyCoordinate(axis, text));
|
||||
this.coords[index] = box;
|
||||
this.addRenderableWidget(box);
|
||||
cx += boxWidth + 4;
|
||||
for (int i = 2; i < 4; i++) {
|
||||
int step = steps[i];
|
||||
this.addRenderableWidget(Button.builder(Component.literal("+" + step), b -> this.nudge(axis, step))
|
||||
.bounds(cx, y, widths[i], 20).build());
|
||||
cx += widths[i];
|
||||
}
|
||||
}
|
||||
|
||||
private void nudge(Direction.Axis axis, int amount) {
|
||||
int step = hasShiftDown() ? amount * 10 : amount;
|
||||
PlacementManager.get().move(this.placement,
|
||||
axis == Direction.Axis.X ? step : 0, axis == Direction.Axis.Y ? step : 0, axis == Direction.Axis.Z ? step : 0);
|
||||
}
|
||||
|
||||
private void applyCoordinate(Direction.Axis axis, String text) {
|
||||
if (text.isEmpty() || text.equals("-")) {
|
||||
return;
|
||||
}
|
||||
int value;
|
||||
try {
|
||||
value = Integer.parseInt(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return;
|
||||
}
|
||||
BlockPos o = this.placement.origin();
|
||||
BlockPos target = switch (axis) {
|
||||
case X -> new BlockPos(value, o.getY(), o.getZ());
|
||||
case Y -> new BlockPos(o.getX(), value, o.getZ());
|
||||
case Z -> new BlockPos(o.getX(), o.getY(), value);
|
||||
};
|
||||
PlacementManager.get().setOrigin(this.placement, target);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
// Keep the fields in sync with changes from buttons or hotkeys, but never overwrite what is being typed.
|
||||
BlockPos o = this.placement.origin();
|
||||
int[] values = {o.getX(), o.getY(), o.getZ()};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
EditBox box = this.coords[i];
|
||||
if (!box.isFocused() && !box.getValue().equals(String.valueOf(values[i]))) {
|
||||
box.setValue(String.valueOf(values[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
if (this.minecraft.level == null) {
|
||||
super.renderBackground(graphics, mouseX, mouseY, partialTick);
|
||||
return;
|
||||
}
|
||||
// Only a dark panel behind the controls, so the placement stays visible in the world.
|
||||
graphics.fill(this.width / 2 - COLUMN - 12, this.panelTop - 2, this.width / 2 + COLUMN + 12, this.panelBottom, 0xA0000000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
graphics.drawCenteredString(this.font, this.title, this.width / 2, this.panelTop + 4, 0xFFFFFF);
|
||||
int left = this.width / 2 - COLUMN - 5;
|
||||
String[] labels = {"X", "Y", "Z"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
graphics.drawString(this.font, labels[i], left + 2, this.coords[i].getY() + 6, 0xFFFFFF);
|
||||
}
|
||||
int y = this.panelBottom - 4 * 10 - 2;
|
||||
for (Component line : this.infoLines()) {
|
||||
graphics.drawString(this.font, line, left, y, 0xFFFFFF, false);
|
||||
y += 10;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Component> infoLines() {
|
||||
List<Component> lines = new ArrayList<>();
|
||||
LitematicSchematic schematic = this.placement.schematic();
|
||||
lines.add(Component.translatable("neolitematica.info.file", this.placement.schematicFile()).withStyle(ChatFormatting.GRAY));
|
||||
if (this.placement.loadError() != null) {
|
||||
lines.add(Component.literal(this.placement.loadError()).withStyle(ChatFormatting.RED));
|
||||
return lines;
|
||||
}
|
||||
if (schematic == null) {
|
||||
lines.add(Component.translatable("neolitematica.hud.loading"));
|
||||
return lines;
|
||||
}
|
||||
Vec3i size = this.placement.placedSize();
|
||||
lines.add(Component.translatable("neolitematica.info.size_blocks", size.getX(), size.getY(), size.getZ(), schematic.nonAirBlocks()));
|
||||
if (!schematic.unknownBlocks().isEmpty()) {
|
||||
lines.add(Component.translatable("neolitematica.info.unknown", schematic.unknownBlocks().size(),
|
||||
String.join(", ", schematic.unknownBlocks())).withStyle(ChatFormatting.LIGHT_PURPLE));
|
||||
}
|
||||
VerificationStats s = this.placement.stats();
|
||||
if (s.total() > 0) {
|
||||
lines.add(Component.translatable(s.complete() ? "neolitematica.info.stats" : "neolitematica.info.stats_partial",
|
||||
s.correct(), s.missing(), s.wrongBlock(), s.wrongState(), s.extra()));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static int degrees(Rotation rotation) {
|
||||
return PlacementTransform.IDENTITY.withRotation(rotation).degrees();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
this.minecraft.setScreen(this.parent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package de.lucas.litematica.gui;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import de.lucas.litematica.placement.PlacementManager;
|
||||
import de.lucas.litematica.schematic.SchematicFiles;
|
||||
import de.lucas.litematica.schematic.SchematicLoadException;
|
||||
import de.lucas.litematica.schematic.SchematicManager;
|
||||
import de.lucas.litematica.schematic.SchematicMetadata;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.Util;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.ObjectSelectionList;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.client.gui.screens.ConfirmScreen;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.lwjgl.PointerBuffer;
|
||||
import org.lwjgl.system.MemoryStack;
|
||||
import org.lwjgl.util.tinyfd.TinyFileDialogs;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.text.DateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Lists the .litematic files in {@code .minecraft/schematics}, shows their details and places them.
|
||||
* Files can be imported with the system file dialog or by dragging them onto the window.
|
||||
*/
|
||||
public class SchematicBrowserScreen extends Screen {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
@Nullable
|
||||
private final Screen parent;
|
||||
private final Map<Path, Object> metadata = new HashMap<>();
|
||||
private FileList list;
|
||||
private Button placeButton;
|
||||
private Button renameButton;
|
||||
private Button deleteButton;
|
||||
@Nullable
|
||||
private Component status;
|
||||
private boolean dialogOpen;
|
||||
|
||||
public SchematicBrowserScreen(@Nullable Screen parent) {
|
||||
super(Component.translatable("neolitematica.screen.browser.title"));
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
int listWidth = Math.max(160, this.width / 2 - 10);
|
||||
this.list = new FileList(this.minecraft, listWidth, this.height - 24 - 58, 24);
|
||||
this.list.setX(10);
|
||||
this.addRenderableWidget(this.list);
|
||||
|
||||
int cx = this.width / 2;
|
||||
int row = this.height - 52;
|
||||
this.placeButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.place"), b -> this.placeSelected())
|
||||
.tooltip(Tooltip.create(Component.translatable("neolitematica.button.place.tooltip")))
|
||||
.bounds(cx - 155, row, 100, 20).build());
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.import"), b -> this.openImportDialog())
|
||||
.tooltip(Tooltip.create(Component.translatable("neolitematica.button.import.tooltip")))
|
||||
.bounds(cx - 50, row, 100, 20).build());
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.open_folder"),
|
||||
b -> Util.getPlatform().openPath(SchematicFiles.directory())).bounds(cx + 55, row, 100, 20).build());
|
||||
row += 24;
|
||||
this.renameButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.rename"), b -> this.renameSelected())
|
||||
.bounds(cx - 155, row, 74, 20).build());
|
||||
this.deleteButton = this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.delete"), b -> this.deleteSelected())
|
||||
.bounds(cx - 77, row, 74, 20).build());
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("neolitematica.button.refresh"), b -> this.refresh())
|
||||
.bounds(cx + 1, row, 74, 20).build());
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("gui.back"), b -> this.onClose())
|
||||
.bounds(cx + 79, row, 76, 20).build());
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
Path previous = this.selectedFile();
|
||||
this.list.clear();
|
||||
try {
|
||||
for (Path file : SchematicFiles.list()) {
|
||||
FileList.Entry entry = this.list.add(file);
|
||||
if (file.equals(previous)) {
|
||||
this.list.setSelected(entry);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
this.status = Component.translatable("neolitematica.message.list_failed", e.getMessage()).withStyle(ChatFormatting.RED);
|
||||
}
|
||||
this.updateButtons();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Path selectedFile() {
|
||||
FileList.Entry entry = this.list == null ? null : this.list.getSelected();
|
||||
return entry == null ? null : entry.file;
|
||||
}
|
||||
|
||||
private void updateButtons() {
|
||||
boolean selected = this.selectedFile() != null;
|
||||
this.placeButton.active = selected && this.minecraft.player != null;
|
||||
this.renameButton.active = selected;
|
||||
this.deleteButton.active = selected;
|
||||
}
|
||||
|
||||
private void placeSelected() {
|
||||
Path file = this.selectedFile();
|
||||
if (file == null || this.minecraft.player == null) {
|
||||
return;
|
||||
}
|
||||
PlacementManager.get().place(file, this.minecraft.player.blockPosition());
|
||||
this.minecraft.setScreen(null);
|
||||
}
|
||||
|
||||
private void renameSelected() {
|
||||
Path file = this.selectedFile();
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
this.minecraft.setScreen(new TextInputScreen(this, Component.translatable("neolitematica.screen.rename.title"),
|
||||
SchematicFiles.baseName(file), name -> {
|
||||
try {
|
||||
SchematicFiles.rename(file, name);
|
||||
this.status = Component.translatable("neolitematica.message.renamed", name);
|
||||
} catch (IOException e) {
|
||||
this.status = Component.translatable("neolitematica.message.rename_failed", e.getMessage()).withStyle(ChatFormatting.RED);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void deleteSelected() {
|
||||
Path file = this.selectedFile();
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
this.minecraft.setScreen(new ConfirmScreen(confirmed -> {
|
||||
if (confirmed) {
|
||||
try {
|
||||
SchematicFiles.delete(file);
|
||||
SchematicManager.get().invalidate(file);
|
||||
this.metadata.remove(file);
|
||||
this.status = Component.translatable("neolitematica.message.deleted", file.getFileName().toString());
|
||||
} catch (IOException e) {
|
||||
this.status = Component.translatable("neolitematica.message.delete_failed", e.getMessage()).withStyle(ChatFormatting.RED);
|
||||
}
|
||||
}
|
||||
this.minecraft.setScreen(this);
|
||||
}, Component.translatable("neolitematica.screen.delete.title"),
|
||||
Component.translatable("neolitematica.screen.delete.message", file.getFileName().toString())));
|
||||
}
|
||||
|
||||
/** Opens the native file dialog on a separate thread so the game keeps rendering. */
|
||||
private void openImportDialog() {
|
||||
if (this.dialogOpen) {
|
||||
return;
|
||||
}
|
||||
this.dialogOpen = true;
|
||||
String title = Component.translatable("neolitematica.button.import").getString();
|
||||
String start = System.getProperty("user.home") + java.io.File.separator;
|
||||
Thread thread = new Thread(() -> {
|
||||
String result;
|
||||
try (MemoryStack stack = MemoryStack.stackPush()) {
|
||||
PointerBuffer filters = stack.mallocPointer(1);
|
||||
filters.put(stack.UTF8("*.litematic"));
|
||||
filters.flip();
|
||||
result = TinyFileDialogs.tinyfd_openFileDialog(title, start, filters, "Litematica (*.litematic)", true);
|
||||
} catch (Throwable t) {
|
||||
LOGGER.error("File dialog failed", t);
|
||||
result = null;
|
||||
}
|
||||
String selection = result;
|
||||
Minecraft.getInstance().execute(() -> {
|
||||
this.dialogOpen = false;
|
||||
if (selection != null && !selection.isBlank()) {
|
||||
List<Path> paths = new ArrayList<>();
|
||||
for (String part : selection.split("\\|")) {
|
||||
paths.add(Path.of(part));
|
||||
}
|
||||
this.importFiles(paths);
|
||||
}
|
||||
});
|
||||
}, "NeoLitematica File Dialog");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFilesDrop(List<Path> files) {
|
||||
this.importFiles(files);
|
||||
}
|
||||
|
||||
private void importFiles(List<Path> files) {
|
||||
int imported = 0;
|
||||
Path last = null;
|
||||
for (Path file : files) {
|
||||
try {
|
||||
last = SchematicFiles.importFile(file);
|
||||
imported++;
|
||||
} catch (SchematicLoadException | IOException e) {
|
||||
this.status = Component.translatable("neolitematica.message.import_failed", file.getFileName().toString(), e.getMessage())
|
||||
.withStyle(ChatFormatting.RED);
|
||||
}
|
||||
}
|
||||
if (imported > 0) {
|
||||
this.status = Component.translatable("neolitematica.message.imported", imported);
|
||||
}
|
||||
this.refresh();
|
||||
if (last != null) {
|
||||
for (FileList.Entry entry : this.list.children()) {
|
||||
if (entry.file.equals(last)) {
|
||||
this.list.setSelected(entry);
|
||||
this.list.centerScrollOn(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
graphics.drawCenteredString(this.font, this.title, this.width / 2, 8, 0xFFFFFF);
|
||||
if (this.list.children().isEmpty()) {
|
||||
graphics.drawCenteredString(this.font, Component.translatable("neolitematica.screen.browser.empty"),
|
||||
10 + this.list.getWidth() / 2, 50, 0xA0A0A0);
|
||||
graphics.drawCenteredString(this.font, Component.translatable("neolitematica.screen.browser.drop_hint"),
|
||||
10 + this.list.getWidth() / 2, 62, 0xA0A0A0);
|
||||
}
|
||||
this.renderInfo(graphics);
|
||||
if (this.status != null) {
|
||||
graphics.drawCenteredString(this.font, this.status, this.width / 2, this.height - 64, 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderInfo(GuiGraphics g) {
|
||||
Path file = this.selectedFile();
|
||||
int x = this.list.getX() + this.list.getWidth() + 10;
|
||||
int y = 28;
|
||||
int maxWidth = this.width - x - 10;
|
||||
if (file == null || maxWidth < 60) {
|
||||
return;
|
||||
}
|
||||
Object info = this.metadata.get(file);
|
||||
List<Component> lines = new ArrayList<>();
|
||||
lines.add(Component.literal(file.getFileName().toString()).withStyle(ChatFormatting.YELLOW));
|
||||
if (info == null) {
|
||||
lines.add(Component.translatable("neolitematica.hud.loading"));
|
||||
} else if (info instanceof String error) {
|
||||
lines.add(Component.literal(error).withStyle(ChatFormatting.RED));
|
||||
} else if (info instanceof SchematicMetadata m) {
|
||||
lines.add(Component.translatable("neolitematica.info.name", m.name()));
|
||||
if (!m.author().isBlank()) lines.add(Component.translatable("neolitematica.info.author", m.author()));
|
||||
lines.add(Component.translatable("neolitematica.info.size", m.enclosingSize().getX(), m.enclosingSize().getY(), m.enclosingSize().getZ()));
|
||||
lines.add(Component.translatable("neolitematica.info.blocks", m.totalBlocks()));
|
||||
lines.add(Component.translatable("neolitematica.info.volume", m.totalVolume()));
|
||||
lines.add(Component.translatable("neolitematica.info.regions", m.regionCount()));
|
||||
lines.add(Component.translatable("neolitematica.info.version", m.formatVersion(), m.dataVersion()));
|
||||
if (m.timeModified() > 0) {
|
||||
lines.add(Component.translatable("neolitematica.info.modified", DateFormat.getDateTimeInstance().format(new Date(m.timeModified()))));
|
||||
}
|
||||
try {
|
||||
lines.add(Component.translatable("neolitematica.info.file_size", Files.size(file) / 1024));
|
||||
} catch (IOException ignored) {
|
||||
// File vanished; the list refreshes on the next action.
|
||||
}
|
||||
if (!m.description().isBlank()) {
|
||||
lines.add(Component.literal(m.description()).withStyle(ChatFormatting.GRAY));
|
||||
}
|
||||
}
|
||||
for (Component line : lines) {
|
||||
for (FormattedCharSequence part : this.font.split(line, maxWidth)) {
|
||||
g.drawString(this.font, part, x, y, 0xFFFFFF, false);
|
||||
y += 10;
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void requestMetadata(Path file) {
|
||||
if (this.metadata.containsKey(file)) {
|
||||
return;
|
||||
}
|
||||
this.metadata.put(file, null);
|
||||
SchematicManager.get().metadata(file).whenComplete((m, error) -> Minecraft.getInstance().execute(() ->
|
||||
this.metadata.put(file, error != null ? SchematicManager.errorMessage(error) : m)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
this.minecraft.setScreen(this.parent);
|
||||
}
|
||||
|
||||
class FileList extends ObjectSelectionList<FileList.Entry> {
|
||||
FileList(Minecraft minecraft, int width, int height, int y) {
|
||||
super(minecraft, width, height, y, 14);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
this.clearEntries();
|
||||
}
|
||||
|
||||
Entry add(Path file) {
|
||||
Entry entry = new Entry(file);
|
||||
this.addEntry(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void centerScrollOn(Entry entry) {
|
||||
super.centerScrollOn(entry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSelected(@Nullable Entry entry) {
|
||||
super.setSelected(entry);
|
||||
if (entry != null) {
|
||||
SchematicBrowserScreen.this.requestMetadata(entry.file);
|
||||
}
|
||||
SchematicBrowserScreen.this.updateButtons();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowWidth() {
|
||||
return this.width - 16;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getScrollbarPosition() {
|
||||
return this.getX() + this.width - 6;
|
||||
}
|
||||
|
||||
class Entry extends ObjectSelectionList.Entry<Entry> {
|
||||
final Path file;
|
||||
private final String label;
|
||||
private long lastClick;
|
||||
|
||||
Entry(Path file) {
|
||||
this.file = file;
|
||||
String relative = SchematicFiles.relativeName(file);
|
||||
this.label = relative.endsWith(SchematicFiles.EXTENSION)
|
||||
? relative.substring(0, relative.length() - SchematicFiles.EXTENSION.length()) : relative;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics g, int index, int top, int left, int width, int height, int mouseX, int mouseY,
|
||||
boolean hovering, float partialTick) {
|
||||
String text = SchematicBrowserScreen.this.font.plainSubstrByWidth(this.label, width - 4);
|
||||
g.drawString(SchematicBrowserScreen.this.font, text, left + 2, top + 2, 0xFFFFFF, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
long now = Util.getMillis();
|
||||
boolean doubleClick = now - this.lastClick < 250L && FileList.this.getSelected() == this;
|
||||
this.lastClick = now;
|
||||
FileList.this.setSelected(this);
|
||||
if (doubleClick) {
|
||||
SchematicBrowserScreen.this.placeSelected();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getNarration() {
|
||||
return Component.literal(this.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.lucas.litematica.gui;
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Small dialog asking for one line of text (used for renaming). */
|
||||
public class TextInputScreen extends Screen {
|
||||
private final Screen parent;
|
||||
private final String initial;
|
||||
private final Consumer<String> onConfirm;
|
||||
private EditBox input;
|
||||
|
||||
public TextInputScreen(Screen parent, Component title, String initial, Consumer<String> onConfirm) {
|
||||
super(title);
|
||||
this.parent = parent;
|
||||
this.initial = initial;
|
||||
this.onConfirm = onConfirm;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
int cx = this.width / 2;
|
||||
int y = this.height / 2 - 20;
|
||||
this.input = new EditBox(this.font, cx - 100, y, 200, 20, this.title);
|
||||
this.input.setMaxLength(128);
|
||||
this.input.setValue(this.initial);
|
||||
this.addRenderableWidget(this.input);
|
||||
this.setInitialFocus(this.input);
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> this.confirm())
|
||||
.bounds(cx - 100, y + 28, 98, 20).build());
|
||||
this.addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), b -> this.onClose())
|
||||
.bounds(cx + 2, y + 28, 98, 20).build());
|
||||
}
|
||||
|
||||
private void confirm() {
|
||||
String value = this.input.getValue().trim();
|
||||
if (!value.isEmpty()) {
|
||||
this.onConfirm.accept(value);
|
||||
}
|
||||
this.onClose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||
if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) {
|
||||
this.confirm();
|
||||
return true;
|
||||
}
|
||||
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
graphics.drawCenteredString(this.font, this.title, this.width / 2, this.height / 2 - 40, 0xFFFFFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
this.minecraft.setScreen(this.parent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package de.lucas.litematica.input;
|
||||
|
||||
import de.lucas.litematica.config.ModConfig;
|
||||
import de.lucas.litematica.config.RenderMode;
|
||||
import de.lucas.litematica.gui.MainScreen;
|
||||
import de.lucas.litematica.gui.PlacementScreen;
|
||||
import de.lucas.litematica.gui.SchematicBrowserScreen;
|
||||
import de.lucas.litematica.placement.PlacementManager;
|
||||
import de.lucas.litematica.placement.SchematicPlacement;
|
||||
import net.minecraft.client.KeyMapping;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** Handles the hotkeys once per client tick. Movement is relative to the direction the player is facing. */
|
||||
public final class InputHandler {
|
||||
private static final int LARGE_STEP = 10;
|
||||
|
||||
private InputHandler() {
|
||||
}
|
||||
|
||||
public static void tick() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null || mc.screen != null) {
|
||||
KeyBindings.ALL.forEach(InputHandler::drain);
|
||||
return;
|
||||
}
|
||||
PlacementManager manager = PlacementManager.get();
|
||||
|
||||
while (KeyBindings.OPEN_MENU.consumeClick()) {
|
||||
mc.setScreen(new MainScreen(null));
|
||||
}
|
||||
while (KeyBindings.OPEN_BROWSER.consumeClick()) {
|
||||
mc.setScreen(new SchematicBrowserScreen(null));
|
||||
}
|
||||
while (KeyBindings.TOGGLE_RENDERING.consumeClick()) {
|
||||
boolean enabled = !ModConfig.RENDERING_ENABLED.get();
|
||||
ModConfig.set(ModConfig.RENDERING_ENABLED, enabled);
|
||||
status(Component.translatable(enabled ? "neolitematica.message.rendering_on" : "neolitematica.message.rendering_off"));
|
||||
}
|
||||
while (KeyBindings.CYCLE_RENDER_MODE.consumeClick()) {
|
||||
RenderMode mode = ModConfig.RENDER_MODE.get().next();
|
||||
ModConfig.set(ModConfig.RENDER_MODE, mode);
|
||||
status(Component.translatable("neolitematica.message.render_mode", mode.getTranslatedName()));
|
||||
}
|
||||
while (KeyBindings.NEXT_PLACEMENT.consumeClick()) {
|
||||
manager.selectNext();
|
||||
SchematicPlacement selected = manager.selected();
|
||||
if (selected != null) {
|
||||
status(Component.translatable("neolitematica.message.selected", selected.name()));
|
||||
}
|
||||
}
|
||||
|
||||
SchematicPlacement p = manager.selected();
|
||||
if (p == null || mc.level == null || !p.dimension().equals(mc.level.dimension().location())) {
|
||||
KeyBindings.ALL.forEach(InputHandler::drain);
|
||||
return;
|
||||
}
|
||||
|
||||
while (KeyBindings.EDIT_PLACEMENT.consumeClick()) {
|
||||
mc.setScreen(new PlacementScreen(null, p));
|
||||
return;
|
||||
}
|
||||
while (KeyBindings.TOGGLE_PLACEMENT.consumeClick()) {
|
||||
manager.setEnabled(p, !p.enabled());
|
||||
status(Component.translatable(p.enabled() ? "neolitematica.message.placement_on" : "neolitematica.message.placement_off", p.name()));
|
||||
}
|
||||
while (KeyBindings.MOVE_TO_PLAYER.consumeClick()) {
|
||||
manager.setOrigin(p, mc.player.blockPosition());
|
||||
showPosition(p);
|
||||
}
|
||||
while (KeyBindings.RESET_POSITION.consumeClick()) {
|
||||
manager.resetOrigin(p);
|
||||
showPosition(p);
|
||||
}
|
||||
|
||||
int step = Screen.hasControlDown() ? LARGE_STEP : 1;
|
||||
Direction facing = mc.player.getDirection();
|
||||
move(manager, p, KeyBindings.MOVE_FORWARD, facing, step);
|
||||
move(manager, p, KeyBindings.MOVE_BACK, facing.getOpposite(), step);
|
||||
move(manager, p, KeyBindings.MOVE_LEFT, facing.getCounterClockWise(), step);
|
||||
move(manager, p, KeyBindings.MOVE_RIGHT, facing.getClockWise(), step);
|
||||
move(manager, p, KeyBindings.MOVE_UP, Direction.UP, step);
|
||||
move(manager, p, KeyBindings.MOVE_DOWN, Direction.DOWN, step);
|
||||
|
||||
while (KeyBindings.ROTATE_CW.consumeClick()) {
|
||||
manager.setTransform(p, p.transform().rotatedClockwise());
|
||||
showTransform(p);
|
||||
}
|
||||
while (KeyBindings.ROTATE_CCW.consumeClick()) {
|
||||
manager.setTransform(p, p.transform().rotatedCounterClockwise());
|
||||
showTransform(p);
|
||||
}
|
||||
while (KeyBindings.MIRROR_X.consumeClick()) {
|
||||
manager.setTransform(p, p.transform().withMirrorX(!p.transform().mirrorX()));
|
||||
showTransform(p);
|
||||
}
|
||||
while (KeyBindings.MIRROR_Y.consumeClick()) {
|
||||
manager.setTransform(p, p.transform().withMirrorY(!p.transform().mirrorY()));
|
||||
showTransform(p);
|
||||
}
|
||||
while (KeyBindings.MIRROR_Z.consumeClick()) {
|
||||
manager.setTransform(p, p.transform().withMirrorZ(!p.transform().mirrorZ()));
|
||||
showTransform(p);
|
||||
}
|
||||
}
|
||||
|
||||
private static void move(PlacementManager manager, SchematicPlacement p, KeyMapping key, Direction direction, int step) {
|
||||
boolean moved = false;
|
||||
while (key.consumeClick()) {
|
||||
manager.move(p, direction.getStepX() * step, direction.getStepY() * step, direction.getStepZ() * step);
|
||||
moved = true;
|
||||
}
|
||||
if (moved) {
|
||||
showPosition(p);
|
||||
}
|
||||
}
|
||||
|
||||
private static void showPosition(SchematicPlacement p) {
|
||||
BlockPos o = p.origin();
|
||||
status(Component.translatable("neolitematica.message.position", p.name(), o.getX(), o.getY(), o.getZ()));
|
||||
}
|
||||
|
||||
private static void showTransform(SchematicPlacement p) {
|
||||
status(Component.translatable("neolitematica.message.transform", p.name(), p.transform().degrees(),
|
||||
TransformText.mirrors(p.transform())));
|
||||
}
|
||||
|
||||
private static void status(Component message) {
|
||||
Minecraft.getInstance().gui.setOverlayMessage(message, false);
|
||||
}
|
||||
|
||||
private static void drain(KeyMapping key) {
|
||||
while (key.consumeClick()) {
|
||||
// Discard presses that happened while a screen was open or nothing was selected.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.lucas.litematica.input;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import net.minecraft.client.KeyMapping;
|
||||
import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent;
|
||||
import net.neoforged.neoforge.client.settings.KeyConflictContext;
|
||||
import net.neoforged.neoforge.client.settings.KeyModifier;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Key bindings, all rebindable in Options → Controls. Defaults use Alt combinations or keys that vanilla leaves
|
||||
* unused in-game (arrow keys, Page Up/Down), to avoid clashes with the many mods in large packs.
|
||||
*/
|
||||
public final class KeyBindings {
|
||||
public static final String CATEGORY = "key.categories.neolitematica";
|
||||
|
||||
public static final KeyMapping OPEN_MENU = alt("open_menu", GLFW.GLFW_KEY_M);
|
||||
public static final KeyMapping OPEN_BROWSER = alt("open_browser", GLFW.GLFW_KEY_L);
|
||||
public static final KeyMapping EDIT_PLACEMENT = alt("edit_placement", GLFW.GLFW_KEY_E);
|
||||
public static final KeyMapping TOGGLE_RENDERING = alt("toggle_rendering", GLFW.GLFW_KEY_H);
|
||||
public static final KeyMapping CYCLE_RENDER_MODE = alt("cycle_render_mode", GLFW.GLFW_KEY_V);
|
||||
public static final KeyMapping NEXT_PLACEMENT = alt("next_placement", GLFW.GLFW_KEY_N);
|
||||
public static final KeyMapping TOGGLE_PLACEMENT = unbound("toggle_placement");
|
||||
public static final KeyMapping MOVE_TO_PLAYER = alt("move_to_player", GLFW.GLFW_KEY_P);
|
||||
public static final KeyMapping RESET_POSITION = unbound("reset_position");
|
||||
|
||||
public static final KeyMapping MOVE_FORWARD = plain("move_forward", GLFW.GLFW_KEY_UP);
|
||||
public static final KeyMapping MOVE_BACK = plain("move_back", GLFW.GLFW_KEY_DOWN);
|
||||
public static final KeyMapping MOVE_LEFT = plain("move_left", GLFW.GLFW_KEY_LEFT);
|
||||
public static final KeyMapping MOVE_RIGHT = plain("move_right", GLFW.GLFW_KEY_RIGHT);
|
||||
public static final KeyMapping MOVE_UP = plain("move_up", GLFW.GLFW_KEY_PAGE_UP);
|
||||
public static final KeyMapping MOVE_DOWN = plain("move_down", GLFW.GLFW_KEY_PAGE_DOWN);
|
||||
|
||||
public static final KeyMapping ROTATE_CW = alt("rotate_cw", GLFW.GLFW_KEY_R);
|
||||
public static final KeyMapping ROTATE_CCW = unbound("rotate_ccw");
|
||||
public static final KeyMapping MIRROR_X = unbound("mirror_x");
|
||||
public static final KeyMapping MIRROR_Y = unbound("mirror_y");
|
||||
public static final KeyMapping MIRROR_Z = unbound("mirror_z");
|
||||
|
||||
static final List<KeyMapping> ALL = List.of(OPEN_MENU, OPEN_BROWSER, EDIT_PLACEMENT, TOGGLE_RENDERING, CYCLE_RENDER_MODE,
|
||||
NEXT_PLACEMENT, TOGGLE_PLACEMENT, MOVE_TO_PLAYER, RESET_POSITION, MOVE_FORWARD, MOVE_BACK, MOVE_LEFT, MOVE_RIGHT,
|
||||
MOVE_UP, MOVE_DOWN, ROTATE_CW, ROTATE_CCW, MIRROR_X, MIRROR_Y, MIRROR_Z);
|
||||
|
||||
private KeyBindings() {
|
||||
}
|
||||
|
||||
public static void register(RegisterKeyMappingsEvent event) {
|
||||
ALL.forEach(event::register);
|
||||
}
|
||||
|
||||
private static KeyMapping alt(String name, int key) {
|
||||
return new KeyMapping("key.neolitematica." + name, KeyConflictContext.IN_GAME, KeyModifier.ALT,
|
||||
InputConstants.Type.KEYSYM, key, CATEGORY);
|
||||
}
|
||||
|
||||
private static KeyMapping plain(String name, int key) {
|
||||
return new KeyMapping("key.neolitematica." + name, KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, key, CATEGORY);
|
||||
}
|
||||
|
||||
private static KeyMapping unbound(String name) {
|
||||
return new KeyMapping("key.neolitematica." + name, KeyConflictContext.IN_GAME, InputConstants.UNKNOWN, CATEGORY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.lucas.litematica.input;
|
||||
|
||||
import de.lucas.litematica.placement.PlacementTransform;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** Short human-readable texts for transforms, shared by HUD, messages and screens. */
|
||||
public final class TransformText {
|
||||
private TransformText() {
|
||||
}
|
||||
|
||||
/** e.g. "X Z", or "-" when nothing is mirrored. */
|
||||
public static Component mirrors(PlacementTransform t) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (t.mirrorX()) sb.append("X ");
|
||||
if (t.mirrorY()) sb.append("Y ");
|
||||
if (t.mirrorZ()) sb.append("Z ");
|
||||
return sb.isEmpty() ? Component.translatable("neolitematica.text.none") : Component.literal(sb.toString().trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.lucas.litematica.mixin;
|
||||
|
||||
import de.lucas.litematica.render.SchematicRenderer;
|
||||
import net.minecraft.client.renderer.LevelRenderer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/**
|
||||
* Notifies the schematic renderer when vanilla re-renders a world section, i.e. when blocks change or chunks load.
|
||||
* NeoForge has no client-side block change event, and the schematic overlay must react immediately when the player
|
||||
* places or breaks a block. All of vanilla's dirty-marking paths funnel into this private method.
|
||||
* {@code require = 0}: if a rendering mod replaces the method, the mod keeps working with chunk-load updates only.
|
||||
*/
|
||||
@Mixin(LevelRenderer.class)
|
||||
abstract class LevelRendererMixin {
|
||||
@Inject(method = "setSectionDirty(IIIZ)V", at = @At("HEAD"), require = 0)
|
||||
private void neolitematica$onSectionDirty(int sectionX, int sectionY, int sectionZ, boolean reRenderOnMainThread, CallbackInfo ci) {
|
||||
SchematicRenderer.get().onWorldSectionDirty(sectionX, sectionY, sectionZ);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package de.lucas.litematica.placement;
|
||||
|
||||
import de.lucas.litematica.schematic.SchematicFiles;
|
||||
import de.lucas.litematica.schematic.SchematicManager;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* All placements of the current world/server. Main thread only. Placements are persisted per world and restored
|
||||
* on the next login, see {@link PlacementStorage}.
|
||||
*/
|
||||
public final class PlacementManager {
|
||||
private static final PlacementManager INSTANCE = new PlacementManager();
|
||||
private static final int SAVE_DELAY_TICKS = 40;
|
||||
|
||||
private final List<SchematicPlacement> placements = new ArrayList<>();
|
||||
@Nullable
|
||||
private UUID selected;
|
||||
@Nullable
|
||||
private String worldKey;
|
||||
private int saveCountdown = -1;
|
||||
|
||||
public static PlacementManager get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public List<SchematicPlacement> all() {
|
||||
return Collections.unmodifiableList(this.placements);
|
||||
}
|
||||
|
||||
/** Placements in the player's current dimension. */
|
||||
public List<SchematicPlacement> inCurrentDimension() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.level == null) {
|
||||
return List.of();
|
||||
}
|
||||
ResourceLocation dim = mc.level.dimension().location();
|
||||
return this.placements.stream().filter(p -> p.dimension().equals(dim)).toList();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public SchematicPlacement selected() {
|
||||
for (SchematicPlacement p : this.placements) {
|
||||
if (p.id().equals(this.selected)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void select(@Nullable SchematicPlacement placement) {
|
||||
this.selected = placement == null ? null : placement.id();
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
/** Selects the next placement in the current dimension, wrapping around. */
|
||||
public void selectNext() {
|
||||
List<SchematicPlacement> list = this.inCurrentDimension();
|
||||
if (list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int index = list.indexOf(this.selected());
|
||||
this.select(list.get((index + 1) % list.size()));
|
||||
}
|
||||
|
||||
/** Places a schematic file with its minimum corner at {@code origin}. The file is loaded in the background. */
|
||||
public SchematicPlacement place(Path file, BlockPos origin) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
ResourceLocation dim = mc.level != null ? mc.level.dimension().location() : ResourceLocation.withDefaultNamespace("overworld");
|
||||
SchematicPlacement placement = new SchematicPlacement(UUID.randomUUID(), SchematicFiles.relativeName(file), dim,
|
||||
SchematicFiles.baseName(file), origin.immutable(), origin.immutable(), PlacementTransform.IDENTITY, true);
|
||||
this.placements.add(placement);
|
||||
this.selected = placement.id();
|
||||
this.loadSchematic(placement, true);
|
||||
this.markDirty();
|
||||
return placement;
|
||||
}
|
||||
|
||||
public void remove(SchematicPlacement placement) {
|
||||
this.placements.remove(placement);
|
||||
if (placement.id().equals(this.selected)) {
|
||||
this.selected = this.placements.isEmpty() ? null : this.placements.getLast().id();
|
||||
}
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
public void move(SchematicPlacement placement, int dx, int dy, int dz) {
|
||||
this.setOrigin(placement, placement.origin().offset(dx, dy, dz));
|
||||
}
|
||||
|
||||
public void setOrigin(SchematicPlacement placement, BlockPos origin) {
|
||||
placement.setOrigin(origin);
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
public void resetOrigin(SchematicPlacement placement) {
|
||||
this.setOrigin(placement, placement.originalOrigin());
|
||||
}
|
||||
|
||||
/** Makes the current position the one {@link #resetOrigin} returns to. */
|
||||
public void storeOriginAsOriginal(SchematicPlacement placement) {
|
||||
placement.setOriginalOrigin(placement.origin());
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
public void setTransform(SchematicPlacement placement, PlacementTransform transform) {
|
||||
placement.setTransform(transform);
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
public void setEnabled(SchematicPlacement placement, boolean enabled) {
|
||||
placement.setEnabled(enabled);
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
public void rename(SchematicPlacement placement, String name) {
|
||||
if (!name.isBlank()) {
|
||||
placement.setName(name.trim());
|
||||
this.markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reloads all placements that use the given file, e.g. after it was replaced on disk. */
|
||||
public void reloadFile(Path file) {
|
||||
String relative = SchematicFiles.relativeName(file);
|
||||
SchematicManager.get().invalidate(file);
|
||||
for (SchematicPlacement p : this.placements) {
|
||||
if (p.schematicFile().equals(relative)) {
|
||||
this.loadSchematic(p, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
if (this.saveCountdown >= 0 && --this.saveCountdown < 0) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
public void onLogin() {
|
||||
this.placements.clear();
|
||||
this.worldKey = PlacementStorage.currentWorldKey();
|
||||
PlacementStorage.Loaded loaded = PlacementStorage.load(this.worldKey);
|
||||
this.placements.addAll(loaded.placements());
|
||||
this.selected = loaded.selected();
|
||||
for (SchematicPlacement p : this.placements) {
|
||||
this.loadSchematic(p, false);
|
||||
}
|
||||
}
|
||||
|
||||
public void onLogout() {
|
||||
this.save();
|
||||
this.placements.clear();
|
||||
this.selected = null;
|
||||
this.worldKey = null;
|
||||
}
|
||||
|
||||
private void markDirty() {
|
||||
this.saveCountdown = SAVE_DELAY_TICKS;
|
||||
}
|
||||
|
||||
private void save() {
|
||||
this.saveCountdown = -1;
|
||||
if (this.worldKey != null) {
|
||||
PlacementStorage.save(this.worldKey, this.placements, this.selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadSchematic(SchematicPlacement placement, boolean announce) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Path file = SchematicFiles.resolve(placement.schematicFile());
|
||||
SchematicManager.get().load(file).whenComplete((schematic, error) -> mc.execute(() -> {
|
||||
if (!this.placements.contains(placement)) {
|
||||
return;
|
||||
}
|
||||
if (error != null) {
|
||||
String message = SchematicManager.errorMessage(error);
|
||||
placement.setSchematic(null, message);
|
||||
notify(Component.translatable("neolitematica.message.load_failed", placement.schematicFile(), message).withStyle(ChatFormatting.RED));
|
||||
} else {
|
||||
placement.setSchematic(schematic, null);
|
||||
if (announce) {
|
||||
notify(Component.translatable("neolitematica.message.placed", placement.name(),
|
||||
placement.origin().getX(), placement.origin().getY(), placement.origin().getZ()));
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static void notify(Component message) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player != null) {
|
||||
mc.player.displayClientMessage(message, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package de.lucas.litematica.placement;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ServerData;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.neoforged.fml.loading.FMLPaths;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Saves placements as JSON in {@code config/neolitematica/placements/<world>.json}, one file per singleplayer world
|
||||
* or server address. This lives on the client only; nothing is stored on the server.
|
||||
*/
|
||||
final class PlacementStorage {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private PlacementStorage() {
|
||||
}
|
||||
|
||||
record Loaded(List<SchematicPlacement> placements, @Nullable UUID selected) {
|
||||
}
|
||||
|
||||
static String currentWorldKey() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
MinecraftServer server = mc.getSingleplayerServer();
|
||||
String key;
|
||||
if (server != null) {
|
||||
Path root = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
|
||||
key = "sp_" + root.getFileName();
|
||||
} else {
|
||||
ServerData data = mc.getCurrentServer();
|
||||
key = data != null ? "mp_" + data.ip : "unknown";
|
||||
}
|
||||
return key.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
}
|
||||
|
||||
private static Path file(String worldKey) {
|
||||
return FMLPaths.CONFIGDIR.get().resolve("neolitematica").resolve("placements").resolve(worldKey + ".json");
|
||||
}
|
||||
|
||||
static Loaded load(String worldKey) {
|
||||
Path file = file(worldKey);
|
||||
List<SchematicPlacement> list = new ArrayList<>();
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return new Loaded(list, null);
|
||||
}
|
||||
try {
|
||||
JsonObject root = JsonParser.parseString(Files.readString(file)).getAsJsonObject();
|
||||
for (JsonElement element : root.getAsJsonArray("placements")) {
|
||||
try {
|
||||
list.add(fromJson(element.getAsJsonObject()));
|
||||
} catch (RuntimeException e) {
|
||||
LOGGER.warn("Skipping invalid placement entry in {}: {}", file, e.toString());
|
||||
}
|
||||
}
|
||||
UUID selected = root.has("selected") ? UUID.fromString(root.get("selected").getAsString()) : null;
|
||||
return new Loaded(list, selected);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
LOGGER.error("Could not read placements from {}", file, e);
|
||||
return new Loaded(list, null);
|
||||
}
|
||||
}
|
||||
|
||||
static void save(String worldKey, List<SchematicPlacement> placements, @Nullable UUID selected) {
|
||||
Path file = file(worldKey);
|
||||
JsonObject root = new JsonObject();
|
||||
JsonArray array = new JsonArray();
|
||||
for (SchematicPlacement p : placements) {
|
||||
array.add(toJson(p));
|
||||
}
|
||||
root.add("placements", array);
|
||||
if (selected != null) {
|
||||
root.addProperty("selected", selected.toString());
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
// Write to a temp file first so a crash mid-write cannot destroy the saved placements.
|
||||
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
Files.writeString(tmp, GSON.toJson(root));
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("Could not save placements to {}", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject toJson(SchematicPlacement p) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("id", p.id().toString());
|
||||
o.addProperty("name", p.name());
|
||||
o.addProperty("file", p.schematicFile());
|
||||
o.addProperty("dimension", p.dimension().toString());
|
||||
o.add("origin", pos(p.origin()));
|
||||
o.add("originalOrigin", pos(p.originalOrigin()));
|
||||
o.addProperty("rotation", p.transform().rotation().name());
|
||||
o.addProperty("mirrorX", p.transform().mirrorX());
|
||||
o.addProperty("mirrorY", p.transform().mirrorY());
|
||||
o.addProperty("mirrorZ", p.transform().mirrorZ());
|
||||
o.addProperty("enabled", p.enabled());
|
||||
return o;
|
||||
}
|
||||
|
||||
private static SchematicPlacement fromJson(JsonObject o) {
|
||||
PlacementTransform transform = new PlacementTransform(
|
||||
Rotation.valueOf(o.get("rotation").getAsString()),
|
||||
o.get("mirrorX").getAsBoolean(),
|
||||
o.get("mirrorY").getAsBoolean(),
|
||||
o.get("mirrorZ").getAsBoolean());
|
||||
return new SchematicPlacement(
|
||||
UUID.fromString(o.get("id").getAsString()),
|
||||
o.get("file").getAsString(),
|
||||
ResourceLocation.parse(o.get("dimension").getAsString()),
|
||||
o.get("name").getAsString(),
|
||||
pos(o.getAsJsonArray("origin")),
|
||||
pos(o.getAsJsonArray("originalOrigin")),
|
||||
transform,
|
||||
o.get("enabled").getAsBoolean());
|
||||
}
|
||||
|
||||
private static JsonArray pos(BlockPos pos) {
|
||||
JsonArray a = new JsonArray();
|
||||
a.add(pos.getX());
|
||||
a.add(pos.getY());
|
||||
a.add(pos.getZ());
|
||||
return a;
|
||||
}
|
||||
|
||||
private static BlockPos pos(JsonArray a) {
|
||||
return new BlockPos(a.get(0).getAsInt(), a.get(1).getAsInt(), a.get(2).getAsInt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.lucas.litematica.placement;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.world.level.block.Mirror;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/**
|
||||
* Rotation and mirroring of a schematic, applied in the order: mirror Y, mirror X, mirror Z, rotate around Y.
|
||||
* This matches vanilla structure placement (mirror before rotate).
|
||||
* <p>
|
||||
* Positions are mapped inside the schematic's bounding box, so the transformed schematic always starts at the
|
||||
* placement origin and extends in positive direction. The origin therefore stays the minimum corner.
|
||||
*
|
||||
* @param mirrorX flips along the X axis (east/west), vanilla {@link Mirror#FRONT_BACK}
|
||||
* @param mirrorY flips upside down; block states are adjusted by {@link VerticalFlip}
|
||||
* @param mirrorZ flips along the Z axis (north/south), vanilla {@link Mirror#LEFT_RIGHT}
|
||||
*/
|
||||
public record PlacementTransform(Rotation rotation, boolean mirrorX, boolean mirrorY, boolean mirrorZ) {
|
||||
public static final PlacementTransform IDENTITY = new PlacementTransform(Rotation.NONE, false, false, false);
|
||||
|
||||
public PlacementTransform withRotation(Rotation rotation) {
|
||||
return new PlacementTransform(rotation, this.mirrorX, this.mirrorY, this.mirrorZ);
|
||||
}
|
||||
|
||||
public PlacementTransform rotatedClockwise() {
|
||||
return this.withRotation(this.rotation.getRotated(Rotation.CLOCKWISE_90));
|
||||
}
|
||||
|
||||
public PlacementTransform rotatedCounterClockwise() {
|
||||
return this.withRotation(this.rotation.getRotated(Rotation.COUNTERCLOCKWISE_90));
|
||||
}
|
||||
|
||||
public PlacementTransform withMirrorX(boolean value) {
|
||||
return new PlacementTransform(this.rotation, value, this.mirrorY, this.mirrorZ);
|
||||
}
|
||||
|
||||
public PlacementTransform withMirrorY(boolean value) {
|
||||
return new PlacementTransform(this.rotation, this.mirrorX, value, this.mirrorZ);
|
||||
}
|
||||
|
||||
public PlacementTransform withMirrorZ(boolean value) {
|
||||
return new PlacementTransform(this.rotation, this.mirrorX, this.mirrorY, value);
|
||||
}
|
||||
|
||||
public boolean swapsXZ() {
|
||||
return this.rotation == Rotation.CLOCKWISE_90 || this.rotation == Rotation.COUNTERCLOCKWISE_90;
|
||||
}
|
||||
|
||||
/** Size of the schematic after this transform. */
|
||||
public Vec3i transformSize(Vec3i size) {
|
||||
return this.swapsXZ() ? new Vec3i(size.getZ(), size.getY(), size.getX()) : size;
|
||||
}
|
||||
|
||||
/** Maps a schematic-local position to an offset from the placement origin. */
|
||||
public BlockPos toPlaced(int x, int y, int z, Vec3i size) {
|
||||
if (this.mirrorY) y = size.getY() - 1 - y;
|
||||
if (this.mirrorX) x = size.getX() - 1 - x;
|
||||
if (this.mirrorZ) z = size.getZ() - 1 - z;
|
||||
return switch (this.rotation) {
|
||||
case NONE -> new BlockPos(x, y, z);
|
||||
case CLOCKWISE_90 -> new BlockPos(size.getZ() - 1 - z, y, x);
|
||||
case CLOCKWISE_180 -> new BlockPos(size.getX() - 1 - x, y, size.getZ() - 1 - z);
|
||||
case COUNTERCLOCKWISE_90 -> new BlockPos(z, y, size.getX() - 1 - x);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link #toPlaced}: maps an offset from the placement origin back to schematic-local coordinates,
|
||||
* written into {@code out}. {@code size} is the untransformed schematic size.
|
||||
*/
|
||||
public void toLocal(int px, int py, int pz, Vec3i size, BlockPos.MutableBlockPos out) {
|
||||
int x, z;
|
||||
switch (this.rotation) {
|
||||
case CLOCKWISE_90 -> {
|
||||
x = pz;
|
||||
z = size.getZ() - 1 - px;
|
||||
}
|
||||
case CLOCKWISE_180 -> {
|
||||
x = size.getX() - 1 - px;
|
||||
z = size.getZ() - 1 - pz;
|
||||
}
|
||||
case COUNTERCLOCKWISE_90 -> {
|
||||
x = size.getX() - 1 - pz;
|
||||
z = px;
|
||||
}
|
||||
default -> {
|
||||
x = px;
|
||||
z = pz;
|
||||
}
|
||||
}
|
||||
int y = py;
|
||||
if (this.mirrorZ) z = size.getZ() - 1 - z;
|
||||
if (this.mirrorX) x = size.getX() - 1 - x;
|
||||
if (this.mirrorY) y = size.getY() - 1 - y;
|
||||
out.set(x, y, z);
|
||||
}
|
||||
|
||||
public BlockState apply(BlockState state) {
|
||||
if (this.mirrorY) state = VerticalFlip.flip(state);
|
||||
if (this.mirrorX) state = state.mirror(Mirror.FRONT_BACK);
|
||||
if (this.mirrorZ) state = state.mirror(Mirror.LEFT_RIGHT);
|
||||
if (this.rotation != Rotation.NONE) state = state.rotate(this.rotation);
|
||||
return state;
|
||||
}
|
||||
|
||||
public int degrees() {
|
||||
return switch (this.rotation) {
|
||||
case NONE -> 0;
|
||||
case CLOCKWISE_90 -> 90;
|
||||
case CLOCKWISE_180 -> 180;
|
||||
case COUNTERCLOCKWISE_90 -> 270;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package de.lucas.litematica.placement;
|
||||
|
||||
import de.lucas.litematica.schematic.LitematicSchematic;
|
||||
import de.lucas.litematica.schematic.SchematicRegion;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a placement: schematic + origin + transform, with block states already transformed per
|
||||
* palette entry. Render workers query blocks in world coordinates through this class without locking.
|
||||
* A new view is created whenever the placement changes.
|
||||
*/
|
||||
public final class PlacementView {
|
||||
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
|
||||
|
||||
private final LitematicSchematic schematic;
|
||||
private final BlockPos origin;
|
||||
private final PlacementTransform transform;
|
||||
private final Vec3i localSize;
|
||||
private final BoundingBox box;
|
||||
private final List<SchematicRegion> regions;
|
||||
private final BlockState[][] transformedPalettes;
|
||||
|
||||
public PlacementView(LitematicSchematic schematic, BlockPos origin, PlacementTransform transform) {
|
||||
this.schematic = schematic;
|
||||
this.origin = origin;
|
||||
this.transform = transform;
|
||||
this.localSize = schematic.size();
|
||||
Vec3i placed = transform.transformSize(this.localSize);
|
||||
this.box = new BoundingBox(origin.getX(), origin.getY(), origin.getZ(),
|
||||
origin.getX() + placed.getX() - 1, origin.getY() + placed.getY() - 1, origin.getZ() + placed.getZ() - 1);
|
||||
this.regions = schematic.regions();
|
||||
this.transformedPalettes = new BlockState[this.regions.size()][];
|
||||
for (int i = 0; i < this.regions.size(); i++) {
|
||||
BlockState[] palette = this.regions.get(i).palette();
|
||||
BlockState[] transformed = new BlockState[palette.length];
|
||||
for (int j = 0; j < palette.length; j++) {
|
||||
transformed[j] = transform.apply(palette[j]);
|
||||
}
|
||||
this.transformedPalettes[i] = transformed;
|
||||
}
|
||||
}
|
||||
|
||||
public LitematicSchematic schematic() {
|
||||
return this.schematic;
|
||||
}
|
||||
|
||||
public BlockPos origin() {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
public PlacementTransform transform() {
|
||||
return this.transform;
|
||||
}
|
||||
|
||||
/** World-space bounding box (inclusive). */
|
||||
public BoundingBox box() {
|
||||
return this.box;
|
||||
}
|
||||
|
||||
/** Transformed block state at a world position; air outside the schematic. {@code scratch} avoids allocation. */
|
||||
public BlockState getState(int x, int y, int z, BlockPos.MutableBlockPos scratch) {
|
||||
int region = this.locate(x, y, z, scratch);
|
||||
if (region < 0) {
|
||||
return AIR;
|
||||
}
|
||||
SchematicRegion r = this.regions.get(region);
|
||||
return this.transformedPalettes[region][r.paletteIndex(scratch.getX(), scratch.getY(), scratch.getZ())];
|
||||
}
|
||||
|
||||
/** {@code true} if the schematic contains a block at this position that does not exist in the running game. */
|
||||
public boolean isUnknown(int x, int y, int z, BlockPos.MutableBlockPos scratch) {
|
||||
int region = this.locate(x, y, z, scratch);
|
||||
if (region < 0) {
|
||||
return false;
|
||||
}
|
||||
SchematicRegion r = this.regions.get(region);
|
||||
return r.unknown()[r.paletteIndex(scratch.getX(), scratch.getY(), scratch.getZ())];
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public CompoundTag getBlockEntity(int x, int y, int z, BlockPos.MutableBlockPos scratch) {
|
||||
int region = this.locate(x, y, z, scratch);
|
||||
return region < 0 ? null : this.regions.get(region).getBlockEntity(scratch.getX(), scratch.getY(), scratch.getZ());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a world position to a region index, leaving the region-local coordinates in {@code scratch}.
|
||||
* Returns -1 if no region covers the position.
|
||||
*/
|
||||
private int locate(int x, int y, int z, BlockPos.MutableBlockPos scratch) {
|
||||
if (!this.box.isInside(x, y, z)) {
|
||||
return -1;
|
||||
}
|
||||
this.transform.toLocal(x - this.origin.getX(), y - this.origin.getY(), z - this.origin.getZ(), this.localSize, scratch);
|
||||
int lx = scratch.getX(), ly = scratch.getY(), lz = scratch.getZ();
|
||||
int fallback = -1;
|
||||
for (int i = 0; i < this.regions.size(); i++) {
|
||||
SchematicRegion r = this.regions.get(i);
|
||||
int rx = lx - r.min().getX(), ry = ly - r.min().getY(), rz = lz - r.min().getZ();
|
||||
if (r.contains(rx, ry, rz)) {
|
||||
int index = r.paletteIndex(rx, ry, rz);
|
||||
if (this.regions.size() == 1 || !r.palette()[index].isAir() || r.unknown()[index]) {
|
||||
scratch.set(rx, ry, rz);
|
||||
return i;
|
||||
}
|
||||
if (fallback < 0) {
|
||||
fallback = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fallback >= 0) {
|
||||
SchematicRegion r = this.regions.get(fallback);
|
||||
scratch.set(lx - r.min().getX(), ly - r.min().getY(), lz - r.min().getZ());
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.lucas.litematica.placement;
|
||||
|
||||
import de.lucas.litematica.schematic.LitematicSchematic;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A schematic placed in a world. Mutable and owned by the main thread; every change increments
|
||||
* {@link #revision()} so the renderer knows to rebuild, and creates a fresh immutable {@link PlacementView}.
|
||||
*/
|
||||
public final class SchematicPlacement {
|
||||
private final UUID id;
|
||||
private final String schematicFile;
|
||||
private final ResourceLocation dimension;
|
||||
private String name;
|
||||
private BlockPos origin;
|
||||
private BlockPos originalOrigin;
|
||||
private PlacementTransform transform;
|
||||
private boolean enabled;
|
||||
|
||||
@Nullable
|
||||
private LitematicSchematic schematic;
|
||||
@Nullable
|
||||
private String loadError;
|
||||
@Nullable
|
||||
private PlacementView view;
|
||||
private int revision;
|
||||
private VerificationStats stats = VerificationStats.EMPTY;
|
||||
|
||||
public SchematicPlacement(UUID id, String schematicFile, ResourceLocation dimension, String name, BlockPos origin,
|
||||
BlockPos originalOrigin, PlacementTransform transform, boolean enabled) {
|
||||
this.id = id;
|
||||
this.schematicFile = schematicFile;
|
||||
this.dimension = dimension;
|
||||
this.name = name;
|
||||
this.origin = origin;
|
||||
this.originalOrigin = originalOrigin;
|
||||
this.transform = transform;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public UUID id() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/** Schematic file, relative to the schematics directory. */
|
||||
public String schematicFile() {
|
||||
return this.schematicFile;
|
||||
}
|
||||
|
||||
public ResourceLocation dimension() {
|
||||
return this.dimension;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public BlockPos origin() {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
public BlockPos originalOrigin() {
|
||||
return this.originalOrigin;
|
||||
}
|
||||
|
||||
public PlacementTransform transform() {
|
||||
return this.transform;
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public LitematicSchematic schematic() {
|
||||
return this.schematic;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String loadError() {
|
||||
return this.loadError;
|
||||
}
|
||||
|
||||
public int revision() {
|
||||
return this.revision;
|
||||
}
|
||||
|
||||
public VerificationStats stats() {
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
public void setStats(VerificationStats stats) {
|
||||
this.stats = stats;
|
||||
}
|
||||
|
||||
/** The current immutable view, or {@code null} while the schematic is not loaded. */
|
||||
@Nullable
|
||||
public PlacementView view() {
|
||||
if (this.view == null && this.schematic != null) {
|
||||
this.view = new PlacementView(this.schematic, this.origin, this.transform);
|
||||
}
|
||||
return this.view;
|
||||
}
|
||||
|
||||
/** Transformed size, or zero while the schematic is not loaded. */
|
||||
public Vec3i placedSize() {
|
||||
return this.schematic == null ? Vec3i.ZERO : this.transform.transformSize(this.schematic.size());
|
||||
}
|
||||
|
||||
void setSchematic(@Nullable LitematicSchematic schematic, @Nullable String error) {
|
||||
this.schematic = schematic;
|
||||
this.loadError = error;
|
||||
this.changed();
|
||||
}
|
||||
|
||||
void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
void setOrigin(BlockPos origin) {
|
||||
if (!origin.equals(this.origin)) {
|
||||
this.origin = origin.immutable();
|
||||
this.changed();
|
||||
}
|
||||
}
|
||||
|
||||
void setOriginalOrigin(BlockPos originalOrigin) {
|
||||
this.originalOrigin = originalOrigin.immutable();
|
||||
}
|
||||
|
||||
void setTransform(PlacementTransform transform) {
|
||||
if (!transform.equals(this.transform)) {
|
||||
this.transform = transform;
|
||||
this.changed();
|
||||
}
|
||||
}
|
||||
|
||||
void setEnabled(boolean enabled) {
|
||||
if (enabled != this.enabled) {
|
||||
this.enabled = enabled;
|
||||
this.changed();
|
||||
}
|
||||
}
|
||||
|
||||
private void changed() {
|
||||
this.view = null;
|
||||
this.revision++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.lucas.litematica.placement;
|
||||
|
||||
/**
|
||||
* Result of comparing a placement with the world, summed over all currently built render sections.
|
||||
* Only sections within render distance are compared, so {@link #complete} tells whether the numbers cover the
|
||||
* whole placement.
|
||||
*/
|
||||
public record VerificationStats(int correct, int missing, int wrongBlock, int wrongState, int extra, int unknown,
|
||||
boolean complete) {
|
||||
public static final VerificationStats EMPTY = new VerificationStats(0, 0, 0, 0, 0, 0, false);
|
||||
|
||||
public int total() {
|
||||
return this.correct + this.missing + this.wrongBlock + this.wrongState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.lucas.litematica.placement;
|
||||
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.AttachFace;
|
||||
import net.minecraft.world.level.block.state.properties.BellAttachType;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.DirectionProperty;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
import net.minecraft.world.level.block.state.properties.Half;
|
||||
import net.minecraft.world.level.block.state.properties.Property;
|
||||
import net.minecraft.world.level.block.state.properties.SlabType;
|
||||
|
||||
/**
|
||||
* Upside-down mirroring of block states. Vanilla has no API for this ({@code Mirror} only covers X and Z), so the
|
||||
* common vertical properties are flipped explicitly: stair/trapdoor halves, slab types, door/plant halves,
|
||||
* up/down facings, floor/ceiling attachment, dripstone direction and up/down connection flags.
|
||||
* Blocks without a vertical representation (e.g. hanging lanterns) keep their state.
|
||||
*/
|
||||
public final class VerticalFlip {
|
||||
private VerticalFlip() {
|
||||
}
|
||||
|
||||
public static BlockState flip(BlockState state) {
|
||||
for (Property<?> property : state.getProperties()) {
|
||||
state = flipProperty(state, property);
|
||||
}
|
||||
if (state.hasProperty(BlockStateProperties.UP) && state.hasProperty(BlockStateProperties.DOWN)) {
|
||||
boolean up = state.getValue(BlockStateProperties.UP);
|
||||
boolean down = state.getValue(BlockStateProperties.DOWN);
|
||||
state = state.setValue(BlockStateProperties.UP, down).setValue(BlockStateProperties.DOWN, up);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private static BlockState flipProperty(BlockState state, Property<?> property) {
|
||||
Object value = state.getValue(property);
|
||||
if (value instanceof Half half) {
|
||||
return set(state, property, half == Half.TOP ? Half.BOTTOM : Half.TOP);
|
||||
}
|
||||
if (value instanceof SlabType type && type != SlabType.DOUBLE) {
|
||||
return set(state, property, type == SlabType.TOP ? SlabType.BOTTOM : SlabType.TOP);
|
||||
}
|
||||
if (value instanceof DoubleBlockHalf half) {
|
||||
return set(state, property, half == DoubleBlockHalf.UPPER ? DoubleBlockHalf.LOWER : DoubleBlockHalf.UPPER);
|
||||
}
|
||||
if (value instanceof AttachFace face && face != AttachFace.WALL) {
|
||||
return set(state, property, face == AttachFace.FLOOR ? AttachFace.CEILING : AttachFace.FLOOR);
|
||||
}
|
||||
if (value instanceof BellAttachType type && (type == BellAttachType.FLOOR || type == BellAttachType.CEILING)) {
|
||||
return set(state, property, type == BellAttachType.FLOOR ? BellAttachType.CEILING : BellAttachType.FLOOR);
|
||||
}
|
||||
if (property instanceof DirectionProperty && value instanceof Direction dir && dir.getAxis() == Direction.Axis.Y) {
|
||||
return set(state, property, dir.getOpposite());
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private static BlockState set(BlockState state, Property property, Comparable value) {
|
||||
// Some blocks restrict a property's values (e.g. hoppers cannot face up); keep the state then.
|
||||
return property.getPossibleValues().contains(value) ? state.setValue(property, value) : state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.lucas.litematica.render;
|
||||
|
||||
import de.lucas.litematica.config.RenderMode;
|
||||
import net.minecraft.world.level.block.LiquidBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/** Comparison of one schematic position with the world. Stored as a byte per block while meshing. */
|
||||
final class BlockStatus {
|
||||
static final byte NONE = 0;
|
||||
/** Schematic block shown as ghost without comparison (mode ALL, or the chunk is not loaded). */
|
||||
static final byte UNCHECKED = 1;
|
||||
static final byte MISSING = 2;
|
||||
static final byte CORRECT = 3;
|
||||
static final byte WRONG_BLOCK = 4;
|
||||
static final byte WRONG_STATE = 5;
|
||||
static final byte EXTRA = 6;
|
||||
static final byte UNKNOWN = 7;
|
||||
static final int COUNT = 8;
|
||||
|
||||
private BlockStatus() {
|
||||
}
|
||||
|
||||
static byte classify(BlockState schematic, boolean unknownBlock, @Nullable BlockState world, RenderMode mode) {
|
||||
if (unknownBlock) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
if (schematic.isAir()) {
|
||||
if (world != null && mode.comparesWorld() && !world.isAir() && !(world.getBlock() instanceof LiquidBlock)) {
|
||||
return EXTRA;
|
||||
}
|
||||
return NONE;
|
||||
}
|
||||
if (world == null || !mode.comparesWorld()) {
|
||||
return UNCHECKED;
|
||||
}
|
||||
if (world == schematic) {
|
||||
return CORRECT;
|
||||
}
|
||||
if (world.getBlock() == schematic.getBlock()) {
|
||||
// Fluid levels change on their own; any state of the right fluid counts as correct.
|
||||
return schematic.getBlock() instanceof LiquidBlock ? CORRECT : WRONG_STATE;
|
||||
}
|
||||
// Air, fluids, grass, snow layers... can simply be built over.
|
||||
if (world.isAir() || world.canBeReplaced()) {
|
||||
return MISSING;
|
||||
}
|
||||
return WRONG_BLOCK;
|
||||
}
|
||||
|
||||
static boolean showsGhost(byte status) {
|
||||
return status == UNCHECKED || status == MISSING;
|
||||
}
|
||||
|
||||
/** Overlay colour as ARGB, or 0 for no overlay. */
|
||||
static int overlayColor(byte status, RenderMode mode) {
|
||||
return switch (status) {
|
||||
case WRONG_BLOCK -> 0xFFFF2828;
|
||||
case WRONG_STATE -> 0xFFFFA000;
|
||||
case UNKNOWN -> 0xFF8C3CFF;
|
||||
case EXTRA -> mode == RenderMode.VERIFY ? 0xFFFF00FF : 0;
|
||||
case CORRECT -> mode == RenderMode.VERIFY ? 0x8028DC28 : 0;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.lucas.litematica.render;
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.mojang.blaze3d.vertex.BufferBuilder;
|
||||
import com.mojang.blaze3d.vertex.BufferUploader;
|
||||
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
|
||||
import com.mojang.blaze3d.vertex.MeshData;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Buffer source for block entity renderers that draws everything semi-transparent.
|
||||
* <p>
|
||||
* Block entity renderers pick their own render types (mostly opaque entity types). Each batch is drawn with the
|
||||
* type's normal state, but with blending forced on and the shader colour's alpha lowered; all vanilla entity
|
||||
* shaders multiply by {@code ColorModulator}, so this makes chests, signs, beds etc. translucent like block ghosts.
|
||||
*/
|
||||
final class GhostBufferSource implements MultiBufferSource, AutoCloseable {
|
||||
private static final int MAX_CACHED_TYPES = 64;
|
||||
|
||||
private final Map<RenderType, ByteBufferBuilder> memory = new HashMap<>();
|
||||
private final Map<RenderType, BufferBuilder> active = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
public VertexConsumer getBuffer(RenderType type) {
|
||||
return this.active.computeIfAbsent(type, t -> new BufferBuilder(
|
||||
this.memory.computeIfAbsent(t, x -> new ByteBufferBuilder(Math.min(x.bufferSize(), 1 << 18))), t.mode(), t.format()));
|
||||
}
|
||||
|
||||
void flush(float alpha) {
|
||||
for (Map.Entry<RenderType, BufferBuilder> entry : this.active.entrySet()) {
|
||||
MeshData mesh = entry.getValue().build();
|
||||
if (mesh == null) {
|
||||
continue;
|
||||
}
|
||||
RenderType type = entry.getKey();
|
||||
type.setupRenderState();
|
||||
RenderSystem.enableBlend();
|
||||
RenderSystem.defaultBlendFunc();
|
||||
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, alpha);
|
||||
BufferUploader.drawWithShader(mesh);
|
||||
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f);
|
||||
type.clearRenderState();
|
||||
}
|
||||
this.active.clear();
|
||||
if (this.memory.size() > MAX_CACHED_TYPES) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
this.active.clear();
|
||||
this.memory.values().forEach(ByteBufferBuilder::close);
|
||||
this.memory.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package de.lucas.litematica.render;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import java.io.IOException;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.StringTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import de.lucas.litematica.placement.PlacementView;
|
||||
import de.lucas.litematica.placement.SchematicPlacement;
|
||||
import de.lucas.litematica.placement.VerificationStats;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.level.block.EntityBlock;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Render state of one placement: its sections and block entity ghosts. Main/render thread only. */
|
||||
final class PlacementRenderer {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
/** Block entity types whose renderer failed once; they are not tried again this session. */
|
||||
static final Set<BlockEntityType<?>> FAILED_BLOCK_ENTITIES = ConcurrentHashMap.newKeySet();
|
||||
private static final Object NO_BLOCK_ENTITY = new Object();
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
private final SchematicPlacement placement;
|
||||
private final Long2ObjectMap<RenderSection> sections = new Long2ObjectOpenHashMap<>();
|
||||
private final Long2ObjectMap<Object> blockEntities = new Long2ObjectOpenHashMap<>();
|
||||
@Nullable
|
||||
private PlacementView view;
|
||||
private int revision = -1;
|
||||
private int generation;
|
||||
|
||||
PlacementRenderer(SchematicPlacement placement) {
|
||||
this.placement = placement;
|
||||
}
|
||||
|
||||
SchematicPlacement placement() {
|
||||
return this.placement;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
PlacementView view() {
|
||||
return this.view;
|
||||
}
|
||||
|
||||
int generation() {
|
||||
return this.generation;
|
||||
}
|
||||
|
||||
Iterable<RenderSection> sections() {
|
||||
return this.sections.values();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
RenderSection section(long key) {
|
||||
return this.sections.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks up placement changes, creates sections in range, drops far ones and adds sections that need a
|
||||
* rebuild to {@code candidates}.
|
||||
*/
|
||||
void update(int camX, int camY, int camZ, int distance, List<RenderSection> candidates) {
|
||||
if (this.placement.revision() != this.revision) {
|
||||
this.revision = this.placement.revision();
|
||||
this.view = this.placement.view();
|
||||
this.generation++;
|
||||
this.blockEntities.clear();
|
||||
for (RenderSection section : this.sections.values()) {
|
||||
section.markDirty();
|
||||
}
|
||||
}
|
||||
if (this.view == null) {
|
||||
this.closeAll();
|
||||
return;
|
||||
}
|
||||
|
||||
BoundingBox box = this.view.box();
|
||||
int minX = Math.max(SectionPos.blockToSectionCoord(box.minX()), camX - distance);
|
||||
int maxX = Math.min(SectionPos.blockToSectionCoord(box.maxX()), camX + distance);
|
||||
int minY = Math.max(SectionPos.blockToSectionCoord(box.minY()), camY - distance);
|
||||
int maxY = Math.min(SectionPos.blockToSectionCoord(box.maxY()), camY + distance);
|
||||
int minZ = Math.max(SectionPos.blockToSectionCoord(box.minZ()), camZ - distance);
|
||||
int maxZ = Math.min(SectionPos.blockToSectionCoord(box.maxZ()), camZ + distance);
|
||||
|
||||
// Drop sections outside the box or well outside the render distance (hysteresis avoids rebuild ping-pong).
|
||||
Iterator<RenderSection> it = this.sections.values().iterator();
|
||||
while (it.hasNext()) {
|
||||
RenderSection section = it.next();
|
||||
int sx = SectionPos.x(section.key()), sy = SectionPos.y(section.key()), sz = SectionPos.z(section.key());
|
||||
boolean inBox = sx >= SectionPos.blockToSectionCoord(box.minX()) && sx <= SectionPos.blockToSectionCoord(box.maxX())
|
||||
&& sy >= SectionPos.blockToSectionCoord(box.minY()) && sy <= SectionPos.blockToSectionCoord(box.maxY())
|
||||
&& sz >= SectionPos.blockToSectionCoord(box.minZ()) && sz <= SectionPos.blockToSectionCoord(box.maxZ());
|
||||
int far = Math.max(Math.abs(sx - camX), Math.max(Math.abs(sy - camY), Math.abs(sz - camZ)));
|
||||
if (!inBox || far > distance + 2) {
|
||||
section.close();
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
|
||||
for (int sx = minX; sx <= maxX; sx++) {
|
||||
for (int sy = minY; sy <= maxY; sy++) {
|
||||
for (int sz = minZ; sz <= maxZ; sz++) {
|
||||
long key = SectionPos.asLong(sx, sy, sz);
|
||||
RenderSection section = this.sections.get(key);
|
||||
if (section == null) {
|
||||
section = new RenderSection(key);
|
||||
this.sections.put(key, section);
|
||||
}
|
||||
if (section.needsBuild()) {
|
||||
candidates.add(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void markDirty(long sectionKey) {
|
||||
RenderSection section = this.sections.get(sectionKey);
|
||||
if (section != null) {
|
||||
section.markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
void markAllDirty() {
|
||||
for (RenderSection section : this.sections.values()) {
|
||||
section.markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sums the comparison results of all built sections into the placement's statistics. */
|
||||
void updateStats(boolean comparesWorld) {
|
||||
if (this.view == null) {
|
||||
this.placement.setStats(VerificationStats.EMPTY);
|
||||
return;
|
||||
}
|
||||
int[] sum = new int[BlockStatus.COUNT];
|
||||
boolean complete = comparesWorld;
|
||||
for (RenderSection section : this.sections.values()) {
|
||||
if (!section.hasData() || section.needsBuild()) {
|
||||
complete = false;
|
||||
}
|
||||
int[] counts = section.counts();
|
||||
for (int i = 0; i < sum.length; i++) {
|
||||
sum[i] += counts[i];
|
||||
}
|
||||
}
|
||||
BoundingBox box = this.view.box();
|
||||
long sectionsInBox = (long) (SectionPos.blockToSectionCoord(box.maxX()) - SectionPos.blockToSectionCoord(box.minX()) + 1)
|
||||
* (SectionPos.blockToSectionCoord(box.maxY()) - SectionPos.blockToSectionCoord(box.minY()) + 1)
|
||||
* (SectionPos.blockToSectionCoord(box.maxZ()) - SectionPos.blockToSectionCoord(box.minZ()) + 1);
|
||||
if (this.sections.size() < sectionsInBox || sum[BlockStatus.UNCHECKED] > 0) {
|
||||
complete = false;
|
||||
}
|
||||
this.placement.setStats(new VerificationStats(sum[BlockStatus.CORRECT], sum[BlockStatus.MISSING],
|
||||
sum[BlockStatus.WRONG_BLOCK], sum[BlockStatus.WRONG_STATE], sum[BlockStatus.EXTRA], sum[BlockStatus.UNKNOWN], complete));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the block entity used to draw a ghost at {@code pos}, creating it from the schematic's NBT on first
|
||||
* use, or {@code null} if the block has no block entity renderer.
|
||||
*/
|
||||
@Nullable
|
||||
BlockEntity blockEntity(long pos, ClientLevel level) {
|
||||
Object cached = this.blockEntities.get(pos);
|
||||
if (cached == null) {
|
||||
cached = this.createBlockEntity(BlockPos.of(pos), level);
|
||||
this.blockEntities.put(pos, cached == null ? NO_BLOCK_ENTITY : cached);
|
||||
}
|
||||
return cached instanceof BlockEntity be ? be : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private BlockEntity createBlockEntity(BlockPos pos, ClientLevel level) {
|
||||
if (this.view == null) {
|
||||
return null;
|
||||
}
|
||||
BlockPos.MutableBlockPos scratch = new BlockPos.MutableBlockPos();
|
||||
BlockState state = this.view.getState(pos.getX(), pos.getY(), pos.getZ(), scratch);
|
||||
if (!(state.getBlock() instanceof EntityBlock entityBlock)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
BlockEntity be = entityBlock.newBlockEntity(pos, state);
|
||||
if (be == null || FAILED_BLOCK_ENTITIES.contains(be.getType())
|
||||
|| Minecraft.getInstance().getBlockEntityRenderDispatcher().getRenderer(be) == null) {
|
||||
return null;
|
||||
}
|
||||
CompoundTag tag = this.view.getBlockEntity(pos.getX(), pos.getY(), pos.getZ(), scratch);
|
||||
if (tag != null) {
|
||||
CompoundTag copy = tag.copy();
|
||||
copy.putInt("x", pos.getX());
|
||||
copy.putInt("y", pos.getY());
|
||||
copy.putInt("z", pos.getZ());
|
||||
repairSignText(copy, "front_text");
|
||||
repairSignText(copy, "back_text");
|
||||
be.loadWithComponents(copy, level.registryAccess());
|
||||
}
|
||||
// Vanilla renderers need the level (e.g. double chests). Modded block entities may register
|
||||
// themselves somewhere when given a level, so they stay detached.
|
||||
ResourceLocation type = BuiltInRegistries.BLOCK_ENTITY_TYPE.getKey(be.getType());
|
||||
if (type != null && type.getNamespace().equals("minecraft")) {
|
||||
be.setLevel(level);
|
||||
}
|
||||
return be;
|
||||
} catch (Throwable t) {
|
||||
LOGGER.warn("Cannot create block entity for {} in schematic: {}", state, t.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some schematic generators store sign lines as plain text instead of JSON text components, which vanilla
|
||||
* rejects (blank sign plus log spam). Plain lines are converted to JSON strings.
|
||||
*/
|
||||
private static void repairSignText(CompoundTag tag, String side) {
|
||||
if (!tag.contains(side, Tag.TAG_COMPOUND)) {
|
||||
return;
|
||||
}
|
||||
ListTag messages = tag.getCompound(side).getList("messages", Tag.TAG_STRING);
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
String line = messages.getString(i);
|
||||
try {
|
||||
// Strict parse like vanilla; the lenient JsonParser would accept unquoted text.
|
||||
if (!line.isBlank() && GSON.getAdapter(JsonElement.class).fromJson(line) != null) {
|
||||
continue;
|
||||
}
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
// Not JSON: plain text.
|
||||
}
|
||||
messages.set(i, StringTag.valueOf(GSON.toJson(line)));
|
||||
}
|
||||
}
|
||||
|
||||
void closeAll() {
|
||||
for (RenderSection section : this.sections.values()) {
|
||||
section.close();
|
||||
}
|
||||
this.sections.clear();
|
||||
this.blockEntities.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package de.lucas.litematica.render;
|
||||
|
||||
import com.mojang.blaze3d.vertex.VertexBuffer;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* GPU buffers of one 16x16x16 world section of a placement. Rebuilt only when the placement or the world inside
|
||||
* the section changes; drawing a frame just binds the cached buffers. Main/render thread only.
|
||||
*/
|
||||
final class RenderSection {
|
||||
private static final long[] NO_BLOCK_ENTITIES = new long[0];
|
||||
|
||||
private final long key;
|
||||
private final int originX, originY, originZ;
|
||||
private final AABB bounds;
|
||||
|
||||
@Nullable
|
||||
private VertexBuffer ghost;
|
||||
@Nullable
|
||||
private VertexBuffer overlay;
|
||||
private long[] blockEntities = NO_BLOCK_ENTITIES;
|
||||
private int[] counts = new int[BlockStatus.COUNT];
|
||||
|
||||
/** Incremented when the section must be rebuilt; a build result carries the version it was started for. */
|
||||
private int dirtyVersion = 1;
|
||||
private int builtVersion;
|
||||
private boolean hasData;
|
||||
private boolean building;
|
||||
|
||||
RenderSection(long key) {
|
||||
this.key = key;
|
||||
this.originX = SectionPos.sectionToBlockCoord(SectionPos.x(key));
|
||||
this.originY = SectionPos.sectionToBlockCoord(SectionPos.y(key));
|
||||
this.originZ = SectionPos.sectionToBlockCoord(SectionPos.z(key));
|
||||
this.bounds = new AABB(this.originX, this.originY, this.originZ, this.originX + 16, this.originY + 16, this.originZ + 16);
|
||||
}
|
||||
|
||||
long key() {
|
||||
return this.key;
|
||||
}
|
||||
|
||||
int originX() {
|
||||
return this.originX;
|
||||
}
|
||||
|
||||
int originY() {
|
||||
return this.originY;
|
||||
}
|
||||
|
||||
int originZ() {
|
||||
return this.originZ;
|
||||
}
|
||||
|
||||
AABB bounds() {
|
||||
return this.bounds;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
VertexBuffer ghost() {
|
||||
return this.ghost;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
VertexBuffer overlay() {
|
||||
return this.overlay;
|
||||
}
|
||||
|
||||
long[] blockEntities() {
|
||||
return this.blockEntities;
|
||||
}
|
||||
|
||||
int[] counts() {
|
||||
return this.counts;
|
||||
}
|
||||
|
||||
boolean hasData() {
|
||||
return this.hasData;
|
||||
}
|
||||
|
||||
void markDirty() {
|
||||
this.dirtyVersion++;
|
||||
}
|
||||
|
||||
boolean needsBuild() {
|
||||
return !this.building && this.builtVersion != this.dirtyVersion;
|
||||
}
|
||||
|
||||
int dirtyVersion() {
|
||||
return this.dirtyVersion;
|
||||
}
|
||||
|
||||
void setBuilding(boolean building) {
|
||||
this.building = building;
|
||||
}
|
||||
|
||||
/** Uploads a finished build. Takes ownership of the result's native memory. */
|
||||
void upload(SectionMeshBuilder.Result result) {
|
||||
this.ghost = uploadInto(this.ghost, result.ghost());
|
||||
this.overlay = uploadInto(this.overlay, result.overlay());
|
||||
if (result.ghostMemory() != null) result.ghostMemory().close();
|
||||
if (result.overlayMemory() != null) result.overlayMemory().close();
|
||||
this.blockEntities = result.blockEntities();
|
||||
this.counts = result.counts();
|
||||
this.builtVersion = result.task().dirtyVersion();
|
||||
this.hasData = true;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static VertexBuffer uploadInto(@Nullable VertexBuffer buffer, @Nullable com.mojang.blaze3d.vertex.MeshData mesh) {
|
||||
if (mesh == null) {
|
||||
if (buffer != null) {
|
||||
buffer.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (buffer == null) {
|
||||
buffer = new VertexBuffer(VertexBuffer.Usage.STATIC);
|
||||
}
|
||||
buffer.bind();
|
||||
buffer.upload(mesh);
|
||||
VertexBuffer.unbind();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (this.ghost != null) {
|
||||
this.ghost.close();
|
||||
this.ghost = null;
|
||||
}
|
||||
if (this.overlay != null) {
|
||||
this.overlay.close();
|
||||
this.overlay = null;
|
||||
}
|
||||
this.hasData = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package de.lucas.litematica.render;
|
||||
|
||||
import de.lucas.litematica.placement.PlacementView;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.level.BlockAndTintGetter;
|
||||
import net.minecraft.world.level.ColorResolver;
|
||||
import net.minecraft.world.level.LightLayer;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.lighting.LevelLightEngine;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* The world as the block renderer sees it while meshing a schematic: block states come from the placement, so
|
||||
* faces between neighbouring schematic blocks are culled and connected textures (CTM, Fusion, connected glass)
|
||||
* connect within the schematic. Biome tints and shading come from the real level.
|
||||
* <p>
|
||||
* Light is always full block light so ghosts stay visible at night and underground.
|
||||
* One instance per build task; not thread-safe (uses a scratch position).
|
||||
*/
|
||||
final class SchematicBlockGetter implements BlockAndTintGetter {
|
||||
private static final int FULL_LIGHT = 15;
|
||||
|
||||
private final PlacementView view;
|
||||
private final ClientLevel level;
|
||||
private final BlockPos.MutableBlockPos scratch = new BlockPos.MutableBlockPos();
|
||||
|
||||
SchematicBlockGetter(PlacementView view, ClientLevel level) {
|
||||
this.view = view;
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockState getBlockState(BlockPos pos) {
|
||||
return this.view.getState(pos.getX(), pos.getY(), pos.getZ(), this.scratch);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FluidState getFluidState(BlockPos pos) {
|
||||
return this.getBlockState(pos).getFluidState();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public BlockEntity getBlockEntity(BlockPos pos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getShade(Direction direction, boolean shade) {
|
||||
return this.level.getShade(direction, shade);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LevelLightEngine getLightEngine() {
|
||||
return this.level.getLightEngine();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBrightness(LightLayer lightType, BlockPos pos) {
|
||||
return FULL_LIGHT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRawBrightness(BlockPos pos, int amount) {
|
||||
return FULL_LIGHT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBlockTint(BlockPos pos, ColorResolver resolver) {
|
||||
return this.level.getBlockTint(pos, resolver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getHeight() {
|
||||
return this.level.getHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMinBuildHeight() {
|
||||
return this.level.getMinBuildHeight();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package de.lucas.litematica.render;
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexBuffer;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import de.lucas.litematica.config.ModConfig;
|
||||
import de.lucas.litematica.config.RenderMode;
|
||||
import de.lucas.litematica.placement.PlacementManager;
|
||||
import de.lucas.litematica.placement.PlacementView;
|
||||
import de.lucas.litematica.placement.SchematicPlacement;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.GameRenderer;
|
||||
import net.minecraft.client.renderer.LevelRenderer;
|
||||
import net.minecraft.client.renderer.LightTexture;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.ShaderInstance;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.culling.Frustum;
|
||||
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.neoforged.neoforge.client.event.RenderLevelStageEvent;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.joml.Matrix4f;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Renders all placements of the current dimension.
|
||||
* <p>
|
||||
* Design (similar in spirit to vanilla's chunk renderer): placements are split into 16x16x16 world sections.
|
||||
* Each section is meshed once on a background thread into GPU vertex buffers and only rebuilt when the placement
|
||||
* changes or a block in that world section changes (reported by {@code LevelRendererMixin}). A frame then only
|
||||
* binds and draws cached buffers, so even schematics with hundreds of thousands of blocks cost little per frame.
|
||||
* <p>
|
||||
* Drawing happens in {@link RenderLevelStageEvent.Stage#AFTER_TRANSLUCENT_BLOCKS} using vanilla shaders, which
|
||||
* keeps it compatible with the normal renderer and with mods that only modify chunk rendering.
|
||||
*/
|
||||
public final class SchematicRenderer {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final SchematicRenderer INSTANCE = new SchematicRenderer();
|
||||
private static final long UPLOAD_BUDGET_NANOS = 3_000_000L;
|
||||
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
|
||||
|
||||
private final Map<UUID, PlacementRenderer> renderers = new HashMap<>();
|
||||
private final ConcurrentLinkedQueue<SectionMeshBuilder.Result> results = new ConcurrentLinkedQueue<>();
|
||||
private final ConcurrentLinkedQueue<Long> dirtyWorldSections = new ConcurrentLinkedQueue<>();
|
||||
private final AtomicInteger inFlight = new AtomicInteger();
|
||||
private final GhostBufferSource blockEntityBuffers = new GhostBufferSource();
|
||||
@Nullable
|
||||
private ExecutorService workers;
|
||||
private int threads;
|
||||
@Nullable
|
||||
private MultiBufferSource.BufferSource lineBuffers;
|
||||
@Nullable
|
||||
private RenderMode lastMode;
|
||||
@Nullable
|
||||
private ClientLevel lastLevel;
|
||||
|
||||
public static SchematicRenderer get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/** Called from the level renderer whenever vanilla marks a world section for re-rendering. Any thread. */
|
||||
public void onWorldSectionDirty(int x, int y, int z) {
|
||||
if (!this.renderers.isEmpty()) {
|
||||
this.dirtyWorldSections.add(SectionPos.asLong(x, y, z));
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks a whole chunk column dirty, used when chunks (re)load. */
|
||||
public void onChunkLoaded(int chunkX, int chunkZ) {
|
||||
ClientLevel level = Minecraft.getInstance().level;
|
||||
if (level == null || this.renderers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
|
||||
this.dirtyWorldSections.add(SectionPos.asLong(chunkX, sy, chunkZ));
|
||||
}
|
||||
}
|
||||
|
||||
/** Main thread, once per client tick: syncs placements and schedules section builds. */
|
||||
public void tick() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
ClientLevel level = mc.level;
|
||||
if (level != this.lastLevel) {
|
||||
this.clear();
|
||||
this.lastLevel = level;
|
||||
}
|
||||
if (level == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
RenderMode mode = ModConfig.RENDER_MODE.get();
|
||||
if (mode != this.lastMode) {
|
||||
this.lastMode = mode;
|
||||
this.renderers.values().forEach(PlacementRenderer::markAllDirty);
|
||||
}
|
||||
|
||||
this.syncPlacements();
|
||||
|
||||
Long key;
|
||||
while ((key = this.dirtyWorldSections.poll()) != null) {
|
||||
for (PlacementRenderer renderer : this.renderers.values()) {
|
||||
renderer.markDirty(key);
|
||||
}
|
||||
}
|
||||
|
||||
Vec3 cam = mc.gameRenderer.getMainCamera().getPosition();
|
||||
int camX = SectionPos.posToSectionCoord(cam.x);
|
||||
int camY = SectionPos.posToSectionCoord(cam.y);
|
||||
int camZ = SectionPos.posToSectionCoord(cam.z);
|
||||
int distance = ModConfig.RENDER_DISTANCE.get();
|
||||
|
||||
List<RenderSection> candidates = new ArrayList<>();
|
||||
Map<RenderSection, PlacementRenderer> owners = new HashMap<>();
|
||||
for (PlacementRenderer renderer : this.renderers.values()) {
|
||||
int before = candidates.size();
|
||||
renderer.update(camX, camY, camZ, distance, candidates);
|
||||
for (int i = before; i < candidates.size(); i++) {
|
||||
owners.put(candidates.get(i), renderer);
|
||||
}
|
||||
renderer.updateStats(mode.comparesWorld());
|
||||
}
|
||||
this.schedule(candidates, owners, cam, level, mode);
|
||||
}
|
||||
|
||||
private void syncPlacements() {
|
||||
Set<UUID> wanted = new HashSet<>();
|
||||
for (SchematicPlacement placement : PlacementManager.get().inCurrentDimension()) {
|
||||
if (placement.enabled()) {
|
||||
wanted.add(placement.id());
|
||||
this.renderers.computeIfAbsent(placement.id(), id -> new PlacementRenderer(placement));
|
||||
}
|
||||
}
|
||||
this.renderers.entrySet().removeIf(entry -> {
|
||||
boolean remove = !wanted.contains(entry.getKey())
|
||||
|| !PlacementManager.get().all().contains(entry.getValue().placement());
|
||||
if (remove) {
|
||||
entry.getValue().closeAll();
|
||||
}
|
||||
return remove;
|
||||
});
|
||||
}
|
||||
|
||||
private void schedule(List<RenderSection> candidates, Map<RenderSection, PlacementRenderer> owners, Vec3 cam,
|
||||
ClientLevel level, RenderMode mode) {
|
||||
int free = this.threads() * 2 - this.inFlight.get();
|
||||
if (free <= 0 || candidates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
candidates.sort(Comparator.comparingDouble(s -> s.bounds().getCenter().distanceToSqr(cam)));
|
||||
ExecutorService pool = this.workers();
|
||||
for (int i = 0; i < candidates.size() && free > 0; i++, free--) {
|
||||
RenderSection section = candidates.get(i);
|
||||
PlacementRenderer owner = owners.get(section);
|
||||
PlacementView view = owner.view();
|
||||
if (view == null) {
|
||||
continue;
|
||||
}
|
||||
// The world copy is taken here on the main thread, so workers never read live chunk data.
|
||||
BlockState[] world = mode.comparesWorld() ? snapshotWorld(level, section.key()) : null;
|
||||
SectionMeshBuilder.Task task = new SectionMeshBuilder.Task(owner, section, view, owner.generation(),
|
||||
section.dirtyVersion(), world, mode, level);
|
||||
section.setBuilding(true);
|
||||
this.inFlight.incrementAndGet();
|
||||
pool.execute(() -> {
|
||||
try {
|
||||
this.results.add(SectionMeshBuilder.build(task));
|
||||
} catch (Throwable t) {
|
||||
LOGGER.error("Failed to build schematic section", t);
|
||||
this.results.add(new SectionMeshBuilder.Result(task, null, null, null, null, new long[0], new int[BlockStatus.COUNT]));
|
||||
} finally {
|
||||
this.inFlight.decrementAndGet();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the world's block states of one section, or {@code null} if the chunk is not loaded. */
|
||||
@Nullable
|
||||
private static BlockState[] snapshotWorld(ClientLevel level, long key) {
|
||||
int sx = SectionPos.x(key), sy = SectionPos.y(key), sz = SectionPos.z(key);
|
||||
if (!level.getChunkSource().hasChunk(sx, sz)) {
|
||||
return null;
|
||||
}
|
||||
BlockState[] states = new BlockState[4096];
|
||||
LevelChunk chunk = level.getChunk(sx, sz);
|
||||
int index = level.getSectionIndexFromSectionY(sy);
|
||||
if (index < 0 || index >= chunk.getSections().length || chunk.getSection(index).hasOnlyAir()) {
|
||||
Arrays.fill(states, AIR);
|
||||
return states;
|
||||
}
|
||||
LevelChunkSection section = chunk.getSection(index);
|
||||
for (int y = 0; y < 16; y++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
states[(y << 8) | (z << 4) | x] = section.getBlockState(x, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
private void uploadResults() {
|
||||
long deadline = System.nanoTime() + UPLOAD_BUDGET_NANOS;
|
||||
SectionMeshBuilder.Result result;
|
||||
while (System.nanoTime() < deadline && (result = this.results.poll()) != null) {
|
||||
SectionMeshBuilder.Task task = result.task();
|
||||
RenderSection section = task.section();
|
||||
section.setBuilding(false);
|
||||
PlacementRenderer owner = task.owner();
|
||||
boolean current = this.renderers.get(owner.placement().id()) == owner
|
||||
&& owner.generation() == task.generation()
|
||||
&& owner.section(section.key()) == section;
|
||||
if (current) {
|
||||
section.upload(result);
|
||||
} else {
|
||||
result.discard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void render(RenderLevelStageEvent event) {
|
||||
if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS) {
|
||||
return;
|
||||
}
|
||||
this.uploadResults();
|
||||
if (!ModConfig.RENDERING_ENABLED.get() || this.renderers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
ClientLevel level = mc.level;
|
||||
if (level == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Camera camera = event.getCamera();
|
||||
Vec3 cam = camera.getPosition();
|
||||
Frustum frustum = event.getFrustum();
|
||||
Matrix4f modelView = event.getModelViewMatrix();
|
||||
Matrix4f projection = event.getProjectionMatrix();
|
||||
int distance = ModConfig.RENDER_DISTANCE.get();
|
||||
int camX = SectionPos.posToSectionCoord(cam.x), camY = SectionPos.posToSectionCoord(cam.y), camZ = SectionPos.posToSectionCoord(cam.z);
|
||||
|
||||
List<RenderSection> visible = new ArrayList<>();
|
||||
List<PlacementRenderer> visibleOwners = new ArrayList<>();
|
||||
for (PlacementRenderer renderer : this.renderers.values()) {
|
||||
for (RenderSection section : renderer.sections()) {
|
||||
if (!section.hasData()) continue;
|
||||
int sx = SectionPos.x(section.key()), sy = SectionPos.y(section.key()), sz = SectionPos.z(section.key());
|
||||
if (Math.max(Math.abs(sx - camX), Math.max(Math.abs(sy - camY), Math.abs(sz - camZ))) > distance) continue;
|
||||
if (!frustum.isVisible(section.bounds())) continue;
|
||||
visible.add(section);
|
||||
visibleOwners.add(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
this.drawGhosts(visible, cam, modelView, projection);
|
||||
this.drawOverlays(visible, cam, modelView, projection);
|
||||
if (ModConfig.RENDER_BLOCK_ENTITIES.get()) {
|
||||
this.drawBlockEntities(visible, visibleOwners, cam, level, event.getPartialTick().getGameTimeDeltaPartialTick(false));
|
||||
}
|
||||
if (ModConfig.SHOW_BOUNDING_BOX.get()) {
|
||||
this.drawBoundingBoxes(cam);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawGhosts(List<RenderSection> sections, Vec3 cam, Matrix4f modelView, Matrix4f projection) {
|
||||
// Block atlas, lightmap and render state of the cutout layer, with blending on: the cutout shader discards
|
||||
// texels below 10% alpha after applying ColorModulator, so transparent texture parts stay invisible.
|
||||
RenderType.cutoutMipped().setupRenderState();
|
||||
RenderSystem.setShader(GameRenderer::getRendertypeCutoutShader);
|
||||
RenderSystem.enableBlend();
|
||||
RenderSystem.defaultBlendFunc();
|
||||
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, ModConfig.GHOST_ALPHA.get().floatValue());
|
||||
boolean overlapping = ModConfig.RENDER_MODE.get() == RenderMode.ALL;
|
||||
if (overlapping) {
|
||||
// In ALL mode ghosts coincide with real blocks; pull them forward to avoid z-fighting.
|
||||
RenderSystem.enablePolygonOffset();
|
||||
RenderSystem.polygonOffset(-1.0f, -10.0f);
|
||||
}
|
||||
ShaderInstance shader = RenderSystem.getShader();
|
||||
for (RenderSection section : sections) {
|
||||
VertexBuffer buffer = section.ghost();
|
||||
if (buffer != null && shader != null) {
|
||||
buffer.bind();
|
||||
buffer.drawWithShader(sectionMatrix(modelView, section, cam), projection, shader);
|
||||
}
|
||||
}
|
||||
VertexBuffer.unbind();
|
||||
if (overlapping) {
|
||||
RenderSystem.polygonOffset(0.0f, 0.0f);
|
||||
RenderSystem.disablePolygonOffset();
|
||||
}
|
||||
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f);
|
||||
RenderSystem.disableBlend();
|
||||
RenderType.cutoutMipped().clearRenderState();
|
||||
}
|
||||
|
||||
private void drawOverlays(List<RenderSection> sections, Vec3 cam, Matrix4f modelView, Matrix4f projection) {
|
||||
RenderSystem.setShader(GameRenderer::getPositionColorShader);
|
||||
ShaderInstance shader = RenderSystem.getShader();
|
||||
if (shader == null) {
|
||||
return;
|
||||
}
|
||||
RenderSystem.enableBlend();
|
||||
RenderSystem.defaultBlendFunc();
|
||||
RenderSystem.enableDepthTest();
|
||||
RenderSystem.depthMask(false);
|
||||
RenderSystem.disableCull();
|
||||
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, ModConfig.OVERLAY_ALPHA.get().floatValue());
|
||||
for (RenderSection section : sections) {
|
||||
VertexBuffer buffer = section.overlay();
|
||||
if (buffer != null) {
|
||||
buffer.bind();
|
||||
buffer.drawWithShader(sectionMatrix(modelView, section, cam), projection, shader);
|
||||
}
|
||||
}
|
||||
VertexBuffer.unbind();
|
||||
RenderSystem.setShaderColor(1.0f, 1.0f, 1.0f, 1.0f);
|
||||
RenderSystem.enableCull();
|
||||
RenderSystem.depthMask(true);
|
||||
RenderSystem.disableBlend();
|
||||
}
|
||||
|
||||
private void drawBlockEntities(List<RenderSection> sections, List<PlacementRenderer> owners, Vec3 cam, ClientLevel level, float partialTick) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
PoseStack pose = new PoseStack();
|
||||
boolean any = false;
|
||||
for (int i = 0; i < sections.size(); i++) {
|
||||
PlacementRenderer owner = owners.get(i);
|
||||
for (long pos : sections.get(i).blockEntities()) {
|
||||
BlockEntity be = owner.blockEntity(pos, level);
|
||||
if (be == null) continue;
|
||||
BlockEntityRenderer<BlockEntity> renderer = mc.getBlockEntityRenderDispatcher().getRenderer(be);
|
||||
if (renderer == null) continue;
|
||||
BlockPos p = be.getBlockPos();
|
||||
pose.pushPose();
|
||||
pose.translate(p.getX() - cam.x, p.getY() - cam.y, p.getZ() - cam.z);
|
||||
try {
|
||||
renderer.render(be, partialTick, pose, this.blockEntityBuffers, LightTexture.FULL_BRIGHT, OverlayTexture.NO_OVERLAY);
|
||||
any = true;
|
||||
} catch (Throwable t) {
|
||||
if (PlacementRenderer.FAILED_BLOCK_ENTITIES.add(be.getType())) {
|
||||
LOGGER.warn("Block entity renderer for {} failed in schematic and is disabled: {}", be.getType(), t.toString());
|
||||
}
|
||||
}
|
||||
pose.popPose();
|
||||
}
|
||||
}
|
||||
if (any) {
|
||||
this.blockEntityBuffers.flush(ModConfig.GHOST_ALPHA.get().floatValue());
|
||||
}
|
||||
}
|
||||
|
||||
private void drawBoundingBoxes(Vec3 cam) {
|
||||
MultiBufferSource.BufferSource buffers = this.lineBuffers();
|
||||
PoseStack pose = new PoseStack();
|
||||
pose.translate(-cam.x, -cam.y, -cam.z);
|
||||
SchematicPlacement selected = PlacementManager.get().selected();
|
||||
for (PlacementRenderer renderer : this.renderers.values()) {
|
||||
PlacementView view = renderer.view();
|
||||
if (view == null) continue;
|
||||
AABB box = AABB.of(view.box());
|
||||
if (renderer.placement() == selected) {
|
||||
LevelRenderer.renderLineBox(pose, buffers.getBuffer(RenderType.lines()), box, 1.0f, 1.0f, 1.0f, 1.0f);
|
||||
BlockPos o = view.origin();
|
||||
LevelRenderer.renderLineBox(pose, buffers.getBuffer(RenderType.lines()),
|
||||
new AABB(o).inflate(0.02), 0.2f, 0.9f, 1.0f, 1.0f);
|
||||
} else {
|
||||
LevelRenderer.renderLineBox(pose, buffers.getBuffer(RenderType.lines()), box, 0.6f, 0.6f, 0.6f, 0.6f);
|
||||
}
|
||||
}
|
||||
buffers.endBatch(RenderType.lines());
|
||||
}
|
||||
|
||||
private static Matrix4f sectionMatrix(Matrix4f modelView, RenderSection section, Vec3 cam) {
|
||||
return new Matrix4f(modelView).translate(
|
||||
(float) (section.originX() - cam.x), (float) (section.originY() - cam.y), (float) (section.originZ() - cam.z));
|
||||
}
|
||||
|
||||
private MultiBufferSource.BufferSource lineBuffers() {
|
||||
if (this.lineBuffers == null) {
|
||||
this.lineBuffers = MultiBufferSource.immediate(new ByteBufferBuilder(1 << 16));
|
||||
}
|
||||
return this.lineBuffers;
|
||||
}
|
||||
|
||||
private int threads() {
|
||||
if (this.threads == 0) {
|
||||
this.threads = ModConfig.BUILDER_THREADS.get();
|
||||
}
|
||||
return this.threads;
|
||||
}
|
||||
|
||||
private ExecutorService workers() {
|
||||
if (this.workers == null) {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
this.workers = Executors.newFixedThreadPool(this.threads(), r -> {
|
||||
Thread thread = new Thread(r, "NeoLitematica Mesh Builder " + counter.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.NORM_PRIORITY - 1);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
return this.workers;
|
||||
}
|
||||
|
||||
/** Frees all GPU buffers, e.g. on world change or logout. Render thread only. */
|
||||
public void clear() {
|
||||
this.renderers.values().forEach(PlacementRenderer::closeAll);
|
||||
this.renderers.clear();
|
||||
this.dirtyWorldSections.clear();
|
||||
SectionMeshBuilder.Result result;
|
||||
while ((result = this.results.poll()) != null) {
|
||||
result.task().section().setBuilding(false);
|
||||
result.discard();
|
||||
}
|
||||
this.blockEntityBuffers.close();
|
||||
}
|
||||
|
||||
/** Forces all sections to rebuild, e.g. after resource packs reload. */
|
||||
public void markAllDirty() {
|
||||
this.renderers.values().forEach(PlacementRenderer::markAllDirty);
|
||||
}
|
||||
|
||||
/** Number of sections still waiting for a build; used by the HUD and the smoke test. */
|
||||
public int pendingSections() {
|
||||
int pending = this.inFlight.get() + this.results.size();
|
||||
for (PlacementRenderer renderer : this.renderers.values()) {
|
||||
for (RenderSection section : renderer.sections()) {
|
||||
if (section.needsBuild()) pending++;
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package de.lucas.litematica.render;
|
||||
|
||||
import com.mojang.blaze3d.vertex.BufferBuilder;
|
||||
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
|
||||
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||
import com.mojang.blaze3d.vertex.MeshData;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexFormat;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import de.lucas.litematica.config.RenderMode;
|
||||
import de.lucas.litematica.placement.PlacementView;
|
||||
import it.unimi.dsi.fastutil.longs.LongArrayList;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.block.BlockRenderDispatcher;
|
||||
import net.minecraft.client.resources.model.BakedModel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import net.neoforged.neoforge.client.model.data.ModelData;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Builds the meshes of one 16x16x16 section of a placement. Runs on a worker thread and only touches immutable
|
||||
* data: the {@link PlacementView}, a copy of the world's block states taken on the main thread, and the
|
||||
* thread-safe parts of the client (block models, block colours, biome tint cache), like vanilla's section builder.
|
||||
*/
|
||||
final class SectionMeshBuilder {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final Set<Block> FAILED_BLOCKS = ConcurrentHashMap.newKeySet();
|
||||
/** Overlay boxes are grown slightly so they do not z-fight with the block they mark. */
|
||||
private static final float OVERLAY_GROW = 0.003f;
|
||||
|
||||
record Task(PlacementRenderer owner, RenderSection section, PlacementView view, int generation, int dirtyVersion,
|
||||
@Nullable BlockState[] world, RenderMode mode, ClientLevel level) {
|
||||
}
|
||||
|
||||
record Result(Task task, @Nullable MeshData ghost, @Nullable ByteBufferBuilder ghostMemory,
|
||||
@Nullable MeshData overlay, @Nullable ByteBufferBuilder overlayMemory,
|
||||
long[] blockEntities, int[] counts) {
|
||||
void discard() {
|
||||
if (this.ghost != null) this.ghost.close();
|
||||
if (this.ghostMemory != null) this.ghostMemory.close();
|
||||
if (this.overlay != null) this.overlay.close();
|
||||
if (this.overlayMemory != null) this.overlayMemory.close();
|
||||
}
|
||||
}
|
||||
|
||||
private SectionMeshBuilder() {
|
||||
}
|
||||
|
||||
static Result build(Task task) {
|
||||
RenderSection section = task.section();
|
||||
PlacementView view = task.view();
|
||||
BoundingBox box = view.box();
|
||||
int ox = section.originX(), oy = section.originY(), oz = section.originZ();
|
||||
int x0 = Math.max(ox, box.minX()), x1 = Math.min(ox + 15, box.maxX());
|
||||
int y0 = Math.max(oy, box.minY()), y1 = Math.min(oy + 15, box.maxY());
|
||||
int z0 = Math.max(oz, box.minZ()), z1 = Math.min(oz + 15, box.maxZ());
|
||||
|
||||
BlockPos.MutableBlockPos scratch = new BlockPos.MutableBlockPos();
|
||||
BlockState[] states = new BlockState[4096];
|
||||
byte[] status = new byte[4096];
|
||||
int[] counts = new int[BlockStatus.COUNT];
|
||||
int ghosts = 0;
|
||||
|
||||
for (int y = y0; y <= y1; y++) {
|
||||
for (int z = z0; z <= z1; z++) {
|
||||
for (int x = x0; x <= x1; x++) {
|
||||
int index = index(x, y, z);
|
||||
BlockState state = view.getState(x, y, z, scratch);
|
||||
boolean unknown = state.isAir() && view.isUnknown(x, y, z, scratch);
|
||||
BlockState world = task.world() == null ? null : task.world()[index];
|
||||
byte s = BlockStatus.classify(state, unknown, world, task.mode());
|
||||
states[index] = state;
|
||||
status[index] = s;
|
||||
counts[s]++;
|
||||
if (BlockStatus.showsGhost(s)) {
|
||||
ghosts++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MeshData ghostMesh = null;
|
||||
ByteBufferBuilder ghostMemory = null;
|
||||
LongArrayList blockEntities = new LongArrayList();
|
||||
if (ghosts > 0) {
|
||||
ghostMemory = new ByteBufferBuilder(Math.min(4 << 20, 4096 + ghosts * 1024));
|
||||
BufferBuilder builder = new BufferBuilder(ghostMemory, VertexFormat.Mode.QUADS, DefaultVertexFormat.BLOCK);
|
||||
SchematicBlockGetter getter = new SchematicBlockGetter(view, task.level());
|
||||
BlockRenderDispatcher dispatcher = Minecraft.getInstance().getBlockRenderer();
|
||||
RandomSource random = RandomSource.create();
|
||||
PoseStack pose = new PoseStack();
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
|
||||
for (int y = y0; y <= y1; y++) {
|
||||
for (int z = z0; z <= z1; z++) {
|
||||
for (int x = x0; x <= x1; x++) {
|
||||
int index = index(x, y, z);
|
||||
if (!BlockStatus.showsGhost(status[index])) {
|
||||
continue;
|
||||
}
|
||||
BlockState state = states[index];
|
||||
pos.set(x, y, z);
|
||||
renderBlock(state, pos, getter, dispatcher, builder, pose, random);
|
||||
if (state.hasBlockEntity()) {
|
||||
blockEntities.add(pos.asLong());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ghostMesh = builder.build();
|
||||
if (ghostMesh == null) {
|
||||
ghostMemory.close();
|
||||
ghostMemory = null;
|
||||
}
|
||||
}
|
||||
|
||||
MeshData overlayMesh = null;
|
||||
ByteBufferBuilder overlayMemory = null;
|
||||
int overlays = 0;
|
||||
for (int i = 0; i < 4096; i++) {
|
||||
if (status[i] != BlockStatus.NONE && BlockStatus.overlayColor(status[i], task.mode()) != 0) {
|
||||
overlays++;
|
||||
}
|
||||
}
|
||||
if (overlays > 0) {
|
||||
overlayMemory = new ByteBufferBuilder(Math.min(4 << 20, overlays * 24 * 16));
|
||||
BufferBuilder builder = new BufferBuilder(overlayMemory, VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
|
||||
for (int y = y0; y <= y1; y++) {
|
||||
for (int z = z0; z <= z1; z++) {
|
||||
for (int x = x0; x <= x1; x++) {
|
||||
int index = index(x, y, z);
|
||||
int color = BlockStatus.overlayColor(status[index], task.mode());
|
||||
if (color != 0) {
|
||||
addOverlayBox(builder, status, x & 15, y & 15, z & 15, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
overlayMesh = builder.build();
|
||||
if (overlayMesh == null) {
|
||||
overlayMemory.close();
|
||||
overlayMemory = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new Result(task, ghostMesh, ghostMemory, overlayMesh, overlayMemory, blockEntities.toLongArray(), counts);
|
||||
}
|
||||
|
||||
private static void renderBlock(BlockState state, BlockPos pos, SchematicBlockGetter getter, BlockRenderDispatcher dispatcher,
|
||||
BufferBuilder builder, PoseStack pose, RandomSource random) {
|
||||
Block block = state.getBlock();
|
||||
if (FAILED_BLOCKS.contains(block)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (state.getRenderShape() == RenderShape.MODEL) {
|
||||
BakedModel model = dispatcher.getBlockModel(state);
|
||||
ModelData modelData = model.getModelData(getter, pos, state, ModelData.EMPTY);
|
||||
random.setSeed(state.getSeed(pos));
|
||||
pose.pushPose();
|
||||
pose.translate(pos.getX() & 15, pos.getY() & 15, pos.getZ() & 15);
|
||||
for (RenderType renderType : model.getRenderTypes(state, random, modelData)) {
|
||||
dispatcher.renderBatched(state, pos, getter, pose, builder, true, random, modelData, renderType);
|
||||
}
|
||||
pose.popPose();
|
||||
}
|
||||
FluidState fluid = state.getFluidState();
|
||||
if (!fluid.isEmpty()) {
|
||||
// The liquid renderer positions vertices relative to the section itself.
|
||||
dispatcher.renderLiquid(pos, getter, builder, state, fluid);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
// Third-party models occasionally assume a real Level; skip that block type instead of failing the section.
|
||||
if (FAILED_BLOCKS.add(block)) {
|
||||
LOGGER.warn("Cannot render {} in schematic, it will be skipped: {}", state, t.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the faces of a highlight box, skipping faces shared with a same-status neighbour in this section. */
|
||||
private static void addOverlayBox(BufferBuilder b, byte[] status, int x, int y, int z, int argb) {
|
||||
byte s = status[(y << 8) | (z << 4) | x];
|
||||
int a = argb >>> 24, r = (argb >> 16) & 0xFF, g = (argb >> 8) & 0xFF, bl = argb & 0xFF;
|
||||
float minX = x - OVERLAY_GROW, minY = y - OVERLAY_GROW, minZ = z - OVERLAY_GROW;
|
||||
float maxX = x + 1 + OVERLAY_GROW, maxY = y + 1 + OVERLAY_GROW, maxZ = z + 1 + OVERLAY_GROW;
|
||||
|
||||
if (!same(status, s, x, y - 1, z)) {
|
||||
quad(b, r, g, bl, a, minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ, minX, minY, maxZ);
|
||||
}
|
||||
if (!same(status, s, x, y + 1, z)) {
|
||||
quad(b, r, g, bl, a, minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ);
|
||||
}
|
||||
if (!same(status, s, x, y, z - 1)) {
|
||||
quad(b, r, g, bl, a, minX, minY, minZ, minX, maxY, minZ, maxX, maxY, minZ, maxX, minY, minZ);
|
||||
}
|
||||
if (!same(status, s, x, y, z + 1)) {
|
||||
quad(b, r, g, bl, a, minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ, minX, maxY, maxZ);
|
||||
}
|
||||
if (!same(status, s, x - 1, y, z)) {
|
||||
quad(b, r, g, bl, a, minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ, minX, maxY, minZ);
|
||||
}
|
||||
if (!same(status, s, x + 1, y, z)) {
|
||||
quad(b, r, g, bl, a, maxX, minY, minZ, maxX, maxY, minZ, maxX, maxY, maxZ, maxX, minY, maxZ);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean same(byte[] status, byte s, int x, int y, int z) {
|
||||
if (x < 0 || y < 0 || z < 0 || x > 15 || y > 15 || z > 15) {
|
||||
return false;
|
||||
}
|
||||
return status[(y << 8) | (z << 4) | x] == s;
|
||||
}
|
||||
|
||||
private static void quad(BufferBuilder b, int r, int g, int bl, int a,
|
||||
float x1, float y1, float z1, float x2, float y2, float z2,
|
||||
float x3, float y3, float z3, float x4, float y4, float z4) {
|
||||
b.addVertex(x1, y1, z1).setColor(r, g, bl, a);
|
||||
b.addVertex(x2, y2, z2).setColor(r, g, bl, a);
|
||||
b.addVertex(x3, y3, z3).setColor(r, g, bl, a);
|
||||
b.addVertex(x4, y4, z4).setColor(r, g, bl, a);
|
||||
}
|
||||
|
||||
static int index(int x, int y, int z) {
|
||||
return ((y & 15) << 8) | ((z & 15) << 4) | (x & 15);
|
||||
}
|
||||
|
||||
static long sectionKey(int sx, int sy, int sz) {
|
||||
return SectionPos.asLong(sx, sy, sz);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
/**
|
||||
* Read-only view of Litematica's packed block state index array.
|
||||
* <p>
|
||||
* Unlike vanilla's {@code SimpleBitStorage}, Litematica packs entries tightly: an entry may span
|
||||
* two neighbouring longs. Entry bit width is {@code max(2, ceil(log2(paletteSize)))}.
|
||||
*/
|
||||
public final class LitematicBitArray {
|
||||
private final long[] data;
|
||||
private final int bitsPerEntry;
|
||||
private final long mask;
|
||||
private final long size;
|
||||
|
||||
public LitematicBitArray(int bitsPerEntry, long size, long[] data) {
|
||||
long required = (size * bitsPerEntry + 63L) / 64L;
|
||||
if (data.length < required) {
|
||||
throw new IllegalArgumentException("Block state array too short: expected " + required + " longs, got " + data.length);
|
||||
}
|
||||
this.data = data;
|
||||
this.bitsPerEntry = bitsPerEntry;
|
||||
this.mask = (1L << bitsPerEntry) - 1L;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public int get(long index) {
|
||||
long startBit = index * this.bitsPerEntry;
|
||||
int startLong = (int) (startBit >> 6);
|
||||
int endLong = (int) (((index + 1L) * this.bitsPerEntry - 1L) >> 6);
|
||||
int offset = (int) (startBit & 63L);
|
||||
|
||||
if (startLong == endLong) {
|
||||
return (int) (this.data[startLong] >>> offset & this.mask);
|
||||
}
|
||||
return (int) ((this.data[startLong] >>> offset | this.data[endLong] << (64 - offset)) & this.mask);
|
||||
}
|
||||
|
||||
public long size() {
|
||||
return this.size;
|
||||
}
|
||||
|
||||
public static int bitsFor(int paletteSize) {
|
||||
return Math.max(2, 32 - Integer.numberOfLeadingZeros(Math.max(1, paletteSize - 1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
import com.mojang.datafixers.DataFixer;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.mojang.serialization.Dynamic;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.NbtAccounter;
|
||||
import net.minecraft.nbt.NbtIo;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.nbt.NbtUtils;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.util.datafix.DataFixers;
|
||||
import net.minecraft.util.datafix.fixes.References;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Reads Litematica's {@code .litematic} format (gzip-compressed NBT, format versions 1 to 7).
|
||||
* <p>
|
||||
* Layout: {@code Version}, {@code MinecraftDataVersion}, {@code Metadata{...}} and {@code Regions{name: {Position,
|
||||
* Size, BlockStatePalette, BlockStates, TileEntities, ...}}}. A region's {@code Size} may be negative on any axis,
|
||||
* meaning the region extends from {@code Position} in the negative direction. Blocks are indexed as
|
||||
* {@code (y * sizeZ + z) * sizeX + x} relative to the region's minimum corner.
|
||||
* <p>
|
||||
* Schematics saved by older Minecraft versions are upgraded through Minecraft's own DataFixer, the same way the
|
||||
* game upgrades old worlds.
|
||||
*/
|
||||
public final class LitematicReader {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
/** Litematica files without {@code MinecraftDataVersion} predate it; 1139 is the first 1.13 data version. */
|
||||
private static final int FALLBACK_DATA_VERSION = 1139;
|
||||
private static final int NEWEST_KNOWN_FORMAT = 7;
|
||||
|
||||
private LitematicReader() {
|
||||
}
|
||||
|
||||
public static LitematicSchematic read(Path file) throws SchematicLoadException {
|
||||
CompoundTag root = readRoot(file);
|
||||
SchematicMetadata metadata = readMetadata(root);
|
||||
|
||||
if (!root.contains("Regions", Tag.TAG_COMPOUND)) {
|
||||
throw new SchematicLoadException("File contains no 'Regions' tag - not a Litematica schematic");
|
||||
}
|
||||
if (metadata.formatVersion() > NEWEST_KNOWN_FORMAT) {
|
||||
LOGGER.warn("{} uses schematic format version {}, newest known is {}; trying anyway",
|
||||
file.getFileName(), metadata.formatVersion(), NEWEST_KNOWN_FORMAT);
|
||||
}
|
||||
|
||||
int currentDataVersion = SharedConstants.getCurrentVersion().getDataVersion().getVersion();
|
||||
int fileDataVersion = metadata.dataVersion();
|
||||
DataFixer fixer = fileDataVersion < currentDataVersion ? DataFixers.getDataFixer() : null;
|
||||
|
||||
CompoundTag regionsTag = root.getCompound("Regions");
|
||||
List<RawRegion> raws = new ArrayList<>();
|
||||
Set<String> unknownBlocks = new LinkedHashSet<>();
|
||||
for (String name : regionsTag.getAllKeys()) {
|
||||
RawRegion raw = readRegion(name, regionsTag.getCompound(name), fixer, fileDataVersion, currentDataVersion, unknownBlocks);
|
||||
if (raw != null) {
|
||||
raws.add(raw);
|
||||
}
|
||||
}
|
||||
if (raws.isEmpty()) {
|
||||
throw new SchematicLoadException("Schematic contains no non-empty regions");
|
||||
}
|
||||
|
||||
int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE;
|
||||
int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
|
||||
for (RawRegion raw : raws) {
|
||||
minX = Math.min(minX, raw.min.getX());
|
||||
minY = Math.min(minY, raw.min.getY());
|
||||
minZ = Math.min(minZ, raw.min.getZ());
|
||||
maxX = Math.max(maxX, raw.min.getX() + raw.sizeX);
|
||||
maxY = Math.max(maxY, raw.min.getY() + raw.sizeY);
|
||||
maxZ = Math.max(maxZ, raw.min.getZ() + raw.sizeZ);
|
||||
}
|
||||
|
||||
List<SchematicRegion> regions = new ArrayList<>(raws.size());
|
||||
long nonAir = 0;
|
||||
for (RawRegion raw : raws) {
|
||||
BlockPos normalizedMin = raw.min.offset(-minX, -minY, -minZ);
|
||||
SchematicRegion region = new SchematicRegion(raw.name, normalizedMin, raw.sizeX, raw.sizeY, raw.sizeZ,
|
||||
raw.palette, raw.unknown, raw.blocks, raw.blockEntities);
|
||||
nonAir += validateAndCount(region);
|
||||
regions.add(region);
|
||||
}
|
||||
|
||||
Vec3i size = new Vec3i(maxX - minX, maxY - minY, maxZ - minZ);
|
||||
if (!unknownBlocks.isEmpty()) {
|
||||
LOGGER.warn("{} references {} block(s) that are not installed: {}", file.getFileName(), unknownBlocks.size(), unknownBlocks);
|
||||
}
|
||||
return new LitematicSchematic(file, metadata, regions, size, nonAir, unknownBlocks);
|
||||
}
|
||||
|
||||
/** Reads only the header. Still decompresses the whole file, but skips block decoding. */
|
||||
public static SchematicMetadata readMetadata(Path file) throws SchematicLoadException {
|
||||
return readMetadata(readRoot(file));
|
||||
}
|
||||
|
||||
private static CompoundTag readRoot(Path file) throws SchematicLoadException {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
throw new SchematicLoadException("File not found: " + file);
|
||||
}
|
||||
try {
|
||||
return NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new SchematicLoadException("Not a valid compressed NBT file: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static SchematicMetadata readMetadata(CompoundTag root) throws SchematicLoadException {
|
||||
if (!root.contains("Version", Tag.TAG_ANY_NUMERIC)) {
|
||||
throw new SchematicLoadException("Missing 'Version' tag - not a Litematica schematic");
|
||||
}
|
||||
int version = root.getInt("Version");
|
||||
int dataVersion = root.contains("MinecraftDataVersion", Tag.TAG_ANY_NUMERIC) ? root.getInt("MinecraftDataVersion") : FALLBACK_DATA_VERSION;
|
||||
CompoundTag meta = root.getCompound("Metadata");
|
||||
CompoundTag enclosing = meta.getCompound("EnclosingSize");
|
||||
return new SchematicMetadata(
|
||||
meta.getString("Name"),
|
||||
meta.getString("Author"),
|
||||
meta.getString("Description"),
|
||||
version,
|
||||
dataVersion,
|
||||
meta.getInt("RegionCount"),
|
||||
meta.getLong("TotalBlocks"),
|
||||
meta.getLong("TotalVolume"),
|
||||
new Vec3i(enclosing.getInt("x"), enclosing.getInt("y"), enclosing.getInt("z")),
|
||||
meta.getLong("TimeCreated"),
|
||||
meta.getLong("TimeModified"));
|
||||
}
|
||||
|
||||
private static RawRegion readRegion(String name, CompoundTag tag, DataFixer fixer, int fromVersion, int toVersion,
|
||||
Set<String> unknownBlocks) throws SchematicLoadException {
|
||||
BlockPos pos = readVec(tag.getCompound("Position"));
|
||||
BlockPos size = readVec(tag.getCompound("Size"));
|
||||
if (size.getX() == 0 || size.getY() == 0 || size.getZ() == 0) {
|
||||
return null;
|
||||
}
|
||||
// Negative sizes extend from Position towards negative coordinates (Position is then the max corner).
|
||||
BlockPos min = new BlockPos(
|
||||
size.getX() < 0 ? pos.getX() + size.getX() + 1 : pos.getX(),
|
||||
size.getY() < 0 ? pos.getY() + size.getY() + 1 : pos.getY(),
|
||||
size.getZ() < 0 ? pos.getZ() + size.getZ() + 1 : pos.getZ());
|
||||
int sx = Math.abs(size.getX());
|
||||
int sy = Math.abs(size.getY());
|
||||
int sz = Math.abs(size.getZ());
|
||||
|
||||
ListTag paletteTag = tag.getList("BlockStatePalette", Tag.TAG_COMPOUND);
|
||||
if (paletteTag.isEmpty()) {
|
||||
throw new SchematicLoadException("Region '" + name + "' has an empty block state palette");
|
||||
}
|
||||
BlockState[] palette = new BlockState[paletteTag.size()];
|
||||
boolean[] unknown = new boolean[paletteTag.size()];
|
||||
for (int i = 0; i < palette.length; i++) {
|
||||
CompoundTag entry = paletteTag.getCompound(i);
|
||||
if (fixer != null) {
|
||||
entry = (CompoundTag) fixer.update(References.BLOCK_STATE, new Dynamic<>(NbtOps.INSTANCE, entry), fromVersion, toVersion).getValue();
|
||||
}
|
||||
String blockName = entry.getString("Name");
|
||||
ResourceLocation id = ResourceLocation.tryParse(blockName);
|
||||
if (id == null || !BuiltInRegistries.BLOCK.containsKey(id)) {
|
||||
unknown[i] = true;
|
||||
unknownBlocks.add(blockName);
|
||||
}
|
||||
// Unknown blocks and invalid property values fall back to air / default values.
|
||||
palette[i] = NbtUtils.readBlockState(BuiltInRegistries.BLOCK.asLookup(), entry);
|
||||
}
|
||||
|
||||
long volume = (long) sx * sy * sz;
|
||||
long[] data = tag.getLongArray("BlockStates");
|
||||
LitematicBitArray blocks;
|
||||
try {
|
||||
blocks = new LitematicBitArray(LitematicBitArray.bitsFor(palette.length), volume, data);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new SchematicLoadException("Region '" + name + "': " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
Long2ObjectMap<CompoundTag> blockEntities = new Long2ObjectOpenHashMap<>();
|
||||
ListTag beList = tag.getList("TileEntities", Tag.TAG_COMPOUND);
|
||||
for (int i = 0; i < beList.size(); i++) {
|
||||
CompoundTag be = beList.getCompound(i);
|
||||
if (fixer != null && be.contains("id", Tag.TAG_STRING)) {
|
||||
be = (CompoundTag) fixer.update(References.BLOCK_ENTITY, new Dynamic<>(NbtOps.INSTANCE, be), fromVersion, toVersion).getValue();
|
||||
}
|
||||
blockEntities.put(BlockPos.asLong(be.getInt("x"), be.getInt("y"), be.getInt("z")), be);
|
||||
}
|
||||
|
||||
return new RawRegion(name, min, sx, sy, sz, palette, unknown, blocks, blockEntities);
|
||||
}
|
||||
|
||||
/** Ensures every stored index is inside the palette and counts non-air blocks. */
|
||||
private static long validateAndCount(SchematicRegion region) throws SchematicLoadException {
|
||||
BlockState[] palette = region.palette();
|
||||
boolean[] counts = new boolean[palette.length];
|
||||
for (int i = 0; i < palette.length; i++) {
|
||||
counts[i] = !palette[i].isAir() || region.unknown()[i];
|
||||
}
|
||||
long volume = region.volume();
|
||||
long nonAir = 0;
|
||||
LitematicBitArray blocks = region.blocks();
|
||||
for (long i = 0; i < volume; i++) {
|
||||
int index = blocks.get(i);
|
||||
if (index >= palette.length) {
|
||||
throw new SchematicLoadException("Region '" + region.name() + "' is corrupt: palette index " + index
|
||||
+ " out of range (palette size " + palette.length + ")");
|
||||
}
|
||||
if (counts[index]) {
|
||||
nonAir++;
|
||||
}
|
||||
}
|
||||
return nonAir;
|
||||
}
|
||||
|
||||
private static BlockPos readVec(CompoundTag tag) {
|
||||
return new BlockPos(tag.getInt("x"), tag.getInt("y"), tag.getInt("z"));
|
||||
}
|
||||
|
||||
private record RawRegion(String name, BlockPos min, int sizeX, int sizeY, int sizeZ, BlockState[] palette,
|
||||
boolean[] unknown, LitematicBitArray blocks, Long2ObjectMap<CompoundTag> blockEntities) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* An immutable, fully loaded schematic. Safe to read from any thread, so render workers query it directly.
|
||||
* Coordinates are relative to the minimum corner of the enclosing box of all regions.
|
||||
*/
|
||||
public final class LitematicSchematic {
|
||||
private final Path file;
|
||||
private final SchematicMetadata metadata;
|
||||
private final List<SchematicRegion> regions;
|
||||
private final Vec3i size;
|
||||
private final long nonAirBlocks;
|
||||
private final Set<String> unknownBlocks;
|
||||
|
||||
LitematicSchematic(Path file, SchematicMetadata metadata, List<SchematicRegion> regions, Vec3i size, long nonAirBlocks, Set<String> unknownBlocks) {
|
||||
this.file = file;
|
||||
this.metadata = metadata;
|
||||
this.regions = List.copyOf(regions);
|
||||
this.size = size;
|
||||
this.nonAirBlocks = nonAirBlocks;
|
||||
this.unknownBlocks = Set.copyOf(unknownBlocks);
|
||||
}
|
||||
|
||||
public Path file() {
|
||||
return this.file;
|
||||
}
|
||||
|
||||
public SchematicMetadata metadata() {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
public List<SchematicRegion> regions() {
|
||||
return this.regions;
|
||||
}
|
||||
|
||||
/** Size of the enclosing box of all regions. */
|
||||
public Vec3i size() {
|
||||
return this.size;
|
||||
}
|
||||
|
||||
/** Number of non-air blocks actually contained in the block arrays. */
|
||||
public long nonAirBlocks() {
|
||||
return this.nonAirBlocks;
|
||||
}
|
||||
|
||||
/** Registry names of blocks that are not available in the running game. */
|
||||
public Set<String> unknownBlocks() {
|
||||
return this.unknownBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the region defining the block at a schematic-local position, or {@code null} if no region covers it.
|
||||
* Overlapping regions are resolved in file order; the first non-air entry wins.
|
||||
*/
|
||||
@Nullable
|
||||
public SchematicRegion regionAt(int x, int y, int z) {
|
||||
SchematicRegion fallback = null;
|
||||
for (SchematicRegion region : this.regions) {
|
||||
int rx = x - region.min().getX();
|
||||
int ry = y - region.min().getY();
|
||||
int rz = z - region.min().getZ();
|
||||
if (region.contains(rx, ry, rz)) {
|
||||
int index = region.paletteIndex(rx, ry, rz);
|
||||
if (!region.palette()[index].isAir() || region.unknown()[index]) {
|
||||
return region;
|
||||
}
|
||||
fallback = region;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public BlockState getBlockState(int x, int y, int z) {
|
||||
SchematicRegion region = this.regionAt(x, y, z);
|
||||
if (region == null) {
|
||||
return Blocks.AIR.defaultBlockState();
|
||||
}
|
||||
return region.getState(x - region.min().getX(), y - region.min().getY(), z - region.min().getZ());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public CompoundTag getBlockEntity(int x, int y, int z) {
|
||||
SchematicRegion region = this.regionAt(x, y, z);
|
||||
if (region == null) {
|
||||
return null;
|
||||
}
|
||||
return region.getBlockEntity(x - region.min().getX(), y - region.min().getY(), z - region.min().getZ());
|
||||
}
|
||||
|
||||
public String displayName() {
|
||||
String name = this.metadata.name();
|
||||
if (name == null || name.isBlank()) {
|
||||
return SchematicFiles.baseName(this.file);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
import net.neoforged.fml.loading.FMLPaths;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* File operations on the schematics directory. The directory is {@code .minecraft/schematics}, the same folder
|
||||
* Litematica uses, so existing schematic collections work without copying.
|
||||
*/
|
||||
public final class SchematicFiles {
|
||||
public static final String EXTENSION = ".litematic";
|
||||
|
||||
private SchematicFiles() {
|
||||
}
|
||||
|
||||
public static Path directory() {
|
||||
Path dir = FMLPaths.GAMEDIR.get().resolve("schematics");
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
} catch (IOException ignored) {
|
||||
// Reported by the operations that actually need the directory.
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** All .litematic files below the schematics directory, sorted by relative path. */
|
||||
public static List<Path> list() throws IOException {
|
||||
Path dir = directory();
|
||||
try (Stream<Path> stream = Files.walk(dir, 8)) {
|
||||
return stream.filter(p -> Files.isRegularFile(p) && isSchematic(p))
|
||||
.sorted(Comparator.comparing(p -> dir.relativize(p).toString().toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isSchematic(Path file) {
|
||||
return file.getFileName().toString().toLowerCase().endsWith(EXTENSION);
|
||||
}
|
||||
|
||||
public static String baseName(Path file) {
|
||||
String name = file.getFileName().toString();
|
||||
return name.toLowerCase().endsWith(EXTENSION) ? name.substring(0, name.length() - EXTENSION.length()) : name;
|
||||
}
|
||||
|
||||
/** Path relative to the schematics directory, with forward slashes; used to reference files in saved placements. */
|
||||
public static String relativeName(Path file) {
|
||||
Path dir = directory().toAbsolutePath().normalize();
|
||||
Path abs = file.toAbsolutePath().normalize();
|
||||
return abs.startsWith(dir) ? dir.relativize(abs).toString().replace('\\', '/') : abs.toString();
|
||||
}
|
||||
|
||||
public static Path resolve(String relativeName) {
|
||||
Path path = Path.of(relativeName);
|
||||
return path.isAbsolute() ? path : directory().resolve(relativeName).normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an external file into the schematics directory after validating it. Never overwrites: a numeric suffix
|
||||
* is added when the name is taken.
|
||||
*/
|
||||
public static Path importFile(Path source) throws SchematicLoadException, IOException {
|
||||
if (!isSchematic(source)) {
|
||||
throw new SchematicLoadException("Only " + EXTENSION + " files are supported: " + source.getFileName());
|
||||
}
|
||||
LitematicReader.readMetadata(source);
|
||||
Path dir = directory();
|
||||
if (source.toAbsolutePath().normalize().startsWith(dir.toAbsolutePath().normalize())) {
|
||||
return source;
|
||||
}
|
||||
Path target = freeName(dir, baseName(source));
|
||||
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
return target;
|
||||
}
|
||||
|
||||
public static Path rename(Path file, String newBaseName) throws IOException {
|
||||
String clean = sanitize(newBaseName);
|
||||
if (clean.isEmpty()) {
|
||||
throw new IOException("Invalid name");
|
||||
}
|
||||
Path target = file.resolveSibling(clean + EXTENSION);
|
||||
if (Files.exists(target)) {
|
||||
throw new IOException("A schematic named '" + clean + "' already exists");
|
||||
}
|
||||
return Files.move(file, target);
|
||||
}
|
||||
|
||||
public static void delete(Path file) throws IOException {
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
|
||||
private static Path freeName(Path dir, String base) {
|
||||
String clean = sanitize(base);
|
||||
Path target = dir.resolve(clean + EXTENSION);
|
||||
for (int i = 2; Files.exists(target); i++) {
|
||||
target = dir.resolve(clean + "_" + i + EXTENSION);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static String sanitize(String name) {
|
||||
return name.trim().replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
/** Thrown when a file cannot be read as a .litematic schematic. The message is shown to the user. */
|
||||
public class SchematicLoadException extends Exception {
|
||||
public SchematicLoadException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public SchematicLoadException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Loads schematics off the main thread and caches them, so several placements of the same file share one copy.
|
||||
* The cache is keyed by absolute path and invalidated when the file's modification time changes.
|
||||
*/
|
||||
public final class SchematicManager {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final SchematicManager INSTANCE = new SchematicManager();
|
||||
|
||||
private final ExecutorService loader = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread thread = new Thread(r, "NeoLitematica Schematic Loader");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private final Map<Path, Cached> cache = new ConcurrentHashMap<>();
|
||||
private final Map<Path, CompletableFuture<LitematicSchematic>> pending = new ConcurrentHashMap<>();
|
||||
|
||||
public static SchematicManager get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/** Loads (or returns the cached) schematic. The future completes on the loader thread. */
|
||||
public CompletableFuture<LitematicSchematic> load(Path file) {
|
||||
Path key = file.toAbsolutePath().normalize();
|
||||
Cached cached = this.cache.get(key);
|
||||
if (cached != null && cached.modified.equals(modifiedTime(key))) {
|
||||
return CompletableFuture.completedFuture(cached.schematic);
|
||||
}
|
||||
CompletableFuture<LitematicSchematic> future = this.pending.computeIfAbsent(key, k -> CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
long start = System.nanoTime();
|
||||
LitematicSchematic schematic = LitematicReader.read(k);
|
||||
this.cache.put(k, new Cached(schematic, modifiedTime(k)));
|
||||
LOGGER.info("Loaded schematic {} ({}x{}x{}, {} blocks, {} regions) in {} ms", k.getFileName(),
|
||||
schematic.size().getX(), schematic.size().getY(), schematic.size().getZ(),
|
||||
schematic.nonAirBlocks(), schematic.regions().size(), (System.nanoTime() - start) / 1_000_000);
|
||||
return schematic;
|
||||
} catch (SchematicLoadException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, this.loader));
|
||||
future.whenComplete((schematic, error) -> this.pending.remove(key, future));
|
||||
return future;
|
||||
}
|
||||
|
||||
/** Reads only the header of a file, for the schematic browser. */
|
||||
public CompletableFuture<SchematicMetadata> metadata(Path file) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return LitematicReader.readMetadata(file);
|
||||
} catch (SchematicLoadException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, this.loader);
|
||||
}
|
||||
|
||||
/** Unwraps the user-facing message of a failed {@link #load}. */
|
||||
public static String errorMessage(Throwable error) {
|
||||
Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
|
||||
return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
public void invalidate(Path file) {
|
||||
this.cache.remove(file.toAbsolutePath().normalize());
|
||||
}
|
||||
|
||||
private static FileTime modifiedTime(Path file) {
|
||||
try {
|
||||
return Files.getLastModifiedTime(file);
|
||||
} catch (IOException e) {
|
||||
return FileTime.fromMillis(0);
|
||||
}
|
||||
}
|
||||
|
||||
private record Cached(LitematicSchematic schematic, FileTime modified) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
import net.minecraft.core.Vec3i;
|
||||
|
||||
/** Header information of a .litematic file. */
|
||||
public record SchematicMetadata(
|
||||
String name,
|
||||
String author,
|
||||
String description,
|
||||
int formatVersion,
|
||||
int dataVersion,
|
||||
int regionCount,
|
||||
long totalBlocks,
|
||||
long totalVolume,
|
||||
Vec3i enclosingSize,
|
||||
long timeCreated,
|
||||
long timeModified
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* One sub-region of a schematic. All block coordinates are relative to the region's minimum corner.
|
||||
*
|
||||
* @param name region name from the file
|
||||
* @param min minimum corner inside the schematic's enclosing box (never negative)
|
||||
* @param palette resolved block states (unknown blocks resolve to air)
|
||||
* @param unknown {@code true} for palette entries whose block is not registered in this game
|
||||
* @param blockEntities block entity NBT keyed by {@link BlockPos#asLong} of the region-local position
|
||||
*/
|
||||
public record SchematicRegion(
|
||||
String name,
|
||||
BlockPos min,
|
||||
int sizeX,
|
||||
int sizeY,
|
||||
int sizeZ,
|
||||
BlockState[] palette,
|
||||
boolean[] unknown,
|
||||
LitematicBitArray blocks,
|
||||
Long2ObjectMap<CompoundTag> blockEntities
|
||||
) {
|
||||
public boolean contains(int x, int y, int z) {
|
||||
return x >= 0 && y >= 0 && z >= 0 && x < this.sizeX && y < this.sizeY && z < this.sizeZ;
|
||||
}
|
||||
|
||||
/** Palette index at a region-local position; the caller must ensure {@link #contains}. */
|
||||
public int paletteIndex(int x, int y, int z) {
|
||||
long index = ((long) y * this.sizeZ + z) * this.sizeX + x;
|
||||
return this.blocks.get(index);
|
||||
}
|
||||
|
||||
public BlockState getState(int x, int y, int z) {
|
||||
return this.palette[this.paletteIndex(x, y, z)];
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public CompoundTag getBlockEntity(int x, int y, int z) {
|
||||
return this.blockEntities.get(BlockPos.asLong(x, y, z));
|
||||
}
|
||||
|
||||
public long volume() {
|
||||
return (long) this.sizeX * this.sizeY * this.sizeZ;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"key.categories.neolitematica": "NeoLitematica",
|
||||
"key.neolitematica.open_menu": "Hauptmenü öffnen",
|
||||
"key.neolitematica.open_browser": "Schematic-Übersicht öffnen",
|
||||
"key.neolitematica.edit_placement": "Ausgewähltes Placement bearbeiten",
|
||||
"key.neolitematica.toggle_rendering": "Alle Schematics ein-/ausblenden",
|
||||
"key.neolitematica.cycle_render_mode": "Rendering-Modus wechseln",
|
||||
"key.neolitematica.next_placement": "Nächstes Placement auswählen",
|
||||
"key.neolitematica.toggle_placement": "Ausgewähltes Placement ein-/ausblenden",
|
||||
"key.neolitematica.move_to_player": "Placement zum Spieler verschieben",
|
||||
"key.neolitematica.reset_position": "Placement-Position zurücksetzen",
|
||||
"key.neolitematica.move_forward": "Placement nach vorne",
|
||||
"key.neolitematica.move_back": "Placement nach hinten",
|
||||
"key.neolitematica.move_left": "Placement nach links",
|
||||
"key.neolitematica.move_right": "Placement nach rechts",
|
||||
"key.neolitematica.move_up": "Placement nach oben",
|
||||
"key.neolitematica.move_down": "Placement nach unten",
|
||||
"key.neolitematica.rotate_cw": "Placement im Uhrzeigersinn drehen",
|
||||
"key.neolitematica.rotate_ccw": "Placement gegen den Uhrzeigersinn drehen",
|
||||
"key.neolitematica.mirror_x": "Placement entlang X spiegeln",
|
||||
"key.neolitematica.mirror_y": "Placement vertikal spiegeln (Y)",
|
||||
"key.neolitematica.mirror_z": "Placement entlang Z spiegeln",
|
||||
|
||||
"neolitematica.render_mode.all": "Alle Blöcke",
|
||||
"neolitematica.render_mode.all.tooltip": "Zeigt jeden Block des Schematics, ohne Vergleich mit der Welt.",
|
||||
"neolitematica.render_mode.missing_only": "Nur fehlende",
|
||||
"neolitematica.render_mode.missing_only.tooltip": "Blendet korrekt platzierte Blöcke aus. Falsche Blöcke rot, falsche Ausrichtung/Zustand orange.",
|
||||
"neolitematica.render_mode.verify": "Prüfen",
|
||||
"neolitematica.render_mode.verify.tooltip": "Wie 'Nur fehlende', zusätzlich korrekte Blöcke grün und überzählige Blöcke magenta.",
|
||||
|
||||
"neolitematica.message.load_failed": "Schematic %s konnte nicht geladen werden: %s",
|
||||
"neolitematica.message.placed": "'%s' platziert bei %s %s %s",
|
||||
"neolitematica.message.rendering_on": "Schematic-Anzeige an",
|
||||
"neolitematica.message.rendering_off": "Schematic-Anzeige aus",
|
||||
"neolitematica.message.render_mode": "Rendering-Modus: %s",
|
||||
"neolitematica.message.selected": "Ausgewählt: %s",
|
||||
"neolitematica.message.placement_on": "'%s' sichtbar",
|
||||
"neolitematica.message.placement_off": "'%s' ausgeblendet",
|
||||
"neolitematica.message.position": "%s: %s %s %s",
|
||||
"neolitematica.message.transform": "%s: Drehung %s°, gespiegelt: %s",
|
||||
"neolitematica.message.list_failed": "Schematics konnten nicht aufgelistet werden: %s",
|
||||
"neolitematica.message.renamed": "Umbenannt in '%s'",
|
||||
"neolitematica.message.rename_failed": "Umbenennen fehlgeschlagen: %s",
|
||||
"neolitematica.message.deleted": "%s gelöscht",
|
||||
"neolitematica.message.delete_failed": "Löschen fehlgeschlagen: %s",
|
||||
"neolitematica.message.import_failed": "Import von %s fehlgeschlagen: %s",
|
||||
"neolitematica.message.imported": "%s Schematic(s) importiert",
|
||||
"neolitematica.text.none": "keine",
|
||||
|
||||
"neolitematica.hud.title": "Schematic: %s",
|
||||
"neolitematica.hud.hidden": "Anzeige ist aus (Alt+H)",
|
||||
"neolitematica.hud.disabled": "Placement ausgeblendet",
|
||||
"neolitematica.hud.loading": "Wird geladen...",
|
||||
"neolitematica.hud.position": "Position: %s %s %s",
|
||||
"neolitematica.hud.size": "Größe: %s x %s x %s Drehung: %s° Spiegelung: %s",
|
||||
"neolitematica.hud.stats": "Fehlend: %s Falsch: %s Korrekt: %s / %s",
|
||||
|
||||
"neolitematica.screen.main.title": "NeoLitematica - Placements",
|
||||
"neolitematica.screen.main.empty": "Noch keine Placements. 'Schematic laden...' verwenden",
|
||||
"neolitematica.screen.remove.title": "Placement entfernen?",
|
||||
"neolitematica.screen.remove.message": "'%s' wird aus dieser Welt entfernt. Die Schematic-Datei bleibt erhalten.",
|
||||
"neolitematica.list.placement_detail": "%s %s %s | %sx%sx%s | %s°",
|
||||
"neolitematica.screen.browser.title": "Schematics",
|
||||
"neolitematica.screen.browser.empty": "Keine .litematic-Dateien im Schematics-Ordner.",
|
||||
"neolitematica.screen.browser.drop_hint": "'Importieren...' verwenden oder Dateien ins Fenster ziehen.",
|
||||
"neolitematica.screen.rename.title": "Neuer Name",
|
||||
"neolitematica.screen.delete.title": "Schematic-Datei löschen?",
|
||||
"neolitematica.screen.delete.message": "%s wird von der Festplatte gelöscht. Das kann nicht rückgängig gemacht werden.",
|
||||
"neolitematica.screen.placement.title": "Placement bearbeiten",
|
||||
|
||||
"neolitematica.button.load_schematic": "Schematic laden...",
|
||||
"neolitematica.button.edit": "Position bearbeiten...",
|
||||
"neolitematica.button.toggle_placement": "Ein- / ausblenden",
|
||||
"neolitematica.button.move_here": "Zu meiner Position",
|
||||
"neolitematica.button.move_here.tooltip": "Setzt die minimale Ecke des Placements an deine Position.",
|
||||
"neolitematica.button.remove": "Placement entfernen",
|
||||
"neolitematica.button.more_settings": "Einstellungen...",
|
||||
"neolitematica.button.controls": "Steuerung...",
|
||||
"neolitematica.button.place": "Hier platzieren",
|
||||
"neolitematica.button.place.tooltip": "Platziert das Schematic mit der minimalen Ecke an deiner Position. Doppelklick geht auch.",
|
||||
"neolitematica.button.import": "Importieren...",
|
||||
"neolitematica.button.import.tooltip": "Kopiert .litematic-Dateien in den Schematics-Ordner. Dateien können auch ins Fenster gezogen werden.",
|
||||
"neolitematica.button.open_folder": "Ordner öffnen",
|
||||
"neolitematica.button.rename": "Umbenennen",
|
||||
"neolitematica.button.delete": "Löschen",
|
||||
"neolitematica.button.refresh": "Aktualisieren",
|
||||
"neolitematica.button.reset_position": "Position zurücksetzen",
|
||||
"neolitematica.button.reset_position.tooltip": "Setzt das Placement auf die ursprüngliche (oder zuletzt gemerkte) Position zurück.",
|
||||
"neolitematica.button.store_origin": "Als Ursprung merken",
|
||||
"neolitematica.button.store_origin.tooltip": "Macht die aktuelle Position zu der, auf die 'Position zurücksetzen' springt.",
|
||||
"neolitematica.button.reset_transform": "Drehung/Spiegelung zurück",
|
||||
|
||||
"neolitematica.option.rendering": "Anzeige",
|
||||
"neolitematica.option.render_mode": "Modus",
|
||||
"neolitematica.option.ghost_alpha": "Deckkraft: %s",
|
||||
"neolitematica.option.render_distance": "Distanz: %s Chunks",
|
||||
"neolitematica.option.rotation": "Drehung",
|
||||
"neolitematica.option.mirror_x": "Spiegeln X",
|
||||
"neolitematica.option.mirror_x.tooltip": "Spiegelt Ost/West.",
|
||||
"neolitematica.option.mirror_y": "Spiegeln Y",
|
||||
"neolitematica.option.mirror_y.tooltip": "Kopfüber. Blockzustände werden angepasst, wo Minecraft ein Gegenstück hat (Treppen, Stufen, Türen, ...).",
|
||||
"neolitematica.option.mirror_z": "Spiegeln Z",
|
||||
"neolitematica.option.mirror_z.tooltip": "Spiegelt Nord/Süd.",
|
||||
"neolitematica.option.visible": "Sichtbar",
|
||||
"neolitematica.label.name": "Name",
|
||||
|
||||
"neolitematica.info.name": "Name: %s",
|
||||
"neolitematica.info.author": "Autor: %s",
|
||||
"neolitematica.info.size": "Größe: %s x %s x %s",
|
||||
"neolitematica.info.blocks": "Blöcke: %s",
|
||||
"neolitematica.info.volume": "Volumen: %s",
|
||||
"neolitematica.info.regions": "Regionen: %s",
|
||||
"neolitematica.info.version": "Format v%s, Datenversion %s",
|
||||
"neolitematica.info.modified": "Geändert: %s",
|
||||
"neolitematica.info.file_size": "Dateigröße: %s KiB",
|
||||
"neolitematica.info.file": "Datei: %s",
|
||||
"neolitematica.info.size_blocks": "Größe %s x %s x %s, %s Blöcke",
|
||||
"neolitematica.info.unknown": "%s unbekannte Blocktypen (nicht installiert): %s",
|
||||
"neolitematica.info.stats": "Korrekt %s, fehlend %s, falsch %s, falscher Zustand %s, überzählig %s",
|
||||
"neolitematica.info.stats_partial": "In der Nähe: korrekt %s, fehlend %s, falsch %s, falscher Zustand %s, überzählig %s",
|
||||
|
||||
"neolitematica.configuration.title": "NeoLitematica-Einstellungen",
|
||||
"neolitematica.configuration.section.neolitematica.client.toml": "Client-Einstellungen",
|
||||
"neolitematica.configuration.section.neolitematica.client.toml.title": "Client-Einstellungen",
|
||||
"neolitematica.configuration.renderingEnabled": "Anzeige aktiv",
|
||||
"neolitematica.configuration.renderMode": "Rendering-Modus",
|
||||
"neolitematica.configuration.ghostAlpha": "Deckkraft der Geisterblöcke",
|
||||
"neolitematica.configuration.overlayAlpha": "Deckkraft der Markierungen",
|
||||
"neolitematica.configuration.renderDistance": "Render-Distanz (Chunks)",
|
||||
"neolitematica.configuration.renderBlockEntities": "Block-Entities anzeigen",
|
||||
"neolitematica.configuration.showBoundingBox": "Rahmen anzeigen",
|
||||
"neolitematica.configuration.showHud": "Info-HUD anzeigen",
|
||||
"neolitematica.configuration.builderThreads": "Mesh-Threads (Neustart nötig)"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"key.categories.neolitematica": "NeoLitematica",
|
||||
"key.neolitematica.open_menu": "Open main menu",
|
||||
"key.neolitematica.open_browser": "Open schematic browser",
|
||||
"key.neolitematica.edit_placement": "Edit selected placement",
|
||||
"key.neolitematica.toggle_rendering": "Show/hide all schematics",
|
||||
"key.neolitematica.cycle_render_mode": "Cycle render mode",
|
||||
"key.neolitematica.next_placement": "Select next placement",
|
||||
"key.neolitematica.toggle_placement": "Show/hide selected placement",
|
||||
"key.neolitematica.move_to_player": "Move placement to player",
|
||||
"key.neolitematica.reset_position": "Reset placement position",
|
||||
"key.neolitematica.move_forward": "Move placement forward",
|
||||
"key.neolitematica.move_back": "Move placement back",
|
||||
"key.neolitematica.move_left": "Move placement left",
|
||||
"key.neolitematica.move_right": "Move placement right",
|
||||
"key.neolitematica.move_up": "Move placement up",
|
||||
"key.neolitematica.move_down": "Move placement down",
|
||||
"key.neolitematica.rotate_cw": "Rotate placement clockwise",
|
||||
"key.neolitematica.rotate_ccw": "Rotate placement counter-clockwise",
|
||||
"key.neolitematica.mirror_x": "Mirror placement along X",
|
||||
"key.neolitematica.mirror_y": "Mirror placement upside down (Y)",
|
||||
"key.neolitematica.mirror_z": "Mirror placement along Z",
|
||||
|
||||
"neolitematica.render_mode.all": "All blocks",
|
||||
"neolitematica.render_mode.all.tooltip": "Shows every schematic block, without comparing with the world.",
|
||||
"neolitematica.render_mode.missing_only": "Missing only",
|
||||
"neolitematica.render_mode.missing_only.tooltip": "Hides blocks that are already placed correctly. Wrong blocks are red, wrong orientation/state orange.",
|
||||
"neolitematica.render_mode.verify": "Verify",
|
||||
"neolitematica.render_mode.verify.tooltip": "Like 'Missing only', additionally marks correct blocks green and extra blocks magenta.",
|
||||
|
||||
"neolitematica.message.load_failed": "Could not load schematic %s: %s",
|
||||
"neolitematica.message.placed": "Placed '%s' at %s %s %s",
|
||||
"neolitematica.message.rendering_on": "Schematic rendering on",
|
||||
"neolitematica.message.rendering_off": "Schematic rendering off",
|
||||
"neolitematica.message.render_mode": "Render mode: %s",
|
||||
"neolitematica.message.selected": "Selected placement: %s",
|
||||
"neolitematica.message.placement_on": "'%s' visible",
|
||||
"neolitematica.message.placement_off": "'%s' hidden",
|
||||
"neolitematica.message.position": "%s: %s %s %s",
|
||||
"neolitematica.message.transform": "%s: rotation %s°, mirrored: %s",
|
||||
"neolitematica.message.list_failed": "Could not list schematics: %s",
|
||||
"neolitematica.message.renamed": "Renamed to '%s'",
|
||||
"neolitematica.message.rename_failed": "Rename failed: %s",
|
||||
"neolitematica.message.deleted": "Deleted %s",
|
||||
"neolitematica.message.delete_failed": "Delete failed: %s",
|
||||
"neolitematica.message.import_failed": "Import of %s failed: %s",
|
||||
"neolitematica.message.imported": "Imported %s schematic(s)",
|
||||
"neolitematica.text.none": "none",
|
||||
|
||||
"neolitematica.hud.title": "Schematic: %s",
|
||||
"neolitematica.hud.hidden": "Rendering is off (Alt+H)",
|
||||
"neolitematica.hud.disabled": "Placement hidden",
|
||||
"neolitematica.hud.loading": "Loading...",
|
||||
"neolitematica.hud.position": "Position: %s %s %s",
|
||||
"neolitematica.hud.size": "Size: %s x %s x %s Rotation: %s° Mirror: %s",
|
||||
"neolitematica.hud.stats": "Missing: %s Wrong: %s Correct: %s / %s",
|
||||
|
||||
"neolitematica.screen.main.title": "NeoLitematica - Placements",
|
||||
"neolitematica.screen.main.empty": "No placements yet. Use 'Load schematic...'",
|
||||
"neolitematica.screen.remove.title": "Remove placement?",
|
||||
"neolitematica.screen.remove.message": "'%s' will be removed from this world. The schematic file is kept.",
|
||||
"neolitematica.list.placement_detail": "%s %s %s | %sx%sx%s | %s°",
|
||||
"neolitematica.screen.browser.title": "Schematics",
|
||||
"neolitematica.screen.browser.empty": "No .litematic files in the schematics folder.",
|
||||
"neolitematica.screen.browser.drop_hint": "Use 'Import...' or drag files onto this window.",
|
||||
"neolitematica.screen.rename.title": "New name",
|
||||
"neolitematica.screen.delete.title": "Delete schematic file?",
|
||||
"neolitematica.screen.delete.message": "%s will be deleted from disk. This cannot be undone.",
|
||||
"neolitematica.screen.placement.title": "Edit placement",
|
||||
|
||||
"neolitematica.button.load_schematic": "Load schematic...",
|
||||
"neolitematica.button.edit": "Edit position...",
|
||||
"neolitematica.button.toggle_placement": "Show / hide",
|
||||
"neolitematica.button.move_here": "Move to my position",
|
||||
"neolitematica.button.move_here.tooltip": "Puts the placement's minimum corner where you stand.",
|
||||
"neolitematica.button.remove": "Remove placement",
|
||||
"neolitematica.button.more_settings": "Settings...",
|
||||
"neolitematica.button.controls": "Controls...",
|
||||
"neolitematica.button.place": "Place here",
|
||||
"neolitematica.button.place.tooltip": "Places the schematic with its minimum corner at your position. Double-click works too.",
|
||||
"neolitematica.button.import": "Import...",
|
||||
"neolitematica.button.import.tooltip": "Copies .litematic files into the schematics folder. You can also drag files onto the window.",
|
||||
"neolitematica.button.open_folder": "Open folder",
|
||||
"neolitematica.button.rename": "Rename",
|
||||
"neolitematica.button.delete": "Delete",
|
||||
"neolitematica.button.refresh": "Refresh",
|
||||
"neolitematica.button.reset_position": "Reset position",
|
||||
"neolitematica.button.reset_position.tooltip": "Moves the placement back to where it was first placed (or last stored).",
|
||||
"neolitematica.button.store_origin": "Store as original",
|
||||
"neolitematica.button.store_origin.tooltip": "Makes the current position the one 'Reset position' returns to.",
|
||||
"neolitematica.button.reset_transform": "Reset rotation/mirror",
|
||||
|
||||
"neolitematica.option.rendering": "Rendering",
|
||||
"neolitematica.option.render_mode": "Mode",
|
||||
"neolitematica.option.ghost_alpha": "Opacity: %s",
|
||||
"neolitematica.option.render_distance": "Distance: %s chunks",
|
||||
"neolitematica.option.rotation": "Rotation",
|
||||
"neolitematica.option.mirror_x": "Mirror X",
|
||||
"neolitematica.option.mirror_x.tooltip": "Mirrors east/west.",
|
||||
"neolitematica.option.mirror_y": "Mirror Y",
|
||||
"neolitematica.option.mirror_y.tooltip": "Upside down. Block states are flipped where Minecraft has an equivalent (stairs, slabs, doors, ...).",
|
||||
"neolitematica.option.mirror_z": "Mirror Z",
|
||||
"neolitematica.option.mirror_z.tooltip": "Mirrors north/south.",
|
||||
"neolitematica.option.visible": "Visible",
|
||||
"neolitematica.label.name": "Name",
|
||||
|
||||
"neolitematica.info.name": "Name: %s",
|
||||
"neolitematica.info.author": "Author: %s",
|
||||
"neolitematica.info.size": "Size: %s x %s x %s",
|
||||
"neolitematica.info.blocks": "Blocks: %s",
|
||||
"neolitematica.info.volume": "Volume: %s",
|
||||
"neolitematica.info.regions": "Regions: %s",
|
||||
"neolitematica.info.version": "Format v%s, data version %s",
|
||||
"neolitematica.info.modified": "Modified: %s",
|
||||
"neolitematica.info.file_size": "File size: %s KiB",
|
||||
"neolitematica.info.file": "File: %s",
|
||||
"neolitematica.info.size_blocks": "Size %s x %s x %s, %s blocks",
|
||||
"neolitematica.info.unknown": "%s unknown block types (not installed): %s",
|
||||
"neolitematica.info.stats": "Correct %s, missing %s, wrong %s, wrong state %s, extra %s",
|
||||
"neolitematica.info.stats_partial": "Nearby: correct %s, missing %s, wrong %s, wrong state %s, extra %s",
|
||||
|
||||
"neolitematica.configuration.title": "NeoLitematica Settings",
|
||||
"neolitematica.configuration.section.neolitematica.client.toml": "Client settings",
|
||||
"neolitematica.configuration.section.neolitematica.client.toml.title": "Client settings",
|
||||
"neolitematica.configuration.renderingEnabled": "Rendering enabled",
|
||||
"neolitematica.configuration.renderMode": "Render mode",
|
||||
"neolitematica.configuration.ghostAlpha": "Ghost block opacity",
|
||||
"neolitematica.configuration.overlayAlpha": "Overlay opacity",
|
||||
"neolitematica.configuration.renderDistance": "Render distance (chunks)",
|
||||
"neolitematica.configuration.renderBlockEntities": "Render block entities",
|
||||
"neolitematica.configuration.showBoundingBox": "Show bounding box",
|
||||
"neolitematica.configuration.showHud": "Show info HUD",
|
||||
"neolitematica.configuration.builderThreads": "Mesh builder threads (restart)"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"required": true,
|
||||
"minVersion": "0.8",
|
||||
"package": "de.lucas.litematica.mixin",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"client": [
|
||||
"LevelRendererMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
modLoader="javafml"
|
||||
loaderVersion="${loader_version_range}"
|
||||
license="${mod_license}"
|
||||
issueTrackerURL=""
|
||||
|
||||
[[mods]]
|
||||
modId="${mod_id}"
|
||||
version="${mod_version}"
|
||||
displayName="${mod_name}"
|
||||
authors="${mod_authors}"
|
||||
description='''${mod_description}'''
|
||||
# Client-only mod: the server does not need it, and its presence/version is never checked.
|
||||
displayTest="IGNORE_ALL_VERSION"
|
||||
|
||||
[[mixins]]
|
||||
config="${mod_id}.mixins.json"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="neoforge"
|
||||
type="required"
|
||||
versionRange="${neo_version_range}"
|
||||
ordering="NONE"
|
||||
side="CLIENT"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="minecraft"
|
||||
type="required"
|
||||
versionRange="${minecraft_version_range}"
|
||||
ordering="NONE"
|
||||
side="CLIENT"
|
||||
@@ -0,0 +1,100 @@
|
||||
package de.lucas.litematica.placement;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.DoorBlock;
|
||||
import net.minecraft.world.level.block.FurnaceBlock;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.block.SlabBlock;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.DoubleBlockHalf;
|
||||
import net.minecraft.world.level.block.state.properties.Half;
|
||||
import net.minecraft.world.level.block.state.properties.SlabType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PlacementTransformTest {
|
||||
private static final Vec3i SIZE = new Vec3i(3, 4, 5);
|
||||
|
||||
private static List<PlacementTransform> allTransforms() {
|
||||
List<PlacementTransform> list = new ArrayList<>();
|
||||
for (Rotation rotation : Rotation.values()) {
|
||||
for (int bits = 0; bits < 8; bits++) {
|
||||
list.add(new PlacementTransform(rotation, (bits & 1) != 0, (bits & 2) != 0, (bits & 4) != 0));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Test
|
||||
void positionsRoundTripAndStayInsideBox() {
|
||||
BlockPos.MutableBlockPos back = new BlockPos.MutableBlockPos();
|
||||
for (PlacementTransform t : allTransforms()) {
|
||||
Vec3i placedSize = t.transformSize(SIZE);
|
||||
for (int x = 0; x < SIZE.getX(); x++) {
|
||||
for (int y = 0; y < SIZE.getY(); y++) {
|
||||
for (int z = 0; z < SIZE.getZ(); z++) {
|
||||
BlockPos placed = t.toPlaced(x, y, z, SIZE);
|
||||
assertTrue(placed.getX() >= 0 && placed.getX() < placedSize.getX(), t + " x");
|
||||
assertTrue(placed.getY() >= 0 && placed.getY() < placedSize.getY(), t + " y");
|
||||
assertTrue(placed.getZ() >= 0 && placed.getZ() < placedSize.getZ(), t + " z");
|
||||
t.toLocal(placed.getX(), placed.getY(), placed.getZ(), SIZE, back);
|
||||
assertEquals(new BlockPos(x, y, z), back.immutable(), t.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A furnace must keep facing the same neighbouring block after its position is transformed. */
|
||||
@Test
|
||||
void stateFacingMatchesPositionTransform() {
|
||||
BlockPos center = new BlockPos(1, 1, 2);
|
||||
for (PlacementTransform t : allTransforms()) {
|
||||
for (Direction facing : Direction.Plane.HORIZONTAL) {
|
||||
BlockState state = Blocks.FURNACE.defaultBlockState().setValue(FurnaceBlock.FACING, facing);
|
||||
BlockPos neighbor = center.relative(facing);
|
||||
BlockPos delta = t.toPlaced(neighbor.getX(), neighbor.getY(), neighbor.getZ(), SIZE)
|
||||
.subtract(t.toPlaced(center.getX(), center.getY(), center.getZ(), SIZE));
|
||||
Direction expected = Direction.fromDelta(delta.getX(), delta.getY(), delta.getZ());
|
||||
assertEquals(expected, t.apply(state).getValue(FurnaceBlock.FACING), t + " " + facing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void verticalFlipAdjustsHalves() {
|
||||
PlacementTransform flip = PlacementTransform.IDENTITY.withMirrorY(true);
|
||||
BlockState stair = Blocks.OAK_STAIRS.defaultBlockState().setValue(StairBlock.HALF, Half.BOTTOM);
|
||||
assertEquals(Half.TOP, flip.apply(stair).getValue(StairBlock.HALF));
|
||||
|
||||
BlockState slab = Blocks.OAK_SLAB.defaultBlockState().setValue(SlabBlock.TYPE, SlabType.BOTTOM);
|
||||
assertEquals(SlabType.TOP, flip.apply(slab).getValue(SlabBlock.TYPE));
|
||||
BlockState doubleSlab = slab.setValue(SlabBlock.TYPE, SlabType.DOUBLE);
|
||||
assertEquals(SlabType.DOUBLE, flip.apply(doubleSlab).getValue(SlabBlock.TYPE));
|
||||
|
||||
BlockState door = Blocks.OAK_DOOR.defaultBlockState().setValue(DoorBlock.HALF, DoubleBlockHalf.LOWER);
|
||||
assertEquals(DoubleBlockHalf.UPPER, flip.apply(door).getValue(DoorBlock.HALF));
|
||||
|
||||
// Hoppers cannot face up; flipping must keep a valid state instead of throwing.
|
||||
BlockState hopper = Blocks.HOPPER.defaultBlockState();
|
||||
assertEquals(Direction.DOWN, flip.apply(hopper).getValue(net.minecraft.world.level.block.HopperBlock.FACING));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotationCycles() {
|
||||
PlacementTransform t = PlacementTransform.IDENTITY;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
t = t.rotatedClockwise();
|
||||
}
|
||||
assertEquals(PlacementTransform.IDENTITY, t);
|
||||
assertEquals(270, PlacementTransform.IDENTITY.rotatedCounterClockwise().degrees());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.lucas.litematica.schematic;
|
||||
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.WallSignBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.core.Direction;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LitematicReaderTest {
|
||||
private static Path sample() {
|
||||
return Path.of(System.getProperty("neolitematica.testSchematic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsSampleCastle() throws Exception {
|
||||
LitematicSchematic schematic = LitematicReader.read(sample());
|
||||
|
||||
assertEquals(new Vec3i(64, 48, 80), schematic.size());
|
||||
assertEquals(1, schematic.regions().size());
|
||||
SchematicRegion region = schematic.regions().getFirst();
|
||||
assertEquals(480, region.palette().length);
|
||||
// Metadata TotalBlocks, also verified with an independent decoder.
|
||||
assertEquals(14905, schematic.nonAirBlocks());
|
||||
assertEquals(schematic.metadata().totalBlocks(), schematic.nonAirBlocks());
|
||||
assertTrue(schematic.unknownBlocks().isEmpty(), () -> "unknown: " + schematic.unknownBlocks());
|
||||
assertEquals(322, region.blockEntities().size());
|
||||
assertEquals(7, schematic.metadata().formatVersion());
|
||||
assertEquals(3955, schematic.metadata().dataVersion());
|
||||
|
||||
BlockState sign = schematic.getBlockState(24, 8, 24);
|
||||
assertSame(Blocks.SPRUCE_WALL_SIGN, sign.getBlock());
|
||||
assertEquals(Direction.WEST, sign.getValue(WallSignBlock.FACING));
|
||||
CompoundTag signTag = schematic.getBlockEntity(24, 8, 24);
|
||||
assertNotNull(signTag);
|
||||
assertEquals("minecraft:sign", signTag.getString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGarbage(@org.junit.jupiter.api.io.TempDir Path dir) throws Exception {
|
||||
Path notGzip = dir.resolve("broken.litematic");
|
||||
Files.writeString(notGzip, "definitely not nbt");
|
||||
assertThrows(SchematicLoadException.class, () -> LitematicReader.read(notGzip));
|
||||
|
||||
Path emptyNbt = dir.resolve("empty.litematic");
|
||||
try (var out = new GZIPOutputStream(Files.newOutputStream(emptyNbt))) {
|
||||
// Empty root compound: tag type 10, empty name, end tag.
|
||||
out.write(new byte[]{10, 0, 0, 0});
|
||||
}
|
||||
assertThrows(SchematicLoadException.class, () -> LitematicReader.read(emptyNbt));
|
||||
assertThrows(SchematicLoadException.class, () -> LitematicReader.read(dir.resolve("missing.litematic")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bitArraySpansLongs() {
|
||||
// 9-bit entries: entry 7 occupies bits 63..71 and therefore spans two longs.
|
||||
long[] data = new long[2];
|
||||
int value = 0b101010101;
|
||||
long bit = 7 * 9;
|
||||
data[0] |= ((long) value) << bit;
|
||||
data[1] |= ((long) value) >>> (64 - bit);
|
||||
LitematicBitArray array = new LitematicBitArray(9, 14, data);
|
||||
assertEquals(value, array.get(7));
|
||||
assertEquals(0, array.get(6));
|
||||
assertEquals(2, LitematicBitArray.bitsFor(1));
|
||||
assertEquals(2, LitematicBitArray.bitsFor(4));
|
||||
assertEquals(3, LitematicBitArray.bitsFor(5));
|
||||
assertEquals(9, LitematicBitArray.bitsFor(480));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user