commit b36a36624053b6d8969047ec1f04d2d761289785 Author: Elias Müller Date: Sat Nov 7 08:15:40 2026 +0100 initial commit: llamamod 0.2.0 – reverse-engineered level formats (wad, map, trak, text, stpc), python tools and notes, java mod manager (iso install, mod pipeline with exe patching, save editor, wine/windows launcher) with mods (test world, debug wireframe, mirror, randomizer, fog, cheats, intro skip, practice, free camera, boot to level, level projects) and the 3d level editor (geometry, objects, cross-world import, environment, textures, play-test, manual) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b19226 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# game data (copyrighted) - never commit +re/groove.exe +re/groove_decomp.c +re/proj/ +re/ghidra.log +out/ +*.wad +*.WAD +*.iso +*.deb +__pycache__/ +.~lock.* +build/ +.gradle/ +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddf8ae0 --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# levelbau – level modding kit for *The Emperor's New Groove* (PC, 2000) + +German title: *Ein Königreich für ein Lama*. PC port by Argonaut, built on the Croc 2 engine. + +This kit came out of an AI-assisted reverse-engineering project. It contains: + +* **Format documentation** for the level files (`*.wad`): geometry, collision, object + placement, textures, waypoints, visibility zones, and what makes the level exit work. +* **Python tools** that read, export and rebuild those files byte-exactly. +* **A level builder** (`tools/levelbuild.py`). You describe a level as a short Python script + (boxes, ramps, stairs, fences, trees, sea, objects) and it writes a complete, playable WAD. +* **An example level, "Testwelt"** (`levels/testwelt.py`). It goes into the hidden, empty + *TestLevel* slot (world 9, chapter 1) and has its own geometry, a gallery with every + object type on pedestals, an animal pen and a small parkour with a working exit. +* **A debug patch generator** (`tools/debugviz.py`). It makes collision visible in every + level, including invisible walls, along with invisible trigger and camera objects. +* **An automated in-game tester** (`tools/lamatest.py`). On Linux it runs the game under + Wine on a virtual display, loads the test level, takes screenshots and detects crashes. + +Nothing from the game itself is included: no game files, no executable, no decompiled +code, no built WADs. Every tool works on **your own copy** of the game. + +> Most notes in `notes/` are written in **German**. Any AI assistant can read and translate +> them, and that is the recommended way to use this kit (see the last section). + +--- + +## Requirements + +* Your own copy of the game. Development used the **German CD version**. Other language + versions should use the same format, but this hasn't been tested. +* Python 3.8+ with `numpy` and `Pillow`. Optional: `capstone` (disassembler helper). +* For the automated in-game tests only: Linux, Wine, Xvfb, xdotool, ImageMagick, tesseract + with German language data, and PulseAudio (`pactl`). +* Optional: Blender (preview renders), and Ghidra (to regenerate the decompile, see below). + +The builder and the export tools are plain Python, so they should also work on Windows. +The test harness is Linux/Wine-only. + +### Paths – adjust these + +The tools were written for one Linux/Wine setup and some paths are hard-coded at the top of +the scripts: + +| Script | Variable | Meaning | +|---|---|---| +| `tools/levelbuild.py` | `WADS` | folder with the original level WADs (the `wads` folder of the game CD) | +| `tools/levelbuild.py` | `MODWAD` | where `--install` puts the built level (`T1L9M001.wad`) | +| `tools/debugviz.py` | `CD_WADS` | same as `WADS` | +| `tools/lamatest.py` | `PREFIX`, `LAMA_MOD` | Wine prefix and the overlay script | + +The game reads its level data from the CD drive, in the `wads` folder of the CD. To mod it, +make a copy of the CD data (or an overlay of symlinks, see `tools/lama-mod`), point the CD +drive or `CDPath` at that copy, and put modified WADs into its `wads` folder. + +--- + +## Folder layout + +``` +README.md this file +notes/ format documentation and findings (German) + geometry.md TRAK/MAP geometry, collision polygons, coordinate spaces, zones, limits + objects.md MAP chunk layout, strat (object) entries, waypoints, player start, exit + strat_types_table.md every object type in every original level, with counts + textures.md TEXT chunk: texture pages, RLE16, texture table, animations + levelbau.md how to use levelbuild.py, measured player physics, exit logic + testharness.md the automated Wine/Xvfb tester + debugviz.md debug patch: colour legend, what is shown, limits + img/ screenshots and preview renders +tools/ + geomlib.py core library: WAD, TRAK, STPC models, MAP geometry, TEXT, collision + geom_export.py WAD -> OBJ/MTL (+ textures, vertex colours, collision.obj, objects.obj) + geom_import.py OBJ -> new geometry inside a WAD copy + mapchunk.py MAP chunk parser/writer (byte-exact on all original levels) + strats.py, strat_table.py, stpcwalk.py, map_dump.py object types and strat code helpers + textchunk.py, text_export.py, texsheet.py textures: export/import PNG, contact sheets + levelbuild.py the level builder (see notes/levelbau.md) + debugviz.py debug patch generator (collision/trigger visualisation) + lamatest.py automated in-game test on a virtual display (Linux/Wine) + lama-mod shell script: builds a CD overlay with modded WADs (Wine setup) + vorschau/ Blender preview render scripts + disx86.py small disassembler helper for groove.exe (needs capstone) + wadtool.py minimal WAD chunk lister/extractor/replacer (used by several tools) + cor2png.py, png2cor.py loading/concept pictures (*.COR, LZ-compressed 640x480) <-> PNG + asf2wav.py music (*.ASF, 4-bit ADPCM) -> WAV +levels/ + testwelt.py the example test world + testwelt_parkour.seq scripted run through the parkour (used with lamatest.py) + sprunglabor.py "jump lab": test lanes used to measure jump height/width +re/ghidra_scripts/ + DumpAll.java Ghidra headless script: decompiles every function to one C file +``` + +--- + +## Quick start + +```bash +# export a level to OBJ (+ collision and object markers) +python3 tools/geom_export.py /path/to/cd/wads/t1l1m001.wad out_village --collision --objects +# -> level.obj/.mtl, texture pages, collision.obj, objects.obj, preview images + +# build the example test world +python3 tools/levelbuild.py levels/testwelt.py testwelt.wad +python3 tools/levelbuild.py --types # object types you can place +python3 tools/levelbuild.py --textures tex.png # texture sheet with the names you can use + +# build the debug patch for all levels +python3 tools/debugviz.py --help +``` + +### Playing the test level + +The game has a hidden world 9, *TestLevel*, with 49 chapter slots, but the retail game ships +no data for it. Put your built WAD into the CD's `wads` folder as `T1L9M001.wad`. Also add a +loading picture `T191.COR`, for example a copy of another `T1xx.COR`. + +To reach world 9, edit a save file (`king0.ksv`…`king2.ksv`, 256 bytes): + +* `+0x04/+0x05` = map cursor (world, chapter) +* `+0x3f/+0x40` = current world/chapter +* `+0x42/+0x43` = highest unlocked world/chapter + +Set them to world 9, chapter 1. + +When you leave through the exit, the game unlocks the next chapter. `levelbuild.py --install` +therefore links chapters 2–49 to the same file, so that picking one on the map doesn't crash. + +### Debug patch: visible collision and invisible walls + +```bash +python3 tools/debugviz.py --all debug_wads --src /path/to/cd/wads # all 33 level WADs +python3 tools/debugviz.py t1l1m001.wad t1l1m001_debug.wad # a single level +python3 tools/debugviz_check.py debug_wads /path/to/cd/wads # offline sanity check +``` + +Put the resulting WADs into the CD's `wads` folder in place of the originals, keeping the +same file names. On the Wine setup, `lama-mod debug an|aus` toggles them. The patch only +adds render geometry. Collision, objects, waypoints and zones stay byte-identical, so +gameplay is unchanged. + +Legend (full version in `notes/debugviz.md`): + +| Colour | Meaning | +|---|---| +| green / yellow-green wireframe | walkable floor / slope | +| blue | wall | +| orange | ceiling / overhang | +| **red, semi-transparent fill** | **invisible collision**: invisible walls and floors | +| magenta lines | death plane ("no floor") | +| violet fill | level exit floor (material 16) | +| other colours | special collision materials | + +| Marker | Meaning | +|---|---| +| white arrow | player start | +| violet cube | exit | +| turquoise cube | checkpoint | +| cyan cube | camera | +| yellow cube | trigger / cutscene | +| orange cube | force or special zone | +| gold cube | secret zone | +| grey cube | other invisible helper object | +| pink wire box | ambience / music / fog zone | + +Cubes sit on a stick, and a horizontal line shows the object's facing. Wire boxes show +trigger areas. + +Status: 19 of 33 levels were loaded in the game without problems. The rest +(catacombs/roller coaster world 7, lab world 8, two others) passed the offline check only. +Collision of moving objects (platforms, doors) is not shown. + +--- + +## LlamaMod and the level editor + +Besides the Python kit, this repository contains **LlamaMod** (Java 25, Swing; `src/`), a +one-click mod manager for the game on **Windows and Linux**, and a full **3D level editor** +built into it. Nothing from the game is included: the wizard installs the game from your own +CD/ISO into LlamaMod's data folder, and all mods are built from that copy. + +* **Mods** – tick mods, click *Apply*/*Play*: debug wireframe, Test World, randomizer, fog, + mirror mode, intro skip, cheats, practice tools, free camera, *boot to level* … Mods may change + level files and `groove.exe` (code caves); the original files are never touched. + Developer notes: `notes/llamamod-app.md`, one `notes/mod-*.md` per mod. +* **Level editor** (*Level Editor* button, or `LlamaMod --editor`): create new levels from + templates, brushes and the base level's tile models and objects, or edit the original + levels; import OBJ/glTF meshes and PNG/JPG textures (texture manager, paint-on-faces, + animations); waypoints, zones, fog, lights, visibility regions; **Play (F5)** builds the + level and starts the game directly in it (Escape returns to the editor). +* **Sharing**: a level is a small `.llamalevel` file with only your changes. Others import it in + LlamaMod (*Mods → Levels → Import level*) and it is built from their own game copy; new levels + appear in the hidden *TestLevel* world (chapters 1–49). + +User guide (English): **`notes/editor-guide.md`**. In the editor: *Help → Getting started* +and *Help → Shortcut sheet*. Technical notes: `notes/editor-design.md`, `editor-api.md`, +`editor-format.md`, `editor-render.md`, `editor-ui.md`, `editor-tools-*.md`, +`editor-playtest.md`. + +```bash +./gradlew build fatJar # tests + build/libs/llamamod--all.jar +./gradlew packageLinux # build/dist/LlamaMod--full-linux-x64.tar.gz (own Java runtime) +./gradlew packageWindows # build/dist/LlamaMod--full-windows-x64.zip (LlamaMod.exe + runtime) +./gradlew packageAll # both systems, full and launcher (no editor) edition +java -jar build/libs/llamamod-*-all.jar # LlamaMod +java -jar build/libs/llamamod-*-all.jar --editor # the level editor alone +``` + +![The level editor](notes/img/editor/ui_select_move.jpg) + +--- + +## Key facts + +* **The world is made of tiles.** TRAK holds about 100–650 small models: render triangles + plus collision polygons. MAP places them on a W×H grid (96×96 in the village) with height + and rotation. MAP also stores baked vertex colours and a 32-zone visibility table. There is + no BSP and no single world mesh. +* **Objects are "strats".** These are compiled scripts in the STPC chunk. In MAP, an object's + type is simply its **byte offset into STPC**. A level can therefore only use the object + types its own STPC contains. The builder uses the village (`t1l1m001`) as its base. +* **Coordinates and scale.** y points up. Fixed point 20.12 for objects, 4096 = 360° for + angles, and render space vs. game space have z mirrored. The llama is about 0.6 units + tall, runs about 1.1 units/s, can step up at most about 0.35 and jumps about 1.3 units + far with a run-up. +* **The level exit (LevelDoor)** is an invisible trigger. It fires only when the llama + stands in its grid cell **on floor with collision material 16**. Its parameters are + (tribe, world, chapter) of the next level. +* **Texture pixels** are PS1-style 15-bit BGR, RLE16-compressed, on 256×256 pages. The + value 0x0000 means transparent. + +The parsers rebuild every original WAD byte-for-byte, so they can be trusted as format +specs. + +--- + +## Building on this with an AI assistant + +This whole kit was made by an AI coding agent (Claude Code) working through the steps +below. Any capable assistant that can run code should be able to continue. + +1. **Give it the notes first.** Ask it to read `notes/*.md` (they are German; it can + translate). They contain offsets, field meanings, confidence levels and pitfalls. +2. **Regenerate the decompile** for questions about game logic. Install Ghidra and a full + JDK (version 25 for Ghidra 12.x), then run + `analyzeHeadless groove -import groove.exe -scriptPath re/ghidra_scripts -postScript DumpAll.java groove_decomp.c`. + This writes all functions as C (about 2 MB) for the assistant to search with grep. + Loader functions worth knowing: MAP `0x42ac50`, TRAK `0x42aac0`, STPC `0x42ab50`, strat + interpreter `0x54d180`. +3. **Always test in the game.** Have the assistant load and screenshot every build. On + Linux, `tools/lamatest.py run ` does this unattended in about 75 s. On + Windows, test by hand or adapt the script. +4. **Change one thing at a time** and keep the original WADs untouched. The game crashes on + inconsistent data: tiles outside the grid, a vertex-colour block that doesn't match the + vertex counts, or missing chapters. + +### Ideas for what to try next + +* Use object types from other levels. You would have to merge and relocate their STPC + code and their textures. +* ~~Import custom textures~~ – done in the level editor (texture manager, import, painting). +* ~~Make a real visual editor~~ – done: the LlamaMod level editor (see above). +* Add custom music or sounds (SMPC/AMPC/SRPC are sound banks). + +--- + +This is a non-commercial fan project, not affiliated with Disney or Argonaut. Please share +only tools and your own content, not the game's files. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..54cba2b --- /dev/null +++ b/build.gradle @@ -0,0 +1,289 @@ +plugins { + id 'application' + id 'com.diffplug.spotless' version '7.0.2' +} + +group = 'com.eliasmueller.llamamod' +version = '0.2.0' + +repositories { + mavenCentral() +} + +def lwjglVersion = '3.4.2' +def lwjglNatives = ['natives-linux', 'natives-windows'] + +dependencies { + implementation 'com.formdev:flatlaf:3.5.4' + implementation 'com.formdev:flatlaf-fonts-inter:4.1' + // level editor viewport: LWJGL (OpenGL) inside Swing via lwjgl3-awt; natives for both packages + implementation platform("org.lwjgl:lwjgl-bom:${lwjglVersion}") + implementation('org.lwjglx:lwjgl3-awt:0.2.5') { transitive = false } + implementation 'org.joml:joml:1.10.8' + ['lwjgl', 'lwjgl-opengl', 'lwjgl-jawt', 'lwjgl-egl'].each { m -> + implementation "org.lwjgl:${m}" + if (m in ['lwjgl', 'lwjgl-opengl']) { + lwjglNatives.each { n -> runtimeOnly "org.lwjgl:${m}::${n}" } + } + } + testImplementation platform('org.junit:junit-bom:5.11.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +def targetJavaVersion = 25 +java { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release.set(targetJavaVersion) +} + +application { + mainClass = 'com.eliasmueller.llamamod.Main' +} + +// build-info.properties: version and a build id (hash of the compiled classes and resources, without +// the edition switch, so an unchanged rebuild keeps the id and the mod cache stays valid) +def buildInfoDir = layout.buildDirectory.dir('generated/buildinfo') +tasks.register('buildInfo') { + def classes = sourceSets.main.output.classesDirs + def resources = fileTree('src/main/resources') { exclude 'com/eliasmueller/llamamod/edition.properties' } + def ver = project.version.toString() + def outDir = buildInfoDir + inputs.files(classes).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.files(resources).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.property('version', ver) + outputs.dir(outDir) + doLast { + def md = java.security.MessageDigest.getInstance('SHA-256') + def add = { File root -> + if (!root.exists()) { + return + } + def files = [] + root.eachFileRecurse(groovy.io.FileType.FILES) { files << it } + files.sort { root.toPath().relativize(it.toPath()).toString().replace('\\', '/') }.each { f -> + if (f.name == 'edition.properties') { + return + } + md.update(root.toPath().relativize(f.toPath()).toString().replace('\\', '/').getBytes('UTF-8')) + md.update(f.bytes) + } + } + classes.files.each(add) + add(file('src/main/resources')) + def id = md.digest().encodeHex().toString().substring(0, 10) + def f = outDir.get().file('com/eliasmueller/llamamod/build-info.properties').asFile + f.parentFile.mkdirs() + f.text = "version=${ver}\nbuild=${id}\n" + } +} + +// user manual (docs/manual, Markdown + images) bundled for the editor's Help → Manual window +processResources { + from('docs/manual') { + into 'com/eliasmueller/llamamod/editor/tools/help/manual' + } + from(tasks.named('buildInfo')) +} + +test { + useJUnitPlatform() + // golden tests: -Dllamamod.cd= -Dllamamod.ref= + systemProperty 'llamamod.cd', System.getProperty('llamamod.cd', "${System.getProperty('user.home')}/.wine-lama/iso-dk/Setup/Eng/Cd") + systemProperty 'llamamod.ref', System.getProperty('llamamod.ref', '') + // level builder references: python3 tools/levelbuild.py levels/testwelt.py /testwelt.wad etc. + systemProperty 'llamamod.lbref', System.getProperty('llamamod.lbref', '') + maxHeapSize = '2g' +} + +// fat jars with all dependencies: full edition (fatJar) and launcher edition without the editor UI +// (fatJarLauncher, edition.properties editor=false; the editor classes stay, mods need them) +def editions = ['full', 'launcher'] +def launcherEditionDir = layout.buildDirectory.dir('generated/edition-launcher') +tasks.register('launcherEdition') { + def outDir = launcherEditionDir + outputs.dir(outDir) + doLast { + def f = outDir.get().file('com/eliasmueller/llamamod/edition.properties').asFile + f.parentFile.mkdirs() + f.text = 'editor=false\n' + } +} +editions.each { ed -> + tasks.register(ed == 'full' ? 'fatJar' : 'fatJarLauncher', Jar) { + archiveClassifier = ed == 'full' ? 'all' : 'launcher' + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest { + attributes 'Main-Class': application.mainClass, 'Implementation-Version': project.version, 'Multi-Release': 'true' + } + if (ed == 'full') { + from sourceSets.main.output + } else { + from(tasks.named('launcherEdition')) + from(sourceSets.main.output) { exclude 'com/eliasmueller/llamamod/edition.properties' } + } + dependsOn configurations.runtimeClasspath + from { configurations.runtimeClasspath.findAll { it.name.endsWith('jar') }.collect { zipTree(it) } } + exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'module-info.class', 'META-INF/versions/*/module-info.class' + } +} + +spotless { + java { + target 'src/**/*.java' + palantirJavaFormat() + removeUnusedImports() + } +} + +// ---------------------------------------------------------------- packaging +// Self-contained Linux folder (+ .tar.gz) and Windows zip (LlamaMod.exe + runtime), both built on +// Linux. jlink cross-links using the Temurin jmods of the target platform; the jlink binary must +// be the exact same JDK version (JDK 25 ships no jmods, so they are downloaded separately). +// Downloads are cached in ~/opt (override: -Pllamamod.opt=). +def runtimeVersion = '25.0.5+8' +def optDir = file(findProperty('llamamod.opt') ?: "${System.getProperty('user.home')}/opt") +def distDir = layout.buildDirectory.dir('dist').get().asFile +// jdk.unsupported: sun.misc.Unsafe for LWJGL +def runtimeModules = 'java.base,java.desktop,java.prefs,java.logging,jdk.charsets,jdk.zipfs,jdk.unsupported' +def extraModules = [linux: '', windows: ',jdk.crypto.mscapi'] + +def adoptium = { String os, String imageType -> + "https://api.adoptium.net/v3/binary/version/jdk-${runtimeVersion.replace('+', '%2B')}/${os}/x64/${imageType}/hotspot/normal/eclipse" +} +def sh = { List cmd -> + def p = new ProcessBuilder(cmd.collect { it.toString() }).inheritIO().start() + if (p.waitFor() != 0) { + throw new GradleException("Command failed: ${cmd.join(' ')}") + } +} +def download = { String url, File target -> + if (!target.exists()) { + target.parentFile.mkdirs() + logger.lifecycle("Downloading ${url}") + def tmp = new File(target.path + '.part') + new URI(url).toURL().withInputStream { i -> tmp.withOutputStream { it << i } } + tmp.renameTo(target) + } + target +} + +tasks.register('fetchJdk') { + description = 'Downloads the Linux JDK (for jlink) and the jmods of both target platforms.' + doLast { + def jdk = new File(optDir, "jdk-${runtimeVersion}") + if (!new File(jdk, 'bin/jlink').exists()) { + def tgz = download(adoptium('linux', 'jdk'), new File(optDir, "dl/jdk-${runtimeVersion}-linux.tar.gz")) + sh(['tar', 'xzf', tgz, '-C', optDir]) + } + ['linux': 'tar.gz', 'windows': 'zip'].each { os, ext -> + def dir = new File(optDir, "jmods-${runtimeVersion}-${os}") + if (!dir.exists()) { + def arc = download(adoptium(os, 'jmods'), new File(optDir, "dl/jmods-${runtimeVersion}-${os}.${ext}")) + def tmp = new File(optDir, "jmods-${runtimeVersion}-${os}.tmp") + project.delete(tmp) + tmp.mkdirs() + if (ext == 'zip') { + project.copy { from project.zipTree(arc); into tmp } + } else { + sh(['tar', 'xzf', arc, '-C', tmp]) + } + // archives contain one top-level folder holding the *.jmod files + def jmodDir = fileTree(tmp).matching { include '**/java.base.jmod' }.singleFile.parentFile + jmodDir.renameTo(dir) + project.delete(tmp) + } + } + } +} + +def jlinkTask = { String name, String os -> + tasks.register(name) { + dependsOn 'fetchJdk' + def out = new File(layout.buildDirectory.get().asFile, "runtime-${os}") + outputs.dir(out) + doLast { + project.delete(out) + sh([new File(optDir, "jdk-${runtimeVersion}/bin/jlink").path, + '--module-path', new File(optDir, "jmods-${runtimeVersion}-${os}").path, + '--add-modules', runtimeModules + extraModules[os], + '--strip-debug', '--no-header-files', '--no-man-pages', + '--compress', 'zip-9', + '--output', out.path]) + } + } +} +jlinkTask('jlinkLinux', 'linux') +jlinkTask('jlinkWindows', 'windows') + +def jarTask = { String ed -> ed == 'full' ? 'fatJar' : 'fatJarLauncher' } +def suffix = { String ed -> ed == 'full' ? '' : 'Launcher' } +def packageName = { String ed, String os -> "LlamaMod-${project.version}-${ed}-${os}-x64" } + +editions.each { ed -> + def stage = new File(distDir, "${ed}/LlamaMod-linux-x64") + tasks.register("linuxImage${suffix(ed)}", Sync) { + dependsOn jarTask(ed), 'jlinkLinux' + from(layout.buildDirectory.dir('runtime-linux')) { into 'runtime' } + from(tasks.named(jarTask(ed))) { into 'lib'; rename { 'llamamod.jar' } } + from('packaging/linux') { filePermissions { unix('rwxr-xr-x') }; include 'LlamaMod', '*.sh' } + from('packaging/linux') { exclude 'LlamaMod', '*.sh' } + from('src/main/resources/com/eliasmueller/llamamod/ui/icon-256.png') { rename { 'llamamod.png' } } + into stage + // jlink marks legal/ files read-only, so Sync cannot overwrite them + doFirst { project.delete(stage) } + } + + tasks.register("packageLinux${suffix(ed)}") { + description = "Self-contained Linux build (${ed} edition): build/dist/${packageName(ed, 'linux')}.tar.gz." + dependsOn "linuxImage${suffix(ed)}" + doLast { + // system tar keeps the executable bits of the runtime (Gradle's Tar normalizes them) + sh(['tar', 'czf', new File(distDir, "${packageName(ed, 'linux')}.tar.gz"), '-C', stage.parentFile, stage.name]) + } + } + + def exe = new File(layout.buildDirectory.get().asFile, "exe/${ed}/LlamaMod.exe") + tasks.register("exeWindows${suffix(ed)}") { + description = 'Wraps the fat jar into LlamaMod.exe with launch4j (Linux binary, downloaded to ~/opt).' + dependsOn jarTask(ed) + inputs.files(tasks.named(jarTask(ed))) + inputs.dir('packaging/windows') + outputs.file(exe) + doLast { + def l4j = new File(optDir, 'launch4j') + if (!new File(l4j, 'launch4j.jar').exists()) { + def tgz = download('https://sourceforge.net/projects/launch4j/files/launch4j-3/3.50/launch4j-3.50-linux-x64.tgz/download', + new File(optDir, 'dl/launch4j-3.50-linux-x64.tgz')) + sh(['tar', 'xzf', tgz, '-C', optDir]) + } + exe.parentFile.mkdirs() + def cfg = new File(exe.parentFile, 'launch4j.xml') + cfg.text = file('packaging/windows/launch4j.xml.in').text + .replace('@JAR@', tasks.named(jarTask(ed)).get().archiveFile.get().asFile.path) + .replace('@OUT@', exe.path) + .replace('@ICON@', file('packaging/icon.ico').path) + .replace('@VERSION@', project.version.toString()) + sh([new File(optDir, "jdk-${runtimeVersion}/bin/java").path, '-jar', new File(l4j, 'launch4j.jar').path, cfg.path]) + } + } + + tasks.register("packageWindows${suffix(ed)}", Zip) { + description = "Windows build (made on Linux, ${ed} edition): build/dist/${packageName(ed, 'windows')}.zip." + dependsOn "exeWindows${suffix(ed)}", 'jlinkWindows' + from(layout.buildDirectory.dir('runtime-windows')) { into 'LlamaMod/runtime' } + from(exe) { into 'LlamaMod' } + from('packaging/windows') { exclude '*.in'; into 'LlamaMod' } + destinationDirectory = distDir + archiveFileName = "${packageName(ed, 'windows')}.zip" + } +} + +tasks.register('packageAll') { + description = 'Linux and Windows packages of both editions (full, launcher) in build/dist.' + dependsOn editions.collectMany { ["packageLinux${suffix(it)}", "packageWindows${suffix(it)}"] } +} diff --git a/deb-build/asf2wav.py b/deb-build/asf2wav.py new file mode 100644 index 0000000..516584c --- /dev/null +++ b/deb-build/asf2wav.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Dekodiert Argonaut-ASF-Musik (v2.1, 4-Bit-ADPCM) wie asfcodec.adl aus dem Spiel. + +Nachgebaut aus der Disassembly von asfcodec.adl (Routinen 0x26e01969 / 0x26e01ca1): + Filter 0: s = s1 + (n << shift) + Filter 1: s = 2*s1 - s2 + (n << shift) (+ Übertrag aus 'adc', s. u.) + - kein Clipping; Ausgabe wird als 16 Bit gespeichert (Überlauf = Wrap) + - s1 läuft innerhalb eines Blocks ungekürzt im Register weiter, + am Blockanfang wird es aus dem 16-Bit-Puffer gelesen + - s2 wird immer aus dem 16-Bit-Puffer gelesen +Aufruf: asf2wav.py eingabe.asf ausgabe.wav +""" +import array +import struct +import sys +import wave + +M32 = 0xFFFFFFFF + + +def s32(x): + x &= M32 + return x - (1 << 32) if x & 0x80000000 else x + + +def wrap16(x): + return ((x + 0x8000) & 0xFFFF) - 0x8000 + + +def decode(data): + off = struct.unpack_from('> 4) + 2 + prev2, prev1 = mem[c] + eax = prev1 # Blockanfang: s1 aus dem Speicher + k = o + c + for byte in blk: + for nib in (byte >> 4, byte & 15): + n = nib - 16 if nib & 8 else nib + edx = s32((n * 16) << cl) + if ctl & 4: + ebx = s32(eax << 7) + a = s32(-prev2 << 6) + carry = ((a & M32) + (edx & M32)) >> 32 + eax = s32(a + edx + ebx + carry) >> 6 + else: + eax = s32((eax << 6) + edx) >> 6 + w = wrap16(eax) + out[k] = w + k += ch + prev2, prev1 = prev1, w + mem[c] = [prev2, prev1] + o += 32 * ch + return rate, ch, out + + +if __name__ == '__main__': + rate, ch, pcm = decode(open(sys.argv[1], 'rb').read()) + with wave.open(sys.argv[2], 'wb') as w: + w.setnchannels(ch) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(pcm.tobytes()) diff --git a/deb-build/build.sh b/deb-build/build.sh new file mode 100755 index 0000000..f7c27cb --- /dev/null +++ b/deb-build/build.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Baut ein .deb für "Ein Königreich für ein Lama" aus einer intakten CD-ISO. +# ./build.sh /pfad/zur/GROOVE.iso +# Benötigt: 7z, ffmpeg (mit argo_asf/libmp3lame), wrestool, icotool, convert, +# fakeroot, dpkg-deb +set -euo pipefail +umask 022 + +ISO=$(realpath "${1:?Aufruf: $0 /pfad/zur/cd.iso}") +HERE=$(cd "$(dirname "$0")" && pwd) +PKG=ein-koenigreich-fuer-ein-lama +VER=1.0-1 +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +ROOT="$WORK/root" +OPT="$ROOT/opt/$PKG" + +echo ">> ISO entpacken" +mkdir -p "$WORK/iso" +7z x -y -o"$WORK/iso" "$ISO" >/dev/null +SRC="$WORK/iso/Setup/Eng" +[ -f "$SRC/Hd/groove.exe" ] || { echo "groove.exe nicht gefunden – falsche ISO?" >&2; exit 1; } + +echo ">> Integrität prüfen" +python3 -I - "$SRC" <<'EOF' +import sys, struct, os +src = sys.argv[1] +bad = [] +fe = open(os.path.join(src, 'Cd/wads/FESOUND.WAD'), 'rb').read() +if struct.unpack('> Spieldaten" +mkdir -p "$OPT" +cp -r "$SRC/Cd" "$OPT/cd" +cp -r "$SRC/Hd" "$OPT/hd" +cp "$WORK/iso/GROOVE.HLP" "$OPT/" 2>/dev/null || true +find "$OPT" -type d -exec chmod 755 {} + +find "$OPT" -type f -exec chmod 644 {} + + +echo ">> Startskript, Menüeintrag, Icon" +install -Dm755 "$HERE/files/$PKG" "$ROOT/usr/games/$PKG" +ln -s "$PKG" "$ROOT/usr/games/lama" +install -Dm644 "$HERE/files/$PKG.desktop" "$ROOT/usr/share/applications/$PKG.desktop" +wrestool -x -t 14 "$SRC/Hd/groove.exe" -o "$WORK/icon.ico" +icotool -x -o "$WORK/icon.png" "$WORK/icon.ico" 2>/dev/null +for s in 32 64 128; do + mkdir -p "$ROOT/usr/share/icons/hicolor/${s}x${s}/apps" + convert "$WORK/icon.png" -filter point -resize "${s}x${s}" \ + "$ROOT/usr/share/icons/hicolor/${s}x${s}/apps/$PKG.png" +done + +echo ">> Soundtrack nach MP3" +OST="$ROOT/usr/share/$PKG/soundtrack" +mkdir -p "$OST" +# Nicht FFmpegs adpcm_argo verwenden: der klemmt den Prädiktor auf 16 Bit, +# das Original lässt ihn überlaufen -> sonst massiver DC-Offset / Drift. +# asf2wav.py dekodiert wie asfcodec.adl; Hochpass 10 Hz entfernt den Rest-DC +# (steckt auch im Original), Pegel so, dass das MP3 nicht übersteuert. +mkdir -p "$WORK/wav" +ls "$SRC/Cd/music/"*.asf | xargs -P "$(nproc)" -I{} \ + sh -c 'python3 -I "$0" "$1" "$2/$(basename "$1" .asf).wav"' "$HERE/asf2wav.py" {} "$WORK/wav" +peak() { + ffmpeg -nostdin -v error -i "$1" -c:a pcm_f32le -f f32le - | python3 -I -c \ + 'import sys,array; a=array.array("f",sys.stdin.buffer.read()); print(max(max(a),-min(a)))' +} +n=0 +while IFS= read -r f; do + n=$((n + 1)); b=$(basename "$f" .wav); out="$OST/$(printf %02d "$n") - $b.mp3" + for gain in -2 -4 -6; do + ffmpeg -nostdin -hide_banner -loglevel error -y -i "$f" \ + -af "highpass=f=10:poles=2,volume=${gain}dB" -c:a libmp3lame -q:a 2 \ + -metadata title="$b" -metadata album="Ein Königreich für ein Lama (PC-Spiel)" \ + -metadata artist="Disney Interactive / Argonaut Games" -metadata date=2000 \ + -metadata track="$n" "$out" + python3 -I -c 'import sys; sys.exit(float(sys.argv[1]) > 0.99)' "$(peak "$out")" && break + done +done < <(ls "$WORK/wav/"*.wav | sort -f) +chmod 644 "$OST"/*.mp3 + +echo ">> Doku" +DOC="$ROOT/usr/share/doc/$PKG" +install -Dm644 "$HERE/files/README.Debian" "$DOC/README.Debian" +install -Dm644 "$HERE/files/copyright" "$DOC/copyright" + +echo ">> Paket bauen" +find "$ROOT" -type d -exec chmod 755 {} + +mkdir -p "$ROOT/DEBIAN" +SIZE=$(du -sk --exclude=DEBIAN "$ROOT" | cut -f1) +sed -e "s/@VERSION@/$VER/" -e "s/@SIZE@/$SIZE/" "$HERE/files/control" > "$ROOT/DEBIAN/control" +fakeroot dpkg-deb -Zxz -z3 --build "$ROOT" "$HERE/${PKG}_${VER}_all.deb" +echo ">> Fertig: $HERE/${PKG}_${VER}_all.deb" diff --git a/deb-build/cor2png.py b/deb-build/cor2png.py new file mode 100644 index 0000000..4f69cac --- /dev/null +++ b/deb-build/cor2png.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Entpackt Argonaut-.cor-Bilder (Ein Königreich für ein Lama) nach PNG. + +Aufbau: 8 Byte Signatur 17 98 AF 89 FE 42 D1 12, u32 Version (1), +u32 entpackte Größe, u32 gepackte Größe, dann LZ-Daten: + n < 0x80 -> n Bytes Rohdaten folgen + 1LLL oooo oooooooo -> kopiere L+3 Bytes ab o Bytes weiter vorne +Das Ergebnis sind 640x480 Pixel à 3 Byte (RGB) oder à 1 Byte (Graustufen, z. B. 151.cor). +Aufruf: cor2png.py eingabe.cor ausgabe.png (benötigt Pillow) +""" +import struct +import sys + +MAGIC = bytes.fromhex('1798af89fe42d112') + + +def unpack(data): + if data[:8] != MAGIC: + raise ValueError('keine COR-Datei') + _, size, packed = struct.unpack_from('> 4) & 7) + 3 + off = ((b & 0x0F) << 8) | src[i + 1] + i += 2 + for _ in range(length): + out.append(out[-off]) + return bytes(out), size + + +if __name__ == '__main__': + raw, size = unpack(open(sys.argv[1], 'rb').read()) + print(f'{sys.argv[1]}: {len(raw)} von {size} Bytes') + if len(sys.argv) > 2: + from PIL import Image + mode = {640 * 480 * 3: 'RGB', 640 * 480: 'L'}[size] + Image.frombytes(mode, (640, 480), raw).save(sys.argv[2]) diff --git a/deb-build/files/README.Debian b/deb-build/files/README.Debian new file mode 100644 index 0000000..9bdf019 --- /dev/null +++ b/deb-build/files/README.Debian @@ -0,0 +1,35 @@ +Ein Königreich für ein Lama unter Wine +====================================== + +Start: Menüeintrag "Ein Königreich für ein Lama" oder im Terminal `lama`. + +Installierte Dateien + /opt/ein-koenigreich-fuer-ein-lama/cd Inhalt der CD (Setup/Eng/Cd), wird zu Laufwerk L: + /opt/ein-koenigreich-fuer-ein-lama/hd Programmdateien (groove.exe, Audio-DLLs, Sprache) + /usr/share/ein-koenigreich-fuer-ein-lama/soundtrack Soundtrack als MP3 + +Pro Benutzer (beim ersten Start angelegt) + ~/.wine-lama Wine-Prefix (32 Bit) + ~/.wine-lama/drive_c/Program Files/Disney Interactive/Ein Koenigreich fuer ein Lama + Programmordner, enthält die Spielstände (*.ksv) + +Was der Starter bei jedem Start einrichtet + - Laufwerk L: als CD-ROM -> /opt/.../cd (das Spiel prüft dort auf 111.cor) + - HKLM\Software\Disney Interactive\Emperors New Groove\1.0: CDPath, ScreenW, ScreenH + - Wine-virtueller-Desktop "Lama" in Spielauflösung, damit das Spiel die + echte Bildschirmauflösung nicht umstellt (es kennt keinen Fenstermodus) + - Arbeitsverzeichnis = Programmordner (das Spiel speichert dort; aus einem + schreibgeschützten Verzeichnis gestartet stürzt es ab) + +Fenstergröße + Standard: Breite des Hauptmonitors, Höhe = Arbeitsbereich minus Titelleiste. + Anpassen in ~/.config/ein-koenigreich-fuer-ein-lama.conf, z. B.: + WIDTH=1920 + HEIGHT=1080 + TITLEBAR=28 + +Bekannte Eigenheiten + - "ege bitte die CD ..." – das fehlende "L" ist ein Fehler im Originalspiel. + - DEFANIM.WAD wird gesucht, fehlt aber auch auf der Original-CD; harmlos. + - Zwischensequenzen sind MPEG-1 (.dat); dafür braucht Wine die 32-Bit- + GStreamer-Plugins (gstreamer1.0-plugins-ugly:i386, gstreamer1.0-libav:i386). diff --git a/deb-build/files/control b/deb-build/files/control new file mode 100644 index 0000000..03ae5b5 --- /dev/null +++ b/deb-build/files/control @@ -0,0 +1,19 @@ +Package: ein-koenigreich-fuer-ein-lama +Version: @VERSION@ +Section: games +Priority: optional +Architecture: all +Depends: wine (>= 6.0), wine32:i386, x11-xserver-utils, x11-utils +Recommends: gstreamer1.0-plugins-ugly:i386, gstreamer1.0-libav:i386 +Installed-Size: @SIZE@ +Maintainer: Elias Müller +Description: Ein Königreich für ein Lama (Disney/Argonaut, PC 2000) für Wine + Deutsche PC-Version von Disneys "Ein Königreich für ein Lama" + (The Emperor's New Groove), entwickelt von Argonaut Games. + . + Enthält die vollständigen Spieldaten der CD, einen Starter, der ein + eigenes 32-Bit-Wine-Prefix einrichtet (CD als Laufwerk L:, Registry, + virtueller Desktop passend zur Bildschirmgröße), einen Menüeintrag + sowie den Soundtrack als MP3. + . + Nur für den privaten Gebrauch mit eigener Original-CD. diff --git a/deb-build/files/copyright b/deb-build/files/copyright new file mode 100644 index 0000000..c4a6a03 --- /dev/null +++ b/deb-build/files/copyright @@ -0,0 +1,8 @@ +Ein Königreich für ein Lama / The Emperor's New Groove (PC) +Copyright (C) 2000 Disney Interactive; entwickelt von Argonaut Games PLC. + +Proprietäre Software. Dieses Paket enthält die Daten der Original-CD und ist +ausschließlich für den privaten Gebrauch durch Besitzer der Original-CD +bestimmt. Weitergabe nicht gestattet. + +Startskript und Paketierung: Elias Müller, 2026. diff --git a/deb-build/files/ein-koenigreich-fuer-ein-lama b/deb-build/files/ein-koenigreich-fuer-ein-lama new file mode 100755 index 0000000..0e14c57 --- /dev/null +++ b/deb-build/files/ein-koenigreich-fuer-ein-lama @@ -0,0 +1,99 @@ +#!/bin/sh +# Startet "Ein Königreich für ein Lama" (Disney/Argonaut, 2000) unter Wine. +# +# Beim ersten Start wird ein eigenes 32-Bit-Wine-Prefix angelegt. Bei jedem +# Start werden Laufwerk L: (Spiel-CD), Registry und Fenstergröße gesetzt. +# +# Einstellungen (optional) in ~/.config/ein-koenigreich-fuer-ein-lama.conf: +# WIDTH=1920 # Fensterbreite (Standard: Breite des Hauptmonitors) +# HEIGHT=1080 # Fensterhöhe (Standard: Arbeitsbereich - Titelleiste) +# TITLEBAR=28 # Höhe der Fenster-Titelleiste in Pixeln +# WINEPREFIX=... # anderes Prefix (Standard: ~/.wine-lama) +set -e + +DATA=/opt/ein-koenigreich-fuer-ein-lama +CONF="${XDG_CONFIG_HOME:-$HOME/.config}/ein-koenigreich-fuer-ein-lama.conf" +TITLEBAR=28 +WIDTH= +HEIGHT= +[ -f "$CONF" ] && . "$CONF" + +export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-lama}" +export WINEARCH=win32 +export WINEDEBUG="${WINEDEBUG:--all}" + +GAMEDIR="$WINEPREFIX/drive_c/Program Files/Disney Interactive/Ein Koenigreich fuer ein Lama" +WINGAME='C:\Program Files\Disney Interactive\Ein Koenigreich fuer ein Lama' + +# --- Prefix anlegen -------------------------------------------------------- +if [ ! -f "$WINEPREFIX/system.reg" ]; then + echo "Lege Wine-Prefix in $WINEPREFIX an ..." + wineboot -i >/dev/null 2>&1 + wineserver -w +fi + +# --- Programmdateien (Festplatten-Teil der Installation) ------------------- +# Programmdateien werden verlinkt; Spielstände (*.ksv) werden kopiert, da das +# Spiel sie im Programmverzeichnis überschreibt. +mkdir -p "$GAMEDIR/music" +for f in "$DATA"/hd/* "$DATA"/hd/music/*; do + [ -f "$f" ] || continue + rel="${f#"$DATA"/hd/}" + case "$f" in + *.ksv) [ -e "$GAMEDIR/$rel" ] || { cp "$f" "$GAMEDIR/$rel"; chmod u+w "$GAMEDIR/$rel"; } ;; + *) ln -sfn "$f" "$GAMEDIR/$rel" ;; + esac +done + +# --- Spiel-CD als Laufwerk L: ---------------------------------------------- +# Mit aktiven Mods (lama-mod an) zeigt L: auf das Overlay. +if [ -d "$WINEPREFIX/cd-overlay" ]; then + ln -sfn "$WINEPREFIX/cd-overlay" "$WINEPREFIX/dosdevices/l:" +else + ln -sfn "$DATA/cd" "$WINEPREFIX/dosdevices/l:" +fi + +# --- Fenstergröße bestimmen ------------------------------------------------ +if [ -z "$WIDTH" ] || [ -z "$HEIGHT" ]; then + geo=$(xrandr --current 2>/dev/null | awk ' + / connected/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+x[0-9]+\+/) { + if ($0 ~ / primary /) { print $i; found = 1; exit } + if (!first) first = $i } } + END { if (!found && first) print first }') + mw=${geo%%x*} + mh=${geo#*x}; mh=${mh%%+*} + wa=$(xprop -root _NET_WORKAREA 2>/dev/null | sed -n 's/.*= *//p' | cut -d, -f4 | tr -d ' ') + case "$mw$mh" in ''|*[!0-9]*) mw=1024; mh=768 ;; esac + h=$mh + case "$wa" in ''|*[!0-9]*) ;; *) [ "$wa" -lt "$h" ] && h=$wa ;; esac + h=$((h - TITLEBAR)) + : "${WIDTH:=$mw}" + : "${HEIGHT:=$h}" +fi + +# --- Registry -------------------------------------------------------------- +REG=$(mktemp --suffix=.reg) +trap 'rm -f "$REG"' EXIT +cat > "$REG" </dev/null 2>&1 + +# --- Start ----------------------------------------------------------------- +# Das Spiel legt Spielstände im aktuellen Verzeichnis an. +cd "$GAMEDIR" +wine "$WINGAME\\groove.exe" "$@" diff --git a/deb-build/files/ein-koenigreich-fuer-ein-lama.desktop b/deb-build/files/ein-koenigreich-fuer-ein-lama.desktop new file mode 100644 index 0000000..a4721b0 --- /dev/null +++ b/deb-build/files/ein-koenigreich-fuer-ein-lama.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Ein Königreich für ein Lama +GenericName=Jump'n'Run +Comment=Disneys Ein Königreich für ein Lama (2000) unter Wine +Exec=/usr/games/ein-koenigreich-fuer-ein-lama +Icon=ein-koenigreich-fuer-ein-lama +Terminal=false +Categories=Game;ActionGame; +StartupWMClass=explorer.exe diff --git a/deb-build/files/lama-mod b/deb-build/files/lama-mod new file mode 100755 index 0000000..276e8fa --- /dev/null +++ b/deb-build/files/lama-mod @@ -0,0 +1,97 @@ +#!/bin/sh +# Mods für "Ein Königreich für ein Lama" ein- und ausschalten. +# +# lama-mod an Mods aus ~/.wine-lama/mods/*/ aktivieren +# lama-mod aus Originalzustand (L: = unveränderte CD-Daten) +# lama-mod status +# lama-mod debug an Debug-Ansicht (Kollision/unsichtbare Objekte) zuschalten +# lama-mod debug aus Debug-Ansicht abschalten (andere Mods bleiben, wie sie sind) +# +# Eine Mod ist ein Ordner, dessen Dateien so liegen wie auf der CD unter +# Setup/Eng/Cd (z. B. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT). +# Aktiviert wird über ein Overlay aus Symlinks: Originaldateien bleiben +# unangetastet, Mod-Dateien kommen dazu bzw. ersetzen gleichnamige. +# Mods mit einer Datei ".aus" im Ordner werden übersprungen (so schaltet +# "lama-mod debug an|aus" die Mod "debug" unabhängig von den anderen). +# Reihenfolge: alphabetisch, spätere Mods gewinnen (testlevel > debug). +set -e +export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-lama}" +MODS="$WINEPREFIX/mods" +OVL="$WINEPREFIX/cd-overlay" +DBG="$MODS/debug" + +if [ -d /opt/ein-koenigreich-fuer-ein-lama/cd ]; then + BASE=/opt/ein-koenigreich-fuer-ein-lama/cd +else + BASE="$WINEPREFIX/iso-dk/Setup/Eng/Cd" +fi + +overlay_an() { + rm -rf "$OVL" + mkdir -p "$OVL" + (cd "$BASE" && find . -type d) | while IFS= read -r d; do mkdir -p "$OVL/$d"; done + (cd "$BASE" && find . -type f) | while IFS= read -r f; do ln -s "$BASE/$f" "$OVL/$f"; done + for m in "$MODS"/*/; do + [ -d "$m" ] || continue + if [ -e "$m/.aus" ]; then + echo "Mod: $(basename "$m") (abgeschaltet)" + continue + fi + echo "Mod: $(basename "$m")" + (cd "$m" && find . -path ./src -prune -o -name .aus -prune -o \( -type f -o -type l \) -print) | while IFS= read -r f; do + # vorhandene Datei gleichen Namens (Groß-/Kleinschreibung egal) ersetzen + dir=$(dirname "$f"); name=$(basename "$f") + mkdir -p "$OVL/$dir" + find "$OVL/$dir" -maxdepth 1 -iname "$name" -exec rm -f {} + + ln -s "$m/$f" "$OVL/$f" + [ -n "$LEISE" ] || echo " + $f" + done + done + ln -sfn "$OVL" "$WINEPREFIX/dosdevices/l:" + echo "Mods aktiv (L: -> $OVL)" +} + +case "${1:-status}" in +an|on) + overlay_an + ;; +aus|off) + ln -sfn "$BASE" "$WINEPREFIX/dosdevices/l:" + rm -rf "$OVL" + echo "Originalzustand (L: -> $BASE)" + ;; +debug) + if [ ! -d "$DBG/wads" ]; then + echo "Debug-Mod fehlt ($DBG/wads)." >&2; exit 1 + fi + case "${2:-status}" in + an|on) + rm -f "$DBG/.aus" + LEISE=1 overlay_an + echo "Debug-Ansicht AN ($(ls "$DBG/wads" | wc -l) Level). Wirkt beim nächsten Laden eines Levels." + ;; + aus|off) + touch "$DBG/.aus" + if [ "$(readlink "$WINEPREFIX/dosdevices/l:")" = "$OVL" ]; then + LEISE=1 overlay_an + fi + echo "Debug-Ansicht AUS." + ;; + status) + if [ -e "$DBG/.aus" ]; then echo "Debug-Ansicht: aus"; else echo "Debug-Ansicht: an (wenn Mods aktiv)"; fi + ;; + *) + sed -n '2,9p' "$0"; exit 1 ;; + esac + ;; +status) + echo "L: -> $(readlink "$WINEPREFIX/dosdevices/l:")" + for m in "$MODS"/*/; do + [ -d "$m" ] || continue + if [ -e "$m/.aus" ]; then s=" (abgeschaltet)"; else s=""; fi + echo "installierte Mod: $(basename "$m")$s" + done + ;; +*) + sed -n '2,9p' "$0"; exit 1 ;; +esac diff --git a/deb-build/lama-mod.bak-vor-debug b/deb-build/lama-mod.bak-vor-debug new file mode 100755 index 0000000..610678d --- /dev/null +++ b/deb-build/lama-mod.bak-vor-debug @@ -0,0 +1,55 @@ +#!/bin/sh +# Mods für "Ein Königreich für ein Lama" ein- und ausschalten. +# +# lama-mod an Mods aus ~/.wine-lama/mods/*/ aktivieren +# lama-mod aus Originalzustand (L: = unveränderte CD-Daten) +# lama-mod status +# +# Eine Mod ist ein Ordner, dessen Dateien so liegen wie auf der CD unter +# Setup/Eng/Cd (z. B. wads/T1L9M001.wad, T191.COR, movies/191INTRO.DAT). +# Aktiviert wird über ein Overlay aus Symlinks: Originaldateien bleiben +# unangetastet, Mod-Dateien kommen dazu bzw. ersetzen gleichnamige. +set -e +export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-lama}" +MODS="$WINEPREFIX/mods" +OVL="$WINEPREFIX/cd-overlay" + +if [ -d /opt/ein-koenigreich-fuer-ein-lama/cd ]; then + BASE=/opt/ein-koenigreich-fuer-ein-lama/cd +else + BASE="$WINEPREFIX/iso-dk/Setup/Eng/Cd" +fi + +case "${1:-status}" in +an|on) + rm -rf "$OVL" + mkdir -p "$OVL" + (cd "$BASE" && find . -type d) | while IFS= read -r d; do mkdir -p "$OVL/$d"; done + (cd "$BASE" && find . -type f) | while IFS= read -r f; do ln -s "$BASE/$f" "$OVL/$f"; done + for m in "$MODS"/*/; do + [ -d "$m" ] || continue + echo "Mod: $(basename "$m")" + (cd "$m" && find . -path ./src -prune -o \( -type f -o -type l \) -print) | while IFS= read -r f; do + # vorhandene Datei gleichen Namens (Groß-/Kleinschreibung egal) ersetzen + dir=$(dirname "$f"); name=$(basename "$f") + mkdir -p "$OVL/$dir" + find "$OVL/$dir" -maxdepth 1 -iname "$name" -exec rm -f {} + + ln -s "$m/$f" "$OVL/$f" + echo " + $f" + done + done + ln -sfn "$OVL" "$WINEPREFIX/dosdevices/l:" + echo "Mods aktiv (L: -> $OVL)" + ;; +aus|off) + ln -sfn "$BASE" "$WINEPREFIX/dosdevices/l:" + rm -rf "$OVL" + echo "Originalzustand (L: -> $BASE)" + ;; +status) + echo "L: -> $(readlink "$WINEPREFIX/dosdevices/l:")" + ls -1 "$MODS" 2>/dev/null | sed 's/^/installierte Mod: /' + ;; +*) + sed -n '2,12p' "$0"; exit 1 ;; +esac diff --git a/deb-build/png2cor.py b/deb-build/png2cor.py new file mode 100644 index 0000000..9a5f4ca --- /dev/null +++ b/deb-build/png2cor.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Packt ein Bild als Argonaut-.cor (Gegenstück zu cor2png.py). + +Das Bild wird auf 640x480 RGB gebracht und mit dem LZ-Schema des Spiels +komprimiert: + n < 0x80 -> n Bytes Rohdaten folgen (n = 1..127) + 1LLL oooo oooooooo -> kopiere L+3 Bytes (3..10) ab o Bytes (1..4095) zurück +Aufruf: png2cor.py eingabe.png ausgabe.cor (benötigt Pillow) +""" +import struct +import sys + +MAGIC = bytes.fromhex('1798af89fe42d112') +MAXOFF, MINLEN, MAXLEN = 0x0FFF, 3, 10 + + +def pack(raw): + out = bytearray() + lit = bytearray() + last = {} # 3-Byte-Präfix -> Liste letzter Positionen + + def flush(): + for k in range(0, len(lit), 127): + part = lit[k:k + 127] + out.append(len(part)) + out.extend(part) + lit.clear() + + i, n = 0, len(raw) + while i < n: + best_len, best_off = 0, 0 + if i + MINLEN <= n: + key = raw[i:i + 3] + for p in reversed(last.get(key, ())): + off = i - p + if off > MAXOFF: + break + ln = 3 + while ln < MAXLEN and i + ln < n and raw[p + ln] == raw[i + ln]: + ln += 1 + if ln > best_len: + best_len, best_off = ln, off + if ln == MAXLEN: + break + step = best_len if best_len >= MINLEN else 1 + for j in range(i, min(i + step, n - 2)): + lst = last.setdefault(raw[j:j + 3], []) + lst.append(j) + if len(lst) > 16: + del lst[0] + if best_len >= MINLEN: + flush() + out.append(0x80 | ((best_len - 3) << 4) | (best_off >> 8)) + out.append(best_off & 0xFF) + i += best_len + else: + lit.append(raw[i]) + i += 1 + flush() + return MAGIC + struct.pack(' **Note:** The editor never changes your installed game or your CD. LlamaMod works on a private +> copy of the game data and builds every level from *your own copy* of the game. + +## What a level is made of + +The game stores each level in one `.wad` file. The editor shows its parts as a tree in the +**Outliner** and as properties in the **Inspector**: + +| Part | What it is | +|---|---| +| **Grid and cells** | A level is a rectangular grid of square **cells**. One cell is one grid square in the viewport (4096 game units); the llama is about 0.6 cells tall. Collision is looked up per cell. | +| **Tiles** | The building blocks of the original levels: a *tile model* placed in one cell, turned in steps about the vertical axis. A model can be used by many tiles. | +| **Tile models** | The 3D models of the level's tile set with their faces, textures and collision polygons. | +| **Brushes** | Editor shapes – floors, blocks, platforms, ramps, stairs, walls, round platforms, fences, trees, water, death planes and imported meshes. They have any size and rotation; when the level is built they are cut into 1×1-cell tiles automatically. See [Building geometry](06-geometry.md). | +| **Objects** | Everything with behaviour: the llama (player start), coins, food, checkpoints, the exit door, enemies, villagers, moving platforms, cameras and triggers. The game calls them *strats* (small scripts). See [Objects](09-objects.md). | +| **Waypoints** | Chains of points that belong to objects: patrol paths of enemies, camera paths, respawn points, the two corners of trigger boxes. See [Waypoints and paths](10-waypoints.md). | +| **Zones** | Environment and teleport entries: fog, sky colour, ambient light and music of an area, and the doors that move the player to another place. See [Environment](12-environment.md). | +| **Lights** | Directional and point lights that light the object models. Tiles get their light from baked vertex colours. | +| **Visibility regions** | Which parts of the level are drawn from where – the engine's way to stay fast. See [Visibility regions](13-visibility.md). | +| **Textures** | 256×256 texture pages and the texture rectangles on them, with optional animations. See [Textures](08-textures.md). | +| **Level settings** | Name, author, grid size, death plane height, feature flags and the TestLevel chapter. See [Level settings](14-level-settings.md). | + +## Collision materials + +Every collision polygon has a **material** number that tells the game what happens when the llama +touches it. The important ones: + +| Material | Meaning | +|---|---| +| **0** | normal ground and walls | +| **16** | **exit floor** – the level door (LevelDoor object) ends the level when the llama stands on it in the door's cell | +| **30** | **no floor / death** – the llama falls, loses a life and respawns at the last checkpoint | +| 17, 18 | selective collision (used by a level feature flag) | +| others | special surfaces whose meaning is not known yet | + +Press **C** in the editor to see collision coloured by material. The **Collision** tool paints +materials (see [Building geometry](06-geometry.md)). + +## Coordinates + +Positions are given in **cells**: **x** grows to the east, **y** is the height, **z** is the grid +row (it grows to the south). The status bar shows the position under the mouse pointer and the +grid cell. Angles are in degrees; an object with yaw 0° faces +z. + +> **Engine limit:** Tiles and objects can only turn about the vertical axis. Brushes can turn +> freely – the build converts them into tiles. + +## Base levels + +Object types, their models and scripts, sounds, the sky and the default textures are not +something the editor can create from nothing. Every project therefore has a **base level**: one of +the original levels that provides these things. A new level built on *Village chapter 1* can use +every object that exists in that chapter – coins, checkpoints, villagers, the exit door and so on. +Choose a base whose objects and look you want. The base level is fixed when the project is +created. Object types of other levels can be added later with **Assets → All worlds** +([Importing objects from other worlds](09-objects.md#importing-objects-from-other-worlds)). + +## Worlds, chapters and the TestLevel + +The game has eight worlds – **Village, Jungle at Night, River, Jungle by Day, Mountain, City, +Catacombs, Lab** – each with several chapters. A ninth, hidden world called **TestLevel** has 49 +empty chapter slots. New levels go into one of these TestLevel chapters, so they never replace an +original level: each project gets its own chapter (shown in the New level dialog and in +[Level settings](14-level-settings.md)). In the game they appear on the world-9 map as +"TestLevel – chapter *n*". + +Edited original levels are different: they replace that original chapter while their mod is +switched on in LlamaMod. + +## Projects + +The editor saves **projects** as `.llamalevel` files. A project stores only *your changes* to the +base level plus your own imported meshes and textures – never the game's files. That keeps +projects small (often a few kilobytes) and shareable. When a level is built, the editor reads the +base level from your game copy and applies the changes; building the same project with the same +game always gives the same result. + +LlamaMod lists every project in its **levels** folder as a mod in the **Levels** category. Tick it +and press **Play** to play the level – or press **F5** in the editor. See +[Sharing levels](17-sharing.md). + +## What you need + +* The PC game (CD or ISO image; developed with the German version). +* LlamaMod – see [Installing LlamaMod](02-installing.md). +* A graphics card with **OpenGL 3.3** for the editor's 3D view. +* On Linux, Wine to run the game (LlamaMod sets it up). diff --git a/docs/manual/02-installing.md b/docs/manual/02-installing.md new file mode 100644 index 0000000..883828b --- /dev/null +++ b/docs/manual/02-installing.md @@ -0,0 +1,88 @@ +# Installing LlamaMod and the first start + +LlamaMod runs on **Windows** and **Linux**. It needs your own copy of the game – the CD or an ISO +image of it. The game itself does not have to be installed: LlamaMod copies what it needs into +its own data folder and never changes the original. + +## Download and start + +| System | Package | Start | +|---|---|---| +| Windows | `LlamaMod--full-windows-x64.zip` | Unzip anywhere and start **LlamaMod.exe**. Keep `LlamaMod.exe` and the `runtime` folder together. No installation and no administrator rights are needed. | +| Linux | `LlamaMod--full-linux-x64.tar.gz` | Unpack and start `./LlamaMod`. `./install-desktop-entry.sh` adds a menu entry. Install **Wine** to run the game. | + +Both packages bring their own Java runtime. The editor's 3D view needs a graphics driver with +**OpenGL 3.3** (any graphics card from the last ten years). + +## First start: setting up the game + +On the first start a wizard asks where your game is: + +![The setup wizard](img/app_wizard.jpg) + +1. Choose **ISO file…** for a CD image, or **Folder / CD…** for the inserted CD or a copied + folder. LlamaMod shows how many files it found and how much disk space it needs – or, in red, + what is wrong: the file is not a CD image, the folder does not contain the game, the ISO file + is incomplete (an interrupted download), or the disk is too full. +2. Check the **Data folder**. Game copy, mods, save games and settings are stored there. The + default is `%LOCALAPPDATA%\LlamaMod` on Windows and `~/.local/share/LlamaMod` on Linux; + **Change…** picks another place. +3. Click **Set up**. LlamaMod copies the game data, checks it for damaged files and reports + **All set!** **Cancel** stops the copy; clicking **Set up** again continues where it stopped. + +You can repeat this later with **Settings → Set up again…**. + +## The LlamaMod window + +![LlamaMod's mod list](img/app_mods.jpg) + +* **▶ Play** (top right) starts the game with the selected mods. While the game runs, the button + reads **■ Quit game** – handy when the game hangs or its window is hidden. If the game + crashes, LlamaMod says so and offers to open the log files. +* **Level Editor** (next to Play) opens the level editor in its own window. +* The **Mods** tab lists all mods by category (**Levels**, **Gameplay**, **Visual**, + **Cheats & debug**). Tick the ones you want, set their options (each one is explained next to + it) and click **Apply**. **Restore original** switches all mods off. Mods marked *modifies groove.exe* change the + game program in LlamaMod's private copy (see [antivirus warnings](20-troubleshooting.md)). + All mods and their options are listed in [LlamaMod mods](18-mods.md). +* The **Save games** tab edits the three save slots: unlocked worlds and chapters, coins, gold + coins and collectibles per level, lives, where the world map **Opens at**, **Unlock all + worlds** and **Unlock TestLevel (world 9)**. **Undo changes** throws away unsaved edits. A + backup is made before every change (**Backups** opens the folder); **Import…** reads a `.ksv` + file and starts in the backups folder, so restoring a backup is two clicks. +* The **Settings** tab sets the game resolution (**Automatic** shows the size it will use), + **Run in a window** (Linux: Wine virtual desktop, recommended), the light or dark + **Appearance**, the Wine command, the data folder (**Open folder**, **Logs**), **Set up + again…** (copies the game once more; mods, settings and save games are kept) and **Clear + cache** for the mod cache. + +![The save game editor (dark theme)](img/app_saves.jpg) + +> **Tip:** Every level project in the data folder's `levels` folder appears as a mod in the +> **Levels** category. That is how you install levels from other people – see +> [Sharing levels](17-sharing.md). + +## Starting the editor + +* Click **Level Editor** in LlamaMod's header, or +* start LlamaMod with `--editor`, optionally followed by a project file or the name of an + original level, for example `LlamaMod --editor t1l1m001` (Village chapter 1). + +The editor opens with a start page: **New level**, **Open original level**, **Open project** and +your recent projects. **Help → Getting started** (**Shift+F1**) shows a five-step tour, and this +manual is always one **F1** away. + +![The editor's start page](img/ui_empty.jpg) + +## The data folder + +| Folder | Content | +|---|---| +| `levels/` | your level projects (`.llamalevel`) – each one is a mod | +| `editor/` | editor settings, autosaves, prefabs | +| `library/` | geometry saved to the editor's library (`.llamamesh`) | +| `original/` | the untouched copy of the game from your CD | +| `game/`, `cd-active/` | the copy the game runs from, with the active mods applied | +| `cache/` | built mod files (cleared with **Settings → Clear cache**) | +| `backups/saves/` | save game backups | +| `logs/` | log files (game, mod apply) for troubleshooting | diff --git a/docs/manual/03-editor-window.md b/docs/manual/03-editor-window.md new file mode 100644 index 0000000..e9c848e --- /dev/null +++ b/docs/manual/03-editor-window.md @@ -0,0 +1,170 @@ +# The editor window + +![The editor window with the village](img/ui_select_move.jpg) + +The window has six areas: + +| Area | Content | +|---|---| +| **Menu bar** | File, Edit, View, Level, Geometry, Tools, Help – every command of the editor. The [menu reference](19-shortcuts.md) lists them all. | +| **Toolbar** | New / open original / open / save, undo / redo, the **tools** in groups – Select, Move, Rotate, Scale · Brush, Tiles, Collision · Paint texture · Place objects, Coins, Waypoints, Zones & triggers · Zones, Lights, Vertex colours, Regions – then duplicate / delete / frame, **Build** and **Play**. The editor always starts with the Select tool. | +| **Options bar** | Above the viewport: the active tool with its options, then **Snap**, the move step, the angle step, **World / Local**, the camera view and **Layers**. | +| **Viewport** | The level in 3D. See [Navigating the viewport](04-navigation.md). | +| **Panels** | Docked around the viewport: Outliner, Objects, Environment, History on the left, Inspector and Visibility on the right, Assets, Textures and Problems at the bottom. | +| **Status bar** | Messages, the item under the mouse pointer, the cursor position in cells and the grid cell, counts (tiles · objects · waypoints · selected), the result of the last build (click it to open Problems) and the progress of background work. | + +## The start page + +When no level is open the viewport shows the start page: **New level** (from an empty island, a +flat floor or nothing, on a TestLevel chapter), **Open original level** (Village, Jungle, River, +City … – move, add and remove anything), **Open project** (a saved `.llamalevel` file) and the +list of recent projects. + +## Creating a new level + +**File → New level…** (**Ctrl+N**) opens the **New level** dialog: + +| Field | Meaning | +|---|---| +| **Level name** | The name LlamaMod and the loading picture show. | +| **Author** | Your name (remembered for the next level). | +| **Base level** | The original level that provides object types, textures, sky, fog and music, shown with its loading picture. Default: Village – Chapter 1. See [Base levels](01-introduction.md#base-levels). | +| **TestLevel chapter** | The chapter of world 9 (TestLevel) the level replaces when it is enabled. The next free chapter is suggested. | +| **Start with** | **Empty** (nothing but the base level's sky, fog and music), **Flat floor** (a 16×14 grass floor, player start and an exit) or **Island** (a grass island with trees in the sea, player start and an exit). | + +Every new level starts clean: of the base level it keeps only the **start zone** (its fog, colours +and music), the **directional lights** (the sun that lights the objects), the object types, textures +and sounds. The base level's house-door teleports, lamps (point lights), paths and visibility regions +are not taken over – everything is visible until you set up regions yourself. + +![The New level dialog](img/ui_dialog_new.jpg) + +**File → Open original level…** (**Ctrl+Shift+O**) opens one of the game's own chapters instead – +see [Editing original levels](16-original-levels.md). **File → Open project…** (**Ctrl+O**) opens +a saved `.llamalevel` file. + +## Panels + + +| Panel | Default place | Shown at first | +|---|---|---| +| **Outliner** | left | yes | +| **Inspector** | right | yes | +| **Assets** | bottom | yes | +| **Objects** | left | yes | +| **Textures** | bottom | yes | +| **History** | left | yes | +| **Problems** | bottom | yes | +| **Environment** | left | yes | +| **Visibility** | right | yes | + + +### Outliner + +The Outliner lists everything in the level as a tree: **Level** (level settings, default zone, +visibility regions), **Tiles** (grouped **by model** or **by cell** – choose in the box at the +top), **Objects** (by category and type), **Waypoint chains**, **Zones**, **Lights**, +**Brushes** and **Assets** (tile models, imported meshes, texture pages). The filter field finds +items by name, type or id. + +* Click selects, **Shift+click** and **Ctrl+click** add to the selection, double-click frames + the item in the viewport. +* The eye button on a row shows or hides the item in the viewport. +* The context menu has **Frame in viewport**, **Select similar**, **Hide** / **Show**, + **Lock** / **Unlock**, **Duplicate** and **Delete**. + +### Inspector + +The Inspector shows the properties of the selection. Its parts are explained in +[Selecting and transforming](05-selection.md#the-inspector); tools and plugins add their own +sections (for example **Brush shape**, **Object parameters**, **Environment zone**). Every +section header has a **?** button that opens the matching chapter of this manual. + +### Assets + +The Assets panel at the bottom holds what you can drag into the viewport: + +| Tab | Content | +|---|---| +| **Tile models** | the base level's tile models with thumbnails – drag one into a cell to place a tile ([Building geometry](06-geometry.md)) | +| **All worlds** | every object type of every original level, to import into this level ([Importing objects](09-objects.md#importing-objects-from-other-worlds)) | +| **Objects** | the placeable object types of the base level and the imported ones ([Objects](09-objects.md)) | +| **Textures** | all textures of the level ([Textures](08-textures.md)) | +| **Imported** | the meshes imported into this project ([Importing meshes](07-meshes.md)) | +| **Library** | geometry you saved for reuse in any level ([The library](07-meshes.md#the-library)) | +| **Prefabs** | saved groups of objects ([Prefabs](09-objects.md#prefabs)) | + +### Other panels + +* **Objects** – the object palette with search, categories, favourites and one-click setups + ([Objects](09-objects.md#the-objects-panel)). +* **Environment** – zones and fog, lights and lighting bake ([Environment](12-environment.md)). +* **Visibility** – the visibility region matrix ([Visibility regions](13-visibility.md)). +* **Textures** – the texture manager ([Textures](08-textures.md#texture-manager)). +* **History** – every change of this session; click an entry to go back to that state, click a + later one to go forward again. +* **Problems** – the results of **Check for problems** and **Build**; double-click a message to + select the item it is about ([Building, problems and play-testing](15-build-play.md)). + +## Arranging panels + +* The left and right docks are narrow, so their tabs show only an icon – only the open panel's + tab shows its name. Point at an icon to see which panel it is, click it to switch. +* Drag the split bars between the docks to resize them. +* Right-click a panel's tab and choose **Move to …** to put it into another dock. +* The **×** on a tab closes the panel (in the side docks it is on the open tab); + **View → Panels** opens it again. +* The **?** at the right end of each dock's tab row opens the help for the panel shown there. + +The editor remembers the layout and the split sizes. + +## Layers + +**Layers** in the options bar (and **View → Layers**) switch parts of the viewport display on +and off: tiles, object models, markers of logic objects, collision, invisible collision volumes, +tile swap alternatives, waypoints and path directions, zones, zone targets, zone areas and +trigger radii, camera frusta, lights and light gizmos, visibility regions, brush outlines and the +grid. Layers only change the display – hidden layers are still part of the level. + +## Light and dark theme + +**View → Dark theme** switches between the light and the dark look. The setting is shared with +LlamaMod and also applies to this manual. + +## Preferences + +**Edit → Preferences…** (**Ctrl+,**): + +| Setting | Meaning | +|---|---| +| **Autosave every (seconds)** | While there are unsaved changes the editor writes a recovery copy this often (default 90 s). | +| **Play-test save slot** | The save slot the game uses for play-tests (default 3). It is unlocked up to your level, with the map cursor on it; a backup is made first. | +| **Theme** | Light or Dark (shared with LlamaMod). | + +Mouse scheme, zoom step, orbit speed, the view cube and back faces are set in **View → +Navigation → Viewport preferences…** ([Viewport preferences](04-navigation.md#viewport-preferences)). + +## Saving, autosave and recovery + +* **File → Save** (**Ctrl+S**) writes the project; the default folder is LlamaMod's `levels` + folder, so the level is a LlamaMod mod right away. **Save as…** (**Ctrl+Shift+S**) picks a + new name. A dot "•" in the title bar means unsaved changes. +* Every save also stores a **thumbnail** of the current view; it becomes the level's loading + picture in the game. +* **File → Open recent** lists the last ten projects. +* Autosave keeps unsaved changes in the data folder. If the editor or the computer crashes, the + next start offers **Recover**, **Discard** or **Later**. +* **File → Close level** (**Ctrl+W**) and **Close editor** (**Ctrl+Q**) ask before throwing away + changes. + +## Getting help + +![The manual window with the contents tree and a full-text search](img/manual_search.jpg) + +* **Help → Manual** (**F1**) opens this manual. Pressing **F1** while the mouse pointer is over a + panel, a tool's options, an Inspector section or a dialog opens the chapter about it. +* **Help → Getting started** (**Shift+F1**) is a short illustrated tour. +* **Help → Keyboard shortcuts** (**Ctrl+F1**) and **Help → Shortcut sheet (printable)…** list all + keys; the complete reference is [Keyboard, mouse and menu reference](19-shortcuts.md). +* **Help → Viewport navigation** shows the mouse controls of the active navigation preset. +* Most buttons and menu items have tooltips – hover over them for a second. diff --git a/docs/manual/04-navigation.md b/docs/manual/04-navigation.md new file mode 100644 index 0000000..5c4f6aa --- /dev/null +++ b/docs/manual/04-navigation.md @@ -0,0 +1,129 @@ +# Navigating the viewport + +The viewport shows the level in 3D with its textures, vertex colours and object models. You move +the camera with the mouse, a few keys and the view cube. The right and middle mouse buttons +always belong to the camera, whatever tool is active. If you already use another 3D program, +pick its mouse scheme under [Navigation presets](#navigation-presets). + +![The viewport with the view cube in the top right corner](img/nav_viewcube.jpg) + +## Mouse and keys + +These controls are the same in every preset: + +| Input | What it does | +|---|---| +| **Right mouse button** held, move the mouse | look around (fly mode) | +| **Right mouse button** held + **W A S D** | fly forward, left, back, right | +| **Right mouse button** held + **Q** / **E** | fly down / up | +| **Shift** while flying | fly four times faster | +| **Mouse wheel** while flying | change the fly speed | +| **Mouse wheel** | zoom smoothly towards the point under the cursor | +| **Ctrl+middle** drag or **Alt+right** drag | zoom | +| **Alt+left** drag | orbit | +| **F** | frame the selection | +| **Home** | frame the whole level | +| Double-click | select and frame | + +The middle mouse button depends on the preset: + +| Input | Fusion 360 (default) | Blender | Unity / Unreal | +|---|---|---|---| +| **Middle** drag | pan | orbit | pan | +| **Shift+middle** drag | orbit | pan | pan | +| Double-click **middle** | frame the whole level | – | – | + +* **Orbit** turns around the surface point under the cursor where you start dragging (if there is + none: the selection, then the view centre). A small red-green-blue cross marks that point + while you orbit. +* **Pan** moves the point you grabbed exactly with the cursor. +* **Zoom** changes the distance by a fixed percentage per wheel notch (default 12 %) towards what + is under the cursor, so pointing at the sky or a far cliff does not make huge jumps. + +> **Tip:** Fly mode is the quickest way to get around a big level: hold the right mouse button, +> steer with the mouse, move with W A S D and adjust the speed with the wheel. + +## The view cube + +The cube in the top right corner of the viewport shows which way you are looking. + +* Click a **face** (Top, Front, Left …), an **edge** or a **corner** to look at the level from + there. The camera turns smoothly and keeps its pivot. +* **Drag** on the cube to orbit. +* The small **house** button goes to the **home view**: a view from the front-right-top that shows + the whole level (also **View → Navigation → Home view**). + +**View → Navigation → View cube** hides or shows it. + +## Views + +| Key | View | +|---|---| +| **Numpad 5** | perspective / orthographic | +| **Numpad 7** | top – looking straight down, orthographic | +| **Numpad 1** | front, orthographic | +| **Numpad 3** | side, orthographic | + +The same views are in **View → Camera** and in the view box of the options bar. Orbiting an +orthographic view switches back to perspective. The top view is handy for laying out paths, coin +lines and zones; the 3D view for checking heights and jumps. + +## Navigation presets + +**View → Navigation** chooses the mouse scheme: **Fusion 360** (the default), **Blender** or +**Unity / Unreal** (see the table above). The status bar shows a one-line summary of the chosen +scheme, and **Help → Viewport navigation** shows the complete cheat sheet for it. + +### Viewport preferences + +**View → Navigation → Viewport preferences…** opens the **Viewport & navigation** settings: + +![The viewport preferences with the navigation cheat sheet](img/nav_prefs.jpg) + +| Setting | Meaning | +|---|---| +| **Navigation preset** | Fusion 360, Blender or Unity / Unreal; the table below the settings shows its controls. | +| **Zoom step (%)** | Distance change per wheel notch (2–40 %, default 12 %). | +| **Invert zoom direction** | Wheel away from you zooms out instead of in. | +| **Orbit speed (°/pixel)** | How fast the camera turns while orbiting. | +| **Show the view cube** | The cube in the top right corner. | +| **Show back sides of one-sided faces** | See [Back faces](#back-faces). | +| **Back side opacity** | How strongly back sides are drawn (default 30 %). | + +## Back faces + +Many faces in the game are *one-sided*: the game does not draw them from behind (you can see +through a cliff wall from the inside). The editor shows these back sides **darkened and hatched** +so you can still see and click them. When the mouse is over one, the status bar adds +"(back side)". **View → Show back faces** switches this off; the opacity is a viewport +preference. + +> **Note:** The hatched back sides are invisible in the game. If a wall must be visible from +> both sides, look at it from behind in the editor before you play-test. + +## Looking through a game camera + +**View → Camera → Look through camera (pilot)** (**Ctrl+Shift+L**) puts the viewport into the +selected camera object or camera node; flying then moves that camera. See +[Cameras and triggers](11-cameras-triggers.md#look-through-camera). + +## What the viewport shows + +* The **grid** marks the level's cells; geometry outside it has no collision in the game. +* **Markers** stand for logic objects without a model (cameras, triggers, the exit, checkpoints' + respawn points). +* **Collision** (**C**) colours every collision polygon by its material: normal ground, exit floor + (16), death floor (30) and others. Invisible walls and blocks are drawn translucent red. +* **Waypoints** of the selected objects are drawn as connected points; **View → Show waypoints + of all objects** shows every chain. +* With **View → Fog preview** the viewport shows the game's fog for the zone the camera is in; + everything beyond the fog is drawn as a pale "ghost" so you do not lose track of it. + **View → Vertex colours only** (**Shift+V**) hides the textures to judge the lighting + ([Environment](12-environment.md#view-options)). + +Use **Layers** in the options bar to switch any of these on or off +([Layers](03-editor-window.md#layers)). + +> **Note:** The viewport does not animate textures and shows the level with the editor's own +> lighting of object models. What you see is very close to the game, but always play-test +> ([F5](15-build-play.md)) before you share a level. diff --git a/docs/manual/05-selection.md b/docs/manual/05-selection.md new file mode 100644 index 0000000..56b3479 --- /dev/null +++ b/docs/manual/05-selection.md @@ -0,0 +1,122 @@ +# Selecting and transforming + +Almost every edit starts with a selection: click something, then move it with the gizmo, change +it in the Inspector or use a command from the menus. + +## Selecting + +| Input | Result | +|---|---| +| Click | select the item under the mouse pointer | +| **Shift+click** | add to the selection | +| **Ctrl+click** | toggle (add or remove) | +| Drag in empty space | box selection (with **Shift** it adds, with **Ctrl** it removes) | +| Double-click | select and frame | +| Click in empty space | clear the selection | +| **Ctrl+A** / **Ctrl+Shift+A** | select all visible items / select none | +| **Shift+S** | **Select similar**: all objects of the same type, or all tiles with the same model | + +The **Select** tool (**Q**) only selects. The transform tools below select the same way and +additionally show a gizmo. You can also select in the [Outliner](03-editor-window.md#outliner), +which reaches items that are hard to click (hidden, locked or far away). + +![A box selection in the village](img/ui_marquee.jpg) + +## Hiding and locking + +* **H** hides the selection in the viewport, **Alt+H** shows everything again. +* **L** locks the selection: locked items are drawn but cannot be clicked in the viewport – + useful for a floor you keep hitting by accident. Unlock them from the Outliner's context menu. + +Hiding and locking only affect the editor. Hidden items are still part of the level and the +game shows them. + +To leave something out of the built level without deleting it: + +* **Tiles:** tick **Exclude from build** (group *Build*) in the Inspector. The tile stays in the + project and in the viewport; the Outliner shows it in italics with *not built*. +* **Brushes:** untick **Enabled** in the Inspector. + +## Move, rotate and scale + +| Tool | Key | Use | +|---|---|---| +| **Move** | **W** | drag the gizmo's arrows (one axis) or squares (a plane); or drag an object straight over the ground | +| **Rotate** | **E** | the green ring turns everything about the vertical axis; the red and blue rings turn **brushes** freely in 3D | +| **Scale** | **R** | brushes only: drag an axis or the centre; the brush changes its own sizes (width, depth, height, radius …) | + +![Moving an object with the gizmo](img/ui_moved.jpg) + +* **X** switches the gizmo between **World** axes and the item's own **Local** axes (also the + **World / Local** button in the options bar). +* Several selected items move and turn together, about the centre of the selection. +* While you drag, the status bar shows how far (in cells) or by how many degrees the selection + has moved, and the snap step in use. +* A drag is one undo step. **Esc** during a drag cancels it. +* Waypoints move along when you move their object; a checkpoint's respawn point turns with it. + +> **Engine limit:** Tiles and objects only turn about the vertical axis, and only brushes can be +> scaled. The status bar tells you when part of the selection cannot follow. + +Brushes also have handles for resizing, quarter turns and mirroring – see +[Building geometry](06-geometry.md#rotating-scaling-and-mirroring). **Geometry → Align** and +**Array duplicate…** arrange several items. + +## Snapping + +**Snap** in the options bar (**G**) snaps moves to the **move step** and rotations to the +**angle step**: + +| Setting | Values | +|---|---| +| Move step | 1, 0.5, 0.25, 0.125 or 1/16 cell | +| Angle step | 90°, 45°, 15°, 5° or 1° | + +Hold **Ctrl** while dragging to invert snapping for that drag. **Geometry → Grid & snapping…** +sets any step you like and switches the grid display ([Grid and snapping](06-geometry.md#grid-and-snapping)). + +## The Inspector + +The Inspector shows what is selected and lets you change it. + +![The Inspector with a selected object](img/obj_exit_inspector.jpg) + +* The **header** names the selection (kind, name, id) and has a button to frame it. With several + items of one kind selected you edit their **common properties**; values that differ show "—". + Items of different kinds have no common properties. +* **Sections** of tools and plugins come first – for example **Brush shape**, **Object + parameters**, **Waypoint chain**, **Environment zone**, **Light**, **Tile**. The section of the + active tool (for example **Brush tool**) is at the very top. +* Below them are the **property groups** (Transform, Position, …). Click a group header to fold + it; the editor remembers which groups you folded. The group **Raw** (data whose meaning is not + known) and, for objects, **Type details** (the engine data of the object type, such as its code + offset and slots) are folded at first. +* The **?** button on every section header opens the matching chapter of this manual. + +### Editing values + +* **Number fields** accept small calculations such as `1.5`, `2+0.25` or `-3*2`. Press **Enter** + to apply. +* **Drag a number's label** left or right to change the value (**Shift** = fine, **Ctrl** = + coarse). The whole drag is one undo step. +* Positions are in cells, angles in degrees, colours open a colour picker. +* **References** to other items (an object's waypoint chain, a tile's model) have a button to + select the referenced item and one to choose another from a list. +* For objects, **Object type** changes what the object is – the type fields and parameters of the + new type are taken from the base level. + +## Duplicating, copying and deleting + +| Command | Key | Notes | +|---|---|---| +| **Duplicate** | **Ctrl+D** | a copy one cell east; objects keep their waypoint chains | +| **Copy** / **Cut** / **Paste** | **Ctrl+C** / **Ctrl+X** / **Ctrl+V** | within the editor window; each paste is offset a little | +| **Copy objects (for other levels)** | **Ctrl+Shift+C** | objects with waypoints and brushes to the system clipboard; **Ctrl+Shift+V** pastes them into any level ([Objects](09-objects.md#copy-objects-between-levels)) | +| **Delete** | **Delete** | removes the selection; deleting waypoints relinks the chain | +| **Array duplicate…** | **Ctrl+Shift+D** | many copies with an offset and turn each ([Array duplicate](06-geometry.md#array-duplicate)) | + +## Undo and history + +Every change can be undone: **Ctrl+Z** undoes, **Ctrl+Y** (or **Ctrl+Shift+Z**) redoes. The +**Edit** menu names the step that will be undone. **Edit → History** opens the **History** +panel with all steps of the session – click an entry to jump back to that state. diff --git a/docs/manual/06-geometry.md b/docs/manual/06-geometry.md new file mode 100644 index 0000000..716705d --- /dev/null +++ b/docs/manual/06-geometry.md @@ -0,0 +1,408 @@ +# Building geometry + +Everything the llama walks on, bumps into or falls through is *geometry*. In the editor you build +it in two ways: + +* **Brushes** – editor shapes such as blocks, platforms, ramps, stairs, walls, water and death + planes. You draw them with the **Brush** tool, resize them with handles and turn them freely. + When the level is built, every brush is cut into the engine's tiles automatically. +* **Tiles** – the placed models of the base level's tile set, exactly as the original levels are + made. The **Tiles** tool paints them onto grid cells. + +Collision is part of both: the **Collision** tool shows and changes what the llama can stand on, +where it dies and where the level exit fires. + +![Drawing a ramp with the Brush tool](img/geo_draw.jpg) + +## How the engine stores geometry + +A few engine facts explain most of what follows: + +* The level is a grid of **cells** (1 cell = one grid square, 4096 game units). The llama is + about 0.6 cells tall. +* Geometry is made of **tiles**: a tile is a model placed in one cell, turned about the vertical + axis. A tile cannot be scaled or mirrored and its collision must stay close to its cell. +* A tile model may have at most **8000 vertices**. +* Every collision polygon carries a **material** number. 0 is normal ground, **16** is the exit + floor the level door needs, **30** is "no floor" (the llama falls and loses a life). +* Collision is looked up per cell, so geometry outside the level grid has no effect. + +Brushes hide most of this: they can have any size, position and rotation, and the build splits +them into 1×1-cell tiles with matching collision for you. + +## Brush tool (B) + +Start it with the toolbar button, the key **B** or **Geometry → Brush tool**. The options bar +shows one button per primitive plus **Height**, **Drag height** and **Work plane**; the +Inspector shows the same primitives with names (section **Draw**) and the options of the chosen +primitive below them. + +### Drawing a brush + +1. Choose a primitive. +2. Drag in the viewport. Most primitives start with a **footprint**: press on the ground (or on + top of other geometry), drag the rectangle and release. +3. For primitives with a height, now move the mouse **up or down** – the preview grows – and + **click** to confirm. **Enter** uses the value of the **Height** field instead, **Esc** + cancels. + +The new brush is selected and shows its handles. The status bar shows the size while you draw. + +| Option (options bar) | Meaning | +|---|---| +| **Height** | height used for **Enter** and for a click without dragging | +| **Drag height** | after the footprint, the mouse movement sets the height; off = the **Height** field is used directly | +| **Work plane** + value | draw on a horizontal plane at this height instead of on the surface under the cursor; the grid moves to that height | + +Footprints snap to the move step of the snapping settings; hold **Ctrl** while dragging to +toggle snapping for this drag. **Shift+click** selects an existing brush without drawing. + +> **Tip:** Drawing on the *surface under the cursor* is the fastest way to stack things: start a +> drag on top of a block and the new brush stands on it. + +### Primitives + +| Primitive | How to draw | Notes | +|---|---|---| +| **Block** | footprint, then height | solid box with top, sides and optional bottom | +| **Platform** | footprint, then the height of the top | floating slab with an edge row, bottom and **Thickness** | +| **Floor** | footprint only | flat floor, top only | +| **Ramp** | footprint, then the height of the high end | rises in the main drag direction | +| **Stairs** | footprint, then the top height | rises in the drag direction; **Steps**, **Walkable (invisible slope)** | +| **Pillar** | click for the default size or drag its size, then height | square column | +| **Round** | drag the diameter, then height | cylinder: round platform or column, **Segments** 3–64 | +| **Wall** | drag along the wall in any direction, then height | a turned block when diagonal; snapped to the angle step; **Thickness** | +| **Fence** | click the corner points, double-click or **Enter** finishes | posts and rails along a polyline | +| **Tree** | click | block tree: trunk and two crown cubes | +| **Water** | footprint only | water surface without collision, with a death plane just above it | +| **Death plane** | footprint only | invisible no-floor plane (material 30) – falling onto it kills | +| **Invisible wall** | drag along its length, then height | collision only, nothing is drawn in the game | +| **Invisible block** | footprint, then height | invisible collision volume: floor, ceiling or blocker | + +> **Note:** Stairs need **Walkable (invisible slope)**: the engine has no step-up logic, so the +> llama walks up an invisible slope that lies over the steps. Stairs start at the floor in front +> of the first step (*Start height (floor in front)*). + +### Primitive options + +The options section in the Inspector changes with the primitive. Textures are chosen with a +swatch button: click it for a searchable grid of all textures of the level (names are the +village texture names; `#n` is texture number *n* of the base level, see +[Textures](08-textures.md)). **none** leaves that part out. + +| Option | Primitives | Meaning | +|---|---|---| +| **Top** / **Texture** | all textured ones | texture of the top (Floor and Water: the only texture) | +| **Sides** | Block, Platform, Ramp, Stairs, Pillar, Round, Wall | side texture | +| **Top edge** | same | texture of the top row of the sides (e.g. a grass edge), or none | +| **Bottom** | same | bottom texture, or none (no bottom faces) | +| **Thickness** | Platform, Wall, Invisible wall | slab or wall thickness in cells | +| **Steps** | Stairs | number of steps (1–64) | +| **Walkable (invisible slope)** | Stairs | adds the invisible slope (see above) | +| **Segments** | Round | number of sides of the cylinder | +| **Collision** | Block, Round, Wall, Floor, Ramp | off = decoration only, the llama walks through | +| **Solid sides** | Block, Round, Wall | off = the sides have no collision, so the llama can walk up onto low plinths | +| **Material** | Block, Platform, Round, Wall, Floor, invisible ones | collision material of the brush | + +### Handles: resizing + +A selected brush shows small cubes on its faces – **orange** for the sides, **green** for top and +bottom. Drag one to resize: the opposite face stays where it is, also for turned brushes. The +status bar shows the new size. + +![A selected brush with its resize handles](img/geo_handles.jpg) + +### Keys while the Brush tool is active + +| Key | Action | +|---|---| +| **[** / **]** | turn the selection by 90° left / right | +| **Page Up** / **Page Down** | raise / lower the selection by the snap step | +| **Enter** | use the **Height** field (height step) or finish a fence | +| **Esc** | cancel the current drawing | +| **Ctrl** (held) | toggle snapping for this drag | +| **Shift+click** | select instead of drawing | + +## Brush properties in the Inspector + +Selecting a single brush shows two Inspector sections: **Brush shape** (below) and the brush's +own parameters (all of them, as numbers and texture fields). Every parameter can be typed in; +number fields accept arithmetic such as `2+0.5`. + +Common parameters: + +| Parameter | Meaning | +|---|---| +| **X0 / Z0 / X1 / Z1** | footprint corners in cells (x east, z = grid row, growing south) | +| **Bottom / Top**, **Height** | heights in cells | +| **Top texture**, **Side texture**, **Top row of the sides**, **Bottom texture** | textures (name, `#n`, a flat colour name or `rgbRRGGBB`) | +| **Sides (n, e, s, w)** | which sides of a block exist | +| **Collision**, **Side collision**, **Collision material** | collision of the whole brush | +| **Casts shadows** | the block shadows other geometry when lighting is baked | +| **Texture rotation (0-3, −1 random)** | turns the texture on every face; −1 picks a random turn per tile | +| **Tile size** | size of the generated tiles (floors, ramps, water) | +| **Tint (r, g, b)** | colour multiplier for the brush's vertex colours | +| **Rises towards** | direction of ramps and stairs: `+x`, `-x`, `+z`, `-z` | +| **Base height** | ramps and stairs: how far the closed sides reach down (empty = 0.5 below) | +| **Post size**, **Post spacing**, **Post texture**, **Rail texture** | fences | +| **Trunk height**, **Crown size**, **Trunk size (0 = auto)** | trees | + +### Brush shape section + +![The Brush shape section after turning a ramp with the blue ring](img/geo_rotate3d.jpg) + +* The first line shows the kind and size, e.g. **Ramp · 6.25 × 2 × 3.5 cells**. +* **Rotation°** – three fields for the rotation about x (east), y (up) and z (south) in degrees, + applied in the order x·y·z. The bottom centre of the brush stays in place. +* **Left 90°** / **Right 90°** – quarter turns about the vertical axis (**[** / **]**). +* **Mirror X** / **Mirror Z** – mirror east ↔ west or north ↔ south. +* **Visible (off = invisible collision volume)** – hides the brush in the game but keeps its + collision. This is how invisible walls and blocks are made. +* **Faces (collision and material)** – a table with one row per face: **top**, **bottom**, + **north**, **east**, **south**, **west**. **Collision** switches the face's collision on or + off, **Material** gives it its own collision material (for example only the top face 16). + +Water and death planes are laid out per grid cell: they can be resized and moved but only turned +by 90°. A collider (a free invisible polygon) is edited through its corner list. + +> **Note:** Brushes are baked into tiles when the level is built, so any rotation and scale works. +> Tiles placed with the Tiles tool cannot do that – see [Tiles tool](#tiles-tool). + +## Rotating, scaling and mirroring + +The general transform tools are described in [Selection and transforming](05-selection.md). +On brushes they can do more than on tiles and objects: + +* **Rotate (E)**: the green ring turns everything about the vertical axis. The **red** and + **blue** rings turn **brushes freely in 3D** (imported meshes about their origin). Tiles and + objects only turn about the vertical axis in the engine; the status bar says so and leaves + them alone. +* **Scale (R)** on a brush changes the primitive's own sizes – width, depth, height, thickness, + radius, crown … ; meshes stretch per axis. The bottom centre stays. +* **Quarter turns and mirrors** of unturned brushes change their parameters instead of adding a + rotation, so textures stay aligned to the grid and ramp directions, sides and per-face settings + follow. + +**Geometry** menu commands for the selection: + +| Command | Key | What it does | +|---|---|---| +| **Turn left 90°** / **Turn right 90°** | **Ctrl+[** / **Ctrl+]** | quarter turn about the vertical axis | +| **Mirror X (east ↔ west)** / **Mirror Z (north ↔ south)** | | mirror about the centre of the selection; tiles and objects are moved and turned (tile models themselves cannot be mirrored) | +| **Array duplicate…** | **Ctrl+Shift+D** | see below | +| **Align ▸** | | see below | + +### Array duplicate + +**Geometry → Array duplicate…** (**Ctrl+Shift+D**) makes several copies in one step – a row of +platforms, a staircase of blocks, a ring of pillars. + +| Field | Meaning | +|---|---| +| **Copies** | number of new copies | +| **Offset X (cells)**, **Offset Y**, **Offset Z** | offset of each copy from the previous one | +| **Turn per copy (°)** | each copy is turned once more than the previous one | +| **Turn about the selection centre (circular array)** | turn around the centre of the selection instead of each item's own position: arranges copies on a circle | + +Objects in the selection get a copy of their waypoint chain. The whole array is one undo step. + +### Align and distribute + +**Geometry → Align** lines up the selection with the **last selected** item (select two or more): + +* **Align West (min X)**, **Align Centre X**, **Align East (max X)** +* **Align Bottom**, **Align Middle Y**, **Align Top** +* **Align North (min Z)**, **Align Centre Z**, **Align South (max Z)** +* **Distribute along X** / **Distribute along Z** – spaces three or more items evenly between the + outermost two. + +![The Geometry menu with the Align submenu](img/geo_menu.jpg) + +### Grid and snapping + +The options bar holds the quick settings: **Snap** (**G**), the move step (1 … 1/16 cell) and +the angle step (90° … 1°). **Geometry → Grid & snapping…** sets any value: + +| Field | Meaning | +|---|---| +| **Snapping on** | same as **G**; hold **Ctrl** while dragging to invert | +| **Move step (cells)** | any step from 1/64 to 16 cells | +| **Angle step (°)** | any angle step | +| **Scale step** | step of the Scale tool | +| **Show the grid** | grid display on or off | + +The engine places tiles on whole cells; brushes and objects can sit anywhere. + +## Tiles tool + +The **Tiles** tool paints tile models of the base level onto grid cells – the way the original +levels are built. Start it with **T**, from the toolbar or **Geometry → Tiles tool**. + +![The Tiles tool with its palette in the village](img/geo_tiles.jpg) + +### Tile palette + +The Inspector shows the **Tile palette**: every tile model of the level with a thumbnail, its +vertex count and how many tiles use it. **Filter (model number)** narrows the list, **Only models +used in the level** hides unused ones. Click a model to make it the current one, or +**Shift+click** a tile in the viewport to pick it. + +### Modes and options + +| Mode | What a click (or drag) does | +|---|---| +| **Paint** | puts the palette model into the cell under the cursor | +| **Pick** | takes the model, height and rotation of the clicked tile (also **Shift+click** in any mode) | +| **Erase** | deletes the clicked tiles | +| **Raise / lower** | raises a tile by the height step, **Shift+click** lowers | +| **Swap model** | replaces the model of the clicked tile with the palette model | + +| Option | Meaning | +|---|---| +| **On surface** | paint at the height of the tile or ground under the cursor; off = the fixed **Height** | +| **Height** | height of painted tiles (**Page Up** / **Page Down**) | +| **Rotation** | 0°, 90°, 180° or 270° (**[** / **]**) | +| **Step** | height step for raising, lowering and Page Up/Down | + +Painting onto a tile at the same height **swaps its model** instead of stacking a second tile. +New tiles copy the vertex colours of an existing tile with the same model. + +> **Tip:** Prefer swapping, moving or hiding original tiles over erasing them. Object scripts can +> address tiles by their number, and erasing a tile shifts the numbers of all later tiles. The +> status bar warns you when you erase an original tile. + +### Tile section in the Inspector + +Selecting tiles shows the **Tile** section: + +* **Left 90°** / **Right 90°**, **Up** / **Down** (by the snap step). +* **Use palette model** – gives the selected tiles the Tiles tool's palette model. +* A reminder that tiles only turn about the vertical axis and cannot be scaled or mirrored (the + level stores one position and one angle per tile). For free shapes use brushes. + +### Tile swaps (doors and bridges) + +Some levels let object scripts switch a tile to another model – an opening door, a bridge that +appears. This is a **tile swap**. The Tile section shows the swap of the selected tile: + +* **Swap entry in cell …** with the current and the alternative model, +* **Alternative = palette** – sets the alternative model from the Tiles tool's palette, +* **Remove swap** – deletes the swap entry, +* **Add swap (door / bridge)** – gives the tile an alternative model. + +The layer **Tile swap alternatives** (View → Layers) draws alternative models in cyan. Tile +swaps need a base level with the feature flag WFPC 0x10000; on other bases the section says so. + +## Collision tool + +The **Collision** tool shows and paints collision materials. Start it from the toolbar or +**Geometry → Collision tool**. It switches the collision layer on: every collision polygon is +drawn in the colour of its material. + +![Painting collision materials](img/geo_collision.jpg) + +Hover over geometry: the polygon (on a brush: the whole face) under the cursor is highlighted and +the status bar names it and its material. Then: + +| Mode | Action | +|---|---| +| **Paint material** | click or drag over polygons to give them the current material; **Shift+click** picks the material of a polygon | +| **Toggle collision** | click a brush face to switch its collision on or off | + +The Inspector shows the list **Collision materials**; **Material** in the options bar does the +same, and **Other…** accepts any number from 0 to 255. + +| Material | Name | Effect | +|---|---|---| +| **0** | Normal | ordinary ground and walls | +| **16** | Exit floor (LevelDoor) | the level door only fires while the llama stands on it in the door's cell | +| **30** | No floor / death | the llama falls through and loses a life | +| **17** | Selective: only flagged objects | only affects some objects | +| **18** | Selective: not flagged objects | only affects the other objects | +| 1–15, 20, 26–28, 33, 255 | special, meaning unknown | read by object scripts of the original levels | + +**Shared models**: original tile models are often used by many tiles. Choose **Shared models: +change all tiles** (paint the model, every tile using it changes) or **Shared models: copy for +this tile** (the painted tile gets its own copy of the model first). + +Brushes have one material per face class (top, sides, bottom); tile models are painted per +polygon. Tile collision cannot be switched off – paint it 30 or hide the tile instead. + +The layer **Invisible collision volumes** draws invisible walls, invisible blocks and colliders +translucent red so you can see them while editing. + +## Special floors + +### Exit floor + +The level exit is an object, the **LevelDoor**, but it only fires while the llama stands on +collision material **16** in the door's own cell. The quick setup **Exit** in the Objects panel +places both at once – the door and a 1×1 floor with material 16 (see +[Objects](09-objects.md)). To make an existing surface the exit floor, paint its top face 16 with +the Collision tool or set the face's **Material** in the Brush shape section. + +### Death floors and the death plane + +Material **30** means "no floor": the llama falls through and loses a life. Use it for pits, +lava and the ground under a parkour: + +* the **Death plane** primitive – an invisible plane of material 30 in every cell of its area + that is not completely covered by floor, +* a **Floor** or **Block** with material 30 (a visible deadly floor), +* **Water** – carries its own death plane just above the water surface. + +**Geometry → Death plane height…** moves all death planes at once: death-plane brushes and the +original level's material-30 tiles. It shows how many of each exist; **Add a death plane over the +whole grid** creates one if there is none. The same height is in +[Level settings](14-level-settings.md). + +![In the game: stepping onto a death floor](img/geo_game_death.jpg) + +### Invisible walls and blocks + +**Invisible wall** and **Invisible block** draw boxes that have collision but are not drawn in the +game – to keep the llama on a path or to give a decoration a simple collision. Any brush becomes +invisible with **Visible** off in the Brush shape section. + +![In the game: an invisible wall stops the llama at the edge](img/geo_game_invisible_wall.jpg) + +### Water + +The **Water** primitive draws a water surface without collision (the llama sinks) and, by +default, a death plane just above it. Its parameters: **Water height**, **Texture**, **Death +plane** (`none`, `whole` = under the whole water, `area` = only in **Death plane area**) and +**Tile size**. + +## Geometry checks + +**Level → Check for problems** and every build run these checks (see +[Building and play-testing](15-build-play.md)): + +| Problem | What to do | +|---|---| +| a tile lies outside the grid | move it in or enlarge the grid in Level settings | +| a tile model (also one generated from a brush or mesh) has more than **8000 vertices** | simplify or split the shape | +| two identical tiles overlap exactly (same model, cell, height and rotation) | delete one – it causes flickering and double collision | +| **no floor collision under the player start** | the llama would fall at once – put floor under it | +| the player start is far above the floor | lower it | +| **no exit floor (collision material 16) in the LevelDoor's cell** | add or paint the exit floor | +| a small **gap** (under 0.2 cells) between neighbouring floors at the same height | close it, the llama can get stuck or fall | +| brush warnings | messages of the brush compiler | + +Double-click a problem to select the item. + +![A clean build of a brush level](img/geo_build_ok.jpg) + +## Engine limits + +> **Engine limit:** at most 8000 vertices per tile model. Brushes are split into 1×1-cell tiles, so +> only very dense meshes or many brushes overlapping one cell reach it. + +* Tiles only turn about the vertical axis and cannot be scaled or mirrored; brushes can. +* Collision materials of brushes are per face class (top, bottom, each side), of tile models per + polygon. +* Water and death planes are cell based: they can be moved, resized and turned by 90° only. +* The gap check looks only at unturned block, platform and floor tops. +* Geometry outside the level grid has no collision. + +![In the game: a course built from brushes](img/geo_game_course.jpg) diff --git a/docs/manual/07-meshes.md b/docs/manual/07-meshes.md new file mode 100644 index 0000000..7aab96b --- /dev/null +++ b/docs/manual/07-meshes.md @@ -0,0 +1,192 @@ +# Importing meshes and the library + +Brushes cover blocks, ramps and stairs. For everything else – a bridge, a ruin, a statue – you +can import a 3D model from Blender or any other modelling program. The editor turns it into a +**mesh brush**: when the level is built, the model is cut into the engine's 1×1-cell tiles, gets +collision and lit vertex colours, and its textures are packed onto texture pages. + +The **library** works the other way round: it saves geometry from any level (original tiles, +brushes and meshes, with their textures) so you can insert it into other levels. And **Export +selection as OBJ** takes level geometry to Blender. + +## Supported files + +| Format | What is read | +|---|---| +| **OBJ** (`.obj` + `.mtl`) | triangles and polygons, UVs, materials with diffuse colour (`Kd`) and texture (`map_Kd`), optional vertex colours (`v x y z r g b`) | +| **glTF 2.0** (`.gltf` with external or embedded buffers and images, `.glb`) | node transforms, base colour factor and texture, vertex colours (`COLOR_0`), double-sided materials | + +Textures may be PNG or JPG. The model file and every file it references are **copied into the +project**, so the `.llamalevel` file stays complete when you share it (see +[Sharing levels](17-sharing.md)). + +## Import mesh dialog + +**File → Import mesh (OBJ, glTF)…** (or **Import mesh…** in the **Imported** tab of the Assets +panel) asks for the file and opens the import dialog. The left side is a preview you can orbit by +dragging; the grid squares are 1 cell and the small marker shows the llama's size (0.6 cells). + +![The Import mesh dialog](img/geo_import.jpg) + +**Source** shows the file name, triangle and vertex count, materials and the resulting size in +cells. + +### Orientation and size + +| Option | Meaning | +|---|---| +| **Up axis** | **Y up (glTF, most OBJ)** or **Z up (some CAD exports)** | +| **Unit scale (file units → cells)** | how many cells one unit of the file is | +| **Largest side** … **cells** + **Fit** | sets the unit scale so the largest side becomes this many cells (default 4) | +| **Turn** | 0°, 90°, 180° or 270° about the vertical axis | +| **Mirror (left ↔ right)** | mirrors the model | +| **Stand on the ground (bottom at the click point)** | puts the lowest point of the model on the surface you click | + +### Textures + +| Option | Meaning | +|---|---| +| **Texture size on the page** | 32, 64, **128** or 256 pixels: the size each material texture gets on the level's texture pages | +| **UV precision (steps per texture)** | 4, **8**, 16 or 32: how finely each triangle's texture rectangle is rounded | +| **Double-sided** | faces are drawn from both sides (thin walls, leaves) | +| **Lit (baked vertex colours)** | the model gets lighting like the rest of the level; off = full brightness | + +### Collision + +| Choice | Result | +|---|---| +| **From the mesh (every triangle)** | every triangle becomes a collision polygon – exact, the llama can walk on all surfaces | +| **Simplified: invisible box around it** | an invisible block around the model instead – cheap and smooth, good for statues and props | +| **None (decoration only)** | no collision at all | + +**Material** sets the collision material (0 normal, 16 exit floor, 30 death … see +[Building geometry](06-geometry.md)). + +### Warnings + +Below the options the dialog estimates how the model will be cut: how many grid cells it covers +and how many triangles land in the densest cell. It warns in red when + +* a cell may exceed the engine's **8000 vertices per tile** – simplify the mesh or scale it up, +* the model is **larger than the level grid**. + +### Placement + +* **Click in the viewport to place** – after **Import**, a box follows the cursor; click where + the model should stand. +* **At the view centre** – the model is placed at the point you look at. + +The status bar then says "Placed … – move/turn/scale it with the gizmo tools". + +## The engine's texture rules + +> **Engine limit:** the engine has no free texture coordinates. Every triangle always shows one +> half of a rectangular area of a texture page. + +The importer therefore gives each triangle the rectangle around its UVs (rounded to the **UV +precision**) on a copy of its material's texture, flipped where that fits better. In practice: + +* **Rectangular quads with UVs that fill the texture map exactly** – the ideal case (walls, planks, + floors). +* Arbitrary UV layouts (unwrapped characters, atlases) are approximated and can look blurry or + shifted. +* UVs beyond 0…1 (tiling) are clamped – repeat the geometry instead of the texture. +* Untextured materials use their flat colour; vertex colours tint the lighting. + +Imported textures are packed onto new 256×256 texture pages at the chosen size. Several mesh +imports add several pages; the engine allows at most 127 pages per level (see +[Textures](08-textures.md)). + +## Mesh brushes + +An imported model is listed in the Outliner under **Imported meshes** (the file) and under +**Brushes** (each placed copy, a *mesh brush*). The **Imported** tab of the Assets panel shows all +meshes of the project: drag one into the viewport to place another copy. + +A mesh brush moves, turns and scales with the gizmo tools like any brush, including free 3D +rotation (about its origin) and per-axis stretching with the Scale tool. Its parameters in the +Inspector: + +| Parameter | Meaning | +|---|---| +| **Mesh asset** | the imported file it shows | +| **X / Y / Z**, **Rotation (°)**, **Scale** | placement | +| **Collision**, **Collision material** | collision from the mesh | +| **Double-sided**, **Lit (vertex colours)** | as in the dialog | +| **Texture size on the page (16-256)**, **UV rectangle precision (steps per texture)** | as in the dialog | +| **Tint (r, g, b)** | colour multiplier | + +The **Brush shape** section (rotation fields, quarter turns, mirror, **Visible**, face table) +works as for other brushes. + +### Re-import mesh + +Changed the model in Blender? Select the mesh brush and choose **Geometry → Re-import mesh…**. +The editor remembers the source file and asks **Reload ?** – **Yes** loads the same file +again, **No** lets you choose another file. Up axis, unit scale and the placement of the brush are +kept. + +## The library + +The library stores pieces of geometry as reusable assets, available in **every** level and on +every base level – a village house in a jungle level, a set of platforms you built once. + +![The Library tab in the Assets panel](img/geo_library.jpg) + +### Saving to the library + +1. Select tiles, brushes and/or mesh brushes. +2. Choose **Geometry → Save selection to library…** or **Save selection…** in the **Library** tab. +3. Enter a name (**Save to library**). + +The asset is written to `/library/.llamamesh` (the LlamaMod data folder). It contains +the tile models with their collision and vertex colours and every texture used, as pictures. +Brushes and meshes are stored in their built form (tiles). Death-plane tiles are left out. + +### Inserting from the library + +* Drag an entry from the **Library** tab into the viewport – it is anchored at the cell under the + drop point, or +* double-click it or press **Insert** – it is placed at the view centre. + +Textures with identical pixels already in the level are reused; the others are packed onto new +texture pages (with their blend mode). Identical tile models are reused too. **Delete** removes +the entry from the library (not from levels that already use it). + +![Library pieces inserted into a new level (right) and the same geometry re-imported from OBJ (left)](img/geo_library_insert.jpg) + +> **Note:** Library pieces stay aligned to whole cells, and objects are not part of them – use +> prefabs for objects (see [Objects](09-objects.md)). + +> **Note:** A library file contains pictures of the game's textures. It is meant for your own +> projects; when you share a level, the textures it uses are stored in the project anyway. + +## Export selection as OBJ + +**File → Export selection as OBJ…** writes the selected tiles, brushes and meshes as a Wavefront +OBJ for Blender: + +* `name.obj` + `name.mtl` + one PNG per texture, +* UVs per triangle (the texture-rectangle halves the engine uses), +* vertex colours as `v x y z r g b`, +* with **Also write the collision polygons (…_collision.obj)** a second OBJ with the collision + polygons, grouped by material. + +The coordinates use the importer's convention, so the file can be imported again unchanged: edit +it in Blender, then **File → Import mesh** it back (round trip). + +> **Note:** The exported textures are game data – fine for your own editing, please don't share +> them. + +## Tips for good meshes + +* Model at the right scale: 1 cell is one grid square, the llama is about 0.6 cells tall. Use + **Fit** in the dialog if the file has other units. +* Keep the polygon count low – the original levels use simple shapes. Very dense meshes hit the + 8000-vertex limit per cell. +* Use simple, rectangular UV layouts and one texture per material. +* Choose **Simplified: invisible box** for decorative props the llama should not climb. +* Check the collision with the **Collision** layer (**C**) and play-test from the spot with + **Shift+F5**. + +A step-by-step example is the [Blender tutorial](tutorial-blender.md). diff --git a/docs/manual/08-textures.md b/docs/manual/08-textures.md new file mode 100644 index 0000000..6627830 --- /dev/null +++ b/docs/manual/08-textures.md @@ -0,0 +1,223 @@ +# Textures + +Every level has its own set of textures, stored on **texture pages** of 256×256 pixels. A +*texture* is a rectangle on one of those pages; faces of tiles and brushes refer to a texture by +its **number**. The editor lets you see all of them, import your own pictures, animate them and +paint them onto faces. + +## Texture basics + +* A level has up to **127 pages**. Each page has one *blend class* (opaque, cut-out, + semi-transparent …); every texture on it should have the same class. +* Textures are numbered: `#221` is texture 221 of the level. In brush texture fields you can type + `#221` or a name. The names (`grass`, `stone`, `bark` …) are those of the village textures; + other base levels mostly use numbers. +* Brushes can also use flat colours (colour names, or `rgbRRGGBB`). +* The game shows textures in **15-bit colour** (5 bits per channel) with 1 bit of transparency, + so gradients get coarser and soft edges become hard. +* Texture entries are never deleted, because faces and object models refer to them by number. + An unused texture can be reused by moving its rectangle. + +## Texture manager + +Open it with the **Textures** tab of the bottom dock, **View → Panels → Texture manager** +(**Ctrl+Shift+T**), or the **Window** button for a large separate window. + +![The texture manager](img/tex_manager.jpg) + +### Pages + +The left list shows every page with a thumbnail, its number and its blend class. Right-click a +page for: + +* **Export page as PNG…** – saves the whole page, +* **Replace page from PNG…** – loads a picture into the whole page (scaled to 256×256 and reduced + to the page's blend class). + +### Atlas + +The middle shows the selected page with every texture rectangle: + +| Colour | Meaning | +|---|---| +| white | used texture | +| red | unused texture | +| cyan | animated texture | +| orange | the current texture, with 8 handles | + +* Click a rectangle to make it the current texture (the smallest one under the cursor wins). +* Drag inside the current rectangle to **move** it, drag a handle to **resize** it. Every face + using the texture follows; each drag is one undo step. +* The mouse wheel zooms; tooltips show size and usage. +* Double-click starts the **Paint texture** tool with that texture. + +### Texture table + +The table lists **#**, a preview, **Name** (editable), **Page**, **Rectangle**, **Blend**, +**Faces** (faces of tile models plus brushes using it), **Models** (tile models using it) and +**Anim** (its animation, or "frame" if it is a frame of another texture's animation). + +The **Show** box filters the list: + +| Filter | Lists | +|---|---| +| **All textures** | everything | +| **On this page** | textures of the selected page | +| **Used** | textures on faces, brushes or in animations | +| **Unused** | the others | +| **Animated** | textures with an animation | +| **Imported** | textures you imported | + +**Name or #** searches by name or number. Double-click a row to paint with that texture. + +> **Note:** Object models (characters, pickups) also use textures from this table, but they are not +> counted. "Unused" means *not used by tiles, brushes or animations*. + +### Toolbar + +| Button | Action | +|---|---| +| **Import…** | import PNG/JPG images as new textures (see below) | +| **Replace pixels…** | put a new picture into the selected texture's rectangle | +| **Export PNG…** | save the selected texture as a PNG file | +| **Paint** | start the Paint texture tool with the selected texture | +| **Window** | open the texture manager in its own large window | + +### Texture tab + +Details of the current texture: + +| Field | Meaning | +|---|---| +| **Name** | name shown in the editor, also usable in brush texture fields | +| **X / Y** | top-left corner of the rectangle on the page (pixels) | +| **W × H** | size in pixels – the engine maps the whole rectangle onto a quad | +| **Blend** | blend class (the texture flags); the page's class should match | +| flags | the raw flag value | + +Below it a usage line (faces, models, brushes, animations) and two buttons: + +* **Duplicate** – a new texture entry with the same rectangle, e.g. as the starting point for an + animation frame, +* **Select users** – selects the tiles whose models use the texture. + +## Texture animations + +The **Animation** tab animates the current texture. The game supports two kinds: + +* **Flip-book** – the texture is replaced by a sequence of frame textures in turn (torches, + flags, water in the original levels). +* **Scroll** – the picture moves vertically inside a band of the page (rivers, lava). + +![The Animation tab with a flip-book](img/tex_anim.jpg) + +**Create animation** starts one (a flip-book begins with the texture itself; add frames with +**+**), **Remove animation** removes it. + +| Field | Meaning | +|---|---| +| **Type** | **Flip-book**, **Scroll up** or **Scroll down** | +| **Frame delay** | game frames each flip-book frame stays (0 = change every frame; the game runs at about 30 frames per second – the label shows the resulting frames/s) | +| **Back and forth** | play forwards then backwards instead of looping | +| **Band height** | scroll: height of the page area the picture wraps in (usually the texture height) | +| **Speed (px/frame)** | scroll: pixels per game frame | +| **Top row (v0)** | scroll: first page row of the band (usually the texture's top edge) | +| **Frames** | flip-book frames: **+** adds a texture by number (the current texture is suggested), **−** removes, **◀ ▶** move the selected frame | + +A live preview runs at game speed. The 3D viewport does not animate textures; the preview and the +game do. + +> **Tip:** To make a new flip-book: import all frames as textures, select the first one, **Create +> animation**, then add the others with **+** by their numbers. + +## Importing textures + +**File → Import texture…** or **Import…** in the texture manager opens the **Import textures** +dialog for one or more images (PNG, JPG, BMP, GIF). The two previews show the picture as it is +(**Original**) and as the game will show it (**In the game (15-bit)**). + +![The Import textures dialog](img/tex_import.jpg) + +| Option | Meaning | +|---|---| +| **Size (longest side)** | 16, 32, **64**, 128, 256 or **Original** (up to 256); the aspect ratio is kept, rectangles need not be powers of two | +| **Blend class** | how the texture is drawn (table below); the description appears below the box | +| **Transparent below alpha** | pixels less opaque than this become fully transparent (default 64) | +| **Keep half-transparent pixels** | pixels between the cut and 75 % opacity stay half transparent instead of solid | +| **Transparent colour** | **None**, **Magenta (FF00FF)** or **Top-left pixel**: a colour that becomes transparent, for images without an alpha channel | +| **Dither (smoother gradients in 15-bit colour)** | ordered dithering against colour banding | +| **Pack into free space of existing pages** | use unused areas of pages of the same blend class before adding new pages | +| **1-pixel border (against seams)** | repeats the edge pixels around the texture, so filtering never shows the neighbour's pixels | + +### Blend classes + +| Class | Use for | What it does | +|---|---|---| +| **Opaque** | walls, floors, most things | solid; transparent pixels become black | +| **Cut-out** | leaves, fences, grids | fully transparent pixels, everything else solid | +| **Semi-transparent** | glass, water, smoke | half-transparent pixels blend 50 % | +| **Additive glow** | light, fire, sparkles | adds its colour to what is behind it | +| **Darken (mode 3)** | shadows | darkens what is behind it | + +The editor guesses the class from the image (alpha channel or not). Imported textures are new +texture numbers; the status bar names them. The whole import is one undo step. + +> **Engine limit:** at most 127 texture pages. The **Check** reports more pages as an error. + +### Replacing pixels + +**Edit → Replace texture pixels…** or **Replace pixels…** puts a new picture into the current +texture's rectangle – every face using the texture changes at once. The dialog is the same, with +the size fixed to the texture's rectangle. Useful to repaint an original texture (a sign, a +poster) without touching any faces. + +## Paint texture tool (M) + +The **Paint texture** tool (**M**, toolbar, or double-click a texture in the manager) puts the +current texture onto faces. + +![Painting a texture onto a tile face](img/tex_paint.jpg) + +* **Hover**: the face under the cursor gets a white outline; the status bar shows the model, face + number, current texture and face flags (and "model shared by n tiles"). +* **Click**: gives the face the current texture. The clicked face stays selected (orange) for + the buttons below. +* **Shift+click** picks the texture of a face (eyedropper). **I** or **Edit → Texture + eyedropper** does the same for the next click. + +Options bar: + +| Control | Meaning | +|---|---| +| swatch | the current texture – choose one in the texture manager or pick it from a face | +| eyedropper button | the next click picks a texture (**I**) | +| scope | **Face** (only the clicked triangle), **Same texture in model** (all faces of the model that had the same texture), **Whole model** | +| rotate button | turns the texture on the selected face (cycles its corners; on brushes: the brush's texture rotation) | +| mirror button | mirrors the texture on the selected face | +| half button | shows the other half of the texture rectangle on the selected triangle | +| **Unique** | if the clicked tile shares its model with other tiles, give it its own copy first | + +> **Note:** Original tiles often share one model – a hundred grass tiles may use the same one. +> Without **Unique**, painting one of them changes all of them. With **Unique** only the clicked +> tile changes (it gets its own copy of the model, which adds data to the level). + +**Brushes**: clicking a brush sets the texture of the side you click – the **top** texture when +you click the top, **bottom** for the underside, otherwise the **side** texture (for fences, +trees and pillars the first texture parameter, such as the post or trunk). The rotate button +then turns the texture of the last painted brush. + +> **Engine limit:** each triangle shows one half of its texture rectangle – that is why "other +> half", rotate and mirror exist instead of free UV editing. Object models cannot be painted. + +## Textures in the game + +![An imported texture in the game](img/tex_game.jpg) + +## Limits + +* Texture entries are never deleted; reuse unused ones by moving their rectangle. +* Object models' textures are not counted as "used" and their faces cannot be painted. +* The importer does not change the class of an existing page; it never packs into a page of + another class. +* Imported meshes create their textures automatically (see + [Importing meshes](07-meshes.md)). diff --git a/docs/manual/09-objects.md b/docs/manual/09-objects.md new file mode 100644 index 0000000..1143980 --- /dev/null +++ b/docs/manual/09-objects.md @@ -0,0 +1,438 @@ +# Objects + +Objects are everything in a level that *does* something: the llama itself, coins and fruit, +checkpoints, exits, enemies, villagers, moving platforms, cameras, cut-scene triggers and many +invisible helpers. This chapter shows how to find, place, move, turn and configure them, how to +reuse them in other levels and how the editor checks them. + +![Placing a coin with the Place tool in the village](img/obj_place.jpg) + +## What objects are + +In the game's files an object is called a *strat* (short for strategy). Every object has + +* a **type** (`Coin`, `CheckPointPost`, `LevelDoor`, `CityGuard` …) – the name of its script, +* a **position** and a **rotation** (only the turn about the vertical axis is used by most types), +* a short list of **parameters** – numbers the script reads when the level starts (for example + "snap to floor" for a coin or the target chapter of an exit), +* optionally a **waypoint chain** – a list of points the object uses as a path, a camera + position, a respawn point or the corners of an area (see [Waypoints and paths](10-waypoints.md)). + +The script code and the 3D model of each type are stored inside the level file. The editor +never changes them: it copies them from the base level and only edits position, rotation, +parameters and waypoints. + +## Which objects you can place + +Every level is built on a **base level** (see [Introduction](01-introduction.md)). The object +types of that base level can be placed right away, because their code and model are copied from +it; types of other levels must be imported first (below). The village (Village – Chapter 1, the default base of new levels) has coins, +fruit, checkpoints, exits, villagers, cameras and more; other bases offer other enemies and +gadgets – the river has river logs, the city has guards, and so on. + +> **Note:** If a type is missing in your base level, the Objects panel simply does not list it. A +> setup that needs a missing type (for example an exit on a base without `LevelDoor`) tells you so +> in an error message, such as "this base level has no LevelDoor to copy". + +To use object types from other worlds, import them from **Assets → All worlds** – see +[Importing objects from other worlds](#importing-objects-from-other-worlds). + +## The Objects panel + +The **Objects** panel sits in the left dock next to the Outliner – the tab with the ⊙ icon (open it with +**Tools → Object palette** if you closed it). It has three parts: a search row, the +**Quick setups** buttons and the list of all placeable types. + +| Element | What it does | +|---|---| +| **Search objects…** | Filters the list by type name and by the description of decoded types | +| Category box | **All categories**, **Player & start**, **Exits**, **Checkpoints**, **Collectibles**, **Cameras**, **Triggers & dialogue**, **Forces & currents**, **Other objects** | +| **★** button | Shows only your favourites | +| Quick setups | One-click helpers, see [One-click setups](#one-click-setups) | +| Type list | Every placeable type with a rendered thumbnail, its category, the number of variants and the marker **parameters decoded** | + +### Object list + +* **Click** an entry to make it the current type and switch to the **Place objects** tool + (**P**). Then click in the viewport to place it. +* **Drag** an entry into the viewport to place one object exactly under the mouse pointer. +* **Right-click** an entry to add it to or remove it from your **favourites**. Favourites are + marked with ★, listed first and remembered between sessions. +* Hover an entry for a tooltip with the description of the type. +* **parameters decoded** means the editor knows the meaning of the type's parameters and shows + named fields in the Inspector (see [Object parameters](#object-parameters)). +* **n variants** means the base level contains the type with different settings (different + code entry points). The palette places the first variant; to get another one, duplicate an + existing object of that variant. + +Thumbnails are rendered in the background the first time the panel is shown; until then a +category icon is displayed. + +## Objects in the Assets panel + +The bottom **Assets** panel also has an **Objects** tab with the same types as large tiles, +filtered by category. Drag a type from there into the viewport to place it under the mouse +pointer. The **Prefabs** tab next to it holds your saved groups of objects +(see [Prefabs](#prefabs)). + +![The Objects tab of the Assets panel](img/ui_assets_objects.jpg) + +## Place tool (P) + +The **Place objects** tool (key **P**) puts objects onto surfaces and moves them along the +ground. It is chosen automatically when you click a type in the Objects panel or a quick setup. + +### Placing an object + +1. Click a type in the Objects panel (the options bar shows *Placing: Coin* or similar). +2. Move the mouse over the level. The tool finds the surface under the pointer. +3. Click to place. The new object is selected, and the status bar shows its position. + +With **Snap to floor** on, the object is put onto the *collision* floor under the pointer, not +just onto the visible surface – so it ends up where the llama can actually reach it. With grid +snapping on (**G**), the position snaps to the move step first. + +### Moving and turning objects + +* **Click and drag an object** to move it along the ground. Its height follows the floor when + **Snap to floor** is on. Hold **Ctrl** while dragging to invert grid snapping. +* With **Move waypoints along** on, the object's waypoint chain moves with it (a guard keeps + its patrol path, a camera zone keeps its box). +* The selected object shows a **yellow knob** in front of it. Drag the knob to turn the object; + the angle snaps to the angle step (Ctrl inverts). A checkpoint's respawn node turns around the + post with it. +* Each drag is a single undo step. + +You can of course also use the general **Move (W)** and **Rotate (E)** tools and the +Inspector – see [Selection and transforming](05-selection.md). + +### Place tool options + +| Option | Meaning | +|---|---| +| **Yaw: Camera direction** | New objects face the way the camera looks (default) | +| **Yaw: Facing the camera** | New objects face you – handy for characters and signs | +| **Yaw: 0° (+z)** | New objects face the +z direction (south on the grid) | +| **Yaw: Keep type default** | New objects get the rotation stored for the type in the base level | +| **Snap to floor** | Put new and dragged objects onto the collision floor under the cursor | +| **Move waypoints along** | Dragging an object also moves its waypoint chain | +| **Exit target** | Only in *Exit* mode: world and chapter the new exit leads to | + +The yaw of new objects also snaps to the angle step while grid snapping is on. + +## One-click setups + +Some objects only work together with other things: a checkpoint needs a respawn point, an exit +needs a special floor, a secret needs an area. The **Quick setups** buttons of the Objects panel +create the complete set with one click in the viewport. + +| Button | Creates | +|---|---| +| **Player start** | Moves the player start (or places a Llama with its camera node) | +| **Checkpoint** | Checkpoint post with its respawn node in front | +| **Exit** | LevelDoor on a 1×1 exit floor (material 16) | +| **Secret** | Secret object with a 3×3 box | +| **Coin line** | Starts the Coin tool in line mode, see [Coin tool (O)](#coin-tool-o) | +| **Coin arc** | Starts the Coin tool in arc mode | + +### Player start + +The player start is the **Llama** object. A level has exactly one, so the setup **moves** the +existing Llama to the clicked point (and turns it to the chosen yaw) instead of adding a second +one. If the level has no Llama yet, one is placed together with its waypoint chain. + +The first node of the Llama's chain is the **start position of the camera**: the setup puts it +8 cells behind the llama, slightly lower; the remaining nodes stay on the llama. See +[The start camera](11-cameras-triggers.md#the-start-camera). + +> **Tip:** **Level → Play from camera position** (**Shift+F5**) starts a play-test at another +> point without moving the real start – see [Building and play-testing](15-build-play.md). + +### Checkpoint + +Places a **CheckPointPost** (or **CheckPointBox** if the base has no post) and a respawn node +0.4 cells in front of it, on the floor. When the llama touches the post, the game stores the +node as the respawn point; after losing a life the llama reappears there, facing the node's +direction. Turn the post with its knob and the node turns along. + +![Respawning at a placed checkpoint](img/obj_game_checkpoint.jpg) + +### Exit + +Places a **LevelDoor** in the centre of the clicked grid cell and a 1×1 **Floor** brush with +collision material **16** (exit floor) under it. The door fires only when the llama stands on +material-16 floor *in the door's own grid cell*, which is why both are needed. + +Choose where the exit leads in the options bar (**Exit target**) before you click, or later in +the Inspector: + +* a world (**1 · Village** … **8 · Lab**, **9 · TestLevel**) and a chapter of that world, +* **World finished → world map (7,7,7)** – the end of a world: the game shows the tally and + returns to the world map. + +> **Engine limit:** Do not use *World finished* in a level that runs as a TestLevel chapter +> (world 9): it would unlock a world 10 that does not exist and the map crashes. Pick a chapter +> instead – the Problems panel warns you. + +![An exit door selected in the Inspector with its target level](img/obj_exit_inspector.jpg) + +### Secret area + +Places a **Secret** object with a waypoint box of 3×3 cells around the clicked point and +switches its *Box area* parameter on. When the llama enters the box, the game shows the secret +message and counts the secret. Resize the box with the +[Zones and triggers tool (J)](11-cameras-triggers.md#the-zones-and-triggers-tool-j). + +## Coin tool (O) + +The **Coin line / arc** tool (key **O**, or the **Coin line** / **Coin arc** quick setups) places +many coins at once. A live preview shows the coins while you move the mouse; **Esc** cancels +after the first click. + +* **Line:** click the start point, then the end point. Coins are placed every *Spacing* cells + from the start towards the end. +* **Arc / circle:** click the centre, then a point on the circle (this sets the radius and the + start angle). *Count* coins are spread over *Arc°* degrees; with 360° they form a closed ring. + +| Option | Default | Meaning | +|---|---|---| +| **Line** / **Arc / circle** | Line | Shape | +| **Coin** / **Coin5** | Coin | Coin type (Coin5 is worth five) | +| **Spacing** | 0.75 | Distance between coins of a line (0.25–5 cells) | +| **Count** | 12 | Number of coins on an arc (1–128) | +| **Arc°** | 360 | Angle covered by an arc (10–360°) | +| **Height** | 0.3 | Height of the coins above the floor (0–3 cells) | +| **Follow floor** | on | Every coin is put onto the floor below it, then raised by *Height* | + +Grid snapping applies to both clicks (Ctrl inverts it). All coins of one line or arc are one +undo step. + +![A coin arc around a hill](img/obj_coin_arc.jpg) + +## Object parameters + +Select an object and the Inspector shows the section **Object parameters** above the general +properties. It is shown when one object or several objects *of the same type* are selected; +changes then apply to all of them. + +### Parameter fields + +At the top a short description explains the type. For decoded types it ends with +*(decoded, confidence high/medium/low)*: + +* **high** – the script was read and the behaviour was seen in the game, +* **medium** – the script was read, +* **low** – only the values of the original levels were compared. + +Each parameter is a row with a name, an editor and the raw value in small grey hexadecimal +digits on the right: + +| Parameter kind | Editor | +|---|---| +| On/off | Check box (stored as 0 or 1.0) | +| Number, distance | Number field in cells | +| Radius | Number field in cells; the script squares it before comparing. Drag it in the viewport with the [Zones and triggers tool](11-cameras-triggers.md#radius-triggers) | +| Seconds | Number field in seconds (the game counts 30 frames per second) | +| Count, world, chapter | Whole number | +| Choice | Drop-down with the known modes; an unknown value shows as *other: …* | +| Event bit | Number 1–32 (0 = none): a progress flag shared with other objects | +| Raw | Plain number for values whose meaning is unknown | + +Hover a row for a tooltip with the parameter number (`p0`, `p1` …), its description, the valid +range, the values used in the original levels and the raw value. Values outside the known range +get an orange warning line below the row. + +Types that are **not decoded** show *Parameters of this type are not decoded yet – raw values*, +with one row per parameter (*Parameter 0*, *Parameter 1* …). The tooltips still list the values +the original levels use, which is a good hint what makes sense. + +If an object has a different number of parameters than its type reads, a warning line appears +with the button **Reset to defaults**. It sets the parameters back to those of the type in the +base level. + +### Exit target + +For a **LevelDoor** (and the village's **VillageEndDoor**) the section starts with +**Target level**: a world box and a chapter box, and the line *Leads to: …* that names the +target in words. The options are the same as for the [Exit setup](#exit). + +### Waypoint row + +At the end of the section a row describes the object's waypoint chain – *No waypoints* or +*Waypoints: 4 nodes · patrol path* – with a short explanation of what the nodes mean for this +type, or how many original objects of the type have a chain. Buttons: + +| Button | Action | +|---|---| +| **Create path** / **Edit path** | Switches to the Waypoint tool (**N**) to add and move nodes | +| **Unlink** | The object stops using the chain (the nodes stay in the level) | +| **Delete path** | Deletes the chain's nodes | +| **Link…** | Uses an existing chain: choose its first node from a list | +| **Look through** | Only for cameras: [pilot mode](11-cameras-triggers.md#look-through-camera) | + +Details: [Waypoints and paths](10-waypoints.md). + +### General properties + +Below the plugin sections the Inspector lists the raw properties of every object: + +* **Type** – object type, code offset in the level's script block, number of variables and + slots, type flags and extra memory (read-only information copied from the base level), +* **Transform** – position (x, y, z in cells) and rotation (degrees about x, y and z), +* **Behaviour** – the raw parameter list, the waypoint chain reference and the object flags. + +You rarely need these; the named fields above are easier and safer. + +## Decoded object types + +The editor knows the parameters of these types (the rest show raw values): + +| Type | Parameters | Waypoints | +|---|---|---| +| **Coin** | Snap to floor (drops onto the floor at level start, random spin), Falls (keeps floor collision, rests on moving floors), No shadow | – | +| **Coin5** | Snap to floor, Falls | – | +| **Health**, **HealthFull**, **HealthGenerator**, **Wompy** | Snap to floor, Falls (Health also No shadow) | – | +| **Grapes**, **HealthBanana**, **ChargeBooster** | Snap to floor | – | +| **RubyIdol** | Falls, Snap to floor | – | +| **CoinLine** / **CoinCircle** | Coins (count), Spacing / Radius – spawners that create coins at run time | – | +| **Secret** | Box area | 2 nodes: opposite corners of the secret area | +| **Llama** | Helper, Unused, Camera mode | First node = camera start | +| **CheckPointPost** | Collision radius (originals 0.2) | 1 node: respawn position and facing | +| **LevelDoor** | Tribe, World, Chapter of the next level (7,7,7 = world finished) | – | +| **CameraSide**, **CameraLift**, **CameraSetDistance**, **CameraFocusPoint** | see [Cameras and triggers](11-cameras-triggers.md) | box pairs / – | +| **CutSceneCameraDirectional**, **CutSceneTrigger** | see [Cut scenes](11-cameras-triggers.md#cut-scenes) | camera path | +| **ForcePlayerMovement**, **WaterReboundPoints**, **ObjectDontDropInBox**, **FallOffNoHurt**, **RiverCurrentBox** | see [Force and water objects](11-cameras-triggers.md#force-and-water-objects) | points / box | +| **RubyIdolDoor** | Idols needed, Offset (sign distance) | – | +| **PressurePadDoor** | Pad flags (bit field), Door z, Camera, Camera hold | – | +| **CityGuard** | View range, Chase range, Event bit, Wall collision | Patrol path (wraps to the first node) | +| **JumpingBeetleBlue**, **JumpingBeetle** | Jump time, Wait, Jump height, Pause | Jump targets | +| **Spider** | Range, Speed, Down time, Up time, Start down | Path | +| **Bunny** | Flee range | – | + +> **Tip:** For pickups, leave **Snap to floor** on: the game then drops the coin onto the floor +> when the level starts, so a coin placed slightly too high still ends up in the right place. + +## Duplicating, copying and pasting + +* **Edit → Duplicate** (**Ctrl+D**) copies the selection one cell east. Objects keep a copy of + their waypoint chain, so a duplicated guard gets its own patrol path. +* **Edit → Copy** / **Paste** (**Ctrl+C** / **Ctrl+V**) work inside the open level. +* **Delete** removes objects; waypoint chains used only by the deleted objects are removed too. + +### Copy objects between levels + +**Edit → Copy objects (for other levels)** (**Ctrl+Shift+C**) puts the selected objects – with +their waypoint chains – and selected brushes on the system clipboard, relative to their common +centre. Open another level (or another editor) and choose **Edit → Paste objects at view +centre** (**Ctrl+Shift+V**). + +Object types are matched **by name and variant** in the target level's base. Types that the +target base does not have are skipped, and a message lists them. Code and models are not +copied – for that, see [Importing objects from other worlds](#importing-objects-from-other-worlds). + +## Prefabs + +A prefab is a saved group of objects (with their waypoints) and brushes that you can place again +in any level – a checkpoint with coins around it, a guard with its patrol, a small platform with +a secret. + +* **Save:** select the objects and brushes, then **Tools → Save selection as prefab…** (or the + **Save selection as prefab…** button of the Prefabs tab) and enter a name. The prefab is stored + as `/editor/prefabs/.json` with a thumbnail. +* **Place:** open **Assets → Prefabs**. **Double-click** places the prefab at the view centre, + **drag** places it under the mouse pointer. +* **Delete** removes the selected prefab file (after a confirmation); **Refresh** rereads the + folder. + +The entries show the base level they were made on and how many objects and brushes they hold. +As with copy and paste, object types the target base does not know are skipped. + +## Importing objects from other worlds + +A project can only place the object types of its base level – unless you **import** types from +other levels. The editor knows every object type of every original level (about 900 types from +33 levels) and can copy a type into your level together with everything it needs: its script +code, models, animations, textures, sounds and texts. A village level can then have spiders from +the jungle, swinging blades from the city or a merry-go-round from the mountain. + +![The All worlds tab of the Assets panel](img/xobj_all_worlds_tab.jpg) + +### The All worlds tab + +**Assets → All worlds** lists the object types of all worlds with thumbnails: + +* **Search all worlds…** finds types by name; the boxes filter by **world** (or *Other levels*) + and by **category** (enemies, collectibles & pickups, platforms, doors & mechanisms, + characters, animals, effects …). +* **Importable only** hides types that cannot be imported (player starts, cameras and cut-scene + objects – about 126 of them). +* A **✓** marks types already imported into this level. The tooltip shows the source level, how + many instances the original has and what the type depends on (sizes, sounds, texts). +* The first time, the editor builds this catalogue from your CD (a few seconds, shown in the + status bar); it is cached afterwards. **Level → Rebuild object catalogue** scans again. + +### Importing and placing + +* **Drag** a type into the viewport: it is imported (in the background) and placed at the drop + point. +* **Double-click** imports it and places it at the view centre. +* The context menu has **Place at view centre**, **Import into the project** (import only) and + **Import all of** *level*…. + +Imported types then appear in **Assets → Objects** and the Objects panel like the base level's +own types, and you place more of them as usual. Selected instances show an **Imported object** +section in the Inspector that names the level they come from. + +![An imported object placed by drag and drop, with its Inspector section](img/xobj_drag_drop_inspector.jpg) + +### Import a whole world + +**Level → Import objects of a world…** (**Ctrl+Shift+I**, or **Import world…** in the tab) +imports many types at once: choose a world or a single level, tick the types you want (with +category, source level, size and notes), keep **Skip types the level already has** on and click +**Preview** – the editor merges them in the background and shows exactly what would be added +(code, models, textures, sounds, texts) against the engine limits. **Import** then adds them. + +![Importing all objects of a world, with the size preview](img/xobj_import_world_preview.jpg) + +### Limits and things to know + +| Limit | Value | +|---|---| +| Level memory (code, textures, models, map, texts) | 16 MB – original levels use up to about 7 MB | +| Sound samples | 128 | +| Texture pages | 127 | + +The preview and **Check for problems** report these (errors above a limit, warnings near it). + +* **Imports are permanent.** An import changes the level's code layout, so it clears the undo + history and appears as a fixed "Import …" step. It cannot be removed from the project later – + delete the placed objects instead, or start a new level. +* The project only stores *which* types were imported, never game data, so sharing stays legal + and small; the receiver's editor and LlamaMod repeat the import from their own CD. +* A type whose code already exists in your level (for example the coin) is shared – it keeps + your level's model and textures. +* New instances get the parameters of the first instance in the source level; change them in + **Object parameters**. +* Objects that belong to level geometry (doors in walls, tile swaps, river or roller-coaster + specials) import fine but only work with matching geometry. Waypoint chains are copied + relative to the object. + +## Object checks + +**Level → Check for problems** and every build check the objects. Double-click a message in the +**Problems** panel to select the object. + +| Message | What to do | +|---|---| +| Waypoint chain … is not stored in one piece (error) | Run **Tools → Repair waypoint chains** | +| The level has n player start objects | Delete all Llamas but one | +| … has n parameters, Type reads m | Press **Reset to defaults** in Object parameters | +| Out-of-range value (e.g. a chapter that does not exist) | Correct the field marked orange | +| … needs n waypoint(s) / box nodes come in pairs | Add nodes with the Waypoint tool | +| … has no waypoints; all original … objects have a chain | The type probably needs a chain – create one | +| LevelDoor … without exit floor (collision material 16) | Use the Exit setup or paint material 16 under the door ([Collision](06-geometry.md)) | +| … targets (7,7,7) "world finished" in TestLevel | Choose a chapter as target | +| Respawn node is n cells away (info) | Move the checkpoint's node next to the post | + +![Object problems listed in the Problems panel](img/obj_problems.jpg) diff --git a/docs/manual/10-waypoints.md b/docs/manual/10-waypoints.md new file mode 100644 index 0000000..ccd3765 --- /dev/null +++ b/docs/manual/10-waypoints.md @@ -0,0 +1,164 @@ +# Waypoints and paths + +Many objects need positions besides their own: a guard walks along a patrol path, a camera zone +covers a box, a checkpoint stores where the llama respawns, a cut scene flies its camera along a +route. The game stores all of these as **waypoints** – points linked into **chains**. This +chapter explains how chains work and how to edit them with the **Waypoint paths** tool. + +![A patrol path of a jumping beetle in a new level](img/obj_beetle_path.jpg) + +## What waypoint chains are + +A waypoint (also called a *node*) has a position, a turn (yaw) and a tilt (pitch), plus a few +fields whose meaning is not known yet. Every node can point to a **next** node; following these +links from a first node gives a chain. An object refers to the **first node** of its chain. + +* One object uses at most one chain. +* Several objects may share one chain (the original levels do this in places). +* A chain without an object is allowed but does nothing. + +In the Outliner the chains are listed under **Waypoint chains**. In the viewport nodes are drawn +as small markers joined by lines. + +## Chain roles + +What the nodes mean depends on the object type. The Inspector names the role in the +[waypoint row](09-objects.md#waypoint-row) and in the Waypoint chain section: + +| Role | Used by | Meaning of the nodes | +|---|---|---| +| **patrol path** | CityGuard, JumpingBeetleBlue, Spider … | Points the character walks or jumps to; patrols wrap from the last node back to the first | +| **camera positions** | Llama (first node), cut-scene cameras, CutSceneTrigger | Camera position; node yaw and pitch give the view direction | +| **respawn point** | CheckPointPost | Where the llama reappears, and which way it faces | +| **box area (two opposite corners)** | Secret, CameraSide, CameraLift, ObjectDontDropInBox, FallOffNoHurt … | Two nodes span a rectangle on the ground (x and z only); some types use several pairs | +| **point list** | ForcePlayerMovement, WaterReboundPoints | Target points | +| **waypoint chain** | other types | Meaning unknown – keep the original layout | + +For types that are not decoded, the editor guesses: camera objects get *camera positions*, types +whose originals always have exactly two nodes get *box area*. + +## Showing waypoints + +By default the viewport shows only the chains of the **selected** objects and nodes – a big level +has hundreds of nodes. To see all of them: + +* **View → Show waypoints of all objects**, or +* the **Show all chains** button of the Waypoint tool (it toggles the same setting). + +Two layers (options bar → **Layers**) control the drawing: + +* **Waypoints** – the node markers and connecting lines, +* **Path directions** – arrows along the chain, a dashed segment from the last back to the first + node for patrol paths (they wrap) and a short tick at each node showing its yaw. + +Click a node marker to select the node. Hidden or locked objects' nodes are not pickable. + +## Waypoint tool (N) + +The **Waypoint paths** button of the toolbar or key **N** starts the tool. It edits the chain of +the **selected object**, or the chain the **selected node** belongs to. The options bar reminds +you of the mouse actions: *Click: add node · Ctrl+click: insert before · drag: move · +Backspace/Del: remove*. + +### Adding nodes + +1. Select the object (click it while the tool is active – clicking an object selects it). +2. **Click** on the ground. The new node is added **after the selected node**, or at the end of + the chain if the object itself is selected. If the object has no chain yet, the click creates + one with this first node. +3. Keep clicking to add more nodes; each new node becomes the selected one, so the chain grows in + the order you click. + +**Ctrl+click** inserts the new node **before** the selected node instead (before the first node +if the first one is selected). + +The new node copies the pitch and the unknown raw fields of the last node of the chain. With **Follow +floor** on it sits on the floor under the pointer; grid snapping applies (Ctrl inverts it – note +that Ctrl+click therefore also inverts snapping). + +### Moving and deleting nodes + +* **Drag a node** to move it. With **Follow floor** on it keeps its height above the floor as you + drag over hills and steps. Each drag is one undo step. +* **Backspace** (while the tool is active) or **Delete** / **Edit → Delete** removes the selected + nodes and **relinks** the chain: the previous node now leads to the following one. The next + node becomes selected, so you can delete several in a row. +* Moving the owner object with the Place tool moves the whole chain along (option + *Move waypoints along*). + +### Waypoint tool options + +| Option | Meaning | +|---|---| +| **Follow floor** | New and dragged nodes stay on the collision floor (on by default) | +| **Orient new nodes** | A new node faces away from the previous node, i.e. along the path (on by default) | +| **Reverse** | Reverses the order of the chain; the object then starts at the former last node | +| **Show all chains** | Toggles between the chains of the selection and all chains | + +![Drawing a path for a new object](img/obj_newlevel_path.jpg) + +## Waypoint chain section + +When a single node is selected, the Inspector shows the section **Waypoint chain**: + +* *Node i of n*, the object(s) using the chain and its role – or *not used by an object*, +* **Select object** – selects the owner, +* **Edit path** – switches to the Waypoint tool, +* **◀** / **▶** – select the previous / next node, +* **Delete node** – removes this node and relinks the chain, +* **Look through** – shows the view from this node (camera nodes, see + [Look through camera](11-cameras-triggers.md#look-through-camera)). + +The node's position, yaw, pitch and raw fields are in the general properties below. + +## Linking chains to objects + +The waypoint row of **Object parameters** (see [Objects](09-objects.md#waypoint-row)) connects +chains and objects: + +* **Create path** / **Edit path** – start the Waypoint tool for this object. +* **Unlink** – the object no longer uses its chain; the nodes stay in the level (for example to + give them to another object). +* **Delete path** – removes the chain's nodes. +* **Link…** – opens *Link to a waypoint chain* with all chains of the level, nearest first: + first node id, number of nodes, distance from the object and which objects use it (or + *unused*). Choose one and press OK. + +**Edit → Duplicate** gives the copy its own copy of the chain. Deleting an object also deletes +chains that no other object uses. + +## Chains must be stored in one piece + +The game does not really follow the *next* links when it loads a level: it links node number *i* +to node *i + 1* in the level's node list whenever *next* is set. A chain therefore has to be +stored **contiguously** – all its nodes one after the other. + +The editor keeps chains contiguous when you add, insert or delete nodes. Older projects or unusual +edits can break this; then **Level → Check for problems** reports an error *Waypoint chain at … is +not stored in one piece*, and building is blocked. Run **Tools → Repair waypoint chains** – it +reorders the node list so every chain is in one piece (the status bar says *Waypoint chains repaired*, or *All waypoint chains are stored in one piece* +when nothing had to be done). + +## Example: a patrolling enemy + +1. Click **JumpingBeetleBlue** (or another enemy with a patrol path) in the Objects panel and + place it with the Place tool. +2. Press **N**. The beetle stays selected. +3. Click four points around the area the beetle should cover. Watch the arrows: the dashed + segment closes the loop back to the first node. +4. Press **F5** to try it: the beetle jumps from node to node. + +![The beetle jumping along its path in the game](img/obj_game_patrol.jpg) + +## Waypoint checks + +The Problems panel reports: + +| Message | Meaning | +|---|---| +| Chain not stored in one piece (error) | Run **Tools → Repair waypoint chains** | +| … needs n waypoint(s) (…), has m | The type reads more nodes than the chain has | +| … uses only n waypoint(s), the chain has m (info) | Extra nodes are ignored by the game | +| … box nodes come in pairs | Box zones need an even number of nodes | +| … has no waypoints; all original … objects have a chain | Most likely the type needs one | +| Respawn node is n cells away (info) | A checkpoint's respawn point far from the post | diff --git a/docs/manual/11-cameras-triggers.md b/docs/manual/11-cameras-triggers.md new file mode 100644 index 0000000..c445f4c --- /dev/null +++ b/docs/manual/11-cameras-triggers.md @@ -0,0 +1,200 @@ +# Cameras and triggers + +The game has no free camera: it follows the llama on its own, and special objects in the level +change how it does that – camera zones, focus points and cut scenes. Other invisible objects react +when the llama enters an area or comes close: secrets, cut-scene triggers, force zones. This +chapter shows how to see, set up and tune all of them. + +![A CameraSide zone with its box and the computed game camera](img/obj_camera_zone.jpg) + +## How the game camera works + +During play the camera hangs behind the llama at a certain **distance** and **height** and turns +with it smoothly. Camera objects change these values while the llama is inside their area: + +* a **camera zone** sets the height, distance and often a fixed view direction (yaw), +* a **focus point** turns the camera towards an object when the llama is near, +* a **cut scene** takes over the camera completely and flies it along a path. + +All camera objects are invisible in the game. In the editor they are drawn as markers, their +areas as boxes and their cameras as frusta (pyramids showing the view). + +## The start camera + +The **Llama** (the player start) owns a waypoint chain. Its **first node is the camera's start +position** when the level begins; the game then moves the camera to its normal place behind the +llama. Levels made with the editor put this node 8 cells behind the llama (the +[Player start](09-objects.md#player-start) setup keeps it there when you move the start). + +The Llama's *Camera mode* parameter switches between the normal and an alternative camera +behaviour. + +> **Note:** In the original levels the Llama's chain is often a longer list used by the level's +> scripts (for example the village's fly-by at the start). Edit it with care. + +## Camera zones + +A camera zone is an object with a **box**: two waypoints marking opposite corners on the ground. +While the llama stands inside the box (the game only tests x and z), the zone controls the camera. +Edit the box with the [Zones and triggers tool](#the-zones-and-triggers-tool-j). + +### CameraSide + +The most common camera zone. Its parameters (Inspector → **Object parameters**): + +| Parameter | Meaning | +|---|---| +| **Height** | Camera height above the llama, 0 = keep the current value | +| **Distance** | Horizontal distance behind the llama, 0 = keep | +| **Yaw mode** | **free**; **node yaw** – the camera looks in the direction of the zone's first node; **node yaw ±180°** – that direction or the opposite one, whichever is closer to the current view; mode 3, mode 4 (unknown) | +| **Height limit** | Only active while the llama is below the first node's height | +| **Flag 0x10000** | Sets an object flag (purpose unknown) | +| **Several boxes** | The chain holds several boxes (node pairs 1–2, 3–4 …) | +| **Height offset** | Added to the camera height | +| **Alt. height** / **Alt. distance** | Used instead of Height / Distance in a special game state | +| p5 … p9 | Not decoded (originals use small values) | + +With the **Camera frusta** layer on, a selected CameraSide shows where the game camera will be: +the editor assumes the llama in the centre of the first box (and distance 2, height 1 when the +parameters are 0). + +![Entering the camera zone switches to the high view in the game](img/obj_game_camera_zone.jpg) + +> **Tip:** The easiest way to set up a CameraSide is [Look through camera](#look-through-camera): +> select the zone, press **Ctrl+Shift+L** and fly the viewport to the view you want. Height, +> distance and the node yaw follow. + +### CameraLift + +A camera zone for lifts and object-relative views, with one box. + +| Parameter | Meaning | +|---|---| +| **Yaw source** | **none**, **object yaw** (the camera turns with the zone object) or **player yaw** | +| **Use helper position** | Tests the box against a helper object instead of the llama | +| **Smooth** | Softer camera movement | +| **Height limit** | Only while the llama is below the node's height | +| **Flag 0x10000**, p2 | Not fully understood | + +### CameraSetDistance and CameraFocusPoint + +* **CameraSetDistance** sets several camera values while it is active: *Height* and four raw values + (*Value g82*, *g68*, *g83*, *g73*) whose exact effect is not known yet. +* **CameraFocusPoint** turns the camera towards the object while the llama is within **Radius**. + Further parameters: *Min. height*, *Max. angle* (largest angle between object and view), + *Height limit*, three flags and *Blend* (0–1, how fast the camera turns). The radius can be + dragged in the viewport (see [Radius triggers](#radius-triggers)). + +## Cut scenes + +Cut scenes are made of two kinds of objects: + +* **CutSceneTrigger** starts a scene when the llama comes within its **Radius**. Its chain is the + camera path. Parameters: *Character* (the trigger is a talking character: drops onto the floor), + *Radius*, *Event bit* (the scene is skipped once this progress bit is set – so it plays only + once), *Height check* (the llama must also be above the trigger), *Flags* (1 = face the + llama, 2 = cannot be skipped) and two flags whose purpose is unknown. +* **CutSceneCameraDirectional** is the camera of a scene. Every node of its chain is a camera + position; the node's **yaw** and **pitch** give the view direction. Parameters: *Event bit*, + *Mode*, *Wait*, *Disabled* (removes itself at the start), *End mode*. + +The dialogue, animations and character movements of a scene are part of the object scripts and +cannot be edited – but you can move, re-aim and re-time the camera, move the trigger and change +its radius. + +![Camera frusta of a cut-scene camera path](img/obj_cutscene_frusta.jpg) + +## Camera frusta layer + +The **Camera frusta** layer (options bar → **Layers**, on by default) draws, for the selected +objects: + +* **CameraSide** / **CameraLift** – the computed game camera looking at the zone, +* **cut-scene cameras** and **camera nodes** – a frustum at every node, pointing along the + node's yaw and pitch. + +The game's field of view is approximated with 60°. + +## Look through camera + +**View → Camera → Look through camera (pilot)** (**Ctrl+Shift+L**), or the **Look through** +buttons in the Inspector and the options bar of the Zones and triggers tool, turns the viewport +into the selected camera: + +| Selection | The viewport shows | Flying changes | +|---|---|---| +| A camera node (any waypoint) | The view from the node | Node position, yaw and pitch | +| A cut-scene camera or other camera object | The view from the first node of its chain | That node's position, yaw and pitch | +| A **CameraSide** zone | The game camera of the zone | *Height*, *Distance* and the first node's yaw | +| A **CameraLift** zone | The game camera of the zone | The first node's yaw | + +While pilot mode is on, fly as usual (right mouse button + **W A S D**, **Q / E**) – the camera +object follows the viewport, and the whole pilot session is **one undo step**. Press +**Ctrl+Shift+L** again, or select something else, to end it. The status bar shows *Piloting …* +while it is active. + +![Piloting a camera: the viewport is the camera](img/obj_pilot.jpg) + +> **Note:** The first node of some original cut-scene cameras is a fly-in start point, so looking +> through it may show a wall. + +## The Zones and triggers tool (J) + +The **Zones & triggers** tool (toolbar, key **J**) adds handles to the selected object: + +* **pink corner handles** at the four corners of each box of a box zone – drag a corner to resize + the box; the opposite corner stays, +* a **yellow radius knob** for every radius parameter (CutSceneTrigger, CameraFocusPoint, guards' + view and chase ranges, ForcePlayerMovement, Bunny) – drag it to change the radius; the status + bar shows the value. + +Grid snapping applies (Ctrl inverts it). Clicks elsewhere select as usual. The options bar has a +**Look through camera** button for camera zones. Each drag is one undo step. + +![Dragging the corners of a secret area](img/obj_zone_handles.jpg) + +### Box zones + +These types use one box (two nodes) – or, for CameraSide with *Several boxes*, several pairs: + +| Type | Effect while the llama is inside | +|---|---| +| **Secret** | Shows the secret message and counts the secret (needs *Box area* on) | +| **CameraSide**, **CameraLift** | Camera control, see [Camera zones](#camera-zones) | +| **ObjectDontDropInBox** | Objects (for example fruit from smashed pots) are not dropped here | +| **FallOffNoHurt** | Falling off costs no energy (optional *Game state* condition) | +| **RiverCurrentBox** | River current (not decoded in detail) | + +The box is a rectangle on the ground; the game ignores the height. The editor draws it with an +estimated height of about 1.2 cells above the lower node. + +### Radius triggers + +Radius parameters are distances in cells. The scripts compare the *squared* distance, which the +editor handles for you – just enter or drag the radius. The **Zone areas & trigger radii** layer +draws the circles of the selected objects. + +![The trigger radius of a cut-scene trigger](img/obj_trigger_radius.jpg) + +## Force and water objects + +* **ForcePlayerMovement** pushes or steers the llama towards the points of its chain while it is + within *Radius*: *Active*, *Radius*, *Game state* (only in that state), *Lock controls*, + *Align yaw* (the llama turns to the node's yaw). +* **WaterReboundPoints** (and **WaterReboundPointsTurnsOff**): the points of its chain are where + the llama is thrown back to after falling into water. Parameter *Active*. + +## Zone areas layer + +The **Zone areas & trigger radii** layer draws the boxes of **all** zone objects as outlines and +fills the boxes and radius circles of the **selected** ones. Switch it off in a busy original +level if the outlines get in the way. + +## Known limits + +* The camera preview of CameraSide assumes the llama in the box centre and the default distance + and height when the parameters are 0; the real camera also depends on where the llama enters. +* Zone heights in the viewport are estimates; the game tests x and z only. +* Many camera parameters are only partly understood (*p5 … p9*, *Value g…*). Copying values + from similar zones in the original levels works best. +* Cut-scene content (dialogue, animation) cannot be edited. diff --git a/docs/manual/12-environment.md b/docs/manual/12-environment.md new file mode 100644 index 0000000..5e3d6dd --- /dev/null +++ b/docs/manual/12-environment.md @@ -0,0 +1,251 @@ +# Environment: zones, fog, music and light + +This chapter covers everything that sets the *mood* of a level: fog and draw distance, sky and +backdrop colours, music, teleports between areas, the lights that colour object models and the +baked vertex colours that light the level geometry itself. + +Most of it lives in the **Environment** panel (left dock, next to the Outliner) with its three +tabs **Zones**, **Lights** and **Lighting**, plus four tools: **Zones**, **Lights**, **Paint vertex +colours** and the region painter of the next chapter ([Visibility regions](13-visibility.md)). + +![The Environment panel with the zone list, the selected zone in the viewport and its Inspector section](img/env_zone.jpg) + +## How the game uses zones + +The game stores a small list of *environment zones* in every level. Each zone holds a complete +environment: fog mode and distances, fog/sky colour, the ambient colour for object models, a +music track and a sky model. There are two kinds: + +* **The start zone** (exactly one per level). The level begins with its fog, colours and music. + Its trigger cell is not used – the original levels park it outside the playfield at cell + (3, y, 0). +* **Teleport / environment zones.** Each one belongs to a *trigger cell* of the grid. When the + player walks onto an **exit floor** (collision material **16**) or into a door object + (RubyIdolDoor, PressurePadDoor) in that cell, the game looks up the zone of the player's cell, + moves the player to the zone's **target** facing the zone's direction, and switches to the + zone's fog, colours and music. The house doors in the Village work this way: you enter a door + and arrive inside the house, with the indoor fog and music. + +A zone can also *only* switch the environment without moving the player: untick **Move the +player here** in its Inspector section. That is how you make, for example, a cave entrance that +turns the fog dark while the llama keeps walking. + +> **Engine limit:** the game switches zones only through these triggers. There is no "area" in +> which a zone is active – after a teleport the new environment stays until the next trigger. + +## The Zones tab + +**Level → Environment zones** opens the Environment panel on this tab. + +### Fog preview + +| Option | What it does | +|---|---| +| **Live fog in the viewport** | Draws the game's fog in the 3D view: geometry fades to the fog colour and everything beyond the fog end disappears, just like in the game. Also **View → Fog preview**. | +| **Zone** | Whose fog is shown: **Camera (nearest zone area)** guesses the zone from where the camera looks (nearest teleport target or the player start), **Start zone**, or **Selected zone only**. A selected zone is always previewed. | +| **Detail** | The game's detail setting **Minimum**, **Medium** or **Maximum**: the fog distances are interpolated between the Min and Max pairs of the zone. | + +The line below shows which zone is previewed and its fog distances in cells. The distances are +measured from the point the camera orbits around (the game camera follows the llama a few cells +behind), so orbiting shows the "visible bubble" around that point. When you zoom far out for an +overview, the preview pauses and says *Zoom in to see the fog of …*. + +![Fog in the game: the original zone (left) and the same spot with a red fog set in the editor (right)](img/env_game_fog.jpg) + +### Zone list + +The list shows the start zone first (*Start zone (level default)*), then every other zone with a +swatch of its fog colour, its trigger → target, the fog end distance and the music track. +Clicking selects the zone (also in the viewport and the Inspector), double-clicking frames it. + +| Button | What it does | +|---|---| +| **+ Teleport** | Starts the **Zones** tool: click the trigger cell, then the arrival point. | +| **+ Environment** | Adds a zone that only switches fog, colours and music, at the cell the camera looks at. | +| delete | Deletes the selected zones. | +| frame | Frames the selection in the viewport. | + +New zones copy the environment of the start zone, so they look the same until you change them. + +## Environment zone section + +Selecting a zone shows the **Environment zone** section in the Inspector. Its header has +**Frame**, **Make start zone** and **Duplicate** (a copy one cell east). Raw fields whose meaning +is unknown stay in the generic properties below the section. + +### Trigger + +| Field | Meaning | +|---|---| +| **Cell X**, **Cell Z (row)** | The trigger cell. It needs an exit floor (material 16) or a door object, otherwise the teleport never fires. | +| **Cell Y (height)** | The height of the trigger, for stacked zones in one cell. | +| **Height test** | When the zone matches: *player y ≤ cell Y*, *player y ≤ cell Y − 1* or *player y ≤ cell Y + 1 (no step)*. The lowest matching zone of the cell wins. | + +For the start zone this part only explains that the trigger cell is not used. + +### Teleport target + +| Field | Meaning | +|---|---| +| **Move the player here** | On: the player is moved to the target. Off: only fog, colours and music change. | +| **Target X / Y / Z** | Arrival point in cells. | +| **Facing (°)** | Direction the player looks after arriving: 0 = +z (down the grid), 90 = +x (east). | +| **Step forward** | Cells the player is pushed along the facing after the teleport, so they do not stand on the exit floor again. Not used with the height test *Y + 1*. | + +### Fog and draw distance + +| Field | Meaning | +|---|---| +| **Mode** | *0 – no fog*, *1 – darkness, light around the player (radius = fog start…end)*, *2 – fog (table)*, *3 – fog (vertex, most levels)*. No original level uses mode 1. | +| fog ramp bar | Shows the fog band from start to end in the zone colour. | +| **Draw distance** | Draw distance in cells at detail Min, Medium and **Max**. Beyond it nothing is drawn. | +| **Start (Min)**, **End (Min)** | Fog start and end at detail Minimum. | +| **Start (Max)**, **End (Max)** | Fog start and end at detail Maximum. If the Max start is not above the Min start, the game uses 1.5 × the Min pair. | +| **Fog / sky colour** | The colour everything fades to; also the backdrop colour behind the sky. | +| **Ambient (objects)** | Added to the light of all object models in this zone. Tiles are not affected – they use their vertex colours. | + +Colour pickers update the viewport live while you pick; the whole pick is one undo step. + +> **Tip:** the original levels end their fog between 7 and 23 cells. Much shorter fog makes a wall +> right in front of the player, much longer fog is slow on old PCs and wakes enemies from far away. + +### Music and sky + +| Field | Meaning | +|---|---| +| **Music** | Track started when the zone becomes active (`music/.ASF`): VILLAGE, JUNGLE1, RIVER, CITY, CATACOMB, LAB, BALLOON, KRONK, VICTORY … *0 – (none)* stops the music. | +| **Sky model** | The object model drawn as the sky dome (one of the sky models used in this level), or none. | +| **Sky yaw** | Turns the sky dome. | + +## Zones tool + +The **Zones** tool (toolbar group *env*, or **+ Teleport**) edits triggers and targets directly in +the viewport. The options bar has two modes: + +| Mode | Click / drag | +|---|---| +| **Target** | Nothing selected: a click on a cell creates a new zone there (its trigger) – then click where the player should arrive. With a zone selected: click or drag places its arrival point; **Shift+drag** turns the facing (22.5° steps). | +| **Trigger cell** | Clicks move the selected zone's trigger cell. | + +**[** and **]** turn the facing of the selected zone by 22.5°. + +The viewport shows a ring and a facing arrow at every zone target (layer *Zone targets & +facing*). The selected zone additionally gets a filled trigger cell, a box and an arc to its +target. The target ring is pickable: clicking it selects the zone. + +## Lights + +The **Lights** tab lists all lights of the level (colour swatch, type, position and radius). + +| Control | What it does | +|---|---| +| **Preview lights on objects** | Lights every object model in the viewport with the engine's formula (also **View → Light objects (LGHT preview)**). | +| **+ Point** | Starts the **Lights** tool. | +| **+ Directional** | Adds a directional light (a sun). | +| delete | Deletes the selected lights. | + +> **Engine limit:** lights only light **object models** (characters, pickups, props), per vertex: +> the zone's ambient colour + all directional lights + the nearest point lights (6 at most). The +> level geometry (tiles) is never lit at run time – it keeps its baked vertex colours. To light +> tiles, [bake the lighting](#baking-the-lighting) or [paint vertex colours](#painting-vertex-colours). + +Directional lights have no position; the viewport draws them as diamonds with a direction line +above the player start. A new level keeps the base level's directional lights (Village: three) and +none of its point lights. + +![The Lights tab with the point lights of the Village and their radius rings](img/env_lights.jpg) + +### Light section + +| Field | Meaning | +|---|---| +| **Light type** | **Directional (sun)**, **Point light** or **Negative point light (darkens)**. Other type numbers are ignored by the engine. | +| **Colour** | Light colour (red / green / blue). | +| **Intensity** | 1.0 = colour value 128 (the engine doubles it), 2.0 = maximum. | +| **Azimuth (°)**, **Elevation (°)** | Directional lights: where the light comes from (0 = from +z, 90 = from +x / east). | +| **X**, **Y (height)**, **Z (row)** | Point lights: position in cells. | +| **Full radius** | Full strength up to this distance (cells). | +| **Falloff radius** | Linear falloff to zero at this distance; nothing beyond. | +| **Flicker** | The engine randomly scales the light ×1.5 / ×0.5 every frame (torches, fires). | + +**Frame** and **Duplicate** are in the section header. + +### Lights tool + +Click on the level to place a point light one cell above the surface; drag to set its radius. The +options bar chooses a colour preset – **Torch (orange)**, **Fire (red)**, **Lamp (warm white)**, +**Magic (cyan)**, **Moon (blue)**, **Glow (green)** – the **Height** above the surface and the +**Radius**. + +In the viewport, directional lights are shown as arrows above the level centre and point lights +as radius rings (layer *Light gizmos*); a selected point light shows spheres for both radii. + +## Baking the lighting + +The **Lighting** tab (also **Level → Bake lighting…**) computes new vertex colours for the tiles: +sun light with real shadows, a sky/ground hemisphere and ambient occlusion in corners. A bake of +the whole Village takes under a second and is one undo step. + +![Lighting tab after baking the Night preset](img/env_bake_night.jpg) + +| Group | Options | +|---|---| +| **Preset** | **Day**, **Dusk**, **Night**, **Cave**, **Flat (no lighting)** fill in the values below (nothing changes until you bake). **Level's current** loads the level's own lighting settings. | +| **Sun** | **Azimuth (°)** (0 = from south, 90 = from east), **Elevation (°)**, **Strength**, **Sun colour**. | +| **Sky** | **Sky colour** (light from above), **Ground colour** (light from below), **Ambient occlusion** (darkening of corners and crevices), **AO radius** (cells), **Shadow light** (0 = black shadows, 1 = no shadows). | +| **Bake** | **All tiles** or **Selected tiles**; **Add point lights (LGHT) to tiles** (the level's point lights also brighten the tiles, with shadows); **Also light brushes (level setting)** – brushes are lit when they are built, from the level's lighting settings, so the bake can store its values there; **Set start zone fog & ambient colour** – takes the preset's fog and ambient colour into the start zone. | +| **Display** | **Textured** or **Vertex colours only**; **Paint vertex colours** switches to the brush. | + +Press **Bake lighting**. The status line says how many tiles were baked. + +![The same village spot in the game before and after a night bake](img/env_game_night.jpg) + +> **Note:** the bake reproduces the look of LlamaMod's level builder, not the hand-tuned original +> lighting of Argonaut. Bake **Selected tiles** if you only want to relight a part of an original +> level. + +## Painting vertex colours + +The **Paint vertex colours** tool (**V**) is a brush for the vertex colours of tiles – darken a +corner, warm up a fireplace, add a glow under a lamp. Every stroke is one undo step. + +![Painting a dark patch onto the village ground](img/env_paint.jpg) + +| Option | Meaning | +|---|---| +| Mode | **Paint** (blend towards the colour), **Lighten**, **Darken**, **Smooth** (evens out neighbouring vertices). | +| colour | The paint colour. **Ctrl+click** on the level picks a colour. | +| **Bright** | Brightness: 1.0 = the texture's own colour, 2.0 = maximum (the engine doubles vertex colours). | +| **Radius** | Brush radius in cells (0.1–16, default 1.5). | +| **Str.** | Strength per dab (0.01–1, default 0.5). | +| Falloff | **Smooth**, **Linear** or **Constant** towards the brush edge. | +| **Selection only** | Only paint the selected tiles. | + +> **Tip:** tiles that share a model still have their own vertex colours, so painting never +> changes other tiles. + +## View options + +| Menu item | Effect | +|---|---| +| **View → Fog preview** | Live fog of the zone (see [Fog preview](#fog-preview)). | +| **View → Light objects (LGHT preview)** | Engine lighting on object models. | +| **View → Vertex colours only** (**Shift+V**) | Shows only the baked/painted vertex colours, without textures – useful to judge the lighting. | + +## Environment checks + +**Level → Check for problems** and every build also check the environment: + +| Severity | Problem | +|---|---| +| warning | No start zone (the level starts without its fog, colours and music), or several start zones (the last one wins). | +| warning | Fog ends after less than 3 cells even at detail Maximum, or the draw distance is above 30 cells. | +| warning | Fog start is not before fog end. | +| warning | A trigger cell is outside the grid (never entered). | +| error | A teleport target is outside the grid. | +| info | No exit floor (material 16) or door object in a trigger cell – the teleport only fires on an exit floor. | +| warning | Tiles with flat grey (unlit) or completely black vertex colours – bake the lighting. | +| warning / info | Point lights with falloff radius 0 or smaller than the full radius, black lights, light types the engine ignores. | + +Double-click a problem to select the zone or light. The visibility checks are described in +[Visibility regions](13-visibility.md). diff --git a/docs/manual/13-visibility.md b/docs/manual/13-visibility.md new file mode 100644 index 0000000..91446b8 --- /dev/null +++ b/docs/manual/13-visibility.md @@ -0,0 +1,114 @@ +# Visibility regions + +The game was written for PCs of the year 2000. To keep the frame rate up it does not draw the +whole level all the time: the level grid is divided into up to **32 visibility regions**, and for +each region a *mask* says which regions can be seen from there. In the Village, for example, the +inside of a house is not drawn while you stand outside. + +New levels start with one region where everything is visible – fine for small levels. For big +levels, or when you change an original level so that new parts become visible, you edit the +regions here. + +## How the engine decides what to draw + +1. The game takes the grid cell the **camera** is in and looks up that cell's region. +2. The region's **mask** has one bit per region. Tiles and objects in cells whose region bit is + set are drawn; all others are skipped. +3. A mask of **0** means "draw everything". When the camera is **outside the grid**, everything + is drawn as well. + +So a row in the mask table answers "standing here, what do I see?". A region must always see +itself – otherwise the ground under the llama and the llama itself would vanish. + +> **Note:** it is the camera's cell that counts, not the llama's. The camera hangs a few cells +> behind the player, so give neighbouring regions some overlap. + +## Visibility panel + +**Level → Visibility regions** opens the **Visibility** panel (right dock). + +![Painting regions while the Visibility panel shows the mask matrix](img/env_regions.jpg) + +### Mask matrix + +A 32 × 32 grid of check cells: the **row** is the region the camera is in, the **column** is the +region that is drawn (header *cam↓ draw→*). Click a cell to toggle it, drag to paint several. +Regions that no cell uses are dimmed. Clicking a row label picks that region (for the preview and +the region brush). A row without any bit means "everything" – the engine rule above. + +Hovering a row shows how many cells the region has, its own triangles and how many triangles the +game draws with the camera there. + +### Masks + +| Control | What it does | +|---|---| +| **Symmetric** | While on, toggling *A sees B* also sets *B sees A*. | +| **All visible** | Every region sees every region. | +| **Self only** | Every region sees only itself (a starting point for hand-made masks). | +| **Make symmetric** | If A sees B, B now also sees A. | +| **Auto-generate…** | Computes regions and masks from the level layout (see below). | +| **Paint regions** | Starts the region brush in the viewport. | + +### Preview + +**Show** hides in the viewport what the game would not draw: + +| Setting | Shows | +|---|---| +| **Off (draw everything)** | The normal editor view. | +| **Follow the camera** | What the game draws for the region the editor camera is in – fly around and watch parts appear and disappear. | +| **From region n** | What the game draws for a camera in region *n*. | + +The preview hides tiles and objects through the editor's hidden flags and restores exactly the +items it hid; things you hid yourself stay hidden. While it is active they show as hidden in the +Outliner. + +![Previewing the Village from one region: the parts the game would skip are hidden](img/env_region_preview.jpg) + +### Statistics + +The bottom of the panel shows how many regions are in use and the triangles drawn per camera +region (maximum and mean). A warning appears when one region draws a lot – the original levels +stay well below 30 000 triangles. + +## Auto-generate + +**Auto-generate…** opens *Auto-generate visibility regions*: + +| Option | Meaning | +|---|---| +| **Layout** | **One region, everything visible**; **Rectangular blocks, visible within a distance**; **Connected areas (islands / rooms), visible within a distance** (default – flood-fills connected floors, at most 32 areas). | +| **Visible within (cells)** | Regions closer than this see each other. The fog end distance of the level is a good value (default 12). | + +It replaces the region of every cell and all masks in one undo step. The result line names the +highest number of triangles drawn from one region; if it is heavy for the original engine it +suggests a smaller distance or more, smaller areas. + +> **Tip:** more regions means less to draw, but areas can pop in when the camera crosses a +> border. Always check the result with **Follow the camera** and in a play-test. + +## Paint regions tool + +The **Paint regions** tool sets the region of grid cells with a square brush. It switches the +*Visibility regions* layer on, which colours every cell by its region. + +| Option | Meaning | +|---|---| +| **Region** | The region to paint (colour swatches, Region 0–31). | +| **Brush** | 1 × 1, 3 × 3, 5 × 5, 9 × 9 or 17 × 17 cells. | +| **Ctrl+click** | Picks the region of a cell. | + +The masks are edited in the Visibility panel. Cells added by growing the grid get region 0. + +## Visibility checks + +Building and **Level → Check for problems** report: + +* **error** – the player start is in a region whose mask does not include the region itself (the + ground and the llama would be invisible); +* **warning** – a region whose geometry is never visible from any region; +* **info** – a region that draws more than 30 000 triangles (heavy for the original engine; the + fog distance limits it further). + +The Village (96 × 96 cells) uses 22 of the 32 regions. diff --git a/docs/manual/14-level-settings.md b/docs/manual/14-level-settings.md new file mode 100644 index 0000000..7c4af4e --- /dev/null +++ b/docs/manual/14-level-settings.md @@ -0,0 +1,112 @@ +# Level settings + +Every level has one set of global settings: its name, the TestLevel chapter it goes into, the +size of the grid, the death plane and a few engine flags. You edit them in the **Level settings** +dialog; all values are also visible in the Inspector. + +## Opening the settings + +* **Level → Level settings…** opens the dialog. +* The **Level settings** node at the top of the Outliner selects the settings, so the Inspector + shows them as normal properties (including the lighting values used for brushes, see + [below](#settings-in-the-inspector)). + +Clicking **OK** applies all changes as one undo step called *Level settings*; **Cancel** changes +nothing. + +![The Level settings dialog of Village chapter 1](img/env_settings.jpg) + +## Level + +| Field | Meaning | +|---|---| +| **Name** | Shown in LlamaMod's mod list and on the loading screen. | +| **Author** | Your name, shown in LlamaMod. | +| **Description** | A few sentences for the mod list. | +| **Project** | *new level* or *changes to an original level* (read only). | +| **Base level** | The original level the project is built on – it provides the object code, textures, sounds, sky and music (read only). | +| **TestLevel chapter** | New levels only: the chapter of world 9 (TestLevel, 1–49) the level replaces when its mod is on. | + +Below the chapter field the dialog lists which chapters other level projects in your `levels` +folder already use, so you can pick a free one. Each level needs its own chapter; chapter 1 is +also used by LlamaMod's Test World mod. Players can change the chapter later in the level mod's +options in LlamaMod (see [Sharing levels](17-sharing.md)). + +## Base level + +The base level is chosen when you create a level (**File → New level…**) and cannot be changed +afterwards, because the project stores only the differences to it. It decides: + +* which **object types** you can place – only types whose code is in the base level exist + (a Llama, coins, the enemies of that world …); +* the **textures** you start with, the **sky** models, sounds, the default fog and music (its start + zone) and the sun (its directional lights); +* the engine data layout (the read-only feature flags below). + +For new levels the default base is Village chapter 1, which has the widest choice of objects. + +## Grid + +| Field | Meaning | +|---|---| +| **Width (cells, x)** | Columns. Growing adds cells in the east. | +| **Height (cells, z)** | Rows. Growing adds cells at the far end (higher z). | + +The note shows the current size and the minimum the content needs – you cannot shrink the grid +below what the level uses. New cells get visibility region 0 (see +[Visibility regions](13-visibility.md)). + +> **Engine limit:** tiles, objects and teleport targets must stay inside the grid. Collision is +> looked up per grid cell, so geometry outside it has no collision. + +## Death plane + +The death plane is an invisible floor with collision material **30**: touching it, or falling +below it, costs the llama a life (it respawns at the last checkpoint). + +| Field | Meaning | +|---|---| +| **Height** | Height of all death planes of the level. Changing it moves the death-plane and sea brushes and, in original levels, the "no floor" tiles (material 30) together. | +| **Add a death plane over the whole grid** | Only shown when the level has none: adds one death-plane brush covering the grid. | + +The note lists the death planes found (brushes and no-floor tiles). **Geometry → Death plane +height…** does the same from the Geometry menu. + +## Feature flags + +The *WFPC* value of the level is a set of engine flags, shown as check boxes with their hex +value: + +| Flag | Meaning | Editable | +|---|---|---| +| `0x00000010` | MAP has a path buffer size (block 14) | no (layout) | +| `0x00000100` | STPC has an extra model block | no (layout) | +| `0x00000800` | Fog flag of the renderer | yes | +| `0x00010000` | Tile swaps (doors / bridges) | no (layout) | +| `0x00080000` | Script flag (read by object scripts) | yes | +| `0x00100000` | TRPS chunk has extra entries | no (layout) | +| `0x00200000` | MAP has a path block | no (layout) | +| `0x04000000` | Path entries with an extra word | no (layout) | +| `0x08000000` | Collision materials 17/18 are ignored | yes | +| `0x10000000` | Two vertex colour pointer tables (light states) | no (layout) | + +Layout flags change how the level data is stored and are fixed by the base level, so they are +greyed out. Set bits whose meaning is unknown are listed as *(unknown)* – keep them unless you +know better. + +## Objects + +| Field | Meaning | +|---|---| +| **Strat pool extra** | Slots for objects = this value + 30. The pool also has to hold objects that are spawned during play (projectiles, effects). Raise it if you place many more objects than the base level had. | + +## Settings in the Inspector + +With the **Level settings** node selected, the Inspector shows the same values as properties, +grouped into *Level*, *Grid*, *Objects*, *Lighting* and *Raw*. The **Lighting** group holds the +lighting that brushes and meshes get when they are built: texture rotation, sun azimuth, +elevation, strength and colour, sky and ground light, ambient occlusion, AO radius and shadow. +The **Lighting** tab of the Environment panel can fill these in for you (option *Also light +brushes* when baking, see [Baking the lighting](12-environment.md#baking-the-lighting)). + +![The level settings shown as properties in the Inspector](img/ui_settings.jpg) diff --git a/docs/manual/15-build-play.md b/docs/manual/15-build-play.md new file mode 100644 index 0000000..715213c --- /dev/null +++ b/docs/manual/15-build-play.md @@ -0,0 +1,100 @@ +# Building, problems and play-testing + +Before the game can load your level, the editor **builds** it: brushes and meshes become tiles, +textures are packed, objects and waypoints are written, and everything is checked. Building is +fast (a few seconds) and happens in the background, so you can keep working. + +## Check for problems + +**Level → Check for problems** runs all checks without building. The results appear in the +**Problems** panel: + +![The Problems panel after a check](img/obj_problems.jpg) + +* **Errors** (red) would break the level. They block **Build** and **Play** until you fix them. +* **Warnings** (yellow) are things that probably do not work as you expect. +* **Info** (blue) are hints. + +Double-click a message to select the item it is about (and frame it with **F**). The button in +the panel's toolbar validates again without building. + +### What is checked + +| Area | Examples | Details | +|---|---|---| +| Level | no player start (error), several player starts, no exit in a new level, no geometry | [Objects](09-objects.md#object-checks) | +| Geometry | tiles outside the grid, tile models over 8000 vertices, overlapping identical tiles, no floor under the player start, no exit floor (16) in the door's cell, small gaps between floors | [Building geometry](06-geometry.md#geometry-checks) | +| Objects | wrong parameter counts, values out of range, missing waypoints, impossible exit targets | [Objects](09-objects.md#object-checks) | +| Waypoints | chains not stored in one piece (with an automatic repair) | [Waypoints](10-waypoints.md#waypoint-checks) | +| Environment | fog too short or too long, missing start zone, teleport targets outside the grid, black or ignored lights | [Environment](12-environment.md#environment-checks) | +| Visibility | the player start in a region that does not draw itself, regions that are never drawn | [Visibility regions](13-visibility.md#visibility-checks) | +| Textures | more than 127 texture pages, textures on missing pages | [Textures](08-textures.md#limits) | + +## Build + +**Level → Build** (**Ctrl+B**, toolbar **Build**) checks and builds the level. When it is done the +Problems panel shows **Build OK** with the numbers of tiles (and how many came from brushes), +objects and waypoints, the size of the level file and the time it took – plus any warnings. The +status bar keeps the result of the last build; click it to open the Problems panel. + +![A successful build](img/geo_build_ok.jpg) + +**File → Export level WAD…** builds the level and saves the game file (`.wad`) wherever you like +– for your own experiments; to play or share a level you do not need it. + +## Play-testing + +**Level → Play** (**F5**, toolbar **Play**) does everything in one go: + +1. builds the level, +2. installs it in LlamaMod's `levels` folder as a level mod (next to the mods you have switched + on), +3. prepares the play-test save slot (unlocked up to your level), +4. starts the game **directly in the level** – no logos, no menus, no map. + +![The game started straight in the level](img/play_boot.jpg) + +In the game, press **Escape** to quit straight back to the editor. When the game has ended, +LlamaMod switches your normal mod selection back on; the level itself stays installed. + +### Play from the camera position + +**Level → Play from camera position** (**Shift+F5**) starts the llama on the ground in the middle +of the view, facing the way the camera looks. Point the view at the spot you want to test – a +difficult jump, the end of a parkour – and press **Shift+F5**. + +![The llama starting where the editor camera looked](img/play_from_here.jpg) + +The editor checks that there is walkable **collision** at that point: many things that look solid +(tree crowns, roofs, decoration) have none. If there is no floor nearby, it tells you instead of +dropping the llama into the void. The original level's opening fly-by is skipped in this mode. + +### Play-test options + +| Option (Level menu) | Default | Meaning | +|---|---|---| +| **Play boots straight into the level** | on | Off: the game starts at the main menu with the map cursor on your level (choose *Spiel starten* → slot → Enter on the map); groove.exe is not changed. | +| **Escape quits the play-test** | on | In a play-test the pause key ends the game at once. Off: Escape opens the normal pause menu. | +| Play-test save slot (**Edit → Preferences**) | 3 | The slot the game plays with; it is backed up before it is changed. | + +> **Note:** Booting into the level uses the **Boot to level** mod, which adds code to LlamaMod's +> private copy of groove.exe. Some antivirus programs warn about such files – see +> [Troubleshooting](20-troubleshooting.md). + +### When something goes wrong + +| Message or symptom | What to do | +|---|---| +| "The game is still running" | The level files cannot be replaced while the game runs. Click **Quit game and play** to close it and start the play-test, or quit it yourself. | +| The llama falls through the floor | Look at the collision (**C**); give the floor collision (the brush's collision faces, or a collision material other than 30). | +| The level door does nothing | The door needs **exit floor (16)** in its own cell – use the **Exit** setup of the Objects panel. | +| The game crashes while loading | Run **Check for problems** and fix all errors; keep everything inside the grid; only one player start. | +| Objects are missing in the game | Some objects only appear after an event or a cut-scene; also check the visibility regions. | + +More in [Troubleshooting and FAQ](20-troubleshooting.md). + +## Playing without the editor + +The level is now a mod in LlamaMod's **Levels** category. To play it the normal way, tick it in +LlamaMod, click **Apply** and **Play**. New levels are on the world-9 (TestLevel) map – unlock +it in **Save games → Unlock TestLevel (world 9)**; edited original levels replace their chapter. diff --git a/docs/manual/16-original-levels.md b/docs/manual/16-original-levels.md new file mode 100644 index 0000000..33f0c28 --- /dev/null +++ b/docs/manual/16-original-levels.md @@ -0,0 +1,106 @@ +# Editing original levels + +Besides building new levels you can change the game's own 31 levels: add coins and checkpoints, +move platforms, change the fog and music, open up new paths. Your project stores only the +changes; the original files on the CD and in LlamaMod's game copy are never modified. + +## Opening an original level + +**File → Open original level…** (**Ctrl+Shift+O**, or **Open original level** on the start page) +shows every level of the CD by name, with its loading picture as a preview: + +* *Village – Chapter 1* … *Lab – Chapter 5*, sorted by world and chapter. The worlds are Village, + Jungle at Night, River, Jungle by Day, Mountain, City, Catacombs and Lab. +* Chapters marked *(unused)* exist on the CD but are not reachable from the world map. +* *Other – …* entries are the front-end and demo files (menus, attract demo); they are listed for + completeness. + +Pick a level and click **Open**. Loading takes a moment; the level appears exactly as on the CD. + +![The Open original level dialog with the loading picture of Village chapter 1](img/ui_dialog_open.jpg) + +> **Tip:** you can also start the editor straight into a level from the command line: +> `LlamaMod --editor t1l1m001` (the file name of the level: t1 l*world* m*chapter*). + +## What you can change + +Everything the editor shows can be edited in an original level, with the same tools as in a new +level: + +* **objects** – move, turn, delete, add any type of this level, change parameters + ([Objects](09-objects.md)); +* **waypoint chains** of enemies, cameras and respawn points ([Waypoints and paths](10-waypoints.md)); +* **tiles** – move, turn, paint with other tile models, repaint textures, paint collision + ([Building geometry](06-geometry.md)); +* new **brushes** and imported **meshes** on top of the original geometry; +* **zones**, fog, music, **lights**, vertex colours and **visibility regions** + ([Environment](12-environment.md), [Visibility regions](13-visibility.md)); +* **textures** – import new ones or replace pixels ([Textures](08-textures.md)). + +The base level of an edited original is the level itself, so all object types of that level are +available. + +## The project stores only differences + +When you save, the `.llamalevel` file contains only what you changed compared to the original +level, plus imported meshes and textures and a thumbnail. Projects are usually a few kilobytes +and contain no game data, so you may share them freely. + +Building is reproducible: the same project and the same CD always give the same level file, and +an unchanged project builds a file that is byte-for-byte identical to the original. + +## Keep tile numbers stable + +Some object scripts address tiles **by their number** in the level, for example doors or +bridges that swap their model. Deleting an original tile shifts the numbers of all tiles after +it, and such a script may then move the wrong tile. + +* Prefer **moving** an unwanted tile out of the way (for example below the ground) or + **painting another model** onto it with the Tiles tool – painting onto a tile at the same + height replaces its model instead of adding a tile, so the numbering stays. +* New tiles are added at the end and never shift existing numbers. + +> **Note:** **Edit → Hide selection** (**H**) only hides things in the editor's viewport. Hidden +> tiles and objects are still built into the level and appear in the game. + +## Playing your changes + +**Play** (**F5**) builds the level, installs it as a LlamaMod mod and starts the game directly in +the changed chapter. **Play from camera position** (**Shift+F5**) puts the llama on the ground in +the middle of your view. See [Building and play-testing](15-build-play.md). + +While the level mod is enabled in LlamaMod, your version **replaces that chapter** of the game: +you reach it on the world map as usual, and it uses your save game's progress. + +> **Note:** with **Play from camera position** the level's intro fly-by is skipped – the original +> cut-scene camera is tied to the original start position. + +## Conflicts with other mods + +Only one mod can provide a level file. If two enabled mods change the same original level – for +example two edited versions of Village chapter 1 – LlamaMod stops applying with a message that +names the file. Switch one of them off. + +Mods that work on *all* levels are applied after level projects and include your changes: +**Mirror mode**, the **Randomizer**, **Draw distance** and the **Debug wireframe** (see +[LlamaMod mods](18-mods.md)). + +## Known oddities + +* **Exit floor check:** in 8 original levels **Level → Check for problems** reports that the + LevelDoor has no exit floor (material 16) in its cell. The original exit floors decode one cell + off the door (for example, door cell 42,49 and floor at 43,48 in the Village). The exits work + in the game – ignore this message for original doors. Exits you build yourself are checked + correctly. +* **Snap to floor** on original levels uses the same collision data, so objects may occasionally + land a little off near those exit floors. +* **Environment zones** of original levels are mostly house doors: their trigger cells hold exit + floors. Moving a door without moving its zone breaks the teleport (see + [How the game uses zones](12-environment.md#how-the-game-uses-zones)). +* **Coins near the start** of Village chapter 1 are not drawn at the very beginning, also in the + unmodified game, so changes there are hard to see right after loading. + +## Tutorial + +[Tutorial: Edit an original level](tutorial-original-level.md) walks through a complete example: +moving coins, adding a checkpoint and changing the music of Village chapter 1. diff --git a/docs/manual/17-sharing.md b/docs/manual/17-sharing.md new file mode 100644 index 0000000..873403c --- /dev/null +++ b/docs/manual/17-sharing.md @@ -0,0 +1,69 @@ +# Sharing levels + +A level project is a single `.llamalevel` file. It contains your changes, the meshes and textures +you imported and a thumbnail – **no game data** – and is usually only a few kilobytes (more if +you imported large textures or models). Everyone who has the game can play it: LlamaMod builds +the level from *their* copy of the CD. + +## Sending a level + +1. **File → Save** (**Ctrl+S**). By default projects are saved in LlamaMod's `levels` folder + (inside the data folder, see [Installing LlamaMod](02-installing.md#the-data-folder)). +2. Give the level a good name, author and description in + [Level settings](14-level-settings.md) – LlamaMod shows them on the mod card. +3. Send the `.llamalevel` file: forum post, Discord, e-mail, a download link. + +> **Tip:** Save once more just before you share: the thumbnail is taken from the current view +> and becomes the loading picture in the game. Frame something nice first. + +## Installing a level you received + +Any of these works: + +* **Copy the file into the `levels` folder** of LlamaMod's data folder (**Settings → Data folder + → Open folder**, then `levels`) and restart LlamaMod. The level appears in the **Levels** + category of the **Mods** tab. +* **Open it in the editor** (**File → Open project**) and press **F5**: the play-test installs it + in the `levels` folder and starts it. +* From the command line: `LlamaMod --import-level `. + +Then tick the level in LlamaMod, click **Apply** and **▶ Play**. + +## Where the level appears in the game + +| Project | In the game | +|---|---| +| **New level** | on the world-9 map as **TestLevel – chapter *n*** (its own loading picture with the level name). Unlock world 9 in **Save games → Unlock TestLevel (world 9)**, or play it with the editor's **F5**. | +| **Edited original level** | replaces that chapter (for example Village chapter 3) while the mod is on. Switch the mod off to get the original back. | + +## Chapters and conflicts + +Each new level occupies one of the 49 TestLevel chapters. The editor picks a free chapter for +new projects, but levels from different people may use the same one. If two active level mods +use the same chapter – or two mods change the same original level – LlamaMod stops applying and +names the files involved. Fix it in the mod's options: every new-level mod has a +**TestLevel chapter** option (1–49). + +> **Note:** Chapter 1 of the TestLevel is also used by the built-in **Test World** mod. Prefer +> chapters 2 and above for your own levels. + +## Updating a shared level + +Change the project and save it under the **same file name**; the receiver replaces the old file. +LlamaMod notices the change (it keys every level by the file's contents) and rebuilds it on the +next **Apply**. + +## Sharing parts of a level + +* **Prefabs** – groups of objects you saved (**Tools → Save selection as prefab…**) live in the + data folder's `editor/prefabs` folder and can be copied to another computer. +* **Library assets** – geometry saved with **Geometry → Save selection to library…** is a + `.llamamesh` file in the `library` folder. Note that library files *do* contain textures from + the game, so share them only with people who own it. +* **Copy objects (for other levels)** (**Ctrl+Shift+C**) puts objects as text on the clipboard – + you can even paste that into a forum post for others to paste into their level. + +## Sharing this manual + +The manual window's **Export** button saves the whole manual as HTML pages or as one HTML file +with all pictures, ready to upload or attach to a forum post. diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md new file mode 100644 index 0000000..d7f73a4 --- /dev/null +++ b/docs/manual/18-mods.md @@ -0,0 +1,194 @@ +# LlamaMod mods + +Besides your own levels, LlamaMod comes with mods that change the game: skip the intro, see +further, play mirrored, shuffle the coins, fly a free camera and more. This chapter explains how +mods work and lists every built-in mod with its options. + +## Using mods + +1. Open the **Mods** tab in LlamaMod. +2. Tick the mods you want. A ticked mod shows its options, each with a short explanation; + changed options take effect on the next **Apply**. +3. Click **Apply**. LlamaMod builds the changed game files into its private copy – your + original files stay untouched. The status bar shows the progress and the time it takes. +4. Click **▶ Play**. + +**Restore original** switches all mods off. Applying is refused while the game runs – quit it +first. + +* Mods are applied in a fixed order, so they combine: the **Mirror mode** also mirrors the Test + World and your levels; the **Debug wireframe** is built on top of everything else. +* Built mod files are cached, so switching mods on and off again is quick. **Settings → Clear + cache** throws the cache away (it is rebuilt when needed). +* Mods marked **modifies groove.exe** add code to the game program of LlamaMod's copy. This is + safe, but on Windows some antivirus programs flag the changed file – see + [Troubleshooting](20-troubleshooting.md). + +![Several mods combined in the village](img/mod_all.jpg) + +## Level mods + +Every `.llamalevel` project in the data folder's `levels` folder is a mod in the **Levels** +category, named after the level, with its author and description. Mods of new levels have one +option: + +| Option | Values | Meaning | +|---|---|---| +| TestLevel chapter | 1 – 49 | the chapter of world 9 the level occupies; each level needs its own (chapter 1 is also used by the Test World mod) | + +Edited original levels have no options – they replace their chapter. See +[Sharing levels](17-sharing.md). + +## Command line + +LlamaMod can be scripted: + +``` +LlamaMod --mods list all mods with their options +LlamaMod --apply fog,mirror --opt fog.distance=400 apply mods with options +LlamaMod --apply restore the original game +LlamaMod --launch start the game +``` + +Option values are checked and saved like in the window. + +## Built-in mods + +The following list is generated from the program, so it always matches your LlamaMod version. + + +### Levels mods + +#### Test World + +Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every object in the game, an animal enclosure and a parkour course with checkpoints. To reach it, click "Unlock TestLevel" on the Save games tab. + +*Id* `testworld` + +### Gameplay mods + +#### Boot to level + +Play starts the game straight in the chosen level – no logos, no menus, no world map. The game plays with the chosen save slot (a new game if it is empty). Handy for practising one level. + +*Id* `boot-to-level` · **modifies groove.exe** (see [antivirus warnings](20-troubleshooting.md)) + +| Option | Values | Default | Description | +|---|---|---|---| +| World | 1 – 9 | 1 | 1 = Village … 8 = Lab, 9 = TestLevel | +| Chapter | 1 – 49 | 1 | Chapter of that world (TestLevel: 1–49). | +| Save slot | 1 – 3 | 3 | The save game used for lives and progress. The game also saves into it. | +| Skip startup logos | on / off | on | Also skips the logo movies and the title screen. | +| Escape quits the game | on / off | off | Escape ends the game at once instead of opening the pause menu. | +| Player start | text | (empty) | Leave empty for the level's own start. Advanced: "x, y, z[, angle]" in cells moves the start. | + +#### Intro skip + +Gets you to the main menu faster: skips the logo movies at the start and stops the demo that plays when you wait in the main menu. Can also skip the story movies between chapters. + +*Id* `intro-skip` · **modifies groove.exe** (see [antivirus warnings](20-troubleshooting.md)) + +| Option | Values | Default | Description | +|---|---|---|---| +| Skip startup logos | on / off | on | The Disney and Argonaut logo movies and the publisher screen. | +| Disable attract demo | on / off | on | The game no longer plays a demo after a minute in the main menu. | +| Skip story movies | on / off | off | The movies before and after chapters. The credits still play. | + +#### Mirror mode + +Plays every level mirrored left to right, like the mirror mode of racing games. The controls stay the same. + +*Id* `mirror` + +| Option | Values | Default | Description | +|---|---|---|---| +| Worlds | text | all | "all", or the numbers of the worlds to mirror, e.g. "1,3" (1 = Village, 9 = TestLevel). | +| Readable textures | on / off | off | Flips writing and pictures back so they read normally. Some textures (water edges, paths) then show seams. Off: a true mirror image. | +| Mirror object models | on / off | off | Also mirrors the shape of objects such as huts and crates (characters stay as they are). Off: objects are only placed mirrored. | + +#### Practice tools + +Practise hard jumps: press F5 to remember where the llama stands and F8 to jump back there (in the same level; the keys can be changed below). Also adds extra checkpoints along the coin trail of levels that have checkpoints. + +*Id* `practice` · **modifies groove.exe** (see [antivirus warnings](20-troubleshooting.md)) + +| Option | Values | Default | Description | +|---|---|---|---| +| Save position key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F5 | Remembers where the llama stands. | +| Restore position key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F8 | Puts the llama back to the remembered place. | +| Extra checkpoints | on / off | on | More checkpoints along the coin trail, in levels that already have checkpoints. | +| Checkpoint spacing | 4 – 40 | 10 | Distance between two checkpoints in tiles. Smaller = more checkpoints. | + +#### Randomizer + +Moves coins and food (and, if you like, pots and enemies) to other places in each level. Every coin stays reachable, and the way through the level does not change. The same seed always gives the same layout, so friends can play the same one. + +*Id* `randomizer` + +| Option | Values | Default | Description | +|---|---|---|---| +| Seed | any text | random | Any word or number. The same seed gives the same layout. | +| Coins | on / off | on | Shuffle the coins. | +| Health and food | on / off | on | Shuffle food and health pickups. | +| Mix coins and food | on / off | on | Coins and food may swap places. Off: coins only trade places with coins, food with food. | +| Pots and jars | on / off | off | Shuffle the pots and jars you can smash. | +| Enemies | on / off | off | Enemies that stand still swap places with others of the same kind. | + +### Visual mods + +#### Draw distance + +See further in every level: the fog moves back and distant parts of the level stay visible. Objects and enemies also come to life from further away. + +*Id* `fog` + +| Option | Values | Default | Description | +|---|---|---|---| +| Draw distance (%) | 50 – 600 | 250 | 100 = like the original, 250 = two and a half times as far. Below 100 the fog gets thicker. | +| Fog | Scaled, Off | Scaled | Scaled: the fog fades in further away. Off: no fog – the world ends abruptly at the draw distance. | + +### Cheats & debug mods + +#### Built-in cheats + +Switches on the game's secret cheat codes for you, so you don't have to type the arrow-key sequences in the main menu. Tick the cheats you want. + +*Id* `cheats` · **modifies groove.exe** (see [antivirus warnings](20-troubleshooting.md)) + +| Option | Values | Default | Description | +|---|---|---|---| +| Infinite health | on / off | on | Hits and falls into water cost no energy, so you never lose a life (code ← → → → ↑ → → ↑). | +| Start with 50 collectibles | on / off | off | The pink idol counter starts at 50 in every chapter (code ↓ ← → ↓ ↓ ← → ↓). | +| Complete chapter rewards | on / off | off | Finishing a chapter also gives you its collectible and gold coin (code → → ↓ ↓ ↓ ↑ ↓ ↓). | +| Start with 500 fruit | on / off | off | The purple fruit counter starts at 500 in every chapter (code ↑ ↓ → ← ← ← ← ↓). | +| Full charge meter | on / off | off | The charge bar starts full in every chapter (code ↑ ← ↓ → ↓ ↓ ↑ ↑). | +| Write display diagnostics | on / off | off | For graphics problems: the game writes a report of your graphics devices (.dispdiag) into the game folder. Same as its hidden -display_diagnostics switch. | + +#### Debug wireframe + +Shows what is normally invisible: the walls and floors the llama collides with (also invisible ones) as coloured lines, and markers for cameras, triggers, exits and checkpoints. + +*Id* `debug-wireframe` + +#### Free camera & noclip + +Press F7 to fly the camera freely around the level: arrow keys move, A/S turn, PgUp/PgDn look up and down, X/Z rise and sink, Shift or Space go faster. Press F9 for noclip: the llama flies through walls (arrow keys, X/Z up and down). Press the key again to switch off (the keys can be changed below). + +*Id* `freecam` · **modifies groove.exe** (see [antivirus warnings](20-troubleshooting.md)) + +| Option | Values | Default | Description | +|---|---|---|---| +| Free camera key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F7 | Switches the free camera on and off. | +| Noclip key | F1, F2, F3, F4, F5, F7, F8, F9, F11, F12, Insert, Delete, Home, End | F9 | Switches noclip (fly through walls) on and off. | +| Pause game during free camera | on / off | on | Enemies and objects freeze while the free camera is on. | +| Camera speed | 1 – 32 | 3 | How fast the free camera flies (1 = slow, 32 = very fast). | +| Noclip speed | 1 – 32 | 2 | How fast the llama flies in noclip (1 = slow, 32 = very fast). | + + +## Pictures + +![Mirror mode in the village](img/mod_mirror.jpg) + +![Draw distance: the city at 400 %](img/mod_fog.jpg) + +![Free camera above the village](img/mod_freecam.jpg) diff --git a/docs/manual/19-shortcuts.md b/docs/manual/19-shortcuts.md new file mode 100644 index 0000000..a002c59 --- /dev/null +++ b/docs/manual/19-shortcuts.md @@ -0,0 +1,277 @@ +# Keyboard, mouse and menu reference + +The tables in this chapter (except the mouse table) are generated from the editor's own list of +tools and commands, so they always match your version – including plugins. **Help → Shortcut +sheet (printable)…** prints the keys on one page. + +Single keys without **Ctrl** or **Alt** (tool keys, **F**, **H**, **Delete** …) work while the +mouse is over the viewport or nothing is being typed; they never fire while a text field has the +focus. + +## Mouse + +| Input | Action | +|---|---| +| Click / **Shift+click** / **Ctrl+click** | select / add / toggle | +| Drag in empty space | box selection (**Shift** adds, **Ctrl** removes) | +| Double-click | select and frame | +| **Right mouse** held + mouse | look around; **W A S D** fly, **Q** / **E** down / up, **Shift** faster, wheel = fly speed | +| Mouse wheel | zoom towards the cursor | +| **Ctrl+middle** / **Alt+right** drag | zoom | +| **Alt+left** drag | orbit | +| **Middle** drag | pan (Fusion 360, Unity / Unreal) or orbit (Blender) | +| **Shift+middle** drag | orbit (Fusion 360), pan (Blender, Unity / Unreal) | +| Double-click **middle** | frame the whole level (Fusion 360) | +| View cube: click / drag / house | look from that side / orbit / home view | +| **Ctrl** while dragging | invert snapping | +| **Esc** while dragging | cancel the drag | + +The middle-button bindings follow the navigation preset (**View → Navigation**, see +[Navigation presets](04-navigation.md#navigation-presets)); **Help → Viewport navigation** shows +the cheat sheet of the active preset. + +## Keys + +Every tool has its own single key, and no key is used twice (a unit test checks this for the +built-in tools and every plugin): + +* **Q W E R** – select, move, rotate, scale (as in most 3D programs) +* Geometry: **B** brush, **T** tiles, **K** collision, **M** paint texture (material), **V** paint + vertex colours +* Objects: **P** place, **O** coin line / arc, **N** waypoint paths (nodes), **J** zones & + triggers +* Environment: **Z** teleport / environment zones, **U** lights (bulb), **Y** visibility regions +* Commands use the other letters (**F** frame, **G** snap, **H** hide, **L** lock, **I** + eyedropper, **C** collision view, **X** local space) or **Ctrl** / **Shift** combinations. + + +### Tools + +| Key | Tool | What it does | +|---|---|---| +| **B** | Brush | Brush: draw blocks, platforms, ramps, stairs, walls, water … on the ground or on surfaces; drag the handles of a selected brush to resize it | +| **E** | Rotate | Rotate: green ring = turn about the vertical axis (everything); red/blue rings turn brushes freely (tiles and objects only turn about the vertical axis) | +| **J** | Zones & triggers | Zones & triggers (J): drag the corner handles of box zones and the radius knob of triggers | +| **K** | Collision | Collision: paint collision materials (exit floor 16, death 30, …) per polygon or brush face, switch brush faces' collision on/off | +| **M** | Paint texture | Click faces to give them the current texture; Shift+click picks a face's texture (eyedropper) | +| **N** | Waypoint paths | Waypoint paths (N): select an object, click to add nodes, drag nodes, Del removes | +| **O** | Coin line / arc | Coin line / arc (O): two clicks place a line or a circle of coins | +| **P** | Place objects | Place objects (P): click to place the palette type on the ground, drag objects to move, drag the knob to rotate | +| **Q** | Select | Select: click, Shift+click adds, Ctrl+click toggles, drag for a box | +| **R** | Scale | Scale: brushes (floors, boxes, meshes) – drag a gizmo axis or the centre | +| **T** | Tiles | Tiles: paint tile models onto grid cells, pick, erase, raise/lower and swap tiles | +| **U** | Lights | Place point lights: click to place, drag to set the radius | +| **V** | Paint vertex colours | Vertex colour brush for tiles (Ctrl+click picks a colour) | +| **W** | Move | Move: drag the gizmo arrows/planes, or drag an object over the ground | +| **Y** | Paint regions | Paint visibility regions on grid cells (Ctrl+click picks a region) | +| **Z** | Zones | Teleport / environment zones: click a cell to add a zone, click again for its target | + +### Commands with a shortcut + +| Key | Command | Menu | +|---|---|---| +| **Ctrl+N** | New level | File | +| **Ctrl+Shift+O** | Open original level | File | +| **Ctrl+O** | Open project | File | +| **Ctrl+S** | Save | File | +| **Ctrl+Shift+S** | Save as | File | +| **Ctrl+W** | Close level | File | +| **Ctrl+Q** | Close editor | File | +| **Ctrl+Z** | Undo | Edit | +| **Ctrl+Y** | Redo | Edit | +| **Ctrl+X** | Cut | Edit | +| **Ctrl+C** | Copy | Edit | +| **Ctrl+V** | Paste | Edit | +| **Ctrl+D** | Duplicate | Edit | +| **Delete** | Delete | Edit | +| **Ctrl+Shift+C** | Copy objects (for other levels) | Edit | +| **Ctrl+Shift+V** | Paste objects at view centre | Edit | +| **Ctrl+A** | Select all | Edit | +| **Ctrl+Shift+A** | Select none | Edit | +| **Shift+S** | Select similar | Edit | +| **H** | Hide selection | Edit | +| **Alt+H** | Show all hidden | Edit | +| **L** | Lock selection | Edit | +| **I** | Texture eyedropper | Edit | +| **Ctrl+,** | Preferences | Edit | +| **F** | Frame selection | View | +| **Home** | Frame level | View | +| **Numpad 5** | Perspective | View → Camera | +| **Numpad 7** | Top | View → Camera | +| **Numpad 1** | Front | View → Camera | +| **Numpad 3** | Side | View → Camera | +| **Ctrl+Shift+L** | Look through camera (pilot) | View → Camera | +| **X** | Gizmo in local space | View | +| **G** | Snap to grid | View | +| **C** | Show collision | View | +| **Ctrl+Shift+T** | Texture manager | View → Panels | +| **Shift+V** | Vertex colours only | View | +| **Ctrl+B** | Build | Level | +| **F5** | Play | Level | +| **Shift+F5** | Play from camera position | Level | +| **Ctrl+Shift+I** | Import objects of a world | Level | +| **Ctrl+[** | Turn left 90° | Geometry | +| **Ctrl+]** | Turn right 90° | Geometry | +| **Ctrl+Shift+D** | Array duplicate | Geometry | +| **F1** | Manual | Help | +| **Shift+F1** | Getting started | Help | +| **Ctrl+F1** | Keyboard shortcuts | Help | +| **Ctrl+Shift+Z** | Redo | (keyboard only) | + + +## Keys inside tools + +| Tool | Keys | +|---|---| +| Brush | **Enter** type the height, **Esc** cancel, **[** / **]** turn 90°, **Page Up** / **Page Down** raise / lower | +| Tiles | **[** / **]** rotation, **Page Up** / **Page Down** height, **Shift+click** pick | +| Paint texture | **Shift+click** pick a face's texture | +| Collision | **Shift+click** pick a material | +| Waypoint paths | **Ctrl+click** insert before, **Backspace** / **Delete** remove a node | +| Zones | **Shift+drag** set the facing, **[** / **]** turn | +| Paint vertex colours, Paint regions | **Ctrl+click** pick | + +## In the game during a play-test + +| Key | Action | +|---|---| +| **Escape** | quit straight back to the editor (Level → Escape quits the play-test) | + +## Menu reference + +Every menu command with its shortcut and a short description. + + +### File menu + +| Command | Key | What it does | +|---|---|---| +| New level… | **Ctrl+N** | Create a new level from a template | +| Open original level… | **Ctrl+Shift+O** | Edit one of the game's levels | +| Open project… | **Ctrl+O** | Open a .llamalevel project | +| Save | **Ctrl+S** | Save the project | +| Save as… | **Ctrl+Shift+S** | Save the project under a new name | +| Close level | **Ctrl+W** | Close the project | +| Import mesh (OBJ, glTF)… | | Import an OBJ or glTF model as level geometry (preview, size, textures, collision) | +| Import texture… | | Import PNG/JPG images as textures (colour reduction, blend class, packing) | +| Export level WAD… | | Build the level and save the game file (.wad) somewhere | +| Export selection as OBJ… | | Mesh, UVs, vertex colours, textures (PNG) and collision for Blender | +| Close editor | **Ctrl+Q** | Close the editor | + +### Edit menu + +| Command | Key | What it does | +|---|---|---| +| Undo | **Ctrl+Z** | Undo the last change | +| Redo | **Ctrl+Y** | Redo what was undone | +| History | | Show all changes; click one to go back to it | +| Cut | **Ctrl+X** | Cut the selection | +| Copy | **Ctrl+C** | Copy the selection | +| Paste | **Ctrl+V** | Paste next to the original | +| Duplicate | **Ctrl+D** | Duplicate the selection (one cell east) | +| Delete | **Delete** | Delete the selection (waypoints: the chain is relinked) | +| Copy objects (for other levels) | **Ctrl+Shift+C** | Copies objects with their waypoints and brushes to the system clipboard (paste into any level) | +| Paste objects at view centre | **Ctrl+Shift+V** | | +| Select all | **Ctrl+A** | Select everything that is visible | +| Select none | **Ctrl+Shift+A** | Clear the selection | +| Select similar | **Shift+S** | Objects of the same type / tiles of the same model | +| Hide selection | **H** | Hide the selection in the viewport (editor only: it is still built – tiles have Exclude from build in the inspector) | +| Show all hidden | **Alt+H** | Show everything that was hidden | +| Lock selection | **L** | Locked items stay visible but cannot be clicked in the viewport | +| Replace texture pixels… | | Replace the current texture's pixels with an image | +| Texture eyedropper | **I** | Pick the texture of a face in the viewport | +| Preferences… | **Ctrl+,** | Autosave, play-test save slot, theme | + +### View menu + +| Command | Key | What it does | +|---|---|---| +| Frame selection | **F** | Frame the selection (F) | +| Frame level | **Home** | Show the whole level (Home) | +| Navigation → Fusion 360 (on/off) | | Mouse bindings like Fusion 360 | +| Navigation → Blender (on/off) | | Mouse bindings like Blender | +| Navigation → Unity / Unreal (on/off) | | Mouse bindings like Unity / Unreal | +| Camera → Perspective | **Numpad 5** | Back to the perspective view | +| Camera → Top | **Numpad 7** | Look straight down (orthographic) | +| Camera → Front | **Numpad 1** | Front view (orthographic) | +| Camera → Side | **Numpad 3** | Side view (orthographic) | +| Navigation → View cube (on/off) | | | +| Navigation → Home view | | | +| Gizmo in local space (on/off) | **X** | Gizmo axes in world or local space (X) | +| Snap to grid (on/off) | **G** | Snap moves and rotations to the grid (G; Ctrl while dragging inverts it) | +| Show collision (on/off) | **C** | Show collision polygons coloured by material (C) | +| Camera → Look through camera (pilot) (on/off) | **Ctrl+Shift+L** | | +| Navigation → Viewport preferences… | | | +| Show waypoints of all objects (on/off) | | | +| Dark theme (on/off) | | Switch between the light and dark theme | +| Show back faces (on/off) | | Draw the back sides of one-sided faces (invisible in the game) hatched instead of hiding them | +| Fog preview (on/off) | | Game fog of the zone the camera is in | +| Light objects (LGHT preview) (on/off) | | | +| Vertex colours only (on/off) | **Shift+V** | Show the baked/painted vertex colours without textures | +| Panels → Texture manager | **Ctrl+Shift+T** | Pages, textures, usage and animations | + +### Level menu + +| Command | Key | What it does | +|---|---|---| +| Level settings… | | Name, chapter, grid size, death plane, feature flags | +| Check for problems | | List problems that would break the level (missing start, exits …) | +| Build | **Ctrl+B** | Validate the level and build its WAD (in the background) | +| Play | **F5** | Build the level, install it as a mod and start the game directly in it (no menus; your mod selection is restored when the game exits) | +| Play from camera position | **Shift+F5** | Play-test with the llama standing on the ground point in the middle of the view, facing the camera direction | +| Play boots straight into the level (on/off) | | Off: Play starts at the main menu with the map cursor on the level (no change to groove.exe) | +| Escape quits the play-test (on/off) | | In a play-test the pause key (Escape) ends the game at once, back to the editor | +| Bake lighting… | | | +| Environment zones | | | +| Visibility regions | | | +| Import objects of a world… | **Ctrl+Shift+I** | Copy object types (enemies, pickups, platforms …) of another world into this level | +| Rebuild object catalogue | | Scan all levels of the CD again (thumbnails, sizes) | + +### Geometry menu + +| Command | Key | What it does | +|---|---|---| +| Brush tool | | Draw blocks, platforms, ramps, stairs, walls, water … (B) | +| Tiles tool | | Paint, pick, erase, raise and swap tiles (T) | +| Collision tool | | Paint collision materials, switch brush faces' collision (K) | +| Align → Align West (min X) | | Line the selection up with the last selected item | +| Align → Align Centre X | | Line the selection up with the last selected item | +| Align → Align East (max X) | | Line the selection up with the last selected item | +| Turn left 90° | **Ctrl+[** | | +| Turn right 90° | **Ctrl+]** | | +| Mirror X (east ↔ west) | | | +| Mirror Z (north ↔ south) | | | +| Array duplicate… | **Ctrl+Shift+D** | n copies with an offset (and turn) each | +| Align → Align Bottom | | Line the selection up with the last selected item | +| Align → Align Middle Y | | Line the selection up with the last selected item | +| Align → Align Top | | Line the selection up with the last selected item | +| Align → Align North (min Z) | | Line the selection up with the last selected item | +| Align → Align Centre Z | | Line the selection up with the last selected item | +| Align → Align South (max Z) | | Line the selection up with the last selected item | +| Grid & snapping… | | Move/angle/scale steps (any value), grid display | +| Death plane height… | | | +| Re-import mesh… | | Load the selected mesh brush's file again (or another file) and keep its placement | +| Save selection to library… | | Tiles, brushes and meshes as a reusable asset (with textures) for any level | +| Align → Distribute along X | | | +| Align → Distribute along Z | | | + +### Tools menu + +| Command | Key | What it does | +|---|---|---| +| Object palette | | | +| Save selection as prefab… | | | +| Repair waypoint chains | | Stores every waypoint chain in one piece (needed by the game) | + +### Help menu + +| Command | Key | What it does | +|---|---|---| +| Manual | **F1** | The complete user manual: contents, search, tutorials. F1 over a panel, tool or dialog opens the matching chapter | +| Export manual as HTML… | | Save the manual as HTML pages (one per chapter) to share it or read it in a browser | +| Getting started | **Shift+F1** | A short illustrated guide: from a new level to play-testing and sharing it | +| Keyboard shortcuts | **Ctrl+F1** | All keyboard and mouse shortcuts (the complete list is in the manual) | +| Viewport navigation | | | +| Shortcut sheet (printable)… | | All shortcuts on one page – print it or save it as HTML | +| About the level editor | | Version, OpenGL and plugin information | + diff --git a/docs/manual/20-troubleshooting.md b/docs/manual/20-troubleshooting.md new file mode 100644 index 0000000..c80c04e --- /dev/null +++ b/docs/manual/20-troubleshooting.md @@ -0,0 +1,118 @@ +# Troubleshooting and FAQ + +## Setting up and playing + +| Problem | Solution | +|---|---| +| "This file is not a CD image (ISO)" | You picked another kind of file. Choose the game's `.iso` file, or use **Folder / CD…** for the CD or a copied folder. | +| "The game was not found here" | The folder is not the game CD. Choose the CD drive itself or a folder you copied the whole CD into – it contains a folder named `Setup`. | +| "The ISO file is incomplete" | The download or copy of the ISO was interrupted. Copy or download it again. | +| "Some game files are damaged" | The CD or ISO copy is faulty. Try another copy, or clean the CD. | +| "Not enough free disk space" | The game copy needs about 900 MB. Free up space or choose another **Data folder**. | +| The game hangs or its window is gone | Click **■ Quit game** in LlamaMod (the Play button while the game runs). | +| "The game crashed" | Switch off the mods you turned on last, or click **Restore original**, and play again. If the original game crashes too, report it with `game.log` (**Settings → Logs**). | +| "groove.exe was removed or changed" | Click **Rebuild**; LlamaMod writes it again and then starts the game. On Windows, see [Antivirus warnings](#antivirus-warnings). | + +## The editor + +| Problem | Solution | +|---|---| +| "OpenGL 3.3 is not available" or a black viewport | Update the graphics driver. In a virtual machine, switch on 3D acceleration. Without a usable driver the editor can still run with software OpenGL, but slowly. | +| The editor is slow in a big level | Hide what you do not need (**H**, or switch layers off), close the Textures window, use the top view for large moves. | +| A key does nothing | Single-letter keys do not work while a text field has the focus – click into the viewport first. Some keys are shared by two tools (see [Keys](19-shortcuts.md#keys)); use the toolbar button. | +| I lost my changes after a crash | Start the editor again and choose **Recover** – autosave keeps unsaved changes (Edit → Preferences sets how often). | +| A panel is gone | **View → Panels** opens it again. | +| An object type I want is not in the list | The Objects list shows the base level's types and the ones you imported. Import the type from **Assets → All worlds** ([Importing objects from other worlds](09-objects.md#importing-objects-from-other-worlds)). | + +## Building and play-testing + +| Problem | Solution | +|---|---| +| Build or Play is refused | The Problems panel lists errors; fix them first (double-click a message to find the item). | +| "The game is still running" | Level files cannot be replaced while the game runs – click **Quit game and play** in the message, or quit the game yourself. | +| The llama falls through the floor | Show collision (**C**). The floor needs collision: check the brush's collision faces in **Brush shape**, or paint the floor with the **Collision** tool. | +| **Play from camera position** says there is no floor | Point the middle of the view at walkable ground. Tree crowns, roofs and decoration often have no collision. | +| The exit does not work | The LevelDoor needs **exit floor (16)** in its cell. The **Exit** setup in the Objects panel places both together. | +| The level does not load / the game crashes | **Level → Check for problems**; keep tiles inside the grid; exactly one player start; no tile model over 8000 vertices. | +| My level is missing on the world map | New levels are in world 9 (TestLevel). Unlock it in LlamaMod's **Save games** tab, or play with **F5**. | +| Two levels show the same content | They use the same TestLevel chapter – change the **TestLevel chapter** option of one of them. | +| Objects near the start are invisible at first | Also true in the original levels: objects within a few cells of the start appear after a moment. | + +## Windows + +### Antivirus warnings + +Some mods (and every play-test with **Play boots straight into the level**) add code to +**groove.exe** in LlamaMod's private game copy. Antivirus programs such as Windows Defender may +report this changed file as a threat – a false positive: your original files are untouched and +nothing is downloaded. + +![LlamaMod's antivirus notice](img/app_av.jpg) + +* On Windows, LlamaMod shows a notice before it applies such mods for the first time (Linux + has no such problem and shows none). +* If groove.exe was removed or blocked, LlamaMod says so and offers **Rebuild**. +* To stop it from happening again, add the LlamaMod data folder as an exclusion: Windows + Security → Virus & threat protection → Manage settings → Exclusions → Add or remove exclusions + → Add an exclusion → Folder. +* Or switch off **Level → Play boots straight into the level**: play-tests then start at the + main menu and do not change groove.exe. + +### Windows in a virtual machine + +LlamaMod and the editor work in a virtual machine with 3D acceleration, but the game's old +DirectX 6/7 graphics often do not: "Direct Draw kann nicht gestartet werden" or an upside-down +picture are typical. Use a real Windows PC, or play under Wine on Linux. On real hardware no +extra wrapper should be needed. + +## Linux + +* The game runs through **Wine**; install it from your distribution. **Settings → Wine command** + points LlamaMod at a specific Wine (leave it empty for the installed one); the line below it + says whether that Wine was found. +* The first **Play** prepares Wine for the game, which can take a minute. +* **Settings → Run in a window** (Wine virtual desktop) is recommended – otherwise the game + switches the whole screen to its resolution. +* LlamaMod uses its own Wine prefix in the data folder, so your other Wine programs are not + affected. + +## The mod cache + +LlamaMod keeps built mod files in the data folder's `cache` folder. If something looks wrong +after an update, **Settings → Clear cache** throws it away; active mods are rebuilt on the next +**Apply**. + +## Known limits + +* Object types from other levels can be imported, but imports are permanent and limited by the + engine's 16 MB level memory, 128 sounds and 127 texture pages + ([Importing objects](09-objects.md#importing-objects-from-other-worlds)). +* Tiles and objects only turn about the vertical axis; only brushes scale and turn freely. +* The engine has no free texture coordinates: every triangle shows one half of a texture + rectangle ([Textures](08-textures.md)). +* At most 8000 vertices per tile model and 127 texture pages per level. +* Lights only light object models; tiles use baked vertex colours. +* Texture animations are not animated in the viewport (the texture manager previews them). +* Many object parameters and some data fields are not understood yet – they are shown raw. +* The editor was tested mainly on Linux; Windows works, but real Windows hardware has seen less + testing. + +## FAQ + +**Do I need the German version of the game?** +The tools were developed with the German CD. Other language versions use the same engine and +should work; if one does not, please report it. + +**Can I share levels with people who do not have the game?** +No – levels are built from the receiver's own game copy. That is also what makes sharing legal: +a project contains no game data. + +**Does the editor change my installed game or my saves?** +No. LlamaMod works on its own copy in the data folder. Play-tests use one save slot of that copy +(slot 3 by default) and back it up first. + +**Can I edit a level I downloaded?** +Yes – open the `.llamalevel` file with **File → Open project**. + +**Where are my levels?** +In the `levels` folder of the data folder (**Settings → Data folder → Open folder** in LlamaMod). diff --git a/docs/manual/21-glossary.md b/docs/manual/21-glossary.md new file mode 100644 index 0000000..3878874 --- /dev/null +++ b/docs/manual/21-glossary.md @@ -0,0 +1,54 @@ +# Glossary + +| Term | Meaning | +|---|---| +| **Ambient colour** | The base light of object models in a zone, set in the zone's environment. | +| **Base level** | The original level a project is built on. It provides the object types, sounds, sky and default textures. Fixed when the project is created. | +| **Blend class** | How a texture is drawn: opaque, cut-out, semi-transparent, additive glow or darken. | +| **Brush** | An editor shape (block, platform, ramp, stairs, wall, cylinder, floor, fence, tree, water, death plane, mesh). Converted into tiles when the level is built. | +| **Build** | Turning the project into the game's level file (`.wad`): brushes become tiles, textures are packed, everything is checked. | +| **Cell** | One grid square of the level, the unit of all positions (4096 game units). | +| **Chain** | See *waypoint chain*. | +| **Chapter** | One level of a world, for example Village chapter 2. The TestLevel world has 49 chapter slots for new levels. | +| **Checkpoint** | A *CheckPointPost* object; the llama respawns at its respawn waypoint after losing a life. | +| **Collision material** | A number on every collision polygon: 0 normal, 16 exit floor, 30 no floor (death), others special. | +| **Collision polygon** | The invisible surfaces the llama walks on and bumps into. They are separate from what you see. | +| **Cut-out** | A texture with fully transparent pixels (leaves, fences). | +| **Death plane** | An invisible material-30 floor below the level: falling onto it costs a life. | +| **Exit floor** | Collision with material 16. The LevelDoor object ends the level when the llama stands on exit floor in the door's cell. | +| **Face** | A triangle of a tile model with one texture. | +| **Fog** | The distance fade of a zone; its end distance is also how far the game draws. | +| **Gizmo** | The arrows and rings in the viewport for moving and rotating the selection. | +| **Grid** | The rectangle of cells that makes up the level. Collision outside it does not work. | +| **groove.exe** | The game program. Some mods change LlamaMod's copy of it. | +| **Inspector** | The panel with the properties of the selection. | +| **Layer** | A part of the viewport display that can be switched on and off (tiles, collision, waypoints …). | +| **`.llamalevel`** | A level project file: your changes, imported meshes and textures, a thumbnail. No game data. | +| **`.llamamesh`** | A library asset: reusable geometry with its textures. | +| **LevelDoor** | The exit object; its parameters choose the next level. | +| **Library** | The editor's store of reusable geometry, shared by all levels. | +| **Llama** | The player object; its position is the player start. | +| **Marker** | A symbol in the viewport for an object without a model (camera, trigger, exit …). | +| **Mesh brush** | An imported OBJ or glTF model placed in the level. | +| **Mod** | A change to the game that LlamaMod applies to its private game copy. Every level project is a mod. | +| **Object** | Anything with behaviour: player, coins, enemies, doors, cameras, triggers. The game calls them *strats*. | +| **Outliner** | The panel with the tree of everything in the level. | +| **Page** | A 256×256 texture image; textures are rectangles on pages. | +| **Play-test** | Building the level and starting the game directly in it (**F5**). | +| **Prefab** | A saved group of objects (with waypoints and brushes) for reuse. | +| **Region** | A visibility region: cells grouped so the game can skip drawing parts of the level that cannot be seen. | +| **Respawn node** | The waypoint where the llama reappears after losing a life at a checkpoint. | +| **Snapping** | Moves and rotations in fixed steps (**G**). | +| **Start zone** | The zone whose fog, colours and music apply when the level starts. | +| **Strat** | The game's name for an object and its script. | +| **TestLevel** | The hidden world 9 with 49 empty chapters; new levels go there. | +| **Teleport** | A zone that moves the player to a target point when an object (usually a door) in its trigger cell fires. | +| **Tile** | A tile model placed in a cell, turned in steps about the vertical axis. | +| **Tile model** | A 3D model of the level's tile set, with faces, textures and collision. | +| **Tile swap** | An alternative model of a tile that the game switches to (opening doors, broken bridges). | +| **Vertex colours** | Colours stored per corner of a tile; they carry the lighting of the level. | +| **WAD** | The game's level file (`t1l1m001.wad` = Village chapter 1). | +| **Waypoint chain** | A list of points belonging to an object: a patrol path, a camera path, a respawn point or the corners of a box. | +| **Work plane** | A fixed height the Brush tool draws on instead of the surface under the cursor. | +| **World** | A group of chapters on the game's map: Village, Jungle at Night, River, Jungle by Day, Mountain, City, Catacombs, Lab (and the hidden TestLevel). | +| **Zone** | An environment entry: fog, colours, music, sky – and for teleports a trigger cell and a target. | diff --git a/docs/manual/img/app_av.jpg b/docs/manual/img/app_av.jpg new file mode 100644 index 0000000..72e0e27 Binary files /dev/null and b/docs/manual/img/app_av.jpg differ diff --git a/docs/manual/img/app_mods.jpg b/docs/manual/img/app_mods.jpg new file mode 100644 index 0000000..035db60 Binary files /dev/null and b/docs/manual/img/app_mods.jpg differ diff --git a/docs/manual/img/app_saves.jpg b/docs/manual/img/app_saves.jpg new file mode 100644 index 0000000..31c0930 Binary files /dev/null and b/docs/manual/img/app_saves.jpg differ diff --git a/docs/manual/img/app_wizard.jpg b/docs/manual/img/app_wizard.jpg new file mode 100644 index 0000000..cddbf14 Binary files /dev/null and b/docs/manual/img/app_wizard.jpg differ diff --git a/docs/manual/img/env_bake_night.jpg b/docs/manual/img/env_bake_night.jpg new file mode 100644 index 0000000..ac05b60 Binary files /dev/null and b/docs/manual/img/env_bake_night.jpg differ diff --git a/docs/manual/img/env_game_fog.jpg b/docs/manual/img/env_game_fog.jpg new file mode 100644 index 0000000..6a7ec9a Binary files /dev/null and b/docs/manual/img/env_game_fog.jpg differ diff --git a/docs/manual/img/env_game_night.jpg b/docs/manual/img/env_game_night.jpg new file mode 100644 index 0000000..03b94db Binary files /dev/null and b/docs/manual/img/env_game_night.jpg differ diff --git a/docs/manual/img/env_lights.jpg b/docs/manual/img/env_lights.jpg new file mode 100644 index 0000000..f8ac54d Binary files /dev/null and b/docs/manual/img/env_lights.jpg differ diff --git a/docs/manual/img/env_paint.jpg b/docs/manual/img/env_paint.jpg new file mode 100644 index 0000000..510ca13 Binary files /dev/null and b/docs/manual/img/env_paint.jpg differ diff --git a/docs/manual/img/env_region_preview.jpg b/docs/manual/img/env_region_preview.jpg new file mode 100644 index 0000000..d631837 Binary files /dev/null and b/docs/manual/img/env_region_preview.jpg differ diff --git a/docs/manual/img/env_regions.jpg b/docs/manual/img/env_regions.jpg new file mode 100644 index 0000000..599c313 Binary files /dev/null and b/docs/manual/img/env_regions.jpg differ diff --git a/docs/manual/img/env_settings.jpg b/docs/manual/img/env_settings.jpg new file mode 100644 index 0000000..21378d5 Binary files /dev/null and b/docs/manual/img/env_settings.jpg differ diff --git a/docs/manual/img/env_zone.jpg b/docs/manual/img/env_zone.jpg new file mode 100644 index 0000000..f36bd3d Binary files /dev/null and b/docs/manual/img/env_zone.jpg differ diff --git a/docs/manual/img/geo_build_ok.jpg b/docs/manual/img/geo_build_ok.jpg new file mode 100644 index 0000000..173ff71 Binary files /dev/null and b/docs/manual/img/geo_build_ok.jpg differ diff --git a/docs/manual/img/geo_collision.jpg b/docs/manual/img/geo_collision.jpg new file mode 100644 index 0000000..5870b76 Binary files /dev/null and b/docs/manual/img/geo_collision.jpg differ diff --git a/docs/manual/img/geo_draw.jpg b/docs/manual/img/geo_draw.jpg new file mode 100644 index 0000000..6bcd8a2 Binary files /dev/null and b/docs/manual/img/geo_draw.jpg differ diff --git a/docs/manual/img/geo_game_course.jpg b/docs/manual/img/geo_game_course.jpg new file mode 100644 index 0000000..1ee1597 Binary files /dev/null and b/docs/manual/img/geo_game_course.jpg differ diff --git a/docs/manual/img/geo_game_death.jpg b/docs/manual/img/geo_game_death.jpg new file mode 100644 index 0000000..04d1231 Binary files /dev/null and b/docs/manual/img/geo_game_death.jpg differ diff --git a/docs/manual/img/geo_game_invisible_wall.jpg b/docs/manual/img/geo_game_invisible_wall.jpg new file mode 100644 index 0000000..451fe77 Binary files /dev/null and b/docs/manual/img/geo_game_invisible_wall.jpg differ diff --git a/docs/manual/img/geo_handles.jpg b/docs/manual/img/geo_handles.jpg new file mode 100644 index 0000000..64f8035 Binary files /dev/null and b/docs/manual/img/geo_handles.jpg differ diff --git a/docs/manual/img/geo_import.jpg b/docs/manual/img/geo_import.jpg new file mode 100644 index 0000000..e3e04b7 Binary files /dev/null and b/docs/manual/img/geo_import.jpg differ diff --git a/docs/manual/img/geo_library.jpg b/docs/manual/img/geo_library.jpg new file mode 100644 index 0000000..2d7c68c Binary files /dev/null and b/docs/manual/img/geo_library.jpg differ diff --git a/docs/manual/img/geo_library_insert.jpg b/docs/manual/img/geo_library_insert.jpg new file mode 100644 index 0000000..1176d03 Binary files /dev/null and b/docs/manual/img/geo_library_insert.jpg differ diff --git a/docs/manual/img/geo_menu.jpg b/docs/manual/img/geo_menu.jpg new file mode 100644 index 0000000..b44b2d7 Binary files /dev/null and b/docs/manual/img/geo_menu.jpg differ diff --git a/docs/manual/img/geo_rotate3d.jpg b/docs/manual/img/geo_rotate3d.jpg new file mode 100644 index 0000000..7e870d4 Binary files /dev/null and b/docs/manual/img/geo_rotate3d.jpg differ diff --git a/docs/manual/img/geo_tiles.jpg b/docs/manual/img/geo_tiles.jpg new file mode 100644 index 0000000..f2cbe05 Binary files /dev/null and b/docs/manual/img/geo_tiles.jpg differ diff --git a/docs/manual/img/manual_search.jpg b/docs/manual/img/manual_search.jpg new file mode 100644 index 0000000..02ff4c0 Binary files /dev/null and b/docs/manual/img/manual_search.jpg differ diff --git a/docs/manual/img/mod_all.jpg b/docs/manual/img/mod_all.jpg new file mode 100644 index 0000000..d8fe4b8 Binary files /dev/null and b/docs/manual/img/mod_all.jpg differ diff --git a/docs/manual/img/mod_fog.jpg b/docs/manual/img/mod_fog.jpg new file mode 100644 index 0000000..34aa7bc Binary files /dev/null and b/docs/manual/img/mod_fog.jpg differ diff --git a/docs/manual/img/mod_freecam.jpg b/docs/manual/img/mod_freecam.jpg new file mode 100644 index 0000000..888b998 Binary files /dev/null and b/docs/manual/img/mod_freecam.jpg differ diff --git a/docs/manual/img/mod_mirror.jpg b/docs/manual/img/mod_mirror.jpg new file mode 100644 index 0000000..d44d782 Binary files /dev/null and b/docs/manual/img/mod_mirror.jpg differ diff --git a/docs/manual/img/nav_prefs.jpg b/docs/manual/img/nav_prefs.jpg new file mode 100644 index 0000000..54cb26b Binary files /dev/null and b/docs/manual/img/nav_prefs.jpg differ diff --git a/docs/manual/img/nav_viewcube.jpg b/docs/manual/img/nav_viewcube.jpg new file mode 100644 index 0000000..3ccf954 Binary files /dev/null and b/docs/manual/img/nav_viewcube.jpg differ diff --git a/docs/manual/img/obj_beetle_path.jpg b/docs/manual/img/obj_beetle_path.jpg new file mode 100644 index 0000000..f8e6ccf Binary files /dev/null and b/docs/manual/img/obj_beetle_path.jpg differ diff --git a/docs/manual/img/obj_camera_zone.jpg b/docs/manual/img/obj_camera_zone.jpg new file mode 100644 index 0000000..b89e933 Binary files /dev/null and b/docs/manual/img/obj_camera_zone.jpg differ diff --git a/docs/manual/img/obj_coin_arc.jpg b/docs/manual/img/obj_coin_arc.jpg new file mode 100644 index 0000000..dbf3e93 Binary files /dev/null and b/docs/manual/img/obj_coin_arc.jpg differ diff --git a/docs/manual/img/obj_cutscene_frusta.jpg b/docs/manual/img/obj_cutscene_frusta.jpg new file mode 100644 index 0000000..a5ee0e5 Binary files /dev/null and b/docs/manual/img/obj_cutscene_frusta.jpg differ diff --git a/docs/manual/img/obj_exit_inspector.jpg b/docs/manual/img/obj_exit_inspector.jpg new file mode 100644 index 0000000..51d941d Binary files /dev/null and b/docs/manual/img/obj_exit_inspector.jpg differ diff --git a/docs/manual/img/obj_game_camera_zone.jpg b/docs/manual/img/obj_game_camera_zone.jpg new file mode 100644 index 0000000..809283f Binary files /dev/null and b/docs/manual/img/obj_game_camera_zone.jpg differ diff --git a/docs/manual/img/obj_game_checkpoint.jpg b/docs/manual/img/obj_game_checkpoint.jpg new file mode 100644 index 0000000..ac3a504 Binary files /dev/null and b/docs/manual/img/obj_game_checkpoint.jpg differ diff --git a/docs/manual/img/obj_game_patrol.jpg b/docs/manual/img/obj_game_patrol.jpg new file mode 100644 index 0000000..0a1af37 Binary files /dev/null and b/docs/manual/img/obj_game_patrol.jpg differ diff --git a/docs/manual/img/obj_newlevel_path.jpg b/docs/manual/img/obj_newlevel_path.jpg new file mode 100644 index 0000000..cdc9803 Binary files /dev/null and b/docs/manual/img/obj_newlevel_path.jpg differ diff --git a/docs/manual/img/obj_pilot.jpg b/docs/manual/img/obj_pilot.jpg new file mode 100644 index 0000000..c31c7e8 Binary files /dev/null and b/docs/manual/img/obj_pilot.jpg differ diff --git a/docs/manual/img/obj_place.jpg b/docs/manual/img/obj_place.jpg new file mode 100644 index 0000000..f0a9a58 Binary files /dev/null and b/docs/manual/img/obj_place.jpg differ diff --git a/docs/manual/img/obj_problems.jpg b/docs/manual/img/obj_problems.jpg new file mode 100644 index 0000000..12efd45 Binary files /dev/null and b/docs/manual/img/obj_problems.jpg differ diff --git a/docs/manual/img/obj_trigger_radius.jpg b/docs/manual/img/obj_trigger_radius.jpg new file mode 100644 index 0000000..b54401e Binary files /dev/null and b/docs/manual/img/obj_trigger_radius.jpg differ diff --git a/docs/manual/img/obj_zone_handles.jpg b/docs/manual/img/obj_zone_handles.jpg new file mode 100644 index 0000000..ced846a Binary files /dev/null and b/docs/manual/img/obj_zone_handles.jpg differ diff --git a/docs/manual/img/play_boot.jpg b/docs/manual/img/play_boot.jpg new file mode 100644 index 0000000..71c4608 Binary files /dev/null and b/docs/manual/img/play_boot.jpg differ diff --git a/docs/manual/img/play_from_here.jpg b/docs/manual/img/play_from_here.jpg new file mode 100644 index 0000000..f80a97f Binary files /dev/null and b/docs/manual/img/play_from_here.jpg differ diff --git a/docs/manual/img/tex_anim.jpg b/docs/manual/img/tex_anim.jpg new file mode 100644 index 0000000..9fe4855 Binary files /dev/null and b/docs/manual/img/tex_anim.jpg differ diff --git a/docs/manual/img/tex_game.jpg b/docs/manual/img/tex_game.jpg new file mode 100644 index 0000000..9ba0a6f Binary files /dev/null and b/docs/manual/img/tex_game.jpg differ diff --git a/docs/manual/img/tex_import.jpg b/docs/manual/img/tex_import.jpg new file mode 100644 index 0000000..cf895ef Binary files /dev/null and b/docs/manual/img/tex_import.jpg differ diff --git a/docs/manual/img/tex_manager.jpg b/docs/manual/img/tex_manager.jpg new file mode 100644 index 0000000..a0fb0ee Binary files /dev/null and b/docs/manual/img/tex_manager.jpg differ diff --git a/docs/manual/img/tex_paint.jpg b/docs/manual/img/tex_paint.jpg new file mode 100644 index 0000000..bf8ae20 Binary files /dev/null and b/docs/manual/img/tex_paint.jpg differ diff --git a/docs/manual/img/ui_assets_objects.jpg b/docs/manual/img/ui_assets_objects.jpg new file mode 100644 index 0000000..5e5c32f Binary files /dev/null and b/docs/manual/img/ui_assets_objects.jpg differ diff --git a/docs/manual/img/ui_dialog_new.jpg b/docs/manual/img/ui_dialog_new.jpg new file mode 100644 index 0000000..4af8f87 Binary files /dev/null and b/docs/manual/img/ui_dialog_new.jpg differ diff --git a/docs/manual/img/ui_dialog_open.jpg b/docs/manual/img/ui_dialog_open.jpg new file mode 100644 index 0000000..7dc1dbd Binary files /dev/null and b/docs/manual/img/ui_dialog_open.jpg differ diff --git a/docs/manual/img/ui_empty.jpg b/docs/manual/img/ui_empty.jpg new file mode 100644 index 0000000..2624b12 Binary files /dev/null and b/docs/manual/img/ui_empty.jpg differ diff --git a/docs/manual/img/ui_marquee.jpg b/docs/manual/img/ui_marquee.jpg new file mode 100644 index 0000000..487cdf4 Binary files /dev/null and b/docs/manual/img/ui_marquee.jpg differ diff --git a/docs/manual/img/ui_moved.jpg b/docs/manual/img/ui_moved.jpg new file mode 100644 index 0000000..501124e Binary files /dev/null and b/docs/manual/img/ui_moved.jpg differ diff --git a/docs/manual/img/ui_select_move.jpg b/docs/manual/img/ui_select_move.jpg new file mode 100644 index 0000000..de1fda7 Binary files /dev/null and b/docs/manual/img/ui_select_move.jpg differ diff --git a/docs/manual/img/ui_settings.jpg b/docs/manual/img/ui_settings.jpg new file mode 100644 index 0000000..3f21818 Binary files /dev/null and b/docs/manual/img/ui_settings.jpg differ diff --git a/docs/manual/img/ui_village.jpg b/docs/manual/img/ui_village.jpg new file mode 100644 index 0000000..cd719f6 Binary files /dev/null and b/docs/manual/img/ui_village.jpg differ diff --git a/docs/manual/img/xobj_all_worlds_tab.jpg b/docs/manual/img/xobj_all_worlds_tab.jpg new file mode 100644 index 0000000..a23e0e7 Binary files /dev/null and b/docs/manual/img/xobj_all_worlds_tab.jpg differ diff --git a/docs/manual/img/xobj_drag_drop_inspector.jpg b/docs/manual/img/xobj_drag_drop_inspector.jpg new file mode 100644 index 0000000..3ee79cb Binary files /dev/null and b/docs/manual/img/xobj_drag_drop_inspector.jpg differ diff --git a/docs/manual/img/xobj_import_world_preview.jpg b/docs/manual/img/xobj_import_world_preview.jpg new file mode 100644 index 0000000..101fe29 Binary files /dev/null and b/docs/manual/img/xobj_import_world_preview.jpg differ diff --git a/docs/manual/index.md b/docs/manual/index.md new file mode 100644 index 0000000..3d287c0 --- /dev/null +++ b/docs/manual/index.md @@ -0,0 +1,69 @@ + +# LlamaMod Level Editor Manual + +Welcome! This manual explains everything the **LlamaMod level editor** and the **LlamaMod** mod +manager can do – from your first island to texture animations, camera zones and visibility +regions. It is written for players and forum members who want to build their own levels for +*The Emperor's New Groove* (PC, 2000; German: *Ein Königreich für ein Lama*). + +![The editor with Village chapter 1 open](img/ui_village.jpg) + +**New here?** Read [Introduction and concepts](01-introduction.md), then follow +[Your first level in 10 minutes](tutorial-first-level.md). Press **F1** anywhere in the editor to +open the chapter about the panel, tool or dialog under the mouse pointer. + +## Getting started + +* [Introduction and concepts](01-introduction.md) +* [Installing LlamaMod and the first start](02-installing.md) +* [The editor window](03-editor-window.md) +* [Navigating the viewport](04-navigation.md) +* [Selecting and transforming](05-selection.md) + +## Tutorials + +* [Your first level in 10 minutes](tutorial-first-level.md) +* [Make a parkour](tutorial-parkour.md) +* [Edit an original level](tutorial-original-level.md) +* [Import a Blender model](tutorial-blender.md) + +## Building levels + +* [Building geometry](06-geometry.md) +* [Importing meshes and the library](07-meshes.md) +* [Textures](08-textures.md) +* [Objects](09-objects.md) +* [Waypoints and paths](10-waypoints.md) +* [Cameras and triggers](11-cameras-triggers.md) +* [Environment: zones, fog, music and light](12-environment.md) +* [Visibility regions](13-visibility.md) +* [Level settings](14-level-settings.md) + +## Testing and sharing + +* [Building, problems and play-testing](15-build-play.md) +* [Editing original levels](16-original-levels.md) +* [Sharing levels](17-sharing.md) + +## Reference + +* [LlamaMod mods](18-mods.md) +* [Keyboard, mouse and menu reference](19-shortcuts.md) +* [Troubleshooting and FAQ](20-troubleshooting.md) +* [Glossary](21-glossary.md) + +## About this manual + +The manual is part of the program: **Help → Manual** (**F1**) opens it in its own window with a +contents tree, full-text search and back/forward buttons. **Export** in that window saves it as +HTML pages or as one HTML file with all pictures – handy for posting in a forum or reading on a +phone. The keyboard reference and the mod reference are generated from the program itself, so +they always match your version. diff --git a/docs/manual/tutorial-blender.md b/docs/manual/tutorial-blender.md new file mode 100644 index 0000000..86ef3c3 --- /dev/null +++ b/docs/manual/tutorial-blender.md @@ -0,0 +1,117 @@ +# Tutorial: Import a Blender model + +This tutorial takes a small model – a wooden bridge – from Blender into a level, with its texture +and collision, and back again. The same steps work for any OBJ or glTF model from other programs. +Background and all options: [Importing meshes and the library](07-meshes.md). + +## 1. Model with the engine in mind + +The game engine is from 2000. A few rules make the result look right: + +* **Scale**: in the editor 1 cell is one grid square and the llama is about 0.6 cells tall. The + simplest setup in Blender is **1 Blender unit = 1 cell**. A walkable bridge is 1.5–2 cells wide. +* **Polygons**: keep it simple – tens or hundreds of faces, not thousands. The model is cut into + 1×1-cell pieces and each piece may have at most 8000 vertices. +* **Up axis**: Blender works Z-up, but its OBJ and glTF exporters convert to Y-up by default. + Keep that default; the editor expects **Y up**. +* **UVs**: the engine has no free texture coordinates – each triangle shows one half of a + rectangle of a texture. Best results: **quads whose UVs fill the whole texture** (in the UV + editor: select the faces, *UV → Reset*), one texture per material. Planks, walls and floors map + exactly; unwrapped organic shapes are only approximated. +* **No tiling**: UVs outside 0…1 are clamped. Repeat the geometry (Array modifier) instead of the + texture. +* **Textures**: PNG or JPG, square or rectangular, ideally 64–256 pixels. Transparent areas + (leaves, railings) work with PNG alpha. +* **Origin**: put the origin at the bottom centre of the model – it becomes the placement point. + +## 2. Export from Blender + +**OBJ** (simplest): + +1. *File → Export → Wavefront (.obj)*. +2. Keep *Forward: -Z*, *Up: Y*; enable **UV Coordinates**, **Normals**, **Materials** and, under + *Materials*, *Path Mode: Copy* so the textures are written next to the `.obj`. +3. Apply modifiers if you used any. + +You get `bridge.obj`, `bridge.mtl` and the texture files. Keep them in one folder. + +**glTF** works too: *File → Export → glTF 2.0*, format *glTF Binary (.glb)* – everything in one +file. Vertex colours (*Color Attribute*) are imported as well. + +## 3. Import into the level + +1. Open your level and choose **File → Import mesh (OBJ, glTF)…**. Pick `bridge.obj`. +2. The **Import mesh** dialog opens. Orbit the preview by dragging; the grid squares are 1 cell and + the marker shows the llama's height. +3. Check the size under **Source**. If it is wrong (for example the file was in centimetres), type + the wanted size into **Largest side** and press **Fit**. +4. **Up axis**: **Y up**. If the bridge lies on its side, switch to **Z up**. +5. **Turn** it if needed; leave **Stand on the ground** on. + +![The Import mesh dialog with preview and options](img/geo_import.jpg) + +### Textures + +* **Texture size on the page**: 128 is a good default; use 64 for small props, 256 only for large, + important surfaces – every material gets its own copy on the level's texture pages. +* **UV precision (steps per texture)**: 8 is fine for rectangular layouts; raise it for finer + layouts. +* **Double-sided** for thin parts seen from both sides (a railing made of planes). +* **Lit (baked vertex colours)** on, so the bridge is shaded like the rest of the level. + +### Collision + +For a bridge the llama walks on, choose **From the mesh (every triangle)**. For a statue or a +decoration choose **Simplified: invisible box around it** (smooth, cheap) or **None (decoration +only)**. **Material** stays 0 (normal). + +### Watch the warnings + +The dialog shows how many grid cells the model covers and the triangles in the densest cell. A red +warning about **8000 vertices per tile** means: simplify the model (Blender: *Decimate*) or scale +it up so it spreads over more cells. + +6. Choose **Click in the viewport to place**, press **Import** and click the spot where the bridge + should stand – for example between two platforms. + +## 4. Adjust and test + +* The bridge is now a **mesh brush**: move it with **W**, turn it with **E** (the red and blue + rings tilt it freely), scale it with **R**. +* Press **C** to see its collision. Every walkable triangle is coloured. +* Point the camera at the bridge and press **Shift+F5** (**Play from camera position**): the + llama starts right there. Walk across, then press **Escape** to come back. + +![An imported OBJ bridge in the game](img/geo_game_course.jpg) + +## 5. Change the model and re-import + +Edit the bridge in Blender and export it again under the same name. In the editor, select the +mesh brush and choose **Geometry → Re-import mesh…** → **Yes** reloads the same file. Its +placement, up axis and scale are kept. + +## 6. Round trip: from the level to Blender + +You can also start from level geometry – to model something that fits exactly onto an original +house or a brush course: + +1. Select tiles, brushes or meshes. +2. **File → Export selection as OBJ…**, keep **Also write the collision polygons** on. +3. Import the OBJ into Blender (*Forward -Z, Up Y*). You get the geometry with its textures and + vertex colours, and `…_collision.obj` with the collision polygons grouped by material. +4. Model your addition around it, export only your new parts, and import them as above. + +> **Note:** Exported textures are game data – fine for your own editing, please don't share them. +> Your imported model and its own textures are stored in the project and travel with the +> `.llamalevel` file. + +## Troubleshooting + +| Problem | Cause and fix | +|---|---| +| model is huge or tiny | wrong unit scale – use **Largest side** + **Fit** | +| model lies on its side | switch **Up axis** | +| no textures | the MTL or the textures were not next to the OBJ – export with *Path Mode: Copy*, or use `.glb` | +| texture looks smeared | UV layout is not rectangular – reset UVs of quads, raise **UV precision** | +| llama falls through | collision is **None** or **Simplified** is not what you wanted – choose **From the mesh**; check with **C** that the surface is coloured; recalculate normals in Blender | +| red 8000-vertex warning | too many triangles in one cell – decimate or scale up | diff --git a/docs/manual/tutorial-first-level.md b/docs/manual/tutorial-first-level.md new file mode 100644 index 0000000..5efb7b9 --- /dev/null +++ b/docs/manual/tutorial-first-level.md @@ -0,0 +1,76 @@ +# Tutorial: Your first level in 10 minutes + +In this tutorial you make a small level from scratch: a grass floor, a few blocks to climb, a +coin trail and an exit – and you play it in the game. You need LlamaMod set up with your game +([Installing LlamaMod](02-installing.md)). + +## Step 1: Create the level + +1. Start LlamaMod and click **Level Editor**. +2. On the start page click **New level** (or **File → New level…**, **Ctrl+N**). +3. Type a **Level name**, for example *My first level*. +4. Leave the **Base level** at *Village – Chapter 1*: its objects (coins, checkpoints, the exit + door) are what you will place. +5. Choose **Start with: Flat floor** and click **Create**. + +![The New level dialog](img/ui_dialog_new.jpg) + +The editor opens a 16×14 grass floor with a player start (the llama) and an exit door. The +TestLevel chapter shown in the dialog is where your level will appear in the game. + +## Step 2: Look around + +* Hold the **right mouse button** and move the mouse to look around; add **W A S D** to fly. +* Press **Home** to see the whole level, click the llama and press **F** to frame it. +* Press **Numpad 7** for the top view and **Numpad 5** to get back. + +## Step 3: Add something to climb + +1. Press **B** for the **Brush** tool. In the options bar choose **Block**. +2. Drag a 2×2 square on the floor next to the llama, move the mouse up until the status bar + shows a height of about 0.5, and click. You have a step. +3. Choose **Platform** and draw a wider platform a bit further away, about 1.2 high. +4. Choose **Ramp** and draw a ramp leading up to the platform – it rises in the direction you + drag. + +![Drawing a ramp](img/geo_draw.jpg) + +Click a brush with the **Select** tool (**Q**) to see its handles; drag a handle to resize it. +Every change can be undone with **Ctrl+Z**. + +## Step 4: A coin trail + +1. Open the **Objects** panel (left) and click **Coin line** under the quick setups – or press + **O** for the **Coin line / arc** tool. +2. Click once at the start of the trail and once at its end. Coins appear every *spacing* cells + on the ground (also up the ramp). + +## Step 5: Check the exit + +The template already has an exit: a **LevelDoor** standing on an **exit floor** (collision +material 16). Press **C** to see collision colours – the exit floor has its own colour. If you +want the exit somewhere else, select the door and its floor brush and move them together with +the **Move** tool (**W**). + +## Step 6: Play it + +1. Press **F5**. The editor builds the level, installs it as a mod and starts the game directly + in it. +2. Walk the llama up the ramp, collect the coins and step on the exit. +3. Press **Escape** to come straight back to the editor. + +Want to test only the platform jump? Point the middle of the view at the platform and press +**Shift+F5** – the llama starts right there. + +## Step 7: Save + +Press **Ctrl+S**. The project is saved in LlamaMod's `levels` folder and shows up as a mod in +LlamaMod's **Levels** category. Send the `.llamalevel` file to a friend to let them play it – +see [Sharing levels](17-sharing.md). + +## What next? + +* [Make a parkour](tutorial-parkour.md) – platforms, death floors, checkpoints. +* [Building geometry](06-geometry.md) – all primitives, rotating, arrays and alignment. +* [Objects](09-objects.md) – enemies, checkpoints, secrets and parameters. +* [Environment](12-environment.md) – fog, music and light. diff --git a/docs/manual/tutorial-original-level.md b/docs/manual/tutorial-original-level.md new file mode 100644 index 0000000..e7c5d89 --- /dev/null +++ b/docs/manual/tutorial-original-level.md @@ -0,0 +1,89 @@ +# Tutorial: Edit an original level + +In this tutorial you change the first level of the game, Village chapter 1: you move a few coins, +add a checkpoint, give the start of the level a different fog and music, try it in the game and +save it as a project you can share. It takes about 15 minutes. + +You need LlamaMod set up with your game (see [Installing LlamaMod](02-installing.md)). + +## Step 1: Open the level + +1. Start the editor (**Level Editor** in LlamaMod's header). +2. Choose **File → Open original level…** (**Ctrl+Shift+O**). +3. Select **Village – Chapter 1** and click **Open**. + +The village appears in the viewport. Press **Home** to see the whole level, then fly closer: +hold the **right mouse button** and use **W A S D** (see [Navigation](04-navigation.md)). + +## Step 2: Move some coins + +1. In the **Outliner**, type `Coin` into the filter field. The tree now lists only coins. +2. Click a coin in the list. The Inspector shows its properties and the viewport highlights it; + press **F** to frame it. +3. Press **W** for the Move tool and drag the arrows of the gizmo – or simply drag the coin over + the ground. With **Snap** on (**G**), it moves in steps of the grid. +4. To move a whole group, **Shift+click** more coins (or draw a box around them in empty space) + and drag them together. + +Every drag is one undo step – **Ctrl+Z** takes it back. + +> **Tip:** **Edit → Select similar** (**Shift+S**) selects all objects of the selected type – an +> easy way to see where every coin of the level is. + +## Step 3: Add a checkpoint + +1. Open the **Objects** panel (left dock) and click the quick setup **Checkpoint**. +2. The Place tool (**P**) is now active. Click on the ground where the checkpoint should stand. + +The editor places a checkpoint post together with its respawn point a little in front of it. +When the llama falls into the water after passing it, it comes back here. More about objects in +[Objects](09-objects.md). + +## Step 4: Change the fog and music + +The start of the level uses the **start zone**: + +1. Open the **Environment** panel (left dock, **Zones** tab). +2. Click the first entry, *Start zone (level default)*. The Inspector shows its **Environment + zone** section. +3. Tick **Live fog in the viewport** in the panel to see the fog as in the game. +4. In the Inspector, click **Fog / sky colour** and choose a new colour – for example a soft + orange for an evening mood. The viewport follows while you pick. +5. Lower **End (Max)** a little for a denser fog. +6. Under **Music & sky**, choose another **Music** track, for example `PUZZLE2`. + +The village houses have their own zones (one per door), so the inside of a house keeps its +original fog. See [Environment](12-environment.md) for every field. + +> **Tip:** for a real evening mood also try **Level → Bake lighting…** with the *Dusk* preset and +> the option **Set start zone fog & ambient colour**. + +## Step 5: Play from the camera position + +1. Fly the camera so that the middle of the view looks at the ground next to your checkpoint. +2. Press **Shift+F5** (**Level → Play from camera position**). + +The editor builds the level, installs it as a mod and starts the game with the llama standing at +that point. Walk through the checkpoint, collect your moved coins and listen to the new music. +Press **Escape** to quit the game and return to the editor. + +> **Note:** **F5** starts at the level's normal start instead, including the intro fly-by. +> If the editor says there is no walkable floor at the point you look at, aim at open ground – +> roofs and trees often have no collision. + +![The game started with the llama at the camera position](img/play_from_here.jpg) + +## Step 6: Save and share + +1. **File → Save** (**Ctrl+S**). Give the project a name, for example `Village evening`. + Projects are saved in LlamaMod's `levels` folder by default. +2. Back in LlamaMod, your project appears in the **Levels** category of the mod list. While it is + enabled, it replaces Village chapter 1 in the game. +3. To share it, send the `.llamalevel` file. It contains only your changes – no game data – so + it is small and others build it from their own CD (see [Sharing levels](17-sharing.md)). + +## What next + +* [Editing original levels](16-original-levels.md) – what to watch out for (tile numbers, + conflicts with other mods). +* [Tutorial: Make a parkour](tutorial-parkour.md) – build new platforms with brushes. diff --git a/docs/manual/tutorial-parkour.md b/docs/manual/tutorial-parkour.md new file mode 100644 index 0000000..97d0381 --- /dev/null +++ b/docs/manual/tutorial-parkour.md @@ -0,0 +1,120 @@ +# Tutorial: Make a parkour + +In this tutorial you build a short jump-and-run course: a start platform, platforms over a deadly +pit, a ramp, stairs, a round platform, a checkpoint, a line of coins and a goal with a working +exit. It takes about 20 minutes. You should know how to move the camera (see +[Navigation](04-navigation.md)); everything else is explained on the way. + +![A finished brush course in the game](img/geo_game_course.jpg) + +## 1. Create the level + +1. **File → New level…** (**Ctrl+N**). +2. **Level name**: `My Parkour`. Keep the **Base level** (Village chapter 1 – it provides grass, + stone and wood textures, coins and checkpoint posts) and the suggested **TestLevel chapter**. +3. **Start with**: **Flat floor**. You get a 16×14 grass floor with a player start and an exit. +4. Click **Create** and **File → Save** (**Ctrl+S**) once, so the project has a file. + +The flat floor will be your start area. The course will lead away from it over a pit. + +## 2. Make the pit deadly + +Anything the llama falls into must kill it, otherwise it lands on nothing and falls forever. + +1. Press **B** for the **Brush** tool and choose **Death plane**. +2. Drag a large area next to the floor where the course will be – generously, it costs nothing. + The plane is invisible in the game; in the editor it is outlined. + +Alternatively, **Geometry → Death plane height…** with **Add a death plane over the whole grid** +puts one under the whole level at once. Its height should be well below your platforms (default +−1.5). + +## 3. Platforms over the pit + +1. In the Brush tool choose **Platform**. +2. In the Inspector set **Top** to `stone` and **Sides** to `stone2` with the swatch buttons + (optional). +3. Drag a 2×2 footprint a little away from the floor's edge, release, move the mouse until the + top is at about 0.5 and click. +4. Draw two or three more platforms, each a bit further away and a bit higher. + +Gaps of 1–2 cells are comfortable jumps; 3 cells needs a run-up. Keep height differences under +about 1 cell at first. + +> **Tip:** Draw one platform, then use **Geometry → Array duplicate…** (**Ctrl+Shift+D**): 4 +> copies, **Offset X** 3, **Offset Y** 0.25 makes a rising row in one step. + +## 4. Ramp, stairs and a round platform + +* **Ramp**: drag the footprint in the direction it should rise, release, move the mouse to the + height of the high end, click. +* **Stairs**: the same; keep **Walkable (invisible slope)** on, otherwise the llama cannot climb + them. +* **Round**: drag the diameter, then the height – a round platform. **Segments** sets how round + it is. + +Select a brush and drag its orange (sides) or green (top/bottom) handles to fix its size. **[** and +**]** turn the selection by 90°, **Page Up/Down** raise or lower it. + +## 5. Check the collision + +Press **C** to show collision. Every surface the llama can stand on is coloured; the death +plane shows as material 30. Look for: + +* platform tops without colour (collision switched off), +* small gaps between pieces that should touch – **Level → Check for problems** reports gaps under + 0.2 cells. + +## 6. A checkpoint + +The llama should not have to start over after every fall. + +1. Open the **Objects** panel (left dock) and click **Checkpoint** in **Quick setups**. +2. Click on a platform in the middle of the course. + +You get a checkpoint post with its respawn point just in front of it. Falling after passing it +puts the llama back there. See [Objects](09-objects.md) for the details. + +## 7. Coins + +1. Click **Coin line** in **Quick setups** (or the **Coin line / arc** tool, **O**). +2. Click above the first platform, then above the last one. Coins appear every *spacing* along the + line, following the floor. + +**Coin arc** places coins on a circle: click the centre, then a point on the circle – nice over a +round platform. + +## 8. The goal + +The template's exit is still on the flat floor. Move it to the end of the course: + +1. Draw a **Block** as the goal platform at the end. +2. Click **Exit** in **Quick setups** and click the middle of the goal block's top. + +That places a **LevelDoor** in the cell centre plus a 1×1 floor with collision material **16**. +The door only fires while the llama stands on material 16 in its cell. In the options bar, choose +where the exit leads (for example the next chapter of a world). + +3. Select the old exit on the flat floor (the LevelDoor and its small floor) and delete it + (**Del**), so the level has one exit. + +## 9. Play-test + +1. **F5** (**Play**) builds the level, installs it as a mod and starts the game directly in it. +2. Run the course. Fall into the pit once to see that the death plane and the checkpoint work. +3. Press **Escape** in the game to quit straight back to the editor. + +To test a hard jump without running the whole course: point the camera at the platform before +it, so the middle of the view lies on its top, and press **Shift+F5** (**Play from camera +position**). The llama starts there, facing the way the camera looks. If there is no walkable +collision at that spot, the editor tells you instead of starting. + +## 10. Iterate + +* Jump too long? Select the platform and nudge it with the **Move** tool (**W**) – with + snapping (**G**) on, steps are 0.25 cells by default. +* Too easy? Make platforms smaller (handles) or add an **Invisible wall** that forces a detour. +* Add decoration: a **Fence** along the start, a few **Trees**, a library piece (see + [Importing meshes and the library](07-meshes.md)). +* Run **Level → Check for problems** before you share the level, then see + [Sharing levels](17-sharing.md). diff --git a/examples/testwelt.llamalevel b/examples/testwelt.llamalevel new file mode 100644 index 0000000..2413881 Binary files /dev/null and b/examples/testwelt.llamalevel differ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..537bbd3 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 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. +# + +############################################################################## +# +# 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/subprojects/plugins/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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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 + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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, 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" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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" "$@" diff --git a/levels/sprunglabor.py b/levels/sprunglabor.py new file mode 100644 index 0000000..29f5ea4 --- /dev/null +++ b/levels/sprunglabor.py @@ -0,0 +1,68 @@ +"""Sprunglabor: misst, wie hoch/weit das Lama springt (fuer Parkour-Abstaende). +Spur A (z=20): Stufen mit wachsender Hoehe; Spur B (z=26): Luecken mit wachsender Breite.""" +LEVEL_ARGS = dict(name='Sprunglabor') + + +def build(L): + L.sea(0, 0, 60, 40, y=-2.0, kill=(4, 10, 56, 34)) + lane = L.opts.get('spur', 'a') + if lane == 'f': + # Weitsprung ueber Wasser (Sturz = Respawn am Start): Startinsel, dann 2 lange Bloecke auf + # gleicher Hoehe mit Luecken 0.5/0.7/0.9/1.1/1.3; Stufen-Spur g daneben (z=26): +0.2/+0.3 mit 0.4 + L.box(8, 17, 14, 29, -1, 0, top='stein', side='stein_kante', rot=0) + x = 14 + for i, gap in enumerate([0.5, 0.7, 0.9, 1.1, 1.3]): + x += gap + L.box(x, 18, x + 2, 22, -1, 0, top='holz_maser' if i % 2 == 0 else 'stein', side='stein_kante', rot=0) + x += 2 + x, y = 14, 0.0 + for i, (gap, dy) in enumerate([(0.4, 0.2), (0.4, 0.2), (0.4, 0.3), (0.5, 0.2), (0.6, 0.2)]): + x += gap; y += dy + L.box(x, 24, x + 2, 28, -1, y, top='holz_maser' if i % 2 == 0 else 'stein', side='stein_kante', rot=0) + x += 2 + L.player(11, 26 if L.opts.get('z') == 'g' else 20, rot=90) + return + L.box(10, 14, 50, 30, -1, 0, top='gras', side='erde', side_top='graskante') + if lane == 'm': + # Muenz-Messbahn: Muenze alle 0.25 ab x=12 -> Muenzzaehler = zurueckgelegte Strecke x4 + L.coins([(12 + 0.25 * i, 20) for i in range(int(L.opts.get('n', 112)))]) + L.player(11.5, 20, rot=90) + return + if lane == 'e': + # Messbahn: Querstreifen je 1 Einheit (abwechselnd weiss/rot), Lama bei x=11.5 + for i in range(12, 48): + L.floor(i, 19, i + 0.15, 21, 0.01, 'weiss' if i % 5 else 'rot', coll=False) + L.player(11.5, 20, rot=90) + elif lane == 'd': + # Luecken 0.5 / 0.75 / 1.0 / 1.25 zwischen 0.6 hohen Bloecken (je 1.5 lang) + L.box(10, 18, 14, 22, 0, 0.6, top='stein', side='stein_kante', rot=0) + x = 14 + for i, gap in enumerate([0.5, 0.75, 1.0, 1.25]): + x += gap + L.box(x, 18, x + 1.5, 22, 0, 0.6, top='holz_maser' if i % 2 == 0 else 'stein', side='stein_kante', rot=0) + x += 1.5 + L.player(11, 20, rot=90) + elif lane == 'c': + # Einzelbloecke wachsender Hoehe, je 2 lang, 3 Abstand + for i, h in enumerate([0.2, 0.35, 0.5, 0.7, 0.9]): + x = 16 + i * 5 + L.box(x, 18, x + 2, 22, 0, h, top='holz_maser' if i % 2 else 'stein', side='stein_kante', rot=0) + L.box(x - 0.1, 17.4, x + 0.1, 17.6, 0, 1.5, top='rot', side='rot', solid=False) # Markierung + L.player(12, 20, rot=90) + elif lane == 'a': + # Stufen: Hoehen 0.5, 1.0, 1.5, 2.25, 3.0 (je 2 tief) + x = 16 + for h, tex in [(0.5, 'stein'), (1.0, 'holz_maser'), (1.5, 'stein'), (2.25, 'holz_maser'), (3.0, 'stein')]: + L.box(x, 18, x + 2, 22, 0, h, top=tex, side='stein_kante', rot=0) + x += 2 + L.player(12, 20, rot=90) + else: + # Luecken: Plattformen auf 1.0, Luecken 1.0, 1.5, 2.0, 2.5, 3.0 + x = 12 + L.box(x, 24, x + 4, 28, 0, 1.0, top='stein', side='stein_kante', rot=0) + x += 4 + for gap in [1.0, 1.5, 2.0, 2.5, 3.0]: + x += gap + L.box(x, 24, x + 2, 28, 0, 1.0, top='holz_maser' if gap % 1 else 'stein', side='stein_kante', rot=0) + x += 2 + L.player(13, 26, rot=90) diff --git a/levels/test_orient.py b/levels/test_orient.py new file mode 100644 index 0000000..bbbdb14 --- /dev/null +++ b/levels/test_orient.py @@ -0,0 +1,17 @@ +"""Kleiner Orientierungstest: Platz, farbige Saeulen in +x (rot), +z (blau), -x (gelb), -z (weiss).""" +LEVEL_ARGS = dict(name='Orient') + + +def build(L): + L.sea(10, 10, 50, 50, y=-2.0) + L.box(20, 20, 30, 30, -1, 0, top='platte', side='stein', side_top='graskante') + L.pillar(29, 25, 0, 3, size=0.8, side='rot', top='rot') # +x + L.pillar(25, 29, 0, 2, size=0.8, side='blau', top='blau') # +z + L.pillar(21, 25, 0, 1.5, size=0.8, side='gelb', top='gelb') # -x + L.pillar(25, 21, 0, 1, size=0.8, side='weiss', top='weiss') # -z + L.stairs(30, 23, 33, 26, 0, 1.5, 3, '+x', top='stein', side='stein2') + L.box(33, 22, 36, 27, -1, 1.5, top='gras', side='erde', side_top='graskante') + L.player(25, 25, rot=0) + L.checkpoint(23, 23) + L.exit(35, 24.5) + L.coins([(26, 25), (27, 25), (28, 25)], y_off=0) diff --git a/levels/testwelt.py b/levels/testwelt.py new file mode 100644 index 0000000..550f89f --- /dev/null +++ b/levels/testwelt.py @@ -0,0 +1,217 @@ +"""Testwelt - eigene Test-Map fuer "Ein Koenigreich fuer ein Lama" (ersetzt TestLevel Kapitel 1). + +Bauen: python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad [--install] +Optionen (--set name=wert): + galerie=alle|keine| welche Objekte in der Galerie stehen + start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2 Startpunkt des Lamas (Tests) + tuer=1,9,1 Parameter des Ausgangs (Stamm, Welt, Kapitel des naechsten Levels) + +Masstab (im Spiel gemessen): Lama ~0.6 hoch, laeuft ~1.1 Einheiten/s, springt 0.35 hoch und mit +Anlauf ~1.3 weit. Stufen <= 0.3 (ohne Luecke = Anschlag), Luecken 0.3, sonst Treppen/Rampen. + +Aufbau (Spielkoordinaten: x nach Osten, z nach Sueden, y hoch; rot 0 = Blick nach +z), kompakt +(2026-11-06 zusammengerueckt: Insel 32.5x23.5 + Parkour-Streifen statt 42x34): + * Startplatz (x 15..21, z 12..19): Lama-Start, Checkpoint, Inka-Saeulen, Blumenbeete + * Objekt-Galerie direkt suedlich (x 14.5..32.5, z 20.5..32): 4 Reihen (Sammeln / Kisten+Toepfe / + Figuren / Mechanik) im Abstand 2.7, Spalten 1.9, jedes Objekt auf flachem Sockel, Wege dazwischen; + Tiere im Gehege gleich oestlich daneben (x 33..42, z 20.8..30) mit Aussichtspodest + * Parkour beginnt an der Ostkante des Platzes, alles auf der Linie z = 15.5: Treppe, Anschlag-Stufe, + 2 Luecken, Balken, Checkpoint, Rampe, Anschlag-Stufe, 3 Trittsteine, schmaler Balken, Treppe, + Ziel-Plattform (y 4) mit Tor ueber dem Ausgang (LevelDoor auf "Tuerboden", Material 16). + Masse unveraendert gegenueber der ersten Fassung, nur um PX = -1 verschoben. + Skript-Durchlauf: levels/testwelt_parkour.seq (mit start=parkour) + * alles auf einer L-foermigen Insel, ringsum Meer (Todesebene -> Respawn am Checkpoint) +""" +import random + +LEVEL_ARGS = dict(name='Testwelt', seed=7) + +# Galerie: (Kategorie, Farbe des Reihen-Pfostens, Typen). Typ = Name oder (Name, Variante) +GALERIE = [ + ('Sammeln', 'gelb', ['Coin', 'ChargeBooster', 'RubyIdol', 'HealthFull', 'HealthGenerator', + 'ChacaChargePotion', 'Secret']), + ('Kisten/Toepfe', 'grau', [('SmashPot', 0), ('SmashPot', 1), 'Jar', 'ObjectDontDropInBox', + 'WaterReboundPoints', 'CheatTeleport']), + ('Figuren', 'blau', ['TipoVillage', 'ChacaVillage', 'PachaGivingInfo', 'KidOnLlama', 'Wompy']), + ('Mechanik', 'rot', ['PressurePadDoor', 'RubyIdolDoor', 'SwitchPlatformPadVillage', + 'AppearingPlatformsVillage', 'RisingColumns', 'SinkingStatue', 'FallingTree', + 'CheckPointPost']), +] +GEHEGE = ['Bunny', 'Bunny', 'Ladybird', 'FlockOfBirds', 'VillagerHerder'] +# bewusst nicht platziert (siehe notes/levelbau.md) +NICHT = {'Llama': 'ist der Spieler', 'LevelDoor': 'Ausgang am Parkour-Ende', + 'CameraSide': 'Kamera-Zone (unsichtbar)', 'CutSceneCameraDirectional': 'Zwischensequenz-Kamera (unsichtbar)', + 'CutSceneTrigger': 'startet eine Zwischensequenz'} + +GX0, DX = 17.4, 1.9 # erste Galerie-Spalte, Abstand (vorher 17.0 / 2.5) +ROW_Z = [22.5, 25.2, 27.9, 30.6] # vorher 25 / 28.5 / 32 / 35.5 +TUER_MAT = 16 # Kollisionsmaterial "Tuerboden" (Dorf: Boden in der Ausgangstuer) +PX = -1.0 # Versatz des Parkours gegenueber der alten Fassung (nur verschoben, Masse gleich) + + +def opt(L, k, d): + return getattr(L, 'opts', {}).get(k, d) + + +def baum(L, x, z, y=0.0, h=1.6, c=1.1): + L.tree(x, z, y, height=h, crown=c) + + +def build(L): + sel = opt(L, 'galerie', 'alle') + rnd = random.Random(3) + L.sun.update(azimuth=220, elevation=48, strength=0.72, sky=(0.47, 0.52, 0.62), ground=(0.27, 0.25, 0.21)) + # ================================================================ Insel + Meer + # L-foermige Insel: Hauptteil (Platz, Galerie, Gehege) + schmaler Streifen unter dem Parkour-Ende + X0, Z0, X1, Z1 = 11, 9.5, 43.5, 33 # Hauptteil + XB = 49 # Ostende des Parkour-Streifens (z Z0..20.5) + L.sea(0, 0, 60, 42, y=-1.5, kill=(X0 - 4, Z0 - 4, 54, Z1 + 4)) + L.box(X0, Z0, X1, Z1, -2.2, 0, top=None, side='erde2', side_top='graskante', solid=False) + L.box(X1, Z0, XB, 20.5, -2.2, 0, top=None, side='erde2', side_top='graskante', solid=False, sides='nes') + # Grundflaechen (ohne Ueberlappung): Platz, Galerie bekommen eigene Boeden + L.floor(X0, Z0, XB, 12, 0, 'gras') # Nordrand + L.floor(X0, 12, 15, 20.5, 0, 'gras') # Westrand neben dem Platz + L.floor(X0, 20.5, 14.5, Z1, 0, 'gras') # Westrand neben der Galerie + L.floor(21, 12, XB, 20.5, 0, 'gras2') # Wiese unter dem Parkour + L.floor(15, 19, 21, 20.5, 0, 'gras') # zwischen Platz und Galerie + L.floor(32.5, 20.5, X1, 32, 0, 'gras') # Gehege + L.floor(14.5, 32, X1, Z1, 0, 'gras') # Suedrand + # ================================================================ Startplatz (6 x 7) + L.box(15, 12, 21, 19, -0.25, 0.0, top='stein', side='stein_kante') + L.box(16.5, 14.0, 19.5, 17.0, 0.0, 0.08, top='inka', side='stein_kante', rot=0, side_coll=False) + for (x, z) in [(15.6, 12.6), (20.4, 12.6), (15.6, 18.4), (20.4, 18.4)]: + L.pillar(x, z, 0, 1.6, size=0.6, side='inka', top='stein') + L.box(x - 0.4, z - 0.4, x + 0.4, z + 0.4, 1.6, 1.8, top='stein', side='stein_kante', rot=0) + L.box(16.6, 19.2, 18.2, 20.1, 0, 0.1, top='blumen', side='holz_dunkel', side_coll=False) + L.box(18.9, 19.2, 20.7, 20.1, 0, 0.1, top='blumen2', side='holz_dunkel', side_coll=False) + for (x, z) in [(12.5, 10.6), (23.5, 10.6), (12.4, 14.2), (12.6, 18.2)]: + baum(L, x, z, h=rnd.uniform(1.3, 1.9)) + # ================================================================ Galerie (direkt suedlich vom Platz) + L.box(14.5, 20.5, 32.5, 32, -0.2, 0.0, top=None, side='stein', solid=False) + L.floor(14.5, 20.5, 32.5, 32, 0, 'gras') + L.floor(15.6, 20.5, 16.3, 32, 0.02, 'sand', coll=False) # Hauptweg Nord-Sued + placed, skipped = [], [] + for (cat, farbe, typen), zr in zip(GALERIE, ROW_Z): + xe = GX0 + (len(typen) - 1) * DX + 0.7 + L.floor(16.3, zr - 1.3, xe, zr - 0.7, 0.02, 'sand', coll=False) # Weg vor der Reihe + # Reihen-Pfosten (Kategoriefarbe) am Weganfang + L.pillar(15.05, zr - 0.3, 0, 1.1, size=0.25, side=farbe, top=farbe) + L.box(14.85, zr - 0.5, 15.25, zr - 0.1, 1.1, 1.25, top=farbe, side='holz_dunkel', rot=0) + x = GX0 + for t in typen: + name, var = (t, 0) if isinstance(t, str) else t + L.box(x - 0.45, zr - 0.45, x + 0.45, zr + 0.45, 0, 0.08, top='stein_gruen', side='stein_kante', + rot=0, side_coll=False) + if sel == 'alle' or name in sel.split(','): + if name in L.protos and var < len(L.protos[name]): + L.strat(name, x, zr, variant=var) + placed.append(name if not var else '%s#%d' % (name, var)) + else: + skipped.append(name) + x += DX + # ================================================================ Gehege (Tiere/Gegner), oestlich der Galerie + gx0, gz0, gx1, gz1 = 33, 20.8, 42, 30 + L.floor(gx0, gz0, gx1, gz1, 0.015, 'gras_streifen', coll=False) + L.fence([(gx0, gz0), (gx1, gz0), (gx1, gz1), (gx0, gz1), (gx0, gz0 + 3.2)], 0, height=0.45, thick=0.07, + posts_every=1.0) + baum(L, gx1 - 1.3, gz1 - 1.3, h=1.8) + baum(L, gx1 - 1.2, gz0 + 1.2, h=1.4) + for i, name in enumerate(GEHEGE): + if sel == 'alle' or name in sel.split(','): + L.strat(name, gx0 + 1.8 + (i % 3) * 2.7, gz0 + 2.3 + (i // 3) * 4.5) + placed.append(name) + # Aussichtspodest am Gehege (Treppe hoch, zwischen Reihe 2 und 3; Eingang ins Gehege noerdlich davon) + L.stairs(31.2, 25.3, 32.4, 26.9, 0, 0.6, 3, '+x', top='holz_maser', side='holz_dunkel') + L.box(32.4, 24.7, 33.0, 27.5, 0, 0.6, top='holz_maser', side='holz_dunkel', rot=0) + # ================================================================ Parkour + # Der Parkour liegt auf einer Linie (z = 15.5, nach Osten), damit man ohne Kameradrehen durchkommt. + # Zwei "Anschlag"-Stufen (+0.3, ohne Luecke) halten das Lama an einer festen Stelle an: dort + # springt man aus dem Stand hoch und hat danach genug Anlauf (wichtig fuer --seq-Laeufe). + # Lama: laeuft ~1.1 Einheiten/s, springt mit Anlauf ~1.3 weit und 0.35 hoch. + # Alle x-Werte unten sind die der alten Fassung + PX (Geometrie unveraendert, nur verschoben). + def X(v): + return v + PX + # 1) Treppe vom Platz (Ostkante x=21) auf 0.8, kleiner Absatz, Anschlag-Stufe 1 (+0.3) + L.stairs(X(22), 14.5, X(24), 16.5, 0, 0.8, 4, '+x', top='stein', side='stein2') + L.box(X(24), 14.5, X(24.6), 16.5, 0, 0.8, top='stein', side='stein2', rot=0) + L.box(X(24.6), 14.5, X(26), 16.5, 0, 1.1, top='gras', side='erde', side_top='graskante') + # 2) zwei Luecken (0.3) mit kleinen Stufen (+0.2) + L.box(X(26.3), 14.7, X(27.8), 16.3, 0, 1.3, top='holz_maser', side='holz_dunkel', rot=0) + L.box(X(28.1), 14.9, X(29.6), 16.1, 0, 1.5, top='holz_maser', side='holz_dunkel', rot=0) + # 3) Balken (0.5 breit) + L.box(X(29.6), 15.25, X(32.5), 15.75, 1.25, 1.5, top='holzbalken', side='holz_dunkel', bottom='holz_dunkel', + rot=0) + L.pillar(X(31.05), 15.5, 0, 1.25, size=0.2, side='rinde', top='rinde') + # 4) Mittelplattform mit Checkpoint + L.box(X(32.5), 14.0, X(35.0), 17.0, 0, 1.5, top='stein', side='stein_moos', side_top='stein_kante') + # 5) Rampe 1.5 -> 2.4, Absatz, Anschlag-Stufe 2 (+0.3) + L.ramp(X(35.0), 14.75, X(38.0), 16.25, 1.5, 2.4, '+x', top='sand', side='erde', base=0.0) + L.box(X(38.0), 14.75, X(38.6), 16.25, 0, 2.4, top='sand', side='erde', rot=0) + L.box(X(38.6), 14.6, X(39.8), 16.4, 0, 2.7, top='stein', side='stein_moos', side_top='stein_kante', rot=0) + # 6) drei Trittsteine (1.5 lang, 1.3 breit, Luecken 0.3, je +0.2) + stones = [(X(40.85), 15.5, 2.9), (X(42.65), 15.5, 3.1), (X(44.45), 15.5, 3.3)] + for (x, z, y) in stones: + L.box(x - 0.75, z - 0.65, x + 0.75, z + 0.65, 0, y, top='stein_gruen', side='stein_moos', rot=0) + # 7) schmaler Balken (0.45 breit) mit Stuetze + L.box(X(45.2), 15.275, X(47.4), 15.725, 3.05, 3.3, top='holzbalken', side='holz_dunkel', bottom='holz_dunkel', + rot=0) + L.pillar(X(46.3), 15.5, 0, 3.05, size=0.18, side='rinde', top='rinde') + # 8) Treppe auf 4.0 + L.stairs(X(47.4), 14.7, X(48.6), 16.3, 3.3, 4.0, 3, '+x', top='stein', side='stein2', base=0.0) + # 9) Ziel-Plattform (ragt ueber die Kueste) mit Saeulen, Brustwehr und Tor ueber dem Ausgang + L.box(X(48.6), 12.6, X(53.6), 18.4, -2.2, 4.0, top='platte2', side='stein_moos', side_top='stein_kante') + for (x, z) in [(49.0, 13.0), (53.2, 13.0), (49.0, 18.0), (53.2, 18.0)]: + L.pillar(X(x), z, 4.0, 5.0, size=0.35, side='inka', top='stein') + L.box(X(53.35), 13.3, X(53.6), 17.7, 4.0, 4.45, top='stein', side='stein_kante', rot=0) + # Mosaik im Tor: Oberseite mit Kollisionsmaterial 16 = "Tuerboden" (LevelDoor loest nur aus, wenn das + # Lama in der Rasterzelle der Tuer auf einem Polygon mit Material 16 steht) + L.box(X(50.5), 14.3, X(52.3), 16.7, 4.0, 4.08, top='muster_lila', side='inka', rot=0, side_coll=False, + mat=TUER_MAT) + # Tor ueber dem Ausgang (LevelDoor bei 50.5/15.5, Zelle 50/15) + for z in (14.45, 16.55): + L.pillar(X(51.5), z, 4.08, 5.3, size=0.3, side='inka', top='stein') + L.box(X(51.25), 14.3, X(51.75), 16.7, 5.3, 5.55, top='stein', side='stein_kante', bottom='stein_kante', rot=0) + + # ================================================================ Deko (nur am Rand, dicht) + for (x, z) in [(12.3, 22.5), (12.6, 27), (12.2, 31.5), (17.5, 32.6), (25, 32.7), (36, 32.5), (42.6, 32.3), + (42.8, 23), (29, 10.5), (35, 10.4), (41, 10.6), (46, 19.6), (45.5, 10.8)]: + baum(L, x, z, h=rnd.uniform(1.2, 2.0), c=rnd.uniform(0.9, 1.3)) + for (x, z) in [(13.2, 25), (21.5, 32.5), (30.5, 32.6), (39, 32.6), (26.5, 19.6), (39.5, 19.4)]: + r = rnd.uniform(0.3, 0.5) + L.box(x - r, z - r * 0.8, x + r, z + r * 0.8, 0, r * 0.9, top='stein_moos', side='stein_blau') + for (x0, z0, w, d, t) in [(11.4, 19.4, 2, 1.8, 'blumen'), (14.8, 32.1, 2, 0.8, 'blumen2'), + (33.5, 32.1, 2.5, 0.8, 'blumen3'), (31, 11, 2, 1.0, 'blumen2'), + (40.5, 18.6, 2, 1.6, 'blumen'), (25, 10.4, 1.5, 1.4, 'blumen3')]: + L.floor(x0, z0, x0 + w, z0 + d, 0.015, t, coll=False) + + # ================================================================ Spieler, Checkpoints, Ausgang + starts = dict(platz=(18, 14.6, 0), parkour=(X(20.5), 15.5, 90), pk1=(X(24.3), 15.5, 90), pk2=(X(33.0), 15.5, 90), + pk3=(X(38.3), 15.5, 90), pk4=(X(44.45), 15.5, 90), pk5=(X(46.0), 15.5, 90), ziel=(X(49.0), 15.5, 90), + ziel2=(X(51.5), 17.8, 180), gehege=(30.4, 26.1, 90), + podest=(32.7, 26.1, 270)) + for i, zr in enumerate(ROW_Z): + starts['reihe%d' % (i + 1)] = (15.95, zr, 90) + starts['weg%d' % (i + 1)] = (15.95, zr - 1.0, 90) + sx, sz, srot = starts[opt(L, 'start', 'platz')] + L.player(sx, sz, rot=srot) + L.checkpoint(16.1, 17.4) + L.checkpoint(X(33.6), 14.6) + # Ausgang: Parameter (Stamm, Welt, Kapitel) des naechsten Levels. (1,9,1) = Kapitel 1 neu laden (nach + # der Bilanz "TestLevel Kapitel 1 / Muenzen / Geheimnisse"). NICHT (7,7,7) ("Welt fertig" wie die + # letzten Kapitel der Original-Welten): das schaltet Welt 10 frei -> Absturz auf der Auswahlkarte. + tuer = [int(v) for v in opt(L, 'tuer', '1,9,1').split(',')] + L.exit(X(51.5), 15.5, params=[v * 0x1000 for v in tuer]) + # Muenzen entlang des Parkours (auf die Flaechen gestellt) + L.coins([(X(x), z) for x, z in [(22.5, 15.5), (23.5, 15.5), (25.3, 15.5), (27.0, 15.5), (28.85, 15.5), + (30.0, 15.5), (31.0, 15.5), (32.0, 15.5), (35.8, 15.5), (36.6, 15.5), + (37.4, 15.5), (39.2, 15.5)]] + [(x, z) for x, z, _ in stones] + + [(X(x), z) for x, z in [(45.8, 15.5), (46.8, 15.5), (48.0, 15.5)]]) + L.coins([(17.0, 16.6), (19.0, 16.6)]) + L._galerie = (placed, skipped) + + +def report(L): + placed, skipped = L._galerie + print('Galerie: %d Objekte: %s' % (len(placed), ', '.join(placed))) + if skipped: + print('nicht im Basislevel: %s' % ', '.join(skipped)) diff --git a/levels/testwelt_parkour.seq b/levels/testwelt_parkour.seq new file mode 100644 index 0000000..bcdada4 --- /dev/null +++ b/levels/testwelt_parkour.seq @@ -0,0 +1 @@ +sleep=3 shot:start run:5.5 shot:anschlag1 run:15:x@0.05,x@1.3,x@2.9 shot:anschlag2 run:12:x@0.05,x@1.15,x@2.55,x@4.2,shot@5.0,shot@7.0,shot@9.0 sleep=3 shot:bilanz diff --git a/notes/bug-review-editor.md b/notes/bug-review-editor.md new file mode 100644 index 0000000..2808a93 --- /dev/null +++ b/notes/bug-review-editor.md @@ -0,0 +1,58 @@ +# Level editor – bug review (2026-11-07, agent "edbugs") + +Scope: `editor.*`, `format.stpcmerge`, `levelbuild`, `mods.levels`, `mods.boottolevel`. Method: code review of +undo/history, project IO, build, play-test; property/fuzz tests; stress run of the editor on Xvfb. + +## New tests + +| Test | What it does | +|---|---| +| `editor/bugs/EditFuzzTest` | 12 seeds (env `LLAMAMOD_FUZZ_SEEDS`, 80 passed) × 150 random steps (move with/without merge key, place object, add brush, duplicate, delete, set random props, undo, redo) on edit/new projects of t1l1m001/t1l2m001 → save → load: same scene (every persistent prop, list order), save bytes identical, build identical and parseable (`Level.parse`); undo all → original WAD bytes; redo → same scene/project again | +| `editor/bugs/BrushFuzzTest` | 260 brushes of every kind with random/absurd parameters (as typed into the inspector): compile < 20 s, no exception, every generated model ≤ 8000 vertices, build OK or validator error | +| `editor/bugs/CorruptProjectTest` | every level.json value replaced by values of other types, truncated zips, flipped bytes: load gives IOException or a project that builds/validates and saves | +| `editor/bugs/CrossImportEdgeTest` | cross-level import into projects that already have own textures (used by a brush and a painted original face): indices shift correctly, save/load/build identical, edits after the import | +| `editor/bugs/EditorRegressionTest`, `editor/ui/DocumentRegressionTest`, `editor/tools/playtest/PlaytestSessionTest` | one regression test per fixed bug (all fail or run out of memory on the code before the fixes) | +| `editor/ui/EditorStress` (main, not a unit test) | on an X display: opens 9 damaged/missing project files, 400 rapid random edits with undo/redo/autosave/duplicate/delete, build + switch level / close level while building, undo all → original WAD, crash with autosave → recover. `java -cp all.jar:build/classes/java/test -Dllamamod.data=DIR -Dllamamod.editor.cd=CD com.eliasmueller.llamamod.editor.ui.EditorStress OUT [corrupt,village,spam,switch,undoall,crash,recover,nocd]` | + +## Fixed bugs + +| # | Severity | Bug / repro | Fix | Test | +|---|---|---|---|---| +| B1 | high | A box/platform/ramp/stairs/pillar/cylinder/tree/quad touching x=0 or z=0 (e.g. box 0..1) failed the **whole build** ("geometry outside the 96x96 grid: cell -1,-1") and made **all brushes vanish** from the live preview: wall collision is also registered in neighbour cells (margin 0.3), which do not exist outside the grid | `LevelBuilder.geometry`: skip margin-only collision cells outside the grid (real out-of-grid geometry still errors; goldens unchanged) | `brushAtGridEdgeBuilds` | +| B2 | low | The out-of-grid build error did not say which brush | `BrushCompiler.culprits`: error names the brushes near that cell ("– move brush:n3 into the grid") | `outsideGeometryNamesTheBrush` | +| B3 | high (data loss) | Recovering an autosave of a project that had a file: the document was **not dirty** (`dirty()` ignored `fresh` when a file was set) → closing/opening another level did not ask and `close()` deleted the autosave: recovered work lost | `Document.dirty()`: `history.isDirty() \|\| fresh \|\| file == null && canUndo()` | `recoveredDocumentWithFileIsDirty`, EditorStress crash/recover (dirty=true) | +| B4 | medium | After a cross-level import (`Importer` clears the history) the document looked saved after one Ctrl+Z (`History.clear()` marked the cleared state as saved; undoing the "Import … (permanent)" marker emptied the stack) → close without prompt lost the import | `History.clear()` keeps the dirty state (sentinel "saved state unreachable") | `clearKeepsDirtyState`, `CrossImportEdgeTest` | +| B5 | low | After more than 500 steps (undo limit), undoing everything showed the level as saved although the dropped steps were still applied | `History.push`: dropping the bottom step makes an empty-stack saved state unreachable | `undoLimitKeepsDirtyState` | +| B6 | high | Absurd brush values from the inspector froze or killed the editor (live preview compiles on the EDT): step ≤ 0 → endless loop in `LevelBuilder.cuts`; step 1e-9 / postsEvery 0 / steps 10⁶ / size 10⁶ / coordinates 10¹² → OutOfMemoryError; NaN, `rotation`/`tint` of the wrong length → exceptions that dropped **all** brushes from preview and build | `BrushCompiler.paramProblem` + `extentProblem` (non-numbers, step 0.125..64, ≤ 200 000 pieces, post spacing ≥ 0.1, steps 1..256, sizes 0..256, array lengths, > 64 cells outside the grid, \|y\| > 4096): brush skipped with a warning; `Validator` reports them as errors with the brush id (extent margin 8); any other runtime error of one brush only skips that brush | `absurdBrushParametersAreRejected`, `BrushFuzzTest` | +| B7 | low | Texture `#-1` (negative index) crashed the whole brush compile (IndexOutOfBounds outside the per-brush handling) | `LevelBuilder.tex`: negative index → IllegalArgumentException (brush skipped, warning) | `absurdBrushParametersAreRejected` | +| B8 | medium | A damaged project (wrong value types, unknown brushKind, broken references) threw ClassCastException/NPE/IAE instead of IOException; a huge brush value in a file ran the loader out of memory (B6) | `ProjectIO.read`/`info` wrap runtime errors: "the project file is damaged: …" | `damagedProjectGivesIoException`, `CorruptProjectTest` | +| B9 | medium (data loss) | Play-test of an unsaved level (or of a project saved outside the levels folder) wrote `/levels/.llamalevel` and **silently overwrote a different project** with that name | `PlayTest.installTarget`: reuse the file only if it is the document's own file or the same project (kind, base, name); else `-2…` (used by both Play implementations) | `playTestDoesNotOverwriteOtherProjects` | +| B10 | medium | Boot-to-level play-test: the restore of the user's mod selection runs in a daemon thread of the editor; closing the (standalone) editor while the game runs left the boot mod (quitOnEscape, no menus) applied | `PlaytestRunner`: pending restore written to `/editor/playtest-session.json`; deleted after the restore; on the next editor start (`onOpen` hook) a left-over session is restored (after the game has exited) | `PlaytestSessionTest` | +| B11 | medium | A build result arriving after the level was closed/replaced (Close or Open while building) was shown for the new level (problems linked to the wrong nodes by id) and – for Play – went on to install and **launch the closed level** | `PlayTest.build`: result ignored when `w.document() != d` (status "Build of … discarded") | EditorStress "switch" (status stays "Not built") | +| B13 | high (game crash) | Player starts around 34–35 / 58 in Village 1 crashed the **game** while loading (page fault reading 0xFACF0002 at 0x405527 in FUN_004054c0). Cause (decompile `FUN_00403020`): when the first floor test finds no polygon for a player-type strat (flags 0x40200) it sets the floor record flags \|= 5 ("floor on a map tile") **without** writing tile/polygon index (+0xc/+0xe); FUN_004054c0 then reads the tile→model table with the uninitialised index. The floor test only walks the tiles listed in the start's **own grid cell** – cell 35,58 has a tile (model 44) but no floor polygon under 35.5/58.5, cell 36,58 has no tile. In game (boot-to-level start override, :8): crash at 35.5/7.06/58.5, 35.5/9/58.5, 36.5/8/58.5, 5/8/5; fine at 34.2/7.35/58.2, 37.5/7.3/58.5 and the original start – exactly the positions `FloorFinder.below` (own cell, ≤ 0.6 above) finds no floor for. A death floor (30) does not crash (record reset at 0x40353e) | `StartOverride.unsafe/unsafeStart`; boot-to-level refuses such a start ("…no collision floor at or below it in grid cell 35, 58 (the game crashes while loading the level)", verified with apptest) and its version is 2 (old cached results are rebuilt); `LevelProjectMod.apply` refuses a level whose built WAD has such a start; the existing GeometryChecks error for the player start now says the game crashes (it already blocks Build/Play in the editor); "Play from camera position" already snaps to a floor of the own cell (`FloorFinder.near`) | `StartSafetyTest` (in-game positions, override refused, moved start reported + mod refused, no false alarms for 8 originals, testwelt and all templates), `OriginalStartsTest` | +| B12 | low | Autosave wrote the file in place; a crash during the write destroyed the only recovery copy | write `.tmp` + move | – (review) | + +## Verified, no bug found + +* Undo/redo consistency over random sequences incl. merged drags, grouped batches, deferred deletes of + waypoint chains, duplicates; undo all → byte-identical original WAD (unit fuzz + in the running editor + after 400 UI-level edits). +* Project round trip: diff storage incl. order, removed/added/changed, runs, unknown fields; save after load + byte-identical; cross-import with shifted user textures. +* Renderer sync: no stale selection after delete/undo spam (`selection.prune` per flush); no exceptions on + stderr during the stress run; build runs on a snapshot (no model access off the EDT). +* Opening damaged/empty/missing files and a missing base level shows one error dialog and keeps the current level. + +## Not fixed (reasons) + +* Damaged-file dialogs show raw exception text ("ClassCastException …"); cosmetic, left to the UX review. +* After a failed open, the status bar keeps "Loading " (`StatusBar.progressDone` keeps the last message) – cosmetic (UX). +* `GlRenderer.dispose()` does not delete `dynVao`/`dynVbo` (only at window close) – negligible. +* `SceneRenderer.ids`/`nodes` keep pick ids of deleted nodes until the next level (small, bounded by edits). +* Tile-paint erase strokes are one undo step per tile (`DeferredRemove` has no merge) – UX, not a defect. +* `installTarget` treats a file with the same kind/base/level name as the previous play-test copy; two different + projects with the same level name and base still share one file (rare; renaming one avoids it). +* `LevelProjectSource`: when all TestLevel chapters 2..49 are taken, the 49th is used twice – practically unreachable. +* LlamaMod's own Play (outside the editor) does not finish a pending boot-to-level restore; it shows the + mod selection as "changes pending" (activeMods contains boot-to-level), so it is visible; fixing it would need a + framework change. diff --git a/notes/bug-review-launcher.md b/notes/bug-review-launcher.md new file mode 100644 index 0000000..dbf8f07 --- /dev/null +++ b/notes/bug-review-launcher.md @@ -0,0 +1,91 @@ +# Launcher and mod pipeline – bug review (2026-11-07, agent "lxbugs") + +Scope: `Main` (CLI), `ui`, `platform` (Installer, IsoImage, GameSource, Integrity, GameLauncher, ExePatcher, +Config, FileOps, RegFile), `save`, `mods` (ModManager, ModRegistry, options, cache, world-9 fixup, ApplyLog), +`exe`, all `mods.*`. Method: code review, decompile checks (`re/groove_decomp.c`), programmatic combination +tests against the real groove.exe, ISO images built with genisoimage, in-game smoke tests with +`tools/apptest.py` (display :8). + +## New tests + +| Test | What it does | +|---|---| +| `mods/ExeModCombinationsTest` | all 31 combinations of the exe mods (intro-skip, cheats, practice, freecam, boot-to-level) × {defaults, every exe option on}: apply against the real groove.exe, re-parse the result (`ExeImage.parse`), sections sorted and non-overlapping (VA and raw), SizeOfImage covers the last section, `objdump -h` accepts the all-mods exe and lists `.llamac`/`.llamad`, restore gives the HKCU base exe, re-apply is 100 % cache hits and byte-identical (journal replay) | +| `platform/IsoVariantsTest` | ISOs built with genisoimage: Joliet (like the CD), Rock Ridge only, plain ISO 9660 → install + restore → lower-case `game/groove.exe` with the HKCU patch, `checkGameExe` OK, save game visible to the save editor, long names kept (Joliet/RR); truncated ISO, text file, all-zero file, ISO without game data → clear `IOException`s; setup again from a changed source / resume after cancel | +| `mods/ModManagerConcurrencyTest` | second instance applying/clearing the cache while the first applies → clean error, first finishes; clear cache refused while the game runs; exe written by another instance is not reported as "changed by antivirus" | +| `save/SaveStoreTest` | three saves within one second keep three backups; `(null)kingN.ksv` migration (empty slot, used slot, wrong size), corrupt slot, import of a wrong-size file, read-only slot file | +| `MainCliTest` | `--install` without/with two arguments, `--apply`/`--launch`/`--unlock` before setup, `--unlock` with missing/non-numeric/out-of-range slot, world, chapter (no file written), valid world-9 chapter 49 | +| `platform/GameLauncherPathsTest` | ANSI code page / length check of game paths; Wine data drive `L:` used for CDPath, exe and registry file | +| `platform/ConfigTest` | truncated config.json with an existing installation → still installed, copy kept as `config.json.broken`; wrong value types | +| `mods/levels/LevelProjectModTest.versionFollowsTheFileWhenItIsSavedAgain` | cache key changes when the project file is saved again | + +## Fixed bugs + +| # | Severity | Bug / repro | Fix | Test | +|---|---|---|---|---| +| L1 | high | **Intro skip + Boot to level could not be combined** (both default to "skip logos"): apply failed with "intro-skip (skip logo movies and images) (0x0041D56E, 3 bytes) overlaps "skip logo movies" of boot-to-level". Hit every editor play-test (it forces boot-to-level logos=true) for users with Intro skip enabled – 16 of 62 exe-mod combinations failed | `IntroSkipMod`: skip its logo patch when the identical patch is already there (boot-to-level runs first, order 90 < 100) | `ExeModCombinationsTest` | +| L2 | medium | ISO without Joliet (Rock Ridge only, e.g. `genisoimage -R`, or plain ISO 9660): names were the upper-case 8.3 names (`GROOVE.EXE`, `ARGOLOGO.DAT` instead of `argologompeg.dat`); on Linux `baseExe` did not find `original/hd/groove.exe` → no exe patch, exe mods failed ("NoSuchFile"), `game/groove.exe` missing → Play impossible; save games as `KING0.KSV` next to the editor's `king0.ksv` | `IsoImage`: Rock Ridge `NM` names when there is no Joliet; `Installer.setupGameDir`: all-upper-case HD names are copied lower-case; `ModManager.baseExe` finds the original exe case-insensitively | `IsoVariantsTest` | +| L3 | medium | **No lock between instances** (second window, CLI `--apply`, standalone editor play-test): both rebuild `cd-active.new`, `cache//.tmp` and `game/groove.exe` → deleted half-built folders, mixed results. The first window's config kept the old exe hash → after the other instance applied, Play showed "groove.exe was removed or changed – probably by antivirus software" | `ModManager.apply`/`clearCache` hold an exclusive `FileLock` on `/.apply.lock` ("Another LlamaMod window or command is applying mods right now…"); `checkGameExe` accepts (and adopts) the exe hash stored on disk by another instance | `ModManagerConcurrencyTest` | +| L4 | medium (data loss) | Save backups are folders named by the second; a second save of the same slot within that second **overwrote the backup of the original state** (`REPLACE_EXISTING`) | `SaveStore.backupDir`: `-2`, `-3`, … when the file already exists | `SaveStoreTest` | +| L5 | medium | Level projects: `LevelProjectMod.version()` was the hash of the file **when the mod list was built**. Saving the project again (editor) while the launcher was open → Apply used the old cache key → the **old build** came back from the cache. The launcher also never rebuilt its mod list (`AppState.registry` was final): projects created in the editor appeared only after a restart | `LevelProjectMod.version()` re-hashes when size/mtime changed; `AppState.reloadMods()` on every `MainFrame.refreshAll()` (editor "mods changed", wizard, theme) | `versionFollowsTheFileWhenItIsSavedAgain` | +| L6 | medium | Setup again (Settings → Run setup again, `--install`) **skipped every file whose size matched** the old copy ("resume"), so a repaired or different ISO did not replace damaged same-size files; files not in the new source stayed in `original/` (and in cd-active); the mod cache (keyed by names + sizes) served outputs of the old data | `Installer`: resume only for an interrupted install of the same source (marker `original/.install-source`), otherwise copy everything, delete stale files, clear the cache | `IsoVariantsTest.setupAgainCopiesChangedDataAndDropsTheCache` | +| L7 | medium | Game paths: the game builds file names in a **260-byte buffer** (0x583748, FUN_00415690, no length check; the pointer at 0x58384C after it is passed to `free`) and uses ANSI file functions. A data folder path longer than ~230 characters crashes the game; characters outside the ANSI code page (Windows user "Łukasz" on a German system, Cyrillic names, …) become '?' → CD files not found. Linux: same through Wine (home folder with such characters) | Linux: the data folder is mapped as drive **L:** in our prefix (`wine/dosdevices/l:`), CDPath = `L:\cd-active\`, exe `L:\game\groove.exe` (short, ASCII); Windows (and the Linux fallback to Z:): `GameLauncher.checkPaths` refuses to start with a clear message (path > 200 chars or not encodable in `native.encoding`) | `GameLauncherPathsTest`; in game: all runs below use `L:` | +| L8 | low | CLI argument errors: `--install` without path → `NoSuchElementException` stack trace; `--unlock` without/with bad numbers → stack traces; `--unlock 4 1 1` wrote `king3.ksv`, `--unlock 0 …` wrote `king-1.ksv`, world 10 / chapter 7 of world 1 were written into the save (game crashes on the map); `--apply`/`--launch` before setup → "NoSuchFileException: …/original/cd"; `--install` while the game runs replaced game/groove.exe and then failed | `Main`: `UsageException` (exit 2, "Error: …"), range checks (slot 1–3, world 1–9, chapter 1–n), "not set up yet", running-game checks for `--install`/`--unlock`; `--editor-build/-import/-new` refused in a build without editor (`Edition`) | `MainCliTest` | +| L9 | low | "Clear cache" (Settings) while the game runs: on Linux with the symlink fallback the running game lost its CD files, on Windows the delete fails half-way on locked hard links, and the following re-apply is refused | `ModManager.clearCache` refuses while the game runs (and takes the apply lock) | `clearCacheIsRefusedWhileTheGameRuns` | +| L10 | low | Damaged `config.json` (cut off by a crash or power loss): defaults → "not installed" → setup wizard again (600 MB copy), and the next save overwrote the damaged file | `Config.load`: copy to `config.json.broken`; if `original/` and `game/groove.exe` exist, the installation stays usable (mod selection/options are lost) | `ConfigTest` | +| L11 | low | Apply wrote `game/groove.exe` **before** swapping `cd-active`; when the swap failed (Windows: cd-active open in Explorer or being scanned) the new exe (e.g. boot-to-level into world 9) ran against the old CD folder; `gameExeSha256` was set before the write, so a failed write produced a false "changed by antivirus" alarm | `ModManager.apply`: cd-active first, then the exe; the hash is recorded after a successful write | existing ModManager tests | +| L12 | low | Windows: replacing a read-only file (save game copied from a CD/old install) failed with AccessDenied | `FileOps.writeAtomic` clears the read-only flag of the target first | `SaveStoreTest` (no-op on Linux) | + +## In-game smoke tests (apptest, display :8, session `lxbugs-mx`, all with CDPath `L:\cd-active\`) + +Each run: apply via the CLI → (slot 3 unlocked at the level, menu → slot → map → level via `tolevel`) → +key sequence with screenshots → crash check (process, game.log, winedbg, window) → audio client present. +Exe mods were additionally checked in every combination by `ExeModCombinationsTest` (apply, re-parse, +objdump, replay, restore). + +| Run | Mods (options) | Level | Result | +|---|---|---|---| +| orig | none | Village 1 | OK, walking | +| all-v | fog, randomizer, mirror, intro-skip, cheats, practice, freecam, debug-wireframe, testworld (defaults) | Village 1 | OK; wireframe + mirror visible, F7 free camera flies, F5/F8 | +| all-t | same | TestLevel 1 (Test World) | OK | +| introskip | intro-skip (story on) | Village 1 | OK | +| cheats | cheats (diagnostics on) | Village 1 | first run: harness timeout on the map (Enter not taken); re-runs `cheats-def` (defaults) and `cheats-diag2` (diagnostics) OK → harness flake | +| practice | practice (defaults, checkpoints) | Village 1 | OK, F5 / F8 | +| freecam | freecam | Village 1 | OK, F7 camera and F9 noclip | +| mrf-v / mrf-r | mirror, randomizer, fog | Village 1 / River 1 | OK / OK (raft level mirrored and randomized) | +| twdbg-t / twdbg-v | testworld, debug-wireframe | TestLevel 1 / Village 1 | OK / OK | +| boot | boot-to-level (world 1, chapter 2) | boots into Village 2 | OK | +| boot-exe | boot-to-level (world 3 ch 1) + intro-skip (story) + cheats + practice + freecam | boots into River 1 | OK (this combination failed to apply before L1); F7 works | + +After rebasing onto the merged main tree (simp-all + launcher UX changes): orig, all-v, boot-exe, mrf-r +re-run with the final jar: all OK (CDPath `L:\cd-active\`). + +![matrix](img/launcher-matrix.jpg) + +Screens: all mods in Village (wireframe, mirrored) and the free camera; River with mirror+randomizer+fog; +Test World with all mods; boot-to-level + all exe mods (River loading card); practice restore. + +## Not fixed / recommendations + +* ~~**Cache key and app updates**~~ – fixed in 0.2.0: the chain root contains the app version, the + build id (hash of the compiled classes) and `ExePatcher.REVISION`; entries of other builds (and of + 0.1.0) are deleted after an apply. +* **Cross-mod hotkeys**: practice (save/restore) and freecam (camera/noclip) offer the same key list + and do not know each other – the same key in both triggers both. Needs a check across options + (e.g. ModsPanel or a `Mod.hotkeys()` hook). +* **Launcher edition** (`edition.properties` `editor=false`): only hides the editor button and the + `--editor*` commands; built by `./gradlew packageAll` (`fatJarLauncher`, `packageLinuxLauncher`, + `packageWindowsLauncher`) since 0.2.0. The editor packages **must stay in + the jar**: `mods.boottolevel.StartOverride` (editor.io/model, tools.playtest.FloorFinder) and + `mods.levels.*` (editor.io) need them at apply time; `ModRegistry` would only skip level projects + (LinkageError is caught), but boot-to-level with a player start override would fail with + NoClassDefFoundError. +* **OneDrive / redirected Documents** (decided: no warning) as data folder: hard links inside a synced folder and + on-demand files are fragile; the wizard could warn when the chosen folder is under `%OneDrive%`. +* **Interrupted apply** (app killed between the two renames of `buildActive`): `cd-active` is + missing until the next apply; the GUI then restores the original (dropping the mods), CLI + `--launch` says "CD folder missing". `cd-active.new` is complete at that point and could be moved + in instead. +* A `config.json` saved by a stale window after another instance applied still overwrites + activeMods/gameExeSha256 (L3 only covers the check); a merge-on-save would be needed. +* Old Wine versions are not detected (no version check); missing Wine gives a clear message. diff --git a/notes/debugviz.md b/notes/debugviz.md new file mode 100644 index 0000000..f0d615c --- /dev/null +++ b/notes/debugviz.md @@ -0,0 +1,221 @@ +# Debug-Ansicht: Kollision & unsichtbare Objekte im Spiel (`tools/debugviz.py`) + +Stand: 2026-11-06. Zeigt in **allen 34 Level-WADs** die Kollisionsgeometrie (auch unsichtbare +Wände/Böden, Todesebenen) und unsichtbare Logik-Objekte (Kameras, Trigger, Ausgänge, +Checkpoints, Kraft-/Box-Zonen, Umgebungszonen …) direkt im Spiel – als farbiges Drahtgitter, +unsichtbare Flächen zusätzlich halbtransparent gefüllt. Spielmechanik bleibt unverändert. + +## Benutzung + +``` +lama-mod debug an # Debug-WADs zuschalten (baut das Overlay neu, aktiviert damit auch andere Mods) +lama-mod debug aus # wieder Original-Level (testlevel-Mod bleibt, wie sie war) +lama-mod debug # Status +``` +Wirkt beim nächsten Laden eines Levels (am besten Spiel neu starten). Nicht umschalten, +während ein Level lädt. Mod-Ordner: `~/.wine-lama/mods/debug/wads/*.wad` (gleiche Namen wie +auf der CD, z. B. `t1l1m001.wad`); die Datei `~/.wine-lama/mods/debug/.aus` = abgeschaltet. +Reihenfolge im Overlay: alphabetisch, spätere gewinnen → `testlevel` (T1L9*) bleibt immer, +Debug-WADs ersetzen nur die Original-Level. `lama-mod an|aus|status` funktionieren wie +vorher (`an` überspringt Mod-Ordner mit `.aus`). Sicherung des alten Skripts: +`deb-build/lama-mod.bak-vor-debug`. + +Neu bauen (z. B. nach Änderungen an Farben): +``` +python3 tools/debugviz.py --all ~/.wine-lama/mods/debug/wads --report /tmp/dv.json +python3 tools/debugviz.py IN.wad OUT.wad [--no-fill] [--no-markers] [--no-coll] [--opaque] + [--width 0.007] [--offset 0.012] +``` +Laufzeit ≈ 10–40 s je Level. + +## Farblegende + +Alle Farben sind RGB (0–255). **Linien** = Drahtgitter (deckend), **Füllung** = halbtransparent +(Alpha 96/255, Blendmodus 1 der Engine). + +### Kollisionsflächen (Rahmen um jedes Kollisionspolygon der Welt-Kacheln) +Kantenfarbe nach Lage, solange das Material 0 (normal) ist: + +| Farbe | RGB | Bedeutung | +|---|---|---| +| **Grün** (Linien) | 40,255,40 | begehbarer Boden (Normale steil nach oben, ny ≥ 0,7) | +| **Gelbgrün** (Linien) | 200,255,0 | Schräge (0,2 ≤ ny < 0,7) | +| **Blau** (Linien) | 0,150,255 | Wand (−0,2 < ny < 0,2) | +| **Orange** (Linien) | 255,130,0 | Decke / Überhang (ny ≤ −0,2) | +| **Rot, halbtransparent gefüllt** | 255,0,0 | **unsichtbare Kollision**: keine Render-Fläche innerhalb ±0,5 Einheiten entlang der Normale (= unsichtbare Wand/Boden/Decke). Die Kantenfarbe zeigt weiterhin Lage bzw. Material. | + +Kollision mit Sondermaterial (Byte 1 des Kollisionspolygons ≠ 0): Kanten in Materialfarbe, +bei unsichtbaren Polygonen auch die Füllung in Materialfarbe (statt Rot): + +| Material | Farbe | RGB | Bedeutung | +|---|---|---|---| +| 30 (0x1e) | **Magenta**, nur Linien (keine Füllung) | 255,0,255 | **kein Boden / Fall- und Todesebene**: Spieler fällt hindurch (FUN_004036d0 setzt Boden = keiner). Meist Modell 0 bei y ≈ −2,75 in jeder Rasterzelle unter den Inseln → magentafarbenes Gitter = Absturz-/Todeszone. | +| 16 | **Violett**, immer gefüllt | 190,60,255 | Ausgangs-Boden: LevelDoor löst nur aus, wenn der Spieler auf Material 16 in der Türzelle steht | +| 17 (0x11) | **Gelb** | 255,255,0 | selektive Kollision: wirkt nur für Objekte mit Laufzeit-Flag 0x8000000 (Strat+0xe8) | +| 18 (0x12) | **Hellorange/Pfirsich** | 255,200,120 | selektive Kollision: wirkt NICHT für Objekte mit Flag 0x8000000 | +| 1 | Türkis | 0,200,200 | Sondermaterial, Bedeutung unbekannt (Strat-Skripte werten es aus) | +| 2 | Weiß | 255,255,255 | dto. | +| 3 | Rosa | 255,120,170 | dto. (häufig in Welt 5 Berg) | +| 5 | Mint | 120,255,200 | dto. (Fluss, Achterbahn) | +| 6 | Lavendel | 160,120,255 | dto. | +| 7 | Hellorange | 255,170,80 | dto. | +| 8 | Dunkelgrün | 0,130,60 | dto. | +| 9 | Hellblau | 150,200,255 | dto. | +| 10 | Oliv | 150,150,0 | dto. | +| 11 | Braun | 150,90,40 | dto. | +| 13 | Lachs | 255,130,110 | dto. | +| 15 | Hellgelb | 255,255,150 | dto. | +| 20 | Petrol | 0,120,140 | dto. | +| 26 | Dunkelviolett | 110,40,160 | dto. | +| 27 | Silber | 190,190,190 | dto. (Verfolgungsjagd t1l2m003) | +| 28 | Pink | 255,0,140 | dto. | +| 33 | Dunkelrot | 150,0,0 | dto. | +| 255 | Fast schwarz | 20,20,20 | dto. | +| sonstige | Hellgrau | 230,230,230 | (kommt in den Originalen nicht vor) | + +### Objekt-Marker (unsichtbare Strats, MAP-Strat-Einträge) +Markerwürfel (10 cm) auf einem dünnen Pfahl 0,35 über dem Objekt-Ursprung; kurzer waagrechter +Strich = Blickrichtung (Kameras, Checkpoints). + +| Farbe | RGB | Kategorie | Typen | +|---|---|---|---| +| **Weiß**, Pfeil flach am Boden | 255,255,255 | Spielerstart (Richtung) | Llama, KuscoAndPachaTogetherOnATree (Fluss), RollerCoaster, LlamaChase | +| **Violett** + Drahtwürfel 1×1×1 um die Rasterzelle | 190,60,255 | Ausgang (Auslösezelle) | LevelDoor, VillageEndDoor, EndGameDoor, TeleportDoor | +| **Grüntürkis** | 0,255,140 | Checkpoint | CheckPointPost, CheckPointBox | +| **Cyan** | 0,255,255 | Kamera | Camera* (CameraSide, CameraLift, CameraForceInsideBox …), CutSceneCamera*, RiverWaterfallCamera | +| **Gelb** | 255,255,0 | Trigger | CutSceneTrigger*, *Trigger*, Say* (Sprüche), ChangeToBunny/Turtle, RiverStartSign | +| **Orange** | 255,90,0 | Kraft-/Sonderzonen | Force*, FallOffNoHurt, ObjectDontDropInBox, RiverCurrentBox, RiverRebound, WaterReboundPoints*, SuckingWPs, ObjectSuckPoints, RiverTrackChange, TrackSwitch, RiverWhirlpool, BackgroundRotationChanger | +| **Gold** | 255,200,0 | Geheimnis-Zone | Secret | +| **Grau** | 170,170,170 | sonstige unsichtbare Steuer-Objekte | CheatTeleport, Wompy*, Spawner, Helper, Coin-Linien/-Kreise, Rain, DrippingWater, WaterfallMist, MusicBox, FlockOfBirds, Bats, Scorpions*, AppearingPlatforms* … | +| **Dunkelgrau**, kleiner Punkt (6 cm) | 90,90,90 | alle übrigen Objekte (Ursprung) | Münzen, Gegner, Türen … – damit wirklich nichts fehlt | +| **Rosa** Drahtwürfel je Rasterzelle | 255,100,180 | Umgebungs-/Musik-/Nebelzone | MAP-Block 4 (90-Byte-Einträge, Zelle x/y/z) | + +**Zonen-Boxen:** hat ein Marker-Objekt genau 2 Wegpunkte und liegt das Objekt im Rechteck +dazwischen, ist das ein Box-Bereich (Secret, CameraSide/Lift, ObjectDontDropInBox, +FallOffNoHurt, RiverCurrentBox …): Drahtquader in der Kategoriefarbe von min(y) bis max(y)+1,2 +(die Höhe ist geschätzt – der Code prüft vermutlich nur x/z), bei Flächen ≤ 64 Einheiten² mit +halbtransparentem Boden (Alpha 70). +**Wegpunkt-Ketten** anderer Marker-Objekte: kleine Würfel (5 cm) in abgedunkelter +Kategoriefarbe (60 %), bei Kraftzonen mit Verbindungslinien (z. B. WaterReboundPoints-Pfade, +CutSceneTrigger-Kamerafahrt nur als Punkte). + +## Wie es gebaut ist (Entscheidungen) + +* **Kollisions-Overlay an die TRAK-Modelle angehängt** (nicht als eigene Kacheln): pro + Kollisionspolygon ein Rahmen aus 2·n Vertices (äußere Ecken + um die Linienbreite nach innen + versetzte Ecken, aus den Kantenebenen berechnet), 2·n Dreiecke; Füllung nutzt die inneren + Ecken mit. Alles 0,012 entlang der Flächennormale versetzt (kein Z-Fighting). Dadurch stimmen + Kachel-Transformation, Zonen-/Sichtbarkeit und Kachel-Wechsel (Alt-Modelle, WFPC 0x10000) + automatisch. Kollision wird aus dem Spielraum zurückgerechnet (geomlib.coll_decode/ + coll_polygon, z gespiegelt). +* Face-Flags der Overlay-Dreiecke: 0x800 | 0x400 (doppelseitig) | 0x4000 (Vertexfarben + ignorieren) | 0x8 bei jedem Texturwechsel und spätestens alle 250 Faces (Render-Batch). +* **Farben** = angehängte Flachfarb-Texturen (TEXT-Tabelle, Seite −1): deckend Flags 0x105, + halbtransparent **0x1105** (Bits 12–14 = 1 → Tabelle 0x573eb8: Blendmodus 1 = + SRCALPHA/INVSRCALPHA, Alpha aus dem 4. Byte (v1) des Eintrags; im Spiel geprüft). + Halbtransparente Dreiecke laufen über den Sortierpuffer (FUN_0041ed90, 512 KB/Frame ≈ 5000 + Dreiecke; ist er voll, fallen sie einfach weg – kein Absturz). Deshalb gibt es Füllungen nur + für unsichtbare Kollision, Material 16 und kleine Zonenböden. +* **Vertexfarben-Block** in MAP: je Kachel (und je Farbsatz/Frame, auch für Alt-Modelle) um + nV(Overlay) Einträge 0x00808080 verlängert (Byte 3 = 0, ändert die Frame-Anzahl nicht). +* **Unsichtbar-Erkennung**: Render-Dreiecke aller Kacheln in Weltkoordinaten, nach Zellen + gebuckelt; für Mittelpunkt + 4 Punkte (70 % Richtung Ecken) jedes Kollisionspolygons wird ein + Strahl entlang der Normale (±0,5) gegen Dreiecke mit ähnlicher Ausrichtung (|cos| > 0,4) + getestet; < 60 % Treffer = unsichtbar. Je Modell Mehrheitsentscheid über bis zu 6 Instanzen. + Strat-Modelle (STPC: Türen, Plattformen, Figuren) zählen NICHT als Render-Fläche – Kollision + hinter beweglichen Objekten kann daher rot erscheinen. + (Erste Version mit 0,06/0,15 Abstand markierte ganze Hauswände und Felswände rot, weil die + Kollisionshülle 0,2–0,4 vor der sichtbaren Wand liegt – zu viel Rauschen.) +* **Marker** als neue Kacheln ohne Kollision, je Rasterzelle ein Modell (Linien in ≤ 1-Stücke + zerteilt und in die Zelle ihres Mittelpunkts gelegt) → bekommen automatisch die Zone der + Zelle. Neue Kacheln werden hinten angehängt (Indizes der Original-Kacheln bleiben). +* **Spielmechanik unverändert**: keine Kollisionspolygone hinzugefügt/geändert, Strats, + Wegpunkte, Parameterpool, Zonen und Rest von MAP byte-gleich (wird nach dem Schreiben + geprüft: Strats/Knoten/Rest/Original-Kacheln identisch). +* Größe: WADs wachsen um 0,1–4 MB (max. 8,9 MB, t1l6m003). Level-Speicher (Arena 16 MB, + FUN_0041ef00) hält TRAK+STPC+MAP – reicht für alle (Ladetest). + +## Grenzen / bekannt +* Kollision der **Objekt-Modelle** (STPC: bewegliche Plattformen, Türen, Kisten …) wird nicht + gezeigt – die bewegen sich; nur ihr Ursprung als dunkelgrauer Punkt. +* Zonen-Höhen der Box-Bereiche sind geschätzt; ob die Engine y prüft, ist offen. +* Bedeutung der meisten Sondermaterialien unbekannt (nur Farbe + Anzahl, s. Tabelle unten); + Material 30 = kein Boden, 16 = Ausgang, 17/18 = selektiv sicher aus dem Code. +* Umgebungszonen (MAP-Block 4): Zelle sicher, Bedeutung (Nebel/Musik/Kamera) nur mittel sicher; + Vorgabe-Eintrag (Position 1,1,1) wird nicht gezeichnet. +* Liegt die Kamera dicht an einem Marker/einer Linie, zerschneidet der Near-Plane-Clip der + Engine die Dreiecke manchmal zu großen Fetzen (nur kurz sichtbar). +* Leistung: unter Wine/llvmpipe (Xvfb) ohne spürbaren Einbruch; auf echter GPU unkritisch + (≤ 2000 zusätzliche Vertices je Kachelmodell, Ø ~1–2× so viele Dreiecke wie das Original). +* Testlevel T1L9 (Testwelt) hat keine Debug-Version (testlevel-Mod hat Vorrang). + +## Statistik je Level (Stand Build 2026-11-06) + +| Level | Welt | WAD alt→neu (KB) | Koll.-Polygone | davon unsichtbar | Sondermaterialien (Anzahl Polygone) | Marker | +|---|---|---|---|---|---|---| +| t1l1m001 | Dorf | 6158→8235 | 3474 | 215 | 2:138, 6:4, 8:35, 9:13, 16:8, 17:5, 18:84, 28:19, 30:1 | ausgang 1, checkpoint 6, geheimnis 2, kamera 6, kraft 6, objekt 95, sonst 4, start 1, trigger 1, umgebung 12 | +| t1l1m002 | Dorf | 5471→7573 | 3909 | 130 | 6:4, 8:14, 16:9, 18:65, 28:23, 30:1 | ausgang 1, checkpoint 5, geheimnis 1, kamera 8, kraft 5, objekt 89, sonst 9, start 1, trigger 1, umgebung 15 | +| t1l1m003 | Dorf | 5891→8348 | 3893 | 79 | 2:15, 6:4, 8:8, 16:5, 17:3, 18:458, 20:6, 28:23, 30:1 | ausgang 1, kamera 3, kraft 6, objekt 45, sonst 3, start 1, trigger 2, umgebung 4 | +| t1l1m004 | Dorf | 5866→7006 | 2169 | 60 | 2:15, 8:8, 16:16, 17:3, 18:90, 30:1 | ausgang 2, geheimnis 1, kamera 2, kraft 1, objekt 31, sonst 2, start 1, trigger 1, umgebung 5 | +| t1l2m001 | Dschungelnacht | 5094→7491 | 3933 | 460 | 1:26, 2:111, 6:156, 9:34, 16:22, 17:24, 18:284, 20:80, 28:28, 30:138, 33:1 | ausgang 1, checkpoint 2, geheimnis 1, kamera 3, kraft 6, objekt 39, sonst 8, start 1, trigger 1, umgebung 8 | +| t1l2m002 | Dschungelnacht | 5329→7561 | 3715 | 389 | 1:216, 2:57, 6:154, 9:24, 16:34, 17:23, 18:319, 28:28, 30:50 | ausgang 1, checkpoint 4, geheimnis 1, kamera 3, kraft 7, objekt 105, sonst 2, start 1, trigger 1, umgebung 18 | +| t1l2m003 | Dschungelnacht (Verfolgung) | 2837→5142 | 4844 | 630 | 5:3, 9:6, 10:6, 11:37, 16:26, 27:206, 30:46 | kamera 2, kraft 1, objekt 33, sonst 4, start 1, trigger 1 | +| t1l3m001 | Fluss | 4099→8220 | 8371 | 1000 | 5:26, 16:8, 18:23 | ausgang 2, geheimnis 1, kamera 13, kraft 4, objekt 140, sonst 13, start 1, trigger 5 | +| t1l3m002 | Fluss | 3799→8249 | 8824 | 1097 | 5:40, 16:5, 18:23, 30:1 | ausgang 2, geheimnis 1, kamera 19, kraft 44, objekt 115, sonst 39, start 1, trigger 6 | +| t1l3m003 | Fluss | 4497→8537 | 8387 | 868 | 5:56, 16:5 | ausgang 4, geheimnis 1, kamera 15, kraft 7, objekt 52, sonst 15, start 1, trigger 3 | +| t1l3m004 | Fluss | 2674→3754 | 2201 | 228 | 5:18, 16:5 | ausgang 2, kamera 5, kraft 4, objekt 31, sonst 7, start 1, trigger 2 | +| t1l4m001 | Dschungeltag | 5469→7380 | 3214 | 230 | 1:148, 6:105, 10:33, 16:18, 17:33, 18:158, 20:25, 28:24, 30:10 | ausgang 1, checkpoint 9, geheimnis 1, kamera 5, kraft 12, objekt 120, sonst 3, start 1, trigger 1, umgebung 10 | +| t1l4m002 | Dschungeltag | 5807→8045 | 3820 | 157 | 1:186, 6:123, 10:80, 16:33, 17:55, 18:309, 20:6, 28:98, 30:22 | ausgang 1, checkpoint 7, geheimnis 2, kamera 1, kraft 14, objekt 135, sonst 3, start 1, trigger 1, umgebung 14 | +| t1l4m003 | Dschungeltag | 6320→8886 | 4805 | 135 | 1:174, 2:52, 6:10, 10:33, 16:36, 17:110, 18:890, 20:186, 28:27, 30:33, 255:8 | ausgang 1, checkpoint 3, geheimnis 2, kamera 6, kraft 4, objekt 99, sonst 3, start 1, trigger 1, umgebung 16 | +| t1l5m001 | Berg | 6090→8613 | 4419 | 188 | 3:356, 8:96, 16:24, 17:35, 18:539, 20:66, 28:6, 30:87 | ausgang 1, checkpoint 3, geheimnis 2, kamera 5, kraft 2, objekt 83, sonst 7, start 1, trigger 1, umgebung 15 | +| t1l5m002 | Berg | 5949→8558 | 5242 | 220 | 2:20, 3:396, 8:23, 9:15, 16:19, 17:33, 18:342, 20:57, 30:120 | ausgang 1, checkpoint 3, geheimnis 4, kamera 10, kraft 3, objekt 67, sonst 6, start 1, trigger 1, umgebung 7 | +| t1l5m003 | Berg | 4989→6796 | 3399 | 98 | 3:232, 8:223, 16:17, 18:20, 20:6, 30:78 | ausgang 1, checkpoint 5, geheimnis 1, kamera 4, kraft 1, objekt 71, sonst 6, start 1, trigger 1, umgebung 2 | +| t1l6m001 | Stadt | 6210→8227 | 2857 | 169 | 6:29, 16:18, 17:140, 18:110, 30:38 | ausgang 1, checkpoint 4, geheimnis 1, kamera 5, kraft 3, objekt 83, sonst 3, start 1, trigger 5, umgebung 20 | +| t1l6m002 | Stadt | 5155→7060 | 1771 | 106 | 6:56, 16:9, 17:20, 18:39, 30:17 | ausgang 1, checkpoint 10, geheimnis 1, kamera 7, kraft 9, objekt 127, sonst 16, start 1, trigger 5, umgebung 46 | +| t1l6m003 | Stadt | 6693→8924 | 2462 | 148 | 1:39, 16:13, 17:80, 18:154, 28:3, 30:10 | ausgang 1, checkpoint 11, geheimnis 6, kamera 11, kraft 6, objekt 107, sonst 15, start 1, trigger 3, umgebung 59 | +| t1l7m001 | Katakomben (Achterbahn) | 3054→6705 | 4043 | 20 | 5:54, 11:18, 13:18, 16:9, 30:18 | kamera 1, objekt 2, sonst 36, start 1, trigger 1, umgebung 14 | +| t1l7m002 | Katakomben | 5280→7722 | 1795 | 111 | 2:1, 11:3, 16:44, 30:93 | ausgang 3, checkpoint 12, geheimnis 5, kamera 6, kraft 4, objekt 186, sonst 12, start 1, trigger 1, umgebung 23 | +| t1l7m003 | Katakomben (Achterbahn) | 2496→5140 | 1999 | 11 | 5:54, 11:90, 13:36, 15:18, 16:9, 30:9 | kamera 1, objekt 11, sonst 41, start 1, trigger 1 | +| t1l7m004 | Katakomben | 5299→6978 | 1892 | 61 | 2:7, 16:32, 30:70 | ausgang 1, checkpoint 3, geheimnis 3, kamera 4, kraft 7, objekt 93, sonst 10, start 1, trigger 2, umgebung 15 | +| t1l7m005 | Katakomben (Achterbahn) | 2390→4782 | 1885 | 9 | 5:54, 11:36, 13:36, 16:9, 30:9 | kamera 1, objekt 1, sonst 51, start 1, trigger 1, umgebung 9 | +| t1l8m001 | Labor | 6632→8824 | 3396 | 185 | 1:40, 8:9, 16:16, 17:8, 18:181, 30:56 | ausgang 1, checkpoint 2, geheimnis 2, kamera 8, kraft 2, objekt 99, sonst 3, start 1, trigger 2, umgebung 19 | +| t1l8m002 | Labor | 5427→7112 | 2204 | 76 | 16:1, 17:44, 18:496, 30:42 | ausgang 1, geheimnis 1, kamera 5, kraft 2, objekt 74, sonst 2, start 1, trigger 3, umgebung 1 | +| t1l8m003 | Labor | 4656→5546 | 1398 | 34 | 16:9, 17:10, 18:196, 30:21 | ausgang 1, checkpoint 2, geheimnis 1, kamera 2, kraft 2, objekt 36, sonst 2, start 1, trigger 3, umgebung 6 | +| t1l8m004 | Labor | 5817→8407 | 2993 | 124 | 9:10, 16:66, 17:1, 30:57 | ausgang 1, checkpoint 7, geheimnis 3, kamera 12, kraft 6, objekt 164, sonst 3, start 1, trigger 7, umgebung 46 | +| t1l8m005 | Labor | 5375→8660 | 4041 | 179 | 7:10, 16:78, 17:1, 30:82 | checkpoint 7, geheimnis 1, kamera 14, kraft 5, objekt 113, sonst 15, start 1, trigger 2, umgebung 47 | +| t1l8m006 | Labor | 5423→5948 | 738 | 44 | 2:13, 8:24, 16:3, 17:5, 18:27, 26:4 | ausgang 1, objekt 15, start 1, trigger 17 | +| t6l6m001 | Sonder (Optionen/Karte) | 1331→1482 | 333 | 0 | | kamera 1, objekt 3 | +| t6l6m002 | Sonder (Optionen/Karte) | 3605→3640 | 64 | 0 | | kamera 1, objekt 2 | + +(Kollisionspolygone je TRAK-Modell gezählt, nicht je Kachel-Instanz.) + +## Tests (2026-11-06) + +**Offline (alle 33 Debug-WADs, `$JOB/tmp/dv/offcheck.py`):** TRAK parst und baut byte-gleich +zurück; ≤ 8000 Vertices je Modell (max. 2012, t1l4m003); Render-Batches ≤ 256 Faces; alle +Texturindizes < Tabellengröße; Vertexfarben-Block je Kachel = nV(Modell) (alle Frames); +MAP mit `mapchunk` vollständig geparst (Rest 0 Byte) und byte-gleich neu gebaut; Strats, +Wegpunkte, Parameterpool, Umgebungszonen, Zonentabelle/-raster, Alt-Block und Original-Kacheln +unverändert; Original-Modelle (Vertices, Faces, **Kollision**) byte-genau unverändert; neue +Marker-Modelle ohne Kollision; alle anderen Chunks (STPC, Sound, LGPC …) identisch; +Kacheln im Raster. TRAK+STPC+MAP+LGPC ≤ 7,6 MB (Level-Arena 16 MB). + +**Im Spiel geladen** (lamatest, über Slot Welt 9 als T1L9M001.wad, ~40–90 s gespielt/gedreht, +kein Absturz, kein Hänger, keine Fehler im Wine-Log): t1l1m001 (mehrfach, Sichtprüfung), +t1l1m003, t1l1m004, t1l2m001, t1l2m002, t1l3m001–004 (Fluss läuft auch im Slot 9 normal: +Boot fährt), t1l4m001–003, t1l5m001–003, t1l6m001–003 = 19 Level. +Getestet wurde der Build direkt vor dem endgültigen: identische Geometrie, nur die Farbwerte +der Sondermaterialien wurden danach auf feste Farben je Material umgestellt (+8–16 Byte +Texturtabelle). Die Vorschaubilder stammen ebenfalls aus diesem Build – Sondermaterial-Farben +darin (z. B. Türkis-Füllung im Berg-Bild) entsprechen noch nicht ganz der Legende oben. +**Nur offline geprüft:** t1l1m002 und t1l2m003 (Ladeversuch scheiterte vorher an der +Menü-Erkennung von lamatest, nicht am Level), t1l7m001–005 (Katakomben/Achterbahn), +t1l8m001–006 (Labor), t6l6m001/002. Ein Test über den echten Spielstand-Slot wurde nicht gemacht. + +lamatest-Änderung nebenbei: Spielstand-Auswahl mit mehr Versuchen und 0,3 s langen +Tastendrücken (unter CPU-Last gingen kurze Drücke verloren). + +Bilder: `notes/img/debug/00_montage.jpg` (6 Welten), `01_…06_*.jpg` (1280×720). + + + diff --git a/notes/editor-api.md b/notes/editor-api.md new file mode 100644 index 0000000..d3c9cae --- /dev/null +++ b/notes/editor-api.md @@ -0,0 +1,158 @@ +# Level editor core – API (`editor.model`, `editor.model.cmd`, `editor.io`) + +Status: phase 1 done (core, no UI): `src/main/java/com/eliasmueller/llamamod/editor/{model,model/cmd,io}`, +LlamaMod integration in `mods/levels` (`notes/mod-levels.md`). Tests: `src/test/java/.../editor/io`. +Project file format: `notes/editor-format.md`. Design brief: `notes/editor-design.md`. + +## Quick start + +```java +byte[] wad = Files.readAllBytes(cd.resolve("wads/t1l1m001.wad")); +Scene s = WadImporter.importWad(wad, "t1l1m001", rel -> read(cd.resolve(rel))); // original level +History h = new History(s); // undo/redo +s.addListener(e -> viewport.repaint()); // change events +h.execute(Edits.translate(List.of(strat), 1.0, 0, 0, null)); // move 1 cell east +h.undo(); +byte[] out = WadBuilder.build(s); // Scene → WAD (byte-identical if unedited) + +Bases bases = Bases.fromCd(cd); // original CD folder +Project p = ProjectIO.load(path, bases); // .llamalevel (p.scene(), p.warnings()) +ProjectIO.save(p, path, bases); // stores only the changes +Project n = Project.create(bases, "new", "t1l1m001"); // empty level on the village base +Project e = Project.create(bases, "edit", "t1l3m001"); // edit an original level +``` + +## Coordinate spaces (important for rendering) + +| Space | Used by | Axes | +|---|---|---| +| **export space** | `TileModel` vertices (local), `Tile.x/y/z` (cells, z ≤ 0), `Light` x/y/z | right-handed, y up; world vertex = `tile.transform().point(vx, vy, vz)` | +| **game space**, 20.12 fixed point (4096 = 1 cell) | `Strat`, `PathNode`, `EnvZone` target, collision records | x east, y up, z = grid row (positive) = −export z | +| **game space, cells (double)** | `Brush` parameters, `MeshData` | same, unscaled | + +Angles: ints in 1/4096 turns (`PropType.ANGLE`). Strat yaw 0 = facing +z (game), 1024 = facing +x. +Tile transform (export space): `X = x + vx·cos a − vz·sin a; Y = y + vy; Z = z + vx·sin a + vz·cos a`, +a = rot/4096·2π (`format.Geometry.TileXform`). Grid cell of a tile = (floor(x), floor(−z)). +Collision records (`TileModel.collision0..2`, 32 bytes) are stored in game space; decode with +`TileModel.collisionPolygon(g, k)` (→ `format.Collision.Decoded`, already converted to export +space) and `Collision.polygon(decoded)` for the corners (local to the tile; apply the tile transform). +Placed in the world: `tile.worldCollisionPolygons()` / `model.collisionPolygons(xf)` (corners, normal, +material; polygons with fewer than 3 corners skipped). + +## Scene (`editor.model.Scene`) + +Singletons: `settings()` (`LevelSettings`), `regions()` (`Regions`). Ordered lists (`NodeList`, +read-only `List` with O(1) `indexOf`; order = WAD order): + +| Accessor | Node | WAD source | +|---|---|---| +| `tileModels()` | `TileModel` | TRAK models (vertices, faces, planes, 3 collision groups, bbox header) | +| `tiles()` | `Tile` | MAP tiles: x, y, z, rot, `model` (ref), swap (alt model, door/bridge), vertex colours | +| `strats()` | `Strat` | MAP objects: `type` (name), `code`, position/rotation (fixed), `params`, type fields, `waypoint` (ref), flags | +| `pathNodes()` | `PathNode` | MAP waypoints/camera/respawn nodes; `next` (ref) chains | +| `zones()` | `EnvZone` | MAP 90-byte env/teleport/respawn zones incl. fog, colours, music | +| `lights()` | `Light` | LGHT (directional / point) | +| `pages()` | `TexturePage` | TEXT pages (256×256 ABGR1555, `argb()` for images) | +| `textures()` | `TextureEntry` | TEXT texture table (faces use the **index** in this list) | +| `textureAnims()` | `TextureAnim` | TEXT texture animations | +| `brushes()` | `Brush` | editor-only primitives → tiles at build time | +| `meshAssets()` | `MeshAsset` | imported OBJ/glTF (`mesh()` = parsed `MeshData`) | +| `chunks()` | `RawChunk` | WAD chunk order; unmanaged chunks (STPC, sound, …) verbatim | + +Other: `node(id)`, `listOf(kind)`, `catalog()` (`StratCatalog`: placeable object types of the base +level, variants with type fields, default params and relative waypoint chains), `assets()` +(project files), `chain(pathNode)`, `playerStart()`, `referencesTo(node)`, `create(kind)` / +`createBrush(kind)` (detached node with a fresh id), `add/remove/set` (with events, no undo). + +**Stable ids**: imported nodes `":"` (`tile:12`, `strat:3`, `wp:40`, `zone:0`, +`model:7`, `tex:120`, `page:2`, `light:0`, `anim:1`, `chunk:5`), new nodes `":n"`, singletons +`settings:0`, `regions:0`. Ids never change, also not when other nodes are deleted. + +## Nodes and properties + +Every node: `id()`, `kind()` (`NodeKind`), `label()`, `props()` → `List`. A `Prop` has +`name()`, `label()`, `group()` (inspector section), `type()` (`PropType`), `readOnly()`, +`nullable()`, `refKind()`, `choices()`, `get(node)`. Value classes by `PropType`: +INT/FIXED/ANGLE/FLAGS/COLOR → `Integer`, DOUBLE → `Double`, BOOL, STRING/CHOICE → `String`, +REF → `Node`, INT_ARRAY `int[]`, DOUBLE_ARRAY `double[]`, BYTES `byte[]`. Arrays are shared: never +mutate a returned array, set a new one. Typed getters exist on every node class (`tile.x()`, +`strat.gx()` (cells), `model.vertices()` …). + +Key render data: +* `TileModel.vertices()` 6 doubles/vertex (x y z nx ny nz), `faces()` 6 ints/face (flags, v0, v1, + v2, tex, pad), `planes()` 4/face; helpers `vertexCount()`, `faceCount()`, `faceFlags(f)`, + `faceVertex(f,k)`, `faceTexture(f)`, `collisionCount(g)`, `collisionMaterial(g,k)`. +* UVs: `format.Geometry.faceUvs(flags, Geometry.texUv(entry.toFormat(), page.width(), page.height()))`; + flat colours: `TextureEntry.isFlat()`, `flatRgb()`. Face flags: 0x400 double-sided, 0x4000 + ignore vertex colours, 0x8 batch start. +* `Tile.colors()`: frames × nv RGBA words (0x80 = 1.0, engine ×2), first frame = `colors[0..nv)`. +* `Regions.visibleFrom(camCellX, camCellZ, cellX, cellZ, width)` = engine visibility rule. +* Collision materials: 0 normal, 16 exit floor (LevelDoor), 17/18 selective, 30 no floor/death. +* `StratCatalog.category(type)` → player, exit, checkpoint, camera, trigger, collectible, force, other. + +## Commands (`editor.model.cmd`) + +`History(scene)`: `execute(cmd)`, `undo()`, `redo()`, `canUndo/Redo`, `undoLabel/redoLabel`, +`begin(label)`/`end()` (group into one step), `markSaved()`/`isDirty()`, `addListener(Runnable)`. +Commands: `SetProp.of(node, "prop", value)` / `SetProp.of(node, prop, value, mergeKey)`, +`AddNode(node[, index])`, `RemoveNode(scene, node)` (clears optional references, refuses if a +tile still uses a model), `Batch(label, cmds[, mergeKey])`. **Drags**: give every step of one +gesture the same `mergeKey` object → consecutive steps merge into one undo entry. +`Edits` helpers: `translate(nodes, dx, dy, dz, mergeKey)` (cells, game space; strats, waypoints, +tiles, brushes, zone targets, point lights), `rotateTo(node, degrees, mergeKey)`, +`placeStrat(scene, type, variant, x, y, z, yawDeg)` (copies type fields/params/waypoint chain from +the catalog), `delete(scene, nodes)` (also removes waypoint chains used only by deleted strats), +`addBrush(scene, kind, params)`, `set(label, Map>, mergeKey)`. + +Events (`SceneListener`, `SceneEvent`): `ADDED(node, index)`, `REMOVED(node, index)`, +`CHANGED(node, prop, oldValue)`, `RELOADED` (bulk change, rebuild everything). Fired synchronously +on the changing thread (use the EDT). + +## Brushes (`Brush`, `BrushKind`) + +Kinds FLOOR, BOX, PLATFORM, PILLAR, RAMP, STAIRS, FENCE, TREE, SEA, KILL_PLANE, QUAD, COLLIDER, MESH +– the `levelbuild.LevelBuilder` primitives; parameters = props named as in `BrushKind.params()` +(`Param(name, label, type, default, role, choices)`; role X/Y/Z/XZ_LIST/XYZ_LIST/XZ_RECT marks +coordinates). `brush.bounds()` gives an approximate box. Texture parameters: village names +(`LevelBuilder.TEX`), `"#index"`, flat colour names (`LevelBuilder.FLAT`) or `"rgbRRGGBB"`. +For a preview of the generated geometry: `BrushCompiler.compile(scene, warnings)` → generated +`TileModel`s/`Tile`s/textures (same as the build). + +## IO (`editor.io`) + +| Class | Purpose | +|---|---| +| `WadImporter.importWad(bytes, base, cd)` | original WAD → Scene (cd: loader for other CD files, may be null) | +| `WadBuilder.build(scene)` / `buildWithReport(scene)` | Scene → WAD (+ `Stats`, warnings); validates first, throws `IllegalStateException` with all errors | +| `Validator.validate(scene, warnings)` | errors (unbuildable) + warnings, for a problems panel | +| `BrushCompiler.compile(scene, warnings)` | brushes/meshes → generated `TileModel`s/`Tile`s/pages/entries (ids "gen:…", not in the scene) – usable as a preview of the build | +| `Project` | `scene()`, `thumbnail()`/`setThumbnail(png)`, `warnings()`; `Project.create(bases, "edit"/"new", base)` | +| `ProjectIO` | `load/read(bytes)`, `save(p, path, bases)`/`write`, `info(bytes)` (header only), `sha256` | +| `Templates` | `original(bases, base)`, `newLevel(bases, base)` (reference), `clearBaseLeftovers(scene)`, `DEFAULT_BASE` | +| `Bases` | original files: `Bases.fromCd(dir)` (case-insensitive), `wad(base)`, `file(rel)` | +| `MeshImport.importFile(scene, path)` | OBJ(+MTL, map_Kd), glTF (.gltf + .bin/data URIs, .glb) → detached `MeshAsset` (add with `AddNode`); files copied into `scene.assets()`; `load(scene, asset)` re-parses | +| `TextureImport.importImages(scene, images, size)` | images → `Result(command, entries, pages)`: execute the command, then use `"#" + entries[i]` as a texture | +| `Images` | page ↔ PNG/`BufferedImage` (`pageImage`, `pagePng`, `toAbgr1555`) | + +Meshes at build time: transformed (scale, yaw, position), then every triangle goes through +`LevelBuilder.quad` (split into 1×1 cells / 10-unit height bands, collision polygon per triangle +with the brush's material, lit vertex colours). The engine has no free UVs – a triangle always +shows one half of a texture rectangle – so each triangle gets the rectangle around its UVs +(rounded to `uvSteps` per texture) on a copy of its material texture (flipped in U and/or V when +that fits the corner order better). Rectangular quads map exactly; arbitrary UV layouts are +approximated; tiling UVs (> 1) are clamped. Material textures are packed into new 256×256 pages +at `texSize` (power of two). Untextured materials use flat colours (Kd / baseColorFactor); +vertex colours tint the lighting. + +## Limits / unknown (phase 1) + +* Strat code (STPC) and object models are kept verbatim: only types present in the base level are + placeable (`StratCatalog`), plus RubyIdol borrowed from t1l1m002 for the village (as levelbuild). +* Meaning of many raw fields is unknown (`unk*` props of zones/waypoints, texture entry flags, + collision groups B/C, region value) – they are editable but not interpreted. +* Tile swaps (WFPC 0x10000) can only exist in bases with that flag. +* Texture names exist only for the village (levelbuild names); other levels use `#index`. +* Lights: types other than 1/2/4 carry no data and are ignored by the engine. +* Removing original tiles shifts tile indices used by strat scripts (see editor-format.md). +* The engine limits: ≤ 8000 vertices per tile model, collision within ±8 of the tile origin + (handled by the brush compiler), texture pages ≤ 127. diff --git a/notes/editor-cross-import.md b/notes/editor-cross-import.md new file mode 100644 index 0000000..9cd94ae --- /dev/null +++ b/notes/editor-cross-import.md @@ -0,0 +1,153 @@ +# Level editor – importing object types from other levels ("All worlds") + +Goal (owner): import objects of **all** worlds into the current level, so a fresh map does not start +from scratch. Packages: `format.stpcmerge` (STPC analysis + merge, no editor dependency) and +`editor.tools.catalog` (`CatalogEditorPlugin`: catalogue, asset tab, drag & drop, bulk import, +project integration). Tests: `StpcMergeTest`, `CrossImportTest` (need `-Dllamamod.cd`). + +![All worlds](img/editor/xobj_all_worlds_tab.jpg) + +## STPC layout (exact for all 33 level WADs) + +``` +u32 nModels, nModels × (0x8c header + data) loader FUN_0041f770, one call per model +u32 nAnims, nAnims × animation set FUN_0041f8b0 +[WFPC & 0x100: model block – used by no CD level] +code region: u32 nUnits, nUnits × {u32 nWords, nWords × u32} ← new finding +``` +The code region is a list of **units**, one per compiled strat source file (village: 53, lab 1: 65); +the chain ends exactly at the chunk end in every level. A unit is self-contained: name strings, inline +data, handler tables and code of one or more strats (e.g. `SinkingStatuePad` + `SinkingStatue`, +`Spider` + `RopeForSpider`). The loader never parses units; MAP strat records and the bytecode +address them by absolute STPC offsets. Other references to STPC: MAP zone field `stpcOffset` (always +a model header: sky models) – nothing else (`DAT_006d9dbc` is used only by the zone loader, the strat +record loader and opcode 0xb2). + +## Strat VM (FUN_0054d180) + +u32 words, low 16 bits = opcode. Opcodes < 0x45: table 0x5796bc, the signed high 16 bits are the +argument. Opcodes 0x45..0x1b1: table 0x578ff4, operands inline (0x1b2+ alias the short table and never +occur). Operand counts were measured by **emulating all 435 handlers** of groove.exe (unicorn, fake +strat struct, IP into a sentinel buffer, several operand/stack fillings) and checking the variable +ones by hand (`StratOps`): + +| Op | Operands / meaning | +|---|---| +| 0x0c | a, slot: event handler slot; entry {type, param, rel} at pc+8+a·4, handler = pc+8+rel·4 (= the MAP "slots") | +| 0x10/0x11 | call next+arg·4 (pushes the return address); 0x12–0x15 conditional, 0x16 jump, 0x2b jump+yield | +| 0x17 | switch: table at next+arg·4+4: n, n × {case (0x44 short / 0x45 imm), rel} | +| 0x02/0x03 | skip list: n, then n items (2 words for 0x45/0xea) | +| 0x05/0x09 | push word / address at next+arg·4 (inline data) | +| 0x45, 0xd7 | push immediate (0xd7 = **sound sample id**) | +| **0xb2** | push STPC base + operand (≥ 0: absolute STPC offset of a model, animation set, unit code or data; < 0: DEFANIM.WAD animation); self-patches to 0x45 at run time | +| 0xb3 | jump to popped address (return) | +| 0xd4 0xea rel | strat name | +| 0xe0 | spawn: pops code address, 5 inline type-field words | +| 0xf0, 0xfa | 1 operand: **text index** (LGPC) | +| 0x94/0x95 | 5/6 inline words; 0xa5 0xa6 0xb1 0xd0 0xd3 0xdb 0x115 0x11c 0x14b 0x14d 0x162 0x167 0x175: 1 word | +| 0xb4, 0x8e, 0x8c | end/kill; with 0x16/0x2b/0xb3 the terminators | +| 0xd5/0x99/0x131 | play / stop / is-playing sound (id from the stack; bit 0x80 = front-end sound) | +| 0x5a, 0xde, 0x187, 0x1a9 | take a **TEXT texture index** from the stack (sprite/shadow at strat+0x128, particles, screen effect); the compiler pushes it with 0x45 right before | +| 0x11a/0x11b | swap the alternative tile models of the strat's cell | + +**Disassembler** (`StratCode`): recursive descent from MAP entries, name sites, 0xb2 code targets, +switch/handler tables; then code after terminators is decoded tentatively and kept if clean (unplaced +spawn stubs). Result on all levels: no decode errors, ~92 % of the code region covered (rest: strings, +inline data), and **no word 0xb2 outside a decoded instruction** – the relocation set is complete. + +**Level-dependent operands** were confirmed by diffing the same unit across levels (units equal +modulo operands): every differing word is a 0xb2 operand, a 0xd7 sound id, a 0xf0/0xfa text index or a +0x45 texture index before a texture op – nothing else. + +## Merge (`StpcMerge`, `TextMerge`, `SoundMerge`, `DialogMerge`) + +New STPC = `[target models][imported models][target anims][imported anims][target units][imported units]`. +Models must stay in the model block (the loader turns their offsets and face texture indices into +pointers), so appending is only possible at the end of each block: **target model offsets stay; +target animation offsets move by the imported model bytes, target code offsets by models + anims.** +Every absolute reference of the target is relocated with that piecewise map: 0xb2 operands (from +the disassembly) and MAP strat codes (zones point at models and stay). + +Per import request (source level + MAP entry): +1. Closure: the entry's unit, then transitively every unit, model and animation set reached by its + 0xb2 operands (sub-strats: shards, projectiles, effects, heads …), plus sound ids, texture indices + (code constants + model faces) and text indices of the copied units. +2. Units identical to a target unit (bytes with the level-dependent operands masked) are **not copied** + but mapped onto the target's unit (Coin, EnemyCoin, Sparkles …); models identical after texture + remapping and identical animation sets are shared too. +3. Textures: needed entries (+ flip-book frames of TEXT animations). Pages identical to a target page + are reused; mostly used pages are copied whole; otherwise the used rectangles (+1 px border) are + packed into new 256×256 pages per page class. Entries identical to a target entry are reused. +4. Sounds: SMPC samples appended (identical ones reused); 0xd7 operands remapped. +5. Texts: LGPC groups (language strings + speech slot) appended, 0xf0/0xfa operands remapped; a speech + slot `#hex` is an SRPC index – SRPC entries (offset/size into the global `Music/.CVS`) are + copied and the slot rewritten (`#` + upper-case hex, FUN_0040d890 reads only upper case). +6. Write, relocate imported 0xb2 operands (source → new offsets), then **verify**: disassemble the + merged STPC again; the set of 0xb2 sites and their values must equal the expected relocation exactly + and every value must hit a model header, animation set or unit. + +Deterministic: same inputs → same bytes (test). All 925 (level, type) entries of the CD merge into +the village and verify (test `everyPlacedTypeMergesIntoTheVillage`). + +## Engine limits + +| Limit | Value | Source | +|---|---|---| +| level memory | 16 MB block for STPC, TEXT (compressed pages + 20 B/entry), TRAK, MAP, LGPC, strat pool | FUN_0041eea0 / FUN_0041ef00 (returns 0 when full) | +| sound samples | 128 (id is a signed char, bit 0x80 = front end) | FUN_00548760 | +| texture pages | 127 (signed byte page index) | TEXT entry | +| texture entries | 65535 (u16 face field) | | +| strat pool | poolExtra + 30 slots | MAP | +The editor estimates level memory from the scene (`CrossImport.limits`); originals use ≤ 7 MB. The +import preview and Build check report it (errors above the hard limits, warnings near them). + +## Editor integration + +* **Project file**: `level.json` key `importedTypes`: `[{"types":[{"level":"t1l2m001","type":"Spider","code":…}]}, …]` + (batches in order). No game data. `ProjectIO` load hook (`ProjectIO.addLoadHook`, registered by + `CrossImport`) merges every batch into **both** the reference scene and the current scene before the + stored changes are applied, so the diff still only contains the user's edits; instances of imported + types are ordinary added strats. Builds (editor, Play, `--editor-build`, LlamaMod level mods) use the + merged scene → deterministic. +* At run time an import merges into reference + scene (strat codes, STPC/SMPC/LGPC/SRPC chunks, new + texture pages/entries/anims inserted after the reference ones – user textures behind them are + renumbered, catalog with remapped codes + the imported variants). Code offsets move, so the undo + history is cleared and replaced by a permanent "Import …" entry. Imports cannot be removed. +* **Catalogue** (`ObjectCatalog`): every placed type of every level (906 types, 33 levels, ~3 s): + name, level/world/chapter, refined category (enemies, collectibles, mechanisms, characters, animals, + effects …), instance count, parameters, dependency counts/size, thumbnail (software rasteriser, + posed skinned models), importable + reasons/notes. Not importable: player starts, cameras and cut + scenes (126); notes: texts copied, tile swaps, waypoint chains. Cached in + `/editor/catalog/` (catalog.json + thumbs/), keyed by the CD's WAD sizes. +* **UI**: Assets → *All worlds* (search, world/category filters, "Importable only", ✓ for imported + types, tooltips with dependencies). Drag into the viewport = import (background) + place at the drop + point; double-click = view centre; context menu: import only, import all of that level. *Import + world…* (Level menu, Ctrl+Shift+I, tab button): all types of a world or level with checkboxes, sizes, + and *Preview* (exact merge result vs. limits). Inspector: "Imported object – from …". Level check: + limits. Imported types then also appear in Assets → Objects like the base's own. + +![drag & drop](img/editor/xobj_drag_drop_inspector.jpg) +![import world](img/editor/xobj_import_world_preview.jpg) + +## Verified in game (apptest, display :6, 2026-11-07) + +* **New level on the village base** (flat floor, chapter 5): Spider (Jungle at Night 1) drops on its + rope when the llama comes near (`xobj_game_spider.jpg`); Grapes (City 2) render and are collected + (fruit counter); CitySwingingBlade (City 2) and MerryGoRound (Mountain 1) render; Bucky (Jungle 1, + dragged in the editor, with texts and sounds) runs around and throws nuts (`xobj_game_bucky.jpg`). + Plus the whole of Jungle at Night via *Import world…* (28 types, +1.2 MB, 44/127 pages, 98/128 + sounds): builds and loads, no crash. +* **Edited original village** (t1l1m001 + Spider, 2 Grapes, JumpingBeetleBlue (jungle), + CitySwingingBlade): intro dialogue still shows the village's own texts, the blade swings, beetle and + grapes are there (`xobj_game_village_*.jpg`), no crash. +* Sounds are verified only structurally (merged bank, remapped ids; the test sessions are muted). + +## Known limits + +* Objects that belong to level geometry (tile swaps, doors in walls, river/roller-coaster specials) + import fine but need matching geometry; waypoint chains are copied relative to the object. +* Parameters are those of the source's first instance (edit them in the inspector). +* A type whose code is identical to the target's (e.g. Coin) is shared, i.e. it keeps the target's + model/textures. +* Level-memory figure is an estimate (sum of the loaded chunks), not measured at run time. +* Imports cannot be undone or removed from a project (re-create the level or edit level.json). diff --git a/notes/editor-design.md b/notes/editor-design.md new file mode 100644 index 0000000..96e5d14 --- /dev/null +++ b/notes/editor-design.md @@ -0,0 +1,48 @@ +# Llama Level Editor – design brief + +Goal (from the project owner): a fully fledged, graphical **3D level editor** for "Ein Königreich für ein Lama" +(Emperor's New Groove PC) – "everything the Argonaut developers probably had available. Support everything that +is possible. Nicely organised, easy to use, well designed." Results are shared as LlamaMod mods. + +## Scope +- Create new levels from scratch (primitive building blocks like levels/testwelt, tile painting, objects). +- Open and modify ORIGINAL levels (move/add/delete objects, tiles, waypoints, zones, lights …). +- Import own 3D models (OBJ, glTF) as level geometry incl. textures and collision. +- Import/edit textures. +- Everything the format allows: tiles (TRAK models on the MAP grid, height, rotation, alt models), strats/objects + with parameters and flags, waypoint chains / paths / camera nodes, environment+teleport/respawn zones (the 90-byte + records), fog per zone, lights (LGHT), visibility regions (region grid + masks), vertex colours / lighting bake, + collision polygons and materials (e.g. 16 = exit floor, 30 = death/no floor), level name, music, kill plane. +- Play-test button: build + launch the game straight into the level. + +## Architecture +- Same Gradle project (`~/projects/llamamod`), packages under `com.eliasmueller.llamamod.editor`: + - `editor.model` – scene model (pure data, no UI): Scene, nodes for tiles/brushes/meshes, objects (strats), + waypoints, zones, lights, regions, materials, assets. Undoable commands (`editor.model.cmd`). + - `editor.io` – project file format, original-level import (WAD → Scene), build (Scene → WAD) on top of + `format` and `levelbuild`. + - `editor.render` – OpenGL viewport (LWJGL 3 via lwjgl3-awt `AWTGLCanvas` inside Swing), textured + + vertex-coloured rendering, wireframe/collision overlays, picking, gizmos. + - `editor.ui` – Swing shell (FlatLaf, same look as LlamaMod): menu/toolbar, viewport, outliner (tree), + inspector (properties), asset browser (models, object types, textures), status bar. +- Launch: from LlamaMod ("Level Editor" button/tab) and standalone `--editor [file]`. +- Natives: LWJGL natives for linux-x64 and windows-x64 must be packaged (both packages). + +## Project format (`.llamalevel`) +- A zip: `level.json` (scene) + `assets/` (imported meshes, PNG textures) + `thumbnail.png`. +- Contains NO original game data. Original-level edits store only a reference (`base: "t1l1m001"`) plus edits + (diff); new levels reference a base level for its object code (STPC) and textures (default `t1l1m001`). +- Deterministic build: same project + same CD → byte-identical WAD. +- Versioned schema (`formatVersion`), forward-compatible loading (unknown fields kept). + +## LlamaMod integration +- Every `.llamalevel` in `/levels/` (plus "Import level…") appears as a mod in category `levels`. +- New levels occupy TestLevel (world 9) chapters 1..49; the user picks a free slot (default: next free). + Edited original levels replace that level's WAD. +- Conflicts (two mods writing the same WAD) are reported clearly. + +## Conventions +- English everywhere, only necessary comments, no commits, `./gradlew spotlessApply build` green. +- Reuse `format`, `levelbuild`, `mods.debug` (collision/marker knowledge), `mods.mirror` (env-zone/light layouts). +- Test in game with `tools/apptest.py` (private silent PulseAudio, own Xvfb display; user watches :5). +- UI screenshots on Xvfb (llvmpipe OpenGL works there) – look at them, iterate on design. diff --git a/notes/editor-format.md b/notes/editor-format.md new file mode 100644 index 0000000..f67d80d --- /dev/null +++ b/notes/editor-format.md @@ -0,0 +1,90 @@ +# `.llamalevel` – level project format (version 1) + +A zip file (deterministic: fixed entry times, `level.json` first, then `thumbnail.png`, then the +assets sorted by path). Code: `editor.io.ProjectIO`, `SceneDiff`, `NodeCodec`. + +| Entry | Content | +|---|---| +| `level.json` | header + the changes against the reference scene (below) | +| `thumbnail.png` | optional preview (also used for the TestLevel loading picture) | +| `assets//…` | imported meshes with their MTL/textures/buffers (`MeshAsset.file` points here) | +| `assets/pages/.png` | pixels of added texture pages (lossless: ABGR1555 ↔ RGBA with alpha 0/128/255) | + +**No original game data.** A project is stored as the difference to a *reference scene* that is +rebuilt from the user's own CD when the project is loaded: + +* `kind: "edit"` – reference = the original level `base` imported unchanged (`Templates.original`). +* `kind: "new"` – reference = `base` with all tiles, tile models, objects and waypoints removed and + visibility regions off (`Templates.newLevel`). The base contributes object code (STPC), + textures, zones (fog/music), lights and sound. Default base `t1l1m001` (village). + A newly created project (`Project.create`) additionally removes, as ordinary edits stored in the + diff, everything bound to the base's positions (`Templates.clearBaseLeftovers`, since 0.2.0): + all zones except the start zone (flag 8), all non-directional lights, waypoints, and sets every + region mask to "all". The reference stays unchanged, so projects saved before keep their zones + and lights. Textures stay (STPC object models index them). + +Building the same project with the same CD gives a byte-identical WAD (`WadBuilder` is +deterministic; brushes use the stored `seed`). + +## level.json + +```json +{ + "formatVersion": 1, + "app": "LlamaMod level editor", + "kind": "new", // "edit" | "new" + "base": "t1l1m001", // base level (CD wads/.wad) + "baseSha256": "9cff…", // of the base WAD; a mismatch gives a load warning + "name": "Testwelt", // copies of settings for listing without the CD + "targetChapter": 2, // TestLevel chapter for new levels (0 = none) + "nextId": 264, // counter for new node ids ("kind:nN") + "settings": { "": value, … }, // changed LevelSettings props + "regions": { "": value, … }, // changed Regions props + "nodes": { + "": { // chunk, page, tex, anim, model, tile, wp, strat, zone, light, mesh, brush + "removed": ["tile:12", …], // reference nodes deleted + "changed": { "strat:11": { "x": 120716, … } }, // changed props of reference nodes + "added": [ { "id": "strat:n1", "": value, … } ], // all persistent props (+ "brushKind") + "order": ["tile:0", …] // only if the list order differs from reference order + added + } + }, + "…": … // unknown top-level keys are kept when saving +} +``` + +Node kinds, ids and properties: `notes/editor-api.md` (every persistent, settable `Prop`). +Values by `PropType`: numbers (INT/FIXED/ANGLE/FLAGS/COLOR as integers, DOUBLE), booleans, +strings, REF = node id (or `null`). Arrays: up to 64 elements as JSON lists, longer ones as +`{"i32": base64}` (int32 LE), `{"f64": base64}` (float64 LE) or `{"bytes": base64}`. A *changed* +array of unchanged length is stored as runs of the changed elements only: +`{"runs": [[start, values], …]}` (values encoded as above) – e.g. editing a few vertices or +pixels of an original page stores just those values. + +Loading: reference scene → removals → create all added nodes (all kinds, so references across +kinds resolve) → set values of added/changed nodes and settings → apply `order`. Problems +(reference to a missing node, a removed id that does not exist) become load warnings, not errors. +Unknown properties of nodes are kept and written back (forward compatibility); a file with a +higher `formatVersion` loads with a warning. + +## Ids are positions in the original WAD + +Reference node ids are `kind:index` of the original WAD, so a project stays valid as long as the +base level is the same file (checked through `baseSha256`). Deleting reference nodes keeps the ids +of the others (they are not renumbered in the project), but it does change the *indices* written +to the WAD: strat scripts that address tiles by index (stMapreplace / stMapadd) may then hit +other tiles – prefer moving or hiding original tiles over deleting them. + +## Mods + +`/levels/*.llamalevel` are listed as mods (`mods.levels.LevelProjectSource`, see +`notes/mod-levels.md`). CLI: + +``` +llamamod --editor-import # edit project (diff to the CD original) +llamamod --editor-new [base] [--name ] # empty new level +llamamod --editor-build # build (prints statistics, warnings) +llamamod --import-level # copy into /levels +(all take --cd ; default: the installed original CD of the data folder) +``` + +Example: `examples/testwelt.llamalevel` (the Test World as 125 brushes + 55 objects, 7 KB). diff --git a/notes/editor-guide.md b/notes/editor-guide.md new file mode 100644 index 0000000..e0cc60d --- /dev/null +++ b/notes/editor-guide.md @@ -0,0 +1,199 @@ +# LlamaMod Level Editor – user guide + +A 3D level editor for *The Emperor's New Groove* (PC, 2000; German: *Ein Königreich für ein +Lama*). It is part of **LlamaMod**, the mod manager: levels you make are installed and played +as LlamaMod mods. Everything is built from **your own copy of the game** – a level project +contains only your changes, so it can be shared freely. + +![The editor with the village](img/editor/ui_select_move.jpg) + +* [Getting started](#getting-started) +* [Moving around](#moving-around) +* [Creating a new level](#creating-a-new-level) +* [Editing an original level](#editing-an-original-level) +* [Objects, waypoints and zones](#objects-waypoints-and-zones) +* [Importing meshes](#importing-3d-models) +* [Textures](#textures) +* [Play-testing](#play-testing) +* [Sharing levels](#sharing-levels) +* [Shortcuts](#shortcuts) · [Troubleshooting](#troubleshooting) + +## Getting started + +1. Install LlamaMod and let its wizard set up the game from your CD or ISO (Windows or Linux). +2. Click **Level Editor** in LlamaMod's header (or start `LlamaMod --editor`). +3. **Help → Getting started** (Shift+F1) shows a five-step tour inside the editor; + **Help → Shortcut sheet** gives a printable page with every key. + +The window: menu and toolbar at the top, the **Outliner** (everything in the level as a tree) +on the left, the **Inspector** (properties of the selection) on the right, the **viewport** in +the middle and **Assets / Textures / Problems** at the bottom. Panels can be moved to another +side through their tab's context menu and reopened under **View → Panels**. + +## Moving around + +| Input | Action | +|---|---| +| right mouse button held + **W A S D**, **Q / E** | fly (Shift = faster, wheel = fly speed) | +| middle mouse (or Alt + left) | orbit around the selection or the point under the cursor | +| Shift + middle mouse | pan | +| mouse wheel | zoom towards the cursor | +| **F** / **Home** | frame the selection / the whole level | +| numpad 7 / 1 / 3 / 5 | top / front / side / perspective | + +Click selects, Shift+click adds, Ctrl+click toggles, dragging in empty space draws a box +selection. **W** move, **E** rotate, **R** scale (brushes), **Q** select; **G** toggles grid +snapping, Ctrl while dragging inverts it. Every change can be undone (**Ctrl+Z**); **Edit → +History** lists all steps. + +## Creating a new level + +**File → New level** asks for a name, the **base level** and a template: + +* **Empty** – nothing but the base level's sky, fog and music, +* **Flat floor** – a 16×14 grass floor with a start and an exit, +* **Island** – a grass island with trees in the sea. + +The *base level* (default: Village chapter 1) supplies the object types, textures, sounds, +sky, the start zone (fog, music) and the directional lights. Its teleport zones, point lights, +paths and visibility regions are not taken over. New levels live in the game's hidden **TestLevel** world (world 9); each project gets one +of its 49 chapters (shown in **Level → Level settings**). + +![New level dialog](img/editor/ui_dialog_new.jpg) + +Build the level from **brushes** (Assets → Brushes: floors, boxes, platforms, ramps, stairs, +fences, trees, sea, kill planes …) and the base level's **tile models**. Drag them from the +Assets panel into the viewport. Brush parameters (size, textures, collision material) are in +the Inspector. + +Every level needs + +* a **Llama** – the player start (Assets → Objects → Player & start), +* an **exit**: a **LevelDoor** on floor with collision material **16** (the templates have one), +* floor with collision under the start. **Level → Check for problems** lists what is missing. + +## Editing an original level + +**File → Open original level** shows all chapters with their loading pictures. You can move, +add and delete objects, tiles, waypoints, zones and lights. The project stores only the +differences to the original, and building it with the same CD gives the same file every time. + +Tip: prefer moving or hiding original tiles over deleting them – some object scripts address +tiles by their number, and deleting shifts those numbers. + +## Objects, waypoints and zones + +* **Objects** (Assets → Objects) are the base level's object types: coins, collectibles, + checkpoints, enemies, villagers, moving platforms … Their parameters and flags are in the + Inspector. Only types that exist in the base level can be placed. +* **Waypoint chains** belong to objects (paths of enemies, camera paths). Select an object to + see its chain; duplicating an object copies its chain. +* **Zones** set fog, colours, music and respawn/teleport targets; **lights** colour the level; + **visibility regions** decide which parts are drawn from where (View → Layers shows them). + +## Importing 3D models + +**File → Import mesh** reads OBJ (with MTL and textures) and glTF/GLB. The model becomes a +*mesh brush*: it is cut into the engine's 1×1-cell tiles at build time, gets collision (choose +the material in the Inspector) and lit vertex colours. The engine has no free texture +coordinates – each triangle shows one half of a texture rectangle – so simple, rectangular UV +layouts look best. + +## Textures + +The **Textures** panel (bottom dock, Ctrl+Shift+T, or **Window** for a large separate window) +is the texture manager: + +![Texture manager](img/editor/tex_manager.jpg) + +* **Pages** (left): the level's 256×256 texture pages with their blend class. +* **Atlas** (middle): the selected page with every texture rectangle – white = used, red = + unused, cyan = animated, orange = selected. Drag the selected rectangle to move it, drag its + handles to resize it, mouse wheel zooms. +* **Table**: number, name, page, rectangle, blend class, how many faces/models use it, animation. + Filter by *used / unused / animated / imported*; double-click a texture to paint with it. +* **Texture** tab: name, position and size, blend class, usage, *Duplicate*, *Select users*. +* **Animation** tab: flip-books (a sequence of frame textures, frame delay, back and forth) and + vertical scrolling (rivers, lava), with a live preview. + +**Import…** adds PNG/JPG/BMP images: + +![Import dialog](img/editor/tex_import.jpg) + +| Option | | +|---|---| +| Size | longest side in pixels; the aspect ratio is kept | +| Blend class | **Opaque**, **Cut-out** (fully transparent pixels: leaves, fences), **Semi-transparent** (50 % blending: glass, water), **Additive glow** (light, fire), **Darken** | +| Transparent below alpha | pixels less opaque than this become invisible | +| Keep half-transparent pixels | for semi-transparent/additive textures | +| Transparent colour | magenta or the top-left pixel for images without alpha | +| Dither | smoother gradients in the game's 15-bit colour | +| Pack into free space | uses unused areas of existing pages of the same blend class before adding pages | + +The preview shows the image as the game will show it. **Replace pixels…** puts a new image into +an existing texture's rectangle (all faces using it change), **Export PNG…** saves one; the +page list's context menu exports or replaces whole pages. + +**Paint texture** tool (**M**): click a face of a tile to give it the current texture (the +*scope* box paints the face, all faces of that model with the same texture, or the whole +model). Clicking a brush sets its top/side/bottom texture by the side you click. +**Shift+click** or the **eyedropper** (**I**) picks the texture of a face. For the selected face: +*Rotate* turns the texture, *Mirror* flips it, *Other half* shows another half of the texture +rectangle. Tiles often share one model – tick **Unique** to give the clicked tile its own copy +first, otherwise every tile with that model changes. + +![Painting a texture](img/editor/tex_paint.jpg) + +## Play-testing + +**Play** (**F5**) builds the level, installs it as a mod and starts the game **directly in the +level** – no intro, no menus, no map. **Play from camera position** (**Shift+F5**) puts the llama +on the ground in the middle of the view, facing the way the camera looks (the editor checks that +there is walkable collision there – trees and roofs often have none). + +In the game, **Escape** quits straight back to the editor. When the game has ended, LlamaMod +switches your normal mod selection back on (the level itself stays installed as a mod). + +![The game started straight in the level](img/editor/play_boot.jpg) + +Options in the **Level** menu: *Play boots straight into the level* (off = the game starts at the +main menu with the map cursor on the level) and *Escape quits the play-test*. The save slot used +for play-testing (default 3, backed up first) is set in **Edit → Preferences**. + +## Sharing levels + +Projects are `.llamalevel` files (saved by default in LlamaMod's `levels` folder). They contain +your changes, imported meshes and textures and a thumbnail – **no game data** – and are usually +only a few kilobytes. To share a level: + +1. **File → Save** and send the `.llamalevel` file (forum, Discord, e-mail). +2. The receiver uses **Mods → Levels → Import level…** in LlamaMod (or copies the file into the + `levels` folder), ticks the level and clicks **Play**. LlamaMod builds it from their own CD. +3. New levels appear on the world map as **TestLevel – chapter n**; edited original levels replace + that chapter while the mod is on. + +If two level mods use the same TestLevel chapter, change the chapter in the mod's options. + +## Shortcuts + +| Keys | Action | +|---|---| +| Ctrl+N / Ctrl+O / Ctrl+S | new / open / save | +| Ctrl+Z / Ctrl+Y | undo / redo | +| Ctrl+D, Del | duplicate, delete | +| Q W E R | select, move, rotate, scale | +| T, I | paint texture, eyedropper | +| G, X, C, H / Alt+H, L | snapping, local space, collision view, hide / show all, lock | +| Ctrl+B, F5, Shift+F5 | build, play, play from camera position | +| Ctrl+Shift+T | texture manager | +| F1, Shift+F1 | shortcuts, getting started | + +## Troubleshooting + +| Problem | Solution | +|---|---| +| "OpenGL 3.3 is not available" | update the graphics driver; in a VM enable 3D acceleration | +| Play: "The game is still running" | quit the game first (level files cannot be replaced while it runs) | +| The llama falls through the floor (play from here) | point the view at walkable ground; check collision with **C** | +| Windows Defender warns about groove.exe | play-tests change groove.exe (boot to level); see LlamaMod's antivirus notice | +| A level does not load / the game crashes | **Level → Check for problems**; keep tiles inside the grid; one player start | diff --git a/notes/editor-manual.md b/notes/editor-manual.md new file mode 100644 index 0000000..abac18a --- /dev/null +++ b/notes/editor-manual.md @@ -0,0 +1,95 @@ +# Level editor – user manual and in-app help (`editor.tools.help`) + +The complete user manual (English, forum audience) lives as Markdown in **`docs/manual/`** (one +file per chapter + `img/`). It is bundled into the jar (`build.gradle` → `processResources` copies +`docs/manual` to `com/eliasmueller/llamamod/editor/tools/help/manual/`) and shown by +**Help → Manual (F1)**. + +![manual window](img/editor/manual_chapter.jpg) + +## Chapters + +`index.md` defines the order and the groups of the contents tree (H2 = group, list links = chapters): +Getting started (01 introduction & concepts, 02 installing, 03 editor window, 04 navigation, +05 selection), Tutorials (first level in 10 minutes, parkour, edit an original level, Blender model), +Building levels (06 geometry, 07 meshes & library, 08 textures, 09 objects incl. cross-world import, +10 waypoints, 11 cameras & triggers, 12 environment, 13 visibility, 14 level settings), Testing and +sharing (15 build/problems/play-test, 16 original levels, 17 sharing), Reference (18 mods, 19 +keyboard/mouse/menu, 20 troubleshooting & FAQ, 21 glossary). + +Markdown subset (own converter `Markdown`, no dependency): ATX headings (one H1 per file), +paragraphs, bold/italic/code, links, images on their own line, nested lists, GFM tables, +block quotes (callouts), fenced code, rules; no raw HTML. Anchors = GitHub slugs, so the files also +read well on GitHub/forums. + +**Generated blocks** `…` are filled from the live +program (`ManualContent`): `shortcuts` (tool keys, keyed commands, key conflicts), `menus` (every +menu command with key and tooltip), `panels` (dock panels) – from `EditorExtensions`, i.e. built-ins +and all plugins – and `mods` (built-in LlamaMod mods with options, from `ModRegistry`). The window +regenerates them at run time; `ManualTest` rewrites the copies in `docs/manual` when they are stale +(`-Dllamamod.manual.noUpdate=true` turns that off). Key names are locale-independent ("Ctrl+Shift+D"). + +## Manual window (`ManualWindow`) + +Own frame (stays open next to the editor, works while a modal dialog is open): toolbar back / +forward (Alt+Left/Right), contents (Alt+Home), previous / next chapter, search field (Ctrl+F), +Export menu; left: **Contents** tree (groups → chapters → H2 sections, follows navigation) and +**Search** (all words must match; ranked by title hits; excerpt; the words are highlighted on the +page); right: the page (Swing HTML with CSS from the current FlatLaf colours, images scaled to the +page width and loaded synchronously so anchors land correctly, previous/next links at the end). +Internal links navigate, external ones open the browser. Re-rendered on theme change (light/dark). +`-Dllamamod.manual.dir=docs/manual` reads the Markdown from a folder (reloaded on every open) for +writing the manual. + +**Export** (`ManualExport`): HTML folder (index.html + one page per chapter with a sidebar, +manual.css with light/dark `prefers-color-scheme`, used images), **one self-contained HTML file** +(all chapters, images as data URIs, links rewritten to in-page anchors – for forum attachments), +and *Print this chapter* (system print dialog → PDF). Also **Help → Export manual as HTML…**. + +## Context help (`ContextHelp`, `context-help.txt`) + +F1 opens the chapter for what the **mouse pointer** is over (else the focus owner), in the main +window and in every dialog/window owned by it. Topics, collected walking up the component tree: +client property `HelpTopics.PROP` (inspector sections `inspector.x:` / `inspector.g:<group>`, +tool options bar `tool.<id>`), menu items / toolbar buttons (`action.<id>` via the action's +"spec"), tool buttons (`tool.<id>` by tooltip), inner tabs (`tab.<title>`), dock tabs +(`panel.<id>`), the selected node kind inside Inspector/Outliner (`node.<KIND>`), the viewport +(`tool.<active tool>`), the window title (`dialog.<title>`), `window.editor`. Mapping file +`src/main/resources/.../tools/help/context-help.txt` (`topic = file.md#anchor`, `prefix*` keys are +wildcards; exact matches of any candidate beat wildcards). The Help → Manual menu item opens the +manual where it was (F1 key = context). + +"?" buttons: every Inspector section header, the tool options bar (next to the tool name), and the +right end of every dock's tab row (FlatLaf trailing component, help for the visible panel). + +Shell changes (minimal, backwards compatible): new `editor/ui/HelpTopics` (handler hook + "?" +button helper; without a handler nothing is shown), `InspectorPanel.section` (topic property + +"?"), `ToolOptionsBar` (topic property + "?"), `build.gradle` (processResources). `help.shortcuts` +moved from F1 to Ctrl+F1 (re-registered by the help plugin). + +## Tests / verification + +* `ManualTest`: every .md is in the contents; exactly one H1 per file; **all internal links and + anchors resolve**, all images exist, no placeholder images; every `context-help.txt` target + resolves; generated references contain every tool / every built-in mod; search; HTML folder and + single-file export; Markdown subset unit checks. Unmapped tools/panels/sections are reported on + stdout (not a failure, so new plugins do not break the build). +* `ManualTour` (test classes, X display): screenshots for the manual and an end-to-end context-help + run with java.awt.Robot (real mouse/F1 events) on Xvfb :17. Results: `?` on the Inspector's + "Object parameters" → 09-objects#object-parameters; F1 over the Textures panel → + 08-textures#texture-manager; F1 over the viewport with the Brush tool → 06-geometry#brush-tool-b; + F1 over the "Waypoint chain" section → 10-waypoints#waypoint-chain-section; F1 in the modal + Preferences dialog → 03-editor-window#preferences. + Images: `img/editor/manual_*.jpg`. + +## Maintaining the manual + +* Edit `docs/manual/*.md`; run `./gradlew test --tests '*ManualTest*'` (link check + regenerates the + generated blocks). New chapter: add it to `index.md`. +* New tool/panel/section/dialog: add a line to `context-help.txt` (the test prints what is unmapped). +* Screenshots: `ManualTour` steps `geo,nav,village,manual,context,dark`, `RetakeTour` steps + `library,insert` (open a `GeoLibDemo` level) and `texpaint` (village), and the other tours + (`EditorTour`, `ObjectsTour`, `EnvTour`); keep images ≤ 1000 px wide (jar size). +* `SmokeTour` (same command line, GeoLibDemo level): real-input checks of the death plane height + field (typed value without Enter + OK), Ctrl-inverted snapping of the place tool and the + Inspector's "Exclude from build"; prints PASS/FAIL. diff --git a/notes/editor-object-schemas.md b/notes/editor-object-schemas.md new file mode 100644 index 0000000..ed137f9 --- /dev/null +++ b/notes/editor-object-schemas.md @@ -0,0 +1,85 @@ +# Object parameter schemas (strat bytecode analysis) + +Registry in code: `editor.tools.objects.Schemas` (named, typed, documented parameters with ranges, +choices and waypoint meaning), statistics of all original instances: +`src/main/resources/.../editor/tools/objects/type-stats.tsv` (`TypeStats`). Types without a schema +fall back to raw values plus "values used in the original levels". Shown in the Inspector section +"Object parameters" and checked by the Problems panel (`ObjectChecks`). + +## How parameters reach the script + +* MAP strat record: count n + index into the parameter pool. The level spawner FUN_0054cfc0 passes + a fake stack, FUN_0054bfc0 pops the n values into the new strat's **variables 0..n−1** + (`strat+0xF4` = variable array). So *parameter i = variable i* of the strat code. +* Values are 20.12 fixed point by convention (0x1000 = 1.0); booleans are 0 / 0x1000, counts and + world/chapter numbers `n << 12`. + +## Strat VM (FUN_0054d180) – enough to disassemble + +Word-coded: `op = w & 0xFFFF`. `op < 0x45`: table 0x5796BC, operand = signed high 16 bits, one +word. `op ≥ 0x45`: table 0x578FF4 + op·4 (up to 0x1F6), inline operand words read via the pc. +Inline lengths (found by decompiling all 337 handlers with headless Ghidra and following helper +calls): 1 word for 0x45 0xA5 0xA6 0xB1 0xB2 0xD0 0xD3 0xD7 0xDB 0x115 0x11C 0x14B 0x14D 0x162 +0x167 0x175; 2 for 0xD4 (name: `d4 ea rel`); 5 for 0x94 and 0xE0 (spawn: FUN_00553170 reads +nParams + 4 type fields); 6 for 0x95 (spawn at offset); short 0x0C (event slot) has 2; 0x02/0x03 +(debug print: count then items, 0x45/0xEA items have one extra word) are variable. A linear sweep +restarted at every `d4` name site disassembles all 31 level WADs without desync and hits every MAP +strat entry (the entry is the name site). Strings live between strats. + +| short op | meaning | short op | meaning | +|---|---|---|---| +| 04 n / 41 n | push / pop variable n | 06 n / 43 n | push / pop global n (0x6D9E8C) | +| 07 n / 40 n | push / pop own property n (FUN_00550E60 / FUN_005509F0) | 1C n / 37 n | player property | +| 44 v | push 16-bit constant | 14 / 15 / 16 | jz / jnz / jmp (pc+4+v·4) | +| 12 / 13 / 2B | jz / jnz / jmp + yield | 17 | switch (table at pc+4+v·4) | + +Long ops used for the decoding: 45 push word, 49 add, 4A sub, 4B mul (fixed), 4C div, 4E eq, +4F lt, 53 gt, BC ne, BD/BE ge/le, AF not, B6 and-logical, B7/B8 bit or/and, 50/51 shl/shr, +AD/AE sin/cos (table 0x574318), AC floor, BF rand, 16E lerp, 54 set model, 8B radius (+0xFE), +83 +0xD2 (collision radius), A5/A6 set/clear strat flags (0x8000 floor collision, 0x40000 walls), +C2 update floor, 73/75/177 waypoint first/next/advance (+0x118 first, +0x120 current; next wraps +to the first node), FE move to waypoint, 178 "inside the box of the current and next waypoint +(x/z)", 192 level change (pops tribe, world, chapter), B4/8E die, E0/95 spawn. +Properties: 16 floor height, 17 floor material, 34–36 x/y/z, 37–39 rx/ry/rz, 3 waypoint +first/last bits, 4 waypoint field +26, 6–8 waypoint x/y/z, 9/10 waypoint rx/ry, 41 distance to +the player, 61–64 tribe/world/chapter/map, 202 distance to player (squared compare). +Camera globals: 47 horizontal distance, 48 height above the target, 29/44 yaw, 36–38 position +and 40–42 look-at of cut-scene cameras, 66 camera flag word, 95 progress bits. +Patterns: `push var i; push 30; mul` = seconds → frames; `push var; push var; mul; pop var` = +squared distance (radius). + +## Decoded types + +Confidence: **high** = code read and behaviour seen in game; **medium** = code read; **low** = +values only. + +| Type | Parameters | Waypoints | +|---|---|---| +| Coin (high) | p0 snap to floor (drops onto floor, random spin), p1 falls (floor collision flag 0x8000), p2 no shadow | – | +| Coin5 (high) | p0 snap, p1 falls | – | +| Health | p0 snap, p1 no shadow, p2 falls | – | +| HealthFull, HealthGenerator, Wompy | p0 snap, p1 falls | – | +| Grapes, HealthBanana, ChargeBooster | p0 snap | – | +| RubyIdol | p0 falls, p1 snap | – | +| Secret (high) | p0 box mode (player inside the box of the 2 nodes, op 0x178) | box (2 nodes) | +| CoinLine / CoinCircle (low) | count (counted down), spacing / radius | – | +| Llama | p0 spawns a helper, p1 unused, p2 camera mode (global 107) | first node = camera start | +| CheckPointPost (high) | p0 collision radius (+0xD2, 0.2) | 1 node = respawn position + yaw (98 of 102) | +| LevelDoor (high) | tribe, world, chapter of the next level (7,7,7 = world finished); fires on material-16 floor of a tile in its cell (any height; often only in the swap model a RubyIdolDoor/other door object switches to when it opens) | – | +| CameraSide | p0 height (g48), p1 distance (g47), p2 yaw mode (1 node yaw, 2 node yaw or ±180°), p3 only below node y, p4 flag 0x10000, p10 several boxes, p11 height offset, p12/p13 alt. height/distance | box pairs, node 0 yaw = camera yaw | +| CameraLift | p0 yaw source (1 object, 2 player), p1 box test on helper strat, p3 smooth (g97), p4 height limit, p5 flag | box | +| CameraSetDistance | g82, g68, g83, height g48, g73 (each with a flag bit in g66) | – | +| CameraFocusPoint | radius², min height², max angle, height limit, 3 flags, blend factor | – | +| CutSceneCameraDirectional | p0 event bit (1..31), p1 mode, p2 wait, p3 disabled, p5 end mode | camera nodes: position, yaw/pitch = view (globals 36–42) | +| CutSceneTrigger | p0 character (floor snap, g72 counter), p2 radius², p3 event bit (skip if set in g95), p5 height check, p6 bits (1 face player, 2 not skippable) | camera path | +| ForcePlayerMovement | p0 active, p1 radius², p2 game state, p3 lock, p4 align yaw | target points | +| ObjectDontDropInBox, FallOffNoHurt | box mode (+ game state) | box | +| WaterReboundPoints | active | rebound points | +| RubyIdolDoor | p1 idols needed, p4 sign offset | – | +| CityGuard | p0 view range², p1 chase range², p2 event bit, p3 wall collision | patrol (wraps) | +| JumpingBeetleBlue, Spider, Bunny | times in seconds (×30), ranges² | patrol path | + +The rest (~150 placed types) shows raw values with the original statistics. Next candidates: +PressurePadDoor (bit field), TargetRopePlatform, CataSwingingBlade (speeds/angles), RiverLog. +Tooling used (not in the repo): Ghidra script decompiling the handler table, a Python sweep +disassembler with an op-name table, "parameter use" context dumps per type. diff --git a/notes/editor-playtest.md b/notes/editor-playtest.md new file mode 100644 index 0000000..39c3643 --- /dev/null +++ b/notes/editor-playtest.md @@ -0,0 +1,108 @@ +# Level editor – play-test straight into the level (`editor.tools.playtest`, `mods.boottolevel`) + +Before: Play installed the level and started the game at its main menu; the player had to +choose *Spiel starten* → slot 3 → Enter on the map. Now **Play (F5)** lands in the level a few +seconds after the game window opens, and **Escape** quits back to the editor. + +![game booted into TestLevel chapter 2](img/editor/play_boot.jpg) + +## Editor side (`PlaytestEditorPlugin`, `PlaytestRunner`, `FloorFinder`) + +| Action | Key | | +|---|---|---| +| Play (replaces the built-in `level.play`) | F5 | build → install level mod → apply user mods + boot mod → save slot → start | +| Play from camera position | Shift+F5 (toolbar) | the llama starts on the ground in the middle of the view, facing the camera direction | +| Play boots straight into the level | Level menu (check) | off = old behaviour (main menu, map cursor) | +| Escape quits the play-test | Level menu (check) | boot mod option `quitOnEscape` | + +* Run: `PlayTest.build` (built-in, background) → the project snapshot is written to + `<data>/levels/<stem>.llamalevel` → the user's **active** mods (others on the same WAD and Test + World on chapter 1 turned off) + the level mod become `enabledMods`; `boot-to-level` is applied + **in addition** (not stored in `enabledMods`) with world/chapter of the project, slot + (Preferences, default 3), `logos=true`, `quitOnEscape`, `start` → save slot unlocked up to the + level → `GameLauncher.launch`. +* **Restore**: a daemon thread polls `GameLauncher.isRunning()` every 2 s; when the game has + ended it re-applies the user's selection without the boot mod (cached outputs, < 1 s) and + restores the boot mod's own options → LlamaMod's normal Play starts at the menu again + (`RestoreCheck`: active `[level-…, boot-to-level]` → `[level-…]`, exe without mod patches). + If the editor is closed before the game, LlamaMod shows the selection as "apply needed". +* **Play from camera position**: ground point = depth-buffer hit at the viewport centre + (export space → game x, y, −z), heading = camera yaw (`atan2(f.x, −f.z)`, 0 = +z). + `FloorFinder` then looks for **collision**: highest floor polygon (|n.y| ≥ 0.5) at or below + the hit + 0.6 among scene tiles and brush-generated tiles, searching up to 0.75 cells around + (rings of 8); material 30 (death floor) is rejected. Needed because render geometry often has + no collision (village: the hill edge east of the start renders, but only a death floor at + y 2.25 lies below – the llama fell to a game over). +* `-Dllamamod.editor.noLaunch=true` skips the start (tests start the game with apptest). + +## Exe mod `boot-to-level` (GAMEPLAY, `modifiesExecutable`) + +Options: `world` 1–9, `chapter` 1–49, `slot` 1–3, `logos` (skip logo movies/title, same patch +as intro-skip – only applied if the bytes are still original, so both mods can be on), +`quitOnEscape`, `start` ("x, y, z[, yaw]" in cells; empty = the level's own start). Order 90 +(after level mods). Registered through `META-INF/services/com.eliasmueller.llamamod.mods.Mod` +(ServiceLoader; `ModRegistry` was not edited). + +### Engine facts used + +| | | +|---|---| +| 0x5846E4 | game state: 1 ?, 2 main menu, 3 in game, 4 attract demo, 5 options?, 6 save slots, 12 world map, 0x11 quit | +| 0x578F28 | frame handler per state (returns 1 = state changed → main loop runs the enter handler) | +| 0x578F7C | enter handler per state; state 3 = FUN_0054ae20: intro movie, loading screen `T1%d%d.COR`, FUN_00419510(tribe, world, chapter, 0) loads the level | +| 0x579050 | per-state update/render (3 = FUN_0054b590, the game frame) | +| 0x584EB4/B8/BC/C0 | tribe, world, chapter, map of the level to load; 0x585588/0x584C24/0x584C20/0x58557C = map cursor | +| 0x584700 | next state for fade transitions; 0x584638 = sub state (7 = enter level) | +| FUN_0041c0e0 | reads `king0..2.ksv` into 0x585274 + slot·0x100 | +| FUN_0041cc70 | loads slot 0x584C28 into the game variables (new game in it if empty) | +| 0x546CC0, 0x546990([0x6D94B4]), 0x5464D0 | music/sound shutdown done before every state switch (copied from the demo switch at 0x549E5D) | +| 0x5FCF08 bit 0x8 | "pause" pressed this frame (Escape) | +| main loop (WinMain) | exits through its normal cleanup (FUN_00545030, DestroyWindow, CoUninitialize) when the state is 0x11 – the main menu's *Beenden* | + +### Patches + +* **Boot**: `jmp` at 0x549C50 (state-2 frame handler, `A1 2C 37 58 00` = `mov eax,[0x58372c]`). + The cave runs once (flag in an `allocateData` block): pushad, read slots, `[0x584C28]=slot−1`, + load slot, set tribe 1/world/chapter/map 0 (+ map cursor), `[0x5855B0]=0`, sub state 7, music + shutdown, state 3, popad, `mov eax,1; ret` → the main loop enters state 3 exactly like the + map's Enter would. Later main-menu frames (after quitting to the menu) run the original code. +* **Escape quits** (`quitOnEscape`): `jmp` at 0x549F40 (state-3 frame handler, same 5 bytes): + `test byte [0x5FCF08],8; jz original; mov [0x5846E4],0x11; mov eax,1; ret`. +* **Logos**: 0x41D56E `68 28 25` → `53 EB 62` (see mod-introskip.md). +* **Start override** (data, not exe): the level's WAD (as written by earlier mods) is imported, + the Llama moved/turned and rebuilt (`StartOverride`; an unchanged rebuild is byte-identical). + Its waypoint chain moves and turns along only if it is an editor/levelbuild camera chain + (all nodes but the first on the llama); original levels use the llama's chain as a + level-wide list, which stays. + +## Verified in game (apptest, display :9, Wine 6, 2026-11-06) + +| Test | Result | +|---|---| +| `boot-to-level` world 1 chapter 2, original data | Village 2 fly-by 5 s after the window, gameplay at ~20 s, walking works | +| Editor Play on `examples/testwelt.llamalevel` (TestLevel 2) | game shows the Testwelt plaza with HUD 4 s after the window opens (`play_boot.jpg`) | +| Editor Play from camera position, Village 1, start 2.3 cells east | llama stands at the new spot (`play_from_here.jpg`); the original village fly-by is skipped (its cut-scene camera is tied to the original start) | +| Same with the view on the hill edge (no collision) | before FloorFinder: llama fell to a game over; now the editor finds the nearest floor or refuses | +| Escape in gameplay (village and TestLevel) | groove.exe gone after 1.3 s, no crash, no Wine error dialog, the next start works | +| Restore after the game | active mods back to the user's selection, exe has no mod patches | + +Images: `img/editor/play_boot.jpg`, `play_from_here.jpg`, `play_editor.jpg`, +`play_windows_editor.jpg`. + +## Windows package under Wine + +`./gradlew packageWindows` → `LlamaMod-windows-x64.zip` (45 MB). Unpacked into an own win64 +prefix (Wine 6.0.3), started as `JDK_JAVA_OPTIONS="-XX:ActiveProcessorCount=4 +-Dllamamod.data=Z:\…\data" wine runtime/bin/javaw.exe -jar LlamaMod.exe --editor t1l1m001` +(the launch4j exe itself hangs in its `javaw -version` check under Wine 6, see llamamod-app.md): +the editor window opens (within about two minutes on the first start, software GL) and renders the village with all phase-3 plugins (Play from +camera position, Textures tab, paint tool) – `img/editor/play_windows_editor.jpg`. The game was +not started from the Windows build (the private Wine prefix has no game install); real Windows +hardware is untested. + +## Tests + +`BootToLevelModTest` (hooks at both frame handlers, logo patch, exe re-parses; Escape hook only +when asked; start parsing; original village: only the llama moves, file size unchanged; +editor-style chain moves and turns with the llama), `RestoreCheck` (manual), `EdTour` + +`PlayTourSteps` (scripted editor runs on an X display: camera placement, play-from-here). diff --git a/notes/editor-render.md b/notes/editor-render.md new file mode 100644 index 0000000..0a329ea --- /dev/null +++ b/notes/editor-render.md @@ -0,0 +1,292 @@ +# Level editor – render package (`editor.render`) + +Phase 1 of the level editor (notes/editor-design.md): OpenGL viewport, level rendering, overlays, +camera, picking, transform gizmo. Package `com.eliasmueller.llamamod.editor.render`; it has no +dependency on `editor.model`/`editor.io` – the UI adapts the scene model to `RenderScene`. + +![montage](img/editor/00_montage.jpg) + +## Build / platform + +* Gradle: LWJGL **3.4.2** (`lwjgl`, `lwjgl-opengl`, `lwjgl-jawt`, `lwjgl-egl`) + **lwjgl3-awt 0.2.5** + (`AWTGLCanvas`, a heavyweight AWT canvas inside Swing; `transitive = false`, its POM pulls + Vulkan/macOS natives) + **JOML 1.10.8** (math). Natives `natives-linux` and `natives-windows` + of `lwjgl` and `lwjgl-opengl` are runtime dependencies, so the fat jar (and with it both packages) + carries `linux/x64/…/*.so` and `windows/x64/…/*.dll`; LWJGL extracts them to a temp dir at runtime. + Fat jar 9.7 MB. +* Fat jar manifest gets `Multi-Release: true` (LWJGL is a multi-release jar), `module-info.class` + files are excluded. jlink modules + `jdk.unsupported` (LWJGL/JOML use `sun.misc.Unsafe`). + `packageLinux`/`packageWindows` otherwise unchanged. +* **OpenGL 3.3 core profile, no 2.1 fallback** (decision): shaders are GLSL 330, texture pages are + one `GL_TEXTURE_2D_ARRAY`, VAOs/VBOs. 3.3 is supported by every GPU/driver since ~2010 (Intel HD + 2000+, Mesa llvmpipe gives 4.5); a 2.1 path would double the renderer for hardware that cannot run + the rest of the toolchain comfortably anyway. The canvas first asks for 4× MSAA and retries + without multisampling; if no 3.3 context can be created the viewport shows a message instead + of crashing (`Viewport.glError()`). No swap interval is requested (Xvfb/GLX has no swap control, + lwjgl3-awt then fails context creation). +* Tested: + * Linux native (Xvfb + llvmpipe, `DISPLAY=:8`): fat jar and the jlinked `LlamaMod-linux-x64` + runtime – rendering, picking, gizmo drags, fly mode via xdotool. + * **Windows package under Wine 6.0.3** (own win64 prefix, `JDK_JAVA_OPTIONS=-XX:ActiveProcessorCount=4`, + `runtime\bin\java.exe -cp LlamaMod.exe …ViewerDemo`): Wine's WGL gives a 3.3 core context on the host + llvmpipe; the screenshot is **pixel-identical** to the Linux one. `LlamaMod.exe --mods` still works. + Real Windows hardware not tested. +* `-Dllamamod.editor.msaa=0` disables multisampling (2–3× faster on software GL). + +## Demo + +``` +java -cp build/libs/llamamod-*-all.jar com.eliasmueller.llamamod.editor.render.ViewerDemo \ + <level.wad | testworld> [--cd DIR] [--size 1280x720] [--collision] [--no-markers] + [--hide waypoints,zones] [--view top|front|side] [--cam x,y,z,yawDeg,pitchDeg] + [--select o12,t5,w3] [--frame] [--gizmo translate|rotate|scale] [--shot out.png] [--bench] [--list] +``` +`testworld` builds the Testwelt (TestWorldMod) from the CD's village WAD. The window has layer +check boxes, view and gizmo mode selectors and a status bar (hover/pick info). `--list` prints +all strats with ids and positions. `--bench` orbits 120 frames and prints timings. + +## Controls (Viewport) + +Navigation presets (`Navigation.Preset`, View › Navigation, Preferences › Viewport & navigation, +Help › Viewport navigation; setting `viewnav.preset`, default **Fusion 360**): + +| Input | Fusion 360 (default) | Blender | Unity / Unreal | +|---|---|---|---| +| middle drag | pan | orbit | pan | +| Shift+middle drag | orbit | pan | pan | +| Ctrl+middle drag | zoom | zoom | zoom | +| double-click middle | fit all | – | – | +| Alt+left drag | orbit | orbit | orbit | +| right drag | look around + **W/A/S/D** move, **Q/E** down/up, **Shift** ×4, wheel = fly speed (all presets) ||| +| Alt+right drag | zoom (all presets) ||| +| wheel | smooth exponential zoom towards the cursor (all presets) ||| + +| Input | Action | +|---|---| +| view cube (top right) | click a face / edge / corner = look from there (animated, pivot and projection kept), drag = orbit, house = home (iso view from front-right-top + frame all) | +| F / Home | frame selection / frame all tiles (smooth 220 ms transition) | +| numpad 7 / 1 / 3 / 5 | top / front / side (orthographic) / toggle perspective | +| W / E / R, Q (when not flying) | gizmo translate / rotate / scale, toggle world/local | +| left click | pick (listener); left drag on a gizmo handle = transform; Ctrl = invert snapping; Esc = cancel drag | + +* **Zoom** (2026-11-07): constant percentage per wheel notch (`zoomFactor`, default 12 %, 2–40 %, + "invert zoom"), fractional touchpad deltas scale smoothly, animated 120 ms (ease-out; further + notches accumulate on the target). Anchor (`anchorDistance`): the depth-buffer hit under the + cursor if it lies in front of the near plane and closer than 3× the pivot depth (and the far + plane), else the current pivot depth along the cursor ray – pointing at the sky or a far cliff + no longer makes a huge step, a near wall no longer stalls. The anchor becomes the new pivot depth. + Old behaviour: a fixed `0.15 × distance` dolly step along the ray, independent of what is under + the cursor (sky/near ground felt extremely fast/slow). +* **Orbit**: pivot = surface point under the cursor at drag start (fallback: selection centre, + then view centre), shown as a small RGB cross; the camera keeps its camera-space offset to the + pivot, so the pivot stays under the cursor; constant angular speed (default 0.35 °/px), pitch + clamped to ±89°. `Camera.viewMatrix` now uses the mirrored yaw direction as up vector when looking + straight **up**, so pitching across the vertical threshold no longer flips the picture. +* **Pan** moves the grabbed point (its view depth) exactly with the cursor (sky: pivot depth). +* Viewport API: `setNavigation/zoomAt/setZoomSpeed/setInvertZoom/setOrbitSpeed/setViewCubeVisible/ + home/snapView`; math in `Navigation` and `ViewCube` (pure, `NavigationTest`, `ViewCubeTest`). + Plugin `tools.viewnav.ViewNavEditorPlugin` (menus, preferences dialog, cheat sheet; the status + bar shows a one-line summary on open and when the preset changes). + +Orbiting a standard ortho view switches back to perspective. Hover picking runs at most every +40 ms while the mouse moves (`setHoverPicking(false)` turns it off). + +![view cube, preferences](img/editor/cull_nav_prefs.jpg) + +## API + +```java +RenderScene scene = LevelSceneAdapter.fromWad(wadBytes).scene(); // or built by the editor UI +Viewport vp = new Viewport(); // JPanel; add to any Swing layout +vp.setScene(scene); +vp.setGrid(0, new float[] {0, -H, W, 0}); // grid plane y and bounds (x0, z0, x1, z1) +vp.setLayerVisible(LevelSceneAdapter.LAYER_COLLISION, true); +vp.setSelection(List.of(PickId.of(PickId.OBJECT, 12))); // highlight (+ unbatched tiles) +vp.gizmo().setVisible(true); vp.gizmo().setTarget(matrix); vp.gizmo().setMode(Gizmo.Mode.ROTATE); +vp.gizmo().setSnap(new Gizmo.Snap(true, 0.25f, 15f, 0.1f)); // grid snapping hook +vp.addViewportListener(new ViewportListener() { + public void picked(PickEvent e) { … e.id(), e.world(), e.modifiers(), e.clickCount() … } + public void hovered(int id) { … } + public void gizmo(GizmoEvent e) { … } // START / DRAG / END / CANCEL + public void cameraChanged(Camera c) { … } +}); +vp.frameAll(); vp.frameSelection(); vp.setView(Camera.View.TOP); +vp.pick(x, y); vp.surfacePoint(x, y); // synchronous id buffer / depth reads +vp.screenshot(); vp.stats(); vp.glInfo(); vp.requestRender(); vp.dispose(); +``` + +### Scene data (render-facing interface) + +* `RenderScene`: `pages` (`TexturePage(w, h, int[] 0xRRGGBBAA)`), `instances`, `markers`, + `overlays` (world-space `Overlay` per layer name), `background` (0xRRGGBB, also fog colour). + After edits: `changed(instance)` (rebuilds only that instance's chunk), `changed()` (everything, + markers/overlays), `texturesChanged()`. Modify on the Swing thread. +* `RenderMesh`: triangle mesh in local space – per vertex positions/normals, per triangle + corner UVs (normalised to the page), per triangle page (-1 = untextured), colour (multiplied, + flat colours / translucency), flags (`DOUBLE_SIDED`, `NO_VERTEX_COLOR`, `LIT` = shade with + normals) and blend class (`BLEND_OPAQUE` = alpha-tested cut-out, `BLEND_ALPHA`, `BLEND_ADD`). + Optional `overlay` = local-space helper geometry that follows every instance (collision). +* `RenderInstance(id, kind, mesh)`: `transform` (JOML `Matrix4f`), `vertexColors` (game + convention 0x80 = 1.0, doubled with clamp), `tint`, `visible`, `layer`. Kind `TILE` instances are + baked into static batches; `OBJECT`/`PREVIEW` are drawn individually with their matrix. +* `Marker`: pickable icon (cube/diamond/flat arrow, pole, direction tick, colour, layer, label). +* `Overlay`: coloured lines + translucent triangles (`box`, `line`, `triangle`). +* Pick ids are plain ints, 0 = nothing; `PickId` is the convention used by the adapter + (kind << 24 | index: TILE, OBJECT, WAYPOINT, ZONE, LIGHT, USER…). +* `Legend`: collision colours by material/slope and marker category colours, same as + notes/debugviz.md (`Legend.category(stratName)`). + +### Gizmo + +`Gizmo` is pure math (unit-tested in `GizmoTest`): modes TRANSLATE (3 axes, 3 planes, centre = +screen plane), ROTATE (3 rings, back halves faded, edge-on rings fall back to screen angle), SCALE +(3 axes in local space, centre = uniform). World or local space (scale always local). Handles +keep a constant screen size (`sizePx`, default 95). Hit testing in screen space (9 px). +`GizmoEvent` carries the **total** delta since drag start (translation, axis+angle about the start +pivot, scale factors) plus the start matrix: `e.result()` = new target transform, `e.applyTo(m)` for +other selected objects. The UI shows a live preview on DRAG, emits one undoable command on END +and restores `e.start()` on CANCEL; it calls `gizmo().setTarget()` so the handles follow +(axes stay as at drag start). Snapping: `Snap(enabled, translate step, rotate degrees, scale step)`, +Ctrl inverts `enabled` while dragging. + +## Coordinate conventions (handedness fix, 2026-11-06) + +* **The view is left-handed like the game's Direct3D renderer.** The engine draws export-space data + (tile floats, TRAK/STPC vertices, strat (x, y, −z)) with a D3D camera, so a plain OpenGL view of + the same coordinates was the game's picture mirrored left-right (owner report: "levels mirrored"). + `Camera.viewMatrix()` = `scale(−1, 1, 1) · lookAt(…)`; the renderer culls with `glFrontFace(GL_CW)`; + data, picking (`ray`/`project` use the matrices) and the scene model are unchanged. +* `Camera.yaw/pitch` equal the game camera angles (0x5790a4/0x5790a0, 4096 = 2π): yaw 0 looks along + game +z (export −z), yaw 90° along +x, **positive yaw turns left**. `forward()` = (sin y·cos p, + sin p, −cos y·cos p) (export), `right()` = screen right = (−cos y, 0, −sin y) (game: (−cos y, sin y), + as found by the freecam mod), `up()` = forward × right. Fly look/orbit: dragging right decreases yaw. + `ViewerDemo --cam x,y,−gz,yawDeg,pitchDeg [--fov vDeg]` reproduces a game camera. +* Rotate gizmo: the edge-on fallback uses the screen angle with the mirrored sign. +* Standard views: **top** = x to the right, game z (grid rows) downwards (cell (0,0) top left, as the + MAP/region grid is laid out; yaw π); **front** looks along game −z (x right); **side** looks along −x. +* Spaces: render/export space = (x, y, −z_game); model and inspector values (strats, waypoints, + zones, lights, collision) are game space, tiles store export floats. LGHT point lights/directions + are stored in game space (engine negates z on load). Strat yaw: `rotateY(−ry·2π/4096)` in export + space with model front = local −z → yaw 0 faces game +z, 1024 faces +x (verified: llama ry 3061 and + Pacha at his runtime yaw 830 face the same way as in game). Tiles: `rotateY(−rot)`. +* Evidence: in-game screenshots (apptest + freecam, camera read with winedbg from 0x5790b0 and the + look-at target 0x6d9d80) vs. ViewerDemo from the same camera: `img/editor/fix_compare_start.jpg` + (game / before / after), `fix_compare_back.jpg`, `fix_compare_yaw.jpg`, `fix_compare_top.jpg`. + The game's horizontal FOV matches 60° vertical at 16:9; its vertical FOV is ~15 % smaller + (non-uniform projection, not reproduced). Regression test: `CameraHandednessTest` (projected + pixel x of the llama and Pacha against the game screenshots, axis conventions). + +## Rendering details + +* **Batching**: tile instances are pre-transformed into 8×8-cell chunks; each chunk is one VBO + (36-byte vertices: position, normal, UV, colour, pick id, page layer + flags) sorted into 6 + ranges (blend class × double-sided) plus a line VBO (collision outlines/fills). Frustum culling + per chunk AABB / per object (`Culling`, below). All pages live in one texture array (+1 white + layer for flat colours), so a chunk needs at most 6 draw calls. Objects use one VBO per mesh. + Selected tiles are taken out of their chunk and drawn alone, so dragging them never rebuilds a + chunk; `changed(instance)` on a tile rebuilds its chunk (~20k vertices, a few ms). +* Passes: opaque (alpha test, back-face culling except double-sided faces), **back sides** (below), + overlay lines, + translucent (alpha, then additive; depth write off, not sorted), overlay fills, selection + (orange tint + hidden-line ghost + wireframe; hover = pale wireframe), gizmo (depth cleared). + Optional distance fog (40–160 cells) in the level's backdrop colour. +* **Picking**: id-buffer pass into an offscreen RGBA8/depth FBO (scissored to a 9×9 window, the + nearest non-zero pixel wins so small markers are easy to hit), depth → world hit point. + Cut-out texels are not pickable (click through leaves). +* Textures: TEXT pages ABGR1555 → RGBA, 0x0000 transparent, STP=0 = half alpha on blend pages + (page class 4/5/7); transparent texels are dilated (no dark fringes), 3 mip levels. + Blend class per face: texture entry bits 12–14 (1/3 → alpha, 2/4 → additive) or, for mode 0, + page class (4/7 alpha, 5 additive) – an approximation of the engine's table at 0x573eb8; flat + colour entries (bit 0) use u0/u1/v0 as RGB, alpha in v1 when a blend mode is set. + +## Culling and "disappearing objects" (2026-11-07) + +Owner report: objects, especially large ones, disappear at the screen edge. + +* **Root cause: not the frustum culling** but the Environment plugin's *fog preview* (on by + default): it sets a `FogOverride` with `clip` + `aroundPivot`, and the mesh shader **discarded + every fragment farther than the fog end from the orbit pivot** (radial). The pivot is the view + centre, so geometry towards the screen edges was cut away, and it popped in/out while panning. + Evidence: `img/editor/cull_root_fogclip.jpg` (old build, same camera: fog preview on / off – the + left islands are missing with it on). Fix (render package only): with `aroundPivot` the shader + draws a desaturated **ghost** beyond the radius instead of discarding (`uClipGhost`); the eye-based + far clip is unchanged. +* Frustum culling itself was correct (JOML plane extraction is orientation-independent; the mirrored + view only swaps the left/right planes). Hardened anyway: `Culling` boxes include every vertex + **and** the instance's overlay (collision lines/fills – a collision-only chunk had an empty box and + was always culled), objects use their local box transformed with the full instance matrix + (rotation/scale, 8 corners), skinned characters are culled with their posed vertices, + a small + absolute/relative margin. `-Dllamamod.editor.noCull=true` disables culling (diagnostics). +* Tests: `CullingTest` – random perspective/ortho cameras (any yaw/pitch, fov 30–100°, aspect), + random rotated/scaled meshes + every village instance: whenever any vertex projects inside the + clip volume the box must be visible (no false culls). Pixel check: ViewerDemo village, city + (t1l6m003) and Testwelt from inside the level at 8 yaws each, culling on vs `noCull`: **0 + differing pixels** in all 24 pairs (`img/editor/cull_overview.jpg`). + +### Back sides of one-sided faces + +The game culls faces without flag 0x400 from behind; the editor now shows those back sides +(View › Show back faces, default on; opacity in Viewport preferences, default 30 %): pass 3 after +the opaque pass draws the single-sided opaque/cut-out ranges again with `glCullFace(GL_FRONT)`, +depth-tested without depth writes, blended, darkened + desaturated with a fine screen-space +diagonal hatch. Front faces look exactly as before (the pass only fills pixels whose depth is +not covered). Translucent one-sided faces (island skirts) get no back side (it showed through them +everywhere). Picking hits both sides; the shader sets bit 31 of the pick id for a back side +(`PickHit.backFace`, `Viewport.lastPickBackFace()/hoverBackFace()`), the status bar appends +"(back side)" to the hovered item. `img/editor/cull_backfaces.jpg` (one-sided cliff walls seen from +behind: game look / editor). + +## Level adapter (`LevelSceneAdapter.fromWad`) + +* Tiles: TRAK model per MAP tile, transform `translation(x, y, z).rotateY(-rot)` (same as + `Geometry.TileXform`), vertex colours = first colour frame (alt models of WFPC 0x10000 tiles are + not shown). UVs via `Geometry.faceUvs`, face flags 0x400 / 0x4000 honoured. +* Collision: every TRAK model's polygons (`Collision.decode/polygon`) as outlines 1 cm above the + face, coloured as in debugviz (floor green, slope yellow-green, wall blue, ceiling orange, + materials 16/17/18/30/… fixed colours); fills for material 16 (exit floor) and faintly 17/18. + Invisible-collision detection (red fill of the in-game debug mod) is not done. +* Strats: game space → export space (z negated), yaw = ry. **Object models**: the strat code sets + its model with `0xb2 <abs STPC offset of the model header>; 0x54` – `StratModels` scans the strat + from its entry to the next strat's name site and takes the first such pair; spawn stubs + (`b2 <code> e0`) are followed. **Characters** (skinned models, header +0x84 = 0, nBones × u16 + vertex counts) are posed with frame 0 of an animation set with the same bone count (first one + referenced by `0xb2 <set>` in the strat code, else the first in STPC): bone matrices 4×4 f32, + row vectors. Result: villagers, kids, Pacha, Tipo, bunnies look right, the llama is shown in a + crouching pose (frame 0 of its set). Village: 20 of 32 placed types get a model (8 of them + skinned); the rest are invisible logic objects, except RisingColumns, SwitchPlatformPadVillage + and AppearingPlatformsVillage, which set their models some other way (shown as markers). +* Invisible strats (categories of debugviz: start, exit, checkpoint, camera, trigger, force, + secret, misc) become markers on poles (exit: purple cell box); start/checkpoint additionally + show their model. Objects without a resolvable model: small grey cube; unposable character: + grey diamond. +* Waypoints: every path node a pickable diamond (layer `waypoints`), chains (`next ≠ 0` → i+1) + and strat → first node as lines. Some chains are point lists across the whole level (e.g. the + llama's), so the layer gets busy – the UI should show it for the selection only. +* Environment zones: pink cell boxes + pickable marker (layer `zones`); the default zone's + backdrop colour (file offset 0x4e, 0x00BBGGRR) becomes `scene.background` (cyan village sky, + purple river fog, dark blue city). + +## Performance (1280×720, llvmpipe software GL on this machine, 4× MSAA / no MSAA) + +| Level | tiles/objects/markers | triangles drawn | draw calls | orbit avg MSAA 4 | no MSAA | +|---|---|---|---|---|---| +| village t1l1m001 (+collision) | 1458 inst., 218 markers | 27k | 125–145 | 17–27 ms | 9 ms (109 fps) | +| river t1l3m002 (+collision) | 1494 / 322 | 22k | 145–170 | 17–40 ms | 10 ms (103 fps) | +| city t1l6m003 (largest WAD) | 2177 / 439 | 41k | 120–140 | 26–50 ms | 18 ms (55 fps) | + +(Ranges: several agents shared the CPU.) Scene conversion from WAD 160–290 ms. On any real GPU +this is far below 1 ms of GPU time; the CPU side is ~150 draw calls per frame. + +## Known limits / next steps + +* Only the first vertex colour frame and the primary tile model (no alt/swap models). +* Translucent faces are not depth-sorted (as the game does per batch; artefacts are rare). +* Engine blend table only approximated; no texture animations (TEXT nAnim block), no sky/backdrop + geometry, no lights (LGHT) yet. +* Characters: frame 0 of a guessed animation set; models whose pose fails show a diamond. +* Collision of object models (moving platforms, doors) is not drawn (same as the debug mod). +* `Viewport.dispose()` plus disposing the frame can hang on Xvfb without a window manager (AWT + focus round trip); the demo exits with `System.exit` after a shot. + +Screenshots: `img/editor/01_village.jpg` … `12_viewer_ui.jpg` (village/river/city/Testwelt with +and without collision, ortho top views, translate/rotate gizmo, the demo window with a selection). diff --git a/notes/editor-tools-environment.md b/notes/editor-tools-environment.md new file mode 100644 index 0000000..3c35f33 --- /dev/null +++ b/notes/editor-tools-environment.md @@ -0,0 +1,131 @@ +# Level editor – environment, lighting & visibility tools + +Plugin `editor.tools.environment.EnvironmentEditorPlugin` (phase 3, `notes/editor-ui.md`). +Code: `src/main/java/com/eliasmueller/llamamod/editor/tools/environment/`, tests +`src/test/.../editor/tools/environment/` (`EnvironmentToolsTest` needs `-Dllamamod.cd`; `EnvTour` += scripted screenshots, `EnvScenarios` = the in-game test projects). + +![zones](img/editor/env_zones.jpg) + +## What the user gets + +| Where | What | +|---|---| +| **Environment** panel (left dock) – *Zones* tab | Live fog preview (on/off, zone: camera / start / selected, detail Min/Medium/Max, status "showing … fog a → b cells"), list of all zones (start zone first; fog colour swatch, kind, trigger → target, fog end, music), + Teleport (starts the Zone tool), + Environment zone, delete, frame | +| Inspector section "Environment zone" | trigger cell x/z/y + height test, *move the player here* (flag 4), target x/y/z, facing (°), step forward, fog mode, fog ramp bar, draw distance at Min/Medium/Max, the four fog values, fog/sky colour and ambient colour pickers (live preview while picking, one undo step), music dropdown (track names), sky model (STPC offsets used in the level) and sky yaw, *Make start zone*, *Duplicate*. Raw fields stay in the generic properties below | +| **Zones** tool | no zone selected: click a cell → new teleport zone (environment copied from the start zone); then click/drag = target, Shift+drag = facing (22.5° steps), `[` `]` turn; *Trigger cell* mode moves the trigger. Viewport: target ring + facing arrow (all zones), selected zone: filled trigger cell, box and an arc to the target; the target is a pickable marker | +| *Lights* tab | *Preview lights on objects* (engine lighting of all object models), list of lights (swatch, type, position, radius), + Point (Lights tool), + Directional, delete | +| Inspector section "Light" | type (directional / point / negative point), colour picker + intensity (0–2, colour value 128 = 1.0), directional: azimuth/elevation, point: position, full radius, falloff radius, flicker | +| **Lights** tool | click = point light 1 cell above the surface (colour preset: torch, fire, lamp, magic, moon, glow), drag = radius. Gizmos: directional = arrows above the level centre, point = radius rings, selected = spheres for both radii | +| *Lighting* tab | bake: presets Day / Dusk / Night / Cave / Flat or the level's own sun, sun azimuth/elevation/strength/colour, sky + ground colour, AO amount/radius, shadow light; scope all / selected tiles; options: add LGHT point lights to the tiles, also set the level lighting for brushes, set the start zone's fog + ambient to the preset. One undo step. Display: textured / vertex colours only; button for the paint tool | +| **Paint vertex colours** tool (V) | modes paint / lighten / darken / smooth, colour + brightness (1 = texture colour, 2 = max), radius, strength, falloff smooth/linear/constant, selection only; Ctrl+click = eyedropper; one undo step per stroke (`PropMapCommand` merges by stroke) | +| **Visibility** panel (right dock) | 32 × 32 mask matrix (row = camera region, column = drawn region; click/drag toggles, *symmetric* option, unused regions dimmed, row label picks a region), preview *off / follow the camera / from region n* (hides the tiles and objects the game would not draw), All visible, Self only, Make symmetric, Auto-generate… (one region / blocks / connected areas + distance) with a triangle-cost warning, statistics (triangles drawn per camera region, max/mean) | +| **Paint regions** tool | region (colour swatches) + brush 1…17 cells, Ctrl+click picks; switches the region layer on | +| Level → *Level settings…* (replaces the built-in action) | name, author, description, TestLevel chapter (+ chapters used by other projects), base level, grid width/height (grows east/south, region grid copied), death plane height (KILL_PLANE / SEA brushes and material-30 no-floor tiles move together, or add one), WFPC flags as check boxes (layout flags read-only, unknown set bits listed raw), strat pool | +| Level → Bake lighting / Environment zones / Visibility regions; View → Fog preview, Light objects, Vertex colours only (Shift+V) | shortcuts to the above | +| Problems (Check/Build) | fog end < 3 or > 30 cells at detail Max, fog start ≥ end, no / several start zones, trigger outside the grid, teleport target outside the grid (error), teleport without exit floor or door object in the trigger cell (info), player start in a region that does not draw itself (error), regions never drawn from anywhere, region drawing > 30k triangles (info), tiles with flat grey / black vertex colours, point lights without radius, black lights, ignored light types | + +## Engine facts used (groove.exe, see also mod-fog.md / mod-mirror.md) + +* **Zone records** (MAP block 4, 90 bytes; `EnvZone`): the start zone has flag 8 (`+0x20`) and + target (1,1,1) raw – exactly one per original level, trigger at (3, y, 0) outside the + playfield. Teleports: strat opcode **0x100** (`FUN_0054df30`, table 0x578ff4 + op·4) looks up the + zone of the calling object's cell (`FUN_0054d560`: same cell x/z, smallest cellY ≥ player cell + y (+1 with angle bit 0, −1 with bit 1)), then `FUN_0054de00`: unless flag 4, position = target, + yaw = (angle & 0xff0) << 12; then `FUN_0041e1c0` switches fog, colours, music; if angle bit 1 + is clear the player is pushed `(short)+0x38` along the facing. In the originals the trigger + cells hold exit floors (collision material 16) or door objects (RubyIdolDoor, PressurePadDoor): + house doors in the village. Flags 0x400/0x800 are read by strat opcode 0x81 (unknown use). +* **Fog mode** (`+0x34` → DAT_005865cc, `FUN_00438180`): 0 none, 1 darkness with a white point + light on the player with radius fog start…end (no original uses it), 2 table fog, 3 vertex fog. + Fog fades to the fog colour (`+0x50`, also the backdrop clear colour); the far clip = fog end. + `+0x4c` = ambient colour of object lighting (`FUN_0041bcf0`, byte/255). +* **Music** ids: table at 0x578960 (20 bytes per entry, name pointer + 8) → `music/<NAME>.ASF` + (`FUN_00546b70`); ids 1–39 (VILLAGE … ENDGAME), 0 stops. `+0x56` u16 → `FUN_005460d0` (low + nibble, bits 8–14; probably ambience, kept raw). +* **Lights** (LGHT, `FUN_0042c180`): positions and directions are **game space** (z = grid row, + the engine negates z when loading) – the editor previously treated them as export space (fixed + in `SceneRenderer`, `Transformers.LIGHT`, `Edits.translate`, see "shared changes"). Colour factor + 2c/255; type 4 = **negative** light −(1 + 2c/255)/2. Point light: full up to `a`, linear falloff + to 0 at `b` (`FUN_0041b8a0` / `FUN_0041ba10`); byte c = 1 → random ×1.5/×0.5 every frame + (DAT_005865d4 = random & 3) = flicker. Lights only light **object models** (strats, per vertex: + ambient + all directional + nearest point lights, max 6, `FUN_0041bdb0`); tiles use baked + vertex colours. 97 directional and 1729 point lights in the 31 levels, no type 4. +* **Regions**: camera cell → mask (`FUN_0041e020`), mask 0 = everything, camera outside the grid + = everything. Tiles (`FUN_0042bf40`) and strats (flag 0x40000 unless strat flag 0x800) in cells + whose region bit is not set are skipped. The second table value + the top tile's y goes to + DAT_00586424 (strat opcode 0x78; unknown use). +* **WFPC** bits: 0x10 / 0x100 / 0x10000 / 0x100000 / 0x200000 / 0x4000000 / 0x10000000 change the + data layout (read-only in the dialog); 0x800 renderer fog flag (`FUN_00424b90`), 0x80000 read by + strat opcode 0x8e, 0x8000000 collision materials 17/18 ignored are editable; others raw. + +## How the previews work + +* **Fog** (`EnvSession.updateFog`): zone = selected zone, else the start zone or the zone whose + *area* is nearest (teleport targets / player start; the game switches zones only by teleports), + fog start/end for the chosen detail exactly as `FUN_0041a480` (incl. the 1.5× loader rule). + Renderer addition `Viewport.setFogOverride(FogOverride)`: fog ramp to the zone colour, geometry + beyond the end discarded, clear colour = fog colour; distances are measured from the orbit + pivot (the game camera follows the llama a few cells behind), so an orbiting camera shows the + "visible bubble" around the point it looks at. When the camera is far from its pivot + (overview) the preview pauses ("Zoom in to see the fog of …"). +* **Shading** `Viewport.setShading(SHADE_VERTEX_COLORS)`: texture rgb replaced by white (cut-out + alpha kept). +* **Object lighting**: per-vertex colours of every OBJECT render instance from the engine + formula (`ObjectLighting`, `EnvMath.shade`), re-applied after scene changes. +* **Region preview**: hides tiles/objects through `ctx.setHidden` and restores exactly the nodes + it hid (user-hidden nodes stay hidden). + +## Bake (`VertexBake`) + +Same shading model as `levelbuild.Lighting` (sky/ground hemisphere by normal, sun Lambert × shadow +ray, AO with 24 Fibonacci rays, `amb·ao + sun·f·(0.6 + 0.4·ao)`), but traced against the real +triangles of all tiles (opaque faces; uniform 1-cell xz grid with a 2D DDA, per-cell max height +skips upward rays). Optional point lights (engine falloff, shadowed, ×0.5). Writes frame 0, keeps +byte 3 (frame count) and other frames. Village (1358 tiles, 26k triangles): **0.7 s**. Brushes +are not tiles in the scene – their light comes from the level's Lighting settings, which the bake +can set (the brush preview recompiles). + +## Shared changes (minimal, backwards compatible) + +* `editor.render.Viewport`: `FogOverride` record, `setFogOverride/fogOverride`, `setShading/shading`, + `SHADE_*`; `GlRenderer.FrameParams.fogOverride/shading`; shader uniforms `uFogMode`, `uFogOrigin`, + `uFarClip`, `uShade` (default path unchanged: 40–160 cells, 0.85). +* Light z sign (stored z = game z): `ui.view.SceneRenderer` (marker position, direction line), + `ui.tools.Transformers.LIGHT`, `model.cmd.Edits.translate`; `model.Light` javadoc corrected. + +## Verified in game (apptest, display :8, Wine 6, Village 1 edits built with `EnvScenarios`) + +See `img/editor/env_game_*.jpg`. + +* **New teleport zone (2026-11-07):** Village 1 edit with zones made by `ZoneTool.create` in the + 2×3 cells east of the player start (all their floor polygons painted material 16, zone cellY 8, + target 40.5/7.25/62.5, red fog 2–7 cells). Walking east from the original start: black fade, the + llama stands at the target next to the checkpoint post, red fog active + (`img/editor/env_game_teleport.jpg`). Teleports fire only while the llama *moves* onto the + exit floor (player code checks a speed property > 0): spawning on a house-door pad + (43.5/48.45) does nothing. +* **Start positions around 34–35 / 58 crash the game while loading** (page fault 0x405527 in + FUN_004054c0, floor-normal lookup), both with boot-to-level's start override and with the Llama + moved in the project (`EnvScenarios` "village-teleport"); the original start is fine. Not + investigated further. + +## Fog preview while zooming + +Beyond max(10, 1.6 × fog end) cells from the orbit pivot the preview fades out over another half +of that distance (fog radius grows ×(1 + 30u²), amount × (1 − u)); the overview (no override) +starts when the fade is complete and ends again at 90 % of it. The override is recomputed on every +camera change (the zone lookup still runs 120 ms after the camera stops), so zooming no longer +pops between "fogged" and "whole level". + +## Known limits + +* Zone "areas" for the fog preview are a heuristic (nearest teleport target); the game's real + current zone depends on which door the player used last. +* The bake is not identical to the original Argonaut lighting (their vertex colours were + hand-tuned/radiosity); it reproduces levelbuild's look. +* The object lighting preview ignores the flicker and the second light set (fog mode 1); objects + whose model is rebuilt by the shell get their lit colours back after the next scene change. +* Grid resizing only grows/shrinks at the east/far end; WFPC layout flags cannot be changed + (would need converting the MAP blocks). +* Region preview uses the editor's hidden flags (visible in the outliner while active). diff --git a/notes/editor-tools-geometry.md b/notes/editor-tools-geometry.md new file mode 100644 index 0000000..12beb4f --- /dev/null +++ b/notes/editor-tools-geometry.md @@ -0,0 +1,164 @@ +# Level editor – geometry tools (`editor.tools.geometry`) + +Plugin `GeometryEditorPlugin` (phase 3, discovered automatically). Everything the owner asked for +under "geometry": draw primitives, resize/turn/scale/mirror them, align and repeat, paint tiles, +paint collision materials, import meshes, reuse geometry across levels, export to Blender, and +geometry checks. Screenshots: `img/editor/geo_*.jpg`. + +![brush](img/editor/geo_handles.jpg) + +## User guide + +### Brush tool (B) – draw primitives +Toolbar button / **Geometry → Brush tool**. The options bar has one button per primitive, the +Inspector shows the same palette with names plus the options of the chosen primitive (textures with +a swatch picker, thickness, steps, segments, collision, solid sides, material). + +| Primitive | How to draw | Brush | +|---|---|---| +| Block, Platform, Ramp, Stairs, Round (cylinder), Floor, Water, Death plane, Invisible block | drag the footprint, then move the mouse up/down for the height and click (Enter = height field, Esc cancels) | BOX, PLATFORM, RAMP, STAIRS, CYLINDER (new), FLOOR, SEA, KILL_PLANE, BOX invisible | +| Wall, Invisible wall | drag along the wall – any direction, snapped to the angle step; then the height | BOX (turned by `rotation` if diagonal) | +| Pillar, Round | click = default size, drag = size | PILLAR, CYLINDER | +| Fence | click the corners, double-click / Enter finishes | FENCE | +| Tree | click | TREE | + +* Drawing happens on the surface under the cursor (top of other geometry, water, the grid) or on a + **work plane** at a fixed height. Footprints snap to the move step (Ctrl inverts snapping). +* Ramps and stairs rise in the main drag direction. Stairs start at the floor in front (`y0`), the + walkable invisible slope makes them climbable (the engine has no step-up logic). +* **Handles**: a selected brush shows cubes on its faces (orange = sides, green = top/bottom). Drag + one to resize; the opposite face stays where it is – also for turned brushes. +* `[` / `]` turn the selection by 90°, Page Up / Page Down raise / lower it by the snap step. +* Status bar shows the size while drawing/resizing. + +### Rotate / scale / arrange +* **Rotate (E)**: the green ring turns everything about the vertical axis (as before). The red and + blue rings now turn **brushes freely in 3D** (`rotation` x·y·z Euler angles about the brush's + centre; meshes about their origin). Tiles and objects only turn about the vertical axis in the + engine – the status bar says so and they are left alone. +* **Scale (R)** on brushes changes the primitive's own sizes in its frame (width/depth/height, + thickness, radius, crown …; meshes stretch per axis) – the bottom centre stays. +* Quarter turns and mirrors of unrotated brushes change their parameters (texture stays aligned to + the grid; ramp/stairs directions, sides, per-face settings follow). +* **Geometry menu**: Turn left/right 90° (Ctrl+[ / Ctrl+]), Mirror X / Z (about the selection + centre; tiles and objects are moved and turned – tile models themselves cannot be mirrored), + **Array duplicate…** (Ctrl+Shift+D: n copies with offset and turn per copy, optionally circular + about the selection centre), **Align ▸** west/centre/east, bottom/middle/top, north/centre/south to + the last selected item, distribute along X / Z, **Grid & snapping…** (any move/angle/scale step, + grid on/off), **Death plane height…** (moves death-plane brushes and the original level's + material-30 tiles, or adds a plane over the whole grid), **Re-import mesh…**, **Save selection to + library…**. +* Inspector section **Brush shape** (one brush): size, rotation X/Y/Z fields, turn/mirror buttons, + *Visible* (off = invisible collision volume) and a face table: collision on/off and material per + face (top, bottom, north, east, south, west). Section **Tile**: turn/raise buttons, "use palette + model", tile swap (see below) and the honest note that tiles only turn about y and cannot scale. + +### Tiles tool (T) – original-style levels +Modes **Paint / Pick / Erase / Raise-lower / Swap model**, palette of the level's tile models with +thumbnails in the Inspector (filter, "only used models"). Paint puts the palette model into the cell +under the cursor (on the surface height, or at a fixed height), rotation 0/90/180/270 (`[` `]`), +height Page Up/Down. Painting onto a tile at the same height swaps its model instead of stacking +(keeps tile indices that strat scripts use). Shift+click = pick (model, height, rotation). New tiles +copy the vertex colours of a tile with the same model. **Tile swaps** (doors/bridges, WFPC 0x10000): +the Tile section shows the swap entry, sets the alternative model from the palette, removes or adds +a swap; layer "Tile swap alternatives" draws the alternative model in cyan. + +### Collision tool (K) +Turns the collision layer on. Hovering highlights the polygon (or, on brushes, the whole face) under +the cursor and names its material; click/drag **paints** the material from the palette (Inspector +list with the known materials of `debugviz.md`: 0 normal, **16 exit floor**, **30 no floor / +death**, 17/18 selective, specials 1…255 "meaning unknown", "Other…" for any number). Shift+click +picks a material. **Toggle collision** mode switches a brush face's collision off/on. Tile models +are painted per polygon; shared models: change the model (all tiles) or give the tile its own copy. +Invisible collision volumes (Invisible wall/block, colliders) are drawn translucent red (layer +"Invisible collision volumes"). + +### Mesh import (File → Import mesh) +File chooser, then a dialog: rotatable preview with a 1-cell grid and a llama-sized marker, source +info, up axis, unit scale and "fit largest side to n cells", turn, mirror, stand on the ground, +texture size and UV precision, double-sided, lit, collision **from the mesh / simplified invisible +box / none** + material, warnings (densest cell vs. the 8000-vertex limit, larger than the grid). +Placement: click in the viewport (a box follows the cursor) or at the view centre. +**Geometry → Re-import mesh…** reloads the selected mesh brush's file (remembered) or another file +and keeps the placement. + +### Library (cross-level reuse) and OBJ export +* **Save selection to library…** (Geometry menu or Assets → **Library** tab): selected tiles + (original TRAK models with collision and vertex colours), brushes and meshes (compiled to tiles) + → `<data>/library/<name>.llamamesh`: a zip with `library.json` (models: vertices, faces, planes, + three collision groups; tiles: offsets from the anchor cell, rotation, vertex colours) and one PNG + per used texture (pixels embedded – owner decision: game data may be embedded). Death-plane tiles + are skipped. +* **Insert**: drag an entry from the Library tab into the viewport (anchor = cell under the drop + point) or double-click / Insert (view centre). Textures with identical pixels in the target level + are reused (same base: nothing new), the others are packed onto new texture pages (blend mode + kept); identical tile models are reused. Works on any base level. +* **File → Export selection as OBJ…**: OBJ (export space = the importer's convention) + MTL + one + PNG per texture, UVs per triangle (the engine's texture-rectangle halves), vertex colours as + `v x y z r g b` (0x80 = 1.0), optionally `…_collision.obj` (polygons grouped by material). The + dialog notes that the textures are game data (personal use). The importer reads it back + (round trip verified: same shape and textures, `img/editor/geo_library_insert.jpg` – right the + library tiles, left the re-imported OBJ as a mesh brush). + +### Checks (Level → Check, Build) +Tiles outside the grid; tile models (also compiled brush/mesh tiles) over **8000 vertices**; +**overlapping** identical tiles; **no floor collision under the player start** (cell-based like the +engine) or start far above the floor; **no exit floor (16)** in the LevelDoor's cell; **gaps** < 0.2 +between neighbouring floors at the same height; brush compiler warnings. + +## Design / implementation + +* **Brush math** (`BrushMath`, pure, unit-tested): footprint → parameters (`Primitive.params`), + handles + `drag` (opposite face kept in world space via `keepWorld`), `rotate` (JOML matrices, + `getEulerAnglesXYZ`; quarter turns parametric), `scale`, `mirror` (local mirror + M·R·M), diff-based + commands (`Edits.set` with merge keys → one undo step per gesture). `BrushTransformer` replaces the + built-in brush transformer (position = bottom centre of the pivot column, yaw = rotation[1]). + `RotateTool3D` wraps the built-in Rotate tool (same id) and handles the non-vertical rings. +* **Generic brush parameters** (all kinds except SEA, KILL_PLANE, COLLIDER): `rotation` {x, y, z}°, + `visible`, `collFaces` ("tbnesw"), `faceMats` int[6] (−1 = brush material); MESH also `stretch`. + Applied by `BrushCompiler.postProcess` on the quads a brush added (`LevelBuilder.editQuads`/ + `editSolids`): face class from the quad's normal in the brush frame, then rotation about + `Brush.pivot()`; default values leave the build byte-identical (golden/test-world tests pass). +* `CollisionIndex`: world polygons of scene tiles + compiled brush tiles (cached per scene, + invalidated by scene events) for picking, highlighting, checks. +* `MeshLibrary` (save/load/insert/OBJ), `LibraryTab` (asset tab + drag & drop kind `geolib`). + +### Shared-file changes (backwards compatible) +| File | Change | +|---|---| +| `levelbuild/LevelBuilder` | `QuadEdit`, `quadCount/solidCount`, `editQuads/editSolids` (editor post-processing), `cylinder(...)` primitive | +| `editor/model/BrushKind` | kind `CYLINDER`; generic params (above); `FACES`, `transformable()`; INT_ARRAY params; STAIRS `y0` label "Start height (floor in front)" | +| `editor/model/Brush` | int[] defaults cloned; `bounds()` = rotated `localBounds()` (now includes platform/pillar/cylinder/tree/fence/mesh sizes), `pivot()`, `rotation()`, `hasRotation()`, `rotationMatrix()`, `rotate()` | +| `editor/io/BrushCompiler` | CYLINDER replay, `postProcess`, `faceClass/frontNormal`, mesh `stretch` (+ mirrored winding) | +| `editor/io/MeshImport` | bug fix: the source folder is normalised, otherwise a path with `..` silently dropped the MTL and textures (meshes rendered untextured after saving) | + +## Verification +* Unit tests `BrushMathTest` (rotation matrix = JOML, Euler round trip, primitives from footprints, + diagonal wall end points, quarter turns = free 90° turn, mirror involution incl. rotated shapes, + handles keep the opposite face also when turned, scaling, transformer; compiled per-face material + 16 / sides without collision / invisible = collision only / 45° walls / tilted top / cylinder) and + `GeometryChecksTest` (gap, player-start floor, exit floor; library save→load→insert into the same + level (textures reused) and into a world-4 base (textures imported, builds)). +* UI on Xvfb: `geo_draw` (ramp preview), `geo_handles`, `geo_rotate3d` (blue ring turned a ramp + 60°, inspector updates live), `geo_collision` (hover highlight, palette), `geo_tiles` (palette + + cell preview, village), `geo_import` (dialog), `geo_placed`, `geo_menu` (Geometry ▸ Align), + `geo_library`, `geo_build_ok`. +* **In game** (apptest, Wine; display :21 because :5 was taken by another agent's session): test + level "Geometry Test" (TestLevel chapter 4, built with the same code paths, `GeoDemo`): start + plaza → ramp → platform → stairs → round platform → **imported OBJ bridge** → goal block whose top + face is painted **16** with a LevelDoor → walked through in one run, the door fired, level summary + shown (`geo_game_course.jpg`). **Death floor** (FLOOR material 30): stepping on it = death and + respawn (`geo_game_death.jpg`). **Invisible wall** at the plaza edge stops the llama + (`geo_game_invisible_wall.jpg`). **Library**: a village piece (9 tiles, 44 textures) saved and + inserted into new levels on the village base (textures reused) and on the world-4 base t1l4m001 + (textures imported, 3 new pages): both build and load in game (`geo_game_library.jpg`); also built + on the river base t1l3m001 (no Llama placeable there, so not played). + +## Known limits +* Collision materials of brushes are per face class, not per individual polygon; tile models per + polygon. Collision of tile-model polygons cannot be switched off (paint 30 or hide the tile). +* Water and death planes are cell based: resize/move/90° only. Gaps check only unrotated + box/platform/floor tops. +* Library insert keeps tiles cell-aligned (anchor = whole cells); objects are not part of the library + (see the objects agent's prefabs / object catalogue). glTF export not done (OBJ only). +* `TextureImport` packs imported textures at one size per insert (largest texture, ≤ 256). diff --git a/notes/editor-tools-objects.md b/notes/editor-tools-objects.md new file mode 100644 index 0000000..ee5bc31 --- /dev/null +++ b/notes/editor-tools-objects.md @@ -0,0 +1,79 @@ +# Level editor – objects, scripts, paths, cameras (`editor.tools.objects`) + +Plugin `ObjectsEditorPlugin` (phase 3, discovered automatically). Parameter decoding and the strat +VM: `notes/editor-object-schemas.md`. Tests: `ObjectToolsTest` (needs `-Dllamamod.cd`), +screenshot tour `ObjectsTour` (test classes). + +![camera zone](img/editor/obj_camera_zone.jpg) + +## What it adds + +| Part | Content | +|---|---| +| **Objects panel** (left dock) | every placeable type of the base with rendered thumbnail, category filter, search (name + schema summary), favourites (right-click, ★ filter), "parameters decoded" marker; drag into the viewport or click → Place tool. Quick setups: Player start, Checkpoint, Exit, Secret, Coin line, Coin arc | +| **Place tool** (P) | click = place on the collision floor under the cursor (snap to floor, grid snap), yaw = camera direction / facing the camera / 0° / type default; click + drag an object = move along the ground (its waypoints move along); drag the yellow knob = rotate (angle snap; a checkpoint's respawn node turns with it). Modes for the setups | +| Setups | **Player start** moves the existing player (exactly one) or places a Llama with its chain (camera node 8 cells behind). **Checkpoint** = CheckPointPost + respawn node 0.4 ahead (floor height). **Exit** = LevelDoor in the cell centre + 1×1 FLOOR brush with collision material 16; target picked in the options bar (world/chapter names, "world finished 7,7,7"). **Secret** = Secret with a 3×3 box (2 nodes) | +| **Coin tool** (O) | Line: two clicks, coins every *spacing*; Arc: centre + point on the circle, *count* coins over *arc°*; Coin/Coin5, height above floor, follow floor; live preview | +| **Waypoint tool** (N) | select an object (or node): click = add node after the selected node / at the chain end (creates the chain if missing), Ctrl+click = insert before, drag = move (keeps height above floor), Backspace/Del = remove and relink; Reverse; orient new nodes along the path | +| **Zones & triggers** (J) | handles: 4 corners of box zones (node pairs: Secret, CameraSide/Lift, ObjectDontDropInBox, FallOffNoHurt …), radius knob for RADIUS parameters (CutSceneTrigger, guards, CameraFocusPoint) | +| Inspector "Object parameters" | named, typed fields (check boxes, choices, counts, seconds, distances) with doc/range/original-value tooltips, raw hex, inline validation, "Reset to defaults"; LevelDoor target picker with "Leads to: …"; waypoint row (role, node count, Create/Edit path, Unlink, Delete path, Link… to an existing chain, Look through). Undo-aware refresh | +| Inspector "Waypoint chain" | node i of n, owner and role, ◀ ▶, select object, delete node, look through | +| Viewport layers | *Path directions* (arrows, dashed wrap segment for patrol paths, node yaw ticks), *Zone areas & trigger radii* (boxes for all zone objects, fills for the selection), *Camera frusta* (CameraSide/Lift: computed game camera from height/distance/node yaw; cut-scene cameras: a frustum per node from node yaw/pitch) | +| Look through camera (Ctrl+Shift+L, View → Camera) | viewport takes the camera of the selected node / cut-scene camera / camera zone; while active, flying writes back node position + yaw/pitch, or for CameraSide the height/distance params and node yaw (one undo step per pilot session) | +| Problems (Level → Check, Build) | chains not stored contiguously (error, Tools → Repair waypoint chains), param count, out-of-range values, missing required waypoints / odd box pairs, types whose originals always have a chain, LevelDoor without material-16 floor in its cell, impossible exit targets and (7,7,7) in TestLevel, far respawn nodes, several player starts | +| Copy / paste between levels | Edit → Copy objects (Ctrl+Shift+C) puts JSON (objects + chains + brushes, relative to their centre) on the system clipboard; Paste objects (Ctrl+Shift+V) at the view centre in any level – types are matched by name/variant in the target base (unknown types skipped with a message) | +| Prefabs | Tools → Save selection as prefab… / asset browser tab "Prefabs": `<data>/editor/prefabs/<name>.json` + thumbnail; double-click = place at view centre, drag = place under the cursor | +| Delete | `edit.delete` is re-registered: a selection of waypoints is removed with the chain relinked | + +Engine fact used throughout: the MAP loader links waypoint record i to i+1 when "next" ≠ 0, so a +chain must be contiguous in the node list (`ChainOps` inserts at the right index and the check / +repair action guard it). + +## Files + +`ObjectsEditorPlugin` (registration, actions, waypoint inspector), `Schemas`/`ParamSchema`/ +`TypeStats` (+ `type-stats.tsv`), `ParamsSection`, `ObjectChecks`, `PlaceTool`, `Setups`, +`ExitTarget`, `CoinTool`, `PathTool`, `ChainOps`, `ZoneTool`, `ObjectOverlays`, `CameraView` +(zone camera + pilot), `ObjectPalette`, `ObjectClipboard`, `Prefabs`, `LinkDialog`, `Ground` +(floor finder on the collision polygons of tiles + brush preview tiles), `Session`, `Roles`, `Draw` +(all game↔render conversions in one place). + +Shared change: `editor.ui.view.SceneRenderer.brushOutput()` getter (brush preview tiles for the +floor finder). Nothing else outside the package. + +## Verified + +UI (Xvfb, `ObjectsTour`): `img/editor/obj_camera_zone.jpg`, `obj_cutscene_frusta.jpg`, +`obj_pilot.jpg`, `obj_exit_inspector.jpg`, `obj_place.jpg`, `obj_zone_handles.jpg`, +`obj_trigger_radius.jpg`, `obj_problems.jpg`, `obj_newlevel_path.jpg`, `obj_newlevel_camera.jpg`, +`obj_coin_arc.jpg`, `obj_newlevel_saved.jpg` (no problems), `obj_beetle_path.jpg`. + +In game (apptest, display :7, new level on base t1l1m002 as TestLevel chapter 2; island of brushes, +coin line + coin arc, checkpoint, exit to Village chapter 2, CameraSide zone at the exit, +JumpingBeetleBlue with a 4-node patrol path, CityGuard with a square path): + +* level loads, coins of line and arc stand where placed (`obj_game_coins.jpg`); +* walking through the checkpoint shows "KONTROLLPUNKT"; falling into the sea respawns the llama at + the post (`obj_game_checkpoint.jpg`); +* entering the camera zone switches to the zone camera (high view, `obj_game_camera_zone.jpg`); +* stepping on the exit floor shows the tally "TestLevel Kapitel 2"; Enter → "Dorf Kapitel 2" + loads = the chosen chapter (`obj_game_exit.jpg`); +* the beetle jumps back and forth along its path (`obj_game_patrol.jpg`). + +## Known limits + +* Parameters are decoded for ~35 common types (confidence per type in the schema doc); the rest + shows raw values with the statistics of the originals. +* The CityGuard (village-2 base) placed in the open did not show up in game – it probably waits for + a scene/event; the patrol test was done with JumpingBeetleBlue. +* In 8 original levels the exit check reports "no material 16 in the door cell": the exit floor + polygons decode one cell off the door (e.g. village door cell 42,49, floor polygon at 43,48) – + original tile collision positions vs. strat positions are not fully understood yet; brush-built + exits are correct (verified in game). Snap-to-floor on original levels uses the same polygons. +* Zone heights are estimated (+1.2 above the lower node); the engine tests x/z only. +* Camera preview of CameraSide assumes the player in the box centre and default distance 2 / + height 1 when the parameters are 0; the game's FOV is approximated by 60°. +* Look-through of the village cut-scene camera's first node looks into a wall (its first node may + be a fly-in start); not compared with the game. +* Pasting into another base matches types by name; object models/code are not imported (that is + the job of `editor.tools.catalog`). diff --git a/notes/editor-tools-textures.md b/notes/editor-tools-textures.md new file mode 100644 index 0000000..bc783de --- /dev/null +++ b/notes/editor-tools-textures.md @@ -0,0 +1,121 @@ +# Level editor – texture tools (`editor.tools.textures`) + +Plugin `TexturesEditorPlugin` (phase 3). User guide: `editor-guide.md#textures`. + +![texture manager](img/editor/tex_manager.jpg) + +## What it adds + +| Item | Where | | +|---|---|---| +| Texture manager | panel `textures` (bottom dock, Ctrl+Shift+T; **Window** button = own 1300×760 frame) | pages, atlas, table, details, animation editor | +| Import texture… | File menu (replaces the built-in `file.importTexture`), panel toolbar | `ImportDialog` | +| Replace texture pixels… | Edit menu, panel toolbar | image → the selected texture's rectangle | +| Paint texture | tool `texpaint`, key **M** (own toolbar group) | `PaintTool` | +| Texture eyedropper | Edit menu, key **I**; Shift+click in the paint tool | | +| Checks | Build/Check | > 127 pages = error, entry on a missing page = error | + +`TexState` holds the window's *current texture* (shared by panel, tool, eyedropper; selecting a +`TextureEntry` anywhere, e.g. in the outliner, makes it current). + +### Texture manager (`TextureManagerPanel`) + +* **Pages**: thumbnail, index, blend class of the page format (`Blend.ofPage`). Context menu: + export page PNG, replace page from PNG (scaled to 256×256, reduced with the page's class). +* **Atlas**: the page (nearest-neighbour, fit to the view until the wheel zooms) with all + rectangles: white = used, red = unused, cyan = animated, orange = current with 8 handles. + Drag inside the current rectangle = move, handles = resize (one undo step per drag: + `TextureLibrary.setRect` with a merge key). Click selects the smallest rectangle under the + cursor; double-click activates the paint tool; tooltips show size/usage. +* **Table**: #, preview, name (editable), page, rectangle, blend, faces (tile-model faces + + brushes), models, animation ("frame" = used as a frame). Filters: all / on this page / used / + unused / animated / imported; search by name or number. +* **Texture tab**: name, X/Y/W/H spinners, blend class (sets the entry flags), raw flags, + usage line, *Duplicate* (new entry with the same rectangle, e.g. for frames), *Select users* + (tiles whose models use it). +* **Animation tab** (`AnimEditor`): create/remove, type flip-book / scroll up / scroll down, + frame delay (≈ fps at 30 game frames/s), back and forth, frame list (+ − ◀ ▶), scroll band / + speed / top row, live preview (`AnimSpec.frameAt/offsetAt`). + +Usage counts (`TextureLibrary.usage`): faces of TRAK tile models (`faces[i+4]`), brush string +parameters `"#n"`, animation targets and frames. Object (STPC) models are not counted – their +textures are in the same table, so "unused" means *not used by tiles/brushes/animations*. + +### Import (`ImportDialog`, `ColorReduction`, `TextureLibrary.importItems`) + +![import dialog](img/editor/tex_import.jpg) + +* Size = longest side (16…256 or original), aspect kept; rectangles need not be powers of two. +* **Blend classes** (`Blend`), derived from the flag pairs of all original level WADs + (entry flags ↔ page flags & 0x7f, counts over 33 WADs): + +| Class | entry flags | page flags | originals | +|---|---|---|---| +| Opaque | 0x0104 | 0x81 | 16 551 entries | +| Cut-out | 0x0406 | 0x84 | 1 774 | +| Semi-transparent (mode 1) | 0x1406 | 0x87 | 528 | +| Additive (mode 2) | 0x2406 | 0x85 | 649 | +| Darken (mode 3) | 0x3406 | 0x82 | 574 | + + Others seen: 0x1104/class 6 (186), 0x2104/class 3 (38), 0x4406/class 5 (469), object-model + entries 0x8cc6/0xbbc6/0x9cc6/0xb8c6 (bit 15). Entry bits 12–14 = blend mode, 0x400 = has + transparent pixels. +* **Colour reduction** to ABGR1555: alpha < cut → 0x0000; with "keep half-transparent" alpha + < 192 → STP 0 (black becomes 0x0421, never 0); optional colour key (magenta / top-left pixel) + and 4×4 ordered dithering; opaque class ignores alpha. Preview = the game's 15-bit result. +* **Packing**: with "pack into free space" the importer searches pages of the same format + class for a free block (`FreeMap`, summed-area table over a 256×256 occupancy mask built from + all rectangles plus scroll bands), 8-pixel grid, largest images first; otherwise / if full it + appends pages (≤ 127). Optional 1-pixel border (edge pixels repeated) against filtering seams. + One undoable `Batch`: `SetProp pixels` of changed existing pages (the project stores only the + changed pixel runs), `AddNode` pages, `AddNode` entries (always appended – faces reference the + index). +* Village test: two 32×32 imports land in free space of existing pages, a 200×100 semi-transparent + one gets a new 0x87 page; the WAD builds and re-imports (`TextureLibraryTest`). + +### Paint tool (`PaintTool`) + +![paint](img/editor/tex_paint.jpg) + +* Hover shows the face under the cursor (white outline) and model/face/texture/flags in the + status bar (plus "model shared by n tiles"). Face picking: `ctx.pickNode` → tile, then a + Möller–Trumbore ray test against that tile model's triangles (tile transform, export space). +* Click = assign the current texture; scope **Face / Same texture in model / Whole model**. + Shift+click or the eyedropper button / **I** = pick. The clicked face stays selected (orange). +* **Rotate** cycles the face's corners (v0,v1,v2 → v1,v2,v0; winding and plane unchanged), + **Mirror** toggles 0x20 (mirror U), **Other half** cycles 0x800/0x10 (`Geometry.faceUvs`: no + 0x800 = corners (a,v1)(b,v1)(a,v0); 0x800 = (a,v1)(b,v1)(b,v0); 0x810 = (b,v0)(a,v0)(a,v1)). +* **Unique**: if the tile's model is shared, the edit first adds a copy of the model and points + the tile at it (one undo step). +* Brushes: the clicked side's normal (from three depth samples) picks the parameter `top` + (n.y > 0.7) / `bottom` / `side`, else the first texture parameter (`tex`, `post`, `trunk` …); + the value becomes `"#n"`. Rotate increments `texRot` (0–3) of the last painted brush. + +## Texture animations (decoded, `AnimSpec`) + +Loader FUN_00406e40 (16-byte runtime records at 0x581138, count 0x581134), update FUN_0040c6e0, +called once per rendered frame from FUN_0054b590 while the game is not paused. The runtime +texture record (20 bytes at 0x581154 + tex·0x14) holds the page and the UVs as floats +(+0xc / +0x10 = v0 / v1, divided by the page height at 0x58114c + page·0x20 + 8). + +| Type | File payload | Behaviour | +|---|---|---| +| 8 flip-book | delay, n, pingPong, n × u16 texture | every delay+1 frames the texture's runtime record is replaced by the next frame's (loop or back and forth). 214 in the levels (torches, water, flags), e.g. `03 08 00 d0 03 …` | +| 1 / 2 scroll | band−1, speed, v0 | V range = v0+off … v0+off+band, off −= / += speed per frame, wraps in the band. 7× type 1, 2× type 2 (rivers, lava: `1f 02 01`) | +| 10 / 11 | band−1, speed, v0, n, n × u16 | scroll as 2 / 1, each wrap switches to the next / previous frame. Unused | +| 7 / 9 | u16 palette, u16 delay, u8 first, u8 last | palette cycling of 8-bit pages. Unused (no palettes) | + +The editor viewport does not animate textures; the Animation tab previews them. + +## Known limits + +* Texture entries are never deleted (indices are referenced by faces and object models); + unused ones can be reused by moving their rectangle. +* Object (STPC) model faces cannot be painted. +* Painting a shared original tile model changes every tile using it unless **Unique** is on. +* The page format of an existing page is not changed by the importer; importing into a page of + another class is avoided. + +Files: `src/main/java/com/eliasmueller/llamamod/editor/tools/textures/*`, tests +`TextureLibraryTest` (colour reduction, anim round trip of all village animations, packing, +build + undo), tour steps `TexTourSteps` (with `playtest.EdTour`). diff --git a/notes/editor-ui.md b/notes/editor-ui.md new file mode 100644 index 0000000..8be2bc5 --- /dev/null +++ b/notes/editor-ui.md @@ -0,0 +1,197 @@ +# Level editor – application shell (`editor.ui`, phase 2) + +The Swing/FlatLaf editor around the phase-1 core (`editor-api.md`, `editor-format.md`) and viewport +(`editor-render.md`). Package `com.eliasmueller.llamamod.editor.ui` (+ `.panels`, `.tools`, `.view`). +Design brief: `editor-design.md`. + +![village](img/editor/ui_select_move.jpg) + +## Launch + +* LlamaMod header: **Level Editor** button next to Play (`ui/MainFrame.openEditor`). Same JVM, same + `AppPaths`/`Config`/`ModManager` objects; after a play-test LlamaMod refreshes (`EditorEnv.setModsChanged`). +* `llamamod --editor [project.llamalevel | t1l3m002]` – standalone (exits when closed). Without an + argument it offers crash recovery. `-Dllamamod.editor.cd=DIR` uses another CD folder, + `-Dllamamod.editor.msaa=0` disables multisampling (software GL). +* Programmatic: `EditorApp.open(new EditorEnv(paths, cfg, modManager, false), arg)` on the EDT. + +## Window + +| Area | Content | +|---|---| +| Menu | File (New level, Open original level, Open project, Open recent, Save, Save as, Close, Import mesh/texture, Export WAD), Edit (Undo/Redo with labels, History, Cut/Copy/Paste/Duplicate/Delete, Select all/none/similar, Hide/Show/Lock, Preferences), View (Frame, Camera ›, local space, snap, collision, dark theme, Panels ›, Layers ›), Level (Settings, Check, Build, Play), Help (Keyboard shortcuts, About) | +| Toolbar | new/open original/open/save · undo/redo · Select Q / Move W / Rotate E / Scale R · duplicate/delete/frame · **Build** · **Play** | +| Options bar | active tool + its options · Snap toggle, move step (1…1/16 cell), angle step · World/Local · camera view · Layers popup | +| Docks | left: Outliner, History · right: Inspector · bottom: Assets, Problems. Tabs can be closed, moved to another dock (tab context menu), reopened in View → Panels; layout and split sizes are remembered | +| Viewport | `render.Viewport` with the live scene; empty state ("Open a level or create a new one", recent projects) while no level is open | +| Status bar | messages, hovered item, cursor position (game cells) and grid cell, counts, last build (click → Problems), progress of background tasks | + +Shortcuts (Help → Keyboard shortcuts lists all, incl. plugins): RMB+WASDQE fly, MMB orbit, +Shift+MMB pan, wheel zoom, F frame, Home frame level, numpad 7/1/3/5 views, Q/W/E/R tools, X local +space, G snap, C collision, H hide, Alt+H show all, L lock, Ctrl+D duplicate, Del delete, +Ctrl+Z/Y/Shift+Z, Ctrl+X/C/V, Ctrl+A, Ctrl+B build, F5 play, F1 help. Single keys never fire while a +text field has the focus; while the GL canvas has the focus a `KeyEventDispatcher` maps keys to +actions (AWT canvases do not get Swing key bindings). + +## Architecture + +| Class | Role | +|---|---| +| `EditorWindow` | the JFrame; implements `EditorContext`; owns selection, viewport, `SceneRenderer`, docks, actions, key dispatch, document switching | +| `EditorContext` | **the API plugins use** (document/scene/history, selection, viewport, pick helpers, execute, hidden/locked, layers, tools, status, background tasks, problems, panels) | +| `EditorExtensions` / `EditorPlugin` / `PluginLoader` | registry + discovery (below) | +| `BuiltinEditorPlugin` | everything built in, registered through the same API | +| `Document` | `Project` + `History` + file + autosave state; `dirty()` | +| `Selection` | ordered node set, primary = last, listeners get the source (avoid echo loops), auto-pruned | +| `ProjectController` | new/open/save/save as/recent, autosave + recovery, thumbnail, import/export | +| `PlayTest` | check, background build, play-test | +| `view.SceneRenderer` | scene model → `RenderScene`, live, stable pick ids | +| `view.Thumbnails` | software rasteriser for asset thumbnails | +| `tools.SelectTool`, `TransformSupport`, `Transformers`, `EditOps` | selection/transform tools and edit operations | +| `panels.*` | Outliner, Inspector, AssetBrowser, Problems, History | + +All edits go through `ctx.execute(Command)` (→ `History`); drags use one merge key per gesture, so a +drag is one undo step (`TransformSupport`, inspector drag-scrub). Scene events arrive synchronously; +the window collects them and flushes the renderer once per EDT turn. + +### Scene → render adapter (`view.SceneRenderer`) + +* Pick ids `kind<<24 | n` (`PickId.TILE/OBJECT/WAYPOINT/ZONE/LIGHT`, `PICK_BRUSH`=6, `PICK_OTHER`=7), + assigned per node on first use, stable for the document (`idOf`, `nodeOf`). +* Tiles: one instance per tile (mesh per `TileModel`, collision overlay), transform + first colour + frame; a CHANGED tile only updates its instance (`RenderScene.changed(inst)` → one chunk rebuild); + a changed model rebuilds its mesh and its tiles. Objects: model via `StratModels` from the STPC raw + chunk (same rules as `LevelSceneAdapter`), markers for logic objects. Markers/overlays (waypoints, + zones + teleport targets, lights, regions, brush boxes) are rebuilt as a whole (`RenderScene.overlaysChanged()`, + cheap). Texture/STPC changes or RELOADED rebuild everything (~0.3 s for the village). +* Brushes: `BrushCompiler.compile` (the build's own code) for a live preview; generated tiles get the + pick id of the smallest enabled brush whose bounds contain them, plus an outline box and handle marker. +* Waypoints default to "selection only" (chains of selected objects/waypoints), View → Layers can show all. +* Hidden nodes: instance/marker `visible=false` (renderer change: invisible tiles leave the static + batches); locked nodes are drawn but `pickNode` ignores them. + +## Extension API (phase 3) + +**Rule for parallel agents: never edit `editor.ui` itself.** Create a package of your own, e.g. +`com.eliasmueller.llamamod.editor.tools.waypoints`, with a class whose simple name ends in +`EditorPlugin`: + +```java +public final class WaypointEditorPlugin implements EditorPlugin { + @Override public void install(EditorExtensions x) { + x.tool(new WaypointTool()); // toolbar + shortcut + x.panel(new PanelSpec("waypoints", "Waypoints", "waypoint", Dock.RIGHT, 30, false, WaypointPanel::new)); + x.action(ActionSpec.of("wp.newChain", "Tools", 10, 10, "New waypoint chain", "waypoint", "ctrl shift W", + ctx -> ...).enabledWhen(ctx -> ctx.document() != null)); + x.inspector(new ChainSection()); // extra inspector content + x.layer(new LayerSpec("wpLabels", "Waypoint labels", "waypoint", false, 55)); + x.renderContributor(new ChainLabels()); // overlay geometry in that layer + x.check((scene, out) -> ...); // build/check problems + x.transformer(new MyNodeTransformer()); // gizmo support for a node kind + x.template(new MyLevelTemplate()); // "New level" start content + x.assetTab(new AssetTab("prefabs", "Prefabs", "assets", 50, PrefabTab::new)); + x.onOpen(ctx -> ctx.addSceneListener(e -> ...)); // runs once the window exists + AssetDrop.register("prefab", (ctx, data, ground) -> ...); // drag & drop into the viewport + } +} +``` + +Discovery: `PluginLoader` scans the classpath (jar or class folders) for `*EditorPlugin.class` below +`com/eliasmueller/llamamod/editor/` (no shared registration file); `-Dllamamod.editor.plugins=a.B,c.D` +adds others. A plugin that throws is skipped with a message. Built-ins install first; registering the +same id again replaces (panels, tools, actions, asset tabs, layers). + +| API | Notes | +|---|---| +| `Tool` | `id/name/icon/tooltip/shortcut/group/order`; `activate/deactivate`; `mousePressed(ToolEvent)` → true = owns the drag (else default click-select); `mouseDragged/Released/Moved`, `keyPressed`; `gizmoMode()` (null = no gizmo) + `gizmo(ctx, GizmoEvent)` + `placeGizmo(ctx)`; `drawOverlay(depthTested, onTop)` (export space, call `ctx.repaintTool()`); `optionsBar(ctx)`; `inspectorSection(ctx, sel)`; `cursor()`. Right/middle mouse and Alt always stay with the camera | +| `ToolEvent` | `x/y/shift/ctrl/alt/clickCount`, lazy `pick()` (respects layers/hidden/locked), `surface()` (geometry point), `onPlane(y)`, `ground()`, `toGame(p)` / `toExport(x, y, z)` | +| `ActionSpec` | menu path `"Tools"` or `"View/Camera"`, group/order, label, icon, `KeyStroke` string, run, `enabledWhen`, `checked` (check box item), `tip`; `x.toolbarAction(id, section)` | +| `PanelSpec` | id, title, icon, default `Dock`, order, visible by default, factory `ctx → JComponent` (created once; follow `addDocumentListener`/`selection().addListener`/`addSceneListener`) | +| `InspectorSection` | `appliesTo(sel)`, `create(ctx, sel)` (rebuilt on selection change), `replacesProperties()` | +| `RenderContributor` | `layer()`, `affectedBy(SceneEvent)`, `followsSelection()`, `build(ctx, builder)` → `builder.overlay()` lines/triangles, `builder.marker(node, x, y, z, rgba)` (pickable → selects node) | +| `NodeTransformer` | position (game cells), yaw (deg or NaN), scale (or null), `set(node, pos, yaw, scale)` → prop map. `set` must compute from the node it gets (the shell passes a drag-start snapshot) | +| `LevelTemplate` | `fill(Scene)` adds nodes directly (no undo) before the editor shows the new level | +| `LevelCheck` | adds `Problem.error/warning/info(msg, node)`; errors block Build/Play | +| `Icons` | vector icons by name; `Icons.register(name, painter)` for your own (16×16 Java2D); `Icons.swatch(colour, w, h)` | +| `Widgets`, `util.SwingListeners` | `Widgets.button(text, icon, tip, run)`, `Widgets.listCell(c, list, selected)`; `onTextChange`, `onMenuOpen` | +| `AssetDrop` | `register(kind, handler)`; `dragSource(list, value → payload(kind, data))` makes a list draggable into the viewport | +| `EditorPrefs` | `ctx.prefs()` JSON settings in `<data>/editor/settings.json`; prefix your keys | +| `NodeCopy` | duplicate/snapshot nodes (fresh ids) | +| `EditOps` (`tools`) | `placeTile`, `placeObject`, `copies`, `duplicate`, `delete`, … reusable from plugins | + +Coordinate reminder: render/viewport points are export space (z = −game z); model positions are +game space (`ToolEvent.toGame` / `toExport`). Drop/placement points: `ctx.groundAt(x, y)`, `ctx.viewCentre()`. Commands: `editor-api.md` (`Edits`, `SetProp`, `Batch` with merge key). + +### Render additions used by tools (backwards compatible, `editor.render`) + +`Viewport.setInput(ViewportInput)` (tool hook: left-button press/drag/release not on the gizmo, moves, +keys), `setToolOverlay(depthTested, onTop)`, `setScreenOverlay(pixels)` (marquee), `unproject(x, y, +depth)` public, `isFlying()`, `canvas()`; `RenderScene.overlaysChanged()`; `LevelSceneAdapter.mesh/page/collision` +public; invisible tile instances are no longer baked into chunks; `History.undoLabels()/redoLabels()`. + +## Project lifecycle + +* **New level**: dialog (name, author, base level by name with its loading picture, TestLevel + chapter = next free one, template Empty / Flat floor / Island). Templates add brushes, a Llama + (camera node 8 cells behind, as levelbuild) and a LevelDoor on a material-16 floor. +* **Open original level**: list "Village – Chapter 1" … "Lab – Chapter 5" (+ unused/front-end WADs) + with the CD loading pictures (`OriginalLevels`, `Cor`). +* **Save** (`.llamalevel`, default folder `<data>/levels` = listed as mods in LlamaMod), Save as, Open + recent (10), dirty marker "•" in the title, confirm on close/new/open. **Thumbnail**: viewport + screenshot 640×360 PNG on every save (also the TestLevel loading picture). +* **Autosave** every 90 s (Preferences) while dirty → `<data>/editor/autosave/*.llamalevel` + `.json` + (title, original file, time); deleted on save/close/discard. After a crash the next start offers + Recover / Discard / Later (verified: kill during editing → recovered with all edits). + +## Build & Play-test + +* **Check**: `Validator` + checks (no player start = error, no exit in a new level = warning, no + geometry, brushes outside the grid). Messages with node ids are double-clickable in Problems. +* **Build** (Ctrl+B): validates, snapshots the project (`ProjectIO.write`), then reads + builds the + WAD in the background (UI stays usable) → Problems: "Build OK: tiles (from brushes), objects, + waypoints, KB, time" + warnings. +* **Play** (F5): build → write the current state to `<data>/levels/<stem>.llamalevel` (if that is the + document's own file it counts as saved; otherwise the user's file stays untouched) → enable the level + mod next to the active mods (turns off other mods writing the same WAD, incl. Test World on chapter + 1), TestLevel chapter option = project chapter → `ModManager.apply` → save slot (Preferences, default + 3; backup first) unlocked up to the level if needed and map cursor on it → start the game. +* **Boot to level** (phase 3, `editor.tools.playtest`, `notes/editor-playtest.md`): Play now also + applies the exe mod `boot-to-level`, so the game loads the level directly (no menus) and Escape + quits; Shift+F5 = play from the camera position. The old menu-based run is still available + (Level → *Play boots straight into the level* off). + `-Dllamamod.editor.noLaunch=true` skips the launch (tests start the game through apptest). + +## Tests + +* `EditorShellTest` (CD needed, `-Dllamamod.cd`): drag = one undo step + world-grid snapping, rotate + about a pivot, duplicate with waypoint-chain copy + delete/undo, brush move/scale, renderer live + updates (tile move, marker move, delete/undo, waypoints of the selection), templates build brush + tiles + thumbnails render, plugin discovery, number-field arithmetic. +* `EditorTour` (test classes, not a unit test): scripted run on an X display, writes the screenshots + below. `java -cp all.jar:build/classes/java/test -Dllamamod.data=DIR -Dllamamod.editor.msaa=0 + com.eliasmueller.llamamod.editor.ui.EditorTour OUTDIR village,select,move,…` (steps: empty, recover, + village, select, move, rotate, marquee, panels, layers, build, drag, newlevel, place, save, play, + crash, dark, shortcuts, dialogs). +* **End-to-end (2026-11-06, Xvfb :8 editor, :7 game)**: new level "Editor Island" (Island template, + chapter 7) → 2 coins + checkpoint placed, checkpoint duplicated → saved → Build OK (1830 brush + tiles, 6 objects, 5.6 MB) → Play (noLaunch: mod applied, slot 3 unlocked, cursor on TestLevel 7) → + `apptest start/tolevel --world 9`: slot shows "TestLevel Kapitel 7", the island loads with trees, + coins and checkpoints, the llama walks (`img/editor/ui_e2e_game.jpg`). Drag & drop from the asset + browser into the viewport placed an object (Robot drag). Crash recovery verified. + +Screenshots (`img/editor/ui_*.jpg`): empty state, village, select+move gizmo, moved, rotate, marquee +(+ result), assets (tile models, objects, textures), level settings, layers (collision), build, drag & +drop, new island, placed objects, play-test dialog, dark theme, shortcuts, new-level and +open-original dialogs, LlamaMod header button, game. + +## Known limits / next steps + +* Rotation only about the vertical axis (rx/rz via the inspector); scale only for brushes. +* Brush preview recompiles all brushes on every brush change (fine for dozens; a large level may + need per-brush compilation). Generated brush tiles are picked by bounds heuristics. +* Tile models without tiles have no "place" preview; dropping places in the cell under the cursor + with grey vertex colours (no lighting bake for original tile models). +* Clipboard is in-memory (same window), paste offsets by one step per paste. +* The LlamaMod mod list shows a newly installed play-test level only after restarting LlamaMod + (`AppState.registry` is created once). +* Not tested on Windows hardware (the viewport was tested under Wine in phase 1). diff --git a/notes/geometry.md b/notes/geometry.md new file mode 100644 index 0000000..ae0db58 --- /dev/null +++ b/notes/geometry.md @@ -0,0 +1,203 @@ +# Level-Geometrie (TRAK / MAP / STPC / TEXT) – Ein Königreich für ein Lama (PC) + +Stand: 2026-11-06. Quelle: groove.exe (Ghidra-Dekompilat `re/groove_decomp.c`, Capstone) + +Datenanalyse aller 34 Level-WADs. Tools: `tools/geomlib.py`, `tools/geom_export.py`, +`tools/geom_import.py`, `tools/geom_demo.py`, `tools/disx86.py`. +Konfidenz: **[sicher]** = aus Code + Daten verifiziert (Round-Trip byte-identisch / Rendering), +**[wahrsch.]** = aus Daten plausibel, **[?]** = unbekannt. + +## 1. Überblick: welcher Chunk ist was + +Chunk-Dispatcher: `FUN_00558xxx` bei VA 0x55899e (Vergleich auf Tag-Konstanten, Tag als u32 LE +gelesen, z. B. 'CPTS' im File = 0x53545043). + +| Chunk | Loader (VA) | Inhalt | +|---|---|---| +| WFPC | inline → `DAT_006da330` | u32 Feature-Flags des Levels (steuern MAP/STPC-Aufbau) **[sicher]** | +| TEXT | 0x4067b0 / 0x406d30 | Texturseiten (256x256, 16 Bit) + Texturtabelle (UV-Rechtecke) **[sicher]** | +| SMPC / AMPC / SRPC | 0x558d30 / 0x558d70 / 0x545350 | Sound (SMPC: 'cvg ' VAG-Samples; AMPC: 'ambi'/'VABp'; SRPC Sound-Referenzen) – **keine Geometrie** | +| **TRAK** | 0x42aac0 → 0x5563f0 | **Welt-Geometrie**: Kachel-Modelle (Render-Dreiecke + Kollisionspolygone) → globales Array `DAT_005846ec` **[sicher]** | +| **STPC** | 0x42ab50 | Objekt-("Strat")-Modelle (gleiches Modellformat), Skelett-Animationen, danach Strat-Skriptdaten (Namen wie "SinkingStatuePad") **[sicher für Modelle]** | +| **MAP** | 0x42ac50 | Kachel-Raster: Platzierung der TRAK-Modelle + Vertexfarben + Zonen-PVS (+ Strats/Pfade/etc., siehe objects.md) **[sicher]** | +| LGHT / LNFO / LGPC | 0x42c180 / 0x42ab10 / 0x558db0 | Lichter / Level-Info / ? – nicht Geometrie | + +**Die Welt ist ein Kachelsystem (Croc-Erbe):** TRAK enthält N "Kachel"-Modelle in lokalen +Koordinaten (ungefähr −0.5..0.5 in x/z, können aber bis ±2.4 hinausragen). MAP platziert sie +auf einem W×H-Raster (1 Einheit = 1 Zelle) mit Höhe y und Drehung um y. Es gibt **keinen BSP**; +die Raumaufteilung ist das Raster selbst (Zell-Listen) + eine 32-Zonen-Sichtbarkeitstabelle. + +## 2. Koordinatenräume [sicher] + +* **Exportraum** (= MAP-Floats "A", = Vertexdaten von TRAK/STPC): rechtshändig, **y oben**, + Rasterzellen liegen bei x ∈ [0,W), **z ∈ (−H, 0]**. OBJ-Export benutzt diesen Raum. +* **Spielraum** (MAP-Fixpunkt "B", Kollision, Strats): (x, y, −z), 20.12-Fixpunkt (4096 = 1). + Der Renderer rechnet B zurück: pos = (Bx, By, −Bz)/4096, Winkel = rot·2π/4096. +* Kachel-Transformation (Vertex v lokal → Welt, Exportraum), a = rot/4096·2π: + `X = x + vx·cos a − vz·sin a ; Y = y + vy ; Z = z + vx·sin a + vz·cos a` + (verifiziert: 93 % aller Kachel-Randvertices treffen exakt einen Nachbarvertex; die drei + anderen Vorzeichen-/Spiegelvarianten liefern 65–70 %). +* **Kollisionsdaten liegen im Spielraum** (z gespiegelt gegenüber den Render-Vertices desselben + Modells). Beim Dekodieren nz negieren (92 % der rekonstruierten Kollisionsecken liegen + < 0.02 neben einem Render-Vertex). +* rx/rz der Kacheln sind in allen Levels 0; nur Drehung um y (praktisch 0/1024/2048/3072). + +## 3. Modellformat (TRAK und STPC) [sicher] + +Fixup-Funktionen: TRAK `FUN_005563f0(&ptr, n, &out)` (Header 0x84), +STPC `FUN_0041f770(&ptr, n, &out)` (Header 0x8c). Datei-Layout: **alle Header eines Aufrufs +hintereinander, dann die Daten der Modelle in Reihenfolge** (TRAK: ein Aufruf mit n=Anzahl; +STPC: n Aufrufe mit je 1 Modell → Header/Daten abwechselnd). Zeigerfelder sind in der Datei 0 +und werden beim Laden gesetzt. + +### Header + +| Off | Typ | Bedeutung | +|---|---|---| +| 0x00 | 3×f32 | Mittelpunkt der Bounding-Box | +| 0x0c | 8×3 f32 | Ecken der Box, Reihenfolge (−x−y+z)(+x−y+z)(−x+y+z)(+x+y+z)(−x−y−z)(+x−y−z)(−x+y−z)(+x+y−z). Etwas größer als die Vertices (Rand nicht konstant, ~0.004–0.014; vermutl. aus Originalmesh). Für Frustum-Culling (`FUN_00402840`). | +| 0x6c | u16 | nVerts | +| 0x6e | u16 | nFaces | +| 0x70 | u32 | → Verts (Datei: 0) | +| 0x74 | u32 | → Faces (Datei: 0) | +| 0x78 / 0x7a / 0x7c | 3×u16 | Anzahl Kollisionspolygone in 3 Gruppen (Code summiert immer alle drei; Gruppenbedeutung [?]) | +| 0x7e | u16 | 0 | +| 0x80 | u32 | → Kollision (Datei: 0) | +| *nur STPC* 0x84 | u16 | = nVerts bei starren Modellen, 0 bei Skin-Modellen | +| *nur STPC* 0x86 | u16 | nBones (Skin-Modelle) – davor in den Daten nBones×u16 = Vertexzahl je Bone (Summe = nVerts, verifiziert) | +| *nur STPC* 0x88 | u32 | → Bone-Tabelle (Datei: 0) | + +### Daten (in dieser Reihenfolge) +1. (nur STPC) nBones × u16 +2. **Vertex** 24 Byte: `f32 x,y,z ; f32 nx,ny,nz` (Normalen geglättet; Lichtwerte kommen aus MAP-Vertexfarben) +3. **Face** 28 Byte (nur Dreiecke): + `u16 flags ; u16 v0,v1,v2 ; u16 tex ; u16 0 ; f32 nx,ny,nz,d` mit n = normalize((v1−v0)×(v2−v0)), d = n·v0 + (verifiziert für alle Faces). Beim Laden wird `tex` durch Zeiger auf Texturtabelleneintrag + (`DAT_00581154 + tex*20`) ersetzt. Ebene dient dem Backface-Culling (Kamera·n − d ≥ ε). +4. **Kollisionspolygon** 32 Byte, nGruppeA+B+C Stück: + `u8 typ ; u8 material ; 5 × { i8 nx, i8 ny, i8 nz, u8 0, i16 d }` + * Ebene 0 = Fläche, Ebenen 1..4 = Kanten (Normalen **nach innen**, Punkt drin ⇔ n·p − d ≥ −Toleranz). + * Normalen ×127 (Code rechnet ×32 → 4096-Skala), d ×4096 (Fixpunkt) → lokale Koordinaten nur ±8 Einheiten! + * typ Bit0 = Viereck (4 Kanten), sonst Dreieck (3 Kanten, 5. Ebene = 0). Bit2 ≈ Normale zeigt nach unten (ny<0; Decken/Überhänge) [wahrsch.]. + * material: Untergrund-Typ (0 = normal; 0x11/0x12 werden je nach WFPC 0x8000000 ignoriert, 0x1e = Wasser-/Todesebene [wahrsch.]; Wert landet im Spieler-Bodenstatus). + * Kollisionsabfragen: `FUN_004036d0` (Boden/Decke), `FUN_00403ad0` (Wände), `FUN_004042f0`, `FUN_004046e0`. Getestet werden **nur die Kacheln der Rasterzelle, in der der Spieler steht** (Zellliste aus MAP), Punkt relativ zu Kachel-B-Position, um −rot gedreht. + +### Face-Flags (u16) +| Bit | Bedeutung | Konfidenz | +|---|---|---| +| 0x0008 | neuer Render-Batch: Renderzustand (Seite, Blendmode) aus diesem Face neu laden, vorherigen Batch zeichnen. Sortierung nach Seite/Zustand + Bit am Wechsel. | sicher (Code) | +| 0x0010, 0x0800, 0x0020 | UV-Ecken-Auswahl (s. u.) | sicher | +| 0x0400 | kein Backface-Culling (doppelseitig) | sicher | +| 0x4000 | Vertexfarben ignorieren (weiß) | sicher | +| 0x0001, 0x0002 | Sonder-Rendermodi (Sortierung/Transparenz; zusammen mit Globalflags 0x5f6ee8) | wahrsch. | +| 0x8000, 0x1000, 0x2000, 0x0200, 0x0100, 0x0004 | in Daten vorhanden, Renderer (0x556510) wertet sie nicht aus | ? (vermutl. PS1-Reste) | + +### UVs – die Engine hat KEINE freien UVs [sicher] +Jedes Dreieck zeigt eine Hälfte eines **Texturrechtecks** (Eintrag der TEXT-Tabelle). +UV-Rechteck: U0=u0/256, U1=(u1+1)/256, V0=v0/256, V1=(v1+1)/256. Mit m = Flag 0x20 (spiegelt U), +a = m?U0:U1, b = m?U1:U0 bekommen die Ecken (v0,v1,v2): +* ohne 0x800: (a,V1) (b,V1) (a,V0) +* 0x800, ohne 0x10: (a,V1) (b,V1) (b,V0) ← erste Hälfte eines Vierecks +* 0x800 + 0x10: (b,V0) (a,V0) (a,V1) ← zweite Hälfte +(Renderer `FUN_00556510` ab Zeile "if ((uVar6 & 0x800) == 0)"). Texturen mit Flag Bit0 sind +Flachfarben (u0,u1,v0 = R,G,B). 96 % der Welt-Faces haben 0x800. + +## 4. TRAK-Chunk [sicher] +`u32 n ; n × Header(0x84) ; Daten Modell 0..n−1`. Alle 34 Level-WADs parsen exakt bis +Chunkende; `geomlib.build_trak(parse_trak(x)) == x` byte-identisch für alle Levels. +Typisch 100–650 Modelle, max. 422 Verts / 798 Faces pro Modell. Modell 0 ist in vielen Levels +eine unsichtbare Ebene (4 Verts, 0 Faces, 1 Kollisionspolygon, material 0x1e) bei y=−2.75 – +die Wasser-/Absturzebene unter den Inseln. + +## 5. MAP-Chunk – Geometrieteile (Loader FUN_0042ac50) [sicher] +(Strat/Pfad/Zonen-Blöcke: siehe notes/objects.md; hier nur, was die Geometrie betrifft.) +Reihenfolge in der Datei: + +1. `u32 N (Kacheln), u32 W, u32 H` (Raster) +2. **A**: N × `{f32 x, f32 y, f32 z, u32 rot, u32 model, u32 animFlag}` (Exportraum; x,z = Zellmitte .5). + Nur zum Einsortieren in die Zelllisten benutzt: Zelle = floor(x) + floor(−z)·W. +3. u32 n + n×u32 (Liste, von Strats referenziert) +4. u32 n + n×90 Byte (Lichter/Kameras? – objects.md) +5. u32 n + n×34 Byte (Pfadknoten) +6. **Zonentabelle** 256 Byte = 32 × `{u32 Sichtmaske, i32 Höhenwert}` → `DAT_006da350` +7. **Zonenraster** W·H × u32 (Zonennummer 0..31 je Zelle) → world+0x48 +8. **B**: N × `{i32 rx, i32 ry, i32 rz, i32 x, i32 y, i32 z}` 20.12-Fixpunkt, Spielraum (z = −A.z), ry = A.rot. + **Wird zum Rendern und für Kollision benutzt.** A und B sind in allen Levels konsistent. +9. **Modellindex**: N × u32 (= A.model) +10. falls WFPC & 0x10000: `u32 n ; n × {u32 cellX, u32 y, u32 cellZ, u32 modelA, u32 modelB}` – + Kachel-Wechsel (Türen/Brücken o. Ä.), Eintrag k gehört zur k-ten Kachel mit animFlag≠0. +11. **Vertexfarben** je Kachel i: nVerts(model) × u32 RGBA (0x80 = 1.0, Spiel ×2 mit Clamp); + Byte3 des ersten Eintrags = Anzahl Farbsätze (0/1 = einer; ≥2 → weitere nVerts×u32 folgen, + zweiter Satz wird bei `DAT_005865d4==1` benutzt). Für animFlag-Kacheln danach dieselbe + Struktur für modelB. Zeigertabelle world+0x58 (bei WFPC 0x10000000 doppelt groß). +12. Rest (u32 n2,n3 + 58-Byte-Strat-Einträge, optional Pfade (0x200000), Sichtweite (0x10), u16) – objects.md. + +`geomlib.build_map_tiles(..., zones='keep', anim=orig)` reproduziert alle MAP-Chunks byte-identisch. + +### Raster / Sichtbarkeit (kein BSP) [sicher] +* Zelllisten: world+0x40 (W·H Zeiger, verkettete Listen von Kachelindizes), aus A gebaut. +* Rendern (`FUN_0042bf40`): Rechteck um die Kamera (Sichtweite) über das Raster; für jede Zelle + alle Kacheln der Liste, aber **nur wenn Bit zone[Zelle] in der Maske der Kamerazone gesetzt ist** + (`DAT_0058642c` = Zonentabelle[zone[Kamerazelle]].maske, `FUN_0041e020`). Zone 0 hat im + Dorf-Level Maske 0x1. → Neue Geometrie muss passende Zonen bekommen, sonst unsichtbar. + `geom_import --zones flat` setzt Raster=0 und Maske(Zone 0)=0xffffffff (alles sichtbar). +* Kollision: nur Kacheln der Spielerzelle (s. o.). + +## 6. STPC-Chunk (Objekte) [sicher bis Animationen] +`u32 n1 ; n1 × (Header 0x8c + Daten)` statische/geskinnte Objektmodelle (33 im Dorf) ; +`u32 n2 ; n2 × Animation` (`FUN_0041f8b0`, Header 32 Byte: +0 nA (nA×4 Byte), +8 nFrames, ++0xc (0 → nFrames×8 Byte), +0x10 nDelta (Frame 0: nDelta×8, sonst nDelta×2 (+2 wenn ungerade) +Byte komprimierte Vertexdeltas), +0x18 nBones (je Frame nBones×64 Byte = 4×4-Matrix)) ; +falls WFPC & 0x100: `u32 n3 ; n3 × {16-Byte-Kopf (+4 Anzahl, +8 → n×8 Byte), Modelle}` ; +danach Strat-Daten (Skripte, Namen) – nicht vom Lader geparst, über Offsets (`DAT_006d9dbc+off`) +aus MAP referenziert. `geom_export --objects` exportiert die n1 Modelle nebeneinander. + +## 7. TEXT – soweit für Geometrie nötig [sicher] +`u32 nPal ; nPal×{u32, u32 n, n×u16, n×u8}` ; `u32 nPages ; nPages×{u32 flags, u32 w, u32 h, +(flags&0x80: u32 len + RLE16) | (flags≠0: w·h·2) | w·h}` ; `u32 nTex ; nTex × 8 Byte` ; Rest (40 Byte im Dorf) [?]. +Textureintrag: `u16 flags, i8 page, i8 page2(-1), u8 u0, u8 u1, u8 v0, u8 v1` (u1/v1 inklusiv). +Flags: Bit0 Flachfarbe, Bit2 1-Pixel-Rand, Bit3/Bit4 Breite/Höhe ×2, Bits 12–14 Blendmodus, Bits 8–10 weiterer Modus. +Pixel: ABGR1555 (R = Bits 0–4), 0 = transparent. Details: notes/textures.md (anderer Agent). + +## 8. Neue Geometrie schreiben – was konsistent sein muss +Umgesetzt in `tools/geom_import.py` (OBJ → TRAK + MAP-Kachelteil, Rest des WADs unverändert): +1. **TRAK**: u32 Anzahl; Header mit korrekten nVerts/nFaces/nKoll; Box+Mittelpunkt (für Culling, + wird neu berechnet); Face-Ebenen aus Vertices; Vertexnormalen. Keine Prüfsummen. +2. **Face-Texturindex** < Anzahl Texturtabelleneinträge; UVs nur über Rechteck-Ecken (+Flags). + Bit 0x8 am Batch-Anfang (Seiten-/Zustandswechsel, spätestens alle 256 Faces). +3. **Limits**: ≤ 8000 Vertices pro Modell (Transformpuffer 0x58b0f0..0x5d15f8, 36 Byte/Vertex), + Batch-Ausgabepuffer 0x5d15f8 ≈ 4800 Vertices; Kollisions-d als i16/4096 → Kollisionspolygone + nur innerhalb ±8 Einheiten vom Kachelursprung. u16-Indizes. +4. **MAP**: N in Kopf, A (Floats) und B (Fixpunkt, z negiert) identisch halten, Modellindizes + < TRAK-Anzahl, Kachelzellen innerhalb W×H, **Vertexfarben-Block exakt nVerts(Modell) pro + Kachel** (sonst verschiebt sich der Rest von MAP = Strats!), Animationsblock (WFPC 0x10000) + passend zu den Flags (Import setzt ihn auf 0 Einträge), Zonen (s. o.). +5. Kollision gehört zur Zelle: ein Polygon wirkt nur, wenn es in der Kachelliste der Zelle steht, + in der der Spieler steht → Import trägt jedes Polygon in jede überdeckte Zelle ein + (auch als reine Kollisionskachel ohne Faces, wie Original-Modell 0). +6. WAD-Gesamtlänge (wadtool/geomlib schreiben sie neu). Keine weiteren Prüfsummen gefunden. +7. Strats (Startpunkt, Gegner, Kamera-Pfade) liegen weiter an ihren alten Positionen (objects.md). + +### Tests (ohne Spielstart) +* Round-Trip TRAK und MAP aller 34 WADs byte-identisch. +* Export → Import (`--coll-obj`) → Export t1l1m001: Rendering identisch (img/), 26435/26442 + Dreiecke (7 entartete fallen weg), Bodenhöhe an 2800 Zufallspunkten: 99 % < 5 mm Abweichung, + 10 Punkte anders (Polygone jetzt auch in Nachbarzellen eingetragen). +* Unabhängiger Gegencheck: `tools/mapchunk.py` (anderer Agent) parst die erzeugten MAPs vollständig. +* Noch NICHT im Spiel getestet: Demo-WADs in $CLAUDE_JOB_DIR/tmp: `demo_add.wad` (Original + + 2 Blöcke + Rampe, Zonen original), `demo.wad` (alles ersetzt durch Bodenplatte y=0 + Blöcke). + +## 9. Offen / unbekannt +* Bedeutung der drei Kollisionsgruppen (Summe wird immer zusammen benutzt) und vollständige Materialliste. +* Face-Flags 0x8000/0x1000/0x2000/0x200/0x100/0x4; genaue Wirkung 0x1/0x2. +* Exakte Herkunft des Box-Randes im Header (Neu-Berechnung mit 1/128 Rand reicht vermutlich). +* Zonentabelle 2. Wert (Höhe, `DAT_00586424`) – evtl. Wasser-/Nebelhöhe. +* STPC-Animation: Bedeutung von nA-Block und Delta-Kompression im Detail (`FUN_0041fa30`). +* Ob der Spieler-Startpunkt/Kamera an Geometrie gekoppelt ist (objects.md). + +## 10. Werkzeuge +* `geom_export.py WAD OUT [--collision] [--objects]` → level.obj/mtl (UV, Vertexfarben, ein + Objekt pro Kachel), page_NN.png, collision.obj (Gruppen matNN_grpG), objects.obj, preview_top/persp.png + (texturierter Software-Renderer). +* `geom_import.py BASIS.wad MESH.obj NEU.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N]` +* `geom_demo.py BASIS.wad demo.obj [--plaza]` – Beispielgeometrie aus Texturkachel-Vierecken. +* `geomlib.py` – Parser/Builder (TRAK, STPC-Modelle, MAP-Kachelteil, TEXT-Tabelle/Seiten, Kollision). diff --git a/notes/img/app/av-warning.png b/notes/img/app/av-warning.png new file mode 100644 index 0000000..77822cc Binary files /dev/null and b/notes/img/app/av-warning.png differ diff --git a/notes/img/app/game-testlevel.jpg b/notes/img/app/game-testlevel.jpg new file mode 100644 index 0000000..f27d408 Binary files /dev/null and b/notes/img/app/game-testlevel.jpg differ diff --git a/notes/img/app/mods.png b/notes/img/app/mods.png new file mode 100644 index 0000000..c6b78aa Binary files /dev/null and b/notes/img/app/mods.png differ diff --git a/notes/img/app/saves-dark.png b/notes/img/app/saves-dark.png new file mode 100644 index 0000000..0229828 Binary files /dev/null and b/notes/img/app/saves-dark.png differ diff --git a/notes/img/app/ux_av_linux_before.jpg b/notes/img/app/ux_av_linux_before.jpg new file mode 100644 index 0000000..e8bd753 Binary files /dev/null and b/notes/img/app/ux_av_linux_before.jpg differ diff --git a/notes/img/app/ux_crash_after.jpg b/notes/img/app/ux_crash_after.jpg new file mode 100644 index 0000000..1d081f4 Binary files /dev/null and b/notes/img/app/ux_crash_after.jpg differ diff --git a/notes/img/app/ux_dark_after.jpg b/notes/img/app/ux_dark_after.jpg new file mode 100644 index 0000000..5b682ff Binary files /dev/null and b/notes/img/app/ux_dark_after.jpg differ diff --git a/notes/img/app/ux_dark_before.jpg b/notes/img/app/ux_dark_before.jpg new file mode 100644 index 0000000..d542b28 Binary files /dev/null and b/notes/img/app/ux_dark_before.jpg differ diff --git a/notes/img/app/ux_exe_after.jpg b/notes/img/app/ux_exe_after.jpg new file mode 100644 index 0000000..aa67bba Binary files /dev/null and b/notes/img/app/ux_exe_after.jpg differ diff --git a/notes/img/app/ux_exe_before.jpg b/notes/img/app/ux_exe_before.jpg new file mode 100644 index 0000000..8c00563 Binary files /dev/null and b/notes/img/app/ux_exe_before.jpg differ diff --git a/notes/img/app/ux_mods_after.jpg b/notes/img/app/ux_mods_after.jpg new file mode 100644 index 0000000..a1d5a29 Binary files /dev/null and b/notes/img/app/ux_mods_after.jpg differ diff --git a/notes/img/app/ux_mods_before.jpg b/notes/img/app/ux_mods_before.jpg new file mode 100644 index 0000000..d40a036 Binary files /dev/null and b/notes/img/app/ux_mods_before.jpg differ diff --git a/notes/img/app/ux_quit_after.jpg b/notes/img/app/ux_quit_after.jpg new file mode 100644 index 0000000..2076aaf Binary files /dev/null and b/notes/img/app/ux_quit_after.jpg differ diff --git a/notes/img/app/ux_saves_after.jpg b/notes/img/app/ux_saves_after.jpg new file mode 100644 index 0000000..8b46833 Binary files /dev/null and b/notes/img/app/ux_saves_after.jpg differ diff --git a/notes/img/app/ux_saves_before.jpg b/notes/img/app/ux_saves_before.jpg new file mode 100644 index 0000000..e7ce889 Binary files /dev/null and b/notes/img/app/ux_saves_before.jpg differ diff --git a/notes/img/app/ux_settings_after.jpg b/notes/img/app/ux_settings_after.jpg new file mode 100644 index 0000000..a249982 Binary files /dev/null and b/notes/img/app/ux_settings_after.jpg differ diff --git a/notes/img/app/ux_settings_before.jpg b/notes/img/app/ux_settings_before.jpg new file mode 100644 index 0000000..95de73e Binary files /dev/null and b/notes/img/app/ux_settings_before.jpg differ diff --git a/notes/img/app/ux_start_after.jpg b/notes/img/app/ux_start_after.jpg new file mode 100644 index 0000000..f8aaf23 Binary files /dev/null and b/notes/img/app/ux_start_after.jpg differ diff --git a/notes/img/app/ux_start_before.jpg b/notes/img/app/ux_start_before.jpg new file mode 100644 index 0000000..0a8b4f7 Binary files /dev/null and b/notes/img/app/ux_start_before.jpg differ diff --git a/notes/img/app/ux_wizard_after.jpg b/notes/img/app/ux_wizard_after.jpg new file mode 100644 index 0000000..d8e521e Binary files /dev/null and b/notes/img/app/ux_wizard_after.jpg differ diff --git a/notes/img/app/ux_wizard_before.jpg b/notes/img/app/ux_wizard_before.jpg new file mode 100644 index 0000000..ca5b4d1 Binary files /dev/null and b/notes/img/app/ux_wizard_before.jpg differ diff --git a/notes/img/app/windows-build-under-wine.png b/notes/img/app/windows-build-under-wine.png new file mode 100644 index 0000000..3ff548b Binary files /dev/null and b/notes/img/app/windows-build-under-wine.png differ diff --git a/notes/img/app/wizard.png b/notes/img/app/wizard.png new file mode 100644 index 0000000..57768a4 Binary files /dev/null and b/notes/img/app/wizard.png differ diff --git a/notes/img/debug/00_montage.jpg b/notes/img/debug/00_montage.jpg new file mode 100644 index 0000000..9c123cc Binary files /dev/null and b/notes/img/debug/00_montage.jpg differ diff --git a/notes/img/debug/01_t1l1m003.jpg b/notes/img/debug/01_t1l1m003.jpg new file mode 100644 index 0000000..9614d1b Binary files /dev/null and b/notes/img/debug/01_t1l1m003.jpg differ diff --git a/notes/img/debug/02_t1l2m001.jpg b/notes/img/debug/02_t1l2m001.jpg new file mode 100644 index 0000000..f3fb92f Binary files /dev/null and b/notes/img/debug/02_t1l2m001.jpg differ diff --git a/notes/img/debug/03_t1l3m001.jpg b/notes/img/debug/03_t1l3m001.jpg new file mode 100644 index 0000000..145170d Binary files /dev/null and b/notes/img/debug/03_t1l3m001.jpg differ diff --git a/notes/img/debug/04_t1l4m001.jpg b/notes/img/debug/04_t1l4m001.jpg new file mode 100644 index 0000000..16d964f Binary files /dev/null and b/notes/img/debug/04_t1l4m001.jpg differ diff --git a/notes/img/debug/05_t1l5m001.jpg b/notes/img/debug/05_t1l5m001.jpg new file mode 100644 index 0000000..e6475f1 Binary files /dev/null and b/notes/img/debug/05_t1l5m001.jpg differ diff --git a/notes/img/debug/06_t1l6m001.jpg b/notes/img/debug/06_t1l6m001.jpg new file mode 100644 index 0000000..3e0a55c Binary files /dev/null and b/notes/img/debug/06_t1l6m001.jpg differ diff --git a/notes/img/demo_add.png b/notes/img/demo_add.png new file mode 100644 index 0000000..7926454 Binary files /dev/null and b/notes/img/demo_add.png differ diff --git a/notes/img/demo_plaza.png b/notes/img/demo_plaza.png new file mode 100644 index 0000000..35d467d Binary files /dev/null and b/notes/img/demo_plaza.png differ diff --git a/notes/img/editor/00_montage.jpg b/notes/img/editor/00_montage.jpg new file mode 100644 index 0000000..200eec3 Binary files /dev/null and b/notes/img/editor/00_montage.jpg differ diff --git a/notes/img/editor/01_village.jpg b/notes/img/editor/01_village.jpg new file mode 100644 index 0000000..0ce3f23 Binary files /dev/null and b/notes/img/editor/01_village.jpg differ diff --git a/notes/img/editor/02_village_collision.jpg b/notes/img/editor/02_village_collision.jpg new file mode 100644 index 0000000..de2fa89 Binary files /dev/null and b/notes/img/editor/02_village_collision.jpg differ diff --git a/notes/img/editor/03_river.jpg b/notes/img/editor/03_river.jpg new file mode 100644 index 0000000..9673b21 Binary files /dev/null and b/notes/img/editor/03_river.jpg differ diff --git a/notes/img/editor/04_city.jpg b/notes/img/editor/04_city.jpg new file mode 100644 index 0000000..b9f8790 Binary files /dev/null and b/notes/img/editor/04_city.jpg differ diff --git a/notes/img/editor/05_city_collision.jpg b/notes/img/editor/05_city_collision.jpg new file mode 100644 index 0000000..cc03b34 Binary files /dev/null and b/notes/img/editor/05_city_collision.jpg differ diff --git a/notes/img/editor/06_testwelt.jpg b/notes/img/editor/06_testwelt.jpg new file mode 100644 index 0000000..b2f09a3 Binary files /dev/null and b/notes/img/editor/06_testwelt.jpg differ diff --git a/notes/img/editor/07_testwelt_collision.jpg b/notes/img/editor/07_testwelt_collision.jpg new file mode 100644 index 0000000..e84a05a Binary files /dev/null and b/notes/img/editor/07_testwelt_collision.jpg differ diff --git a/notes/img/editor/08_top_river.jpg b/notes/img/editor/08_top_river.jpg new file mode 100644 index 0000000..3ea0840 Binary files /dev/null and b/notes/img/editor/08_top_river.jpg differ diff --git a/notes/img/editor/09_top_village_collision.jpg b/notes/img/editor/09_top_village_collision.jpg new file mode 100644 index 0000000..38b7998 Binary files /dev/null and b/notes/img/editor/09_top_village_collision.jpg differ diff --git a/notes/img/editor/10_gizmo_translate.jpg b/notes/img/editor/10_gizmo_translate.jpg new file mode 100644 index 0000000..c1e7cd4 Binary files /dev/null and b/notes/img/editor/10_gizmo_translate.jpg differ diff --git a/notes/img/editor/11_gizmo_rotate.jpg b/notes/img/editor/11_gizmo_rotate.jpg new file mode 100644 index 0000000..52b368f Binary files /dev/null and b/notes/img/editor/11_gizmo_rotate.jpg differ diff --git a/notes/img/editor/12_viewer_ui.jpg b/notes/img/editor/12_viewer_ui.jpg new file mode 100644 index 0000000..d484cee Binary files /dev/null and b/notes/img/editor/12_viewer_ui.jpg differ diff --git a/notes/img/editor/cull_backfaces.jpg b/notes/img/editor/cull_backfaces.jpg new file mode 100644 index 0000000..3814860 Binary files /dev/null and b/notes/img/editor/cull_backfaces.jpg differ diff --git a/notes/img/editor/cull_nav_orbit.jpg b/notes/img/editor/cull_nav_orbit.jpg new file mode 100644 index 0000000..22ece55 Binary files /dev/null and b/notes/img/editor/cull_nav_orbit.jpg differ diff --git a/notes/img/editor/cull_nav_pan.jpg b/notes/img/editor/cull_nav_pan.jpg new file mode 100644 index 0000000..f020074 Binary files /dev/null and b/notes/img/editor/cull_nav_pan.jpg differ diff --git a/notes/img/editor/cull_nav_prefs.jpg b/notes/img/editor/cull_nav_prefs.jpg new file mode 100644 index 0000000..6b6bd36 Binary files /dev/null and b/notes/img/editor/cull_nav_prefs.jpg differ diff --git a/notes/img/editor/cull_nav_zoom.jpg b/notes/img/editor/cull_nav_zoom.jpg new file mode 100644 index 0000000..2c2c1a5 Binary files /dev/null and b/notes/img/editor/cull_nav_zoom.jpg differ diff --git a/notes/img/editor/cull_overview.jpg b/notes/img/editor/cull_overview.jpg new file mode 100644 index 0000000..40ccd55 Binary files /dev/null and b/notes/img/editor/cull_overview.jpg differ diff --git a/notes/img/editor/cull_root_fogclip.jpg b/notes/img/editor/cull_root_fogclip.jpg new file mode 100644 index 0000000..d642e0e Binary files /dev/null and b/notes/img/editor/cull_root_fogclip.jpg differ diff --git a/notes/img/editor/env_bake_night.jpg b/notes/img/editor/env_bake_night.jpg new file mode 100644 index 0000000..d4a53ee Binary files /dev/null and b/notes/img/editor/env_bake_night.jpg differ diff --git a/notes/img/editor/env_fog_zoom.jpg b/notes/img/editor/env_fog_zoom.jpg new file mode 100644 index 0000000..a25f930 Binary files /dev/null and b/notes/img/editor/env_fog_zoom.jpg differ diff --git a/notes/img/editor/env_game_fog.jpg b/notes/img/editor/env_game_fog.jpg new file mode 100644 index 0000000..111ab73 Binary files /dev/null and b/notes/img/editor/env_game_fog.jpg differ diff --git a/notes/img/editor/env_game_night.jpg b/notes/img/editor/env_game_night.jpg new file mode 100644 index 0000000..00aa757 Binary files /dev/null and b/notes/img/editor/env_game_night.jpg differ diff --git a/notes/img/editor/env_game_regions.jpg b/notes/img/editor/env_game_regions.jpg new file mode 100644 index 0000000..2eb46eb Binary files /dev/null and b/notes/img/editor/env_game_regions.jpg differ diff --git a/notes/img/editor/env_game_teleport.jpg b/notes/img/editor/env_game_teleport.jpg new file mode 100644 index 0000000..b54f619 Binary files /dev/null and b/notes/img/editor/env_game_teleport.jpg differ diff --git a/notes/img/editor/env_lights.jpg b/notes/img/editor/env_lights.jpg new file mode 100644 index 0000000..0c845d3 Binary files /dev/null and b/notes/img/editor/env_lights.jpg differ diff --git a/notes/img/editor/env_paint.jpg b/notes/img/editor/env_paint.jpg new file mode 100644 index 0000000..6b69df4 Binary files /dev/null and b/notes/img/editor/env_paint.jpg differ diff --git a/notes/img/editor/env_problems.jpg b/notes/img/editor/env_problems.jpg new file mode 100644 index 0000000..2945ccf Binary files /dev/null and b/notes/img/editor/env_problems.jpg differ diff --git a/notes/img/editor/env_region_preview.jpg b/notes/img/editor/env_region_preview.jpg new file mode 100644 index 0000000..1b2da70 Binary files /dev/null and b/notes/img/editor/env_region_preview.jpg differ diff --git a/notes/img/editor/env_regions.jpg b/notes/img/editor/env_regions.jpg new file mode 100644 index 0000000..8bf5757 Binary files /dev/null and b/notes/img/editor/env_regions.jpg differ diff --git a/notes/img/editor/env_settings.jpg b/notes/img/editor/env_settings.jpg new file mode 100644 index 0000000..785c328 Binary files /dev/null and b/notes/img/editor/env_settings.jpg differ diff --git a/notes/img/editor/env_zone.jpg b/notes/img/editor/env_zone.jpg new file mode 100644 index 0000000..5bae61f Binary files /dev/null and b/notes/img/editor/env_zone.jpg differ diff --git a/notes/img/editor/exit_village_swap.jpg b/notes/img/editor/exit_village_swap.jpg new file mode 100644 index 0000000..05d51b3 Binary files /dev/null and b/notes/img/editor/exit_village_swap.jpg differ diff --git a/notes/img/editor/fix_compare_back.jpg b/notes/img/editor/fix_compare_back.jpg new file mode 100644 index 0000000..adbdcf5 Binary files /dev/null and b/notes/img/editor/fix_compare_back.jpg differ diff --git a/notes/img/editor/fix_compare_start.jpg b/notes/img/editor/fix_compare_start.jpg new file mode 100644 index 0000000..492e3cb Binary files /dev/null and b/notes/img/editor/fix_compare_start.jpg differ diff --git a/notes/img/editor/fix_compare_top.jpg b/notes/img/editor/fix_compare_top.jpg new file mode 100644 index 0000000..f6bc6b8 Binary files /dev/null and b/notes/img/editor/fix_compare_top.jpg differ diff --git a/notes/img/editor/fix_compare_yaw.jpg b/notes/img/editor/fix_compare_yaw.jpg new file mode 100644 index 0000000..efd2f60 Binary files /dev/null and b/notes/img/editor/fix_compare_yaw.jpg differ diff --git a/notes/img/editor/geo_build_ok.jpg b/notes/img/editor/geo_build_ok.jpg new file mode 100644 index 0000000..92df26c Binary files /dev/null and b/notes/img/editor/geo_build_ok.jpg differ diff --git a/notes/img/editor/geo_collision.jpg b/notes/img/editor/geo_collision.jpg new file mode 100644 index 0000000..af89739 Binary files /dev/null and b/notes/img/editor/geo_collision.jpg differ diff --git a/notes/img/editor/geo_draw.jpg b/notes/img/editor/geo_draw.jpg new file mode 100644 index 0000000..5827036 Binary files /dev/null and b/notes/img/editor/geo_draw.jpg differ diff --git a/notes/img/editor/geo_game_course.jpg b/notes/img/editor/geo_game_course.jpg new file mode 100644 index 0000000..c6a7659 Binary files /dev/null and b/notes/img/editor/geo_game_course.jpg differ diff --git a/notes/img/editor/geo_game_death.jpg b/notes/img/editor/geo_game_death.jpg new file mode 100644 index 0000000..c7d7baa Binary files /dev/null and b/notes/img/editor/geo_game_death.jpg differ diff --git a/notes/img/editor/geo_game_invisible_wall.jpg b/notes/img/editor/geo_game_invisible_wall.jpg new file mode 100644 index 0000000..fcd4089 Binary files /dev/null and b/notes/img/editor/geo_game_invisible_wall.jpg differ diff --git a/notes/img/editor/geo_game_library.jpg b/notes/img/editor/geo_game_library.jpg new file mode 100644 index 0000000..6acfe2c Binary files /dev/null and b/notes/img/editor/geo_game_library.jpg differ diff --git a/notes/img/editor/geo_handles.jpg b/notes/img/editor/geo_handles.jpg new file mode 100644 index 0000000..255c182 Binary files /dev/null and b/notes/img/editor/geo_handles.jpg differ diff --git a/notes/img/editor/geo_import.jpg b/notes/img/editor/geo_import.jpg new file mode 100644 index 0000000..b35d9fa Binary files /dev/null and b/notes/img/editor/geo_import.jpg differ diff --git a/notes/img/editor/geo_library.jpg b/notes/img/editor/geo_library.jpg new file mode 100644 index 0000000..95d6772 Binary files /dev/null and b/notes/img/editor/geo_library.jpg differ diff --git a/notes/img/editor/geo_library_insert.jpg b/notes/img/editor/geo_library_insert.jpg new file mode 100644 index 0000000..64ef443 Binary files /dev/null and b/notes/img/editor/geo_library_insert.jpg differ diff --git a/notes/img/editor/geo_menu.jpg b/notes/img/editor/geo_menu.jpg new file mode 100644 index 0000000..9816e59 Binary files /dev/null and b/notes/img/editor/geo_menu.jpg differ diff --git a/notes/img/editor/geo_placed.jpg b/notes/img/editor/geo_placed.jpg new file mode 100644 index 0000000..cbeac90 Binary files /dev/null and b/notes/img/editor/geo_placed.jpg differ diff --git a/notes/img/editor/geo_rotate3d.jpg b/notes/img/editor/geo_rotate3d.jpg new file mode 100644 index 0000000..21606d1 Binary files /dev/null and b/notes/img/editor/geo_rotate3d.jpg differ diff --git a/notes/img/editor/geo_tiles.jpg b/notes/img/editor/geo_tiles.jpg new file mode 100644 index 0000000..8487be5 Binary files /dev/null and b/notes/img/editor/geo_tiles.jpg differ diff --git a/notes/img/editor/help_guide.jpg b/notes/img/editor/help_guide.jpg new file mode 100644 index 0000000..b6b2419 Binary files /dev/null and b/notes/img/editor/help_guide.jpg differ diff --git a/notes/img/editor/help_sheet.jpg b/notes/img/editor/help_sheet.jpg new file mode 100644 index 0000000..028324f Binary files /dev/null and b/notes/img/editor/help_sheet.jpg differ diff --git a/notes/img/editor/manual_chapter.jpg b/notes/img/editor/manual_chapter.jpg new file mode 100644 index 0000000..4feee20 Binary files /dev/null and b/notes/img/editor/manual_chapter.jpg differ diff --git a/notes/img/editor/manual_context.jpg b/notes/img/editor/manual_context.jpg new file mode 100644 index 0000000..1696082 Binary files /dev/null and b/notes/img/editor/manual_context.jpg differ diff --git a/notes/img/editor/manual_dark.jpg b/notes/img/editor/manual_dark.jpg new file mode 100644 index 0000000..da337f2 Binary files /dev/null and b/notes/img/editor/manual_dark.jpg differ diff --git a/notes/img/editor/manual_home.jpg b/notes/img/editor/manual_home.jpg new file mode 100644 index 0000000..60188d6 Binary files /dev/null and b/notes/img/editor/manual_home.jpg differ diff --git a/notes/img/editor/manual_search.jpg b/notes/img/editor/manual_search.jpg new file mode 100644 index 0000000..3269e1d Binary files /dev/null and b/notes/img/editor/manual_search.jpg differ diff --git a/notes/img/editor/new_level_clean.jpg b/notes/img/editor/new_level_clean.jpg new file mode 100644 index 0000000..eb6ab63 Binary files /dev/null and b/notes/img/editor/new_level_clean.jpg differ diff --git a/notes/img/editor/obj_beetle_path.jpg b/notes/img/editor/obj_beetle_path.jpg new file mode 100644 index 0000000..f97bac5 Binary files /dev/null and b/notes/img/editor/obj_beetle_path.jpg differ diff --git a/notes/img/editor/obj_camera_zone.jpg b/notes/img/editor/obj_camera_zone.jpg new file mode 100644 index 0000000..79a85c3 Binary files /dev/null and b/notes/img/editor/obj_camera_zone.jpg differ diff --git a/notes/img/editor/obj_coin_arc.jpg b/notes/img/editor/obj_coin_arc.jpg new file mode 100644 index 0000000..23d4507 Binary files /dev/null and b/notes/img/editor/obj_coin_arc.jpg differ diff --git a/notes/img/editor/obj_cutscene_frusta.jpg b/notes/img/editor/obj_cutscene_frusta.jpg new file mode 100644 index 0000000..9a28d51 Binary files /dev/null and b/notes/img/editor/obj_cutscene_frusta.jpg differ diff --git a/notes/img/editor/obj_exit_inspector.jpg b/notes/img/editor/obj_exit_inspector.jpg new file mode 100644 index 0000000..bbca2e6 Binary files /dev/null and b/notes/img/editor/obj_exit_inspector.jpg differ diff --git a/notes/img/editor/obj_game_camera_zone.jpg b/notes/img/editor/obj_game_camera_zone.jpg new file mode 100644 index 0000000..77e69b3 Binary files /dev/null and b/notes/img/editor/obj_game_camera_zone.jpg differ diff --git a/notes/img/editor/obj_game_checkpoint.jpg b/notes/img/editor/obj_game_checkpoint.jpg new file mode 100644 index 0000000..68b18c0 Binary files /dev/null and b/notes/img/editor/obj_game_checkpoint.jpg differ diff --git a/notes/img/editor/obj_game_coins.jpg b/notes/img/editor/obj_game_coins.jpg new file mode 100644 index 0000000..73dce84 Binary files /dev/null and b/notes/img/editor/obj_game_coins.jpg differ diff --git a/notes/img/editor/obj_game_exit.jpg b/notes/img/editor/obj_game_exit.jpg new file mode 100644 index 0000000..c56b9e1 Binary files /dev/null and b/notes/img/editor/obj_game_exit.jpg differ diff --git a/notes/img/editor/obj_game_patrol.jpg b/notes/img/editor/obj_game_patrol.jpg new file mode 100644 index 0000000..d99a713 Binary files /dev/null and b/notes/img/editor/obj_game_patrol.jpg differ diff --git a/notes/img/editor/obj_newlevel_camera.jpg b/notes/img/editor/obj_newlevel_camera.jpg new file mode 100644 index 0000000..f51cb49 Binary files /dev/null and b/notes/img/editor/obj_newlevel_camera.jpg differ diff --git a/notes/img/editor/obj_newlevel_path.jpg b/notes/img/editor/obj_newlevel_path.jpg new file mode 100644 index 0000000..a403007 Binary files /dev/null and b/notes/img/editor/obj_newlevel_path.jpg differ diff --git a/notes/img/editor/obj_newlevel_saved.jpg b/notes/img/editor/obj_newlevel_saved.jpg new file mode 100644 index 0000000..f8f78ef Binary files /dev/null and b/notes/img/editor/obj_newlevel_saved.jpg differ diff --git a/notes/img/editor/obj_pilot.jpg b/notes/img/editor/obj_pilot.jpg new file mode 100644 index 0000000..47a1ee1 Binary files /dev/null and b/notes/img/editor/obj_pilot.jpg differ diff --git a/notes/img/editor/obj_place.jpg b/notes/img/editor/obj_place.jpg new file mode 100644 index 0000000..1599508 Binary files /dev/null and b/notes/img/editor/obj_place.jpg differ diff --git a/notes/img/editor/obj_problems.jpg b/notes/img/editor/obj_problems.jpg new file mode 100644 index 0000000..4f73dc6 Binary files /dev/null and b/notes/img/editor/obj_problems.jpg differ diff --git a/notes/img/editor/obj_trigger_radius.jpg b/notes/img/editor/obj_trigger_radius.jpg new file mode 100644 index 0000000..aadd032 Binary files /dev/null and b/notes/img/editor/obj_trigger_radius.jpg differ diff --git a/notes/img/editor/obj_zone_handles.jpg b/notes/img/editor/obj_zone_handles.jpg new file mode 100644 index 0000000..35bbd3f Binary files /dev/null and b/notes/img/editor/obj_zone_handles.jpg differ diff --git a/notes/img/editor/play_boot.jpg b/notes/img/editor/play_boot.jpg new file mode 100644 index 0000000..acb51b6 Binary files /dev/null and b/notes/img/editor/play_boot.jpg differ diff --git a/notes/img/editor/play_editor.jpg b/notes/img/editor/play_editor.jpg new file mode 100644 index 0000000..1962de3 Binary files /dev/null and b/notes/img/editor/play_editor.jpg differ diff --git a/notes/img/editor/play_from_here.jpg b/notes/img/editor/play_from_here.jpg new file mode 100644 index 0000000..4645008 Binary files /dev/null and b/notes/img/editor/play_from_here.jpg differ diff --git a/notes/img/editor/play_windows_editor.jpg b/notes/img/editor/play_windows_editor.jpg new file mode 100644 index 0000000..30ccd98 Binary files /dev/null and b/notes/img/editor/play_windows_editor.jpg differ diff --git a/notes/img/editor/tex_anim.jpg b/notes/img/editor/tex_anim.jpg new file mode 100644 index 0000000..86040f6 Binary files /dev/null and b/notes/img/editor/tex_anim.jpg differ diff --git a/notes/img/editor/tex_game.jpg b/notes/img/editor/tex_game.jpg new file mode 100644 index 0000000..957b684 Binary files /dev/null and b/notes/img/editor/tex_game.jpg differ diff --git a/notes/img/editor/tex_import.jpg b/notes/img/editor/tex_import.jpg new file mode 100644 index 0000000..d16d983 Binary files /dev/null and b/notes/img/editor/tex_import.jpg differ diff --git a/notes/img/editor/tex_manager.jpg b/notes/img/editor/tex_manager.jpg new file mode 100644 index 0000000..90607d3 Binary files /dev/null and b/notes/img/editor/tex_manager.jpg differ diff --git a/notes/img/editor/tex_paint.jpg b/notes/img/editor/tex_paint.jpg new file mode 100644 index 0000000..65ee9ec Binary files /dev/null and b/notes/img/editor/tex_paint.jpg differ diff --git a/notes/img/editor/ui_assets_models.jpg b/notes/img/editor/ui_assets_models.jpg new file mode 100644 index 0000000..47c9f9b Binary files /dev/null and b/notes/img/editor/ui_assets_models.jpg differ diff --git a/notes/img/editor/ui_assets_objects.jpg b/notes/img/editor/ui_assets_objects.jpg new file mode 100644 index 0000000..fa88e3e Binary files /dev/null and b/notes/img/editor/ui_assets_objects.jpg differ diff --git a/notes/img/editor/ui_assets_textures.jpg b/notes/img/editor/ui_assets_textures.jpg new file mode 100644 index 0000000..c04227f Binary files /dev/null and b/notes/img/editor/ui_assets_textures.jpg differ diff --git a/notes/img/editor/ui_build.jpg b/notes/img/editor/ui_build.jpg new file mode 100644 index 0000000..3208527 Binary files /dev/null and b/notes/img/editor/ui_build.jpg differ diff --git a/notes/img/editor/ui_dark.jpg b/notes/img/editor/ui_dark.jpg new file mode 100644 index 0000000..84d13e4 Binary files /dev/null and b/notes/img/editor/ui_dark.jpg differ diff --git a/notes/img/editor/ui_dialog_new.jpg b/notes/img/editor/ui_dialog_new.jpg new file mode 100644 index 0000000..abcda08 Binary files /dev/null and b/notes/img/editor/ui_dialog_new.jpg differ diff --git a/notes/img/editor/ui_dialog_open.jpg b/notes/img/editor/ui_dialog_open.jpg new file mode 100644 index 0000000..b69dc49 Binary files /dev/null and b/notes/img/editor/ui_dialog_open.jpg differ diff --git a/notes/img/editor/ui_dragdrop.jpg b/notes/img/editor/ui_dragdrop.jpg new file mode 100644 index 0000000..59a9af8 Binary files /dev/null and b/notes/img/editor/ui_dragdrop.jpg differ diff --git a/notes/img/editor/ui_e2e_game.jpg b/notes/img/editor/ui_e2e_game.jpg new file mode 100644 index 0000000..5ea18fb Binary files /dev/null and b/notes/img/editor/ui_e2e_game.jpg differ diff --git a/notes/img/editor/ui_empty.jpg b/notes/img/editor/ui_empty.jpg new file mode 100644 index 0000000..5ab7cef Binary files /dev/null and b/notes/img/editor/ui_empty.jpg differ diff --git a/notes/img/editor/ui_layers.jpg b/notes/img/editor/ui_layers.jpg new file mode 100644 index 0000000..7921ced Binary files /dev/null and b/notes/img/editor/ui_layers.jpg differ diff --git a/notes/img/editor/ui_llamamod_button.jpg b/notes/img/editor/ui_llamamod_button.jpg new file mode 100644 index 0000000..44fc03e Binary files /dev/null and b/notes/img/editor/ui_llamamod_button.jpg differ diff --git a/notes/img/editor/ui_marquee.jpg b/notes/img/editor/ui_marquee.jpg new file mode 100644 index 0000000..04a496f Binary files /dev/null and b/notes/img/editor/ui_marquee.jpg differ diff --git a/notes/img/editor/ui_marquee_result.jpg b/notes/img/editor/ui_marquee_result.jpg new file mode 100644 index 0000000..519f305 Binary files /dev/null and b/notes/img/editor/ui_marquee_result.jpg differ diff --git a/notes/img/editor/ui_moved.jpg b/notes/img/editor/ui_moved.jpg new file mode 100644 index 0000000..26cf763 Binary files /dev/null and b/notes/img/editor/ui_moved.jpg differ diff --git a/notes/img/editor/ui_new_island.jpg b/notes/img/editor/ui_new_island.jpg new file mode 100644 index 0000000..ac2ea80 Binary files /dev/null and b/notes/img/editor/ui_new_island.jpg differ diff --git a/notes/img/editor/ui_placed.jpg b/notes/img/editor/ui_placed.jpg new file mode 100644 index 0000000..4bf68ed Binary files /dev/null and b/notes/img/editor/ui_placed.jpg differ diff --git a/notes/img/editor/ui_play.jpg b/notes/img/editor/ui_play.jpg new file mode 100644 index 0000000..a3630f2 Binary files /dev/null and b/notes/img/editor/ui_play.jpg differ diff --git a/notes/img/editor/ui_rotate.jpg b/notes/img/editor/ui_rotate.jpg new file mode 100644 index 0000000..cb2a664 Binary files /dev/null and b/notes/img/editor/ui_rotate.jpg differ diff --git a/notes/img/editor/ui_select_move.jpg b/notes/img/editor/ui_select_move.jpg new file mode 100644 index 0000000..65842ff Binary files /dev/null and b/notes/img/editor/ui_select_move.jpg differ diff --git a/notes/img/editor/ui_settings.jpg b/notes/img/editor/ui_settings.jpg new file mode 100644 index 0000000..2ff7b41 Binary files /dev/null and b/notes/img/editor/ui_settings.jpg differ diff --git a/notes/img/editor/ui_shortcuts.jpg b/notes/img/editor/ui_shortcuts.jpg new file mode 100644 index 0000000..2bd0aec Binary files /dev/null and b/notes/img/editor/ui_shortcuts.jpg differ diff --git a/notes/img/editor/ui_village.jpg b/notes/img/editor/ui_village.jpg new file mode 100644 index 0000000..aeb237e Binary files /dev/null and b/notes/img/editor/ui_village.jpg differ diff --git a/notes/img/editor/ux_brush_after.jpg b/notes/img/editor/ux_brush_after.jpg new file mode 100644 index 0000000..6383302 Binary files /dev/null and b/notes/img/editor/ux_brush_after.jpg differ diff --git a/notes/img/editor/ux_brush_before.jpg b/notes/img/editor/ux_brush_before.jpg new file mode 100644 index 0000000..877ef5f Binary files /dev/null and b/notes/img/editor/ux_brush_before.jpg differ diff --git a/notes/img/editor/ux_coin_after.jpg b/notes/img/editor/ux_coin_after.jpg new file mode 100644 index 0000000..7b2b2a2 Binary files /dev/null and b/notes/img/editor/ux_coin_after.jpg differ diff --git a/notes/img/editor/ux_coin_before.jpg b/notes/img/editor/ux_coin_before.jpg new file mode 100644 index 0000000..e99d398 Binary files /dev/null and b/notes/img/editor/ux_coin_before.jpg differ diff --git a/notes/img/editor/ux_llama_after.jpg b/notes/img/editor/ux_llama_after.jpg new file mode 100644 index 0000000..7e187da Binary files /dev/null and b/notes/img/editor/ux_llama_after.jpg differ diff --git a/notes/img/editor/ux_llama_before.jpg b/notes/img/editor/ux_llama_before.jpg new file mode 100644 index 0000000..c489c45 Binary files /dev/null and b/notes/img/editor/ux_llama_before.jpg differ diff --git a/notes/img/editor/ux_menu_after.jpg b/notes/img/editor/ux_menu_after.jpg new file mode 100644 index 0000000..ca56cc5 Binary files /dev/null and b/notes/img/editor/ux_menu_after.jpg differ diff --git a/notes/img/editor/ux_menu_before.jpg b/notes/img/editor/ux_menu_before.jpg new file mode 100644 index 0000000..4a2fffe Binary files /dev/null and b/notes/img/editor/ux_menu_before.jpg differ diff --git a/notes/img/editor/ux_orig_after.jpg b/notes/img/editor/ux_orig_after.jpg new file mode 100644 index 0000000..ca80dee Binary files /dev/null and b/notes/img/editor/ux_orig_after.jpg differ diff --git a/notes/img/editor/ux_orig_before.jpg b/notes/img/editor/ux_orig_before.jpg new file mode 100644 index 0000000..4e58552 Binary files /dev/null and b/notes/img/editor/ux_orig_before.jpg differ diff --git a/notes/img/editor/ux_quit_game.jpg b/notes/img/editor/ux_quit_game.jpg new file mode 100644 index 0000000..8024a47 Binary files /dev/null and b/notes/img/editor/ux_quit_game.jpg differ diff --git a/notes/img/editor/ux_small_after.jpg b/notes/img/editor/ux_small_after.jpg new file mode 100644 index 0000000..3197be4 Binary files /dev/null and b/notes/img/editor/ux_small_after.jpg differ diff --git a/notes/img/editor/ux_small_before.jpg b/notes/img/editor/ux_small_before.jpg new file mode 100644 index 0000000..61ccf10 Binary files /dev/null and b/notes/img/editor/ux_small_before.jpg differ diff --git a/notes/img/editor/xobj_all_worlds_tab.jpg b/notes/img/editor/xobj_all_worlds_tab.jpg new file mode 100644 index 0000000..30d7cf4 Binary files /dev/null and b/notes/img/editor/xobj_all_worlds_tab.jpg differ diff --git a/notes/img/editor/xobj_catalogue_thumbs.jpg b/notes/img/editor/xobj_catalogue_thumbs.jpg new file mode 100644 index 0000000..7d35693 Binary files /dev/null and b/notes/img/editor/xobj_catalogue_thumbs.jpg differ diff --git a/notes/img/editor/xobj_drag_drop_inspector.jpg b/notes/img/editor/xobj_drag_drop_inspector.jpg new file mode 100644 index 0000000..a3c516e Binary files /dev/null and b/notes/img/editor/xobj_drag_drop_inspector.jpg differ diff --git a/notes/img/editor/xobj_game_bucky.jpg b/notes/img/editor/xobj_game_bucky.jpg new file mode 100644 index 0000000..b1e1bf5 Binary files /dev/null and b/notes/img/editor/xobj_game_bucky.jpg differ diff --git a/notes/img/editor/xobj_game_spider.jpg b/notes/img/editor/xobj_game_spider.jpg new file mode 100644 index 0000000..0ff67ee Binary files /dev/null and b/notes/img/editor/xobj_game_spider.jpg differ diff --git a/notes/img/editor/xobj_game_village_blade.jpg b/notes/img/editor/xobj_game_village_blade.jpg new file mode 100644 index 0000000..0a5b029 Binary files /dev/null and b/notes/img/editor/xobj_game_village_blade.jpg differ diff --git a/notes/img/editor/xobj_game_village_edit.jpg b/notes/img/editor/xobj_game_village_edit.jpg new file mode 100644 index 0000000..8d51553 Binary files /dev/null and b/notes/img/editor/xobj_game_village_edit.jpg differ diff --git a/notes/img/editor/xobj_game_village_grapes.jpg b/notes/img/editor/xobj_game_village_grapes.jpg new file mode 100644 index 0000000..38f76e5 Binary files /dev/null and b/notes/img/editor/xobj_game_village_grapes.jpg differ diff --git a/notes/img/editor/xobj_import_world_preview.jpg b/notes/img/editor/xobj_import_world_preview.jpg new file mode 100644 index 0000000..679036e Binary files /dev/null and b/notes/img/editor/xobj_import_world_preview.jpg differ diff --git a/notes/img/launcher-matrix.jpg b/notes/img/launcher-matrix.jpg new file mode 100644 index 0000000..b20122c Binary files /dev/null and b/notes/img/launcher-matrix.jpg differ diff --git a/notes/img/mods/all-mods-village.jpg b/notes/img/mods/all-mods-village.jpg new file mode 100644 index 0000000..7b1e77b Binary files /dev/null and b/notes/img/mods/all-mods-village.jpg differ diff --git a/notes/img/mods/cheats_fall.jpg b/notes/img/mods/cheats_fall.jpg new file mode 100644 index 0000000..d875c0a Binary files /dev/null and b/notes/img/mods/cheats_fall.jpg differ diff --git a/notes/img/mods/cheats_hud.jpg b/notes/img/mods/cheats_hud.jpg new file mode 100644 index 0000000..3eae819 Binary files /dev/null and b/notes/img/mods/cheats_hud.jpg differ diff --git a/notes/img/mods/fog-city-400.jpg b/notes/img/mods/fog-city-400.jpg new file mode 100644 index 0000000..bee4612 Binary files /dev/null and b/notes/img/mods/fog-city-400.jpg differ diff --git a/notes/img/mods/fog-jungle-50-100-400.jpg b/notes/img/mods/fog-jungle-50-100-400.jpg new file mode 100644 index 0000000..5c227bb Binary files /dev/null and b/notes/img/mods/fog-jungle-50-100-400.jpg differ diff --git a/notes/img/mods/freecam-fix-controls.jpg b/notes/img/mods/freecam-fix-controls.jpg new file mode 100644 index 0000000..1c6a8cb Binary files /dev/null and b/notes/img/mods/freecam-fix-controls.jpg differ diff --git a/notes/img/mods/freecam-fix-noclip.jpg b/notes/img/mods/freecam-fix-noclip.jpg new file mode 100644 index 0000000..957de6d Binary files /dev/null and b/notes/img/mods/freecam-fix-noclip.jpg differ diff --git a/notes/img/mods/freecam-fix-village.jpg b/notes/img/mods/freecam-fix-village.jpg new file mode 100644 index 0000000..f8aa393 Binary files /dev/null and b/notes/img/mods/freecam-fix-village.jpg differ diff --git a/notes/img/mods/freecam-noclip.jpg b/notes/img/mods/freecam-noclip.jpg new file mode 100644 index 0000000..d1f9be4 Binary files /dev/null and b/notes/img/mods/freecam-noclip.jpg differ diff --git a/notes/img/mods/freecam-village.jpg b/notes/img/mods/freecam-village.jpg new file mode 100644 index 0000000..119bcd1 Binary files /dev/null and b/notes/img/mods/freecam-village.jpg differ diff --git a/notes/img/mods/introskip_menu.jpg b/notes/img/mods/introskip_menu.jpg new file mode 100644 index 0000000..c7cace1 Binary files /dev/null and b/notes/img/mods/introskip_menu.jpg differ diff --git a/notes/img/mods/levels_01_village_edit.jpg b/notes/img/mods/levels_01_village_edit.jpg new file mode 100644 index 0000000..f3ee6f7 Binary files /dev/null and b/notes/img/mods/levels_01_village_edit.jpg differ diff --git a/notes/img/mods/levels_02_testwelt_chapter2.jpg b/notes/img/mods/levels_02_testwelt_chapter2.jpg new file mode 100644 index 0000000..523689c Binary files /dev/null and b/notes/img/mods/levels_02_testwelt_chapter2.jpg differ diff --git a/notes/img/mods/levels_03_mesh_import.jpg b/notes/img/mods/levels_03_mesh_import.jpg new file mode 100644 index 0000000..3159e83 Binary files /dev/null and b/notes/img/mods/levels_03_mesh_import.jpg differ diff --git a/notes/img/mods/mirror_01_village.jpg b/notes/img/mods/mirror_01_village.jpg new file mode 100644 index 0000000..77fabe6 Binary files /dev/null and b/notes/img/mods/mirror_01_village.jpg differ diff --git a/notes/img/mods/mirror_02_wireframe.jpg b/notes/img/mods/mirror_02_wireframe.jpg new file mode 100644 index 0000000..9251352 Binary files /dev/null and b/notes/img/mods/mirror_02_wireframe.jpg differ diff --git a/notes/img/mods/mirror_03_river_testworld.jpg b/notes/img/mods/mirror_03_river_testworld.jpg new file mode 100644 index 0000000..31f51fa Binary files /dev/null and b/notes/img/mods/mirror_03_river_testworld.jpg differ diff --git a/notes/img/mods/practice-extra-checkpoint.jpg b/notes/img/mods/practice-extra-checkpoint.jpg new file mode 100644 index 0000000..98b76de Binary files /dev/null and b/notes/img/mods/practice-extra-checkpoint.jpg differ diff --git a/notes/img/mods/practice-teleport.jpg b/notes/img/mods/practice-teleport.jpg new file mode 100644 index 0000000..88ce8e1 Binary files /dev/null and b/notes/img/mods/practice-teleport.jpg differ diff --git a/notes/img/mods/randomizer-jungle-seed2.jpg b/notes/img/mods/randomizer-jungle-seed2.jpg new file mode 100644 index 0000000..08fe33d Binary files /dev/null and b/notes/img/mods/randomizer-jungle-seed2.jpg differ diff --git a/notes/img/t1l1m001_close.png b/notes/img/t1l1m001_close.png new file mode 100644 index 0000000..380cc87 Binary files /dev/null and b/notes/img/t1l1m001_close.png differ diff --git a/notes/img/t1l1m001_persp.png b/notes/img/t1l1m001_persp.png new file mode 100644 index 0000000..d0edb78 Binary files /dev/null and b/notes/img/t1l1m001_persp.png differ diff --git a/notes/img/testwelt_1_startplatz.jpg b/notes/img/testwelt_1_startplatz.jpg new file mode 100644 index 0000000..21d1a45 Binary files /dev/null and b/notes/img/testwelt_1_startplatz.jpg differ diff --git a/notes/img/testwelt_2_galerie.jpg b/notes/img/testwelt_2_galerie.jpg new file mode 100644 index 0000000..f4fb5b1 Binary files /dev/null and b/notes/img/testwelt_2_galerie.jpg differ diff --git a/notes/img/testwelt_3_parkour_checkpoint.jpg b/notes/img/testwelt_3_parkour_checkpoint.jpg new file mode 100644 index 0000000..a340e11 Binary files /dev/null and b/notes/img/testwelt_3_parkour_checkpoint.jpg differ diff --git a/notes/img/testwelt_4_gehege.jpg b/notes/img/testwelt_4_gehege.jpg new file mode 100644 index 0000000..be31784 Binary files /dev/null and b/notes/img/testwelt_4_gehege.jpg differ diff --git a/notes/img/testwelt_5_ziel_tor.jpg b/notes/img/testwelt_5_ziel_tor.jpg new file mode 100644 index 0000000..ce79528 Binary files /dev/null and b/notes/img/testwelt_5_ziel_tor.jpg differ diff --git a/notes/img/testwelt_6_sprung.jpg b/notes/img/testwelt_6_sprung.jpg new file mode 100644 index 0000000..5181aec Binary files /dev/null and b/notes/img/testwelt_6_sprung.jpg differ diff --git a/notes/img/testwelt_7_trittsteine.jpg b/notes/img/testwelt_7_trittsteine.jpg new file mode 100644 index 0000000..1cca770 Binary files /dev/null and b/notes/img/testwelt_7_trittsteine.jpg differ diff --git a/notes/img/testwelt_8_ziel_tor.jpg b/notes/img/testwelt_8_ziel_tor.jpg new file mode 100644 index 0000000..300777d Binary files /dev/null and b/notes/img/testwelt_8_ziel_tor.jpg differ diff --git a/notes/img/testwelt_9_bilanz.jpg b/notes/img/testwelt_9_bilanz.jpg new file mode 100644 index 0000000..a4cf6cb Binary files /dev/null and b/notes/img/testwelt_9_bilanz.jpg differ diff --git a/notes/img/testwelt_montage.jpg b/notes/img/testwelt_montage.jpg new file mode 100644 index 0000000..e177ca2 Binary files /dev/null and b/notes/img/testwelt_montage.jpg differ diff --git a/notes/img/texturen_dorf.png b/notes/img/texturen_dorf.png new file mode 100644 index 0000000..fe326dd Binary files /dev/null and b/notes/img/texturen_dorf.png differ diff --git a/notes/img/vorschau/01_uebersicht_schraeg.jpg b/notes/img/vorschau/01_uebersicht_schraeg.jpg new file mode 100644 index 0000000..beba3db Binary files /dev/null and b/notes/img/vorschau/01_uebersicht_schraeg.jpg differ diff --git a/notes/img/vorschau/02_uebersicht_nordost.jpg b/notes/img/vorschau/02_uebersicht_nordost.jpg new file mode 100644 index 0000000..63fee4f Binary files /dev/null and b/notes/img/vorschau/02_uebersicht_nordost.jpg differ diff --git a/notes/img/vorschau/03_draufsicht.jpg b/notes/img/vorschau/03_draufsicht.jpg new file mode 100644 index 0000000..d412feb Binary files /dev/null and b/notes/img/vorschau/03_draufsicht.jpg differ diff --git a/notes/img/vorschau/04_startplatz.jpg b/notes/img/vorschau/04_startplatz.jpg new file mode 100644 index 0000000..0b31323 Binary files /dev/null and b/notes/img/vorschau/04_startplatz.jpg differ diff --git a/notes/img/vorschau/05_galerie_reihe1.jpg b/notes/img/vorschau/05_galerie_reihe1.jpg new file mode 100644 index 0000000..e2617c9 Binary files /dev/null and b/notes/img/vorschau/05_galerie_reihe1.jpg differ diff --git a/notes/img/vorschau/06_galerie_vom_podest.jpg b/notes/img/vorschau/06_galerie_vom_podest.jpg new file mode 100644 index 0000000..c243ceb Binary files /dev/null and b/notes/img/vorschau/06_galerie_vom_podest.jpg differ diff --git a/notes/img/vorschau/07_gehege.jpg b/notes/img/vorschau/07_gehege.jpg new file mode 100644 index 0000000..8aa34f7 Binary files /dev/null and b/notes/img/vorschau/07_gehege.jpg differ diff --git a/notes/img/vorschau/08_parkour_checkpoint.jpg b/notes/img/vorschau/08_parkour_checkpoint.jpg new file mode 100644 index 0000000..7ec3b14 Binary files /dev/null and b/notes/img/vorschau/08_parkour_checkpoint.jpg differ diff --git a/notes/img/vorschau/09_parkour_balken.jpg b/notes/img/vorschau/09_parkour_balken.jpg new file mode 100644 index 0000000..f7c69aa Binary files /dev/null and b/notes/img/vorschau/09_parkour_balken.jpg differ diff --git a/notes/img/vorschau/10_ziel_tor.jpg b/notes/img/vorschau/10_ziel_tor.jpg new file mode 100644 index 0000000..e45e8d4 Binary files /dev/null and b/notes/img/vorschau/10_ziel_tor.jpg differ diff --git a/notes/img/vorschau/11_bilanz.jpg b/notes/img/vorschau/11_bilanz.jpg new file mode 100644 index 0000000..a5e2aa9 Binary files /dev/null and b/notes/img/vorschau/11_bilanz.jpg differ diff --git a/notes/img/vorschau/12_montage.jpg b/notes/img/vorschau/12_montage.jpg new file mode 100644 index 0000000..b843423 Binary files /dev/null and b/notes/img/vorschau/12_montage.jpg differ diff --git a/notes/levelbau.md b/notes/levelbau.md new file mode 100644 index 0000000..c4b9569 --- /dev/null +++ b/notes/levelbau.md @@ -0,0 +1,273 @@ +# Levelbau mit `tools/levelbuild.py` + +Stand: 2026-11-06 (Ausgang + Parkour überarbeitet). Baut komplette Level-WADs aus einer Level-Beschreibung (Python-Skript). +Basis ist immer ein Original-Level (Standard: Dorf `t1l1m001.wad`): dessen Strat-Code/Objektmodelle +(STPC), Texturen (TEXT), Sound usw. werden übernommen; **Geometrie (TRAK), Kachelraster, +Vertexfarben, Objekte und Wegpunkte (MAP) werden komplett neu erzeugt.** +Ergebnis im Spiel geprüft: `out/testwelt.wad` (= installierte Testwelt, siehe unten). + +## Aufruf + +``` +python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad # bauen +python3 tools/levelbuild.py levels/testwelt.py out/testwelt.wad --install # + als TestLevel installieren +python3 tools/levelbuild.py levels/testwelt.py out/t.wad --set start=ziel # Variablen ans Skript +python3 tools/levelbuild.py --types # platzierbare Strat-Typen des Basislevels +python3 tools/levelbuild.py --textures tex.png # Textur-Kontaktabzug + Namensliste +python3 tools/texsheet.py WAD out.png [--all] [--only 1,2,3] [--size 96] +``` +`--install` kopiert nach `~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` (legt einmalig +`.orig` an) und legt die Kapitel-Symlinks 2..49 an (s. „Ausgang“). Test im Spiel: `tools/lamatest.py run out/x.wad ordner` (s. testharness.md). + +## Level-Skript + +```python +LEVEL_ARGS = dict(name='Testwelt', seed=7) # NAME-Chunk, Zufallssaat (Texturdrehung) + +def build(L): # L = levelbuild.Level + L.sea(0, 0, 88, 72, y=-2.0, kill=(6, 4, 80, 66)) # Meer + Todesebene + L.box(14, 12, 26, 24, -0.4, 0, top='stein', side='stein') + L.stairs(26, 18, 29, 22, 0, 1.5, 3, '+x') + L.player(20, 14.5, rot=0) + L.checkpoint(17, 18.5) + L.exit(70.5, 12.5) + L.strat('Bunny', 66, 34) + L.coins([(27, 20), (28, 20)]) + +def report(L): # optional, nach dem Bauen + ... +``` +`L.opts` enthält die `--set`-Werte (Strings). + +### Koordinaten +* Spielkoordinaten wie bei Strats: **x nach Osten, y nach oben, z = Rasterzeile (positiv, + "Süden")**, 1 Einheit = 1 Rasterzelle. Raster des Dorfes: 96×96 → alles muss in x,z ∈ [0,96) liegen. +* Winkel in **Grad**, 0° = Blick nach +z, 90° = Blick nach +x (im Spiel geprüft). +* Intern wird in den Exportraum (z gespiegelt) umgerechnet; Umlaufsinn wird automatisch + korrigiert (`_orient`), man gibt nur die gewünschte Außennormale an. + +### Geometrie-Bausteine +| Methode | Beschreibung | +|---|---| +| `floor(x0,z0,x1,z1,y, tex, rot='random', coll=True)` | waagerechte Fläche (in 1×1-Kacheln unterteilt) | +| `box(x0,z0,x1,z1,y0,y1, top, side, side_top=None, bottom=None, sides='nesw', side_coll=None)` | Quader; `side_top` = Textur der obersten Wandreihe (z. B. `'graskante'`), `side_coll=False` = Wände ohne Kollision (niedrige Sockel begehbar) | +| `platform(x,z,w,d,y, top, side, thick=0.5, side_top='graskante', bottom='erde')` | schwebende Plattform, Oberkante y | +| `pillar(x,z,y0,y1,size, side, top)` | Säule um Mittelpunkt | +| `ramp(x0,z0,x1,z1, ya, yb, '+x', top, side, base)` | Rampe, steigt in Richtung `+x/-x/+z/-z` | +| `stairs(x0,z0,x1,z1, y0, y1, n, '+x', top, side, base, walkable=True)` | Treppe; `walkable`: unsichtbare Schräge als Kollision | +| `fence(pts, y, height)` | Zaun (Pfosten + Latte) entlang Punktliste | +| `tree(x,z,y, height, crown)` | Klotz-Baum | +| `sea(x0,z0,x1,z1, y, tex='meer', kill=(...))` | Wasserfläche ohne Kollision + Todesebene | +| `quad([BL,BR,TR,TL], tex, coll, rot)` | einzelnes Viereck (Ecken von vorne gesehen) | +| `collider(pts, normal)` | unsichtbare Kollisionsfläche | +| `obj(path, offset, scale, tex)` | OBJ-Datei (Spielkoordinaten), `usemtl tex_N` oder Texturname | +| `height_at(x,z)` | Höhe der obersten begehbaren Fläche | + +Texturen: Name aus `TEX` (Dorf: `gras, gras2, gras_dunkel, blumen, sand, erde, graskante, +stein, stein_kante, stein_moos, platte, holz_dunkel, holz_maser, holzbalken, rinde, kiste, +inka, muster_lila, …`), direkter Index (int) oder Flachfarbe (`'meer'`, `'rot'`, … oder +`(r,g,b)`; wird als Flachfarb-Eintrag (Flags 0x105, Seite −1) an die Texturtabelle angehängt). +Kontaktabzug mit Indizes: `notes/img/texturen_dorf.png`. +**Jedes Dreieck zeigt eine ganze Texturkachel** (die Engine hat keine freien UVs) – daher wird +alles in 1×1-Stücke zerlegt; schmale/flache Wände zeigen die Textur gestaucht. + +### Beleuchtung (Vertexfarben) +`L.sun = dict(azimuth=215, elevation=50, color=(1,.93,.8), strength=.62, sky=(.5,.55,.66), +ground=(.3,.28,.24), ao=.55, ao_radius=1.3, shadow=.35)` – je Vertex: +Himmel/Boden-Halbkugellicht + Sonne (Lambert) mit **Schatten** (Strahl zur Sonne gegen alle +Quader) + **Umgebungsverdeckung** (24 Strahlen, Reichweite `ao_radius`). Quader, Säulen, +Treppen, Plattformen melden sich als Schattenwerfer an (`solids`). 1.0 = Texturfarbe +(Vertexfarbe 0x80), max. 2.0. `tint=(r,g,b)` je Baustein multipliziert. + +### Objekte (Strats) +`L.strat(name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False)` +* `y=None`: auf die oberste begehbare Fläche bei (x,z) stellen. +* Typfelder (nvars, Slots, Flags, Zusatzspeicher) und Parameter werden von der **ersten + Original-Instanz** des Typs kopiert (`params=[…]` überschreibt; Anzahl muss passen). +* `variant`: Typen mit mehreren Code-Einstiegen (z. B. SmashPot: 2 Spawn-Stubs). +* **Wegpunkte** (`wp`): `'relative'` = Wegpunktkette des Originals mitverschieben (Versatz zum + Objekt bleibt, y wird auf den Boden gesetzt, wenn dort einer ist), `'here'` = alle Punkte auf + das Objekt, `'none'` = keine (−1), oder eigene Liste `[(x,z[,y[,grad]]), …]` (Zusatzfelder + der Original-Wegpunkte werden übernommen). Ketten werden neu nummeriert (keine Verweise ins + alte Level). +* `player(x,z,rot)` (Strat `Llama`), `checkpoint(x,z)` (`CheckPointPost`), `exit(x,z)` + (`LevelDoor`), `coins([(x,z),…])`. +* Typen, die im Dorf-Code stehen, aber dort nicht platziert sind, über `Strats.BORROWED` mit Typfeldern + aus einem anderen Level: derzeit `RubyIdol` (aus t1l1m002). +* `poolExtra` wird auf max(Original, Anzahl Strats + 80) gesetzt; der Parameterpool des + Originals bleibt erhalten, neue Parameter werden angehängt. + +### Was `build()` erzeugt +1. Alle Dreiecke werden nach (Rasterzelle, Höhenband 10 Einheiten ab y = −3) auf Kacheln + verteilt; Kachelursprung = Zellmitte/Bandmitte (Kollisions-d muss in ±8 liegen). +2. **Kollision**: jedes Viereck mit `coll=True` wird ein Kollisionspolygon; Böden nur in der + eigenen Zelle, Wände/Schrägen zusätzlich in Nachbarzellen bis 0,3 Abstand (die Engine prüft + nur die Kacheln der Zelle, in der der Spieler steht). Unbenutzte Hilfsvertices sorgen dafür, + dass die Kachel-Box die Kollisionspolygone umfasst. +3. Identische Kachelmodelle werden zusammengefasst (Vertexfarben stehen in MAP je Kachel). +4. **Todesebene**: Original-Modell 0 des Dorfes (unsichtbar, Material 0x1e) in jeder Zelle + des `kill`-Bereichs, die nicht vollständig von Boden bedeckt ist. +5. MAP: Zonenraster = 0, Zone 0 sieht alles (Maske 0xffffffff), keine Kachel-Wechsel (Alt-Block + leer), Kachelblöcke A/B/Modelle/Vertexfarben konsistent, Strats + Wegpunkte neu, Licht-/ + Umgebungszonen (90-Byte-Block) und Rest unverändert. +6. TEXT: angehängte Flachfarben. NAME: `LEVEL_ARGS['name']`. +7. Gegenprobe: MAP wird nach dem Schreiben wieder vollständig geparst. + +## Erkenntnisse aus den Spieltests (lamatest, Xvfb) +* **Maßstab**: Lama ≈ 0,6 Einheiten hoch, läuft ≈ 1,1 Einheiten/s (Münz-Messbahn, s. u.; die + früher notierten 0,85 waren zu niedrig), braucht ~1 s zum Beschleunigen. Sprung (X) überwindet + 0,35 hohe Kanten, 0,5 nicht. **Mit Anlauf fliegt es ~1,3 weit** (horizontal gleich schnell wie + beim Laufen), auf +0,2 landet es nach ~1,05; aus dem Stand deutlich kürzer (0,5-Lücke nicht + geschafft). → Parkour: Lücken 0,3 mit +0,2, Plattformen davor/dahinter ≥ 1,5 lang (nach der + Landung muss Platz für den nächsten Absprung sein), größere Höhen über Treppen/Rampen. + Messlevel: `levels/sprunglabor.py` (`--set spur=a..g|m`; `m` = Münz-Messbahn: Münze alle 0,25 → + Münzzähler × 0,25 = gelaufene Strecke; `f` = Weitsprung über Wasser). +* **Keine Stufen-Logik**: schon eine 0,12 hohe Kante *mit Wandkollision* blockiert das Lama. + Ohne Wandkollision (`side_coll=False`) läuft es auf niedrige Sockel hinauf; Treppen mit + unsichtbarer Schräge (`stairs(walkable=True)`) und Rampen sind begehbar (geprüft). +* **Steuerung ist kamerarelativ** (Hoch = Kamerablickrichtung). Die Startkamera steht hinter + dem Lama; steht direkt dahinter ein Hindernis (z. B. Pfosten), weicht sie seitlich aus → + Startpunkte hinten freihalten. +* Rot 0° = Blick +z, 90° = Blick +x. +* **Todesebene funktioniert**: Sturz ins Meer → Lebensenergie weg, Respawn am letzten + Checkpoint (bzw. Start). +* Spieler-Wegpunktkette (14 Punkte beim Dorf-Lama, Bedeutung unklar): `player()` legt den + ersten 8 Einheiten hinter das Lama, den Rest auf das Lama, Zusatzfelder vom Original. +* **LevelDoor** (Ausgang) – gelöst, siehe Abschnitt „Ausgang (LevelDoor)“ unten. +* Größen, die laufen: TRAK 0,6–1,0 MB, bis 1100 Modelle, 5200 Kacheln, WAD bis 6,9 MB + (Originale: TRAK ≤ 1 MB, ≤ 5800 Kacheln, WAD ≤ 6,85 MB). Testwelt: 628 Modelle, + 2474 Kacheln, TRAK 646 KB, WAD 6,3 MB. + +## Ausgang (LevelDoor) – wie er funktioniert + +Strat-Bytecode (Dorf, STPC 0x430332, 3 Parameter, unsichtbar, kein Modell), dekodiert über die +VM `FUN_0054d180` (Opcode < 0x45: Tabelle 0x5796bc mit 16-Bit-Operand, sonst 0x578ff4+op·4): +``` +g65,g66,g67 = Param 0,1,2 ; Stamm, Welt, Kapitel des nächsten Levels (20.12) +loop: if g21 == 3 ; Spielzustand "läuft" + and Spieler.Boden.Material == 16 ; Eigenschaft 0x11 = *(strat+0xc0)+4 = Material-Byte des + ; Kollisionspolygons unter dem Spieler (FUN_00403020) + and floor(Spieler.x) == floor(Tür.x) and floor(Spieler.z) == floor(Tür.z) ; gleiche Rasterzelle + -> op 0x192 (FUN_0054d620): Levelwechsel(Param), g1 += 1, Ende + yield +``` +* Die Tür ist also nur ein Auslöser; im Dorf liegt im Türdurchgang ein Bodenpolygon mit + **Material 16** (8 Stück im Dorf, auch in allen anderen Levels). Unsere Testwelt hatte überall + Material 0 → nichts passierte. **Fix:** Mosaik im Tor mit `mat=16` (`box(..., mat=TUER_MAT)`), + Tür in dieselbe Rasterzelle, Spielerweg führt mitten hindurch. +* **Nur Kacheln der Türzelle zählen** (Zellliste, FUN_004036d0), die Höhe nicht (t1l3m002–4: Boden + 3 Einheiten unter der Tür). **Dorf 1 & Co.:** In der Türzelle liegt zunächst *kein* Material 16 – + es steckt im **Wechselmodell** der Kachel (MAP-Kachel-Wechsel, WFPC 0x10000): die RubyIdolDoor in + derselben Zelle tauscht beim Öffnen (Op 0x11a/0x11b) Kachel 420 von Modell 48 auf 49, und erst + Modell 49 hat das Ausgangspolster (42,25–42,75 / 49,23–49,50, y 3,25). Im Spiel geprüft + (2026-11-07): Modell 49 eingesetzt + Tür entfernt, Lama bei 42,5/3,5/49,37 → sofort Bilanz + „Dorf Kapitel 1“; Original (Tür zu) → nichts. Betrifft 8 der 31 Original-Türen (Ruby-, + PressurePad-, Lab-Türen); das Polster bei 43,5/48,45 (Zelle 43,48) ist dagegen ein Hauseingang + (Zonen-Teleport). t1l3m003: 2 der 4 Türen (Zellen 71,48/71,49) haben gar kein Polster. +* Zweiter Weg (Strat-Code bei 0x4252aa, Spielerlogik [wahrsch.]): Boden-Material 16 und Zelle = + g104/g105 (setzt die LevelDoor beim Start, Llama setzt sie auf −1) → Levelwechsel(g101..g103). +* Parameter in den Originalen: (1, Welt, Kapitel+1) = nächstes Kapitel; im letzten Kapitel einer + Welt **(7,7,7)** = „Welt geschafft“ → zurück zur Auswahlkarte, nächste Welt freischalten. +* `FUN_0054d620`: merkt aktuelles Level (0x585008..14), setzt nächstes (0x584eb4/b8/bc), zeigt die + Bilanz (Münzen x/55, Geheimnisse) mit „EINGABETASTE = speichern / ESC = weiter“. Fortschritt + 0x585018/19 (max. Welt/Kapitel, im KSV +0x42/+0x43): ist man im höchsten freien Kapitel, wird + das nächste Kapitel freigeschaltet; bei (7,7,7) die nächste Welt. +* **(7,7,7) in Welt 9 stürzt ab**: schaltet „Welt 10“ frei → nach dem Speichern steht der + Kartencursor auf Welt 10, Weltnamen-Zeiger 0x57bd40[9] = NULL → Page Fault bei 0x41B5CC. +* **Lösung Testwelt: Parameter (1,9,1)** – nach der Bilanz wird Kapitel 1 neu geladen (kein + Absturz, getestet mit Speichern). Nebenwirkung: Kapitel 2 wird freigeschaltet (und von + Kapitel n aus Kapitel n+1). Fehlt dessen WAD, stürzt die Auswahl ab („Couldnt load wad“). + Deshalb legt `levelbuild.py --install` (Funktion `kapitel_links()`) in der Mod + **Symlinks T1L9M002..049.wad → T1L9M001.wad und T192..T1949.COR → T191.COR** an (Welt 9 hat + 49 Kapitel-Plätze, Tabelle 0x572420) und ruft `lama-mod an` auf. `lama-mod` übernimmt dafür jetzt + auch Symlinks aus Mod-Ordnern (`find … -type f -o -type l`). Geprüft: Kapitel 2 auf der Karte + wählbar, lädt die Testwelt. +* Die Auswahlkarte zeigt für Welt 9 keinen Hintergrund (nur Text) und 772 als Münzziel + (Münztabelle 0x5722f4 hat nur Welten 1–8) – kosmetisch. + +## Die Testwelt (`levels/testwelt.py` → `out/testwelt.wad`, installiert als T1L9M001.wad) +**2026-11-06 kompakt umgebaut** („alles näher zusammen“): L-förmige Insel, Hauptteil 32,5×23,5 +(x 11..43,5, z 9,5..33) + Streifen unter dem Parkour-Ende (x 43,5..49, z 9,5..20,5), vorher +42×34 (x 10..52, z 8..42). Galerie, Gehege und Parkour grenzen direkt aneinander, keine leeren +Wiesen mehr. NAME-Chunk "Testwelt" (wird im Spiel nirgends angezeigt). +Vorschaubilder (Handy, 1280 px): `notes/img/vorschau/` (01–03 Blender-Renderings mit +Beschriftung, 04–11 Spielfotos, 12 Montage). Ältere Bilder `notes/img/testwelt_*.jpg` zeigen +noch das alte, weitläufige Layout. + +| Bereich | alt | neu | +|---|---|---| +| Startplatz | x 14..22, z 11..19 (8×8) | x 15..21, z 12..19 (6×7) | +| Galerie | z 22..38, Spalten 2,5, Reihen z 25/28,5/32/35,5 | x 14,5..32,5, z 20,5..32, Spalten 1,9, Reihen z 22,5/25,2/27,9/30,6 (Abstand 2,7) | +| Gehege | x 39..49, z 24..35 | x 33..42, z 20,8..30 (direkt östlich der Galerie) | +| Parkour | x 22..53,6 | x 21..52,6 (nur um −1 in x verschoben, Maße gleich) | +| Ausgang | LevelDoor (51,5/15,5) | LevelDoor (50,5/15,5), Zelle 50/15, Mosaik mit Material 16 | + +* **Startplatz** (x 15..21, z 12..19): Steinplatz mit Inka-Mosaik, 4 Inka-Säulen, Blumenbeete, + Bäume, 2 Münzen, Checkpoint (16,1/17,4). Lama startet bei (18, 14,6) mit Blick nach Süden + (Galerie direkt dahinter, Parkour-Treppe links im Bild). +* **Galerie** (z 20,5..32): 4 Reihen, je Objekt ein flacher Sockel (0,9 breit, 0,08 hoch, + begehbar), Lücke 1,0 zwischen Sockeln, Sandweg (0,6) vor jeder Reihe, farbiger Pfosten am + Reihenanfang (gelb/grau/blau/rot), Hauptweg Nord–Süd bei x 15,6..16,3: + 1. Sammeln: Coin, ChargeBooster, RubyIdol, HealthFull, HealthGenerator, ChacaChargePotion, Secret + 2. Kisten/Töpfe: SmashPot (2 Varianten), Jar, ObjectDontDropInBox, WaterReboundPoints, CheatTeleport + 3. Figuren: TipoVillage, ChacaVillage, PachaGivingInfo, KidOnLlama, Wompy + 4. Mechanik: PressurePadDoor, RubyIdolDoor, SwitchPlatformPadVillage, AppearingPlatformsVillage, + RisingColumns, SinkingStatue, FallingTree, CheckPointPost +* **Gehege** (x 33..42, z 20,8..30, Zaun, Eingang Westseite z 20,8..24): Bunny ×2, Ladybird, + FlockOfBirds, VillagerHerder; Aussichtspodest (0,6) mit Treppe an der Westseite zwischen + Galerie-Reihe 2 und 3. +* **Parkour** (beginnt an der Ostkante des Platzes, **alles auf der Linie z = 15,5 nach Osten**, + damit man ohne Kameradrehen durchkommt – Drehen per A/S oder Links/Rechts war im Skript nicht + reproduzierbar). Geometrie im Skript als „alte x + PX“ (PX = −1) geschrieben: + Treppe 0→0,8 · **Anschlag-Stufe** +0,3 (ohne Lücke: Lama läuft dagegen und steht still) · + 2 Lücken 0,3 mit je +0,2 auf 1,5 lange Holzklötze · Balken 0,5 breit · Mittelplattform (1,5) mit + Checkpoint · Sandrampe 1,5→2,4 · **Anschlag-Stufe** +0,3 · 3 Trittsteine (1,5 lang, Lücken 0,3, + je +0,2) · schmaler Balken 0,45 · Treppe 3,3→4,0 · Ziel-Plattform (4,0; x 47,6..52,6, ragt über + die Küste) mit Säulen, Brustwehr und Tor; darunter Mosaik mit Material 16, LevelDoor bei + (50,5 / 15,5). 18 Münzen entlang. Unter dem Parkour liegt Wiese (Herunterfallen ist harmlos). +* **Skript-Durchlauf** (Start → Ziel → Ausgang → Bilanz), alte Fassung 4/4, kompakte Fassung 2/2 + Läufe erfolgreich (Bilanz 18/55 Münzen); die .seq ist zeitbasiert/relativ und blieb unverändert: + ``` + python3 tools/levelbuild.py levels/testwelt.py out/testwelt_parkour.wad --set start=parkour + lamatest.py run out/testwelt_parkour.wad AUSGABE --seq "$(cat levels/testwelt_parkour.seq)" + ``` + (`run:T:x@t,…` hält Up T s und drückt X zu festen Zeiten; die Anschlag-Stufen setzen den + Zeitfehler zurück. Fotos (`shot@`) blockieren ~0,4 s → nie kurz vor einem Sprung.) +* Im Spiel geprüft: Laden ohne Absturz (viele Läufe), alle 31 Galerie-Objekte erscheinen + (Münze/Secret/… reagieren: Münzzähler, "GEHEIMNIS!"), Lama läuft Galerie/Gehege ab, + Treppe hoch, Sprung auf Plattform B und auf Trittstein 1, Rampe hoch, Treppe zur + Ziel-Plattform, Meer = Respawn, kompletter Parkour per Skript, Ausgang → Bilanz → Kapitel 1 neu. + Bilder: aktuell `notes/img/vorschau/` (kompaktes Layout); `notes/img/testwelt_*.jpg` = + altes weitläufiges Layout (1–6 + Montage sogar noch mit altem Parkour). +* Startpunkt für Tests umschaltbar: `--set start=platz|reihe1..4|weg1..4|gehege|podest|parkour|pk1..pk5|ziel|ziel2` + (`podest` = auf dem Aussichtspodest, Blick nach Westen über die Galerie); + Ausgangsparameter `--set tuer=1,9,1`. + +### Nicht platzierte Dorf-Typen +| Typ | Grund | +|---|---| +| Llama | ist der Spieler (Start) | +| LevelDoor | Ausgang am Parkour-Ende (braucht Boden mit Material 16, s. o.) | +| CameraSide | Kamera-Zone, unsichtbar | +| CutSceneCameraDirectional | Zwischensequenz-Kamera, unsichtbar | +| CutSceneTrigger | startet Zwischensequenz | +Kein platzierbarer Typ hat das Spiel zum Absturz gebracht (keine Bisektion nötig). +Hinweis: Mechanik-Objekte (Türen, Säulen, Statue) gehören im Dorf zu bestimmter Geometrie / +Kachel-Wechseln; in der Galerie stehen sie "nackt" (z. B. schwebendes RubyIdolDoor-Schild). +Typen, die nur im Code stehen (BouncingCoin, Sparkles, RubyIdolStatue …), haben keine +Original-Instanz mit Typfeldern und werden nicht angeboten (Ausnahme RubyIdol über `Strats.BORROWED`). + +## Vorschau-Renderings (Blender) +`/usr/bin/blender` (Debian 3.0.1): **Cycles rendert nur Schwarz** (auch die Standardszene), OCIO +fehlt („Color management: fallback“, nur View „Standard“). **Eevee geht**, braucht aber ein +X-Display → eigenes `Xvfb :7` (nie :0, :5 gehört lamatest). Ablauf (Skripte in +`tools/vorschau/`: `strats_json.py` (aus levelbau/ aufrufen), `render_level.py`, `label.py`): +1. `geom_export.py out/testwelt.wad GE` → level.obj/mtl + page_NN.png +2. Strat-Positionen: Levelskript mit `levelbuild.Level` ausführen (ohne `L.build`), `L.strats` als JSON +3. `DISPLAY=:7 STRATS=strats.json VIEWS=schraeg,schraeg2,oben blender -b -P render_level.py -- GE r 64`: + eigener OBJ-Leser (der 3.0-Importer kennt keine Vertexfarben), Spiegelung OBJ→Spiel (y_blender = + −Spiel-z, Umlaufsinn drehen), Material = Textur (Closest) × Vertexfarbe, etwas Emission, + Sonne + Himmel, Marker für Strats (Münzen gold, Lama-Start oranger Kegel, Galerie-Objekte + in Reihenfarbe, Tiere weiß, Ausgang lila Ring); schreibt je Ansicht PNG + JSON mit + Bildkoordinaten der Bereichsnamen. +4. PIL: auf 1280 px skalieren, Beschriftungen (Start/Galerie/Gehege/Parkour/Ziel), JPG q85. diff --git a/notes/llamamod-app.md b/notes/llamamod-app.md new file mode 100644 index 0000000..12946df --- /dev/null +++ b/notes/llamamod-app.md @@ -0,0 +1,359 @@ +# LlamaMod – the mod manager app + +Single-click mod manager for *The Emperor's New Groove* (PC, 2000; German: *Ein Königreich für +ein Lama*). Java 25 + Swing + FlatLaf, runs on Linux (game through Wine) and Windows. +UI language: English; all strings are in +`src/main/resources/com/eliasmueller/llamamod/ui/messages.properties` (a translation is another +file such as `messages_de.properties`, selected with `-Dllamamod.lang=de`). + +## Build + +``` +./gradlew spotlessApply build # compile + tests +./gradlew fatJar # build/libs/llamamod-<ver>-all.jar (fatJarLauncher: …-launcher.jar) +./gradlew packageLinux # build/dist/LlamaMod-<ver>-full-linux-x64.tar.gz (jlinked runtime) +./gradlew packageWindows # build/dist/LlamaMod-<ver>-full-windows-x64.zip (LlamaMod.exe + runtime) +./gradlew packageAll # both editions: …-full-… and …-launcher-… for Linux and Windows +``` + +**Version**: single source `version` in build.gradle → manifest `Implementation-Version`, launch4j +version info, package names and the generated `build-info.properties` (task `buildInfo`: `version` +and `build` = first 10 hex digits of SHA-256 over the compiled classes and `src/main/resources` +except `edition.properties`; deterministic, so an unchanged rebuild keeps the id). Read by +`BuildInfo` (UI header, editor About, `--version`, `--help`, mod cache key). +**Editions**: `full` (editor) and `launcher` (`edition.properties` `editor=false`, generated by +`launcherEdition`; same classes, only the editor UI/commands are hidden). Launcher tasks have the +suffix `Launcher` (`fatJarLauncher`, `packageLinuxLauncher`, `packageWindowsLauncher`). + +Both packages are built on Linux. `fetchJdk` downloads (once, into `~/opt`, override with +`-Pllamamod.opt=<dir>`): the Temurin JDK 25.0.5+8 for Linux (its `jlink`), and the Temurin +**jmods** archives for Linux and Windows of exactly the same version (JDK 25 itself ships without +jmods, JEP 493; cross-linking works when versions match). `exeWindows` downloads launch4j 3.50 +(Linux build) and wraps the fat jar into `LlamaMod.exe`, which starts `runtime\bin\javaw.exe`. +Runtime modules: java.base, java.desktop, java.prefs, java.logging, jdk.charsets, jdk.zipfs +(+ jdk.crypto.mscapi on Windows). + +## Command line (scripting/tests) + +``` +llamamod --version --install <iso|folder> --mods --launch [--wait] +llamamod --apply [id,id] [--opt id.key=value ...] +llamamod --registry --saves --unlock <slot 1-3> <world> <chapter> [--cursor] +``` +`--mods` lists every mod with category and its options (current value, type, range, default). +`--opt` values are validated and saved in config.json like the GUI does. Errors (e.g. "The game is +running") are printed as `Error: …` with exit code 1. +Data folder override: `-Dllamamod.data=<dir>` or `LLAMAMOD_DATA` (not persisted). Test-only +system properties: `llamamod.extraMods=<class,...>` (extra Mod classes), +`llamamod.gameDisplay=:5` (X display for the game, Linux). + +## Data folder + +Default `~/.local/share/LlamaMod` (`$XDG_DATA_HOME`), Windows `%LOCALAPPDATA%\LlamaMod`; a +different folder chosen in the wizard is remembered via `java.util.prefs`. + +| Path | Content | +|---|---| +| `config.json` | settings, enabled/active mods, mod option values (`modOptions`), CD fingerprint | +| `original/hd`, `original/cd` | pristine copy of `Setup/<lang>/Hd` and `…/Cd` from the ISO | +| `game/` | copy of the HD part the game runs from: **patched** `groove.exe`, DLLs, music, save games | +| `cd-active/` | what the game sees as its CD: hard links to originals + mod outputs | +| `cache/<mod>/<key>/` | mod outputs + `manifest.json` | +| `backups/saves/<timestamp>/` | save game backups (taken before every write) | +| `wine/` | own Wine prefix (Linux) | +| `logs/` | game.log, wineboot.log, registry.log | + +## First start + +The wizard takes an ISO file, an extracted folder or a mounted CD. ISO 9660 is read in pure Java +(`platform/IsoImage`, Joliet names when present, else Rock Ridge names, multi-extent files; all-upper-case HD +names of plain ISO 9660 copies are installed lower-case). The game folder is found by +looking for `…/Hd/groove.exe` next to `…/Cd/wads/`. After copying, `platform/Integrity` runs the +same checks as `deb-build/build.sh` (FESOUND.WAD length field, "OFNI" at offset 4 of every level +WAD, `music/english.cvs` must not end in 4 MiB of zeros); damaged copies are rejected. +The ISO also contains three `(null)king*.ksv` leftovers; they are not copied. + +## Registry, CD path and the exe patch + +All registry access of groove.exe goes through five small wrappers, always with +`HKEY_LOCAL_MACHINE\Software\Disney Interactive\Emperors New Groove\1.0`: + +| Function | Purpose | +|---|---| +| FUN_00405790 | read string (CDPath, HDPath, DisplayDevice, D3DDevice) | +| FUN_004057c0 | read number (ScreenW/H/BPP, SoundVolume, MusicVolume, …) | +| FUN_00405800 / FUN_00405830 | write string / float | +| FUN_00405870 | write DWORD | + +Each contains `68 90 E0 56 00` (push offset subkey string, VA 0x56E090) followed by +`68 02 00 00 80` (push HKEY_LOCAL_MACHINE). FUN_00405960 opens the key and creates it if missing. + +**Patch** (`platform/ExePatcher`, applied only to `game/groove.exe`, never the original): the +byte `02` of each `push 80000002h` becomes `01` (HKEY_CURRENT_USER), so no admin rights are needed +on Windows. German CD (`groove.exe` MD5 697d67a31c20079838c662d8187dbd86, 1,642,496 bytes), +file offsets of the changed bytes: **0x579B, 0x57D0, 0x5810, 0x5859, 0x5880** (original 02, +new 01; patched MD5 6199a42eb7582b2a70b1ac6e29f00f43). The patcher searches the pattern +(push of the subkey string VA + push HKLM) instead of fixed offsets, so other language builds work +if their code is the same. + +File lookup (FUN_00415690): tries `HDPath\dir\file`, then `CDPath\dir\file` (checked with +`_access`), and only then loops over drive letters with `GetDriveTypeA == DRIVE_CDROM`. A plain +folder in CDPath is therefore enough – no CD drive, no drive-type check. + +Values LlamaMod writes before every start (Windows: `reg import`, Linux: `wine regedit /S`, both +from a UTF-16 .reg file): + +| Value | Content | +|---|---| +| CDPath | absolute path of `cd-active\` (Linux: `L:\cd-active\`, drive L: = data folder in our prefix) | +| HDPath | `.\` – save games are named `sprintf("%sking%d.ksv", HDPath)`; without HDPath MSVCRT prints `(null)king0.ksv`. Existing `(null)kingN.ksv` files are migrated into empty slots. | +| ScreenW / ScreenH | resolution (automatic = main screen; with a Wine virtual desktop, minus panels and title bar) | + +The game must run with the game folder as working directory (it saves relative to it). +Path limits: the game builds file names in a 260-byte buffer (0x583748) and uses ANSI file APIs, so +CDPath and the game folder must be ≤ 200 characters and encodable in the ANSI code page. On Linux +the data folder is mapped as drive `L:` (`wine/dosdevices/l:`), which keeps both short and ASCII; on +Windows the launcher refuses other paths with a message (choose a folder like `C:\Games\LlamaMod`). + +## Launching + +* Windows: `game\groove.exe`, cwd = game folder. The game has no windowed mode. +* Linux: own prefix `wine/`, created with `WINEARCH=win32` (falls back to the default arch if this + Wine cannot create win32 prefixes; system.reg only appears after `wineserver -w`). + `WINEDLLOVERRIDES=winemenubuilder.exe=d;mscoree=d;mshtml=d` (no menu entries, no Mono/Gecko + prompts). Optional virtual desktop: `wine explorer /desktop=LlamaMod,WxH Z:\…\groove.exe`. + Wine missing → clear error message. Running instances are detected via `/proc/*/environ` + (`WINEPREFIX=<our prefix>`), so two games never share the prefix. + +## Mods + +`mods/ModRegistry` loads built-in mods by class name (missing classes are skipped): +`mods.debug.DebugWireframeMod`, `mods.testworld.TestWorldMod`, plus ServiceLoader mods. +`mods/ModManager` applies the enabled mods in `order()` (then id) against an overlay context: +reads see original + earlier mods, writes go to `cache/`. Paths are case-insensitive and mapped to +the CD's spelling; `list(dir)` returns CD-root-relative paths. Cache key of a mod = +SHA-256(key of the input state | id | version [| options as sorted JSON]), the chain starts at +SHA-256(CD fingerprint (names and sizes) | app version+build id | `ExePatcher.REVISION`), so a hit +skips the mod, and an app update re-runs every mod once (a changed mod without a `version()` bump +never serves stale output). Manifests store `"app"`; after each apply, cache entries of other app +builds (including all of 0.1.0, which have no `"app"`) are deleted. Then `cd-active` is rebuilt in +`cd-active.new` and swapped in (hard link → symlink (not Windows) → copy). Applying (and restoring) +is refused while the game runs (`GameLauncher.isRunning()`) and holds an exclusive lock on +`<data>/.apply.lock` (a second window/CLI/editor gets "Another LlamaMod window or command is applying +mods"); every run is logged to `logs/apply.log`. + +### Writing a mod + +```java +public interface Mod { + String id(); String name(); String description(); + default String category() { return OTHER; } // LEVELS, GAMEPLAY, VISUAL, CHEATS, OTHER + default List<ModOption> options() { return List.of(); } + default int order() { return 100; } + default String version() { return "1"; } // bump to invalidate cached outputs + void apply(ModContext ctx) throws IOException; +} +``` +Register built-in mods in `ModRegistry.BUILTIN`. The UI groups mods by category (in the order +above, collapsible headers with "n of m on") and sorts them by name inside a group. + +**Options** (`mods/ModOption`, record `(key, label, description, type, defaultValue, min, max, +choices)`): factories `ModOption.bool(key, label, desc, def)`, `integer(key, label, desc, def, min, +max)`, `string(…, def)`, `choice(key, label, desc, def, ModOption.of(value, label)…)`, `seed(key, +label, desc, def)` (empty default = a random seed is chosen on first use and saved). In `apply`: +`ctx.option(key)` (normalized string), `ctx.optionBool`, `ctx.optionInt`, `ctx.optionSeed` (long: +numeric text as is, other text hashed). Values live in config.json (`modOptions.<id>.<key>`, only +user-set values and generated seeds); invalid stored values fall back to the default. The UI shows +the editors under the mod card while the mod is enabled (checkbox / spinner / combo box / text +field / seed field with "Random"); any change marks the selection as pending ("Apply" needed, +compared via `ModManager.selectionKey()` against `activeKey`). + +**Unit tests of a mod**: `src/test/…/mods/TestModContext(cdDir, mod, optionOverrides, exeImageOrNull)` +reads a CD folder, collects writes in `ctx.written`, resolves options (defaults + overrides). + +### groove.exe patches and code caves (`exe/ExeImage`, `exe/X86`) + +`game/groove.exe` is rebuilt on every apply: `original/hd/groove.exe` → HKCU base patch +(`platform/ExePatcher`, owner "base", always first) → exe changes of the enabled mods in apply +order. Without exe mods the result is byte-identical to the old ExePatcher output; "Restore +original" goes back to it. A mod gets the shared image with `ctx.exe()`; all changes are +attributed to the mod id, journaled into the mod's cache manifest (`"exe": [...]`) and replayed +(with full verification) on a cache hit. Every patch/allocation is logged in apply.log +(`exe <mod>: patch 0x… n bytes – label`). + +| API | Purpose | +|---|---| +| `sections()`, `section(".text")`, `imageBase()`, `entryPoint()` | PE layout | +| `fileOffset(va)` / `va(fileOffset)` | conversion (-1 if not file-backed, e.g. .bss) | +| `read(va, n)`, `readU8`, `readU32`, `findString(s)` | read current bytes | +| `find("8B 0D ?? ?? ?? ?? 85 C9")`, `find(".text", pattern)`, `findUnique(pattern)` | byte patterns, `??` = any byte | +| `findImport(dll, fn)` | IAT slot of an imported function | +| `importAddress(dll, fn)` | IAT slot, or for functions groove.exe does not import (GetAsyncKeyState, GetKeyState: user32 imports only window functions) a cave slot filled at startup by a loader stub (LoadLibraryA + GetProcAddress, runs before the original entry point 0x563EE0) – call with `call dword [slot]` either way | +| `patch(va, "expected hex (?? ok)", "replacement hex", label)` / byte[] variant | verifies the current bytes, refuses overlap with another mod's patch or cave block (`ExePatchException` with both owners and addresses) | +| `allocate(size, label)` → VA | zeroed, 16-byte aligned **code** block in section `.llamac` (VA 0x6EF000+, **read + execute, not writable**, max `CODE_RESERVE` = 128 KiB) | +| `allocateData(size, label)` → VA | zeroed, 16-byte aligned **data** block in section `.llamad` (VA 0x70F000+ = caveStart + CODE_RESERVE, **read + write, not executable**) | +| `write(va, bytes, label)`, `place(bytes, label)`, `placeData(bytes, label)` | fill own cave blocks (build time); `place` = read-only data in the code section, `placeData` = initialized variables | +| `caveStart()`, `dataStart()`, `nextAllocation()`, `nextDataAllocation()`, `inDataSection(va)` | cave addresses | +| `emit(label, a -> …)` → VA | assemble with `X86` at the next cave address and place it | +| `hookJmp(site, expectedHex, target, label)` / `hookCall(…)` | `E9`/`E8 rel32` over ≥5 bytes, rest NOPs | +| `ExeImage.callRel32(from, to)` | raw encoding | + +**Runtime-writable memory must come from `allocateData`/`placeData`.** Cave code that writes to +an `allocate`/`place`/`emit` block at runtime (counters, flags, saved registers, toggles) now +faults with an access violation, because `.llamac` is not writable. Strings and constant tables +may stay in the code section. Why: a section that is writable *and* executable (the old RWX +`.llama`) is a classic antivirus heuristic. Layout: both sections are appended at the file end +(SizeOfImage, NumberOfSections, SizeOfCode, SizeOfInitializedData updated, checksum 0); with a data +section the code section's VirtualSize spans the whole reserve so the sections stay contiguous in +memory (Windows rejects gaps); a code-only result has just `.llamac`. The loader's import slots +(`importAddress` for non-imported functions) live in `.llamad`. Cache manifests mark data blocks +with `"data": true`; old manifests whose import slot was in the code cave fail the replay and the +mod simply runs again. + +`X86` (origin VA): `hex("60 9C")`, `u8/u16/u32`, `asciiz`, `push(imm)`, `call(va)`, `jmp(va)`, +`jcc(X86.JNE, va)`, `callPtr(slot)`, `pushad/popad/pushfd/popfd/ret/ret(n)`, `align(n)`, +labels (`label()`, `bind(l)`, `jmp(l)`, `jcc(cc, l)`, `call(l)`). +A typical detour: `long fn = exe.emit("x", a -> a.pushad()…popad().hex(<stolen bytes>).jmp(site + n)); +exe.hookJmp(site, "<stolen bytes>", fn, "x")` – stolen bytes must not contain relative jumps/calls. +The exe has a `.reloc` section but no ASLR flag, so it always loads at 0x400000 (absolute addresses +in cave code are fine). Tests: `exe/ExeImageTest` (real exe, re-parse + `objdump -h`), +`mods/ModOptionsAndExeTest`; in-game check: `mods/ExeSmokeMod` (test classes, see below; its call +counter is an `allocateData` block). + +### Antivirus false positives + +A changed groove.exe with extra code may be flagged by antivirus programs (Windows Defender). +Mitigations: no RWX section (see above), `Mod.modifiesExecutable()` (default false; override it +to `true` in mods that call `ctx.exe()` so the user is warned *before* the first apply), and +`ModManager.modifiesExecutable(mod)` = declared **or** recorded (`config.json` `exeMods`: mods +whose journal was non-empty at their last apply). The UI shows a badge "modifies groove.exe" on +such cards, `--mods` appends `[modifies groove.exe]`. + +* Notice (`ui/AntivirusDialogs.notice`): when applying a selection with such mods (and once more + after an apply that turned out to patch the exe), once per session; explains the false + positive, that the original files are untouched, shows the data folder with a Copy button and + the Defender exclusion path; "Don't show again" → `antivirusNoticeDismissed` in config.json. +* Detection: every apply stores SHA-256 of the written exe (`gameExeSha256`). + `ModManager.checkGameExe()` → `OK | MISSING | LOCKED | CHANGED` runs after apply/restore and + before every launch (GUI and `--launch`/`--apply` CLI). On Windows, `CreateProcess` errors 225 + (ERROR_VIRUS_INFECTED), 226, 5 (access denied) and 2 raise `GameLauncher.BlockedException`. + Both show "groove.exe was removed or changed – probably by antivirus software" with Rebuild + (re-applies the active mods), Open data folder and the exclusion hint. + +![antivirus notice](img/app/av-warning.png) + +**World 9 ("TestLevel")**: if the result contains `wads/T1L9M001.wad`, chapters 2–49 +(`T1L9M002..049.wad`) are linked to it and `T192..T1949.COR` to `T191.COR`; a missing T191.COR is +taken from `t111.cor` (see levelbau.md: the exit unlocks chapter n+1 and the map crashes if its +WAD is missing). + +## Save games (KSV, 256 bytes) + +Decoded from FUN_0041c640 (load) / FUN_0041c820 (save); details in `save/Ksv.java`. + +| Offset | Meaning | +|---|---| +| 0x00 | used flag (0 = empty, shown as "LEER") | +| 0x01 | lives (0x584e6c >> 12, new game 5) | +| 0x02 | energy (0x584e70 >> 12, reset to 5 on level start) | +| 0x03–0x06 | map cursor: tribe (1), world, chapter, map (0) | +| 0x07 | gold coin count (= chapters with flag bit 1, recomputed by FUN_0041cd70) | +| 0x08–0x3D | u8[9][6] chapter flags, index world·6+chapter: bit 0 collectible (strat var 0xFA), bit 1 gold coin | +| 0x3E–0x41 | last played: tribe, world, chapter, map | +| 0x42 / 0x43 | highest unlocked world / chapter | +| 0x44–0xAF | u16[9][6] coins per chapter (stored as coins<<12, truncated to 16 bits → coins mod 16) | +| 0xB0–0xCF | unknown 32-byte buffer (0x584e80) | +| 0xD0–0xD9 | options: effects volume, music volume, 0x80, speaker mode, 6 more option bytes | + +Chapters per world (table 0x572420): 4, 3, 4, 3, 3, 3, 5, 5, 49. World 9 has no row in the +per-chapter arrays. The KSV files shipped on the CD are empty (0x00 then 0xCC). Selecting an empty +slot and "new game" immediately writes a zeroed file. + +## Tests done (2026-11-06) + +* Linux (Wine 6.0.3, Xvfb): ISO → wizard → install (all 175 CD files identical to the reference + copy) → mods (test mod + real DebugWireframeMod, 33 WADs in ~4 s) → save editor (slot set to + TestLevel) → Play → game reaches menu, slot shows "TestLevel Kapitel 1", the modded world-9 WAD + loads; Village shows the debug wireframe. Saves are written as `king1.ksv` (HDPath fix works); + registry values land in HKCU only. +* Windows build under Wine 6.0.3 (win64 prefix): UI, wizard and install work, cd-active uses hard + links, game starts from the Windows code path (`reg import`, HKCU only, CDPath with spaces) and + reaches a level. Wine 6 quirks: JDK 25 needs `-XX:ActiveProcessorCount=n` (GetProcessGroupAffinity + missing) – set `JDK_JAVA_OPTIONS` for testing; launch4j's own `javaw -version` check then hangs + under Wine 6, so the app was started with `runtime\bin\javaw.exe -jar LlamaMod.exe` (the exe is a + valid jar). On Windows the app makes SunMSCAPI the first provider (OS RNG; the default DRBG + seeding enumerates network interfaces, which crashes old Wine). +* Audio in automated runs: a private PulseAudio daemon with only a null sink + (`PULSE_SERVER=unix:…`), because PulseEffects on the desktop moves new streams away from a null + sink on the main server. Disabling the Wine audio driver is not an option: the game crashes at + 0x0054696D (FUN_00546960 writes to the NULL voice channel) when a voiced dialog starts. + +## Troubleshooting: Windows in a virtual machine + +Tested in a VirtualBox 7 Windows 10 guest (VBoxSVGA, 3D acceleration on). LlamaMod itself works +(install, exe patch, HKCU registry, cd-active, mods – the debug wireframe was visible in the demo), +but the guest's legacy DirectX 6/7 support does not: + +| Symptom | Cause | Workaround | +|---|---|---| +| "Direct Draw kann nicht gestartet werden." | device enumeration finds no usable D3D HAL device (`-display_diagnostics` writes `<PC>.dispdiag` next to the exe) | use a DirectDraw wrapper | +| "…muss DirectX7 installiert sein…" with dgVoodoo2 (WARP or D3D11) | `DirectDrawCreateEx(IID_IDirectDraw7)` fails inside dgVoodoo's DDraw.dll | not solved; use WineD3D instead | +| WineD3D for Windows: game runs, picture upside down, no keyboard input | VirtualBox OpenGL flips FBO blits; `OffscreenRenderingMode=backbuffer` is ignored by newer WineD3D | untested: older WineD3D (Wine 3.x based) or dxwrapper with `Dd7to9 = 1` (D3D9 path) | + +On real hardware no wrapper should be needed (unverified). + +## Testing mods in game (`tools/apptest.py`) + +Runs the game through the LlamaMod app (fat jar CLI), invisible and muted, one isolated *session* +per name: own data folder (`<root>/<name>/data`, = LLAMAMOD_DATA, own Wine prefix and save games), +own Xvfb display, own PulseAudio daemon with only a null sink (PulseEffects on the desktop steals +streams from null sinks on the main server). Root: `$APPTEST_ROOT`, default +`/home/elias/.claude/jobs/90c6eed7/tmp`. The original game data is installed once from +`~/Downloads/GROOVE_DK.iso` into `<root>/_llamamod-template` (file lock, so parallel agents wait) +and hard-linked into each session (~30 MB per session + ~200 MB Wine prefix on first start). +`~/.wine-lama` and the user's own game are never touched. + +``` +./gradlew fatJar # the harness uses the newest build/libs/*-all.jar (--jar to override) +T=tools/apptest.py +$T run NAME --display 5 --mods fog,testworld --opt fog.strength=2 --level 1 1 \ + --seq "Up:1.5 shot:walk a+x:0.5 sleep=1 shot:after" --out DIR [--keep] + # setup + apply + unlock slot 3 at world/chapter (cursor) + start + tolevel + keys + stop + # exit 0 ok, 3 crash, 4 timeout; DIR: report.json, 00_start.png, seq_NN_*.jpg, 99_end.jpg, + # zz_failure.jpg, game.log, apply.log +$T setup NAME # install session (fast after the template exists) +$T apply NAME fog,mirror --opt fog.strength=0 --opt mirror.on=true # no ids = original +$T unlock NAME 3 WORLD CHAPTER # slot 3, map cursor on WORLD/CHAPTER (do this before start) +$T start NAME --display 5 # Xvfb + private pulse + `--launch`; waits for window and audio +$T tolevel NAME --world 1 # menu "Spiel starten" -> slot 3 -> map -> Enter -> skip fly-by +$T keys NAME Up:1.5 x a+x:0.5 sleep=2 shot:name dialog --out DIR # KEY[:hold s], default hold 0.2 s +$T shot NAME out.png [--small] # 1920x1080 PNG, --small = 960x540 JPEG +$T ocr NAME [WxH+X+Y] # German tesseract OCR +$T status NAME # JSON: game pids, window, audio clients, crash (exit 3) +$T stop NAME # wineserver -k (own prefix), kill own Xvfb/pulse by PID +``` +Python: `sys.path.insert(0, "tools"); from apptest import Session; s = Session("x", display=7); +s.setup(); s.apply(["fog"], ["fog.strength=2"]); s.unlock(3, 1, 1); s.start(); s.tolevel(world=1); +s.key("Up", 1.5); s.shot("a.png"); print(s.crashed()); s.stop()`. + +* Display: the user watches **:5** with a viewer; parallel agents use their own numbers (7, 8, …). + `start` refuses a display that is in use and never uses :0; xdotool only talks to the session's + display. +* Unregistered mods: `APPTEST_CP=build/classes/java/test APPTEST_JAVA_OPTS="-Dllamamod.extraMods=<class>"` + (classpath = jar + APPTEST_CP, also works for `build/classes/java/main` of a work in progress). + Example (exe pipeline smoke test, passes): `APPTEST_CP=build/classes/java/test + APPTEST_JAVA_OPTS="-Dllamamod.extraMods=com.eliasmueller.llamamod.mods.ExeSmokeMod" $T run fw + --display 5 --mods exe-smoke,testworld --level 9 1 --out DIR`; with `--opt exe-smoke.crash=true` + it faults on purpose and `run` exits 3 with "Unhandled page fault … at address 006EF012". +* Crash detection (`crashed()`): groove.exe process of the prefix gone, "Unhandled exception/page + fault" in `data/logs/game.log`, winedbg running, or the window gone. With the Wine virtual + desktop (always on in sessions, 1920x1080) Wine error dialogs are drawn inside the desktop + window, so they are found through game.log/winedbg rather than X window titles. +* Audio check: `status` lists `application.name` of the sink inputs on the private server; the + game shows up as `groove.exe` (binary "wine"). +* Keys: hold ≥0.12 s (DirectInput misses shorter presses); default 0.2 s, menu navigation 0.3 s. + Controls are camera-relative (see testharness.md for movement measurements). +* Navigation timing (Wine 6, llvmpipe): menu ~6 s, slot ~14 s, gameplay ~25 s after start. + `tolevel(world=9)` waits for the beige world-9 map, other worlds for a stable picture. +* Session state (PIDs, display): `<root>/<name>/harness/state.json`; logs `xvfb.log`, `pulse.log` + there, game output in `data/logs/game.log`. Delete `<root>/<name>` to start from scratch. diff --git a/notes/mod-boottolevel.md b/notes/mod-boottolevel.md new file mode 100644 index 0000000..046c6aa --- /dev/null +++ b/notes/mod-boottolevel.md @@ -0,0 +1,22 @@ +# Boot to level (`boot-to-level`, GAMEPLAY) + +`mods/boottolevel/BootToLevelMod` – starts the game directly in one level: no logos, no menus, +no world map. Used by the level editor's Play button (details, offsets and tests: +`editor-playtest.md`). + +| Option | Default | | +|---|---|---| +| `world` / `chapter` | 1 / 1 | level to load (world 9 = TestLevel, chapters 1–49) | +| `slot` | 3 | save slot the game plays with (new game if empty) | +| `logos` | on | skip logo movies and title screen (skipped if intro-skip already patched them) | +| `quitOnEscape` | off | Escape (pause) ends the game at once through the normal exit path | +| `start` | empty | "x, y, z[, yaw]" in cells: moves the level's player start (WAD rebuild) | + +Patches: `jmp` at 0x549C50 (main-menu frame handler) → once: read slots (FUN_0041c0e0), load +slot (FUN_0041cc70), set tribe/world/chapter/map 0x584EB4…, music shutdown, state 0x5846E4 = 3, +return 1. `quitOnEscape`: `jmp` at 0x549F40 (in-game frame handler): pause bit 0x8 of 0x5FCF08 → +state 0x11 (the main menu's "Beenden"), return 1. Data: one 4-byte `allocateData` flag. +Registered via ServiceLoader (`META-INF/services/com.eliasmueller.llamamod.mods.Mod`). + +Verified on display :9: Village 2 and TestLevel 2 load within seconds of the window; Escape +ends groove.exe in ~1.3 s without errors. diff --git a/notes/mod-cheats.md b/notes/mod-cheats.md new file mode 100644 index 0000000..c1c72de --- /dev/null +++ b/notes/mod-cheats.md @@ -0,0 +1,78 @@ +# Built-in cheats (`cheats`, CHEATS) + +`mods/cheats/CheatsMod` – one groove.exe hook, no data changes. + +## How the game does it + +Main menu frame function 0x54B460 (state 2, table 0x579050): whenever a pad bit is held +(0x5FCF08 ≠ 0) it calls **FUN_0041a160(0)**, the sequence matcher: + +``` +for i in 0..5: + if newlyPressed(0x5FCF30) & seq[i][pos[i]]: pos[i] at 0x58483C + 4i + pos[i]++ + if seq[i][pos[i]] == 0: sequence complete + if cheatFlags(0x584710) & (1<<i): clear bit (toggle off, no sound) + else: set bit, play sound (FUN_00546170(0,-127,8)) + i == 2: next mode 0x584700 = 0x0A, 0x584638 = 4 (go to the credits) + pos[i] = 0 + else pos[i] = 0 any other key resets this cheat +``` +Sequence table **0x571FBC**, 6 × 9 dwords (8 steps + 0). Menu key bits (FUN_0041a220, raw DIK +state array 0x58226C + 4·DIK): ↑ 0x10, ↓ 0x40, ← 0x80, → 0x20; Return 0x4000, Space 0x8000, +C 0x2000, A 0x400, S 0x800, Esc 0x1000, Tab 0x08, backslash 0x01. Typing a sequence works +with arrows alone; no extra key has to be held (the "hold" is just that the matcher only runs while +some key is down). The flag word is never saved or reset (only bit 2 is cleared after the +credits), so cheats stay on until the game quits. + +## Decoded cheat table + +| Bit | Sequence | Read at | Effect | Option | +|---|---|---|---|---| +| 0 | ← → → → ↑ → → ↑ | 0x54F5C9, VM opcode 0xC0 "hurt player" (FUN_0054f5c0) | energy (0x584E70) is not decreased → no damage, no lost lives | `health` (default on) | +| 1 | ↓ ← → ↓ ↓ ← → ↓ | strat property 0xEA (0x5524F4), read by the Llama strat at chapter start | property 0xFA (0x584E60, pink idol counter, chapter collectible) = 50 | `collectibles` | +| 2 | ← ← ← ← → → → → | FUN_0041a160, 0x54A5F6 | plays the credits (CREDITS.dat) right away, then back to the main menu, bit cleared | – (one-shot, not a setting) | +| 3 | → → ↓ ↓ ↓ ↑ ↓ ↓ | 0x54D6F9 (chapter finished) | chapter flags `|= 3` (collectible + gold coin, KSV +0x08) | `rewards` | +| 4 | ↑ ↓ → ← ← ← ← ↓ | strat property 0xB2 (0x550EE1) | property 0xC0 (0x584FF4, purple fruit counter) = 500 instead of 0 | `fruit` | +| 5 | ↑ ← ↓ → ↓ ↓ ↑ ↑ | strat property 0x97 (0x55100C) | property 0xE8 (0x6DA304, charge bar) = 165 instead of 50 | `charge` | + +Llama strat (t1l1m001 STPC 0x429536, same in all 23 level WADs) – VM short opcodes: 0x07 p = +push property p, 0x14 n = pop, skip n words if zero, 0x16 n = skip n words, 0x40 p = pop into +property p, 0x44 n = push n, long opcode 0x45 = push next word: +``` +if prop[0x97]: prop[0xE8] = 0xA5000 else prop[0xE8] = 0x32000 ; 165 / 50 (20.12) +if prop[0xB2]: prop[0xC0] = 0x1F4000 else prop[0xC0] = 0 ; 500 / 0 +if prop[0xEA]: prop[0xFA] = 0x32000 ; 50 +``` +Strat property switch: FUN_00550e60 (get, 0x10A cases, table 0x552684), FUN_005509f0 (address +for store, index table 0x550D64 → 0x550C78). + +## Patch + +`call FUN_0041c050` at 0x41D3EA in WinMain (right after the command line is parsed, before +DirectX init) → cave: `or dword [0x584710], mask` (+ `mov byte [0x5834D0], 1` for +diagnostics) → `jmp 0x41C050`. The cave writes only game globals, so it needs no data block. + +## Hidden debug features found + +| Feature | State | In the mod | +|---|---|---| +| `-display_diagnostics` (DAT_005834D0) | works: writes `<COMPUTER>.dispdiag` (DDraw/D3D enumeration) into the game folder | option `diagnostics` (tested: ELIASPC.dispdiag, 60 KB) | +| `c:\kingdom_dbg.txt` | dead: FUN_0040d2c0 (called from FUN_0043c220) only creates the file with "Logfile %s started now/today (write this code!)"; the 43 debug prints (FUN_00414600) go to OutputDebugStringA (0x6DC3E8), never to the file | no | +| FrameAdvanceDelay (FUN_0043cb40) | slide show `In\001.bmp…` + `In\In.wav` at start, `Out\…` at exit, delay from registry `FrameAdvanceDelay` (default 1.0 s); folders are empty | no (data, not a cheat) | +| F4 in game | requests state 0x0C (world map) – the normal "back to map" | no | +| F7 in game | toggles 0x584708 → start row of the strat debug print (0x6D9DB8 = 0x20 / 0x48); nothing visible in retail strats | no | +| F6 | screenshot `capture%d.tga` | no | +| `CheatTeleport`, `CheatCharge` strats | present in most level WADs; strat-only logic, not decoded | no | + +Level select / unlock-all is the save editor's job (`--unlock`), not duplicated here. + +## Verified in game (Wine 6, Xvfb :8, Village 1-1) + +* All cheats on: chapter start shows the full charge bar, pink idol 50, purple fruit 500 + (`img/mods/cheats_hud.jpg`, original on the left). +* Infinite health: running off the cliff into the sea three times – original loses an energy + segment per fall (HUD shows the red segment), with the cheat the energy HUD never appears, no + loss (`img/mods/cheats_fall.jpg`). +* `rewards` (bit 3) decoded only, not played through to a chapter end. +* Diagnostics: file written, game runs normally. diff --git a/notes/mod-fog.md b/notes/mod-fog.md new file mode 100644 index 0000000..7c72830 --- /dev/null +++ b/notes/mod-fog.md @@ -0,0 +1,60 @@ +# Draw distance mod (`fog`, VISUAL) + +Code: `src/main/java/com/eliasmueller/llamamod/mods/fog/FogMod.java`, test +`src/test/.../mods/fog/FogModTest` (needs `-Dllamamod.cd=<cd dir>`). Data-only (MAP chunks), no +exe patch. + +## Options + +| Key | Type | Default | Effect | +|---|---|---|---| +| `distance` | INT % 100–600 | 250 | fog start/end (= far clip) × distance/100 | +| `fog` | CHOICE `scaled` / `off` | scaled | `off`: fog band shrunk to 1/16 tile, i.e. no fade, hard edge at the draw distance | + +## Engine facts (groove.exe) + +* Environment zone (MAP block 4, 90 bytes on disk, 0x5c in memory; loader FUN_0042ac50). Fog + values are 20.12 fixed point, 4096 = 1 tile: + | memory | file offset | meaning | + |---|---|---| + | +0x34 | 0x34 | fog mode (3 = vertex fog/darkening; 2 = other mode; read by FUN_00438180) | + | +0x3c / +0x40 | 0x3a / 0x3e | fog start / end for detail "Minimum" | + | +0x44 / +0x48 | 0x42 / 0x46 | fog start / end for detail "Maximum" (loader: if +0x44 ≤ +0x3c, both are set to 1.5 × the low pair) | + | +0x4c, +0x50 | 0x4a, 0x4e | colours (ambient → FUN_0041bcf0, backdrop/fog → FUN_00439110) | + | +0x58 | 0x56 | music id | + The u16 at +0x38 takes 2 bytes on disk, so all later file offsets are memory − 2. +* Per frame (FUN_0041a480; zone switch FUN_0041e1c0): `t` = detail setting (0x5848cc: 1.0 + Maximum, 0.5 Medium, 0.0 Minimum). Fixed `end = lo_end + (hi_end − lo_end)·t` → 0x58658c / + 0x586590; float `far = end·(t + (1−t)·0.5)/4096` → 0x57f90c = 0x57f8fc; fog start likewise → + 0x57f8f8; 0x57f904 = 1/(far − start) (fog factor), 0x57f914 = 0.9999999/(far − near) (depth). + `FUN_00424bb0(near, far)` sets the clip range for the rasteriser. +* Everything that limits view distance derives from these zone values: + * fog: vertex colours are scaled by `(far − d)·0x57f904` when `d > start` (0x421a14 …, 0x557b20 …), + * far clip plane 0x57f90c, + * tile renderer FUN_0042bf40 walks the grid cells within camera ± (far + const), + * strat visibility/activity radius `0x586590 + 0x800` (FUN_00428700, FUN_004361xx; strat opcode + 0xf2 also returns it). So objects (and enemies) wake up from further away as well. +* Not changed: the region mask `0x58642c` (32 region bits from MAP block 6/7, set by triggers; + hides other rooms/areas) – it is a level-design visibility mechanism, not a distance limit. +* Original fog end at Maximum detail, per level: 7–16 tiles (village t1l1m001 8.2–12.8, jungle + t1l2m001 8.6–10.5, jungle night chase t1l2m003 6.9, city t1l6m001 14, roller coaster up to 23). + At Minimum the float far is only half of the low pair (2.5–4 tiles). Zones with mode 0 carry + placeholder values (2.0/3.5/3.0/5.25) and are scaled as well. + +## Verified in game (apptest, Wine 6, llvmpipe, 2026-11-06) + +* Jungle t1l2m001 at 50 % / original / 400 %: at 50 % the spider-web gate at the end of the first + path disappears in the dark fog, at 400 % it is a bit clearer than in the original + (`img/mods/fog-jungle-50-100-400.jpg`). This proves the four zone values drive fog + clip. +* Village t1l1m001 (400 %, 300 %, 50 %) and city t1l6m001 (400 %, `img/mods/fog-city-400.jpg`): + levels load and run, no crash, no slowdown noticeable in the harness; the start views are + enclosed (village backdrop mountains are a sky texture, the city streets are short), so the + difference there is small. +* Not tested: 600 % in large open levels (roller coaster, 23 tiles × 6) – expect more tiles and + strats per frame; and the `off` mode in game (unit test only). + +## Known limits +* Many areas are enclosed or use region masks, so the gain is mostly visible on long paths and + open views (jungle, river, roller coaster). The village backdrop is a texture, not geometry. +* Objects/enemies activate from further away (strat radius = fog end); with large values enemies + may start moving before they are in view. diff --git a/notes/mod-freecam.md b/notes/mod-freecam.md new file mode 100644 index 0000000..ba17770 --- /dev/null +++ b/notes/mod-freecam.md @@ -0,0 +1,113 @@ +# Mod "freecam" – Free camera & noclip (CHEATS) + +Package `mods.freecam` (`FreeCamMod`, `FreeCamPatch`); uses `mods.practice.Asm`/`Hotkeys`. Version 3. + +## Options and controls + +| Key | Default | Meaning | +|---|---|---| +| `camKey` | F7 | toggle free camera | +| `noclipKey` | F9 | toggle noclip | +| `freeze` | true | stop all strats (objects, llama, camera strat) while the free camera is on | +| `camSpeed` | 3 (1–32) | free camera speed, 1/32 tile per logic tick (30 ticks/s) | +| `noclipSpeed` | 2 (1–32) | noclip speed, 1/32 tile per tick | + +The controls are the game's own (default bindings below; remapped keys and joysticks work, because +the mod reads the game's control bits, not raw keys): + +| Game control (default key) | Free camera | Noclip | +|---|---|---| +| Up / Down (arrows) | fly forward / back (horizontal, along the view) | llama away from / towards the camera | +| Left / Right (arrows) | strafe left / right | llama screen-left / screen-right | +| Camera left / right (A / S) | turn left / right | orbit the camera around the llama | +| Jump (X) / Action (Z) | up / down | llama up / down | +| PgUp / PgDn | look up / down (±88°) | – | +| Power (Space) or Shift | ×4 speed | ×4 speed | + +While the free camera is on, the game gets no movement input (pads cleared, only pause kept), so +with `freeze` off the game keeps running but the llama stands still; Escape/P still opens the pause +menu (no camera movement while it is open). In noclip the llama still gets the input (walk/jump +animations), but its position is the mod's. Toggles (F7/F9) and Shift use GetAsyncKeyState. +Both toggles are switched off when a level (re)starts. + +## How it works + +| Site | Original | Cave | +|---|---|---| +| 0x54b5fd | `call FUN_00427c40` (right after the frame's input read FUN_00419e00/FUN_0041a220) | copy control bits for the free camera; while it is on: clear pads, rerun FUN_004256c0 | +| 0x54b79f / 0x54b7e0 | `call FUN_0054bad0` (running / paused frame) | call it, then toggles + free camera or noclip chase camera | +| 0x54b614 / 0x54b7a4 / 0x54b7e5 | `call FUN_0041e020` (region mask from the camera cell) | with free cam/noclip on: run it with the llama's x/z as camera cell | +| 0x54b783 | `call FUN_004287c0` (particles, after each tick's strat update) | noclip, then jmp to the original | +| 0x54c4a0 | entry of FUN_0054c4a0 `55 8B EC 83 EC 4C` | `ret` while the free camera is on (only with `freeze`) | +| 0x54d0d5 | `mov [0x6d9e40], edi` in FUN_0054d0d0 (strat system reset at level start) | clear both toggles | + +Input facts: FUN_00419e00 ORs, for each of the 14 bindings (DIK codes at 0x584758, order Left, +Right, Up, Down, A, S, Space, X, Return, P, PgUp, PgDn, Z, O), the bit from table 0x572100 into the +u16 pad word `*(0x5fcf70)` (12-byte pad structs, count 0x5fcf6c; +2/+3 stick). FUN_004256c0 turns +the pads into the held control bits 0x5fcf20 (Up 0x10, Right 0x20, Down 0x40, Left 0x80, +A 0x400, S 0x800, Return 0x1000, Z 0x2000, X 0x4000, Space 0x8000, pause 0x8; PgUp/PgDn 0x100/0x200 +are **not** copied there, the mod reads them from the keyboard pad word), stick magnitude 0x5fcefc +and direction; FUN_00425bb0/FUN_00425c00 derive the edge bits the strats use. + +Camera facts: the game camera is a strat (`*(0x6d9e28)`). FUN_0054bad0 copies its position to +0x6d9d20.. and 0x5790b0/b4/b8 and computes yaw/pitch (0x5790a4/0x5790a0, 0..4095, pitch < 0 = +looking down) from the position and the look-at target 0x6d9d80/84/88. The renderer +FUN_0041a920(&0x5790a0, &0x6d9d70) recomputes the angles from position → target (and writes them +into the camera strat +0x20/+0x24), so position + target are what count. Forward = +(sin yaw · cos pitch, sin pitch, cos yaw · cos pitch) from the sine table 0x574318 (s32[4096], +4096 = 1.0); screen-right on the ground = (−cos yaw, sin yaw). +FUN_0041e020 takes the camera's cell, looks up its region (MAP grid +0x48) and sets the region mask +0x58642c (which rooms/tiles/strats are drawn; 0xffffffff outside the grid). A free camera in a cell +of another region got mask 1 in Village 1, so the whole level vanished and only the static backdrop +was left – that is why the free camera "only froze the game" for the user. The mod now computes the +mask from the llama's cell. + +Noclip: after every tick's strat update the player strat (`*(0x6d9e1c)`) gets the noclip position +at +0x30.. (current), +0x50.. (previous tick, so the next wall sweep starts there) and +0x94.. +(render copy), and its collision flags +0xe8 & 0x48000 (0x8000 floor check FUN_00403020, 0x40000 +wall check FUN_00403ee0) are cleared, so it neither falls nor dies over the void. The camera strat +does not follow a llama moved this way (it stays where it was and only turns), so while noclip is on +(and the free camera off) the mod drives a chase camera: target = llama + (target − llama) and +camera = target + (camera − target), both offsets taken when noclip is switched on; A/S rotate the +camera offset around the llama (64/4096 per tick ≈ the game's 90° per 0.5 s). Switching noclip off +ORs the collision bits back (same player strat only) and hands the camera back to the camera strat. + +## Fix history (version 3, 2026-11-06) + +User report (Village 1/TestLevel, with testworld, cheats, fog 500 %/off, intro-skip): +1. "Free camera just freezes the game": the camera did move, but I (forward) flew along the pitched + view into the ground within a second, and outside the llama's region the region mask hid the + whole level (only the backdrop stayed). Fixed: region mask from the llama's cell; arrows move + horizontally, X/Z change height. +2. Noclip camera did not follow: the camera strat ignores the teleported llama → chase camera. +3. Noclip left/right swapped: right vector was (cos, −sin), correct is (−cos, sin). +4. Free camera J/L turned the wrong way (L = +yaw = left); now S (camera right) = −yaw. During + noclip the camera strat's own turning fought with the llama → replaced by the orbiting chase cam. +Also: controls switched from I/K/J/L/T/G/U/H (GetAsyncKeyState) to the game's own controls, input is +swallowed while the free camera is on. + +## Verified in game (2026-11-06, apptest, user's mod set: testworld, cheats, fog 500/off, intro-skip) + +* TestLevel (world 9), freeze on (`img/mods/freecam-fix-controls.jpg`): F7, Up flies forward, + S turns right, Right strafes right, X rises, PgDn looks down, A turns left, Z sinks; F7 off → + normal camera behind the llama. +* TestLevel noclip (`img/mods/freecam-fix-noclip.jpg`): Up/Right/Left move the llama away/screen- + right/screen-left with the camera following, A orbits, X lifts it, F9 off → it lands and the game + camera takes over. +* TestLevel, freeze off: the game runs while the camera flies; arrows/X/Z/A/S do not move or turn + the llama; after F7 off it walks normally. +* Village 1 (`img/mods/freecam-fix-village.jpg`): flying up and back shows the village from above + (before the fix: only backdrop); noclip over the cliff edge with chase camera; pause menu with + the free camera on (menu navigation does not move the camera), no crash. +* Values checked with winedbg (`attach`, `x /4x`): control bits, camera/target, region mask. +* `FreeCamModTest` (practice + freecam on the real exe) passes; PracticeMod untouched. + +## Limits + +* After switching noclip off in the air, the llama falls to the floor below as usual. +* Enemies, water and triggers still work during noclip (a trigger may start a cutscene/dialog). +* F7/F9/Shift react even when the game window is not focused (GetAsyncKeyState). +* The region mask follows the llama: flying far into another room may show it without its own + region's objects, or the backdrop only where the level has no geometry (open sea around islands). +* A cutscene camera set via 0x57f8bc in the render function still wins over the free camera. +* No mouse look. diff --git a/notes/mod-introskip.md b/notes/mod-introskip.md new file mode 100644 index 0000000..02034bd --- /dev/null +++ b/notes/mod-introskip.md @@ -0,0 +1,56 @@ +# Intro skip (`intro-skip`, GAMEPLAY) + +`mods/introskip/IntroSkipMod` – groove.exe patches only, no data changes. + +| Option | Default | Patch | +|---|---|---| +| `logos` – skip startup logos | on | 0x41D56E `68 28 25` → `53 EB 62` | +| `demo` – disable attract demo | on | 0x549DC0 `76 41` → `EB 41` | +| `story` – skip story movies | off | 0x54AEC9 / 0x54B138 / 0x54B26F `E8 …` (call FUN_0041f630) → 5×`90` | + +## Startup sequence (WinMain FUN_0041d350) + +After DirectDraw/DirectInput/sound init and the (empty) `In` slide show (FUN_0043cb40(1)): + +``` +FUN_0041f630("disneylogompeg.dat") ; movies/, DirectShow; any key skips +if DAT_005865e4 (movie failed): ; set to 1 at playback start, 0 on end/skip + show disney.cor 3 s, arglogo.cor 3 s, movinfo.cor 8 s ; FUN_0041d2c0(name, ms), key skips +else + FUN_0041f630("argologompeg.dat") +show Englogo.cor 3 s ; title screen "Ein Königreich für ein Lama" +call FUN_00545030 ... ; 0x41D5D3, game init continues -> main menu +``` +The logo patch replaces `push offset "disneylogompeg.dat"` by `push ebx; jmp 0x41D5D3`. The extra +`push ebx` is needed: the skipped block leaves one dword (`push ebx` at 0x41D554) and the englogo +call's two arguments are cleaned together with a later `push 2Ch` by `add esp,0Ch` at 0x41D5E4. + +## Attract demo + +Main menu frame function 0x549C50 (state 2, table 0x578F28): if +`frame(0x584650) − lastInput(0x5863F4) > 0x708` (1800 frames) it sets the next mode 0x584700 = 4; +0x549E66 then switches to game state 4 and loads one of three demos round robin from table +0x578EF8 (16 bytes each: map, tribe, world, chapter; index 0x6D9BF8). Demo input files are +`wads/*.DEM` (u32 frames + 8 bytes per frame, FUN_004255b0). This is the only place that starts +a demo, so turning the `jbe` into `jmp` disables it completely. + +## Story movies + +`movies/%d%d%dINTRO.DAT` before a chapter (FUN_0054ae20, not in demo mode), `EULOGY.DAT` +(0x54B138) and `%d%d%dOUTRO.DAT` after a world (0x54B26F) all go through FUN_0041f630; the callers +clean the stack, so NOPing the call is safe. `CREDITS.dat` (0x54A5F1) is left alone. + +## Level fly-by – not implemented + +The letterbox fly-over at level start is driven by camera strats (strat VM), not by engine code; +the engine has no "skip cut scene" flag to set. Return skips it in game. Left out on purpose. + +## Measured (Wine 6, llvmpipe, Xvfb, loaded machine) + +| | main menu after launch | attract demo | +|---|---|---| +| original | 31.5 s (title screen 23.4–31.5 s) | starts after ~34 s idle | +| logos + demo | 22.3 s | none within 110 s | + +Under Wine the MPEG logo movies render black, so most of the saving is the title screen and the +movie attempts. Screenshot: `img/mods/introskip_menu.jpg`. diff --git a/notes/mod-levels.md b/notes/mod-levels.md new file mode 100644 index 0000000..bf88b3e --- /dev/null +++ b/notes/mod-levels.md @@ -0,0 +1,54 @@ +# Level projects as mods (`mods.levels`, category LEVELS) + +Every `.llamalevel` file (format: `notes/editor-format.md`) in `<data>/levels/` is a mod. +Code: `mods/levels/LevelProjectSource` (a `ModSource`), `LevelProjectMod`, `LevelProjects` +(folder, `importFile` for an "Import level…" button), `LoadingPicture`, `EditorCli`. + +| | | +|---|---| +| id | `level-<file name>` (lower case, non-alphanumerics → `-`) | +| name / description | level name, description and author from the project | +| version | first 16 hex digits of the file's SHA-256 (any change of the file rebuilds) | +| order | 60: after Test World (50, chapter 1), before Mirror (75) and the debug view (100), so those also apply to the level | +| option `chapter` (new levels only) | TestLevel (world 9) chapter 1..49; default = the project's `targetChapter`, or the next free one (from 2) if another project already uses it | + +Apply: the project is loaded against the **pristine** CD (`ctx.originalCd()`), built with +`editor.io.WadBuilder` and written to `wads/T1L9M0nn.wad` (new levels) or `wads/<base>.wad` +(edited originals). New levels also get a loading picture `T19<n>.COR` (thumbnail if present, +level name, "TestLevel – chapter n"). If the target was already changed by an earlier mod (Test +World on chapter 1, another level on the same chapter, two edits of one original level), the apply +stops with a clear message naming the file and what to do. + +World 9: `ModManager.world9Fixup` now also works when only a chapter other than 1 is provided: +the lowest provided chapter fills all missing chapters 1..49 (the map crashes on a missing +chapter WAD), loading pictures as before. To play chapter n: unlock it in the save editor (or +`--unlock <slot> 9 <n> --cursor`), then select it on the world-9 map. + +CLI (`EditorCli`): `--editor-import`, `--editor-new`, `--editor-build`, `--import-level`, see +editor-format.md. The registry reads the folder when it is created (restart the app / create a +new `ModRegistry` after importing). + +## Verified in game (apptest, display :7, Wine 6, 2026-11-06) + +* **Original imported → project → built** (`--editor-import t1l1m001`, mod `level-village`): the + WAD in cd-active is byte-identical to the CD file; Village 1 loads and plays (intro, Pacha). +* **Edited original** (`level-village-edited`: three coins moved, coins, charge boosters and three + CheckPointPosts added): loads and plays; the added post stands where placed (3 cells ahead of + the start, `img/mods/levels_01_village_edit.jpg`), the moved coins appear at their new places, + added coins are collected (coin HUD). Note: in Village 1 the coins and other objects within ~3 + cells of the start are not visible at the start (also in the original), so edits there are + hard to see. +* **New level from brushes** (`examples/testwelt.llamalevel` → `level-testwelt`, chapter 2): + loads from the world-9 map (slot shows "TestLevel Kapitel 2"), generated loading picture, plaza, + gallery objects, walking (`img/mods/levels_02_testwelt_chapter2.jpg`). Its geometry, objects, + waypoints and textures equal the Test World mod's WAD (unit test; only the generated death plane + model differs). +* **Mesh and texture import** (testwelt + an OBJ box with a PNG texture via MTL, a block with an + imported PNG texture, chapter 3): both render with their textures, the mesh has collision + (`img/mods/levels_03_mesh_import.jpg`). + +## Known limits + +* Exit doors (LevelDoor) keep their parameters; the test world's door leads to (1,9,1) = chapter 1 + (which then is a copy of the lowest provided chapter if no other mod provides it). +* New ModRegistry instances are needed to see newly imported projects. diff --git a/notes/mod-mirror.md b/notes/mod-mirror.md new file mode 100644 index 0000000..403129e --- /dev/null +++ b/notes/mod-mirror.md @@ -0,0 +1,70 @@ +# Mod "Mirror mode" (`mods.mirror.MirrorMod`, id `mirror`, GAMEPLAY) + +Mirrors every playable level left-right (x → W − x on the W×H tile grid), like the mirror mode of +racing games. Controls are unchanged (the world is mirrored, not the input). +Code: `mods/mirror/Mirror.java` (pure byte transform), `MirrorMod.java`; test `MirrorTest`. + +## Options + +| Key | Default | Meaning | +|---|---|---| +| `worlds` | `all` | `all` or world numbers, e.g. `1,3` (9 = TestLevel / Test World) | +| `readableTextures` | false | on: face flag 0x20 (mirror U) is toggled so writing reads normally, but textures that continue across faces get seams (fountain water rim in the village); off: true mirror image | +| `objects` | false | also mirror rigid STPC object models (verts, face planes, collision); skinned characters never | + +`order()` = 75: after `testworld` (50, so the Test World is mirrored too) and before +`debug-wireframe` (100, so the overlay is built from the mirrored collision and lines up). +Files: `wads/t1l<w>m<c>.wad` only (front end `t0*`, map `t6l6*` stay). + +## What is transformed (all chunk sizes unchanged → STPC absolute offsets stay valid) + +| Data | Transform | +|---|---| +| TRAK models (all, shared by tiles) | vertex x, normal x, face plane nx negated; bbox centre x negated, corner pairs (−x/+x) swapped + x negated; collision: i8 nx of all 5 planes negated (d unchanged; −128 → 127) | +| Face winding | **kept**. groove.exe sets `D3DRENDERSTATE_CULLMODE = D3DCULL_NONE` once (decomp ~line 31587, Device3 vtable +0x58) and culls in software with the stored face plane. Reordering vertices would break the fixed UV corner patterns (pattern 0x800+0x10 has no odd permutation that keeps the UVs). | +| MAP tiles (float A) | x → W − x, rot → (4096 − rot) & 0xfff | +| MAP tiles (fixed B) | x → W·4096 − x, rot as above (A and B stay exactly consistent) | +| Region grid, tile-swap block (cell x) | columns mirrored (cell → W − 1 − cell) | +| Strats | x → W·4096 − 1 − x (keeps `floor(x)` in the mirrored cell: LevelDoor compares cells), ry and rz → −angle, rx kept | +| Waypoints / camera nodes | same as strats (x, ry, rz) | +| 90-byte "env zones" | really **teleport/respawn entries** (FUN_0054d560 / FUN_0054de00): f0..2 = trigger cell (x,y,z), f3..5 = target position (fixed), f7 bits 4..11 = facing angle (bits 0/1 = y compare flags). Mirrored: cell x, position x, angle. Default entry (pos 1,1,1) untouched. f11 (+0x2c) = sky model yaw offset, kept. | +| LGHT (FUN_0042c180) | u32 n; per light u8 type, r, g, b; type 1 directional: 3 f32 → x negated; types 2/4 point: 3 f32 + 2 f32 + u8 → x → W − x | +| STPC (option) | rigid models (nBones = 0) incl. WFPC&0x100 extra models, same as TRAK models | +| Vertex colours | unchanged (vertex order unchanged) | + +Rotation rule: mirroring x conjugates rotations; about y and z they become −angle, about x +unchanged. 0 = facing +z, so facing (sin a, cos a) → (−sin a, cos a) = angle −a. + +Mirroring twice gives the original WAD byte for byte (except LGHT float rounding and −128 normals) – +checked in `MirrorTest`, together with: every render vertex and collision corner of every tile of +t1l1m001 lands exactly at the mirrored world position, region per tile cell preserved. + +## Not mirrored / known limits +* Strat bytecode is untouched: hard-coded offsets/directions in scripts (if any) are not mirrored. + Parameter-pool values are not interpreted (could contain coordinates/angles for some types). +* Object models stay unmirrored by default (placed and turned mirrored, so their collision matches + their own look). Skinned/animated characters are never mirrored (bone matrices and compressed + vertex deltas of the anim sets would need conjugation). +* Sky/background models (STPC, drawn camera-relative) are not mirrored. +* Attract-mode demos (`*.DEM`, recorded input) desync in mirrored levels (harmless). +* Textures are mirrored by default (true mirror); `readableTextures` flips them back per face, + which breaks textures that are laid out across several faces. +* **River intro fly-by (t1l3m001)**: the camera partly shows an empty grey view and the intro + dialog starts earlier than in the original. No crash; after the intro the river plays normally. + Most likely a hard-coded lateral camera offset in strat code (camera ends up in a cell whose + visibility zone hides the river). The village fly-by is correct. + +## In-game verification (apptest, display :7, 2026-11-06) +* **Village t1l1m001**: same key sequence on original and mirrored – a perfect mirror image + (house/fountain swapped sides), llama stands on the ground, walls/fountain rim block, coins + collect (counter matches the original run), Pacha dialog triggers, camera rotate (A) works, + intro fly-by (not skipped) correct. With `debug-wireframe` + `objects=true`: overlay lines up + with the mirrored geometry, no crash. +* **River t1l3m001**: loads, raft/tree floats on the mirrored river, auto-scroll and steering work, + coins collected, wireframe lines up; fly-by see limits. +* **City t1l6m001**: loads, intro cutscene with Pacha (cutscene cameras) fine, walls and crates + block, wireframe lines up. +* **Test World** (`testworld,mirror`, world 9): mirrored island (parkour stairs on the right), + walking/jumping fine. +* Images: `notes/img/mods/mirror_01_village.jpg` (original vs. mirror), `mirror_02_wireframe.jpg` + (village + city with the debug wireframe), `mirror_03_river_testworld.jpg`. diff --git a/notes/mod-practice.md b/notes/mod-practice.md new file mode 100644 index 0000000..0a59494 --- /dev/null +++ b/notes/mod-practice.md @@ -0,0 +1,90 @@ +# Mod "practice" – Practice tools (GAMEPLAY) + +Package `mods.practice` (`PracticeMod`, `SavePositionPatch`, `ExtraCheckpoints`, plus the helpers +`Asm` (extra x86 encodings on top of `exe/X86`) and `Hotkeys` (key choices), also used by `freecam`). + +## Options + +| Key | Default | Meaning | +|---|---|---| +| `saveKey` | F5 | store the llama's position (choices F1–F5, F7–F9, F11, F12, Insert, Delete, Home, End; F6 = game screenshot, F10 = system key are left out) | +| `loadKey` | F8 | put the llama back to the stored position | +| `checkpoints` | true | add extra CheckPointPost strats to the level WADs | +| `spacing` | 10 (4–40) | minimum distance (tiles) between two checkpoints | + +## Save / restore position (exe) + +Hook: `call FUN_0054c4a0` (strat update) at **0x54b77e** inside the tick loop of the frame function +FUN_0054b590 is redirected to a cave: + +1. poll `GetAsyncKeyState` (via `importAddress`, the game does not import it) with edge detection; +2. save: player strat `*(0x6d9e1c)` +0x30/+0x34/+0x38 (position, 20.12 fixed, z positive), + +0x24 (yaw), and the current world/chapter `0x584eb8`/`0x584ebc`; +3. restore (only if a position was saved in the same world/chapter and the player exists): write + position into +0x30.. **and** the previous-tick copy +0x50.. (the wall collision FUN_00403ee0 + sweeps from previous to current position, so the jump must not look like movement), yaw into + +0x24/+0x44; +4. call the original strat update; +5. for 4 ticks after a restore the position is written again after the update ("pin"), so the + remaining fall speed of the llama script is used up on the floor collision instead of moving + the llama away. + +Strat structure facts used (0x154 bytes per strat, FUN_0054c4a0/FUN_0054bc30): +0x04 next, ++0x20/24/28 rotation (angle<<12, 4096 = 360°), +0x30/34/38 position, +0x40..+0x5f copy of ++0x20..+0x3f from the previous tick, +0x70 3×3 matrix, +0x94/98/9c render position copy, ++0xc0 collision block, +0xe8 flags (0x8000 floor, 0x40000 wall collision). Velocity is not a +fixed field – movement comes from strat-script opcodes (e.g. FUN_00550690 = move along yaw), so +it lives in the llama's script variables. + +The stored position survives deaths and restarts of the same level; it is ignored in another +level. Keys are polled with GetAsyncKeyState, so they also react when the game window is not +focused. + +## Extra checkpoints (WAD) + +`ExtraCheckpoints.process`: for every `t[1-8]l*m*.wad` that already contains `CheckPointPost` +instances (STPC code + type constants available): + +* candidates = `Coin`/`Coin5` positions (the designers' route), sorted by distance from `Llama`; +* a candidate becomes a checkpoint if it is ≥ `spacing` from the start, every original and every + added checkpoint, and walkable floor (collision polygon with ny ≥ 0.7, material ≠ 30/17) lies + 0.8 below … 0.2 above the coin (`Floor.below`, world-space collision of all tiles); +* new strat = copy of the first CheckPointPost (code, nVars 12, slots 1, flags 0x28000, extra 0, + parameter value 0x333 appended to the parameter pool), yaw = direction from the nearest previous + checkpoint, `poolExtra` +1 per post (max 40 per level); +* each original checkpoint owns exactly one path node = respawn point (position + yaw, `next` 0, + ~0.3–0.5 tiles beside the post); new ones get a node 0.4 tiles ahead (or behind/sideways if + there is no floor ahead). + +The floor finder reproduces the height of 68 of 102 original posts within 0.05 (the rest stand +on moving platforms/strat models). With spacing 10, 32 checkpoints are added in 17 levels +(e.g. Village 1: +2, jungle day 3: +5). Levels without CheckPointPost (Village 3/4, river, +chase, roller coaster, lab 2/6) are unchanged. + +## Tests + +`PracticeModTest`: instruction encodings (checked against nasm), all WADs: original posts on +floor, strat/node/poolExtra counts after processing. `FreeCamModTest` applies practice + freecam +to the real exe (no overlapping patches) and writes `build/tmp/practice-freecam.exe` for objdump. + +## Verified in game (2026-11-06, Wine 6, apptest, Village 1 = t1l1m001) + +* F5 → walk away → F8: the llama is back at the stored spot (`img/mods/practice-teleport.jpg`); + memory check with winedbg: player +0x30.. equals the stored position after F8. +* After restoring, the camera is not snapped; it swings back behind the llama on its own (it may + look from an unusual angle for a moment). +* Together with `freecam`: no patch conflicts; pause menu, leaving to the map and re-entering the + level, falling off the island (respawn) and game over → no crash; the stored position stays + valid after the level is restarted. +* Extra checkpoints: levels load with the added posts; a new post renders at its place + (`img/mods/practice-extra-checkpoint.jpg`, Village 1, (42.91, 2.9, 50.84), seen with the free + camera). + +## Limits / not verified + +* Activation of an added checkpoint and respawning at its path node were **not** verified in game + (falling off the island does not use checkpoints; reaching a real death in the test harness was + not attempted). The strat record, parameter and node layout are copied 1:1 from the level's own + CheckPointPost, so it is expected to behave like the original ones. +* Posts are placed at coins with floor below; a coin on a moving platform or behind a one-way + section can give a post the llama only reaches from one side. diff --git a/notes/mod-randomizer.md b/notes/mod-randomizer.md new file mode 100644 index 0000000..604d7d6 --- /dev/null +++ b/notes/mod-randomizer.md @@ -0,0 +1,62 @@ +# Randomizer mod (`randomizer`, GAMEPLAY) + +Code: `src/main/java/com/eliasmueller/llamamod/mods/randomizer/` (`RandomizerMod`, `Shuffler`), +test `src/test/.../mods/randomizer/RandomizerModTest` (needs `-Dllamamod.cd=<cd dir>`). + +## Options + +| Key | Type | Default | Effect | +|---|---|---|---| +| `seed` | SEED | random on first use | same seed = same layout | +| `coins` | BOOL | on | `Coin`, `Coin5` | +| `food` | BOOL | on | `Health`, `HealthFull`, `Grapes`, `HealthBanana` | +| `mix` | BOOL | on | coins and food share one pool (otherwise each kind only among its own places) | +| `pots` | BOOL | off | `SmashPot(spawn)`, `Jar` among their own places | +| `enemies` | BOOL | off | enemies without patrol path, only among places of the same type | + +## How it works + +Per level WAD `t1l[1-8]mNNN.wad`: parse MAP (`MapChunk`, TRAK vertex counts), name every strat from +STPC (`Strats.nameOr`), sort eligible strats into pools, and permute the *places* inside each pool: +only fields 0–5 of the 58-byte strat record (rotation x/y/z, position x/y/z) are exchanged. Type +code, parameters, nVars/slots/flags/extra and the waypoint index stay with the strat, so the +multiset of objects in a level and the set of occupied places are unchanged – every pickup ends +on a spot where the original game had a pickup (reachable by construction). MAP is rebuilt with +`MapChunk.build()`; all other chunks are untouched. + +PRNG: `java.util.Random(seed * 0x9E3779B97F4A7C15 + levelFileName.hashCode())`, pools in MAP order, +`Collections.shuffle` – both are specified by the JDK, so a seed gives the same result everywhere. + +Never moved: +* every type not in the lists above (Llama/start, LevelDoor, CheckPoint*, Camera*, CutScene*, + triggers, Force*, switches/doors, RubyIdol (doors depend on it), ChargeBooster, CoinLine/Circle/ + RollerCoaster, Secret, platforms …), +* strats with a waypoint (index ≠ −1: patrol paths, secret areas – their coordinates are absolute) + and strats with flag bit 1 (spawned later by an event), +* pots within 3 tiles of a `Secret` (the village secret is "smash the pot cluster"). + +Pool sizes (all options on, seed 1): 10–65 pickups per level, up to 14 pots; enemies only in +t1l4m002/3 (`VenusFlyTrap`, `AxeGuard`) because almost all enemies have patrol waypoints. Roller +coaster levels (t1l7m001/3/5) and t1l8m006 are unchanged. + +Save game: coins per chapter are counted at pickup; the number of coins in a level is unchanged, +so the per-chapter totals and the "all coins" flag behave as in the original. + +## Verified in game (apptest, 2026-11-06) + +* Village t1l1m001, seed 1: level loads and plays, no crash (most swaps there are coin↔coin, so the + start view looks the same). +* Jungle t1l2m001, seed 2 (chosen because 3 food items land near the start): three of the four coins on + the path ahead of the start are replaced by Health/HealthFull pickups, the coins moved elsewhere + (`img/mods/randomizer-jungle-seed2.jpg`); level runs normally. +* Unit test (`RandomizerModTest`, all 33 WADs): deterministic per seed, different seeds differ, + pickup places are an exact permutation, type/params/waypoint fields unchanged, every other strat + (Llama, LevelDoor, CheckPoint*, cameras, triggers …) byte-identical, strats with waypoints never + move. + +## Known limits +* Not played to the level exit in game; correctness of the exit/checkpoints rests on them being + untouched (unit test). +* A coin that sat in the air in an arc may now be a food item there (still reachable, it was a + pickup spot before); first coin parameter (0x1000 vs 0) stays with the strat. +* Enemy shuffle has little effect: almost all enemies have patrol waypoints and are excluded. diff --git a/notes/objects.md b/notes/objects.md new file mode 100644 index 0000000..8b0cb6c --- /dev/null +++ b/notes/objects.md @@ -0,0 +1,135 @@ +# MAP-Chunk & Objekte (Strats) – "Ein Königreich für ein Lama" (PC) + +Quelle: groove.exe, MAP-Lader `FUN_0042ac50`, Strat-Spawn `FUN_0054cfc0` → `FUN_0054bfc0`, +Strat-VM `FUN_0054d180`, Chunk-Dispatcher bei 0x558960. Werkzeuge in `../tools/`. +Alle 34 Original-MAPs werden von `mapchunk.parse()` vollständig gelesen (Rest 0 Byte) und +von `mapchunk.build()` **byte-identisch** zurückgeschrieben. + +## Begriffe +* **Tile** = platziertes Geometrie-Modell aus **TRAK** (Level-"Baukasten", 1x1-Raster). +* **Strat** = Objekt/Script-Instanz (Gegner, Münze, Tür, Spieler …). Der Strat-Code + (Bytecode, u32-Wörter) und die Objektmodelle liegen in **STPC**; MAP enthält nur Instanzen + mit Offset in STPC. Es gibt **keine Typ-ID** – der Typ *ist* die Code-Adresse. + Typnamen stehen im Code: Opcode `0xd4, 0xea, s32 rel` → Name = C-String bei + (Adresse hinter rel) + rel. `strats.py` löst das auf. +* Koordinaten: Welt-y nach **oben**. Tiles speichern float x, y, z mit **z negativ**; + Strats/fixed-point (20.12, 4096 = 1.0) speichern z **positiv** (= −tile.z). + Winkel: 4096 = 360°. Boden-Tiles (flaches Modell, Vertex-y = +0.25) → Objekte stehen auf + `tile.y + 0.25` (bestätigt an Spieler/Checkpoints/Münzen). + +## Layout MAP (alles little endian) — Konfidenz: hoch (aus Lader + Roundtrip) +WFPC (u32 Flags, globale 0x6da330) steuert optionale Blöcke. + +| # | Block | Format | +|---|---|---| +| 1 | Kopf | u32 nTiles, u32 W, u32 H (Rastergröße in Zellen) | +| 2 | Tiles | nTiles × {f32 x, f32 y, f32 z, u32 rotY(0/1024/2048/3072), u32 modell, u32 hatAlt} | +| 3 | Parameterpool | u32 n, n × u32 (Strat-Parameter, von Strats per Index referenziert) | +| 4 | Zonen ("Environment") | u32 n, n × 90 Byte (s.u.) | +| 5 | Wegpunkte | u32 n, n × 34 Byte {u32 weiter(≠0: nächster Eintrag gehört zur selben Kette, 0 = Kettenende), u32, u16 rx, u16 ry, u16 rz, s32 x, y, z (fixed, z positiv), u32, u32} | +| 6 | Regionstabelle | 32 × {u32 flags, u32 wert} (→ 0x6da350) | +| 7 | Regionsraster | W×H × u32 Regionsindex (0..31) je Zelle, Zeile = z-Zelle | +| 8 | Tiles fixed | nTiles × 6 u32 {rx=0, rotY, rz=0, x·4096, y·4096, −z·4096} – **exakt aus Block 2 ableitbar** | +| 9 | Tile-Modelle | nTiles × u32 TRAK-Modellindex (= Feld 5 aus Block 2) | +| 10 | Alt-Modelle (WFPC&0x10000) | u32 n, n × 5 u32; n = Anzahl Tiles mit hatAlt≠0, in Tile-Reihenfolge; Feld 4 = alternatives TRAK-Modell (z. B. Tür offen/zu, stMapreplace) | +| 11 | Vertexfarben | je Tile: nv(TRAK-Modell) × u32 RGBA (Byte 3 = Frameanzahl F); wenn max F>1: (F−1)×nv weitere u32; danach dasselbe für das Alt-Modell, falls vorhanden. Farbe wird ×2 (Clamp 255) | +| 12 | Strats | u32 nStrats, u32 poolExtra, nStrats × 58 Byte (s.u.) | +| 13 | Pfade (WFPC&0x200000) | u32 n; je: u32 nsub; je sub: u16 cnt, u16 flags; cnt × {u32,u32,[u32 wenn WFPC&0x4000000],u16 typ,u16} – in keinem Original-Level benutzt | +| 14 | (WFPC&0x10) | u32 (Default 200; Größe eines 0x48-Puffers) | +| 15 | | u16 (→ mapobj+0xc, Bedeutung offen) | + +Zellenzuordnung Tile: `col = int(x−0.5)`, `row = −int(z+0.5)`, Index `col + row·W` (verkettete +Liste je Zelle). **Tiles außerhalb 0..W−1 / 0..H−1 schreiben in fremden Speicher.** + +### Strat-Eintrag (58 Byte) — `mapchunk.STRAT = '3H3iIIi4Iiih'+'H'` +| Off | Typ | Feld | Konfidenz | +|---|---|---|---| +| 0 | u16 ×3 | Rotation x, y, z (4096=360°; x/z fast immer 0) | hoch | +| 6 | s32 ×3 | Position x, y, z fixed 20.12 (z positiv) | hoch | +| 18 | u32 | **Code-Offset in STPC** (= Objekttyp) | hoch | +| 22 | u32 | Anzahl Parameter n | hoch | +| 26 | s32 | Index in Parameterpool (−1 wenn n=0); Parameter werden auf den Strat-Stack gelegt | hoch | +| 30 | u32 | Anzahl Strat-Variablen (vom Compiler, **typkonstant**) | hoch | +| 34 | u32 | Anzahl 16-Byte-Slots (Kollision/Unterobjekte; typkonstant) | mittel | +| 38 | u32 | Typflags (0x8000/0x80000/0x40000 = Kollisions-/Physikblock; typkonstant) | mittel | +| 42 | u32 | Zusatzspeicher (&0x3fffffff, meist 0..3; typkonstant) | mittel | +| 46 | s32 | Wegpunkt-Index (Block 5) oder −1 | hoch | +| 50 | u32 | immer 0 | – | +| 54 | u16 | Flags; Bit 1 (2) = beim Levelstart nicht erzeugen | hoch | +| 56 | u16 | immer 0 | – | + +`poolExtra` (zweites u32 im Strat-Kopf) = max. gleichzeitige Strats − 30 (Engine legt +poolExtra+30 Slots à 0x154 Byte an). **Muss ≥ Anzahl Strats + dynamisch gespawnte sein.** + +### Zonen (90 Byte, im Speicher 0x5c) — Konfidenz mittel +u32 cellX, cellY, cellZ, s32 x,y,z (fixed), u32 ?, u32 rot?, u32 flags(+0x20; **Bit 8 = Startzone** +→ DAT_00584644), u32, u32 ptr-Offset in STPC (0=keiner), u32, u32, u32, u16, u32 Nebel nah/fern +(+0x3c/+0x40), u32 ×2 (+0x44/+0x48), u32 Farbe (z. B. 0x404040), u32 Farbe, u16, u16, u32 Musik-ID +(+0x58; FUN_00546b70). Kamera-/Nebel-/Musikbereiche; nicht Lichtquellen (die stehen in LGHT). + +## Spieler, Checkpoints, Ausgang +* **Spielerstart** = Position/Rotation der Strat-Instanz **`Llama`** (genau 1× in jedem + Lauf-Level; Typfelder nvars 78, flags 0x248000). Sonderlevel: Fluss (t1l3*) = + `KuscoAndPachaTogetherOnATree`, Achterbahn (t1l7m001/3/5) = `RollerCoaster`, + t1l2m003 = `LlamaChase`. +* **Checkpoints** = `CheckPointPost` (spawnt `CheckPointHead`), Variante `CheckPointBox`. +* **Ausgang** = `LevelDoor` (1×/Level), Sonderfälle `VillageEndDoor`, `EndGameDoor`. +* Immer vorhanden: `CheatTeleport`, `Wompy` (Funktion nicht geprüft), `CutSceneTrigger`. + +## Objekttypen +Vollständige Tabelle Typ × Level (Instanzzahlen): `strat_types_table.md` +(erzeugt mit `tools/strat_table.py <wads>`). Kurz: +* in praktisch allen Levels: Coin, CutSceneTrigger, CutSceneCameraDirectional, LevelDoor, + Secret, Llama, CheatTeleport, ChargeBooster, Wompy, CheckPointPost, Coin5, CameraSide, Health, + HealthFull, SmashPot, RubyIdol, Grapes, HealthGenerator, RubyIdolDoor … +* ~300 Typen insgesamt, ~200 weitere nur intern gespawnt (Splitter, Seile, Effekte). +* **Jeder Level hat seinen eigenen kompilierten Strat-Code + Modelle in STPC.** Ein Typ ist + nur in Levels platzierbar, deren STPC ihn enthält (`strats.py level.wad --all` listet alle + benannten Strats im Code, auch nie direkt platzierte). Der Code enthält **absolute + STPC-Offsets** (z. B. Spawn-Stubs `b2 <abs> e0 …`), STPC ist also nicht verschiebbar, + ohne zu relozieren. Typen aus anderen Levels zu übernehmen = STPC (+ TEXT-Indizes) + zusammenführen → aufwendig, vorerst nicht. +* "Spawn-Stub"-Einträge (z. B. SmashPot): MAP zeigt auf einen kleinen Code + `push X; spawn(abs Ziel, nvars, …); end` → `strats.py` meldet `Name(spawn)`. + +## Regeln beim Schreiben einer neuen MAP +1. Tile-Blöcke 2/8/9/11 parallel halten (gleiche Anzahl/Reihenfolge); Block 8 aus 2 ableiten; + Block 9 = Feld 5 aus 2; Vertexfarben-Länge exakt = Σ nv(Modell)·Frames (+Alt). +2. Alt-Block: Anzahl = Tiles mit hatAlt≠0, gleiche Reihenfolge. Neue Tiles einfach hinten + anhängen (Strats können Tiles per Index ersetzen – `stMapadd/stMapreplace` – deshalb + bestehende Indizes nicht umsortieren). +3. Alle Tiles in 0..W × 0..H (z negativ!). Regionsraster W·H Einträge. +4. Strat: Code-Offset muss Strat-Anfang in **diesem** STPC sein; nvars/slots/flags/extra + **vom Original-Typ kopieren**; Parameter in Pool anhängen, Index/Anzahl setzen; Wegpunkt + −1 oder gültiger Index (manche Typen brauchen Wegpunkte, sonst "removing strat"). +5. poolExtra erhöhen, wenn mehr Strats. +6. WFPC-Flags bestimmen, welche optionalen Blöcke vorhanden sein müssen – nicht ändern. + +## Werkzeuge +* `mapchunk.py` – parse/build (Roundtrip identisch für alle 34 MAPs), `vcol_split` je Tile. +* `map_dump.py level.wad [--strats --tiles --lights]` +* `strats.py level.wad [--all]` – Typen mit Instanzzahlen, Code-Offsets, Typfeldern. +* `strat_table.py wads_dir` – Typ×Level-Tabelle. +* `stpcwalk.py` – STPC grob ablaufen (Modelle → Animsets → [WFPC&0x100-Block] → Strat-Code). +* `make_testlevel.py quelle.wad ziel.wad [--name …] [--spacing 3] [--floor-model N] [--keep-original]` + – sucht freien Rasterbereich, legt Bodenplatte aus Kopien des häufigsten flachen + Boden-Tiles (t1l1m001: TRAK-Modell 12) an, stellt je Typ eine Instanz (Parameter vom + Original) im 6er-Raster auf, Spieler an den Rand, Parkour (6 Trittplatten +0.5 mit + Lücken, Münzen) zu einer Zielplattform mit LevelDoor. Ausgelassen: Camera*/CutScene*/ + Force*/Trigger. Test (nicht im Spiel geprüft): T1L9M001 → 1753 Tiles, 36 Strats, 27 Typen. + +## Andere Chunks (Dispatcher 0x558960) +* WFPC: u32 Feature-Flags (→ 0x6da330), z. B. 0x18410690. +* INFO/VERS: je u32 1 (ignoriert). +* NAME: u32 + Levelname. +* TRAK: Tile-Modelle (u32 n, n×0x84-Header, dann Daten; Header+0x6c u16 nVert, +0x6e nFace(0x1c), + +0x78/7a/7c Anzahl 0x20-Primitive). Face+8 u16 = Index in TEXT-Texturliste. +* STPC: u32 n Modelle (je 0x8c-Header direkt + Daten), u32 n Animsets (0x20-Header + Daten), + [WFPC&0x100: Zusatzmodelle], dann Strat-Code bis Chunkende. +* LGHT: u32 n; je Licht u8 typ, u8 r, u8 g, u8 b (×2), Typ 1/2/…: weitere u32 (Richtung/Position, + z negiert). FUN_0042c180. +* LNFO: 2 u32 (→ 0x5ff070, 0x5ff060), z. B. 55, 2. +* LGPC: u32 n(+1), u32 m, u32; dann (n+1)·m × u32 Größen, danach die Blobs. FUN_00558db0. +* FONT: 256 × 4 u16 (Glyphen-Rechtecke). SPRT: u32 (bei WFPC&0x100000 Liste). +* SMPC / AMPC / SRPC: Soundbänke ("cvg"-Samples / "ambi" Ambient / Referenzen), FUN_00545350. +* 'GRIM' (nicht in Level-WADs gesehen): FUN_00437b40. diff --git a/notes/strat_types_table.md b/notes/strat_types_table.md new file mode 100644 index 0000000..c0576b5 --- /dev/null +++ b/notes/strat_types_table.md @@ -0,0 +1,342 @@ +| Typ | Level (MAP-Instanzen) | Σ | im Code von n Levels | +|---|---|---|---| +| Coin | 1l1m001:36 1l1m002:29 1l1m003:11 1l1m004:6 1l2m001:12 1l2m002:26 1l2m003:23 1l3m001:45 1l3m002:55 1l3m003:25 1l3m004:20 1l4m001:54 1l4m002:28 1l4m003:41 1l5m001:32 1l5m002:22 1l5m003:29 1l6m001:19 1l6m002:33 1l6m003:1 1l7m002:39 1l7m004:46 1l8m001:27 1l8m002:45 1l8m003:10 1l8m004:46 1l8m005:27 | 787 | 27 | +| CutSceneTrigger | 1l1m001:1 1l1m002:1 1l1m003:1 1l1m004:1 1l2m001:1 1l2m002:1 1l2m003:1 1l3m001:1 1l3m002:1 1l3m003:1 1l3m004:1 1l4m001:1 1l4m002:1 1l4m003:1 1l5m001:1 1l5m002:1 1l5m003:1 1l6m001:5 1l6m002:5 1l6m003:3 1l7m002:1 1l7m004:2 1l8m001:1 1l8m002:1 1l8m003:1 1l8m004:7 1l8m005:2 | 45 | 27 | +| CutSceneCameraDirectional | 1l1m001:5 1l1m002:2 1l1m003:3 1l1m004:2 1l2m001:3 1l2m003:1 1l3m001:1 1l3m002:4 1l3m003:3 1l3m004:2 1l4m001:2 1l4m003:1 1l5m001:1 1l5m002:6 1l5m003:4 1l6m001:4 1l6m002:4 1l6m003:5 1l7m002:4 1l7m004:3 1l8m001:4 1l8m002:2 1l8m003:1 1l8m004:7 1l8m005:10 | 84 | 27 | +| LevelDoor | 1l1m001:1 1l1m002:1 1l1m003:1 1l1m004:1 1l2m001:1 1l2m002:1 1l3m001:2 1l3m002:2 1l3m003:4 1l3m004:2 1l4m001:1 1l4m002:1 1l4m003:1 1l5m001:1 1l5m002:1 1l5m003:1 1l6m001:1 1l6m002:1 1l6m003:1 1l7m002:1 1l7m004:1 1l8m001:1 1l8m002:1 1l8m003:1 1l8m004:1 | 31 | 25 | +| Secret | 1l1m001:2 1l1m002:1 1l1m004:1 1l2m001:1 1l2m002:1 1l3m001:1 1l3m002:1 1l3m003:1 1l4m001:1 1l4m002:2 1l4m003:2 1l5m001:2 1l5m002:4 1l5m003:1 1l6m001:1 1l6m002:1 1l6m003:6 1l7m002:5 1l7m004:3 1l8m001:2 1l8m002:1 1l8m003:1 1l8m004:3 1l8m005:1 | 45 | 26 | +| Llama | 1l1m001:1 1l1m002:1 1l1m003:1 1l1m004:1 1l2m001:1 1l2m002:1 1l4m001:1 1l4m002:1 1l4m003:1 1l5m001:1 1l5m002:1 1l5m003:1 1l6m001:1 1l6m002:1 1l6m003:1 1l7m002:1 1l7m004:1 1l8m001:1 1l8m002:1 1l8m003:1 1l8m004:1 1l8m005:1 1l8m006:1 | 23 | 23 | +| CheatTeleport | 1l1m001:1 1l1m002:1 1l1m003:1 1l1m004:1 1l2m001:1 1l2m002:1 1l4m001:1 1l4m002:1 1l4m003:1 1l5m001:1 1l5m002:1 1l5m003:1 1l6m001:1 1l6m002:1 1l6m003:1 1l7m002:1 1l7m004:1 1l8m001:1 1l8m002:1 1l8m003:1 1l8m004:1 1l8m005:1 | 22 | 26 | +| ChargeBooster | 1l1m001:5 1l1m002:4 1l1m003:5 1l1m004:2 1l2m001:3 1l2m002:3 1l4m001:8 1l4m002:16 1l4m003:6 1l5m001:9 1l5m002:7 1l5m003:18 1l6m001:14 1l6m002:13 1l7m002:30 1l7m004:2 1l8m001:13 1l8m003:2 1l8m004:28 1l8m005:38 | 226 | 20 | +| Wompy | 1l1m001:1 1l1m002:1 1l1m003:1 1l1m004:1 1l2m001:1 1l2m002:1 1l4m001:1 1l4m002:1 1l4m003:1 1l5m001:1 1l5m002:1 1l5m003:1 1l6m001:1 1l6m002:1 1l6m003:1 1l7m002:1 1l8m001:1 1l8m003:1 1l8m004:1 1l8m005:1 | 20 | 20 | +| CheckPointPost | 1l1m001:6 1l1m002:5 1l2m001:2 1l2m002:4 1l4m001:9 1l4m002:7 1l4m003:2 1l5m001:3 1l5m002:3 1l5m003:5 1l6m001:4 1l6m002:10 1l6m003:10 1l7m002:12 1l7m004:2 1l8m001:2 1l8m003:2 1l8m004:7 1l8m005:7 | 102 | 19 | +| Coin5 | 1l1m004:4 1l2m003:1 1l3m004:4 1l4m001:1 1l4m002:7 1l4m003:3 1l5m001:3 1l5m002:3 1l5m003:4 1l6m001:5 1l6m002:2 1l6m003:11 1l7m002:5 1l7m004:8 1l8m001:6 1l8m002:7 1l8m003:5 1l8m004:1 1l8m005:1 | 81 | 19 | +| CameraSide | 1l1m001:1 1l1m002:4 1l2m002:2 1l2m003:1 1l4m001:3 1l4m002:1 1l4m003:4 1l5m001:4 1l5m002:2 1l6m001:1 1l6m002:3 1l6m003:6 1l7m004:1 1l8m003:1 1l8m004:2 1l8m005:3 | 39 | 16 | +| Health | 1l1m002:4 1l2m001:3 1l2m002:3 1l4m001:5 1l4m002:7 1l4m003:4 1l5m002:4 1l5m003:4 1l6m001:6 1l6m002:11 1l7m002:11 1l7m004:3 1l8m001:3 1l8m003:7 1l8m004:13 1l8m005:8 | 96 | 16 | +| HealthFull | 1l1m001:1 1l1m002:1 1l2m001:1 1l2m002:1 1l4m001:1 1l5m001:3 1l6m001:4 1l6m002:1 1l7m002:3 1l7m004:1 1l8m001:1 1l8m002:1 1l8m003:1 1l8m004:2 1l8m005:1 | 23 | 15 | +| SmashPot(spawn) | 1l1m001:13 1l1m002:2 1l1m003:3 1l1m004:5 1l2m001:4 1l2m002:14 1l5m001:6 1l5m002:3 1l5m003:2 1l6m001:7 1l6m003:17 1l8m001:4 1l8m004:8 1l8m005:6 | 94 | 14 | +| RubyIdol | 1l1m002:1 1l2m002:1 1l4m001:1 1l4m003:1 1l5m001:1 1l5m003:1 1l6m001:1 1l6m002:4 1l7m002:1 1l8m001:3 1l8m003:1 1l8m004:2 1l8m005:2 | 20 | 14 | +| Grapes | 1l1m002:7 1l2m001:2 1l2m002:9 1l4m001:4 1l4m002:5 1l4m003:4 1l5m002:4 1l6m002:12 1l6m003:20 1l7m002:2 1l7m004:2 1l8m005:4 | 75 | 12 | +| HealthGenerator | 1l1m001:2 1l1m002:1 1l4m001:1 1l4m002:1 1l4m003:1 1l6m002:6 1l6m003:5 1l7m002:3 1l7m004:2 1l8m001:1 1l8m004:4 1l8m005:1 | 28 | 12 | +| RubyIdolDoor | 1l1m001:2 1l1m002:2 1l1m003:1 1l2m002:2 1l4m001:1 1l4m002:1 1l4m003:1 1l5m001:1 1l5m003:2 1l6m001:3 1l6m002:2 1l7m004:1 | 19 | 12 | +| WaterReboundPoints | 1l1m001:3 1l2m001:2 1l2m002:7 1l4m001:5 1l4m002:14 1l4m003:3 1l5m002:1 1l6m001:3 1l6m002:6 1l7m002:3 1l7m004:1 | 48 | 11 | +| ForcePlayerMovement | 1l1m003:1 1l5m003:1 1l6m003:1 1l7m002:1 1l7m004:2 1l8m001:2 1l8m002:2 1l8m003:2 1l8m004:1 1l8m005:1 | 14 | 10 | +| CameraLift | 1l1m002:2 1l3m001:11 1l3m002:12 1l3m003:11 1l4m003:1 1l5m002:1 1l8m001:3 1l8m002:2 1l8m004:2 | 45 | 9 | +| JumpingBeetleBlue | 1l1m002:6 1l1m003:3 1l1m004:6 1l2m001:3 1l4m001:3 1l4m002:4 1l5m002:2 1l5m003:1 1l8m004:8 | 36 | 9 | +| ObjectDontDropInBox | 1l1m001:3 1l1m002:4 1l1m003:3 1l2m001:2 1l4m003:1 1l5m001:2 1l5m002:2 1l6m002:2 1l7m004:2 | 21 | 9 | +| CityGuard | 1l1m002:1 1l2m002:4 1l5m001:1 1l5m002:3 1l6m001:5 1l6m002:11 1l8m004:1 | 26 | 7 | +| Jar | 1l1m001:6 1l1m002:1 1l1m003:2 1l6m002:3 1l6m003:2 1l7m004:1 1l8m003:1 | 16 | 7 | +| Ladybird | 1l1m001:2 1l1m002:3 1l1m003:1 1l1m004:2 1l4m001:1 1l4m003:2 1l5m001:1 | 12 | 7 | +| Butterfly | 0i0m000:2 1l1m002:1 1l2m001:1 1l4m001:9 1l4m002:1 1l4m003:2 | 16 | 6 | +| FireworksTrigger | 1l7m001:1 1l7m003:1 1l7m005:1 1l8m002:1 1l8m003:1 1l8m006:1 | 6 | 6 | +| PressurePadDoor | 1l1m001:2 1l1m002:1 1l1m003:2 1l2m001:1 1l4m003:2 1l5m001:1 | 9 | 6 | +| WompySimple | 1l2m003:1 1l3m001:1 1l3m002:1 1l3m003:1 1l7m004:1 1l8m002:1 | 6 | 7 | +| CoinLine | 1l1m002:1 1l1m003:1 1l2m003:3 1l5m001:1 1l7m004:3 | 9 | 5 | +| FlockOfBirds | 1l1m001:1 1l2m001:1 1l3m001:1 1l3m003:1 1l5m001:1 | 5 | 5 | +| WaterfallMist | 1l2m001:1 1l3m001:8 1l3m002:13 1l3m003:6 1l3m004:6 | 34 | 5 | +| AxeGuard | 1l4m002:2 1l4m003:1 1l5m002:1 1l5m003:1 | 5 | 4 | +| Bats | 1l3m002:7 1l3m003:1 1l5m001:1 1l5m002:3 | 12 | 4 | +| CameraFixedPointBox | 1l3m004:1 1l7m001:1 1l7m003:1 1l7m005:1 | 4 | 4 | +| CataSwingingBlade | 1l7m002:38 1l7m004:2 1l8m001:6 1l8m002:8 | 54 | 4 | +| CityGuardWithRubyIdol | 1l2m001:1 1l5m001:1 1l7m002:1 1l8m004:1 | 4 | 4 | +| KuscoAndPachaTogetherOnATree | 1l3m001:1 1l3m002:1 1l3m003:1 1l3m004:1 | 4 | 4 | +| Moth | 1l3m002:4 1l3m004:2 1l8m001:9 1l8m002:10 | 25 | 4 | +| RiverWaterfallCamera | 1l3m001:1 1l3m002:3 1l3m003:1 1l3m004:2 | 7 | 4 | +| CheckPointBox | 1l4m003:1 1l6m003:1 1l7m004:1 | 3 | 3 | +| CoinCircle | 1l7m002:3 1l8m004:1 1l8m005:4 | 8 | 3 | +| CoinRollerCoaster | 1l7m001:35 1l7m003:40 1l7m005:50 | 125 | 3 | +| DrippingWater | 1l3m001:2 1l3m002:14 1l3m003:4 | 20 | 3 | +| FallOffNoHurt | 1l1m003:2 1l1m004:1 1l6m003:1 | 4 | 3 | +| ForcePlayerInsideBox | 1l7m004:2 1l8m004:5 1l8m005:4 | 11 | 3 | +| JungleDartLauncher | 1l4m001:2 1l4m002:3 1l8m001:1 | 6 | 3 | +| LabSinkingPlatform | 1l7m002:1 1l8m003:5 1l8m004:9 | 15 | 3 | +| PachaInfoCity | 1l6m001:1 1l6m002:1 1l6m003:2 | 4 | 3 | +| PotionLlama | 1l4m003:1 1l7m004:2 1l8m003:1 | 4 | 3 | +| RandomBird2 | 1l1m002:1 1l1m003:4 1l4m002:1 | 6 | 3 | +| RandomBird3 | 1l1m002:1 1l1m003:4 1l4m001:1 | 6 | 3 | +| RiverBats | 1l3m001:1 1l3m002:4 1l3m004:1 | 6 | 3 | +| RiverJumpingMonkeyRed | 1l3m001:5 1l3m002:7 1l3m003:2 | 14 | 3 | +| RiverLog | 1l3m001:38 1l3m002:8 1l3m003:3 | 49 | 3 | +| RiverRebound | 1l3m001:4 1l3m002:4 1l3m003:7 | 15 | 3 | +| Rock | 1l2m001:1 1l2m002:2 1l4m003:2 | 5 | 3 | +| RollerCoaster | 1l7m001:1 1l7m003:1 1l7m005:1 | 3 | 3 | +| RollerDoor | 1l7m001:2 1l7m003:11 1l7m005:1 | 14 | 3 | +| RubyIdolDoorCata | 1l7m002:4 1l8m001:2 1l8m003:1 | 7 | 3 | +| ScorpionSmashPot | 1l5m001:1 1l5m002:1 1l6m001:3 | 5 | 3 | +| Spider | 1l2m001:3 1l2m002:7 1l5m001:8 | 18 | 3 | +| TargetRopePlatform | 1l2m002:18 1l4m002:1 1l5m002:1 | 20 | 3 | +| TentaclePlant | 1l4m001:2 1l4m002:12 1l4m003:4 | 18 | 3 | +| TipoVillage | 1l1m001:4 1l1m002:4 1l1m004:1 | 9 | 3 | +| VenusFlyTrap | 1l4m001:2 1l4m002:8 1l4m003:5 | 15 | 3 | +| VillagerHerder | 1l1m001:1 1l1m003:1 1l4m002:1 | 3 | 3 | +| WaspNew | 1l4m001:3 1l4m002:7 1l4m003:2 | 12 | 3 | +| WompyRollerCoaster | 1l7m001:1 1l7m003:1 1l7m005:1 | 3 | 3 | +| AppearingPlatforms | 1l7m002:1 1l7m004:2 | 3 | 2 | +| Bucky | 1l2m001:1 1l2m002:3 | 4 | 2 | +| Bunny | 1l1m001:11 1l1m002:6 | 17 | 2 | +| BunnyMurderPuzzle | 1l1m003:1 1l1m004:1 | 2 | 2 | +| CameraFocusPoint | 1l2m002:1 1l5m002:1 | 2 | 2 | +| CameraForceInsideBox | 1l7m002:2 1l8m004:1 | 3 | 2 | +| CameraSetDistance | 1l8m002:1 1l8m005:1 | 2 | 2 | +| ChargingDoor | 1l8m004:2 1l8m005:3 | 5 | 2 | +| CircleDoor | 1l8m004:1 1l8m005:1 | 2 | 2 | +| CitySwingingBlade | 1l6m002:8 1l6m003:1 | 9 | 2 | +| CoinBat | 1l7m002:3 1l7m004:2 | 5 | 2 | +| FallingBridge | 1l5m001:7 1l5m002:6 | 13 | 2 | +| FlyHover | 1l4m002:2 1l4m003:1 | 3 | 2 | +| GoldIdolDoor | 1l1m004:1 1l6m001:1 | 2 | 2 | +| GorillaGuard | 1l8m001:2 1l8m005:3 | 5 | 2 | +| HealthBanana | 1l3m001:9 1l3m003:2 | 11 | 2 | +| HelpGirlRiver | 1l3m001:1 1l3m002:2 | 3 | 2 | +| JumpingBeetle | 1l1m002:4 1l4m002:2 | 6 | 2 | +| LabFallingPits | 1l8m001:1 1l8m004:1 | 2 | 2 | +| LabJengaSpiral | 1l8m004:2 1l8m005:1 | 3 | 2 | +| LabLift | 1l8m004:10 1l8m005:2 | 12 | 2 | +| LabSwingingBlade | 1l8m004:15 1l8m005:2 | 17 | 2 | +| LabWall | 1l8m001:1 1l8m002:1 | 2 | 2 | +| MerryGoRound | 1l5m001:1 1l5m002:1 | 2 | 2 | +| MountainRock | 1l5m001:1 1l5m002:1 | 2 | 2 | +| MouthPlatform | 1l6m002:3 1l6m003:2 | 5 | 2 | +| MusicBox | 1l3m003:1 1l5m003:4 | 5 | 2 | +| ObjectSuckPoints | 1l6m002:1 1l6m003:4 | 5 | 2 | +| PachaInfoCatacombs | 1l7m002:2 1l7m004:2 | 4 | 2 | +| PachaInfoJungle | 1l4m001:2 1l4m003:1 | 3 | 2 | +| PachaInfoLab | 1l8m001:1 1l8m004:1 | 2 | 2 | +| Rain | 1l2m001:1 1l6m001:1 | 2 | 2 | +| RandomBird1 | 1l1m003:2 1l5m001:1 | 3 | 2 | +| RiverCroc | 1l3m001:23 1l3m002:20 | 43 | 2 | +| RiverCurrentBox | 1l3m002:4 1l3m004:3 | 7 | 2 | +| RiverDude1 | 1l3m001:1 1l3m003:1 | 2 | 2 | +| RiverHangingSpider | 1l3m001:2 1l3m002:4 | 6 | 2 | +| RiverSalamander | 1l3m001:1 1l3m003:1 | 2 | 2 | +| RiverStartSign | 1l1m003:1 1l3m003:2 | 3 | 2 | +| RubyIdolDoorCracked | 1l2m001:1 1l4m003:1 | 2 | 2 | +| RubyIdolDoorLab | 1l8m004:2 1l8m005:2 | 4 | 2 | +| SayCrocodiles | 1l3m001:2 1l3m002:1 | 3 | 2 | +| SayPullingBack | 1l3m001:1 1l3m002:1 | 2 | 2 | +| SaySlowDown | 1l3m001:1 1l3m002:2 | 3 | 2 | +| Scorpions3 | 1l5m001:2 1l5m002:1 | 3 | 3 | +| SmashPotPuzzle | 1l1m003:1 1l6m001:1 | 2 | 2 | +| Smoker | 0i0m000:1 1l4m001:1 | 2 | 2 | +| SneakingGuard | 1l6m002:5 1l6m003:7 | 12 | 2 | +| SpitPlatformTarget | 1l6m002:13 1l6m003:11 | 24 | 2 | +| SummonPlatform | 1l6m002:1 1l6m003:4 | 5 | 2 | +| SwitchPlatformPad | 1l7m002:3 1l7m004:1 | 4 | 2 | +| TargetRopePlatformCity | 1l6m001:2 1l6m002:3 | 5 | 3 | +| TipoCatacombs | 1l7m002:1 1l7m004:1 | 2 | 2 | +| TipoJungleDay | 1l4m001:1 1l4m003:1 | 2 | 2 | +| TipoRiver | 1l3m001:1 1l3m003:1 | 2 | 2 | +| Washing | 1l1m003:1 1l1m004:1 | 2 | 2 | +| ?@0xbb538 | 1l2m003:1 | 1 | 0 | +| Alligator | 1l4m002:1 | 1 | 1 | +| AlligatorSpawner | 1l6m003:2 | 2 | 1 | +| AppearingPlatformsCity | 1l6m002:1 | 1 | 1 | +| AppearingPlatformsJungle | 1l4m002:1 | 1 | 1 | +| AppearingPlatformsLab | 1l8m001:1 | 1 | 1 | +| AppearingPlatformsVillage | 1l1m001:1 | 1 | 1 | +| AppleSeedPuzzle | 1l8m005:1 | 1 | 1 | +| BackgroundRotationChanger | 1l2m003:1 | 1 | 1 | +| BalanceStatues | 1l7m004:1 | 1 | 1 | +| BigCutscenePacha | 1l8m001:1 | 1 | 1 | +| BigCutsceneYzma | 1l8m001:1 | 1 | 1 | +| BlindMan | 1l6m001:1 | 1 | 1 | +| BlindMansBunny | 1l6m001:1 | 1 | 1 | +| Blockhead | 1l7m002:14 | 14 | 1 | +| BuckyBalloon | 1l2m001:1 | 1 | 1 | +| BunnyDoor | 1l7m004:2 | 2 | 1 | +| BunnyPuzzle | 1l6m001:1 | 1 | 1 | +| CameraForceInsideBox2 | 1l8m001:1 | 1 | 1 | +| Carrot | 1l7m004:14 | 14 | 1 | +| ChacaChargePotion | 1l1m001:1 | 1 | 3 | +| ChacaVillage | 1l1m001:2 | 2 | 3 | +| ChangeToBunny | 1l8m002:1 | 1 | 1 | +| ChangeToTurtle | 1l8m003:1 | 1 | 1 | +| Chicken | 1l3m003:3 | 3 | 1 | +| CityBunnyMurderPuzzle | 1l6m003:1 | 1 | 1 | +| CityGeneral | 1l6m001:1 | 1 | 1 | +| CityGeneralBlock | 1l6m001:1 | 1 | 1 | +| CityGeneralPlatform | 1l6m001:1 | 1 | 1 | +| CityNakedGuy | 1l6m003:1 | 1 | 1 | +| CityPeacock | 1l6m003:2 | 2 | 1 | +| CitySecretDoor | 1l6m003:4 | 4 | 1 | +| CityWall | 1l6m002:2 | 2 | 1 | +| CoinSpawnOracle | 1l2m001:1 | 1 | 1 | +| Column2 | 1l1m002:5 | 5 | 1 | +| Condor | 1l5m002:2 | 2 | 1 | +| CondorManDoor | 1l5m002:1 | 1 | 1 | +| CondorManFly | 1l5m002:1 | 1 | 1 | +| CowGuard | 1l8m001:1 | 1 | 1 | +| CowGuardNoCoin | 1l8m001:2 | 2 | 1 | +| CowGuardSpawner | 1l8m005:8 | 8 | 1 | +| CutSceneTriggerEndGame | 1l8m006:1 | 1 | 1 | +| DragonFly | 1l3m003:2 | 2 | 1 | +| DropDownBridge | 1l6m002:1 | 1 | 1 | +| EndGameBucky | 1l8m006:1 | 1 | 1 | +| EndGameChaca | 1l8m006:1 | 1 | 1 | +| EndGameCondorMan | 1l8m006:1 | 1 | 1 | +| EndGameDoor | 1l8m006:1 | 1 | 1 | +| EndGameFlyHover | 1l8m006:1 | 1 | 1 | +| EndGameGuards | 1l8m006:1 | 1 | 1 | +| EndGameGuardsBottle | 1l8m006:1 | 1 | 1 | +| EndGameKronk | 1l8m006:1 | 1 | 1 | +| EndGameKuzcoHuman | 1l8m006:1 | 1 | 1 | +| EndGameKuzcoHumanDance | 1l8m006:1 | 1 | 1 | +| EndGameNakedGuy | 1l8m006:1 | 1 | 1 | +| EndGamePachaDance | 1l8m006:1 | 1 | 1 | +| EndGameTipo | 1l8m006:1 | 1 | 1 | +| EndGameTriggerBox | 1l8m006:15 | 15 | 1 | +| EndGameYzmaCat | 1l8m006:1 | 1 | 1 | +| FallingJungleTree | 1l2m001:1 | 1 | 1 | +| FallingLog | 1l2m002:1 | 1 | 1 | +| FallingMountainTreeLong | 1l5m002:1 | 1 | 1 | +| FallingTree | 1l1m001:1 | 1 | 1 | +| FishBalloon | 1l2m002:1 | 1 | 1 | +| FlipFireBlock | 1l7m002:12 | 12 | 1 | +| FlipPlatform | 1l7m002:3 | 3 | 1 | +| FloatingPlatformJungleDay | 1l4m002:7 | 7 | 1 | +| FloorTileDoor | 1l8m001:1 | 1 | 1 | +| FlyStuck | 1l2m001:2 | 2 | 1 | +| FrogDoor | 1l4m003:2 | 2 | 1 | +| GhostCatacombs(spawn) | 1l7m004:1 | 1 | 1 | +| GoldIdol | 1l6m001:1 | 1 | 1 | +| HarryRandomBird1 | 1l3m003:1 | 1 | 1 | +| HarryRandomBird2 | 1l3m003:1 | 1 | 1 | +| HarryRandomBird3 | 1l3m003:1 | 1 | 1 | +| HelpGirlSkip | 1l1m002:1 | 1 | 1 | +| JumpingBlob | 1l4m003:5 | 5 | 1 | +| JungleDayMovingLift | 1l4m002:4 | 4 | 1 | +| JungleDaySlidingDoor | 1l4m003:2 | 2 | 1 | +| JungleFallingIdolHelper(spawn) | 1l4m003:1 | 1 | 1 | +| JunglePinata | 1l4m002:4 | 4 | 1 | +| JunglePinataKid | 1l4m002:6 | 6 | 1 | +| JunglePressurePadDoor | 1l2m002:1 | 1 | 1 | +| JungleSneakGuard | 1l4m001:13 | 13 | 1 | +| JungleSneakGuardStationary | 1l4m001:5 | 5 | 1 | +| KidOnCanoe | 1l3m003:1 | 1 | 1 | +| KidOnLlama | 1l1m001:1 | 1 | 1 | +| KidOnLlamaCatacombs | 1l7m002:1 | 1 | 1 | +| KidOnLlamaCity | 1l6m001:1 | 1 | 1 | +| KidOnLlamaFinal | 1l8m006:2 | 2 | 1 | +| KidOnLlamaJungle | 1l2m002:1 | 1 | 1 | +| KidOnLlamaLab | 1l8m005:1 | 1 | 1 | +| KronkAngel | 6l6m002:1 | 1 | 1 | +| KronkDevil | 6l6m002:1 | 1 | 1 | +| KronkOnIce | 1l5m003:1 | 1 | 1 | +| KronkOnIceDoor | 1l5m003:1 | 1 | 1 | +| KronkRace | 1l1m003:1 | 1 | 1 | +| KronkRaceLab | 1l8m002:1 | 1 | 1 | +| KuzcoStatue | 1l6m002:1 | 1 | 1 | +| LabBigCutscene | 1l8m001:1 | 1 | 1 | +| LabDumbGuards | 1l8m005:1 | 1 | 1 | +| LabFlipPlatform | 1l8m004:2 | 2 | 1 | +| LabFrogDoor | 1l8m003:1 | 1 | 1 | +| LabGuardDoor | 1l8m001:2 | 2 | 1 | +| LabPussyLift | 1l8m005:1 | 1 | 2 | +| LabSlidingDoor | 1l8m001:2 | 2 | 2 | +| LabSlidingDoorJamie | 1l8m005:1 | 1 | 1 | +| LabTriggerDoor | 1l8m001:1 | 1 | 1 | +| LabWaterfall | 1l8m005:4 | 4 | 1 | +| LabWhizzDoor | 1l8m005:1 | 1 | 1 | +| LizardGuard | 1l8m001:1 | 1 | 1 | +| LizardGuardNoCoin | 1l8m001:1 | 1 | 1 | +| LlamaChase | 1l2m003:1 | 1 | 1 | +| LobsterCrate | 1l4m003:2 | 2 | 1 | +| MainMenuCamera | 6l6m001:1 | 1 | 1 | +| MainMenuSoldier | 6l6m001:3 | 3 | 1 | +| NakedPants | 1l6m003:1 | 1 | 1 | +| NakedPeacock | 1l6m003:1 | 1 | 1 | +| NewBlockheadKid | 1l7m002:3 | 3 | 1 | +| NewBlockheadKidHelper | 1l7m002:1 | 1 | 1 | +| NewBossBlock | 1l7m002:1 | 1 | 1 | +| OptionsCamera | 6l6m002:1 | 1 | 1 | +| PachaBalloons | 1l8m004:1 | 1 | 1 | +| PachaFrogPotion | 1l8m003:1 | 1 | 1 | +| PachaGivingInfo | 1l1m001:2 | 2 | 1 | +| PachaInfoMountain | 1l5m003:1 | 1 | 1 | +| PachaIntoVillage | 1l1m004:1 | 1 | 1 | +| PachaTurtlePotion | 1l8m002:1 | 1 | 1 | +| PachaWalkMountain | 1l5m001:1 | 1 | 1 | +| Panther | 1l2m003:8 | 8 | 1 | +| PotionPadBunny | 1l7m004:1 | 1 | 1 | +| PotionPadFrog | 1l4m003:1 | 1 | 1 | +| PotionPadTurtle | 1l1m003:1 | 1 | 1 | +| PressurePadCataDoor | 1l7m004:1 | 1 | 1 | +| PressurePadCityDoor | 1l6m002:1 | 1 | 1 | +| PressurePadPalaceDoor | 1l6m003:1 | 1 | 1 | +| PumpBlockPuzzle | 1l5m001:1 | 1 | 1 | +| RisingColumns | 1l1m001:1 | 1 | 1 | +| RiverBucky | 1l3m002:1 | 1 | 1 | +| RiverButterfly | 1l3m003:4 | 4 | 1 | +| RiverCrocGame | 1l3m002:1 | 1 | 1 | +| RiverDam | 1l3m002:1 | 1 | 1 | +| RiverDude2 | 1l3m003:1 | 1 | 1 | +| RiverDude4 | 1l3m003:1 | 1 | 1 | +| RiverFallingSpider | 1l3m001:1 | 1 | 1 | +| RiverGate | 1l3m003:2 | 2 | 1 | +| RiverJumpingMonkey | 1l3m002:9 | 9 | 1 | +| RiverSwingingMonkey | 1l3m002:2 | 2 | 1 | +| RiverSwingingMonkeyRed | 1l3m002:1 | 1 | 1 | +| RiverTrackChange | 1l3m004:1 | 1 | 1 | +| RiverTriggerBox | 1l3m002:1 | 1 | 1 | +| RiverTurtle | 1l3m001:13 | 13 | 1 | +| RiverWhirlpool | 1l3m002:36 | 36 | 1 | +| RiverYzmaGate | 1l3m004:2 | 2 | 1 | +| RiverYzmaWompy | 1l3m004:1 | 1 | 1 | +| RockForKid | 1l2m002:3 | 3 | 1 | +| RockPadDoor | 1l2m002:1 | 1 | 1 | +| RollingBoulderMountain | 1l5m003:6 | 6 | 2 | +| RollingBoulderNoWalls | 1l5m001:1 | 1 | 1 | +| SalamanderSmashPot | 1l8m001:4 | 4 | 1 | +| SandTimerDoor | 1l1m002:2 | 2 | 1 | +| SandTimerDoorCity | 1l6m002:1 | 1 | 1 | +| SayGlassOfWater | 1l3m004:1 | 1 | 1 | +| Scorpions1 | 1l7m002:1 | 1 | 3 | +| Scorpions5 | 1l7m002:1 | 1 | 3 | +| SinkingStatue | 1l1m001:1 | 1 | 1 | +| SlidingBlockhead | 1l7m002:4 | 4 | 1 | +| SlidingBlockhead2 | 1l7m002:4 | 4 | 1 | +| SmartFly | 1l4m003:1 | 1 | 1 | +| SmashStatueBig | 1l1m002:2 | 2 | 1 | +| SmashStatueDoor | 1l1m002:1 | 1 | 1 | +| SmashStatueRisingColumn | 1l6m001:3 | 3 | 1 | +| SmashStatueSmall | 1l1m002:2 | 2 | 1 | +| SneakingGuardJar | 1l6m003:2 | 2 | 2 | +| SpitDropPlatform | 1l6m003:5 | 5 | 1 | +| SpittingPuzzle | 1l1m002:1 | 1 | 1 | +| SuckingWPs | 1l1m002:1 | 1 | 1 | +| SwitchHorizontalLift | 1l5m002:1 | 1 | 1 | +| SwitchPlatformPadLab | 1l8m004:1 | 1 | 1 | +| SwitchPlatformPadVillage | 1l1m001:1 | 1 | 1 | +| SwitchVerticalLift | 1l5m002:1 | 1 | 1 | +| TalkingCityFolk | 1l6m002:2 | 2 | 1 | +| TargetRopePlatformJungleDay | 1l4m002:4 | 4 | 1 | +| TargetRopeSpider | 1l2m002:3 | 3 | 1 | +| TeleportDoor | 1l7m002:2 | 2 | 1 | +| TheNewGroove | 0i0m000:1 | 1 | 1 | +| ThreePartStatue | 1l2m002:1 | 1 | 1 | +| TipoHelp | 1l1m002:1 | 1 | 1 | +| TipoLab | 1l8m004:1 | 1 | 1 | +| TipoMountain | 1l5m001:1 | 1 | 1 | +| TitleLlama | 0i0m000:1 | 1 | 1 | +| TitlePacha | 0i0m000:1 | 1 | 1 | +| TrackSwitch | 1l4m001:7 | 7 | 1 | +| Trampoline | 1l6m003:11 | 11 | 1 | +| TrampolineBall | 1l6m003:4 | 4 | 1 | +| TreadmillBridge | 1l5m002:1 | 1 | 1 | +| TreadmillVerticalLift | 1l5m001:1 | 1 | 1 | +| TurtleDoor | 1l1m003:1 | 1 | 1 | +| VillageEndDoor | 1l1m004:1 | 1 | 1 | +| WarthogGuard | 1l8m001:1 | 1 | 1 | +| WaterReboundPointsTurnsOff | 1l2m001:2 | 2 | 1 | +| YzmaCat | 1l8m005:1 | 1 | 2 | +| YzmaCatIntro | 1l8m005:1 | 1 | 1 | +| YzmaCutscene | 1l3m004:1 | 1 | 1 | +| YzmaHammer | 1l8m004:1 | 1 | 1 | +| YzmaInCity | 1l6m003:1 | 1 | 1 | +| YzmaInVillage | 1l1m004:1 | 1 | 1 | +| YzmaRiver | 1l3m004:1 | 1 | 1 | +| YzmaRubyIdolDoor | 1l8m004:1 | 1 | 1 | +| YzmaThrone | 1l6m003:1 | 1 | 1 | +| fat_bird | 1l3m003:1 | 1 | 1 | + +Nur im Code (nie direkt in MAP): AppearedPlatform, AppleHole, AppleSeedFX, AppleSeedPad, AxeGuardAxe, BalanceStatue, BallShrapnel, Balloon, BalloonFish, BalloonFishPlatform, BalloonPacha, BalloonPlatform, Bat, BeetleBlueBodyPart, BeetleBlueShell, BeetleBodyPart, BeetleShell, Bird, BlobOnTopCity, BlobOnTopJungle, BlobOnTopJungleDay, BlobShell, BlockShadow, BlockheadColl, BossBomb, BouncingCoin, BouncingHealth, BridgePiece, BridgeSmashStatue, BunnyPotion, BunnyRotors, Camera, CameraCollisionInFront, CameraPlayCutSceneAutomatically, ChacaGrapes, ChangePlayerTo, ChargingDoorPad, CheatCharge, CheckExitFinalBit, CheckPointHead, CircleDoorPlatform, CityBarrelHalf, CityGeneralBarrel, CityGeneralFinalAxe, CityMurderBunny, CityWallShrapnel, CoinForBat, Column, ColumnChild, CondorSleeping, CowGuardSpawned, CutSceneDirectionalWithWPs, DropObject, DumbGuardLab, EndDoorPad, EndDoorWeight, EndGameChacaSkip, EndGameChacaSkipShadow, EndGameGuardsGorrila, EndGameGuardsLizard, EndGameGuardsOstrich, EndGameGuardsWartHog, EndGameKronkRope, EndGameTipoSkip, EndGameTipoSkipShadow, EnemyCoin, EnemyCoin5, FallingPitLab, Feather, FishSpit, Flame, FrogPotion, FrogPotionFromPacha, GoldIdolStatue, Groove, GuardLookAhead, HammerForYzma, JengaSpiralLab, JungleDart, JungleDaySlidingDoorPad, JungleFallingRubyIdol, JunglePillarHelper, JunglePressurePad, KidOnLlamaCircle, KidsRubyIdol, KronkIdol, KronkLabNet, KronkOnIcePickup, KronkPotionLlama, KronkRubyIdol, KuscoBunny, KuscoFrog, KuscoTurtle, KuzcoStatueDoor, KuzcoStatuePart, LabFinalPotion, LabLiftPad, LabSlidingDoorBox, LabSlidingDoorBoxJamie, LabSlidingDoorPad, LabSlidingDoorPadJamie, LabSlidingDoorTile, LabSlidingDoorTileJamie, LabSlidingDoorTrigger, LabSlidingDoorTriggerJamie, LabSlidingDoorWhizzJamie, LabWallShrapnel, LabWaterfallPad, LlamaSpit, Lobster, LobsterPotShrapnel, LobsterStars, LobsterStarsChild, Lobsters, Lobsters1, MerryGoRoundCar, MerryGoRoundOracleHead, MurderBunny, NestForBridge, NestForHorizontalLift, New, NewBossBlockBit, OracleDoor, OracleHead, PachaLookAhead, PinataShrapnel, PlayerFallenOff, PressurePad, PressurePadCata, PressurePadPalace, PumpBlock, PumpLift, PumpLiftPad, RealAppleHole, RisingColumnForStatue, RiverBat, RiverCrocDam, RiverCrocMinGame, RiverDamShrapnel, RiverJumpingMonkeyItem, RiverJumpingMonkeyRedItem, RiverSwingingMonkeyItem, RiverSwingingMonkeyRedItem, RockPad, RockShrapnel, RollingBoulderForMountain, RollingBoulderForMountainNoWalls, RopeForBalloon, RopeForBalloonFish, RopeForBalloonPacha, RopeForHelpGirlSkip, RopeForRiverFallingSpider, RopeForRiverHangingSpider, RopeForSpider, RopeForTargetPlatform, RopeForTargetPlatformCity, RopeForTargetPlatformJungleDay, RopeForTargetSpider, RubyIdolReward, RubyIdolStatue, SalamanderNew, SandTimerDoorJungle, SandTimerPad, SandTimerPadCity, SandTimerPadJungle, ScorpionBlack, ScorpionBrown, ScorpionBrownStars, ScorpionBrownStarsChild, ScorpionSalamander, Scorpions, Scorpions2, SecretEffect, SinkingStatuePad, SlidingBlockheadPad2, SmartFlyDoor, SmashLobsterCrate, SmashPot2, SmashPotShrapnel, SmashStatueBloke, SmashWallShrapnel, SmokeOnPlayer, SmokeOnYzma, Sparkles, SparklesRGB, SpawnableNeedleBall, SpawnableNeedleBallRev, SpawnablePop, SpawnableRing, SpawnableSmoker, SpawnedAlligator, SpiderIceBlock, SpitPlatform, Squasher, StatueShrapnel, StunnedStars, StunnedStarsChild, SwitchForBridge, SwitchForHorizontalLift, SwitchForMouthPlatform, SwitchForSummonPlatform, SwitchForVerticalLift, SwitchPlatform, SwitchPlatformLab, SwitchPlatformVillage, TheNewGrooveCamera, ThreePartHead, ThrowAcorn, TickTockSound, TrampolineDoor, TreadmillForBridge, TreadmillForVerticalLift, TurtlePotion, TurtlePotionFromPacha, WaterArcDot, WaterFallSound, WaterSplash, WaterTrail, WaterfallBit, WompyBeingUsed, YzmaBigCutscenePotion, YzmaCatIntroFake, YzmaCatIntroPotion, YzmaDamMist, YzmaDamShrapnel, YzmaInVillageProjectile, YzmaInVillageProjectileCity, YzmaRiverItem, YzmaRubyIdol diff --git a/notes/testharness.md b/notes/testharness.md new file mode 100644 index 0000000..05248c7 --- /dev/null +++ b/notes/testharness.md @@ -0,0 +1,146 @@ +# Test-Harness `tools/lamatest.py` + +Startet "Ein Königreich für ein Lama" unbeaufsichtigt und unsichtbar (Xvfb :5, +Ton in Null-Sink), lädt TestLevel Kapitel 1 (T1L9M001.wad) und macht Screenshots. +Getestet am 2026-11-06 mit Wine 6.0.3, llvmpipe. + +## Befehle + +``` +lamatest.py run <wad> <outdir> [n] # alles in einem: installieren, start, tolevel, tour, stop +lamatest.py start # Xvfb :5 + Null-Sink lama_stumm + groove.exe +lamatest.py tolevel # Menü -> Slot mit TestLevel -> Kapitel 1 -> Gameplay +lamatest.py tour <outdir> [n] # n Kamera-Fotos (Rundumblick) + n Lauf-Fotos + Lama-Sicht +lamatest.py shot out.png # Vollbild 1920x1080 +lamatest.py key <xdotool-key> [s] # Taste s Sekunden halten (Default 0.12) +lamatest.py keys Up:1.5 a:0.5 x # Folge, Name[:Haltezeit], auch sleep=2 +lamatest.py seq <outdir> "TOKENS" # --seq-Tokens (shot:, run:, rj: …) im laufenden Spiel +lamatest.py status | stop | restore-wad +``` + +### `run`-Optionen (2026-11-06 ergänzt) +``` +lamatest.py run <wad> <outdir> [n] [--seq "TOKENS"] [--tour] [--keep] +``` +* **WAD-Wiederherstellung**: `run` merkt sich die *vorher installierte* + `T1L9M001.wad` (`$LAMATEST_DIR/vorher_T1L9M001.wad`) und spielt sie nach dem Lauf zurück – + also nicht `.orig`. Mit `--keep` bleibt die getestete WAD installiert. + (`restore-wad` stellt weiterhin explizit `.orig` her.) +* `--seq` ersetzt die Standard-Tour durch eine Tastenfolge (mit `--tour` zusätzlich Tour): + * `Up:1.5`, `a:0.5`, `x` … Taste[:Haltezeit] wie bei `keys`; `sleep=1` + * `shot:NAME` → `seq_NN_NAME.jpg`, `fp:NAME` → Foto in Lama-Sicht (Return gehalten) + * `dialog` → Dialog/Letterbox wegklicken + * `Up+x:0.8` → Tasten gleichzeitig halten + * `rj:T1:T2` → Anlauf-Sprung (Up halten, nach T1 s X, Up noch T2 s) + * `dj:T` → Doppel-X mit Anlauf, `hop:N:T` → Up halten, N× alle T s springen + * `run:T:x@1.3,x@2.9,shot@5.0,Left@3.0-3.4` → Up T s halten, nach Zeitplan (s ab Beginn) + X tippen / fotografieren (`seq_NN_runT.jpg`) / Taste von–bis halten; `run/Left:T:…` hält + statt Up eine andere Taste. Echtzeit: ein Foto blockiert ~0,4 s (verzögert folgende Ereignisse + und bremst das Spiel) → keine Fotos kurz vor Sprüngen. +* Steuerung ist **kamerarelativ** (Up = in Kamerablickrichtung, Left/Right seitlich). + A/S drehen die Kamera, S:0.45 ≈ 90° nach links schauen. +* Messwerte (Lama): ~1,1 Einheiten/s Laufen, Sprung 0,35 hoch, mit Anlauf ~1,3 weit – siehe + levelbau.md. Zeitgesteuerte Läufe streuen um ~0,3 Einheiten (Startverzögerung, Bildrate); + längere Strecken brauchen „Anschläge“ (Wand/Stufe, an der das Lama stehen bleibt). +* Kamera: nach Laufen gegen eine Wand oder einem Sturz dreht sich die Kamera unvorhersehbar → + Up läuft danach in eine andere Richtung. A/S-Drehung (S:0.45) ist nur ungefähr 90°. +* Kompletter Testwelt-Parkour per Skript: `levels/testwelt_parkour.seq` (levelbau.md). + +* `LAMATEST_SHOT=1280` (Umgebung): `shot:`/`fp:`-Fotos 1280×720, Qualität 85 statt 960×540 (Vorschaubilder). +* „EINGEFROREN?“ bei einer `--seq` ohne Bewegung (z. B. nur `sleep=3 shot:x`) ist ein Fehlalarm – + das stehende Lama ändert das Bild nicht; mit Kameradrehung/Laufen kommt „OK“. +* Gute Blickwinkel im Spiel sind schwer: die Kamera hängt tief hinter dem Lama; erhöhte + Startpunkte (Podest, Parkour-Checkpoint `pk2` + `A:0.6`) zeigen mehrere Bereiche. + +Hilfsskript für Serien: `$CLAUDE_JOB_DIR/tmp/lb_run.sh WAD NAME "SEQ"` (Montage `lb_mNAME.jpg`, +WAD-Pfad absolut!), `rs.sh NAME "SEQ"` (Neustart mit installierter WAD + `seq`, Montage `i/NAME.jpg`). +**Achtung**: kein `pgrep -f groove.exe` in Warteschleifen – das trifft die eigene Shell +(Kommandozeile enthält "groove.exe") und lamatest hält sie für eine fremde Instanz. + +Zustand/PIDs/wine.log: `$LAMATEST_DIR` (Default `/tmp/lamatest-$USER`). + +`run` Exit-Codes: 0 OK, 3 Absturz, 4 Timeout/hängt, 5 Bild eingefroren. +Ausgabe in `<outdir>`: `report.txt`, `wine.log` (stderr, auf 2 MB gekürzt), +`00_geladen.png` (voll) + `.jpg`, `tour_*.jpg` (960x540, ~30 KB), bei Fehler +`zz_absturz.jpg` / `zz_timeout.jpg`. Ein Lauf ≈ 75 s, ≈ 1,2 MB. + +## Sicherheitsregeln (im Skript umgesetzt) + +- Bricht ab, wenn eine groove.exe mit anderem DISPLAY läuft (Elias' Instanz). +- xdotool nur mit DISPLAY=:5. +- Ton: eigener Null-Sink, App wird nie stummgeschaltet; `stop` entlädt den Sink. +- Spielstände (`*.ksv` im Spielordner) werden bei `start` gesichert und bei `stop` + bei Änderung zurückgespielt (neu entstandene gelöscht). +- `stop`: `wineserver -k` nur, wenn keine fremde groove.exe läuft, sonst nur eigene PIDs. +- Der Ausgang der Testwelt speichert Fortschritt (Kapitel 2 frei) – wird bei `stop` mit den + Spielständen zurückgespielt. +- WAD: erstes `run` sichert das Original als `mods/testlevel/wads/T1L9M001.wad.orig`; + `restore-wad` spielt es zurück. Das Overlay (`lama-mod an`) besteht aus + **Symlinks** auf die Mod-Dateien → Überschreiben von + `~/.wine-lama/mods/testlevel/wads/T1L9M001.wad` wirkt sofort; `lama-mod an` wird + nur aufgerufen, wenn der Overlay-Link fehlt. + +## Ablauf `tolevel` (gemessen) + +| Schritt | Taste | Erkennung | t (ab Start) | +|---|---|---|---| +| Logo → Hauptmenü | – | OCR Ausschnitt 700x300+1150+650, Schwelle 75 % → nur markierter Punkt "Spiel starten" lesbar | ~6 s | +| Spiel starten | Return | – | | +| Spielstandauswahl (Karussell, startet bei Slot 2, **kein** Wrap) | Left ×3, dann Right bis "TestLevel" | OCR Ausschnitt 1600x280+160+740 (fuzzy, 2 Schwellen) | ~20 s | +| Slot 3 laden | Return | Auswahlkarte = >75 % beige Pixel | ~25 s | +| Kapitel 1 aktivieren (Cursor aus KSV +4/+5) | Return | | | +| Kamera-Flug (Letterbox: oberste+unterste Zeile schwarz) | Return überspringt | | +1,5 s | +| Gameplay | – | hell, kein Letterbox, goldenes HUD oben links (oder >25 s) 3× hintereinander | ~29 s gesamt, Laden ~4 s | + +Ohne Überspringen dauert der Kamera-Flug im Dorf ~20 s. +Ladebild = T191.COR ("TestLevel Kapitel 1 … LADEVORGANG"). + +Fallback: wird kein Menü erkannt, nach 40 s Return (überspringt Demo). Achtung: +Return auf einem falsch erkannten Bildschirm kann ein anderes Spiel starten – +Spielstände werden aber am Ende zurückgespielt. + +## Steuerung PC (Tastatur, aus "Optionen → Tastatur konfigurieren", verifiziert) + +| Aktion | Taste | +|---|---| +| Auf / Ab (vorwärts / rückwärts laufen) | Up / Down | +| Links / Rechts (drehen) | Left / Right | +| Sprung (auch Dialog weiter) | X | +| Action | Z | +| Power | Space | +| Lama-Sicht (Ego-Perspektive, halten) | Return | +| Kamera nach links / rechts drehen | A / S (0,5 s halten ≈ 90°) | +| Pausenmenü | Escape | + +Registry `HKCU\Software\Disney Interactive\Emperors New Groove\1.0`, +`"InputDevice Wine Keyboard"` = 14 DIK-Codes (je 4 Hex): +CB Left, CD Right, C8 Up, D0 Down, 1E A, 1F S, 39 Space, 2D X, 1C Return, +19 P, C9 PgUp, D1 PgDn, 2C Z, 18 O. Die Reihenfolge der Registry ≠ Menüreihenfolge; +P/PgUp/PgDn/O vermutlich Pause/Inventar (laut exe-Strings: PAUSE GAME, CYCLE +INVENTORY UP/DOWN, USE AN ITEM, TURN 180, SELECT) – nicht verifiziert. +Weitere Werte dort: CameraMode, ControlType, ScreenW/H (1920x1142), CDPath=L:\. + +Pausenmenü (Escape): Grafik-/Tonoptionen, Tastatur/Joystick konfigurieren, +Weitere Optionen, Weiter, "Beenden und zur Auswahlkarte wechseln" (Bestätigung +Ja/Nein, Default Nein → Up, Return), Spiel beenden. Hauptmenü-Untermenüs: Escape = zurück. + +## Beobachtungen Dorf-1-Kopie + +- Läuft man im Dorf vorwärts, startet sofort der Pacha-Dialog (Letterbox mit + Untertiteln, ~9× X zum Durchklicken). `tour` erkennt das, fotografiert den + Dialog einmal und klickt ihn weg. +- Lama kann in der Tour von der Klippe fallen (Respawn) – egal für Sichtprüfung. + +## Absturzerkennung + +Pro Poll: groove.exe-Prozess auf :5 weg / Fenster "Groove" weg / Fenster mit +"Programmfehler", "error", "exception" … (Wine-Crashdialog). Im Report steht die +Zeile `wine: Unhandled page fault … at address XXXXXXXX` (Adresse in Ghidra +nachschlagen). Getestet: auf 300 KB gekürzte WAD und WAD mit 2000 zufälligen +Bytes → beide Absturz während des Ladebilds, erkannt nach ~5 s (Exit 3). +Harmloses Log-Rauschen (gstreamer/MPEG-1 fehlt, ole, ChangeDisplaySettings, +MIDIMAP) wird im Report ausgefiltert. + +## Abhängigkeiten + +Xvfb, xdotool, ImageMagick (import/convert), tesseract mit `deu`, pactl, wine. diff --git a/notes/textures.md b/notes/textures.md new file mode 100644 index 0000000..9427139 --- /dev/null +++ b/notes/textures.md @@ -0,0 +1,51 @@ +# TEXT-Chunk (Texturen) + +Quelle: groove.exe `FUN_004067b0` (Größenlauf), `FUN_004071d0` → `FUN_00406880` (Paletten), +`FUN_00406ab0` (Seiten), `FUN_00406a10` (RLE), `FUN_00406d30` (Texturliste), +`FUN_00406e40` (Texturanimationen), `FUN_00407240`/`FUN_00408470` (Upload/Konvertierung). +Werkzeug: `tools/textchunk.py`, `tools/text_export.py`. Parse + Neuaufbau ist für **alle +Level-WADs byte-identisch** (auch der RLE-Encoder erzeugt exakt die Originaldaten). + +## Aufbau +``` +u32 nPal (in allen WADs 0) + nPal × { u32 id, u32 n, u16 farbe[n], u8 extra[n] } +u32 nSeiten (13–22 je Level, alle 256×256) + nSeiten × { u32 flags, u32 w, u32 h, Daten } + flags == 0 : w·h Byte (8 Bit, Palette) – unbenutzt + flags & 0x80 : u32 Länge, RLE16 – alle Originalseiten + sonst : w·h u16 roh +u32 nTex (~180–1260) + nTex × 8 Byte { u16 flags, u8 seite, u8 clut(0xff), u8 x0, u8 x1, u8 y0, u8 y1 } (Kanten inklusive) +u32 nAnim + nAnim × { u16 texIndex, u8 typ, Nutzdaten je typ } + typ 1,2 : 3 Byte typ 7,9 : u16,u16,u8,u8 + typ 8 : u8, u8 nFrames, u8, nFrames × u16 texIndex (Daumenkino) + typ 10,11: u8,u8,u8,u8 n, n × u16 texIndex +``` +RLE16: Folge von s16 n; n>0 → n Literal-u16; n<0 → nächstes u16 (−n)-mal. + +## Pixel +16 Bit **PS1-Reihenfolge**: Bits 0–4 R, 5–9 G, 10–14 B, Bit 15 = STP. +`0x0000` = durchsichtig (Konverter schreibt 0 für Pixel 0, alles andere wird umgerechnet). +STP=0 bei Farbe≠0 tritt auf Seiten mit Blendmodus auf (halbtransparente Pixel). +Export: Alpha 0 = 0x0000, Alpha 128 = STP 0, Alpha 255 = STP 1. + +Seiten-flags & 0x7f = Format/Blendklasse (Tabelle 0x570370, je 0x20 Byte): +1 = opak, 2 = eigener Konverter (0x409640), 3 = opak+Flag, 4/7 = Alpha (Blend 1), +5 = Blend 3, 6 = wie 1. (Modi 1/2/3 ≈ PS1-Halbtransparenz-Modi; Details offen.) + +## Texturliste +Faces der Modelle (TRAK/STPC, Face+8 u16) verweisen auf den Index in der Texturliste; +Eintrag = Rechteck auf einer Seite. `flags`-Lowbyte 4/5/6/0xc6, Highbyte 1/4/17/20/36/… +(Bedeutung offen; vermutl. Blend/Größenklasse). Fürs Ersetzen: Liste unverändert lassen +und nur Pixel in den Seiten tauschen → keine Querverweise betroffen. + +## Export / Ersetzen +``` +text_export.py export level.wad ordner # pageNN_fXX.png + texinfo.txt (≈370 KB je Level) +text_export.py import level.wad ordner neu.wad +``` +Import ändert nur Pixel, deren RGBA vom Export abweicht → unveränderte Seiten bleiben +bitgenau. Größe muss 256×256 bleiben. Neue Seiten/Texturen anhängen ist möglich +(nTex/nSeiten erhöhen), dann müssen aber Faces in TRAK/STPC die neuen Indizes verwenden. diff --git a/notes/ux-review-editor.md b/notes/ux-review-editor.md new file mode 100644 index 0000000..856e598 --- /dev/null +++ b/notes/ux-review-editor.md @@ -0,0 +1,95 @@ +# Level editor – UX review (2026-11-07) + +Usability pass by "edux": the editor used like a new user on Xvfb (1920×1080 screen; windows of +1500×920, 1920×1050 and 1280×720), mouse and keyboard only, following the manual's tutorials +(*Your first level*, *Make a parkour*, *Edit an original level*, *Import a Blender model*) plus +placing objects, the Objects/Environment/Textures panels, Shift+F5, dark theme, F1 help and +Preferences. Play-tests ran from the editor's own **F5 / Shift+F5** flow, with the game on its own +display and the editor started with the private PulseAudio server of an apptest session +(`PULSE_SERVER` and `-Dllamamod.gameDisplay` are inherited by `GameLauncher`; the game's audio went +into the private null sink). Both play-tests (new flat-floor level, Village from the camera +position) started straight in the level, and Escape in the game returned to the editor. + +## Before / after + +| Before | After | +|---|---| +| ![](img/editor/ux_llama_before.jpg) | ![](img/editor/ux_llama_after.jpg) | +| A 1920-wide window: the Inspector is 170 px wide and shows no values at all. Side docks have two rows of tabs. | The docks get their intended widths (290 / 340 px). Side-dock tabs are icons with the active one named. The engine fields of the object type are folded away. | +| ![](img/editor/ux_brush_before.jpg) | ![](img/editor/ux_brush_after.jpg) | +| The Brush tool's buttons read "Bl…", "Pl…", the texture names "gr…", the numbers show "0,5". | Full labels and a decimal point. | +| ![](img/editor/ux_coin_before.jpg) | ![](img/editor/ux_coin_after.jpg) | +| The Coin tool's options are cut off: only the top pixels of the fields are visible. | The options are on one line, or wrap to a second row in narrow windows. | +| ![](img/editor/ux_menu_before.jpg) | ![](img/editor/ux_menu_after.jpg) | +| *Help* comes before *Geometry*, and the shortcuts are German ("Strg", "Umschalt"). | The menus are File … Geometry, Tools, Help, and the keys are named "Ctrl", "Shift". | +| ![](img/editor/ux_orig_before.jpg) | ![](img/editor/ux_orig_after.jpg) | +| *Open original level*: the description is cut at the right edge, and the selected row's id is grey on orange. | The text wraps and the id is readable. | +| ![](img/editor/ux_small_before.jpg) | ![](img/editor/ux_small_after.jpg) | +| A 1280×720 window: the right dock is squeezed to 130 px. Also, Esc did not close the dialog, so it was still open and blocked every key. | The docks keep their sizes and the tool options wrap. Esc closes dialogs. | + +![](img/editor/ux_quit_game.jpg) + +*New: Play while the game still runs offers to quit it.* + +## Findings + +Severity: **H** = blocks or seriously misleads; **M** = friction every session; **L** = polish. + +| # | Sev | Where | Finding | Status | +|---|---|---|---|---| +| 1 | H | Docks (`DockLayout.applySizes`) | The right dock started at about 170 px instead of 340. The inner split's width was read before the outer divider moved. Effect: Inspector values and Brush buttons are invisible or truncated in every session that has no saved layout. | **Fixed**: lay out the outer split first | +| 2 | H | Window resize | When the window shrinks (1280×720, a laptop), the GL canvas's large minimum size squeezes the side docks to about 130 px. | **Fixed**: the viewport card has a small minimum size | +| 3 | H | Inspector sections | A section kept the height it had when created. Wrapped text and later rows (the Llama's waypoint buttons, the parameter descriptions) were clipped. | **Fixed**: the maximum height follows the preferred height | +| 4 | H | Inspector rows | Labels had a minimum height of 10 px. When the GridBag fell back to minimum sizes, every descender was cut ("Obiect tvpe"). | **Fixed** | +| 5 | H | Tool options bar | Options wider than the space left of Snap/View/Layers (the Coin, Place and Zones tools at 1500 px, the Brush tool at 1280 px) went to a second FlowLayout row that the bar did not grow for, so they were invisible. | **Fixed**: `WrapLayout`, the bar grows to two rows; all tools have the same bar height, so the viewport no longer jumps when you switch tools | +| 6 | H | Dialogs | Esc did not close the editor's own dialogs (*Open original level*, *New level*, *Level settings*, *Import mesh* …). A dialog half off-screen then silently swallowed all keys. | **Fixed**: Esc acts as the close button on every JDialog that has no Esc binding of its own | +| 7 | M | Side docks | Four tabs in 290 px wrapped into two rows that swap places when clicked (Swing's wrap layout). Clicks hit the wrong row, and typing into the filter field fired tool shortcuts. | **Fixed**: one scrolling row; in the side docks inactive tabs show only their icon (title as tooltip) and only the active tab has a ×. The asset, environment and texture sub-tabs also use one scrolling row | +| 8 | M | Locale (`Main`) | On a German system: menu keys read "Strg+Umschalt+D" and "Strg+Öffnende Klammer", spinners showed "0,75" next to "0.25", and Swing dialogs said "Ja"/"Nein" while the manual says Yes. Cause: `ImageIO` loads AWT before anything else. | **Fixed**: `Locale.setDefault(ENGLISH)` as the first line of `Main.main` (framework change, see below) | +| 9 | M | Menu bar | *Geometry* (a plugin menu) came after *Help*. | **Fixed**: plugin menus go before Tools, and Help is last | +| 10 | M | View menu | There were two **Panels** submenus (the Textures plugin's *View/Panels* entry and the shell's own), and a separator between every submenu. | **Fixed**: plugin entries are merged into the one Panels menu, and the submenus form one block | +| 11 | M | Toolbar | Tools were sorted by order number only, so the groups interleaved (Place objects, Brush, Coins, Tiles, Waypoints, Collision …) with separators in between. Two tools had the same icon (Zones and Zones & triggers). | **Fixed**: grouped in workflow order Select · Geometry · Texture paint · Objects · Environment. *Zones & triggers* uses the trigger icon | +| 12 | M | Start-up | The last tool was restored (for example Brush or Scale), so the first click in a freshly opened level drew a brush. | **Fixed**: the editor always starts with Select | +| 13 | M | Play / F5 | "The game is still running. Quit it first" was a dead end, because the game window is often hidden (another display, or full screen behind the editor). | **Fixed**: **Quit game and play** kills our own game (`GameLauncher.kill`), waits for the previous play-test to restore the mod selection, then starts. Tested: the old game was closed and the new one started in the village | +| 14 | M | Inspector, objects | Every object showed the engine fields of its type (code offset, variables, slots, type flags, extra memory) above Transform. | **Fixed**: they moved to a **Type details** section that is folded at first. *Object type* stays in **Type** | +| 15 | M | Inspector number fields | Long values showed their end ("6238" for 40.6238, "396, 4096, 8192"). | **Fixed**: the caret goes to the start and the full value is in the tooltip. Truncated labels ("Flags (2 = not s…") also get a tooltip | +| 16 | M | Outliner filter | Typing "Coin" showed a collapsed *Coin* group (depth 3), so the matches needed another click. | **Fixed**: groups are expanded down to depth 4 while filtering | +| 17 | M | Move / rotate / scale | No feedback during a drag: how far, which snap step. | **Fixed**: the status bar shows "Move 1 item Δ x 0.50 y 0.00 z 0.00 cells (snap 0.25, Ctrl = free)", the rotation angle and the scale factors | +| 18 | M | Assets panel | *Tile models* is empty in a new level ("0 of 0" and a white area). The *Imported* tab gave no hint either. | **Fixed**: grey hints ("no tile models yet … the Library tab has ready-made pieces", "File → Import mesh…", "Nothing matches the filter", "Open a level …") | +| 19 | M | Window size | The first-start size of 1500×920 is larger than a 1366×768 laptop screen, so the window hung over the edges. | **Fixed**: the size is clamped to the screen, and the window is maximized when it doesn't fit | +| 20 | L | Open original level | The description was clipped (the dialog was packed before the text was set), and the selected row's id was grey on orange. | **Fixed** | +| 21 | L | Status bar | The last-build label was cut ("Built 03:37:16 · 6158 KB · 2 w…"). | **Fixed**: "Built 03:37 · 6.0 MB · 2 warnings" | +| 22 | M | New level | A new level keeps the base level's 13 zones and 30 lights. Their outlines float far from the 16×14 floor (pink boxes in the sky), which is confusing in an empty level. | **Fixed (0.2.0)**: new projects keep only the start zone and the directional lights; teleport zones, point lights, waypoints and region masks of the base are removed (stored as edits, old projects unchanged). Directional light markers are drawn above the player start | +| 23 | M | Play-test | While the game runs, nothing in the editor says so: the status line falls back to "Ready" after 6 s. | Not fixed. It needs a sticky status or a "Playing…" state of the Play button (a shell API addition) | +| 24 | L | Shift+F5 | There is no mark in the viewport showing which point "the middle of the view" is. | Not fixed. Suggestion: a small crosshair while Shift is held | +| 25 | L | Outliner | Rows show technical ids (`brush:n19`, `strat:6`). Several brushes are all called "Floor". | Not fixed (cosmetic) | +| 26 | L | Inspector hints | Help texts in tool sections use fixed 215–230 px HTML widths, so they don't use a wider dock. | Not fixed (cosmetic) | +| 27 | L | Problems panel | It shows "No problems." before any check has run. | Not fixed | +| 28 | L | Duplicates | Objects can be placed from both the Objects panel (left) and Assets → Objects (bottom). | Kept: both are documented, and drag & drop needs the grid | +| 29 | L | Menus | Key names come from AWT ("Ctrl+Open Bracket"), while the manual writes "Ctrl+[". | Not fixed (would need a custom accelerator renderer) | +| 30 | L | Toolbar | The tool buttons stay enabled while no level is open. | Not fixed (harmless) | + +These worked well: the start page with recent projects, the *New level* dialog, crash recovery (a +killed editor offered **Recover** and restored the level), coin lines, Quick setups with +options-bar exit targets, *Death plane height…*, *Array duplicate*, the *Import mesh* dialog +(preview, size, warnings, click-to-place), context help (F1 opened the chapter of the panel +under the mouse), the Shift+F5 hint when aiming at the sky, and the dark theme (readable; +the selected **Snap** toggle has rather low contrast). + +## Changes (files) + +* `editor/ui`: `DockLayout` (sizes, scroll tabs, compact side tabs, minimum viewport size), + `ToolOptionsBar` + new `WrapLayout`, `EditorWindow` (menu order, merged Panels menu, + start tool, window size), `EditorExtensions.tools()` (grouped order), `EditorApp` (Esc closes + dialogs), `OpenOriginalDialog`, `PlayTest` (status text), `panels/InspectorPanel`, + `panels/OutlinerPanel`, `panels/AssetBrowserPanel`, `tools/TransformSupport`. +* Plugins (one-liners): `tools/objects/ZoneTool` (icon), `tools/environment/EnvironmentPanel` and + `tools/textures/TextureManagerPanel` (scroll tabs), `tools/playtest/PlaytestRunner` + (Quit game and play). +* **Framework**: `Main.java`: `Locale.setDefault(Locale.ENGLISH)` as the first line of `main` + (it affects the launcher UI too: English key names and Swing dialog buttons, decimal points). +* Manual: 03 (docks, toolbar groups, start tool), 05 (drag feedback, Type details), 09 + (Objects tab icon), 15 and 20 (Quit game and play). + +Verified: `./gradlew spotlessApply build` green (239 tests, ManualTest/ShortcutConflictTest/ +EditorShellTest included), every fix checked in the running editor (screenshots above), two +play-tests in the game. diff --git a/notes/ux-review-launcher.md b/notes/ux-review-launcher.md new file mode 100644 index 0000000..1ed50cf --- /dev/null +++ b/notes/ux-review-launcher.md @@ -0,0 +1,98 @@ +# LlamaMod launcher – UX review (2026-11-07) + +Usability pass by "lxux" over the mod manager without the editor (the *launcher* edition that +Discord testers get, `edition.properties` `editor=false`; the full edition was checked too). +Used like a new, non-technical user on Xvfb (1920×1080 screen with a window manager; windows of +980×760, 1280×720 and 1920×1040), mouse only: the first-start wizard with +`~/Downloads/GROOVE_DK.iso` and its error cases (a text file named `.iso`, a folder without the +game, a truncated ISO, cancelling the copy), the Mods tab (all categories, every option editor, +badges, Apply/Restore, progress), Save games (unlock, edit, save, backups), Settings (resolution, +virtual desktop, Wine, theme, data folder, cache, set up again) and Play. The game ran on its own +display (`-Dllamamod.gameDisplay=:9`, supported by `GameLauncher.wineProcess`) with the private +PulseAudio of an apptest session (`PULSE_SERVER`); the game's audio went into the null sink. Game +messages were tested by killing the game, deleting `game/groove.exe`, and crashing it on purpose +(`ExeSmokeMod` with `crash=true`). Light and dark themes. + +Not tested live: "not enough disk space" (no small file system without root; the check is +covered by code review), Windows (the antivirus notice is unchanged there). + +## Before / after + +| Before | After | +|---|---| +| ![](img/app/ux_start_before.jpg) | ![](img/app/ux_start_after.jpg) | +| The app opens with the mod list scrolled to the end (Visual, Cheats); Levels and Gameplay are out of sight. | The list opens at the top and keeps its place when it is rebuilt (Apply, theme). Short windows get a lower header. | +| ![](img/app/ux_mods_before.jpg) | ![](img/app/ux_mods_after.jpg) | +| Options have no explanation unless you hover (Worlds "all"? Checkpoint spacing "10" of what?). Descriptions talk about "far clip plane", "strafe", "rigid object models". | Every option is explained next to it. Mod descriptions say what happens in the game and which keys to press. | +| ![](img/app/ux_wizard_before.jpg) | ![](img/app/ux_wizard_after.jpg) | +| A text file is "shorter than expected (truncated?)", a wrong folder gets "expected Setup/…/Hd/groove.exe", a half-downloaded ISO is "Game found" and fails only after Set up. | Plain messages that say what to do; an incomplete ISO is caught at once, with both sizes. | +| ![](img/app/ux_exe_before.jpg) | ![](img/app/ux_exe_after.jpg) | +| On Linux a missing groove.exe is blamed on antivirus software and explained with Windows Defender menus. After Rebuild you have to click Play again. | Linux gets a short message; **Rebuild** writes the file and starts the game. | +| ![](img/app/ux_saves_before.jpg) | ![](img/app/ux_saves_after.jpg) | +| "lowest 4 bits of the coin count", "unknown item flag (bit 0)", "Cursor on", "Revert"; no hint how to restore a backup. | Plain wording, "Opens at", "Undo changes"; **Import…** starts in the backups folder and the hint says so. | +| ![](img/app/ux_settings_before.jpg) | ![](img/app/ux_settings_after.jpg) | +| "registry values ScreenW/ScreenH", "wine from PATH"; the mod cache shows 0.0 MB after applying 6 mods (stale). | "Automatic (1920x1052)" shows the real size, plain hints, a note on what *Set up again* keeps; the cache size is current. | +| ![](img/app/ux_dark_before.jpg) | ![](img/app/ux_dark_after.jpg) | +| Dark theme: the *modifies groove.exe* badge is dark orange on grey (barely readable). | A lighter badge text in the dark theme. | + +| New: quitting a hidden or hung game | New: crash report | +|---|---| +| ![](img/app/ux_quit_after.jpg) | ![](img/app/ux_crash_after.jpg) | +| While the game runs, Play becomes **■ Quit game** (neutral colour) with a confirmation. | Shown as soon as Wine reports the crash (the game hides behind Wine's error window until that is closed), with Quit game / Open logs. | + +![](img/app/ux_av_linux_before.jpg) + +*Before: on Linux every first Apply of an exe mod showed this Windows Defender notice. Now it is +shown on Windows only.* + +## Findings + +Severity: **H** = blocks or seriously misleads; **M** = friction every session; **L** = polish. + +| # | Sev | Where | Finding | Status | +|---|---|---|---|---| +| 1 | H | Mods tab | Opens scrolled to the bottom: building the cards queues "scroll caret into view" for the last spinner/text field. Also after Apply and theme changes the list jumped. | **Fixed**: `reload()` restores the scroll position (top on the first build) after those events | +| 2 | H | Mod options | Option explanations existed only as tooltips. Non-technical users don't hover; "Worlds: all", "Checkpoint spacing 10", "Camera speed 3", "Fog: Scaled" are guesswork. | **Fixed**: grey explanation next to every option (wraps with the window) | +| 3 | H | Mod texts | Descriptions were written for developers ("far clip plane", "strafe", "rigid object models", "1/32 tile per tick", "Used by the level editor's Play button" in the launcher edition) and Free camera/Practice did not say which key switches them on. | **Fixed**: all 11 built-in mods rewritten: what changes in the game, keys (F7/F9, F5/F8, "can be changed below"), how to reach the Test World. Cheat codes kept as "(code ← → …)" | +| 4 | H | Wizard | A non-ISO file → "ISO image is shorter than expected (truncated?)"; a wrong folder → "expected Setup/…/Hd/groove.exe and Setup/…/Cd/wads". | **Fixed**: "This file is not a CD image (ISO)…" / "The game was not found here… (it contains a folder named Setup)" | +| 5 | H | Wizard | A truncated ISO (interrupted download) passed the check ("Game found: 187 files") and failed after Set up with the same cryptic message. | **Fixed**: `IsoImage` compares the file size with the end of the last file extent: "The ISO file is incomplete: it has 300 MB, but the CD image needs 582 MB. The download or copy was probably interrupted – copy the file again." | +| 6 | M | Wizard | Not enough space was only found after Set up ("Not enough disk space: … needed"). | **Fixed**: checked when the source is chosen (nearest existing parent of the data folder); the installer's message says to free space or choose another folder | +| 7 | M | Wizard | Cancel during the copy silently went back to the first page. Long messages were cut at the dialog edge. | **Fixed**: "Setup cancelled. Click Set up to continue – files already copied are kept." (resume works); messages wrap | +| 8 | H | Play | No way to stop the game from LlamaMod; under Wine a hung or hidden game blocked Apply and saves until the user found `wineserver -k`. | **Fixed**: the Play button reads **■ Quit game** while the game runs (neutral colour, confirmation "Progress since your last save is lost") | +| 9 | H | Play | A crash only produced "The game has ended." – and under Wine not even that while Wine's error window (often invisible behind/inside the virtual desktop) kept the process alive. | **Fixed**: `GameLauncher.crashReport()` reads game.log; the app shows "The game crashed" with advice (switch off the last mods / Restore original, report game.log), Quit game and Open logs – also while the error window is still open | +| 10 | M | Play / Linux | The antivirus notice (Windows Defender menus) appeared on Linux before every first exe-mod Apply; "groove.exe was removed or changed – probably by antivirus software" on Linux. | **Fixed**: notice on Windows only; Linux gets a plain "missing or changed – Rebuild" dialog | +| 11 | M | Play | After **Rebuild** in that dialog the user had to click Play again. | **Fixed**: Rebuild continues with the launch (also after Apply → Play) | +| 12 | M | Play | "Creating Wine prefix (once, takes a moment)", "Writing registry values". | **Fixed**: "Preparing Wine for the game (only the first time, may take a minute)", "Writing the game settings" | +| 13 | M | Settings | A wrong *Wine command* showed "Found: /wrong/path" and Play failed with "Cannot run program … error=2". | **Fixed**: "Not found: … – correct the command or leave the field empty", Play explains the same; the line updates when the field is left | +| 14 | M | Settings | The mod cache size was computed once at start (0.0 MB after applying 548 MB). | **Fixed**: the tab refreshes when opened | +| 15 | M | Settings | "Game resolution (registry values ScreenW/ScreenH)"; what "Automatic" means was only in a tooltip. | **Fixed**: the entry reads "Automatic (1920x1052)" (updates with *Run in a window*); plain hint mentions typing a custom size | +| 16 | M | Window | At 1280×720 (laptops) the 150 px header left room for 3½ mod cards. | **Fixed**: header is 104 px when the window is under 860 px tall (also the default 980×760 window) | +| 17 | M | Saves | "The game stores only the lowest 4 bits of the coin count (0–15). Collectible = unknown item flag (bit 0)." "Cursor on", "Revert". Status after saving printed the full backup path. Restoring a backup meant finding the folder by hand. | **Fixed**: plain hint, "Opens at", "Undo changes", "Save game 1 saved. The old version was backed up (Import… restores it)."; Import… opens in the backups folder | +| 18 | M | Status bar | "6 mod(s) active, 33 file(s) changed." / "Applying mods – done." – no next step, no duration. | **Fixed**: "Mods applied – 6 active (took 3 s). Click Play to start the game."; every task ends with "– done (took …)" | +| 19 | L | Mod cards | "v1 · testworld": ids mean nothing to players. | **Fixed**: "v1"; the id is in the tooltip (for the command line) | +| 20 | L | Dark theme | *modifies groove.exe* badge text nearly invisible. | **Fixed** | +| 21 | L | Hints | Grey hint text used `Label.disabledForeground` (about 3:1 contrast). | **Fixed**: `App.hintColor()` (blend of label and background, ~6:1), used for option, settings, wizard and save hints | +| 22 | L | Mods intro | Fixed 640 px wrap; long technical sentence. | **Fixed**: "Tick the mods you want, then click Apply and Play…" | +| 23 | L | Wizard done | "Choose mods on the Mods tab, edit save games…". | **Fixed**: "Next: tick the mods you like on the Mods tab, click Apply, then Play. The Save games tab can unlock worlds." | +| 24 | L | Launcher edition | No help/manual at all (the manual window lives in the editor plugin and needs an editor context). | Open – a Help button would need `ManualWindow` without `EditorContext` | +| 25 | L | Mirror mode | *Worlds* is free text ("all" / "1,3"); a list of checkboxes would be friendlier. | Open – now explained inline; changing the option type would change saved configs | +| 26 | L | Wizard | The copy page shows the file name, not "123 of 568 MB" or a time. | Open (copy takes seconds from an ISO) | +| 27 | L | Save games | The chapter table shows 4–5 rows at 720 px height. | Partly – the lower header gives it 46 px more | + +## Changes (files) + +* UI: `ui/MainFrame` (Quit game, crash dialog, Rebuild → continue, header height, task duration, + settings refresh), `ui/ModsPanel` (inline option hints, scroll position, card height, badge, + version label, status), `ui/SetupWizard` (free-space check, cancel message, wrapped messages), + `ui/SettingsPanel`, `ui/SavesPanel`, `ui/AntivirusDialogs` (Windows-only notice, Linux text, + `problem` returns the choice), `ui/App.hintColor()`, `messages.properties`. +* Platform: `IsoImage` (not-an-ISO and incomplete-ISO messages, extent check), `GameSource` + (game-not-found message), `Installer` (space and damage messages, `notEnoughSpace`), + `GameLauncher` (`crashReport()`, `isCommand()`, Wine-command check, progress texts). +* Mods (texts only, no behaviour or cache-key change): testworld, mirror, randomizer, + boottolevel, cheats, debug, fog, freecam, introskip, practice, levels. +* Test: `platform/LauncherMessagesTest`. +* Manual: chapters 02 (wizard errors, Quit game, inline explanations, Save games/Settings + labels), 18 (options explained, Windows-only antivirus), 20 (new "Setting up and playing" + table, Wine command, first Play), generated mod reference, `img/app_mods.jpg`, + `img/app_saves.jpg`. diff --git a/packaging/icon.ico b/packaging/icon.ico new file mode 100644 index 0000000..c5f31a3 Binary files /dev/null and b/packaging/icon.ico differ diff --git a/packaging/icon.svg b/packaging/icon.svg new file mode 100644 index 0000000..0931189 --- /dev/null +++ b/packaging/icon.svg @@ -0,0 +1,18 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> + <defs> + <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0" stop-color="#E07A2E"/> + <stop offset="1" stop-color="#A8441A"/> + </linearGradient> + </defs> + <rect x="8" y="8" width="240" height="240" rx="52" fill="url(#bg)"/> + <!-- stepped Inca band --> + <path d="M8 196 h48 v-14 h24 v-14 h96 v14 h24 v14 h48 v0 v0 a52 52 0 0 1 0 0 V196 Z" fill="#F4C542" opacity="0.0"/> + <path d="M8 200 H60 V186 H84 V172 H172 V186 H196 V200 H248 V196 A52 52 0 0 1 196 248 H60 A52 52 0 0 1 8 196 Z" fill="#7A2E12" opacity="0.55"/> + <!-- llama head --> + <g fill="#FFF4E0"> + <path d="M98 214 C96 176 100 140 104 112 C92 106 84 96 84 84 C84 66 100 56 118 58 L122 34 C123 26 133 26 134 34 L136 58 L146 34 C148 26 158 28 157 36 L152 62 C166 70 174 86 172 102 C170 116 160 124 148 126 C150 156 154 186 156 214 Z"/> + </g> + <circle cx="132" cy="84" r="7" fill="#3B1A0C"/> + <path d="M150 102 q8 4 16 -1" stroke="#3B1A0C" stroke-width="5" fill="none" stroke-linecap="round"/> +</svg> diff --git a/packaging/linux/LlamaMod b/packaging/linux/LlamaMod new file mode 100644 index 0000000..ce2ae02 --- /dev/null +++ b/packaging/linux/LlamaMod @@ -0,0 +1,5 @@ +#!/bin/sh +# LlamaMod launcher: uses the bundled Java runtime next to this script. +HERE=$(dirname "$(readlink -f "$0")") +exec "$HERE/runtime/bin/java" -Xmx1g -Dsun.java2d.uiScale.enabled=true \ + -jar "$HERE/lib/llamamod.jar" "$@" diff --git a/packaging/linux/README.txt b/packaging/linux/README.txt new file mode 100644 index 0000000..2958251 --- /dev/null +++ b/packaging/linux/README.txt @@ -0,0 +1,6 @@ +LlamaMod – mod manager for The Emperor's New Groove (PC, 2000) + +Start: ./LlamaMod +Menu entry: ./install-desktop-entry.sh +Requirements: Wine (to run the game), your own copy of the game (ISO or CD). +Data folder: ~/.local/share/LlamaMod (game copy, mods, save games, settings) diff --git a/packaging/linux/install-desktop-entry.sh b/packaging/linux/install-desktop-entry.sh new file mode 100644 index 0000000..d17e70e --- /dev/null +++ b/packaging/linux/install-desktop-entry.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Adds LlamaMod to the desktop application menu (current user only). +set -e +HERE=$(dirname "$(readlink -f "$0")") +APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps" +mkdir -p "$APPS" "$ICONS" +cp "$HERE/llamamod.png" "$ICONS/llamamod.png" +cat > "$APPS/llamamod.desktop" <<EOD +[Desktop Entry] +Type=Application +Name=LlamaMod +Comment=Mod manager for The Emperor's New Groove (PC) +Exec="$HERE/LlamaMod" +Icon=llamamod +Terminal=false +Categories=Game; +EOD +echo "Installed $APPS/llamamod.desktop" diff --git a/packaging/windows/README.txt b/packaging/windows/README.txt new file mode 100644 index 0000000..1fa9d06 --- /dev/null +++ b/packaging/windows/README.txt @@ -0,0 +1,6 @@ +LlamaMod – mod manager for The Emperor's New Groove (PC, 2000) + +Unzip anywhere and start LlamaMod.exe. No installation and no admin rights needed. +Keep LlamaMod.exe and the "runtime" folder together - the exe does not work on its own. +On first start choose your game ISO (or the CD). Everything is stored in +%LOCALAPPDATA%\LlamaMod (game copy, mods, save games, settings). diff --git a/packaging/windows/launch4j.xml.in b/packaging/windows/launch4j.xml.in new file mode 100644 index 0000000..02854d1 --- /dev/null +++ b/packaging/windows/launch4j.xml.in @@ -0,0 +1,29 @@ +<launch4jConfig> + <headerType>gui</headerType> + <jar>@JAR@</jar> + <outfile>@OUT@</outfile> + <errTitle>LlamaMod</errTitle> + <icon>@ICON@</icon> + <stayAlive>false</stayAlive> + <jre> + <path>runtime</path> + <requiresJdk>false</requiresJdk> + <requires64Bit>true</requires64Bit> + <opt>-Xmx1g</opt> + </jre> + <messages> + <jreNotFoundErr>LlamaMod could not find its Java runtime. LlamaMod.exe only works together with the "runtime" folder next to it. Unpack the whole LlamaMod zip file and start LlamaMod.exe from the unpacked "LlamaMod" folder.</jreNotFoundErr> + <jreVersionErr>LlamaMod could not start its Java runtime. Make sure the "runtime" folder next to LlamaMod.exe is complete (unpack the LlamaMod zip file again).</jreVersionErr> + </messages> + <versionInfo> + <fileVersion>@VERSION@.0</fileVersion> + <txtFileVersion>@VERSION@</txtFileVersion> + <fileDescription>LlamaMod</fileDescription> + <copyright>Elias Müller</copyright> + <productVersion>@VERSION@.0</productVersion> + <txtProductVersion>@VERSION@</txtProductVersion> + <productName>LlamaMod</productName> + <internalName>LlamaMod</internalName> + <originalFilename>LlamaMod.exe</originalFilename> + </versionInfo> +</launch4jConfig> diff --git a/re/ghidra_scripts/DumpAll.java b/re/ghidra_scripts/DumpAll.java new file mode 100644 index 0000000..f5fb903 --- /dev/null +++ b/re/ghidra_scripts/DumpAll.java @@ -0,0 +1,18 @@ +import ghidra.app.script.GhidraScript; +import ghidra.app.decompiler.*; +import ghidra.program.model.listing.*; +import java.io.*; +public class DumpAll extends GhidraScript { + public void run() throws Exception { + DecompInterface d = new DecompInterface(); + d.openProgram(currentProgram); + PrintWriter w = new PrintWriter(new FileWriter(getScriptArgs()[0])); + for (Function f : currentProgram.getFunctionManager().getFunctions(true)) { + DecompileResults r = d.decompileFunction(f, 60, monitor); + w.println("// ==== " + f.getName() + " @ " + f.getEntryPoint()); + if (r.decompileCompleted()) w.println(r.getDecompiledFunction().getC()); + else w.println("// decompile failed"); + } + w.close(); + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..7aa26a0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'llamamod' diff --git a/src/main/java/com/eliasmueller/llamamod/BuildInfo.java b/src/main/java/com/eliasmueller/llamamod/BuildInfo.java new file mode 100644 index 0000000..db39985 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/BuildInfo.java @@ -0,0 +1,42 @@ +package com.eliasmueller.llamamod; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** App version and build id from build-info.properties (generated by the Gradle task buildInfo). */ +public final class BuildInfo { + private static final Properties PROPS = new Properties(); + + static { + try (InputStream in = BuildInfo.class.getResourceAsStream("build-info.properties")) { + if (in != null) { + PROPS.load(in); + } + } catch (IOException ignored) { + // unknown build + } + } + + private BuildInfo() {} + + /** Release version from build.gradle, "dev" when unknown. */ + public static String version() { + return PROPS.getProperty("version", "dev"); + } + + /** Hash of the compiled classes and resources; changes with every code change. */ + public static String build() { + return PROPS.getProperty("build", "unknown"); + } + + /** "0.2.0 (build 1a2b3c4d5e)". */ + public static String display() { + return version() + " (build " + build() + ")"; + } + + /** Identifies the program code for the mod cache ("0.2.0+1a2b3c4d5e"). */ + public static String cacheId() { + return version() + "+" + build(); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/Edition.java b/src/main/java/com/eliasmueller/llamamod/Edition.java new file mode 100644 index 0000000..afa5f41 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/Edition.java @@ -0,0 +1,26 @@ +package com.eliasmueller.llamamod; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** Build edition: the launcher-only edition ships without the level editor (edition.properties, editor=false). */ +public final class Edition { + private static final Properties PROPS = new Properties(); + + static { + try (InputStream in = Edition.class.getResourceAsStream("edition.properties")) { + if (in != null) { + PROPS.load(in); + } + } catch (IOException ignored) { + // default edition + } + } + + private Edition() {} + + public static boolean editorEnabled() { + return !"false".equals(PROPS.getProperty("editor")); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/Main.java b/src/main/java/com/eliasmueller/llamamod/Main.java new file mode 100644 index 0000000..892315e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/Main.java @@ -0,0 +1,347 @@ +package com.eliasmueller.llamamod; + +import com.eliasmueller.llamamod.editor.ui.EditorApp; +import com.eliasmueller.llamamod.mods.Mod; +import com.eliasmueller.llamamod.mods.ModManager; +import com.eliasmueller.llamamod.mods.ModOption; +import com.eliasmueller.llamamod.mods.ModOptions; +import com.eliasmueller.llamamod.mods.ModRegistry; +import com.eliasmueller.llamamod.mods.levels.EditorCli; +import com.eliasmueller.llamamod.platform.AppPaths; +import com.eliasmueller.llamamod.platform.Config; +import com.eliasmueller.llamamod.platform.GameLauncher; +import com.eliasmueller.llamamod.platform.GameSource; +import com.eliasmueller.llamamod.platform.Installer; +import com.eliasmueller.llamamod.platform.Os; +import com.eliasmueller.llamamod.platform.Progress; +import com.eliasmueller.llamamod.save.Ksv; +import com.eliasmueller.llamamod.save.SaveStore; +import com.eliasmueller.llamamod.save.Worlds; +import com.eliasmueller.llamamod.ui.App; +import java.io.IOException; +import java.nio.file.Path; +import java.security.Provider; +import java.security.Security; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.imageio.ImageIO; + +/** Starts the GUI; a few command-line commands exist for scripting and tests. */ +public final class Main { + private Main() {} + + public static void main(String[] args) throws Exception { + // the UI is English; set first (ImageIO already loads AWT) so key names ("Ctrl", not "Strg"), dialog buttons + // and number fields (decimal point) match + Locale.setDefault(Locale.ENGLISH); + if (Os.isWindows()) { + // Use the OS RNG (CryptGenRandom) for SecureRandom: the default DRBG seeds itself by + // enumerating network interfaces, which crashes under older Wine. + Provider mscapi = Security.getProvider("SunMSCAPI"); + if (mscapi != null) { + Security.removeProvider("SunMSCAPI"); + Security.insertProviderAt(mscapi, 1); + } + } + ImageIO.setUseCache(false); + if (args.length > 0 && args[0].equals("--editor")) { + if (!Edition.editorEnabled()) { + System.err.println("This LlamaMod build has no level editor."); + System.exit(1); + } + EditorApp.main(Arrays.copyOfRange(args, 1, args.length)); + return; + } + if (args.length > 0 && (args[0].equals("--version") || args[0].equals("-V"))) { + System.out.println( + "LlamaMod " + BuildInfo.display() + (Edition.editorEnabled() ? "" : ", launcher edition")); + return; + } + if (args.length == 0 || args[0].equals("--gui")) { + App.start(); + return; + } + try { + System.exit(cli(args)); + } catch (IOException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); + } catch (UsageException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(2); + } + } + + /** Wrong command-line arguments (exit code 2). */ + static final class UsageException extends IllegalArgumentException { + UsageException(String msg) { + super(msg); + } + } + + private static int intArg(List<String> rest, int i, String what, int min, int max) { + if (i >= rest.size()) { + throw new UsageException("missing " + what); + } + int v; + try { + v = Integer.parseInt(rest.get(i).trim()); + } catch (NumberFormatException e) { + throw new UsageException(what + " must be a number, got \"" + rest.get(i) + "\""); + } + if (v < min || v > max) { + throw new UsageException(what + " must be " + min + "–" + max + ", got " + v); + } + return v; + } + + private static void requireInstalled(Config cfg) { + if (!cfg.installed()) { + throw new UsageException("LlamaMod is not set up yet. Run --install <iso|folder> first."); + } + } + + private static void usage() { + System.out.println( + """ + LlamaMod %s – mod manager for The Emperor's New Groove (PC) + + llamamod start the GUI + llamamod --version show the version and build id + llamamod --install <iso|folder> set up the game data + llamamod --mods list available mods + llamamod --apply [id,id,...] [--opt id.key=value ...] + apply mods (none = original); options are saved + llamamod --launch [--wait] start the game + llamamod --registry show the registry values + llamamod --saves show save games + llamamod --unlock <slot 1-3> <world> <chapter> [--cursor] + llamamod --editor [project.llamalevel | t1l1m001] level editor + llamamod --editor-build <project.llamalevel> <out.wad> [--cd <dir>] + llamamod --editor-import <level.wad|t1l1m001> <project.llamalevel> [--cd <dir>] + llamamod --editor-new <project.llamalevel> [base level] [--name <name>] [--cd <dir>] + llamamod --import-level <project.llamalevel> (into the level folder, shown as a mod) + Data folder: -Dllamamod.data=<folder> or LLAMAMOD_DATA (default %s) + """ + .formatted(BuildInfo.display(), AppPaths.defaultRoot())); + } + + private static final String ANTIVIRUS_HINT = + "probably by antivirus software. Run --apply again (same mods) to rebuild it and add the data" + + " folder %s as an exclusion in your antivirus program."; + + private static void checkExe(ModManager mm, AppPaths paths) throws IOException { + ModManager.ExeStatus s = mm.checkGameExe(); + if (s != ModManager.ExeStatus.OK) { + throw new IOException("game/groove.exe was removed or changed (" + + s.name().toLowerCase(Locale.ROOT) + ") – " + ANTIVIRUS_HINT.formatted(paths.root())); + } + } + + static int cli(String[] args) throws Exception { + AppPaths paths = AppPaths.resolve(); + Config cfg = Config.load(paths.config()); + Progress console = new Progress() { + String last = ""; + long lastT; + + @Override + public void update(double f, String m) { + long t = System.currentTimeMillis(); + if (!m.equals(last) && t - lastT > 300 || f >= 1) { + System.out.printf(Locale.ROOT, "[%3.0f%%] %s%n", f * 100, m); + last = m; + lastT = t; + } + } + }; + List<String> rest = new ArrayList<>(Arrays.asList(args).subList(1, args.length)); + switch (args[0]) { + case "--install" -> { + if (rest.size() != 1) { + throw new UsageException("--install expects one ISO file or folder"); + } + if (cfg.installed() && new GameLauncher(paths, cfg).isRunning()) { + throw new IOException("The game is running. Please quit it before setting up again."); + } + try (GameSource src = GameSource.open(Path.of(rest.getFirst()))) { + new Installer(paths).install(src, cfg, console); + } + new ModManager(paths, cfg).restoreOriginal(console); + System.out.println("Set up in " + paths.root()); + } + case "--mods" -> listMods(paths, cfg); + case "--apply" -> { + return apply(paths, cfg, rest, console); + } + case "--launch" -> { + requireInstalled(cfg); + new SaveStore(paths).migrateNullSaves().forEach(System.out::println); + checkExe(new ModManager(paths, cfg), paths); + GameLauncher gl = new GameLauncher(paths, cfg); + Process p; + try { + p = gl.launch(console); + } catch (GameLauncher.BlockedException e) { + throw new IOException(e.getMessage() + " – " + ANTIVIRUS_HINT.formatted(paths.root())); + } + System.out.println("PID " + p.pid()); + if (rest.contains("--wait")) { + System.out.println("exited: " + p.waitFor()); + } + } + case "--registry" -> System.out.print( + new GameLauncher(paths, cfg).registry().text()); + case "--saves" -> listSaves(paths); + case "--unlock" -> { + requireInstalled(cfg); + if (new GameLauncher(paths, cfg).isRunning()) { + throw new IOException("The game is running. Please quit it before changing save games."); + } + int slot = intArg(rest, 0, "slot", 1, SaveStore.SLOTS) - 1; + int w = intArg(rest, 1, "world", 1, Worlds.COUNT); + int c = intArg(rest, 2, "chapter", 1, Worlds.chapters(w)); + SaveStore st = new SaveStore(paths); + Ksv k = st.load(slot); + k.ensureUsed(); + k.setUnlocked(w, c); + if (rest.contains("--cursor")) { + k.setCursor(w, c); + } + Path b = st.save(slot, k); + System.out.println("saved" + (b != null ? ", backup: " + b : "")); + } + case "--editor-build", "--editor-import", "--editor-new", "--import-level" -> { + if (!args[0].equals("--import-level") && !Edition.editorEnabled()) { + throw new UsageException("This LlamaMod build has no level editor."); + } + return EditorCli.run(args[0], rest, paths); + } + default -> { + usage(); + return args[0].equals("--help") || args[0].equals("-h") ? 0 : 2; + } + } + return 0; + } + + private static void listMods(AppPaths paths, Config cfg) { + ModRegistry reg = new ModRegistry(); + ModManager mm = new ModManager(paths, cfg); + for (Mod m : reg.all()) { + System.out.printf( + "%-20s %-8s %-10s %s – %s%s%n", + m.id(), + m.version(), + m.category(), + m.name(), + m.description(), + mm.modifiesExecutable(m) ? " [modifies groove.exe]" : ""); + Map<String, String> values = ModOptions.resolve(m, cfg); + for (ModOption o : m.options()) { + String range = + switch (o.type()) { + case INT -> " " + o.min() + ".." + o.max(); + case CHOICE -> " " + + o.choices().stream() + .map(ModOption.Choice::value) + .toList(); + default -> ""; + }; + System.out.printf( + " --opt %s.%s=%s (%s%s, default %s) %s%n", + m.id(), + o.key(), + values.get(o.key()), + o.type().name().toLowerCase(Locale.ROOT), + range, + o.defaultValue().isEmpty() ? "random" : o.defaultValue(), + o.label()); + } + } + reg.problems().forEach(p -> System.out.println("! " + p)); + } + + /** {@code --apply [id,id,...] [--opt id.key=value ...]}; returns the exit code. */ + private static int apply(AppPaths paths, Config cfg, List<String> rest, Progress console) throws IOException { + requireInstalled(cfg); + ModRegistry reg = new ModRegistry(); + List<Mod> mods = new ArrayList<>(); + List<String> opts = new ArrayList<>(); + for (int i = 0; i < rest.size(); i++) { + String a = rest.get(i); + if (a.equals("--opt") && i + 1 < rest.size()) { + opts.add(rest.get(++i)); + } else if (a.startsWith("--opt=")) { + opts.add(a.substring(6)); + } else if (mods.isEmpty() && !a.startsWith("--")) { + for (String id : a.split(",")) { + if (id.isBlank()) { + continue; + } + Mod m = reg.get(id.trim()); + if (m == null) { + System.err.println("Unknown mod: " + id); + return 2; + } + mods.add(m); + } + } else { + System.err.println("Unexpected argument: " + a); + return 2; + } + } + for (String o : opts) { + int dot = o.indexOf('.'); + int eq = o.indexOf('='); + if (dot <= 0 || eq < dot) { + System.err.println("--opt expects id.key=value, got: " + o); + return 2; + } + Mod m = reg.get(o.substring(0, dot)); + if (m == null) { + System.err.println("Unknown mod in --opt: " + o.substring(0, dot)); + return 2; + } + try { + ModOptions.set(cfg, m, o.substring(dot + 1, eq), o.substring(eq + 1)); + } catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + return 2; + } + } + cfg.enabledMods = new ArrayList<>(mods.stream().map(Mod::id).toList()); + ModManager mm = new ModManager(paths, cfg); + ModManager.Result r = mm.apply(mods, console); + System.out.println( + "Active: " + r.modIds() + ", changed files: " + r.changedFiles() + ", cached: " + r.cachedMods()); + r.notes().forEach(n -> System.out.println(" " + n)); + checkExe(mm, paths); + return 0; + } + + private static void listSaves(AppPaths paths) { + SaveStore st = new SaveStore(paths); + for (int s = 0; s < SaveStore.SLOTS; s++) { + Ksv k = st.load(s); + if (!k.used()) { + System.out.printf("Slot %d: empty%n", s + 1); + continue; + } + System.out.printf( + "Slot %d: last %s %d, unlocked up to %s %d, map cursor %s %d, lives %d, gold coins %d%n", + s + 1, + Worlds.name(k.lastWorld()), + k.lastChapter(), + Worlds.name(k.maxWorld()), + k.maxChapter(), + Worlds.name(k.cursorWorld()), + k.cursorChapter(), + k.lives(), + k.goldCoins()); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/Bases.java b/src/main/java/com/eliasmueller/llamamod/editor/io/Bases.java new file mode 100644 index 0000000..81fb98f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/Bases.java @@ -0,0 +1,72 @@ +package com.eliasmueller.llamamod.editor.io; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.stream.Stream; + +/** + * Source of original game files (the extracted CD): base level WADs and other levels for foreign + * object types. Paths are CD-relative with '/', matched case-insensitively. + */ +@FunctionalInterface +public interface Bases { + /** File content, or {@code null} if the CD has no such file. */ + byte[] file(String relPath) throws IOException; + + default byte[] wad(String base) throws IOException { + byte[] d = file("wads/" + base.toLowerCase(Locale.ROOT) + ".wad"); + if (d == null) { + throw new IOException("base level " + base + " not found on the game CD"); + } + return d; + } + + /** Same as {@link #file} with IO errors as {@code null} (for optional lookups). */ + default byte[] fileOrNull(String relPath) { + try { + return file(relPath); + } catch (IOException e) { + return null; + } + } + + static Bases fromCd(Path cd) { + return rel -> { + Path p = resolveIgnoreCase(cd, rel); + return p == null ? null : Files.readAllBytes(p); + }; + } + + static Path resolveIgnoreCase(Path root, String rel) throws IOException { + Path cur = root; + for (String part : rel.split("/")) { + Path direct = cur.resolve(part); + if (Files.exists(direct)) { + cur = direct; + continue; + } + if (!Files.isDirectory(cur)) { + return null; + } + Path found = null; + try (Stream<Path> s = Files.list(cur)) { + for (Path c : s.toList()) { + if (c.getFileName().toString().equalsIgnoreCase(part)) { + found = c; + break; + } + } + } catch (UncheckedIOException e) { + throw e.getCause(); + } + if (found == null) { + return null; + } + cur = found; + } + return cur; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/BrushCompiler.java b/src/main/java/com/eliasmueller/llamamod/editor/io/BrushCompiler.java new file mode 100644 index 0000000..ee096b6 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/BrushCompiler.java @@ -0,0 +1,854 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.MeshAsset; +import com.eliasmueller.llamamod.editor.model.MeshData; +import com.eliasmueller.llamamod.editor.model.RawChunk; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.format.Collision; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.format.Trak; +import com.eliasmueller.llamamod.format.Vertex; +import com.eliasmueller.llamamod.format.Wad; +import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import com.eliasmueller.llamamod.levelbuild.Opts; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Brushes and mesh placements → generated tile models, tiles (with baked vertex colours and + * collision) and textures, through {@link LevelBuilder} (same primitives as levels/testwelt.py). + * Death planes (SEA with a death plane, KILL_PLANE) are placed in every cell of their area that is + * not completely covered by floor collision. Generated nodes are not part of the scene; their ids + * start with "gen:". + */ +public final class BrushCompiler { + /** Local y of the death plane in its model (as the village's model 0). */ + static final double KILL_LOCAL_Y = -2.75; + + static final int KILL_MATERIAL = 30; + static final int MESH_TEXTURE_FLAGS = 0x104; + + private BrushCompiler() {} + + public record Output( + List<TileModel> models, List<Tile> tiles, List<TexturePage> pages, List<TextureEntry> textures) { + static final Output EMPTY = new Output(List.of(), List.of(), List.of(), List.of()); + } + + /** Kill area {y, x0, z0, x1, z1} in game space. */ + private record Kill(double y, double x0, double z0, double x1, double z1) {} + + /** One mesh triangle as a builder quad. */ + private record MeshQuad(double[][] points, String tex, double[] tint, boolean doubleSided) {} + + /** Most quads one brush may split into (keeps the live preview responsive). */ + static final long MAX_PIECES = 200_000; + + /** + * Parameter values the builder cannot handle (they would hang it, run out of memory or index + * past an array): non-numbers, tile sizes / spacings near zero, too many steps, arrays of the + * wrong length. null if fine. + */ + public static String paramProblem(Brush b) { + BrushKind k = b.brushKind(); + for (BrushKind.Param p : k.params()) { + Object v = b.get(p.name()); + if (v instanceof Double d && !Double.isFinite(d)) { + return p.label() + " is not a number"; + } + if (v instanceof double[] a) { + for (double x : a) { + if (!Double.isFinite(x)) { + return p.label() + " contains a value that is not a number"; + } + } + } + } + for (String n : List.of("tint", "rotation", "stretch", "normal")) { + if (has(k, n) && b.get(n) instanceof double[] a && a.length != 3) { + return k.param(n).label() + " needs 3 values"; + } + } + if (has(k, "killArea") && "area".equals(b.get("kill")) && b.a("killArea").length != 4) { + return k.param("killArea").label() + " needs 4 values"; + } + if (has(k, "step")) { + double step = b.d("step"); + if (step < 0.125 || step > 64) { + return "tile size must be 0.125..64"; + } + if (has(k, "x0")) { + double nx = Math.abs(b.d("x1") - b.d("x0")) / step + 2, nz = Math.abs(b.d("z1") - b.d("z0")) / step + 2; + if (nx * nz > MAX_PIECES) { + return "too many tiles (" + (long) (nx * nz) + "): use a larger tile size"; + } + } + } + if (has(k, "postsEvery") && b.d("postsEvery") < 0.1) { + return "post spacing must be at least 0.1"; + } + if (has(k, "steps") && (b.i("steps") < 1 || b.i("steps") > 256)) { + return "steps must be 1..256"; + } + for (String n : List.of("thick", "size", "radius", "height", "crown", "trunkSize", "w", "d")) { + if (has(k, n) && (b.d(n) < 0 || b.d(n) > 256)) { + return k.param(n).label() + " must be 0..256"; + } + } + if (has(k, "texSize") && (b.i("texSize") < 16 || b.i("texSize") > 256)) { + return "texture size must be 16..256"; + } + if (has(k, "uvSteps") && (b.i("uvSteps") < 1 || b.i("uvSteps") > 256)) { + return "UV precision must be 1..256"; + } + if (k == BrushKind.FENCE && b.a("points").length / 2 > 1024 + || (k == BrushKind.QUAD || k == BrushKind.COLLIDER) && b.a("points").length > 12) { + return "too many points"; + } + if (k == BrushKind.FENCE) { + double len = 0; + double[] a = b.a("points"); + for (int i = 0; i + 3 < a.length; i += 2) { + len += Math.hypot(a[i + 2] - a[i], a[i + 3] - a[i + 1]); + } + if (len / b.d("postsEvery") > MAX_PIECES) { + return "too many fence posts: use a larger post spacing"; + } + } + return null; + } + + private static boolean has(BrushKind k, String param) { + for (BrushKind.Param p : k.params()) { + if (p.name().equals(param)) { + return true; + } + } + return false; + } + + /** Cells a brush may reach beyond the grid before the compiler skips it (memory guard). */ + static final double FAR_MARGIN = 64; + + /** Highest |y| (cells) of brush geometry. */ + static final double MAX_HEIGHT = 4096; + + /** + * Why a brush's bounds are unusable: non-finite coordinates, more than margin cells outside + * the w × h grid, or beyond ±{@link #MAX_HEIGHT}; null if fine. + */ + public static String extentProblem(Brush b, int w, int h, double margin) { + double[] bb = b.bounds(); + for (double v : bb) { + if (Double.isNaN(v)) { + return "coordinates are not numbers"; + } + } + if (!Double.isFinite(bb[0]) && !Double.isFinite(bb[3])) { + return null; + } + for (double v : bb) { + if (!Double.isFinite(v)) { + return "coordinates are infinite"; + } + } + if (bb[0] < -margin || bb[2] < -margin || bb[3] > w + margin || bb[5] > h + margin) { + return String.format( + java.util.Locale.ROOT, + "extends outside the %d×%d grid (x %.1f..%.1f, z %.1f..%.1f)", + w, + h, + bb[0], + bb[3], + bb[2], + bb[5]); + } + if (bb[1] < -MAX_HEIGHT || bb[4] > MAX_HEIGHT) { + return String.format(java.util.Locale.ROOT, "height %.1f..%.1f is out of range", bb[1], bb[4]); + } + return null; + } + + /** {@link #paramProblem}, then {@link #extentProblem}; null if the brush can be built. */ + static String problem(Brush b, int w, int h, double margin) { + String bad = paramProblem(b); + return bad != null ? bad : extentProblem(b, w, h, margin); + } + + /** Brushes near the cell named in a "geometry outside the grid: cell x,z" error (to find them). */ + static String culprits(List<Brush> brushes, String msg) { + java.util.regex.Matcher m = + java.util.regex.Pattern.compile("cell (-?\\d+),(-?\\d+)").matcher(msg == null ? "" : msg); + if (!m.find()) { + return ""; + } + int cx = Integer.parseInt(m.group(1)); + int cz = Integer.parseInt(m.group(2)); + List<String> ids = new ArrayList<>(); + for (Brush b : brushes) { + double[] bb = b.bounds(); + if (Double.isFinite(bb[0]) + && bb[0] - 1 < cx + 1 + && bb[3] + 1 > cx + && bb[2] - 1 < cz + 1 + && bb[5] + 1 > cz) { + ids.add(b.id()); + } + } + return ids.isEmpty() + ? "" + : " – move " + String.join(", ", ids.subList(0, Math.min(5, ids.size()))) + " into the grid"; + } + + public static Output compile(Scene s, List<String> warn) { + List<Brush> brushes = s.brushes().stream().filter(Brush::enabled).toList(); + if (brushes.isEmpty()) { + return Output.EMPTY; + } + LevelSettings st = s.settings(); + List<TexturePage> genPages = new ArrayList<>(); + List<TextureEntry> genTex = new ArrayList<>(); + MeshTextures mt = new MeshTextures(s, genPages, genTex); + // mesh materials first: their pages and entries must exist in the TEXT the builder sees + Map<Brush, List<MeshQuad>> meshQuads = new HashMap<>(); + for (Brush b : brushes) { + if (b.brushKind() == BrushKind.MESH) { + meshQuads.put(b, mt.triangles(b, warn)); + } + } + List<TexturePage> pages = new ArrayList<>(s.pages()); + pages.addAll(genPages); + List<TextureEntry> entries = new ArrayList<>(s.textures()); + entries.addAll(genTex); + byte[] text = ChunkWriter.text(s, pages, entries); + LevelBuilder lb = new LevelBuilder(syntheticBase(s, text), rel -> null, st.levelName(), st.seed(), Map.of()); + LevelBuilder.Sun sun = lb.sun; + sun.azimuth = st.sunAzimuth(); + sun.elevation = st.sunElevation(); + sun.strength = st.sunStrength(); + sun.color = st.sunColor().clone(); + sun.sky = st.sky().clone(); + sun.ground = st.ground().clone(); + sun.ao = st.ao(); + sun.aoRadius = st.aoRadius(); + sun.shadow = st.shadow(); + List<Kill> kills = new ArrayList<>(); + for (Brush b : brushes) { + try { + String bad = problem(b, st.width(), st.height(), FAR_MARGIN); + if (bad != null) { + throw new IllegalArgumentException(bad); + } + int q0 = lb.quadCount(); + int s0 = lb.solidCount(); + replay(lb, b, kills, meshQuads.get(b)); + postProcess(lb, b, q0, s0); + } catch (IllegalArgumentException e) { + warn.add(b.label() + ": " + e.getMessage() + " – skipped"); + } catch (RuntimeException e) { + // one broken brush must not take all the others with it (live preview and build) + warn.add(b.label() + ": cannot be built (" + e + ") – skipped"); + } + } + byte[] wad; + try { + wad = lb.build(); + } catch (IllegalStateException e) { + throw new IllegalStateException("brushes: " + e.getMessage() + culprits(brushes, e.getMessage()), e); + } + Level lv = Level.parse(wad); + List<TileModel> models = new ArrayList<>(); + for (int i = 0; i < lv.models().size(); i++) { + TileModel m = WadImporter.tileModel("gen:model:" + i, lv.models().get(i)); + m.setRaw("header", null); + models.add(m); + } + List<Tile> tiles = new ArrayList<>(); + MapChunk map = lv.map(); + List<MapChunk.TileColors> cols = map.tileColors(Trak.vertCounts(lv.models())); + for (int i = 0; i < map.tiles.size(); i++) { + MapChunk.Tile mt2 = map.tiles.get(i); + Tile t = new Tile("gen:tile:" + i); + t.setRaw("x", mt2.x()); + t.setRaw("y", mt2.y()); + t.setRaw("z", mt2.z()); + t.setRaw("rot", mt2.rot()); + t.setRaw("model", models.get(mt2.model())); + t.setRaw("colors", cols.get(i).frames().getFirst()); + tiles.add(t); + } + List<TextChunk.TextureEntry> outTex = TextChunk.textureEntries(lv.wad().require("TEXT")); + for (int i = entries.size(); i < outTex.size(); i++) { + genTex.add(WadImporter.textureEntry("gen:tex:" + i, outTex.get(i))); + } + if (!kills.isEmpty()) { + addKillPlanes(s, kills, models, tiles); + } + return new Output(models, tiles, genPages, genTex); + } + + /** WAD the builder takes object code, textures and grid size from (no tiles). */ + private static byte[] syntheticBase(Scene s, byte[] text) { + List<Wad.Chunk> ch = new ArrayList<>(); + for (RawChunk c : s.chunks()) { + if (c.tag().equals("WFPC") || c.tag().equals("STPC")) { + ch.add(new Wad.Chunk(c.tag(), c.data())); + } + } + ch.add(new Wad.Chunk("TEXT", text)); + ch.add(new Wad.Chunk("TRAK", Trak.build(List.of()))); + ch.add(new Wad.Chunk("MAP ", ChunkWriter.map(s, List.of(), List.of()))); + ch.add(new Wad.Chunk("END ", new byte[0])); + return new Wad(ch).toBytes(); + } + + private static Opts opts(Brush b) { + Opts o = Opts.o(); + for (BrushKind.Param p : b.brushKind().params()) { + Object v = b.get(p.name()); + if (v == null) { + continue; + } + switch (p.name()) { + case "top" -> o.top((String) v); + case "side" -> o.side((String) v); + case "sideTop" -> o.sideTop((String) v); + case "bottom" -> o.bottom((String) v); + case "sides" -> o.sides((String) v); + case "coll" -> o.coll((Boolean) v); + case "sideColl" -> o.sideColl((Boolean) v); + case "mat" -> o.mat((Integer) v); + case "solid" -> o.solid((Boolean) v); + case "texRot" -> o.rot((Integer) v); + case "step" -> o.step((Double) v); + case "thick" -> o.thick((Double) v); + case "base" -> o.base((Double) v); + case "walkable" -> o.walkable((Boolean) v); + case "tint" -> { + double[] t = (double[]) v; + o.tint(t[0], t[1], t[2]); + } + default -> {} + } + } + return o; + } + + private static double[][] points(double[] a) { + if (a.length != 9 && a.length != 12) { + throw new IllegalArgumentException("3 or 4 corners (x, y, z) needed"); + } + double[][] p = new double[a.length / 3][]; + for (int i = 0; i < p.length; i++) { + p[i] = new double[] {a[3 * i], a[3 * i + 1], a[3 * i + 2]}; + } + return p; + } + + private static void replay(LevelBuilder lb, Brush b, List<Kill> kills, List<MeshQuad> mesh) { + Opts o = opts(b); + switch (b.brushKind()) { + case FLOOR -> { + if (b.i("mat") == 0) { + lb.floor(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y"), b.s("tex"), o); + } else { + // same surface as floor(), with a collision material + o.top(b.s("tex")).side(Opts.NONE).solid(false); + lb.box(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y"), b.d("y"), o); + } + } + case BOX -> lb.box(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("y0"), b.d("y1"), o); + case PLATFORM -> lb.platform(b.d("x"), b.d("z"), b.d("w"), b.d("d"), b.d("y"), o); + case PILLAR -> lb.pillar(b.d("x"), b.d("z"), b.d("y0"), b.d("y1"), b.d("size"), o); + case CYLINDER -> lb.cylinder(b.d("x"), b.d("z"), b.d("y0"), b.d("y1"), b.d("radius"), b.i("segments"), o); + case RAMP -> lb.ramp(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), b.d("ya"), b.d("yb"), b.s("direction"), o); + case STAIRS -> lb.stairs( + b.d("x0"), + b.d("z0"), + b.d("x1"), + b.d("z1"), + b.d("y0"), + b.d("y1"), + b.i("steps"), + b.s("direction"), + o); + case FENCE -> { + double[] a = b.a("points"); + List<double[]> pts = new ArrayList<>(); + for (int i = 0; i + 1 < a.length; i += 2) { + pts.add(new double[] {a[i], a[i + 1]}); + } + lb.fence(pts, b.d("y"), b.d("height"), b.d("thick"), b.s("post"), b.s("rail"), b.d("postsEvery")); + } + case TREE -> lb.tree( + b.d("x"), + b.d("z"), + b.d("y"), + b.d("height"), + b.d("crown"), + b.s("trunk"), + b.s("leaves"), + b.d("trunkSize")); + case SEA -> { + double y = b.d("y"); + lb.sea(b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"), y, b.s("tex"), new double[0], b.d("step")); + switch (b.s("kill")) { + case "whole" -> kills.add(new Kill(y + 0.05, b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"))); + case "area" -> { + double[] k = b.a("killArea"); + kills.add(new Kill(y + 0.05, k[0], k[1], k[2], k[3])); + } + default -> {} + } + } + case KILL_PLANE -> kills.add(new Kill(b.d("y"), b.d("x0"), b.d("z0"), b.d("x1"), b.d("z1"))); + case QUAD -> { + double[] t = b.a("tint"); + lb.quad( + points(b.a("points")), + b.s("tex"), + b.b("coll"), + b.i("mat"), + t.clone(), + b.i("texRot"), + b.b("shade"), + b.b("doubleSided"), + b.b("render")); + } + case COLLIDER -> lb.collider( + points(b.a("points")), b.i("mat"), b.a("normal").clone()); + case MESH -> { + if (mesh != null) { + for (MeshQuad q : mesh) { + lb.quad( + q.points(), + q.tex(), + b.b("coll"), + b.i("mat"), + q.tint(), + 0, + b.b("shade"), + q.doubleSided(), + true); + } + } + } + } + } + + // ------------------------------------------------------------ generic brush parameters + + /** Face class (index in {@link BrushKind#FACES}) of a quad from its front normal (game space). */ + public static int faceClass(double[] n) { + if (n[1] >= 0.35) { + return 0; + } + if (n[1] <= -0.35) { + return 1; + } + if (Math.abs(n[0]) > Math.abs(n[2])) { + return n[0] > 0 ? 3 : 5; + } + return n[2] > 0 ? 4 : 2; + } + + /** Front normal (game space) of builder quad corners. */ + public static double[] frontNormal(double[][] p) { + double[] a = {p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]}; + double[] c = {p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]}; + // builder corners are wound for export space (z mirrored): the cross product points inwards + double[] n = {-(a[1] * c[2] - a[2] * c[1]), -(a[2] * c[0] - a[0] * c[2]), -(a[0] * c[1] - a[1] * c[0])}; + double l = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + return l < 1e-12 ? new double[] {0, 1, 0} : new double[] {n[0] / l, n[1] / l, n[2] / l}; + } + + /** + * Applies the generic parameters of transformable kinds to the quads/shadow boxes a brush added: + * per-face collision and material, visibility and the rotation about the pivot. + */ + static void postProcess(LevelBuilder lb, Brush b, int q0, int s0) { + if (!b.brushKind().transformable()) { + return; + } + String cf = b.s("collFaces"); + int[] fm = b.get("faceMats") instanceof int[] a && a.length == 6 ? a : null; + boolean visible = b.visible(); + boolean perFace = + !BrushKind.FACES.equals(cf) || fm != null && Arrays.stream(fm).anyMatch(m -> m >= 0); + boolean rot = b.hasRotation(); + if (!perFace && visible && !rot) { + return; + } + double[] pv = b.pivot(); + double[] m = Brush.rotationMatrix(b.rotation()); + lb.editQuads(q0, e -> { + if (perFace) { + int k = faceClass(frontNormal(e.points)); + char c = BrushKind.FACES.charAt(k); + if (cf.indexOf(c) < 0) { + e.coll = false; + } + if (fm != null && fm[k] >= 0) { + e.mat = fm[k]; + } + } + if (!visible) { + e.render = false; + } + if (rot) { + for (int i = 0; i < e.points.length; i++) { + e.points[i] = Brush.rotate(m, pv, e.points[i]); + } + } + }); + lb.editSolids(s0, a -> { + if (!visible) { + return null; + } + double[] o = { + Double.MAX_VALUE, + Double.MAX_VALUE, + Double.MAX_VALUE, + -Double.MAX_VALUE, + -Double.MAX_VALUE, + -Double.MAX_VALUE + }; + for (int c = 0; c < 8; c++) { + double[] p = {a[(c & 1) != 0 ? 3 : 0], a[(c & 2) != 0 ? 4 : 1], a[(c & 4) != 0 ? 5 : 2]}; + double[] w = rot ? Brush.rotate(m, pv, p) : p; + for (int k = 0; k < 3; k++) { + o[k] = Math.min(o[k], w[k]); + o[k + 3] = Math.max(o[k + 3], w[k]); + } + } + return o; + }); + } + + // ------------------------------------------------------------ death planes + + static Model killModel() { + Model m = new Model(); + double y = KILL_LOCAL_Y; + double[][] c = {{-0.5, y, -0.5}, {-0.5, y, 0.5}, {0.5, y, 0.5}, {0.5, y, -0.5}}; + for (double[] p : c) { + m.verts.add(new Vertex(p[0], p[1], p[2], 0, 1, 0)); + } + m.coll.get(0).add(Collision.fromPolygon(Arrays.asList(c), KILL_MATERIAL, null)); + m.dirty = true; + return m; + } + + private static void addKillPlanes(Scene s, List<Kill> kills, List<TileModel> models, List<Tile> tiles) { + Map<Long, Double> floor = new HashMap<>(); + List<Tile> all = + new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList()); + all.addAll(tiles); + for (Tile t : all) { + int cx = t.cellX(); + int cz = t.cellZ(); + for (TileModel.PlacedPolygon p : t.worldCollisionPolygons()) { + if (p.material() == KILL_MATERIAL || Math.abs(p.normal()[1]) < 0.7) { + continue; + } + double[][] xz = new double[p.corners().length][]; + for (int i = 0; i < xz.length; i++) { + xz[i] = new double[] {p.corners()[i][0], -p.corners()[i][2]}; + } + double a = clippedArea(xz, cx, cz); + if (a > 0) { + floor.merge(cellKey(cx, cz), a, Double::sum); + } + } + } + TileModel km = WadImporter.tileModel("gen:model:kill", killModel()); + km.setRaw("header", null); + models.add(km); + int[] kc = new int[km.vertexCount()]; + Arrays.fill(kc, 0x01404040); + int w = s.settings().width(); + int h = s.settings().height(); + Set<Long> placed = new HashSet<>(); + int n = 0; + for (Kill k : kills) { + for (long cx = Math.max(0, (long) Math.floor(k.x0)); cx < Math.min(w, (long) Math.ceil(k.x1)); cx++) { + for (long cz = Math.max(0, (long) Math.floor(k.z0)); cz < Math.min(h, (long) Math.ceil(k.z1)); cz++) { + long key = cellKey(cx, cz); + if (floor.getOrDefault(key, 0.0) > 0.999 || !placed.add(key)) { + continue; + } + Tile t = new Tile("gen:kill:" + n++); + t.setRaw("x", cx + 0.5); + t.setRaw("y", (double) (float) (k.y - KILL_LOCAL_Y)); + t.setRaw("z", -(cz + 0.5)); + t.setRaw("rot", 0); + t.setRaw("model", km); + t.setRaw("colors", kc.clone()); + tiles.add(t); + } + } + } + } + + private static long cellKey(long cx, long cz) { + return (cx << 32) ^ (cz & 0xffffffffL); + } + + /** Area of a polygon (x, z) clipped to the cell [cx, cx+1] × [cz, cz+1]. */ + static double clippedArea(double[][] p, int cx, int cz) { + List<double[]> poly = new ArrayList<>(Arrays.asList(p)); + double[][] edges = {{1, 0, cx}, {-1, 0, -(cx + 1)}, {0, 1, cz}, {0, -1, -(cz + 1)}}; + for (double[] e : edges) { + List<double[]> out = new ArrayList<>(); + for (int i = 0; i < poly.size(); i++) { + double[] a = poly.get(i); + double[] b = poly.get((i + 1) % poly.size()); + double da = e[0] * a[0] + e[1] * a[1] - e[2]; + double db = e[0] * b[0] + e[1] * b[1] - e[2]; + if (da >= 0) { + out.add(a); + } + if ((da >= 0) != (db >= 0)) { + double t = da / (da - db); + out.add(new double[] {a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t}); + } + } + poly = out; + if (poly.isEmpty()) { + return 0; + } + } + double a = 0; + for (int i = 0; i < poly.size(); i++) { + double[] u = poly.get(i); + double[] v = poly.get((i + 1) % poly.size()); + a += u[0] * v[1] - v[0] * u[1]; + } + return Math.abs(a) / 2; + } + + // ------------------------------------------------------------ meshes + + /** + * Mesh materials → texture pages/entries; triangles → builder quads {points, texture, tint, + * double-sided}. The engine shows each triangle as one half of a texture rectangle, so every + * triangle gets the rectangle around its UVs on a (possibly flipped) copy of its texture with + * the corner order that fits best; UVs that tile beyond 0..1 are clamped. + */ + private static final class MeshTextures { + private final Scene s; + private final List<TextureEntry> genTex; + private final TextureAtlas atlas; + private final Map<String, TextureAtlas.Slot> slots = new HashMap<>(); + private final Map<String, Integer> entries = new HashMap<>(); + private final int texBase; + + MeshTextures(Scene s, List<TexturePage> genPages, List<TextureEntry> genTex) { + this.s = s; + this.genTex = genTex; + int[] n = {0}; + atlas = new TextureAtlas(s.pages().size(), () -> { + TexturePage p = new TexturePage("gen:page:" + n[0]++); + genPages.add(p); + return p; + }); + texBase = s.textures().size(); + } + + List<MeshQuad> triangles(Brush b, List<String> warn) { + MeshAsset a = (MeshAsset) b.ref("asset"); + if (a == null) { + warn.add(b.label() + ": no mesh asset"); + return List.of(); + } + MeshData m = a.mesh(); + if (m == null) { + try { + m = MeshImport.load(s, a); + } catch (java.io.IOException | RuntimeException e) { + warn.add(b.label() + ": " + a.file() + " cannot be read: " + e.getMessage()); + return List.of(); + } + } + double sc = b.d("scale"); + double[] st = b.get("stretch") instanceof double[] x && x.length == 3 ? x : new double[] {1, 1, 1}; + boolean mirror = st[0] * st[1] * st[2] < 0; + double ang = Math.toRadians(b.d("rotY")); + double ca = Math.cos(ang); + double sa = Math.sin(ang); + double ox = b.d("x"); + double oy = b.d("y"); + double oz = b.d("z"); + int size = b.i("texSize"); + int steps = Math.max(1, b.i("uvSteps")); + double[] tint = b.a("tint"); + List<MeshQuad> out = new ArrayList<>(); + double[] pos = m.positions(); + for (int t = 0; t < m.triangleCount(); t++) { + double[][] p = new double[3][]; + for (int k = 0; k < 3; k++) { + int v = m.triangles()[3 * t + k]; + double x = pos[3 * v] * sc * st[0]; + double y = pos[3 * v + 1] * sc * st[1]; + double z = pos[3 * v + 2] * sc * st[2]; + p[k] = new double[] {ox + x * ca + z * sa, oy + y, oz - x * sa + z * ca}; + } + MeshData.Material mat = m.materialList().get(m.materials()[t]); + double[] uv = Arrays.copyOfRange(m.uvs(), 6 * t, 6 * t + 6); + if (mirror) { + double[] q = p[1]; + p[1] = p[2]; + p[2] = q; + double u1 = uv[2], v1 = uv[3]; + uv[2] = uv[4]; + uv[3] = uv[5]; + uv[4] = u1; + uv[5] = v1; + } + double[] tn = tint.clone(); + if (m.colors() != null) { + double[] c = new double[3]; + for (int k = 0; k < 3; k++) { + int col = m.colors()[m.triangles()[3 * t + k]]; + c[0] += (col >> 16 & 0xff) / 255.0 / 3; + c[1] += (col >> 8 & 0xff) / 255.0 / 3; + c[2] += (col & 0xff) / 255.0 / 3; + } + for (int k = 0; k < 3; k++) { + tn[k] *= c[k]; + } + } + String tex; + if (mat.image() == null || Double.isNaN(uv[0])) { + tex = LevelBuilder.rgb(mat.rgb() >> 16 & 0xff, mat.rgb() >> 8 & 0xff, mat.rgb() & 0xff); + } else { + int[] best = fit(uv); + int r = best[0]; + int variant = best[1]; + p = new double[][] {p[r], p[(r + 1) % 3], p[(r + 2) % 3]}; + double[] u2 = new double[6]; + for (int k = 0; k < 3; k++) { + int src = (r + k) % 3; + u2[2 * k] = flip(uv[2 * src], (variant & 1) != 0); + u2[2 * k + 1] = flip(uv[2 * src + 1], (variant & 2) != 0); + } + tex = "#" + entry(m.materials()[t], a, mat, variant, size, steps, u2); + } + out.add(new MeshQuad(p, tex, tn, b.b("doubleSided") || mat.doubleSided())); + } + return out; + } + + private static double flip(double v, boolean f) { + return f ? 1 - v : v; + } + + /** {rotation, flip variant (bit 0 U, bit 1 V)} with the smallest corner error. */ + static int[] fit(double[] uv) { + int[] best = {0, 0}; + double err = Double.MAX_VALUE; + for (int variant = 0; variant < 4; variant++) { + for (int r = 0; r < 3; r++) { + double[] u = new double[3]; + double[] v = new double[3]; + for (int k = 0; k < 3; k++) { + int src = (r + k) % 3; + u[k] = flip(uv[2 * src], (variant & 1) != 0); + v[k] = flip(uv[2 * src + 1], (variant & 2) != 0); + } + double umin = Math.min(u[0], Math.min(u[1], u[2])); + double umax = Math.max(u[0], Math.max(u[1], u[2])); + double vmin = Math.min(v[0], Math.min(v[1], v[2])); + double vmax = Math.max(v[0], Math.max(v[1], v[2])); + // pattern of LevelBuilder triangles (0x820): bottom-left, bottom-right, top-right + double e = Math.abs(u[0] - umin) + + Math.abs(v[0] - vmax) + + Math.abs(u[1] - umax) + + Math.abs(v[1] - vmax) + + Math.abs(u[2] - umax) + + Math.abs(v[2] - vmin); + if (e < err - 1e-9) { + err = e; + best = new int[] {r, variant}; + } + } + } + return best; + } + + private int entry( + int matIndex, MeshAsset a, MeshData.Material mat, int variant, int size, int steps, double[] uv) { + String sk = a.id() + "/" + matIndex + "/" + variant + "/" + size; + TextureAtlas.Slot slot = slots.get(sk); + if (slot == null) { + BufferedImage img = new BufferedImage(mat.width(), mat.height(), BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < mat.height(); y++) { + for (int x = 0; x < mat.width(); x++) { + int sx = (variant & 1) != 0 ? mat.width() - 1 - x : x; + int sy = (variant & 2) != 0 ? mat.height() - 1 - y : y; + img.setRGB(x, y, mat.image()[sy * mat.width() + sx]); + } + } + slot = atlas.add(img, size); + slots.put(sk, slot); + } + double umin = Double.MAX_VALUE, umax = -Double.MAX_VALUE, vmin = Double.MAX_VALUE, vmax = -Double.MAX_VALUE; + for (int k = 0; k < 3; k++) { + umin = Math.min(umin, uv[2 * k]); + umax = Math.max(umax, uv[2 * k]); + vmin = Math.min(vmin, uv[2 * k + 1]); + vmax = Math.max(vmax, uv[2 * k + 1]); + } + double fu = Math.floor(umin); + double fv = Math.floor(vmin); + umin = clamp01(umin - fu); + umax = clamp01(umax - fu); + vmin = clamp01(vmin - fv); + vmax = clamp01(vmax - fv); + int q = Math.max(1, slot.size() / steps); + int u0 = (int) Math.floor(umin * slot.size() / q) * q; + int u1 = (int) Math.ceil(umax * slot.size() / q) * q - 1; + int v0 = (int) Math.floor(vmin * slot.size() / q) * q; + int v1 = (int) Math.ceil(vmax * slot.size() / q) * q - 1; + u1 = Math.max(u0, Math.min(slot.size() - 1, u1)); + v1 = Math.max(v0, Math.min(slot.size() - 1, v1)); + String ek = slot.page() + "/" + (slot.x() + u0) + "/" + (slot.x() + u1) + "/" + (slot.y() + v0) + "/" + + (slot.y() + v1); + Integer idx = entries.get(ek); + if (idx == null) { + idx = texBase + genTex.size(); + TextureEntry te = new TextureEntry("gen:tex:" + idx); + te.setRaw("flags", MESH_TEXTURE_FLAGS); + te.setRaw("page", slot.page()); + te.setRaw("page2", -1); + te.setRaw("u0", slot.x() + u0); + te.setRaw("u1", slot.x() + u1); + te.setRaw("v0", slot.y() + v0); + te.setRaw("v1", slot.y() + v1); + genTex.add(te); + entries.put(ek, idx); + } + return idx; + } + + private static double clamp01(double v) { + return Math.max(0, Math.min(1, v)); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/ChunkWriter.java b/src/main/java/com/eliasmueller/llamamod/editor/io/ChunkWriter.java new file mode 100644 index 0000000..cf350b7 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/ChunkWriter.java @@ -0,0 +1,319 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.TextureAnim; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.format.ByteSink; +import com.eliasmueller.llamamod.format.Face; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.format.Trak; +import com.eliasmueller.llamamod.format.Vertex; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** Serialises scene parts into chunk data (inverse of {@link WadImporter}). */ +final class ChunkWriter { + private ChunkWriter() {} + + /** Tile model to the format layer (header kept while the vertices are unchanged). */ + static Model toModel(TileModel tm) { + Model m = new Model(); + double[] v = tm.vertices(); + for (int i = 0; i < v.length; i += TileModel.VERTEX_STRIDE) { + m.verts.add(new Vertex(v[i], v[i + 1], v[i + 2], v[i + 3], v[i + 4], v[i + 5])); + } + int[] f = tm.faces(); + double[] p = tm.planes(); + for (int i = 0, k = 0; i < f.length; i += TileModel.FACE_STRIDE, k += TileModel.PLANE_STRIDE) { + m.faces.add(new Face( + f[i], f[i + 1], f[i + 2], f[i + 3], f[i + 4], f[i + 5], p[k], p[k + 1], p[k + 2], p[k + 3])); + } + for (int g = 0; g < 3; g++) { + byte[] c = tm.collision(g); + for (int o = 0; o + TileModel.COLLISION_SIZE <= c.length; o += TileModel.COLLISION_SIZE) { + m.coll.get(g).add(Arrays.copyOfRange(c, o, o + TileModel.COLLISION_SIZE)); + } + } + if (tm.header() != null) { + m.header = Arrays.copyOf(tm.header(), Trak.TRAK_HEADER); + m.origVertCount = m.verts.size(); + } else { + m.dirty = true; + } + return m; + } + + static byte[] trak(List<TileModel> models) { + List<Model> l = new ArrayList<>(models.size()); + for (TileModel tm : models) { + l.add(toModel(tm)); + } + return Trak.build(l); + } + + static Map<Object, Integer> indexMap(List<?> l) { + Map<Object, Integer> m = new IdentityHashMap<>(l.size() * 2); + for (int i = 0; i < l.size(); i++) { + m.put(l.get(i), i); + } + return m; + } + + /** Effective strat pool size written to MAP (enough slots for all placed strats). */ + static int poolExtra(Scene s) { + LevelSettings st = s.settings(); + return Math.max(st.poolExtra(), s.strats().size() + st.poolReserve()); + } + + private static final int[] DUMMY_NODE = new int[10]; + + /** + * MAP chunk. models/tiles: the scene's lists plus generated geometry (brushes); every tile's + * model must be in models. + */ + static byte[] map(Scene s, List<TileModel> models, List<Tile> tiles) { + LevelSettings st = s.settings(); + int wf = st.wfpc(); + Map<Object, Integer> mi = indexMap(models); + Map<Object, Integer> ni = indexMap(s.pathNodes()); + ByteSink o = new ByteSink(1 << 20); + o.i32(tiles.size()).i32(st.width()).i32(st.height()); + boolean alts = (wf & MapChunk.WF_ALTS) != 0; + int lastSwap = -1; + if (alts) { + for (int i = 0; i < tiles.size(); i++) { + if (tiles.get(i).hasSwap()) { + lastSwap = i; + } + } + } + for (int i = 0; i < tiles.size(); i++) { + Tile t = tiles.get(i); + int a = t.alt(); + if (alts && t.hasSwap() && a == 0) { + a = 1; + } else if (alts && !t.hasSwap() && a != 0 && i < lastSwap) { + a = 0; + } + o.f32(t.x()) + .f32(t.y()) + .f32(t.z()) + .i32(t.rot()) + .i32(index(mi, t.model(), t)) + .i32(a); + } + List<Integer> pool = new ArrayList<>(); + int[] pidx = paramIndices(s, pool); + o.i32(pool.size()); + for (int v : pool) { + o.i32(v); + } + o.i32(s.zones().size()); + for (EnvZone z : s.zones()) { + for (int i = 0; i < EnvZone.FIELDS.length; i++) { + if (i == 14 || i == 21 || i == 22) { + o.u16(z.field(i)); + } else { + o.i32(z.field(i)); + } + } + } + boolean dummy = s.pathNodes().isEmpty() && st.isNewLevel(); + o.i32(dummy ? 1 : s.pathNodes().size()); + if (dummy) { + node(o, DUMMY_NODE); + } + for (PathNode n : s.pathNodes()) { + int next = n.next() != null ? index(ni, n.next(), n) : n.nextRaw(); + node(o, new int[] {next, n.unk1(), n.rx(), n.ry(), n.rz(), n.x(), n.y(), n.z(), n.unk2(), n.unk3()}); + } + o.i32s(s.regions().table()); + o.i32s(s.regions().grid()); + for (Tile t : tiles) { + o.i32s(t.effectiveFixed()); + } + for (Tile t : tiles) { + o.i32(index(mi, t.model(), t)); + } + if (alts) { + int n = 0; + for (Tile t : tiles) { + n += t.hasSwap() ? 1 : 0; + } + o.i32(n); + for (Tile t : tiles) { + if (t.hasSwap()) { + int[] c = t.swapCell(); + o.i32(c[0]).i32(c[1]).i32(c[2]); + o.i32(t.swapModel() != null ? index(mi, t.swapModel(), t) : index(mi, t.model(), t)); + o.i32(index(mi, t.swapAltModel(), t)); + } + } + } + for (Tile t : tiles) { + o.i32s(colors(t.colors(), t.model().vertexCount())); + if (alts && t.hasSwap()) { + int[] ac = t.altColors(); + o.i32s(colors(ac == null ? new int[0] : ac, t.swapAltModel().vertexCount())); + } + } + o.i32(s.strats().size()).i32(poolExtra(s)); + for (int k = 0; k < pidx.length; k++) { + Strat x = s.strats().get(k); + int wp = x.waypoint() != null ? index(ni, x.waypoint(), x) : x.waypointRaw(); + o.u16(x.rx()).u16(x.ry()).u16(x.rz()); + o.i32(x.x()).i32(x.y()).i32(x.z()); + o.i32(x.code()).i32(x.params().length).i32(pidx[k]); + o.i32(x.nVars()).i32(x.slots()).i32(x.typeFlags()).i32(x.extra()); + o.i32(wp).i32(x.zero()).u16(x.flags()).u16(x.last()); + } + o.bytes(st.mapTail()); + return o.toByteArray(); + } + + /** + * Fills pool with the parameter pool and returns each strat's pool index: unchanged parameters + * keep their original index, changed ones are appended. + */ + private static int[] paramIndices(Scene s, List<Integer> pool) { + int[] pool0 = s.settings().paramPool(); + for (int v : pool0) { + pool.add(v); + } + int[] pidx = new int[s.strats().size()]; + for (int k = 0; k < pidx.length; k++) { + Strat x = s.strats().get(k); + int[] p = x.params(); + Integer pi = x.paramIndex(); + if (p.length == 0) { + pidx[k] = pi != null ? pi : -1; + } else if (pi != null + && pi >= 0 + && pi + p.length <= pool0.length + && Arrays.equals(p, Arrays.copyOfRange(pool0, pi, pi + p.length))) { + pidx[k] = pi; + } else { + pidx[k] = pool.size(); + for (int v : p) { + pool.add(v); + } + } + } + return pidx; + } + + private static void node(ByteSink o, int[] v) { + o.i32(v[0]).i32(v[1]).u16(v[2]).u16(v[3]).u16(v[4]); + o.i32(v[5]).i32(v[6]).i32(v[7]).i32(v[8]).i32(v[9]); + } + + private static int index(Map<Object, Integer> m, Object target, Object user) { + Integer i = m.get(target); + if (i == null) { + throw new IllegalStateException(user + " references " + target + ", which is not part of the level"); + } + return i; + } + + /** + * Colour block of one tile: frames × nv words. A length that does not fit the model (vertices + * edited) is repaired: the first frame is resized (new vertices grey) and the frame count + * byte kept consistent. + */ + static int[] colors(int[] c, int nv) { + if (nv == 0) { + return new int[0]; + } + int frames = 1; + for (int i = 0; i < Math.min(nv, c.length); i++) { + frames = Math.max(frames, c[i] >>> 24); + } + if (c.length == frames * nv) { + return c; + } + int[] out = new int[nv]; + for (int i = 0; i < nv; i++) { + out[i] = i < c.length ? c[i] & 0x00ffffff : Tile.GREY; + } + out[0] |= 0x01000000; + return out; + } + + static byte[] text(Scene s, List<TexturePage> pages, List<TextureEntry> entries) { + ByteSink o = new ByteSink(1 << 20); + o.bytes(s.settings().textPalettes()); + o.i32(pages.size()); + for (TexturePage p : pages) { + o.bytes(encodePage(p)); + } + o.i32(entries.size()); + for (TextureEntry e : entries) { + o.u16(e.flags()) + .u8(e.page()) + .u8(e.page2()) + .u8(e.u0()) + .u8(e.u1()) + .u8(e.v0()) + .u8(e.v1()); + } + o.i32(s.textureAnims().size()); + for (TextureAnim a : s.textureAnims()) { + o.u16(a.texture()).u8(a.type()).bytes(a.data()); + } + o.bytes(s.settings().textTail()); + return o.toByteArray(); + } + + /** Page record {flags, w, h, data} (cached original bytes while unchanged). */ + static byte[] encodePage(TexturePage p) { + if (p.encoded() != null) { + return p.encoded(); + } + ByteSink o = new ByteSink(p.pixels().length * 2 + 16); + int f = p.flags(); + o.i32(f).i32(p.width()).i32(p.height()); + if (f == 0) { + for (int v : p.pixels()) { + o.u8(v); + } + } else if ((f & 0x80) != 0) { + byte[] b = TextChunk.rleEncode(p.pixels()); + o.i32(b.length).bytes(b); + } else { + for (int v : p.pixels()) { + o.u16(v); + } + } + byte[] out = o.toByteArray(); + p.setEncodedCache(out); + return out; + } + + static byte[] lights(Scene s) { + ByteSink o = new ByteSink(1024); + o.i32(s.lights().size()); + for (Light l : s.lights()) { + o.u8(l.type()).u8(l.r()).u8(l.g()).u8(l.b()); + if (l.type() == Light.DIRECTIONAL) { + o.f32(l.x()).f32(l.y()).f32(l.z()); + } else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) { + o.f32(l.x()).f32(l.y()).f32(l.z()).f32(l.a()).f32(l.rangeB()).u8(l.c()); + } + } + o.bytes(s.settings().lghtTail()); + return o.toByteArray(); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/Images.java b/src/main/java/com/eliasmueller/llamamod/editor/io/Images.java new file mode 100644 index 0000000..f7cc27d --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/Images.java @@ -0,0 +1,99 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.format.TextChunk; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import javax.imageio.ImageIO; + +/** PNG ↔ texture page pixels. ABGR1555 → RGBA → ABGR1555 is lossless (alpha 0 / 128 / 255). */ +public final class Images { + private Images() {} + + public static BufferedImage pageImage(TexturePage p) { + BufferedImage img = new BufferedImage(p.width(), p.height(), BufferedImage.TYPE_INT_ARGB); + img.setRGB(0, 0, p.width(), p.height(), p.argb(), 0, p.width()); + return img; + } + + public static byte[] pagePng(TexturePage p) { + return png(pageImage(p)); + } + + public static byte[] png(BufferedImage img) { + try { + ByteArrayOutputStream o = new ByteArrayOutputStream(); + ImageIO.write(img, "png", o); + return o.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static BufferedImage read(byte[] data) throws IOException { + BufferedImage img = ImageIO.read(new ByteArrayInputStream(data)); + if (img == null) { + throw new IOException("not a readable image (PNG or JPEG expected)"); + } + return img; + } + + /** ABGR1555 pixels of an image (scaled to w×h if the size differs). */ + public static int[] pagePixels(byte[] png, int w, int h) { + try { + return toAbgr1555(read(png), w, h); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public static int[] toAbgr1555(BufferedImage img, int w, int h) { + BufferedImage src = img.getWidth() == w && img.getHeight() == h ? img : scale(img, w, h); + boolean alpha = src.getColorModel().hasAlpha(); + int[] out = new int[w * h]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int c = src.getRGB(x, y); + out[y * w + x] = TextChunk.rgbaToAbgr1555(c >> 16 & 0xff, c >> 8 & 0xff, c & 0xff, c >>> 24, alpha); + } + } + return out; + } + + /** Area-averaged scaling (premultiplied by alpha so transparent pixels do not bleed). */ + public static BufferedImage scale(BufferedImage img, int w, int h) { + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + int sw = img.getWidth(); + int sh = img.getHeight(); + for (int y = 0; y < h; y++) { + int y0 = y * sh / h; + int y1 = Math.max(y0 + 1, (y + 1) * sh / h); + for (int x = 0; x < w; x++) { + int x0 = x * sw / w; + int x1 = Math.max(x0 + 1, (x + 1) * sw / w); + long r = 0, g = 0, b = 0, a = 0; + int n = 0; + for (int yy = y0; yy < y1; yy++) { + for (int xx = x0; xx < x1; xx++) { + int c = img.getRGB(xx, yy); + int ca = c >>> 24; + a += ca; + r += (c >> 16 & 0xff) * ca; + g += (c >> 8 & 0xff) * ca; + b += (c & 0xff) * ca; + n++; + } + } + int oa = (int) (a / n); + int or = a == 0 ? 0 : (int) (r / a); + int og = a == 0 ? 0 : (int) (g / a); + int ob = a == 0 ? 0 : (int) (b / a); + out.setRGB(x, y, oa << 24 | or << 16 | og << 8 | ob); + } + } + return out; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/MeshImport.java b/src/main/java/com/eliasmueller/llamamod/editor/io/MeshImport.java new file mode 100644 index 0000000..89f5d2e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/MeshImport.java @@ -0,0 +1,685 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.MeshAsset; +import com.eliasmueller.llamamod.editor.model.MeshData; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.platform.Json; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; + +/** + * Imports 3D models as {@link MeshAsset}s: Wavefront OBJ (+ MTL, diffuse colour and map_Kd + * texture, optional vertex colours "v x y z r g b") and glTF 2.0 (.gltf with external or embedded + * buffers/images, .glb; node transforms, base colour factor/texture, COLOR_0, doubleSided). + * Files are copied into the project assets; the mesh is parsed from there, so a project is + * self-contained. Axes: files are y-up right-handed (or z-up, {@code MeshAsset.upAxis}); 1 file + * unit = {@code unitScale} cells. In game space z is mirrored (the result is not mirrored). + */ +public final class MeshImport { + /** Diffuse colour of materials without one. */ + private static final int GREY = 0xc0c0c0; + + private static final MeshData.Material DEFAULT_MATERIAL = new MeshData.Material("default", GREY, null, 0, 0, false); + + private MeshImport() {} + + /** Copies the file (and the files it references) into the scene assets; returns a detached asset node. */ + public static MeshAsset importFile(Scene s, Path file) throws IOException { + String fn = file.getFileName().toString(); + String stem = fn.contains(".") ? fn.substring(0, fn.lastIndexOf('.')) : fn; + String dir = "assets/" + slug(stem); + int n = 2; + while (hasDir(s, dir)) { + dir = "assets/" + slug(stem) + "-" + n++; + } + Path parent = file.toAbsolutePath().normalize().getParent(); + Map<String, byte[]> files = new LinkedHashMap<>(); + byte[] main = Files.readAllBytes(file); + files.put(fn, main); + for (String dep : dependencies(fn, main, rel -> read(parent, rel))) { + byte[] d = read(parent, dep); + if (d != null) { + files.put(dep, d); + } + } + MeshAsset a = (MeshAsset) s.create(NodeKind.MESH_ASSET); + a.setRaw("name", stem); + String d0 = dir; + files.forEach((k, v) -> s.assets().put(d0 + "/" + k, v)); + a.setRaw("file", dir + "/" + fn); + a.setMesh(parse(fn, main, rel -> s.assets().get(d0 + "/" + rel), a.upAxis(), a.unitScale())); + return a; + } + + private static boolean hasDir(Scene s, String dir) { + return s.assets().keySet().stream().anyMatch(k -> k.startsWith(dir + "/")); + } + + private static byte[] read(Path dir, String rel) { + try { + Path p = dir.resolve(rel.replace('\\', '/')).normalize(); + return p.startsWith(dir) && Files.isRegularFile(p) ? Files.readAllBytes(p) : null; + } catch (IOException | RuntimeException e) { + return null; + } + } + + static String slug(String s) { + String r = s.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "-").replaceAll("^-+|-+$", ""); + return r.isEmpty() ? "mesh" : r; + } + + /** Parses the asset from the scene's assets (and caches it in the node). */ + public static MeshData load(Scene s, MeshAsset a) throws IOException { + String f = a.file(); + byte[] main = s.assets().get(f); + if (main == null) { + throw new IOException("asset " + f + " missing in the project"); + } + String dir = f.contains("/") ? f.substring(0, f.lastIndexOf('/') + 1) : ""; + MeshData m = parse( + f.substring(dir.length()), + main, + rel -> s.assets().get(dir + rel.replace('\\', '/')), + a.upAxis(), + a.unitScale()); + a.setMesh(m); + return m; + } + + /** Files referenced by an OBJ (MTL, textures) or glTF (buffers, images). */ + static List<String> dependencies(String name, byte[] main, Function<String, byte[]> files) { + List<String> out = new ArrayList<>(); + String lower = name.toLowerCase(Locale.ROOT); + if (lower.endsWith(".obj")) { + for (String line : new String(main, StandardCharsets.UTF_8).split("\r?\n")) { + String t = line.trim(); + if (t.startsWith("mtllib ")) { + String mtl = t.substring(7).trim(); + out.add(mtl); + byte[] md = files.apply(mtl); + if (md != null) { + for (String ml : new String(md, StandardCharsets.UTF_8).split("\r?\n")) { + String m = ml.trim(); + if (m.toLowerCase(Locale.ROOT).startsWith("map_kd ")) { + out.add(lastToken(m)); + } + } + } + } + } + } else if (lower.endsWith(".gltf")) { + Map<?, ?> j = (Map<?, ?>) Json.parse(new String(main, StandardCharsets.UTF_8)); + for (String k : List.of("buffers", "images")) { + if (j.get(k) instanceof List<?> l) { + for (Object o : l) { + if (((Map<?, ?>) o).get("uri") instanceof String u && !u.startsWith("data:")) { + out.add(URLDecoder.decode(u, StandardCharsets.UTF_8)); + } + } + } + } + } + return out; + } + + private static String lastToken(String s) { + String[] p = s.trim().split("\\s+"); + return p[p.length - 1]; + } + + public static MeshData parse(String name, byte[] main, Function<String, byte[]> files, String up, double scale) + throws IOException { + String lower = name.toLowerCase(Locale.ROOT); + Builder b = new Builder(up, scale); + if (lower.endsWith(".obj")) { + obj(main, files, b); + } else if (lower.endsWith(".glb")) { + glb(main, files, b); + } else if (lower.endsWith(".gltf")) { + gltf((Map<?, ?>) Json.parse(new String(main, StandardCharsets.UTF_8)), null, files, b); + } else { + throw new IOException("unsupported model format (OBJ, glTF or GLB expected): " + name); + } + if (b.tris.isEmpty()) { + throw new IOException(name + " contains no triangles"); + } + return b.build(); + } + + /** Collects triangles in game space. */ + private static final class Builder { + final List<double[]> pos = new ArrayList<>(); + final List<Integer> cols = new ArrayList<>(); + boolean hasCols; + final List<int[]> tris = new ArrayList<>(); + final List<double[]> uvs = new ArrayList<>(); + final List<Integer> mats = new ArrayList<>(); + final List<MeshData.Material> materials = new ArrayList<>(); + final String up; + final double scale; + + Builder(String up, double scale) { + this.up = up; + this.scale = scale; + } + + /** File coordinates (right-handed) → game space. */ + int vertex(double x, double y, double z, Integer rgb) { + double gx = x; + double gy = "z".equals(up) ? z : y; + double gz = "z".equals(up) ? y : -z; + pos.add(new double[] {gx * scale, gy * scale, gz * scale}); + cols.add(rgb == null ? 0xffffff : rgb); + hasCols |= rgb != null; + return pos.size() - 1; + } + + void tri(int a, int b, int c, double[] uv, int mat) { + tris.add(new int[] {a, b, c}); + uvs.add(uv); + mats.add(mat); + } + + int material(MeshData.Material m) { + materials.add(m); + return materials.size() - 1; + } + + MeshData build() { + if (materials.isEmpty()) { + materials.add(DEFAULT_MATERIAL); + } + double[] p = new double[pos.size() * 3]; + for (int i = 0; i < pos.size(); i++) { + System.arraycopy(pos.get(i), 0, p, 3 * i, 3); + } + int[] t = new int[tris.size() * 3]; + double[] u = new double[tris.size() * 6]; + int[] m = new int[tris.size()]; + for (int i = 0; i < tris.size(); i++) { + System.arraycopy(tris.get(i), 0, t, 3 * i, 3); + System.arraycopy(uvs.get(i), 0, u, 6 * i, 6); + m[i] = Math.min(mats.get(i), materials.size() - 1); + } + int[] c = null; + if (hasCols) { + c = cols.stream().mapToInt(Integer::intValue).toArray(); + } + return new MeshData(p, t, u, m, c, List.copyOf(materials)); + } + } + + private static int[] argb(BufferedImage img) { + int w = img.getWidth(); + int h = img.getHeight(); + return img.getRGB(0, 0, w, h, null, 0, w); + } + + private static final double[] NO_UV = {Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN}; + + // ------------------------------------------------------------ OBJ + + private static void obj(byte[] data, Function<String, byte[]> files, Builder b) throws IOException { + List<double[]> v = new ArrayList<>(); + List<Integer> vc = new ArrayList<>(); + List<double[]> vt = new ArrayList<>(); + Map<String, MeshData.Material> lib = new HashMap<>(); + Map<String, Integer> used = new HashMap<>(); + int cur = -1; + Map<Integer, Integer> vmap = new HashMap<>(); + for (String raw : new String(data, StandardCharsets.UTF_8).split("\r?\n")) { + String line = raw.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String[] p = line.split("\\s+"); + switch (p[0]) { + case "v" -> { + v.add(new double[] {d(p[1]), d(p[2]), d(p[3])}); + vc.add(p.length >= 7 ? rgb(d(p[4]), d(p[5]), d(p[6])) : null); + } + case "vt" -> vt.add(new double[] {d(p[1]), p.length > 2 ? d(p[2]) : 0}); + case "mtllib" -> lib.putAll(mtl(files.apply(line.substring(6).trim()), files)); + case "usemtl" -> { + String n = line.substring(6).trim(); + Integer i = used.get(n); + if (i == null) { + MeshData.Material m = lib.getOrDefault(n, new MeshData.Material(n, GREY, null, 0, 0, false)); + i = b.material(m); + used.put(n, i); + } + cur = i; + } + case "f" -> { + int n = p.length - 1; + int[] vi = new int[n]; + double[][] uv = new double[n][]; + for (int k = 0; k < n; k++) { + String[] q = p[k + 1].split("/"); + int a = idx(q[0], v.size()); + vi[k] = vmap.computeIfAbsent(a, x -> { + double[] c = v.get(x); + return b.vertex(c[0], c[1], c[2], vc.get(x)); + }); + uv[k] = q.length > 1 && !q[1].isEmpty() ? vt.get(idx(q[1], vt.size())) : null; + } + if (cur < 0) { + cur = b.material(DEFAULT_MATERIAL); + used.put("", cur); + } + for (int k = 1; k + 1 < n; k++) { + int[] c = {0, k, k + 1}; + double[] u = NO_UV.clone(); + if (uv[0] != null && uv[k] != null && uv[k + 1] != null) { + for (int j = 0; j < 3; j++) { + u[2 * j] = uv[c[j]][0]; + u[2 * j + 1] = 1 - uv[c[j]][1]; + } + } + b.tri(vi[0], vi[k], vi[k + 1], u, cur); + } + } + default -> {} + } + } + } + + private static int idx(String s, int n) { + int i = Integer.parseInt(s); + return i > 0 ? i - 1 : n + i; + } + + private static double d(String s) { + return Double.parseDouble(s); + } + + private static int rgb(double r, double g, double b) { + return c8(r) << 16 | c8(g) << 8 | c8(b); + } + + private static int c8(double v) { + return (int) Math.max(0, Math.min(255, Math.round(v * 255))); + } + + private static Map<String, MeshData.Material> mtl(byte[] data, Function<String, byte[]> files) throws IOException { + Map<String, MeshData.Material> out = new HashMap<>(); + if (data == null) { + return out; + } + String name = null; + int kd = GREY; + String map = null; + List<String> lines = new ArrayList<>(List.of(new String(data, StandardCharsets.UTF_8).split("\r?\n"))); + lines.add("newmtl"); + for (String raw : lines) { + String line = raw.trim(); + String[] p = line.split("\\s+"); + if (p[0].equals("newmtl")) { + if (name != null) { + out.put(name, material(name, kd, map == null ? null : files.apply(map), false)); + } + name = line.length() > 7 ? line.substring(7).trim() : null; + kd = GREY; + map = null; + } else if (p[0].equals("Kd") && p.length >= 4) { + kd = rgb(d(p[1]), d(p[2]), d(p[3])); + } else if (p[0].equalsIgnoreCase("map_Kd")) { + map = lastToken(line); + } + } + return out; + } + + private static MeshData.Material material(String name, int rgb, byte[] image, boolean dbl) throws IOException { + if (image == null) { + return new MeshData.Material(name, rgb, null, 0, 0, dbl); + } + BufferedImage img = Images.read(image); + return new MeshData.Material(name, rgb, argb(img), img.getWidth(), img.getHeight(), dbl); + } + + // ------------------------------------------------------------ glTF + + private static void glb(byte[] d, Function<String, byte[]> files, Builder b) throws IOException { + ByteBuffer bb = ByteBuffer.wrap(d).order(ByteOrder.LITTLE_ENDIAN); + if (d.length < 20 || bb.getInt(0) != 0x46546c67) { + throw new IOException("not a GLB file"); + } + int o = 12; + Map<?, ?> json = null; + byte[] bin = null; + while (o + 8 <= d.length) { + int len = bb.getInt(o); + int type = bb.getInt(o + 4); + byte[] c = Arrays.copyOfRange(d, o + 8, o + 8 + len); + if (type == 0x4e4f534a) { + json = (Map<?, ?>) Json.parse(new String(c, StandardCharsets.UTF_8)); + } else if (type == 0x004e4942) { + bin = c; + } + o += 8 + len; + } + if (json == null) { + throw new IOException("GLB without JSON chunk"); + } + gltf(json, bin, files, b); + } + + private static List<?> list(Map<?, ?> m, String k) { + return m.get(k) instanceof List<?> l ? l : List.of(); + } + + private static int num(Object o, int def) { + return o instanceof Number n ? n.intValue() : def; + } + + private static void gltf(Map<?, ?> j, byte[] glbBin, Function<String, byte[]> files, Builder b) throws IOException { + List<byte[]> buffers = new ArrayList<>(); + for (Object o : list(j, "buffers")) { + Object uri = ((Map<?, ?>) o).get("uri"); + if (uri == null) { + buffers.add(glbBin); + } else { + buffers.add(uri((String) uri, files)); + } + } + Gltf g = new Gltf(j, buffers, files); + Map<Integer, Integer> matIndex = new HashMap<>(); + List<?> scenes = list(j, "scenes"); + List<Object> roots = new ArrayList<>(); + if (!scenes.isEmpty()) { + roots.addAll(list((Map<?, ?>) scenes.get(num(j.get("scene"), 0)), "nodes")); + } else { + for (int i = 0; i < list(j, "nodes").size(); i++) { + roots.add((long) i); + } + } + for (Object r : roots) { + g.node(num(r, 0), identity(), b, matIndex); + } + if (roots.isEmpty()) { + for (int m = 0; m < list(j, "meshes").size(); m++) { + g.mesh(m, identity(), b, matIndex); + } + } + } + + private static byte[] uri(String uri, Function<String, byte[]> files) throws IOException { + if (uri.startsWith("data:")) { + int c = uri.indexOf(','); + return Base64.getDecoder().decode(uri.substring(c + 1)); + } + byte[] d = files.apply(URLDecoder.decode(uri, StandardCharsets.UTF_8)); + if (d == null) { + throw new IOException("referenced file " + uri + " missing"); + } + return d; + } + + private static double[] identity() { + return new double[] {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; + } + + /** Column-major 4×4 product a·b. */ + private static double[] mul(double[] a, double[] b) { + double[] r = new double[16]; + for (int c = 0; c < 4; c++) { + for (int row = 0; row < 4; row++) { + double s = 0; + for (int k = 0; k < 4; k++) { + s += a[k * 4 + row] * b[c * 4 + k]; + } + r[c * 4 + row] = s; + } + } + return r; + } + + private static final class Gltf { + final Map<?, ?> j; + final List<byte[]> buffers; + final Function<String, byte[]> files; + + Gltf(Map<?, ?> j, List<byte[]> buffers, Function<String, byte[]> files) { + this.j = j; + this.buffers = buffers; + this.files = files; + } + + void node(int i, double[] parent, Builder b, Map<Integer, Integer> mats) throws IOException { + Map<?, ?> n = (Map<?, ?>) list(j, "nodes").get(i); + double[] m = local(n); + double[] w = mul(parent, m); + if (n.get("mesh") != null) { + mesh(num(n.get("mesh"), 0), w, b, mats); + } + for (Object c : list(n, "children")) { + node(num(c, 0), w, b, mats); + } + } + + static double[] local(Map<?, ?> n) { + if (n.get("matrix") instanceof List<?> l) { + double[] m = new double[16]; + for (int k = 0; k < 16; k++) { + m[k] = ((Number) l.get(k)).doubleValue(); + } + return m; + } + double[] t = vec(n.get("translation"), new double[] {0, 0, 0}); + double[] q = vec(n.get("rotation"), new double[] {0, 0, 0, 1}); + double[] s = vec(n.get("scale"), new double[] {1, 1, 1}); + double x = q[0], y = q[1], z = q[2], w = q[3]; + double[] r = { + 1 - 2 * (y * y + z * z), + 2 * (x * y + z * w), + 2 * (x * z - y * w), + 0, + 2 * (x * y - z * w), + 1 - 2 * (x * x + z * z), + 2 * (y * z + x * w), + 0, + 2 * (x * z + y * w), + 2 * (y * z - x * w), + 1 - 2 * (x * x + y * y), + 0, + t[0], + t[1], + t[2], + 1 + }; + for (int c = 0; c < 3; c++) { + for (int row = 0; row < 3; row++) { + r[c * 4 + row] *= s[c]; + } + } + return r; + } + + static double[] vec(Object o, double[] def) { + if (!(o instanceof List<?> l)) { + return def; + } + double[] v = new double[l.size()]; + for (int k = 0; k < v.length; k++) { + v[k] = ((Number) l.get(k)).doubleValue(); + } + return v; + } + + void mesh(int mi, double[] w, Builder b, Map<Integer, Integer> mats) throws IOException { + Map<?, ?> mesh = (Map<?, ?>) list(j, "meshes").get(mi); + boolean mirror = det3(w) < 0; + for (Object po : list(mesh, "primitives")) { + Map<?, ?> p = (Map<?, ?>) po; + if (num(p.get("mode"), 4) != 4) { + continue; + } + Map<?, ?> attr = (Map<?, ?>) p.get("attributes"); + double[][] pos = accessor(num(attr.get("POSITION"), -1)); + double[][] uv = attr.get("TEXCOORD_0") != null ? accessor(num(attr.get("TEXCOORD_0"), 0)) : null; + double[][] col = attr.get("COLOR_0") != null ? accessor(num(attr.get("COLOR_0"), 0)) : null; + int[] idx; + if (p.get("indices") != null) { + double[][] ix = accessor(num(p.get("indices"), 0)); + idx = new int[ix.length]; + for (int k = 0; k < ix.length; k++) { + idx[k] = (int) ix[k][0]; + } + } else { + idx = new int[pos.length]; + for (int k = 0; k < idx.length; k++) { + idx[k] = k; + } + } + int matI = num(p.get("material"), -1); + Integer bm = mats.get(matI); + if (bm == null) { + bm = b.material(material(matI)); + mats.put(matI, bm); + } + int[] vi = new int[pos.length]; + for (int k = 0; k < pos.length; k++) { + double[] q = pos[k]; + double x = w[0] * q[0] + w[4] * q[1] + w[8] * q[2] + w[12]; + double y = w[1] * q[0] + w[5] * q[1] + w[9] * q[2] + w[13]; + double z = w[2] * q[0] + w[6] * q[1] + w[10] * q[2] + w[14]; + Integer c = col == null ? null : rgb(col[k][0], col[k][1], col[k][2]); + vi[k] = b.vertex(x, y, z, c); + } + for (int k = 0; k + 2 < idx.length; k += 3) { + int a = idx[k]; + int c1 = mirror ? idx[k + 2] : idx[k + 1]; + int c2 = mirror ? idx[k + 1] : idx[k + 2]; + double[] u = NO_UV.clone(); + if (uv != null) { + int[] cs = {a, c1, c2}; + for (int q = 0; q < 3; q++) { + u[2 * q] = uv[cs[q]][0]; + u[2 * q + 1] = uv[cs[q]][1]; + } + } + b.tri(vi[a], vi[c1], vi[c2], u, bm); + } + } + } + + static double det3(double[] m) { + return m[0] * (m[5] * m[10] - m[9] * m[6]) + - m[4] * (m[1] * m[10] - m[9] * m[2]) + + m[8] * (m[1] * m[6] - m[5] * m[2]); + } + + MeshData.Material material(int i) throws IOException { + if (i < 0) { + return DEFAULT_MATERIAL; + } + Map<?, ?> m = (Map<?, ?>) list(j, "materials").get(i); + String name = m.get("name") instanceof String s ? s : "material" + i; + boolean dbl = Boolean.TRUE.equals(m.get("doubleSided")); + Map<?, ?> pbr = m.get("pbrMetallicRoughness") instanceof Map<?, ?> x ? x : Map.of(); + double[] f = vec(pbr.get("baseColorFactor"), new double[] {1, 1, 1, 1}); + int rgb = rgb(f[0], f[1], f[2]); + if (pbr.get("baseColorTexture") instanceof Map<?, ?> bt) { + Map<?, ?> tex = (Map<?, ?>) list(j, "textures").get(num(bt.get("index"), 0)); + Object src = tex.get("source"); + if (src != null) { + Map<?, ?> img = (Map<?, ?>) list(j, "images").get(num(src, 0)); + byte[] data; + if (img.get("uri") instanceof String u) { + data = uri(u, files); + } else { + data = view(num(img.get("bufferView"), 0)); + } + BufferedImage bi = Images.read(data); + int[] px = argb(bi); + if (f[0] != 1 || f[1] != 1 || f[2] != 1) { + for (int k = 0; k < px.length; k++) { + int c = px[k]; + px[k] = (c & 0xff000000) + | c8((c >> 16 & 0xff) / 255.0 * f[0]) << 16 + | c8((c >> 8 & 0xff) / 255.0 * f[1]) << 8 + | c8((c & 0xff) / 255.0 * f[2]); + } + } + return new MeshData.Material(name, rgb, px, bi.getWidth(), bi.getHeight(), dbl); + } + } + return new MeshData.Material(name, rgb, null, 0, 0, dbl); + } + + byte[] view(int i) { + Map<?, ?> v = (Map<?, ?>) list(j, "bufferViews").get(i); + byte[] buf = buffers.get(num(v.get("buffer"), 0)); + int off = num(v.get("byteOffset"), 0); + return Arrays.copyOfRange(buf, off, off + num(v.get("byteLength"), 0)); + } + + double[][] accessor(int i) throws IOException { + Map<?, ?> a = (Map<?, ?>) list(j, "accessors").get(i); + int count = num(a.get("count"), 0); + int comps = + switch ((String) a.get("type")) { + case "SCALAR" -> 1; + case "VEC2" -> 2; + case "VEC3" -> 3; + case "VEC4" -> 4; + default -> throw new IOException("unsupported accessor type " + a.get("type")); + }; + int ct = num(a.get("componentType"), 5126); + int size = + switch (ct) { + case 5120, 5121 -> 1; + case 5122, 5123 -> 2; + case 5125, 5126 -> 4; + default -> throw new IOException("unsupported component type " + ct); + }; + boolean norm = Boolean.TRUE.equals(a.get("normalized")); + double[][] out = new double[count][comps]; + if (a.get("bufferView") == null) { + return out; + } + Map<?, ?> v = (Map<?, ?>) list(j, "bufferViews").get(num(a.get("bufferView"), 0)); + byte[] buf = buffers.get(num(v.get("buffer"), 0)); + if (buf == null) { + throw new IOException("buffer missing"); + } + int base = num(v.get("byteOffset"), 0) + num(a.get("byteOffset"), 0); + int stride = num(v.get("byteStride"), comps * size); + ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN); + for (int k = 0; k < count; k++) { + for (int c = 0; c < comps; c++) { + int o = base + k * stride + c * size; + double x = + switch (ct) { + case 5120 -> norm ? Math.max(bb.get(o) / 127.0, -1) : bb.get(o); + case 5121 -> norm ? (bb.get(o) & 0xff) / 255.0 : bb.get(o) & 0xff; + case 5122 -> norm ? Math.max(bb.getShort(o) / 32767.0, -1) : bb.getShort(o); + case 5123 -> norm ? (bb.getShort(o) & 0xffff) / 65535.0 : bb.getShort(o) & 0xffff; + case 5125 -> Integer.toUnsignedLong(bb.getInt(o)); + default -> bb.getFloat(o); + }; + out[k][c] = x; + } + } + return out; + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/NodeCodec.java b/src/main/java/com/eliasmueller/llamamod/editor/io/NodeCodec.java new file mode 100644 index 0000000..5db539c --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/NodeCodec.java @@ -0,0 +1,195 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Prop; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.cmd.Values; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Property values ↔ JSON values (platform.Json types). Arrays longer than {@link #INLINE} are + * stored as base64 ({"i32": …}, {"f64": …}, {"bytes": …}); a changed array of the same length is + * stored as runs of changed elements ({"runs": [[start, values], …]}) so unchanged (original) + * data is never written. + */ +final class NodeCodec { + static final int INLINE = 64; + + private NodeCodec() {} + + static Object encode(Prop p, Object v) { + if (v == null) { + return null; + } + return switch (p.type()) { + case INT, FIXED, ANGLE, FLAGS, COLOR -> ((Integer) v).longValue(); + case DOUBLE -> v; + case BOOL, STRING, CHOICE -> v; + case REF -> ((Node) v).id(); + case INT_ARRAY -> ints((int[]) v); + case DOUBLE_ARRAY -> doubles((double[]) v); + case BYTES -> Map.of("bytes", Base64.getEncoder().encodeToString((byte[]) v)); + }; + } + + private static Object ints(int[] a) { + if (a.length <= INLINE) { + List<Object> l = new ArrayList<>(a.length); + for (int x : a) { + l.add((long) x); + } + return l; + } + ByteBuffer b = ByteBuffer.allocate(4 * a.length).order(ByteOrder.LITTLE_ENDIAN); + for (int x : a) { + b.putInt(x); + } + return Map.of("i32", Base64.getEncoder().encodeToString(b.array())); + } + + private static Object doubles(double[] a) { + if (a.length <= INLINE) { + List<Object> l = new ArrayList<>(a.length); + for (double x : a) { + l.add(x); + } + return l; + } + ByteBuffer b = ByteBuffer.allocate(8 * a.length).order(ByteOrder.LITTLE_ENDIAN); + for (double x : a) { + b.putDouble(x); + } + return Map.of("f64", Base64.getEncoder().encodeToString(b.array())); + } + + /** Decodes a value; refs are resolved in scene (missing → IllegalArgumentException). */ + static Object decode(Prop p, Object j, Scene s, Object current) { + if (j == null) { + return null; + } + if (j instanceof Map<?, ?> m && m.containsKey("runs")) { + return applyRuns(p, current, (List<?>) m.get("runs")); + } + return switch (p.type()) { + case INT, FIXED, ANGLE, FLAGS, COLOR -> (int) ((Number) j).longValue(); + case DOUBLE -> ((Number) j).doubleValue(); + case BOOL -> (Boolean) j; + case STRING, CHOICE -> (String) j; + case REF -> { + Node n = s.node((String) j); + if (n == null) { + throw new IllegalArgumentException("reference to unknown node " + j); + } + yield n; + } + case INT_ARRAY -> decodeInts(j); + case DOUBLE_ARRAY -> decodeDoubles(j); + case BYTES -> Base64.getDecoder().decode((String) ((Map<?, ?>) j).get("bytes")); + }; + } + + private static int[] decodeInts(Object j) { + if (j instanceof Map<?, ?> m) { + ByteBuffer b = ByteBuffer.wrap(Base64.getDecoder().decode((String) m.get("i32"))) + .order(ByteOrder.LITTLE_ENDIAN); + int[] a = new int[b.remaining() / 4]; + for (int i = 0; i < a.length; i++) { + a[i] = b.getInt(); + } + return a; + } + List<?> l = (List<?>) j; + int[] a = new int[l.size()]; + for (int i = 0; i < a.length; i++) { + a[i] = (int) ((Number) l.get(i)).longValue(); + } + return a; + } + + private static double[] decodeDoubles(Object j) { + if (j instanceof Map<?, ?> m) { + ByteBuffer b = ByteBuffer.wrap(Base64.getDecoder().decode((String) m.get("f64"))) + .order(ByteOrder.LITTLE_ENDIAN); + double[] a = new double[b.remaining() / 8]; + for (int i = 0; i < a.length; i++) { + a[i] = b.getDouble(); + } + return a; + } + List<?> l = (List<?>) j; + double[] a = new double[l.size()]; + for (int i = 0; i < a.length; i++) { + a[i] = ((Number) l.get(i)).doubleValue(); + } + return a; + } + + /** Value for "changed from base to cur": runs for same-length arrays, else the full value. */ + static Object encodeChange(Prop p, Object base, Object cur) { + if (p.type().isArray() && base != null && cur != null && length(base) == length(cur) && length(cur) > INLINE) { + List<Object> runs = new ArrayList<>(); + int n = length(cur); + int i = 0; + while (i < n) { + if (same(base, cur, i)) { + i++; + continue; + } + int j = i; + while (j < n && !same(base, cur, j)) { + j++; + } + runs.add(List.of((long) i, encode(p, slice(cur, i, j)))); + i = j; + } + Map<String, Object> m = new LinkedHashMap<>(); + m.put("runs", runs); + return m; + } + return encode(p, cur); + } + + private static int length(Object a) { + return switch (a) { + case int[] x -> x.length; + case double[] x -> x.length; + case byte[] x -> x.length; + default -> -1; + }; + } + + private static boolean same(Object a, Object b, int i) { + return switch (a) { + case int[] x -> x[i] == ((int[]) b)[i]; + case double[] x -> Double.doubleToRawLongBits(x[i]) == Double.doubleToRawLongBits(((double[]) b)[i]); + case byte[] x -> x[i] == ((byte[]) b)[i]; + default -> false; + }; + } + + private static Object slice(Object a, int from, int to) { + return switch (a) { + case int[] x -> java.util.Arrays.copyOfRange(x, from, to); + case double[] x -> java.util.Arrays.copyOfRange(x, from, to); + case byte[] x -> java.util.Arrays.copyOfRange(x, from, to); + default -> throw new IllegalArgumentException(); + }; + } + + private static Object applyRuns(Prop p, Object current, List<?> runs) { + Object out = Values.copy(current); + for (Object r : runs) { + List<?> run = (List<?>) r; + int start = (int) ((Number) run.get(0)).longValue(); + Object vals = decode(p, run.get(1), null, null); + System.arraycopy(vals, 0, out, start, length(vals)); + } + return out; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/Project.java b/src/main/java/com/eliasmueller/llamamod/editor/io/Project.java new file mode 100644 index 0000000..6528440 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/Project.java @@ -0,0 +1,67 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.Scene; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A loaded {@code .llamalevel} project: the scene plus what the file carries besides it (thumbnail, + * fields of newer format versions that are written back unchanged) and load warnings. + */ +public final class Project { + private final Scene scene; + final Scene reference; + private byte[] thumbnail; + final Map<String, Object> unknownTop = new LinkedHashMap<>(); + final SceneDiff.Unknown unknownNodes = new SceneDiff.Unknown(); + private final List<String> warnings = new ArrayList<>(); + + /** reference: the unedited scene the project is stored against (same kind and base). */ + public Project(Scene reference, Scene scene) { + this.reference = reference; + this.scene = scene; + } + + /** + * New project for an original level (kind "edit") or an empty level (kind "new", without the base + * level's teleport zones, point lights and region masks: {@link Templates#clearBaseLeftovers}). + */ + public static Project create(Bases cd, String kind, String base) throws java.io.IOException { + Scene scene = Templates.reference(cd, kind, base); + if (LevelSettings.NEW.equals(kind)) { + Templates.clearBaseLeftovers(scene); + } + return new Project(Templates.reference(cd, kind, base), scene); + } + + public Scene scene() { + return scene; + } + + /** The unedited scene the project is diffed against (extensions that change both use it). */ + public Scene reference() { + return reference; + } + + /** Top-level level.json entries not written by the core (kept on save; extensions store data here). */ + public Map<String, Object> extra() { + return unknownTop; + } + + /** PNG or {@code null}. */ + public byte[] thumbnail() { + return thumbnail; + } + + public void setThumbnail(byte[] png) { + thumbnail = png; + } + + /** Problems found while loading (base level changed, unknown references, …). */ + public List<String> warnings() { + return warnings; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectIO.java b/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectIO.java new file mode 100644 index 0000000..e605a33 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectIO.java @@ -0,0 +1,241 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.platform.Json; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +/** + * {@code .llamalevel} files (zip: level.json + assets/ + thumbnail.png), see notes/editor-format.md. + * Only differences to the reference scene are stored, so a project contains no original game data. + */ +public final class ProjectIO { + public static final int FORMAT_VERSION = 1; + public static final String EXTENSION = ".llamalevel"; + static final String LEVEL_JSON = "level.json"; + static final String THUMBNAIL = "thumbnail.png"; + private static final long ZIP_TIME = 946684800000L; + + private static final List<String> TOP_KEYS = List.of( + "formatVersion", + "app", + "kind", + "base", + "baseSha256", + "name", + "targetChapter", + "nextId", + "settings", + "regions", + "nodes"); + + private ProjectIO() {} + + /** + * Extension that adjusts reference and current scene identically before the stored changes are + * applied (e.g. object types imported from other levels, keyed by a top-level level.json entry). + */ + public interface LoadHook { + void beforeChanges(Map<String, Object> json, Bases cd, Scene reference, Scene scene, List<String> warnings) + throws IOException; + } + + private static final List<LoadHook> HOOKS = new CopyOnWriteArrayList<>(); + + static { + try { + Class.forName("com.eliasmueller.llamamod.editor.tools.catalog.CrossImport"); + } catch (ClassNotFoundException | LinkageError e) { + // optional extension + } + } + + public static void addLoadHook(LoadHook h) { + HOOKS.add(h); + } + + public static Project load(Path file, Bases cd) throws IOException { + return read(Files.readAllBytes(file), cd); + } + + /** Reads a project; a damaged file (wrong value types, broken references) gives an IOException. */ + public static Project read(byte[] zip, Bases cd) throws IOException { + try { + return readChecked(zip, cd); + } catch (RuntimeException e) { + throw damaged(e); + } + } + + private static IOException damaged(RuntimeException e) { + return new IOException("the project file is damaged: " + e, e); + } + + private static int intOr(Map<String, Object> m, String key, long def) { + return ((Number) m.getOrDefault(key, def)).intValue(); + } + + private static Project readChecked(byte[] zip, Bases cd) throws IOException { + Map<String, byte[]> files = unzip(zip); + Map<String, Object> json = json(files); + int ver = intOr(json, "formatVersion", 1); + String kind = (String) json.getOrDefault("kind", LevelSettings.EDIT); + String base = (String) json.getOrDefault("base", Templates.DEFAULT_BASE); + Scene ref = Templates.reference(cd, kind, base); + Scene cur = Templates.reference(cd, kind, base); + Project p = new Project(ref, cur); + if (ver > FORMAT_VERSION) { + p.warnings().add("the project was written by a newer editor (format " + ver + "); unknown parts are kept"); + } + String sha = (String) json.get("baseSha256"); + if (sha != null && !sha.equals(sha256(cd.wad(base)))) { + p.warnings().add("the base level " + base + " differs from the one the project was made with"); + } + for (Map.Entry<String, byte[]> e : files.entrySet()) { + if (e.getKey().startsWith("assets/")) { + cur.assets().put(e.getKey(), e.getValue()); + } + } + for (LoadHook h : HOOKS) { + h.beforeChanges(json, cd, ref, cur, p.warnings()); + } + cur.setNextIdCounter(intOr(json, "nextId", 1)); + SceneDiff.apply(cur, json, cur.assets(), p.unknownNodes, p.warnings()); + cur.assets().keySet().removeIf(k -> k.startsWith("assets/pages/")); + for (Map.Entry<String, Object> e : json.entrySet()) { + if (!TOP_KEYS.contains(e.getKey())) { + p.unknownTop.put(e.getKey(), e.getValue()); + } + } + p.setThumbnail(files.get(THUMBNAIL)); + return p; + } + + /** Header only (no base level needed). */ + public static ProjectInfo info(byte[] zip) throws IOException { + try { + return infoChecked(zip); + } catch (RuntimeException e) { + throw damaged(e); + } + } + + @SuppressWarnings("unchecked") + private static ProjectInfo infoChecked(byte[] zip) throws IOException { + Map<String, Object> json = json(unzip(zip)); + Map<String, Object> st = (Map<String, Object>) json.getOrDefault("settings", Map.of()); + return new ProjectInfo( + intOr(json, "formatVersion", 1), + (String) json.getOrDefault("kind", LevelSettings.EDIT), + (String) json.getOrDefault("base", Templates.DEFAULT_BASE), + (String) json.getOrDefault("name", ""), + (String) st.getOrDefault("author", ""), + (String) st.getOrDefault("description", ""), + intOr(json, "targetChapter", 0)); + } + + public static void save(Project p, Path file, Bases cd) throws IOException { + byte[] d = write(p, cd); + Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.write(tmp, d); + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING); + } + + public static byte[] write(Project p, Bases cd) throws IOException { + Scene s = p.scene(); + LevelSettings st = s.settings(); + Map<String, byte[]> assets = new TreeMap<>(s.assets()); + Map<String, Object> json = new LinkedHashMap<>(); + json.put("formatVersion", (long) FORMAT_VERSION); + json.put("app", "LlamaMod level editor"); + json.put("kind", st.projectKind()); + json.put("base", st.base()); + json.put("baseSha256", sha256(cd.wad(st.base()))); + json.put("name", st.levelName()); + json.put("targetChapter", (long) st.targetChapter()); + json.put("nextId", (long) s.nextIdCounter()); + json.putAll(SceneDiff.diff(p.reference, s, assets, p.unknownNodes)); + p.unknownTop.forEach(json::putIfAbsent); + Map<String, byte[]> files = new TreeMap<>(assets); + files.put(LEVEL_JSON, Json.write(json).getBytes(StandardCharsets.UTF_8)); + if (p.thumbnail() != null) { + files.put(THUMBNAIL, p.thumbnail()); + } + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + try (ZipOutputStream z = new ZipOutputStream(bo)) { + for (String name : order(files)) { + ZipEntry e = new ZipEntry(name); + e.setTime(ZIP_TIME); + z.putNextEntry(e); + z.write(files.get(name)); + z.closeEntry(); + } + } + return bo.toByteArray(); + } + + /** level.json first, then the thumbnail, then assets (sorted). */ + private static List<String> order(Map<String, byte[]> files) { + return files.keySet().stream() + .sorted(Comparator.comparingInt((String n) -> n.equals(LEVEL_JSON) ? 0 : n.equals(THUMBNAIL) ? 1 : 2) + .thenComparing(Comparator.naturalOrder())) + .toList(); + } + + @SuppressWarnings("unchecked") + private static Map<String, Object> json(Map<String, byte[]> files) throws IOException { + byte[] j = files.get(LEVEL_JSON); + if (j == null) { + throw new IOException("not a level project (level.json missing)"); + } + try { + return (Map<String, Object>) Json.parse(new String(j, StandardCharsets.UTF_8)); + } catch (RuntimeException e) { + throw new IOException("level.json is damaged: " + e.getMessage(), e); + } + } + + static Map<String, byte[]> unzip(byte[] zip) throws IOException { + Map<String, byte[]> out = new LinkedHashMap<>(); + try (ZipInputStream z = new ZipInputStream(new ByteArrayInputStream(zip))) { + ZipEntry e; + while ((e = z.getNextEntry()) != null) { + String n = e.getName(); + if (e.isDirectory() || n.contains("..") || n.startsWith("/")) { + continue; + } + out.put(n, z.readAllBytes()); + } + } + if (out.isEmpty()) { + throw new IOException("not a level project (empty or not a zip file)"); + } + return out; + } + + public static String sha256(byte[] d) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(d)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectInfo.java b/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectInfo.java new file mode 100644 index 0000000..f1a0b5e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectInfo.java @@ -0,0 +1,15 @@ +package com.eliasmueller.llamamod.editor.io; + +/** Header of a project file, read without the game data (mod lists). */ +public record ProjectInfo( + int formatVersion, + String kind, + String base, + String name, + String author, + String description, + int targetChapter) { + public boolean isNewLevel() { + return "new".equals(kind); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/SceneDiff.java b/src/main/java/com/eliasmueller/llamamod/editor/io/SceneDiff.java new file mode 100644 index 0000000..09cb41a --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/SceneDiff.java @@ -0,0 +1,258 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.NodeList; +import com.eliasmueller.llamamod.editor.model.Prop; +import com.eliasmueller.llamamod.editor.model.PropType; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.cmd.Values; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Differences between a reference scene (original level or empty template) and the edited scene, + * as JSON-ready maps (the "settings", "regions" and "nodes" parts of level.json, see + * notes/editor-format.md). + */ +final class SceneDiff { + private SceneDiff() {} + + /** Unknown node fields read from a newer file, kept for writing back: id → field → value. */ + static final class Unknown { + final Map<String, Map<String, Object>> fields = new HashMap<>(); + } + + /** Diff; added texture pages put their pixels as PNG into assets. */ + static Map<String, Object> diff(Scene ref, Scene cur, Map<String, byte[]> assets, Unknown unknown) { + Map<String, Object> out = new LinkedHashMap<>(); + Map<String, Object> st = changed(ref.settings(), cur.settings(), unknown); + if (!st.isEmpty()) { + out.put("settings", st); + } + Map<String, Object> rg = changed(ref.regions(), cur.regions(), unknown); + if (!rg.isEmpty()) { + out.put("regions", rg); + } + Map<String, Object> nodes = new LinkedHashMap<>(); + for (NodeKind k : Scene.LIST_KINDS) { + NodeList<Node> rl = ref.listOf(k); + NodeList<Node> cl = cur.listOf(k); + Map<String, Object> d = new LinkedHashMap<>(); + List<Object> removed = new ArrayList<>(); + Map<String, Object> changedNodes = new LinkedHashMap<>(); + List<String> expected = new ArrayList<>(); + for (Node r : rl) { + Node c = cur.node(r.id()); + if (c == null || c.kind() != k) { + removed.add(r.id()); + continue; + } + expected.add(r.id()); + Map<String, Object> ch = changed(r, c, unknown); + if (!ch.isEmpty()) { + changedNodes.put(r.id(), ch); + } + } + List<Object> added = new ArrayList<>(); + for (Node c : cl) { + if (ref.node(c.id()) == null) { + added.add(full(c, assets, unknown)); + expected.add(c.id()); + } + } + List<String> actual = cl.stream().map(Node::id).toList(); + if (!removed.isEmpty()) { + d.put("removed", removed); + } + if (!changedNodes.isEmpty()) { + d.put("changed", changedNodes); + } + if (!added.isEmpty()) { + d.put("added", added); + } + if (!actual.equals(expected)) { + d.put("order", new ArrayList<Object>(actual)); + } + if (!d.isEmpty()) { + nodes.put(k.prefix(), d); + } + } + if (!nodes.isEmpty()) { + out.put("nodes", nodes); + } + return out; + } + + private static boolean editable(Prop p) { + return p.persistent() && p.settable(); + } + + private static Map<String, Object> changed(Node r, Node c, Unknown unknown) { + Map<String, Object> m = new LinkedHashMap<>(); + for (Prop p : c.props()) { + if (!editable(p)) { + continue; + } + Object a = p.get(r); + Object b = p.get(c); + boolean same = p.type() == PropType.REF ? idOf(a).equals(idOf(b)) : Values.same(a, b); + if (!same) { + m.put(p.name(), NodeCodec.encodeChange(p, a, b)); + } + } + return withUnknown(m, c, unknown); + } + + private static String idOf(Object n) { + return n == null ? "" : ((Node) n).id(); + } + + private static Map<String, Object> full(Node c, Map<String, byte[]> assets, Unknown unknown) { + Map<String, Object> m = new LinkedHashMap<>(); + m.put("id", c.id()); + if (c instanceof Brush b) { + m.put("brushKind", b.brushKind().name()); + } + for (Prop p : c.props()) { + if (!editable(p) || p.name().equals("brushKind")) { + continue; + } + Object v = p.get(c); + if (c instanceof TexturePage pg && p.name().equals("pixels") && pg.flags() != 0) { + String path = "assets/pages/" + c.id().replace(':', '_') + ".png"; + assets.put(path, Images.pagePng(pg)); + m.put(p.name(), Map.of("png", path)); + continue; + } + m.put(p.name(), NodeCodec.encode(p, v)); + } + return withUnknown(m, c, unknown); + } + + /** Adds the unknown fields kept for node c (written back unchanged). */ + private static Map<String, Object> withUnknown(Map<String, Object> m, Node c, Unknown unknown) { + Map<String, Object> u = unknown == null ? null : unknown.fields.get(c.id()); + if (u != null) { + u.forEach(m::putIfAbsent); + } + return m; + } + + /** Applies a diff to the reference scene (which becomes the edited scene). */ + @SuppressWarnings("unchecked") + static void apply( + Scene s, Map<String, Object> json, Map<String, byte[]> assets, Unknown unknown, List<String> warn) { + Map<String, Object> nodes = (Map<String, Object>) json.getOrDefault("nodes", Map.of()); + // 1. removals, 2. create added nodes (all kinds, so references resolve), 3. set values + List<Map.Entry<Node, Map<String, Object>>> fill = new ArrayList<>(); + for (NodeKind k : Scene.LIST_KINDS) { + Map<String, Object> d = (Map<String, Object>) nodes.get(k.prefix()); + if (d == null) { + continue; + } + for (Object id : (List<Object>) d.getOrDefault("removed", List.of())) { + Node n = s.node((String) id); + if (n == null) { + warn.add("removed " + id + " does not exist in the base level"); + } else { + s.remove(n); + } + } + for (Object o : (List<Object>) d.getOrDefault("added", List.of())) { + Map<String, Object> m = (Map<String, Object>) o; + String id = (String) m.get("id"); + if (s.node(id) != null) { + warn.add("added node " + id + " already exists – skipped"); + continue; + } + BrushKind bk = k == NodeKind.BRUSH ? BrushKind.valueOf((String) m.get("brushKind")) : null; + Node n = Scene.createWithId(k, id, bk); + s.add(n, -1); + fill.add(Map.entry(n, m)); + } + Map<String, Object> ch = (Map<String, Object>) d.getOrDefault("changed", Map.of()); + for (Map.Entry<String, Object> e : ch.entrySet()) { + Node n = s.node(e.getKey()); + if (n == null) { + warn.add("changed " + e.getKey() + " does not exist in the base level"); + continue; + } + fill.add(Map.entry(n, (Map<String, Object>) e.getValue())); + } + } + if (json.get("settings") instanceof Map<?, ?> m) { + fill.add(Map.entry(s.settings(), (Map<String, Object>) m)); + } + if (json.get("regions") instanceof Map<?, ?> m) { + fill.add(Map.entry(s.regions(), (Map<String, Object>) m)); + } + for (Map.Entry<Node, Map<String, Object>> e : fill) { + Node n = e.getKey(); + Set<String> known = new HashSet<>(List.of("id", "brushKind")); + for (Prop p : n.props()) { + known.add(p.name()); + if (!e.getValue().containsKey(p.name()) || !editable(p)) { + continue; + } + Object j = e.getValue().get(p.name()); + Object v; + if (n instanceof TexturePage pg + && p.name().equals("pixels") + && j instanceof Map<?, ?> pm + && pm.containsKey("png")) { + byte[] png = assets.get((String) pm.get("png")); + if (png == null) { + warn.add(n.id() + ": page image " + pm.get("png") + " missing"); + continue; + } + v = Images.pagePixels(png, pg.width(), pg.height()); + } else { + v = NodeCodec.decode(p, j, s, p.get(n)); + } + try { + p.setRaw(n, v); + } catch (IllegalArgumentException ex) { + warn.add(n.id() + "." + p.name() + ": " + ex.getMessage()); + } + } + for (Map.Entry<String, Object> f : e.getValue().entrySet()) { + if (!known.contains(f.getKey())) { + unknown.fields + .computeIfAbsent(n.id(), x -> new LinkedHashMap<>()) + .put(f.getKey(), f.getValue()); + } + } + } + for (NodeKind k : Scene.LIST_KINDS) { + Map<String, Object> d = (Map<String, Object>) nodes.get(k.prefix()); + if (d == null || !d.containsKey("order")) { + continue; + } + List<Node> order = new ArrayList<>(); + for (Object id : (List<Object>) d.get("order")) { + Node n = s.node((String) id); + if (n != null && n.kind() == k) { + order.add(n); + } + } + for (Node n : s.listOf(k)) { + if (!order.contains(n)) { + order.add(n); + } + } + s.reorder(k, order); + } + for (var st : s.strats()) { + st.setTypeName(s.catalog().nameOf(st.code())); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/Templates.java b/src/main/java/com/eliasmueller/llamamod/editor/io/Templates.java new file mode 100644 index 0000000..e635b0d --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/Templates.java @@ -0,0 +1,92 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.Scene; +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +/** Reference scenes projects are stored against: an original level, or an empty level on a base. */ +public final class Templates { + /** Default base for new levels: the village (object code, textures and sound of world 1). */ + public static final String DEFAULT_BASE = "t1l1m001"; + + /** Strat pool reserve for new levels (levelbuild: objects + 80). */ + public static final int NEW_LEVEL_RESERVE = 80; + + private Templates() {} + + /** The original level as a scene (kind "edit"). */ + public static Scene original(Bases cd, String base) throws IOException { + String b = base.toLowerCase(Locale.ROOT); + return WadImporter.importWad(cd.wad(b), b, cd::fileOrNull); + } + + /** + * Empty level (kind "new") on a base level: tiles, tile models, objects and waypoints removed, + * visibility regions off (one region that sees everything). Textures, zones (fog, music), + * lights, object code and sound of the base stay. + */ + public static Scene newLevel(Bases cd, String base) throws IOException { + Scene s = original(cd, base); + makeEmpty(s); + return s; + } + + static void makeEmpty(Scene s) { + s.clearQuiet(NodeKind.TILE); + s.clearQuiet(NodeKind.TILE_MODEL); + s.clearQuiet(NodeKind.STRAT); + s.clearQuiet(NodeKind.PATH_NODE); + LevelSettings st = s.settings(); + st.setRaw("kind", LevelSettings.NEW); + st.setRaw("name", "New level"); + st.setRaw("targetChapter", 1); + st.setRaw("poolReserve", NEW_LEVEL_RESERVE); + int[] table = s.regions().table().clone(); + table[0] = 0xffffffff; + s.regions().setRaw("table", table); + s.regions().setRaw("grid", new int[st.width() * st.height()]); + } + + /** + * Removes what a new level would inherit from the base level at the base's own positions: the + * teleport zones (the start zone with its fog, ambient colour and music stays), the point lights + * (the directional lights stay, they light the objects) and region masks (every region sees + * everything). Textures and object code stay – STPC object models use the textures by index. + * Applied to the scene of a new project, not to the reference, so saved projects keep their content. + */ + public static void clearBaseLeftovers(Scene s) { + EnvZone start = s.zones().stream() + .filter(EnvZone::isDefault) + .findFirst() + .orElse(s.zones().isEmpty() ? null : s.zones().get(0)); + for (EnvZone z : List.copyOf(s.zones())) { + if (z != start) { + s.remove(z); + } + } + for (Light l : List.copyOf(s.lights())) { + if (l.type() != Light.DIRECTIONAL) { + s.remove(l); + } + } + for (PathNode n : List.copyOf(s.pathNodes())) { + s.remove(n); + } + int[] table = s.regions().table().clone(); + for (int i = 0; i < table.length; i += 2) { + table[i] = 0xffffffff; + } + s.regions().setRaw("table", table); + } + + /** Reference scene of a project kind. */ + public static Scene reference(Bases cd, String kind, String base) throws IOException { + return LevelSettings.NEW.equals(kind) ? newLevel(cd, base) : original(cd, base); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/TextureAtlas.java b/src/main/java/com/eliasmueller/llamamod/editor/io/TextureAtlas.java new file mode 100644 index 0000000..fee6302 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/TextureAtlas.java @@ -0,0 +1,109 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.TexturePage; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * Packs square images (power-of-two sizes ≤ 256) into new 256×256 texture pages (largest first + * is the caller's job; slots are filled row by row per size). + */ +final class TextureAtlas { + static final int PAGE = 256; + /** Opaque RLE page / page with transparent pixels (formats of the original pages). */ + static final int OPAQUE = 0x81; + + static final int ALPHA = 0x84; + + record Slot(int page, int x, int y, int size) {} + + private final List<TexturePage> pages = new ArrayList<>(); + private final List<boolean[]> used = new ArrayList<>(); + private final int firstIndex; + private final Supplier<TexturePage> factory; + + /** firstIndex: index the first new page will have in the TEXT page list. */ + TextureAtlas(int firstIndex, Supplier<TexturePage> factory) { + this.firstIndex = firstIndex; + this.factory = factory; + } + + List<TexturePage> pages() { + return pages; + } + + static int fitSize(int requested) { + int s = 16; + while (s * 2 <= Math.min(PAGE, Math.max(16, requested))) { + s *= 2; + } + return s; + } + + /** Places an image scaled to size×size; returns its slot (page = index in the full page list). */ + Slot add(BufferedImage img, int size) { + int s = fitSize(size); + int cells = PAGE / s; + for (int p = 0; p <= pages.size(); p++) { + if (p == pages.size()) { + TexturePage pg = factory.get(); + pg.setRaw("flags", OPAQUE); + pg.setRaw("pixels", new int[PAGE * PAGE]); + pages.add(pg); + used.add(new boolean[(PAGE / 16) * (PAGE / 16)]); + } + boolean[] u = used.get(p); + int g = s / 16; + for (int cy = 0; cy < cells; cy++) { + for (int cx = 0; cx < cells; cx++) { + if (free(u, cx * g, cy * g, g)) { + mark(u, cx * g, cy * g, g); + draw(pages.get(p), img, cx * s, cy * s, s); + return new Slot(firstIndex + p, cx * s, cy * s, s); + } + } + } + } + throw new IllegalStateException("unreachable"); + } + + private static boolean free(boolean[] u, int x, int y, int g) { + int w = PAGE / 16; + for (int j = y; j < y + g; j++) { + for (int i = x; i < x + g; i++) { + if (u[j * w + i]) { + return false; + } + } + } + return true; + } + + private static void mark(boolean[] u, int x, int y, int g) { + int w = PAGE / 16; + for (int j = y; j < y + g; j++) { + for (int i = x; i < x + g; i++) { + u[j * w + i] = true; + } + } + } + + private static void draw(TexturePage pg, BufferedImage img, int ox, int oy, int s) { + int[] px = Images.toAbgr1555(img, s, s); + int[] dst = pg.pixels(); + boolean alpha = false; + for (int y = 0; y < s; y++) { + for (int x = 0; x < s; x++) { + int v = px[y * s + x]; + dst[(oy + y) * PAGE + ox + x] = v; + alpha |= v == 0 || (v & 0x8000) == 0; + } + } + if (alpha) { + pg.setRaw("flags", ALPHA); + } + pg.setEncodedCache(null); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/TextureImport.java b/src/main/java/com/eliasmueller/llamamod/editor/io/TextureImport.java new file mode 100644 index 0000000..13d028e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/TextureImport.java @@ -0,0 +1,78 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Images (PNG, JPEG) → new texture pages and texture table entries. Each image is scaled to a + * square power-of-two size (16..256) and packed into new 256×256 pages; the entry covers the whole + * image. Transparent pixels (alpha < 64) become 0x0000 (the engine's transparent colour), half + * transparent ones STP = 0. Existing pages are never touched. + */ +public final class TextureImport { + /** Normal opaque texture entry flags (most entries of the original levels). */ + public static final int ENTRY_FLAGS = 0x104; + + private TextureImport() {} + + public record NamedImage(String name, BufferedImage image) {} + + /** command adds the pages and entries; entries[i] = texture index of image i after executing it. */ + public record Result(Command command, int[] entries, List<TexturePage> pages) {} + + public static Result importImages(Scene s, List<NamedImage> images, int size) { + List<TexturePage> pages = new ArrayList<>(); + TextureAtlas atlas = new TextureAtlas(s.pages().size(), () -> { + TexturePage p = (TexturePage) s.create(NodeKind.TEXTURE_PAGE); + pages.add(p); + return p; + }); + List<Command> cmds = new ArrayList<>(); + List<TextureEntry> entries = new ArrayList<>(); + int[] idx = new int[images.size()]; + for (int i = 0; i < images.size(); i++) { + NamedImage ni = images.get(i); + TextureAtlas.Slot slot = atlas.add(ni.image(), size); + TextureEntry e = (TextureEntry) s.create(NodeKind.TEXTURE); + e.setRaw("name", ni.name()); + e.setRaw("flags", ENTRY_FLAGS); + e.setRaw("page", slot.page()); + e.setRaw("page2", -1); + e.setRaw("u0", slot.x()); + e.setRaw("u1", slot.x() + slot.size() - 1); + e.setRaw("v0", slot.y()); + e.setRaw("v1", slot.y() + slot.size() - 1); + entries.add(e); + idx[i] = s.textures().size() + i; + } + for (TexturePage p : pages) { + p.setRaw("name", "imported"); + cmds.add(new AddNode(p)); + } + for (TextureEntry e : entries) { + cmds.add(new AddNode(e)); + } + return new Result(new Batch("Import textures", cmds), idx, pages); + } + + public static Result importFiles(Scene s, List<Path> files, int size) throws IOException { + List<NamedImage> l = new ArrayList<>(); + for (Path f : files) { + String n = f.getFileName().toString(); + l.add(new NamedImage( + n.contains(".") ? n.substring(0, n.lastIndexOf('.')) : n, Images.read(Files.readAllBytes(f)))); + } + return importImages(s, l, size); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/Validator.java b/src/main/java/com/eliasmueller/llamamod/editor/io/Validator.java new file mode 100644 index 0000000..807941d --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/Validator.java @@ -0,0 +1,128 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.format.MapChunk; +import java.util.ArrayList; +import java.util.List; + +/** + * Checks what the engine needs before a build. Errors make the WAD unusable (or crash the game); + * warnings are suspicious but buildable. Also usable by the UI for a problems list. + */ +public final class Validator { + /** Vertex transform buffer limit per model (0x58b0f0..0x5d15f8, 36 bytes per vertex). */ + public static final int MAX_VERTS = 8000; + + private Validator() {} + + /** Errors; warnings are added to warn. */ + public static List<String> validate(Scene s, List<String> warn) { + List<String> err = new ArrayList<>(); + LevelSettings st = s.settings(); + int w = st.width(); + int h = st.height(); + if (s.regions().grid().length != w * h) { + err.add("region grid has " + s.regions().grid().length + " cells, the grid " + w * h); + } + if (s.regions().table().length != 64) { + err.add("region table must have 64 values"); + } + int nTex = s.textures().size(); + for (TileModel m : s.tileModels()) { + if (m.vertexCount() > MAX_VERTS) { + err.add(m.id() + ": " + m.vertexCount() + " vertices (engine limit " + MAX_VERTS + ")"); + } + if (m.vertices().length % TileModel.VERTEX_STRIDE != 0 + || m.faces().length % TileModel.FACE_STRIDE != 0 + || m.planes().length != m.faceCount() * TileModel.PLANE_STRIDE) { + err.add(m.id() + ": array lengths do not match"); + continue; + } + for (int g = 0; g < 3; g++) { + if (m.collision(g).length % TileModel.COLLISION_SIZE != 0) { + err.add(m.id() + ": collision group " + g + " is not a multiple of 32 bytes"); + } + } + int nv = m.vertexCount(); + for (int f = 0; f < m.faceCount(); f++) { + for (int k = 0; k < 3; k++) { + if (m.faceVertex(f, k) >= nv) { + err.add(m.id() + " face " + f + ": vertex index out of range"); + break; + } + } + if (m.faceTexture(f) >= nTex) { + warn.add(m.id() + " face " + f + ": texture " + m.faceTexture(f) + " beyond the table (" + nTex + + ")"); + } + } + } + boolean alts = (st.wfpc() & MapChunk.WF_ALTS) != 0; + for (Tile t : s.tiles()) { + if (t.model() == null || !s.contains(t.model())) { + err.add(t.id() + ": model missing"); + continue; + } + int cx = t.cellX(); + int cz = t.cellZ(); + if (cx < 0 || cx >= w || cz < 0 || cz >= h) { + err.add(t.id() + ": outside the " + w + "×" + h + " grid (cell " + cx + "," + cz + ")"); + } + if (t.hasSwap()) { + if (!alts) { + err.add(t.id() + ": tile swaps need WFPC flag 0x10000, which this base level does not have"); + } else if (t.swapAltModel() == null || !s.contains(t.swapAltModel())) { + err.add(t.id() + ": swap without alternative model"); + } + } + } + for (Strat x : s.strats()) { + if (x.waypoint() != null && !s.contains(x.waypoint())) { + err.add(x.id() + ": waypoint is not part of the level"); + } + StratCatalog.Type t = s.catalog().type(x.type()); + if (x.type().isEmpty()) { + warn.add(x.id() + ": unknown strat code 0x" + Integer.toHexString(x.code())); + } else if (t != null && t.variants().stream().noneMatch(v -> v.nParams() == x.params().length)) { + warn.add(x.id() + " (" + x.type() + "): " + x.params().length + " parameters, the type uses " + + t.variants().getFirst().nParams()); + } + double gx = x.gx(); + double gz = x.gz(); + if (gx < 0 || gx >= w || gz < 0 || gz >= h) { + warn.add(x.id() + " (" + x.type() + ") is outside the grid"); + } + } + for (Brush b : s.brushes()) { + String bad = b.enabled() ? BrushCompiler.problem(b, w, h, 8) : null; + if (bad != null) { + err.add(b.id() + " (" + b.label() + "): " + bad); + } + } + for (PathNode n : s.pathNodes()) { + if (n.next() != null && !s.contains(n.next())) { + err.add(n.id() + ": next node is not part of the level"); + } + } + for (TexturePage p : s.pages()) { + if (p.pixels().length != p.width() * p.height()) { + err.add(p.id() + ": pixel count does not match " + p.width() + "×" + p.height()); + } + } + if (s.pages().size() > 127) { + err.add("more than 127 texture pages"); + } + if (st.isNewLevel() && (st.targetChapter() < 0 || st.targetChapter() > 49)) { + err.add("TestLevel chapter must be 1..49"); + } + return err; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/WadBuilder.java b/src/main/java/com/eliasmueller/llamamod/editor/io/WadBuilder.java new file mode 100644 index 0000000..c5bb1ba --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/WadBuilder.java @@ -0,0 +1,107 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.RawChunk; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.Wad; +import java.util.ArrayList; +import java.util.List; + +/** + * Scene → level WAD. Brushes and mesh placements are converted to tiles ({@link BrushCompiler}) + * and appended after the scene's own tiles and models. Without edits the result is byte-identical + * to the imported WAD. The output is validated first ({@link Validator}) and parsed back after. + */ +public final class WadBuilder { + private WadBuilder() {} + + /** Statistics and non-fatal notes of a build. */ + public record Result(byte[] wad, Stats stats, List<String> warnings) {} + + public record Stats( + int models, + int tiles, + int generatedModels, + int generatedTiles, + int strats, + int waypoints, + int textures, + int pages, + int wadBytes) {} + + public static byte[] build(Scene s) { + return buildWithReport(s).wad(); + } + + public static Result buildWithReport(Scene s) { + List<String> warn = new ArrayList<>(); + List<String> errors = Validator.validate(s, warn); + if (!errors.isEmpty()) { + throw new IllegalStateException("The level is not valid:\n " + String.join("\n ", errors)); + } + BrushCompiler.Output gen = BrushCompiler.compile(s, warn); + List<TileModel> models = new ArrayList<>(s.tileModels()); + models.addAll(gen.models()); + List<Tile> tiles = + new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList()); + tiles.addAll(gen.tiles()); + List<TexturePage> pages = new ArrayList<>(s.pages()); + pages.addAll(gen.pages()); + List<TextureEntry> entries = new ArrayList<>(s.textures()); + entries.addAll(gen.textures()); + + List<Wad.Chunk> out = new ArrayList<>(); + boolean nameDone = false; + for (RawChunk c : s.chunks()) { + String tag = c.tag(); + byte[] d = + switch (tag) { + case "TRAK" -> ChunkWriter.trak(models); + case "MAP " -> ChunkWriter.map(s, models, tiles); + case "TEXT" -> c.managed() ? ChunkWriter.text(s, pages, entries) : c.data(); + case "LGHT" -> c.managed() ? ChunkWriter.lights(s) : c.data(); + case "NAME" -> name(s, c); + default -> c.data(); + }; + nameDone |= tag.equals("NAME"); + out.add(new Wad.Chunk(tag, d)); + } + if (!nameDone) { + warn.add("the base level has no NAME chunk; the level name is not stored"); + } + byte[] wad = new Wad(out).toBytes(); + Level chk = Level.parse(wad); + if (chk.map().tiles.size() != tiles.size() + || chk.map().strats.size() != s.strats().size()) { + throw new IllegalStateException("MAP does not parse back (tiles/strats)"); + } + Stats st = new Stats( + models.size(), + tiles.size(), + gen.models().size(), + gen.tiles().size(), + s.strats().size(), + s.pathNodes().size(), + entries.size(), + pages.size(), + wad.length); + return new Result(wad, st, warn); + } + + private static byte[] name(Scene s, RawChunk c) { + String n = s.settings().levelName(); + if (c.managed()) { + return WadImporter.nameChunk(s.settings().nameChunkPrefix(), n); + } + byte[] d = c.data(); + Wad w = new Wad(List.of(new Wad.Chunk("NAME", d))); + if (n.equals(w.levelName())) { + return d; + } + return WadImporter.nameChunk(java.util.Arrays.copyOf(d, 4), n); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/WadImporter.java b/src/main/java/com/eliasmueller/llamamod/editor/io/WadImporter.java new file mode 100644 index 0000000..126997e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/WadImporter.java @@ -0,0 +1,496 @@ +package com.eliasmueller.llamamod.editor.io; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.RawChunk; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.model.TextureAnim; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.format.Bytes; +import com.eliasmueller.llamamod.format.Face; +import com.eliasmueller.llamamod.format.FormatException; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.Stpc; +import com.eliasmueller.llamamod.format.Strats; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.format.Trak; +import com.eliasmueller.llamamod.format.Vertex; +import com.eliasmueller.llamamod.format.Wad; +import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * Original level WAD → {@link Scene}. Node ids follow the position in the WAD ("tile:0", "strat:12", + * …), so the same WAD always gives the same ids (projects store edits against them). + */ +public final class WadImporter { + /** Chunks the editor regenerates; all others are kept verbatim. */ + static final Set<String> MANAGED = Set.of("TRAK", "MAP ", "TEXT", "LGHT", "NAME"); + + /** + * Strat types placed in no instance of a level whose code nevertheless has them: type fields from + * another level (as levelbuild does). + */ + private WadImporter() {} + + /** + * @param base level name, e.g. "t1l1m001" (stored as the project base) + * @param cd loads other CD files by relative path for foreign strat types (may be {@code null}) + */ + public static Scene importWad(byte[] wadBytes, String base, Function<String, byte[]> cd) { + Wad wad = Wad.parse(wadBytes); + Scene s = new Scene(); + LevelSettings st = s.settings(); + int wf = wad.wfpcFlags(); + st.setRaw("wfpc", wf); + st.setRaw("base", base == null ? "" : base); + st.setRaw("kind", LevelSettings.EDIT); + + Set<String> managed = new HashSet<>(); + Level lv = Level.parse(wadBytes); + importTrak(s, lv.models()); + managed.add("TRAK"); + importMap(s, lv); + managed.add("MAP "); + if (wad.has("TEXT")) { + importText(s, wad.require("TEXT")); + managed.add("TEXT"); + } + if (wad.has("LGHT") && importLights(s, wad.require("LGHT"))) { + managed.add("LGHT"); + } + if (wad.has("NAME")) { + byte[] nm = wad.require("NAME"); + String name = wad.levelName(); + if (name != null && Arrays.equals(nm, nameChunk(Arrays.copyOf(nm, 4), name))) { + st.setRaw("name", name); + st.setRaw("nameChunkPrefix", Arrays.copyOf(nm, 4)); + managed.add("NAME"); + } else if (name != null) { + st.setRaw("name", name); + } + } + int i = 0; + for (Wad.Chunk c : wad.chunks()) { + RawChunk r = new RawChunk("chunk:" + i++); + r.setRaw("tag", c.tag()); + r.setRaw("data", managed.contains(c.tag()) ? null : c.data()); + s.addQuiet(r); + } + if (wad.has("STPC")) { + s.setCatalog(catalog(wad.require("STPC"), wf, lv.map(), base, cd)); + } + return s; + } + + static byte[] nameChunk(byte[] prefix, String name) { + byte[] n = name.getBytes(StandardCharsets.ISO_8859_1); + byte[] out = new byte[4 + n.length + 1]; + System.arraycopy(prefix, 0, out, 0, 4); + System.arraycopy(n, 0, out, 4, n.length); + return out; + } + + static TileModel tileModel(String id, Model m) { + TileModel t = new TileModel(id); + double[] v = new double[m.verts.size() * TileModel.VERTEX_STRIDE]; + int k = 0; + for (Vertex x : m.verts) { + v[k++] = x.x(); + v[k++] = x.y(); + v[k++] = x.z(); + v[k++] = x.nx(); + v[k++] = x.ny(); + v[k++] = x.nz(); + } + int[] f = new int[m.faces.size() * TileModel.FACE_STRIDE]; + double[] p = new double[m.faces.size() * TileModel.PLANE_STRIDE]; + int a = 0; + int b = 0; + for (Face x : m.faces) { + f[a++] = x.flags(); + f[a++] = x.v0(); + f[a++] = x.v1(); + f[a++] = x.v2(); + f[a++] = x.tex(); + f[a++] = x.pad(); + p[b++] = x.nx(); + p[b++] = x.ny(); + p[b++] = x.nz(); + p[b++] = x.d(); + } + t.setRaw("vertices", v); + t.setRaw("faces", f); + t.setRaw("planes", p); + for (int g = 0; g < 3; g++) { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + for (byte[] r : m.coll.get(g)) { + bo.writeBytes(r); + } + t.setRaw("collision" + g, bo.toByteArray()); + } + t.setRaw("header", m.header == null ? null : Arrays.copyOf(m.header, 0x6c)); + return t; + } + + private static void importTrak(Scene s, List<Model> models) { + for (int i = 0; i < models.size(); i++) { + s.addQuiet(tileModel("model:" + i, models.get(i))); + } + } + + private static void importMap(Scene s, Level lv) { + MapChunk m = lv.map(); + LevelSettings st = s.settings(); + List<TileModel> models = s.tileModels(); + int[] vc = Trak.vertCounts(lv.models()); + st.setRaw("width", m.width); + st.setRaw("height", m.height); + List<MapChunk.TileColors> cols = m.tileColors(vc); + int[] altIdx = m.altIndexPerTile(); + for (int i = 0; i < m.tiles.size(); i++) { + MapChunk.Tile mt = m.tiles.get(i); + Tile t = new Tile("tile:" + i); + if (m.tileModels[i] != mt.model()) { + throw new FormatException("tile " + i + ": model index differs between MAP blocks"); + } + t.setRaw("x", mt.x()); + t.setRaw("y", mt.y()); + t.setRaw("z", mt.z()); + t.setRaw("rot", mt.rot()); + t.setRaw("model", models.get(mt.model())); + t.setRaw("alt", mt.alt()); + int[] fx = m.tilesFixed.get(i); + t.setRaw( + "fixed", Arrays.equals(fx, Tile.deriveFixed(mt.x(), mt.y(), mt.z(), mt.rot())) ? null : fx.clone()); + t.setRaw("colors", flatten(cols.get(i).frames())); + if (altIdx[i] >= 0) { + int[] a = m.alts.get(altIdx[i]); + t.setRaw("swapCell", new int[] {a[0], a[1], a[2]}); + t.setRaw("swapModel", models.get(a[3])); + t.setRaw("swapAltModel", models.get(a[4])); + t.setRaw("altColors", flatten(cols.get(i).altFrames())); + } + s.addQuiet(t); + } + long flagged = m.tiles.stream().filter(x -> x.alt() != 0).count(); + if (m.alts.size() > flagged) { + throw new FormatException("more tile swap entries than flagged tiles"); + } + st.setRaw("paramPool", m.params.clone()); + for (int i = 0; i < m.envZones.size(); i++) { + EnvZone z = new EnvZone("zone:" + i); + int[] f = m.envZones.get(i).f(); + for (int k = 0; k < f.length; k++) { + z.setRaw(EnvZone.FIELDS[k], f[k]); + } + s.addQuiet(z); + } + List<PathNode> nodes = importPathNodes(s, m); + s.regions().setRaw("table", m.regionTable.clone()); + s.regions().setRaw("grid", m.regionGrid.clone()); + importStrats(s, m, nodes); + st.setRaw("poolExtra", m.stratExtra); + st.setRaw("poolReserve", m.stratExtra - m.strats.size()); + // everything after the strats (paths, WFPC 0x10 value, u16, rest) stays verbatim + byte[] full = lv.wad().require("MAP"); + st.setRaw("mapTail", new byte[0]); + byte[] head = ChunkWriter.map(s, models, s.tiles()); + if (head.length > full.length || !Arrays.equals(head, 0, head.length, full, 0, head.length)) { + throw new FormatException("MAP does not rebuild (" + firstDiff(head, full) + ")"); + } + st.setRaw("mapTail", Arrays.copyOfRange(full, head.length, full.length)); + } + + private static List<PathNode> importPathNodes(Scene s, MapChunk m) { + List<PathNode> nodes = new ArrayList<>(); + for (int i = 0; i < m.nodes.size(); i++) { + nodes.add(new PathNode("wp:" + i)); + } + for (int i = 0; i < m.nodes.size(); i++) { + MapChunk.PathNode p = m.nodes.get(i); + PathNode n = nodes.get(i); + if (p.next() > 0 && p.next() < nodes.size()) { + n.setRaw("next", nodes.get(p.next())); + n.setRaw("nextRaw", 0); + } else { + n.setRaw("nextRaw", p.next()); + } + n.setRaw("unk1", p.unk1()); + n.setRaw("rx", p.rx()); + n.setRaw("ry", p.ry()); + n.setRaw("rz", p.rz()); + n.setRaw("x", p.x()); + n.setRaw("y", p.y()); + n.setRaw("z", p.z()); + n.setRaw("unk2", p.unk2()); + n.setRaw("unk3", p.unk3()); + s.addQuiet(n); + } + return nodes; + } + + private static void importStrats(Scene s, MapChunk m, List<PathNode> nodes) { + for (int i = 0; i < m.strats.size(); i++) { + MapChunk.Strat r = m.strats.get(i); + Strat x = new Strat("strat:" + i); + x.setRaw("code", r.code()); + x.setRaw("rx", r.rx()); + x.setRaw("ry", r.ry()); + x.setRaw("rz", r.rz()); + x.setRaw("x", r.x()); + x.setRaw("y", r.y()); + x.setRaw("z", r.z()); + int n = r.nParams(); + int pi = r.paramIndex(); + if (n > 0 && (pi < 0 || pi + n > m.params.length)) { + throw new FormatException("strat " + i + ": parameters outside the pool"); + } + x.setRaw("params", n == 0 ? new int[0] : Arrays.copyOfRange(m.params, pi, pi + n)); + x.setRaw("paramIndex", pi); + x.setRaw("nVars", r.nVars()); + x.setRaw("slots", r.slots()); + x.setRaw("typeFlags", r.typeFlags()); + x.setRaw("extra", r.extra()); + if (r.waypoint() >= 0 && r.waypoint() < nodes.size()) { + x.setRaw("waypoint", nodes.get(r.waypoint())); + } else { + x.setRaw("waypointRaw", r.waypoint()); + } + x.setRaw("zero", r.zero()); + x.setRaw("flags", r.flags() & 0xffff); + x.setRaw("last", r.last()); + s.addQuiet(x); + } + } + + private static String firstDiff(byte[] a, byte[] b) { + int n = Math.min(a.length, b.length); + for (int i = 0; i < n; i++) { + if (a[i] != b[i]) { + return "first difference at " + i; + } + } + return "length " + a.length + " vs " + b.length; + } + + private static int[] flatten(List<int[]> frames) { + int n = 0; + for (int[] f : frames) { + n += f.length; + } + int[] out = new int[n]; + int o = 0; + for (int[] f : frames) { + System.arraycopy(f, 0, out, o, f.length); + o += f.length; + } + return out; + } + + private static void importText(Scene s, byte[] d) { + TextChunk tc = TextChunk.parse(d); + int o = 0; + int npal = Bytes.u32(d, o); + o += 4; + for (int i = 0; i < npal; i++) { + o += 8 + 3 * Bytes.u32(d, o + 4); + } + s.settings().setRaw("textPalettes", Arrays.copyOfRange(d, 0, o)); + o += 4; + for (int i = 0; i < tc.pages.size(); i++) { + TextChunk.Page p = tc.pages.get(i); + int start = o; + int fl = Bytes.i32(d, o); + int w = Bytes.u32(d, o + 4); + int h = Bytes.u32(d, o + 8); + o += 12; + if (fl == 0) { + o += w * h; + } else if ((fl & 0x80) != 0) { + o += 4 + Bytes.u32(d, o); + } else { + o += w * h * 2; + } + TexturePage pg = new TexturePage("page:" + i); + pg.setRaw("flags", p.flags); + pg.setRaw("width", p.w); + pg.setRaw("height", p.h); + pg.setRaw("pixels", p.pixels); + pg.setEncodedCache(Arrays.copyOfRange(d, start, o)); + s.addQuiet(pg); + } + List<TextChunk.TextureEntry> te = TextChunk.textureEntries(d); + Map<Integer, String> names = textureNames(s.settings().base()); + for (int i = 0; i < te.size(); i++) { + TextureEntry t = textureEntry("tex:" + i, te.get(i)); + t.setRaw("name", names.getOrDefault(i, "")); + s.addQuiet(t); + } + o += 4 + 8 * te.size(); + int na = Bytes.u32(d, o); + o += 4; + for (int i = 0; i < na; i++) { + Bytes.need(d, o, 3); + int tex = Bytes.u16(d, o); + int type = d[o + 2] & 0xff; + o += 3; + int len = TextureAnim.fixedPayload(type); + if (type == 8) { + len = 3 + 2 * (d[o + 1] & 0xff); + } else if (type == 10 || type == 11) { + len = 4 + 2 * (d[o + 3] & 0xff); + } + Bytes.need(d, o, len); + TextureAnim a = new TextureAnim("anim:" + i); + a.setRaw("texture", tex); + a.setRaw("type", type); + a.setRaw("data", Arrays.copyOfRange(d, o, o + len)); + o += len; + s.addQuiet(a); + } + s.settings().setRaw("textTail", Arrays.copyOfRange(d, o, d.length)); + } + + static TextureEntry textureEntry(String id, TextChunk.TextureEntry e) { + TextureEntry t = new TextureEntry(id); + t.setRaw("flags", e.flags()); + t.setRaw("page", e.page()); + t.setRaw("page2", e.page2()); + t.setRaw("u0", e.u0()); + t.setRaw("u1", e.u1()); + t.setRaw("v0", e.v0()); + t.setRaw("v1", e.v1()); + return t; + } + + /** Names of village textures (levelbuild names) for the outliner; other levels have none. */ + private static Map<Integer, String> textureNames(String base) { + Map<Integer, String> m = new HashMap<>(); + if ("t1l1m001".equalsIgnoreCase(base)) { + LevelBuilder.TEX.forEach((k, v) -> m.putIfAbsent(v, k)); + } + return m; + } + + /** False if the chunk does not parse exactly (then it stays raw). */ + private static boolean importLights(Scene s, byte[] d) { + try { + int n = Bytes.u32(d, 0); + int o = 4; + List<Light> out = new ArrayList<>(); + for (int i = 0; i < n; i++) { + Bytes.need(d, o, 4); + Light l = new Light("light:" + i); + int type = d[o] & 0xff; + l.setRaw("type", type); + l.setRaw("r", d[o + 1] & 0xff); + l.setRaw("g", d[o + 2] & 0xff); + l.setRaw("b", d[o + 3] & 0xff); + o += 4; + if (type == Light.DIRECTIONAL || type == Light.POINT || type == Light.POINT_BRIGHT) { + Bytes.need(d, o, 12); + l.setRaw("x", (double) Bytes.f32(d, o)); + l.setRaw("y", (double) Bytes.f32(d, o + 4)); + l.setRaw("z", (double) Bytes.f32(d, o + 8)); + o += 12; + } + if (type == Light.POINT || type == Light.POINT_BRIGHT) { + Bytes.need(d, o, 9); + l.setRaw("a", (double) Bytes.f32(d, o)); + l.setRaw("rangeB", (double) Bytes.f32(d, o + 4)); + l.setRaw("c", d[o + 8] & 0xff); + o += 9; + } + out.add(l); + } + for (Light l : out) { + s.addQuiet(l); + } + s.settings().setRaw("lghtTail", Arrays.copyOfRange(d, o, d.length)); + return Arrays.equals(ChunkWriter.lights(s), d); + } catch (FormatException e) { + s.clearQuiet(NodeKind.LIGHT); + return false; + } + } + + /** Placeable types of the level (levelbuild's prototype rules). */ + static StratCatalog catalog(byte[] stpc, int wf, MapChunk map, String base, Function<String, byte[]> cd) { + StratCatalog c = new StratCatalog(); + int lo = Stpc.codeStart(stpc, wf); + for (MapChunk.Strat s : map.strats) { + String nm = Strats.nameOr(stpc, s.code(), lo, "?@" + Integer.toHexString(s.code())) + .replace("(spawn)", ""); + c.add(nm, variant(s, map, base)); + } + Map<String, Integer> named = new HashMap<>(); + for (Strats.Named n : Strats.allNamed(stpc, lo)) { + named.putIfAbsent(n.name(), n.offset()); + c.addCodeOnly(n.name(), n.offset()); + } + if (cd != null) { + for (Map.Entry<String, String> f : Strats.BORROWED.entrySet()) { + String nm = f.getKey(); + if (c.has(nm) || !named.containsKey(nm) || f.getValue().contains(base == null ? "?" : base)) { + continue; + } + Strats.Borrowed b = Strats.borrow(nm, f.getValue(), cd, named.get(nm)); + if (b != null) { + c.add(nm, variant(b.strat(), b.map(), f.getValue())); + } + } + } + return c; + } + + private static StratCatalog.Variant variant(MapChunk.Strat s, MapChunk map, String src) { + int[] prm = s.nParams() > 0 && s.paramIndex() >= 0 && s.paramIndex() + s.nParams() <= map.params.length + ? Arrays.copyOfRange(map.params, s.paramIndex(), s.paramIndex() + s.nParams()) + : new int[0]; + List<int[]> chain = new ArrayList<>(); + int k = s.waypoint(); + while (k >= 0 && k < map.nodes.size() && chain.size() <= 200) { + MapChunk.PathNode n = map.nodes.get(k); + chain.add(new int[] { + n.x() - s.x(), n.y() - s.y(), n.z() - s.z(), n.rx(), n.ry(), n.rz(), n.unk1(), n.unk2(), n.unk3() + }); + if (n.next() == 0) { + break; + } + k = n.next(); + } + return new StratCatalog.Variant( + s.code(), + s.nVars(), + s.slots(), + s.typeFlags(), + s.extra(), + s.flags() & 0xffff, + s.rx(), + s.ry(), + s.rz(), + prm, + List.copyOf(chain), + src); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Bounds.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Bounds.java new file mode 100644 index 0000000..7c92a08 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Bounds.java @@ -0,0 +1,19 @@ +package com.eliasmueller.llamamod.editor.model; + +/** Axis-aligned bounds as {x0, y0, z0, x1, y1, z1}. */ +final class Bounds { + private Bounds() {} + + /** Empty bounds (+∞ minimum, −∞ maximum) to {@link #grow}. */ + static double[] empty() { + double p = Double.POSITIVE_INFINITY; + double n = Double.NEGATIVE_INFINITY; + return new double[] {p, p, p, n, n, n}; + } + + /** Extends axis k (0 = x, 1 = y, 2 = z) to include v. */ + static void grow(double[] b, int k, double v) { + b[k] = Math.min(b[k], v); + b[k + 3] = Math.max(b[k + 3], v); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Brush.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Brush.java new file mode 100644 index 0000000..4f831bc --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Brush.java @@ -0,0 +1,325 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.Arrays; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Primitive building block converted to tiles (TRAK models, MAP tiles, vertex colours, collision) + * when the level is built. Brushes are replayed in list order, so the order matters for overlapping + * geometry and for the random texture rotation. Parameters depend on {@link #brushKind()}; every + * parameter is a {@link Prop} named like the {@link BrushKind.Param}. + */ +public final class Brush extends Node { + BrushKind brushKind; + String name = ""; + boolean enabled = true; + final Map<String, Object> values = new LinkedHashMap<>(); + + private static final Map<BrushKind, List<Prop>> PROPS = new EnumMap<>(BrushKind.class); + + public Brush(String id, BrushKind kind) { + super(id); + brushKind = kind; + for (BrushKind.Param p : kind.params()) { + Object d = p.def(); + values.put(p.name(), d instanceof double[] a ? a.clone() : d instanceof int[] ia ? ia.clone() : d); + } + } + + @Override + public NodeKind kind() { + return NodeKind.BRUSH; + } + + @Override + public List<Prop> props() { + synchronized (PROPS) { + return PROPS.computeIfAbsent(brushKind, Brush::define); + } + } + + private static List<Prop> define(BrushKind k) { + Props<Brush> l = new Props<Brush>() + .group("Brush") + .p( + "brushKind", + "Kind", + PropType.CHOICE, + n -> n.brushKind.name(), + (n, v) -> n.brushKind = BrushKind.valueOf(v), + b -> b.choices(Arrays.stream(BrushKind.values()) + .map(Enum::name) + .toList()) + .readOnly()) + .str("name", "Name", n -> n.name, (n, v) -> n.name = v) + .p("enabled", "Enabled", PropType.BOOL, n -> n.enabled, (n, v) -> n.enabled = v) + .group(k.label()); + for (BrushKind.Param p : k.params()) { + l.p(p.name(), p.label(), p.type(), n -> n.values.get(p.name()), (n, v) -> n.values.put(p.name(), v), b -> { + if (p.nullable()) { + b.nullable(); + } + if (p.type() == PropType.CHOICE) { + b.choices(p.choices()); + } + if (p.type() == PropType.REF) { + b.ref(NodeKind.valueOf(p.choices().getFirst())); + } + return b; + }); + } + return l.build(); + } + + @Override + public String label() { + return (name.isEmpty() ? brushKind.label() : name) + " (" + id() + ")"; + } + + public BrushKind brushKind() { + return brushKind; + } + + public String name() { + return name; + } + + public boolean enabled() { + return enabled; + } + + /** False only for kinds with a "visible" parameter that is switched off (invisible collision). */ + public boolean visible() { + return !(values.get("visible") instanceof Boolean v) || v; + } + + public double d(String p) { + Object v = values.get(p); + return v == null ? Double.NaN : ((Number) v).doubleValue(); + } + + public Double dOrNull(String p) { + Object v = values.get(p); + return v == null ? null : ((Number) v).doubleValue(); + } + + public int i(String p) { + return ((Number) values.get(p)).intValue(); + } + + public boolean b(String p) { + return (Boolean) values.get(p); + } + + public String s(String p) { + Object v = values.get(p); + return v == null ? "" : (String) v; + } + + public double[] a(String p) { + return (double[]) values.get(p); + } + + public Node ref(String p) { + return (Node) values.get(p); + } + + /** Parameter values after a translation by (dx, dy, dz) (for a move command). */ + public Map<String, Object> translated(double dx, double dy, double dz) { + Map<String, Object> out = new LinkedHashMap<>(); + for (BrushKind.Param p : brushKind.params()) { + Object v = values.get(p.name()); + if (v == null) { + continue; + } + Object nv = + switch (p.role()) { + case X -> (Double) v + dx; + case Y -> (Double) v + dy; + case Z -> (Double) v + dz; + case XZ_LIST -> shift((double[]) v, new double[] {dx, dz}); + case XYZ_LIST -> shift((double[]) v, new double[] {dx, dy, dz}); + case XZ_RECT -> shift((double[]) v, new double[] {dx, dz}); + case NONE -> null; + }; + if (nv != null) { + out.put(p.name(), nv); + } + } + return out; + } + + private static double[] shift(double[] a, double[] d) { + double[] r = a.clone(); + for (int i = 0; i < r.length; i++) { + r[i] += d[i % d.length]; + } + return r; + } + + /** + * Axis-aligned world bounds {x0, y0, z0, x1, y1, z1} (approximate): {@link #localBounds()} turned + * by the brush rotation about {@link #pivot()}. + */ + public double[] bounds() { + double[] lb = localBounds(); + if (!hasRotation() || !Double.isFinite(lb[0])) { + return lb; + } + double[] m = rotationMatrix(rotation()); + double[] pv = pivot(); + double[] b = Bounds.empty(); + for (int c = 0; c < 8; c++) { + double[] p = {lb[(c & 1) != 0 ? 3 : 0], lb[(c & 2) != 0 ? 4 : 1], lb[(c & 4) != 0 ? 5 : 2]}; + double[] w = rotate(m, pv, p); + for (int k = 0; k < 3; k++) { + Bounds.grow(b, k, w[k]); + } + } + return b; + } + + /** Bounds before the rotation, including the sizes of platforms, pillars, trees, meshes, … */ + public double[] localBounds() { + double[] b = roleBounds(); + if (!Double.isFinite(b[0])) { + return b; + } + switch (brushKind) { + case PLATFORM -> { + Bounds.grow(b, 0, d("x") + d("w")); + Bounds.grow(b, 2, d("z") + d("d")); + Bounds.grow(b, 1, d("y") - d("thick")); + } + case PILLAR -> square(b, d("size") / 2); + case CYLINDER -> square(b, d("radius")); + case TREE -> { + square(b, d("crown") / 2); + Bounds.grow(b, 1, d("y") + d("height") + d("crown") * 1.1); + } + case FENCE -> Bounds.grow(b, 1, d("y") + d("height")); + case RAMP, STAIRS -> { + Double base = dOrNull("base"); + Bounds.grow(b, 1, base != null ? base : b[1] - 0.5); + } + case MESH -> { + Node a = ref("asset"); + if (a instanceof MeshAsset ma && ma.mesh() != null) { + double[] mb = ma.mesh().bounds(); + double sc = d("scale"); + double[] st = values.get("stretch") instanceof double[] s ? s : new double[] {1, 1, 1}; + double ang = Math.toRadians(d("rotY")); + double ca = Math.cos(ang); + double sa = Math.sin(ang); + for (int c = 0; c < 8; c++) { + double x = (c & 1) != 0 ? mb[3] : mb[0]; + double y = (c & 2) != 0 ? mb[4] : mb[1]; + double z = (c & 4) != 0 ? mb[5] : mb[2]; + x *= sc * st[0]; + y *= sc * st[1]; + z *= sc * st[2]; + Bounds.grow(b, 0, d("x") + x * ca + z * sa); + Bounds.grow(b, 1, d("y") + y); + Bounds.grow(b, 2, d("z") - x * sa + z * ca); + } + } + } + default -> {} + } + return b; + } + + private void square(double[] b, double h) { + double cx = (b[0] + b[3]) / 2; + double cz = (b[2] + b[5]) / 2; + Bounds.grow(b, 0, cx - h); + Bounds.grow(b, 0, cx + h); + Bounds.grow(b, 2, cz - h); + Bounds.grow(b, 2, cz + h); + } + + /** Point the rotation turns about: the mesh origin for meshes, else the centre of the local bounds. */ + public double[] pivot() { + if (brushKind == BrushKind.MESH) { + return new double[] {d("x"), d("y"), d("z")}; + } + double[] b = localBounds(); + return new double[] {(b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2}; + } + + /** Rotation {x, y, z} in degrees (game space, applied as x·y·z like JOML rotateXYZ), zeros if none. */ + public double[] rotation() { + return values.get("rotation") instanceof double[] r && r.length == 3 ? r : new double[3]; + } + + public boolean hasRotation() { + double[] r = rotation(); + return r[0] % 360 != 0 || r[1] % 360 != 0 || r[2] % 360 != 0; + } + + /** Row-major 3×3 matrix Rx·Ry·Rz of rotation angles in degrees (game space, right-handed). */ + public static double[] rotationMatrix(double[] deg) { + double ax = Math.toRadians(deg[0]); + double ay = Math.toRadians(deg[1]); + double az = Math.toRadians(deg[2]); + double cx = Math.cos(ax), sx = Math.sin(ax); + double cy = Math.cos(ay), sy = Math.sin(ay); + double cz = Math.cos(az), sz = Math.sin(az); + return new double[] { + cy * cz, + -cy * sz, + sy, + sx * sy * cz + cx * sz, + -sx * sy * sz + cx * cz, + -sx * cy, + -cx * sy * cz + sx * sz, + cx * sy * sz + sx * cz, + cx * cy + }; + } + + /** m·(p − pivot) + pivot. */ + public static double[] rotate(double[] m, double[] pivot, double[] p) { + double x = p[0] - pivot[0], y = p[1] - pivot[1], z = p[2] - pivot[2]; + return new double[] { + pivot[0] + m[0] * x + m[1] * y + m[2] * z, + pivot[1] + m[3] * x + m[4] * y + m[5] * z, + pivot[2] + m[6] * x + m[7] * y + m[8] * z + }; + } + + private double[] roleBounds() { + double[] b = Bounds.empty(); + for (BrushKind.Param p : brushKind.params()) { + Object v = values.get(p.name()); + if (v == null) { + continue; + } + switch (p.role()) { + case X -> Bounds.grow(b, 0, (Double) v); + case Y -> Bounds.grow(b, 1, (Double) v); + case Z -> Bounds.grow(b, 2, (Double) v); + case XZ_LIST, XZ_RECT -> { + double[] a = (double[]) v; + for (int i = 0; i + 1 < a.length; i += 2) { + Bounds.grow(b, 0, a[i]); + Bounds.grow(b, 2, a[i + 1]); + } + } + case XYZ_LIST -> { + double[] a = (double[]) v; + for (int i = 0; i + 2 < a.length; i += 3) { + Bounds.grow(b, 0, a[i]); + Bounds.grow(b, 1, a[i + 1]); + Bounds.grow(b, 2, a[i + 2]); + } + } + case NONE -> {} + } + } + return b; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/BrushKind.java b/src/main/java/com/eliasmueller/llamamod/editor/model/BrushKind.java new file mode 100644 index 0000000..a67cd52 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/BrushKind.java @@ -0,0 +1,310 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * Brush (primitive) types and their parameters. Brushes are converted to tiles at build time by + * {@code levelbuild.LevelBuilder} (same primitives as levels/testwelt.py). Coordinates are game + * space (x east, y up, z = grid row, positive), 1 unit = 1 cell. Textures: a name of {@code + * LevelBuilder.TEX} (village names), "#index" (texture table index), a flat colour name of {@code + * LevelBuilder.FLAT} or "rgbRRGGBB"; "" = leave that part out. + */ +public enum BrushKind { + FLOOR("Floor", "Flat horizontal surface split into 1×1 tiles"), + BOX("Box", "Axis-aligned block with top, sides and optional bottom"), + PLATFORM("Platform", "Floating platform (box with edge and bottom textures)"), + PILLAR("Pillar", "Square pillar around a centre point"), + CYLINDER("Cylinder", "Round platform or column: n-sided prism around a centre point"), + RAMP("Ramp", "Slope rising in one direction, sides closed to the base"), + STAIRS("Stairs", "Steps rising in one direction (walkable = invisible slope as collision)"), + FENCE("Fence", "Posts and rail along a polyline"), + TREE("Tree", "Block tree: trunk and two crown cubes"), + SEA("Sea / water", "Water surface without collision, optionally with a death plane"), + KILL_PLANE("Death plane", "Invisible no-floor plane (material 30) in all cells not covered by floor"), + QUAD("Quad", "Single quad or triangle (corners seen from the front)"), + COLLIDER("Collider", "Invisible collision surface"), + MESH("Mesh", "Imported mesh (OBJ/glTF) split into tiles, with collision"); + + /** Coordinate role of a parameter (for moving brushes). */ + public enum Role { + NONE, + X, + Y, + Z, + /** double[] of (x, z) pairs. */ + XZ_LIST, + /** double[] of (x, y, z) triples. */ + XYZ_LIST, + /** double[4] {x0, z0, x1, z1}. */ + XZ_RECT + } + + public record Param(String name, String label, PropType type, Object def, Role role, List<String> choices) { + public boolean nullable() { + return def == null; + } + } + + public static final List<String> DIRECTIONS = List.of("+x", "-x", "+z", "-z"); + public static final List<String> KILL_MODES = List.of("none", "whole", "area"); + + /** Face classes of the generic face parameters: top, bottom, north (z0), east (x1), south (z1), west (x0). */ + public static final String FACES = "tbnesw"; + + /** Kinds with the generic parameters rotation, visible, collFaces, faceMats (cell-based kinds have none). */ + public boolean transformable() { + return this != SEA && this != KILL_PLANE && this != COLLIDER; + } + + private final String label; + private final String description; + private List<Param> params; + + BrushKind(String label, String description) { + this.label = label; + this.description = description; + } + + public String label() { + return label; + } + + public String description() { + return description; + } + + public synchronized List<Param> params() { + if (params == null) { + params = List.copyOf(define(this)); + } + return params; + } + + public Param param(String name) { + for (Param p : params()) { + if (p.name.equals(name)) { + return p; + } + } + throw new IllegalArgumentException(this + " has no parameter " + name); + } + + private static final double[] WHITE = {1, 1, 1}; + + private static List<Param> define(BrushKind k) { + P p = new P(); + defineKind(k, p); + if (k.transformable()) { + p.a("rotation", "Rotation (x, y, z °)", new double[] {0, 0, 0}, Role.NONE); + if (k == MESH) { + p.a("stretch", "Stretch (x, y, z)", new double[] {1, 1, 1}, Role.NONE); + } + if (k != QUAD) { + p.b("visible", "Visible (off = invisible collision)", true); + } + p.s("collFaces", "Faces with collision (t b n e s w)", FACES); + p.ia("faceMats", "Material per face (t b n e s w, −1 = brush)", new int[] {-1, -1, -1, -1, -1, -1}); + } + return p.l; + } + + private static void defineKind(BrushKind k, P p) { + switch (k) { + case FLOOR -> p.rect() + .d("y", "Height", 0.0, Role.Y) + .s("tex", "Texture", "grass") + .i("texRot", "Texture rotation (0-3, −1 random)", -1) + .d("step", "Tile size", 1.0, Role.NONE) + .b("coll", "Collision", true) + .i("mat", "Collision material", 0) + .tint(); + case BOX -> p.rect() + .d("y0", "Bottom", 0.0, Role.Y) + .d("y1", "Top", 1.0, Role.Y) + .s("top", "Top texture", "grass") + .s("side", "Side texture", "earth") + .s("sideTop", "Top row of the sides", "") + .s("bottom", "Bottom texture", "") + .s("sides", "Sides (n, e, s, w)", "nesw") + .b("coll", "Collision", true) + .b("sideColl", "Side collision", true) + .i("mat", "Collision material", 0) + .b("solid", "Casts shadows", true) + .i("texRot", "Texture rotation (0-3, −1 random)", -1) + .d("step", "Tile size", 1.0, Role.NONE) + .tint(); + case PLATFORM -> p.d("x", "X", 0.0, Role.X) + .d("z", "Z", 0.0, Role.Z) + .d("w", "Width (x)", 2.0, Role.NONE) + .d("d", "Depth (z)", 2.0, Role.NONE) + .d("y", "Top", 1.0, Role.Y) + .d("thick", "Thickness", 0.5, Role.NONE) + .s("top", "Top texture", "grass") + .s("side", "Side texture", "earth") + .s("sideTop", "Top row of the sides", "grass_edge") + .s("bottom", "Bottom texture", "earth") + .i("mat", "Collision material", 0) + .i("texRot", "Texture rotation (0-3, −1 random)", -1) + .tint(); + case CYLINDER -> p.d("x", "X (centre)", 0.0, Role.X) + .d("z", "Z (centre)", 0.0, Role.Z) + .d("y0", "Bottom", 0.0, Role.Y) + .d("y1", "Top", 1.0, Role.Y) + .d("radius", "Radius", 1.5, Role.NONE) + .i("segments", "Segments (3-64)", 16) + .s("top", "Top texture", "stone") + .s("side", "Side texture", "stone2") + .s("sideTop", "Top row of the sides", "") + .s("bottom", "Bottom texture", "") + .b("coll", "Collision", true) + .b("sideColl", "Side collision", true) + .i("mat", "Collision material", 0) + .tint(); + case PILLAR -> p.d("x", "X (centre)", 0.0, Role.X) + .d("z", "Z (centre)", 0.0, Role.Z) + .d("y0", "Bottom", 0.0, Role.Y) + .d("y1", "Top", 1.5, Role.Y) + .d("size", "Size", 0.5, Role.NONE) + .s("side", "Side texture", "stone") + .s("top", "Top texture", "stone") + .i("texRot", "Texture rotation (0-3, −1 random)", -1) + .tint(); + case RAMP -> p.rect() + .d("ya", "Low end height", 0.0, Role.Y) + .d("yb", "High end height", 1.0, Role.Y) + .c("direction", "Rises towards", "+x", DIRECTIONS) + .s("top", "Top texture", "sand") + .s("side", "Side texture", "earth") + .n("base", "Base height (empty = low end − 0.5)", Role.Y) + .b("coll", "Collision", true) + .d("step", "Tile size", 1.0, Role.NONE) + .tint(); + case STAIRS -> p.rect() + .d("y0", "Start height (floor in front)", 0.0, Role.Y) + .d("y1", "Top height", 1.0, Role.Y) + .i("steps", "Steps", 4) + .c("direction", "Rises towards", "+x", DIRECTIONS) + .s("top", "Top texture", "stone") + .s("side", "Side texture", "stone2") + .n("base", "Base height (empty = bottom − 0.5)", Role.Y) + .b("walkable", "Walkable (invisible slope)", true) + .tint(); + case FENCE -> p.a("points", "Points (x, z pairs)", new double[] {0, 0, 4, 0}, Role.XZ_LIST) + .d("y", "Ground height", 0.0, Role.Y) + .d("height", "Height", 0.45, Role.NONE) + .d("thick", "Post size", 0.07, Role.NONE) + .s("post", "Post texture", "wood_dark") + .s("rail", "Rail texture", "wood_grain") + .d("postsEvery", "Post spacing", 1.0, Role.NONE); + case TREE -> p.d("x", "X", 0.0, Role.X) + .d("z", "Z", 0.0, Role.Z) + .d("y", "Ground height", 0.0, Role.Y) + .d("height", "Trunk height", 1.6, Role.NONE) + .d("crown", "Crown size", 1.1, Role.NONE) + .s("trunk", "Trunk texture", "bark") + .s("leaves", "Leaves texture", "grass_dark") + .d("trunkSize", "Trunk size (0 = auto)", 0.0, Role.NONE); + case SEA -> p.rect() + .d("y", "Water height", -1.5, Role.Y) + .s("tex", "Texture", "sea") + .c("kill", "Death plane", "whole", KILL_MODES) + .a("killArea", "Death plane area (x0, z0, x1, z1)", new double[] {0, 0, 0, 0}, Role.XZ_RECT) + .d("step", "Tile size", 4.0, Role.NONE); + case KILL_PLANE -> p.rect().d("y", "Height", -1.5, Role.Y); + case QUAD -> p.a( + "points", + "Corners (x, y, z) × 3 or 4", + new double[] {0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1}, + Role.XYZ_LIST) + .s("tex", "Texture", "stone") + .b("coll", "Collision", true) + .i("mat", "Collision material", 0) + .i("texRot", "Texture rotation (0-3)", 0) + .b("doubleSided", "Double-sided", false) + .b("shade", "Lit (vertex colours)", true) + .b("render", "Visible", true) + .tint(); + case COLLIDER -> p.a( + "points", + "Corners (x, y, z) × 3 or 4", + new double[] {0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1}, + Role.XYZ_LIST) + .i("mat", "Collision material", 0) + .a("normal", "Front side normal", new double[] {0, 1, 0}, Role.NONE); + case MESH -> p.r("asset", "Mesh asset", NodeKind.MESH_ASSET) + .d("x", "X", 0.0, Role.X) + .d("y", "Y", 0.0, Role.Y) + .d("z", "Z", 0.0, Role.Z) + .d("rotY", "Rotation (°)", 0.0, Role.NONE) + .d("scale", "Scale", 1.0, Role.NONE) + .b("coll", "Collision", true) + .i("mat", "Collision material", 0) + .b("doubleSided", "Double-sided", false) + .b("shade", "Lit (vertex colours)", true) + .i("texSize", "Texture size on the page (16-256)", 128) + .i("uvSteps", "UV rectangle precision (steps per texture)", 8) + .tint(); + } + } + + private static final class P { + final List<Param> l = new ArrayList<>(); + + P d(String n, String lb, double def, Role r) { + l.add(new Param(n, lb, PropType.DOUBLE, def, r, List.of())); + return this; + } + + P n(String n, String lb, Role r) { + l.add(new Param(n, lb, PropType.DOUBLE, null, r, List.of())); + return this; + } + + P i(String n, String lb, int def) { + l.add(new Param(n, lb, PropType.INT, def, Role.NONE, List.of())); + return this; + } + + P b(String n, String lb, boolean def) { + l.add(new Param(n, lb, PropType.BOOL, def, Role.NONE, List.of())); + return this; + } + + P s(String n, String lb, String def) { + l.add(new Param(n, lb, PropType.STRING, def, Role.NONE, List.of())); + return this; + } + + P c(String n, String lb, String def, List<String> ch) { + l.add(new Param(n, lb, PropType.CHOICE, def, Role.NONE, ch)); + return this; + } + + P a(String n, String lb, double[] def, Role r) { + l.add(new Param(n, lb, PropType.DOUBLE_ARRAY, def, r, List.of())); + return this; + } + + P ia(String n, String lb, int[] def) { + l.add(new Param(n, lb, PropType.INT_ARRAY, def, Role.NONE, List.of())); + return this; + } + + P r(String n, String lb, NodeKind k) { + l.add(new Param(n, lb, PropType.REF, null, Role.NONE, List.of(k.name()))); + return this; + } + + P rect() { + return d("x0", "X0", 0.0, Role.X) + .d("z0", "Z0", 0.0, Role.Z) + .d("x1", "X1", 1.0, Role.X) + .d("z1", "Z1", 1.0, Role.Z); + } + + P tint() { + return a("tint", "Tint (r, g, b)", WHITE.clone(), Role.NONE); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/EnvZone.java b/src/main/java/com/eliasmueller/llamamod/editor/model/EnvZone.java new file mode 100644 index 0000000..2b1246e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/EnvZone.java @@ -0,0 +1,206 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Environment / teleport zone (MAP 90-byte record, 0x5c bytes in memory; FUN_0054d560, + * FUN_0054de00, FUN_0041e1c0). A zone is entered through its trigger cell; it moves the player to + * the target position (teleport/respawn) and switches fog, colours and music. The entry with + * target (1, 1, 1) is the level-wide default. Fog distances are 20.12 fixed point (4096 = 1 cell): + * "low" = detail Minimum, "high" = detail Maximum (if high start ≤ low start the engine uses 1.5 × + * the low pair). Field names with "unk" are not understood. + */ +public final class EnvZone extends Node { + /** Field names in file order; u16 fields: unk14, unk21, unk22. */ + public static final String[] FIELDS = { + "cellX", + "cellY", + "cellZ", + "x", + "y", + "z", + "unk6", + "angleFlags", + "flags", + "unk9", + "stpcOffset", + "skyYaw", + "unk12", + "fogMode", + "unk14", + "fogNearLow", + "fogFarLow", + "fogNearHigh", + "fogFarHigh", + "ambientColor", + "fogColor", + "unk21", + "unk22", + "music" + }; + + private static final String[] LABELS = { + "Trigger cell X", + "Trigger cell Y", + "Trigger cell Z", + "Target X", + "Target Y", + "Target Z", + "Field +18", + "Facing (bits 4-11) + y compare (bits 0-1)", + "Flags (bit 8 = start zone)", + "Field +24", + "STPC offset (0 = none)", + "Sky yaw offset", + "Field +30", + "Fog mode (3 = vertex fog, 2 = other)", + "Field +38 (u16)", + "Fog start (detail Minimum)", + "Fog end (detail Minimum)", + "Fog start (detail Maximum)", + "Fog end (detail Maximum)", + "Ambient colour", + "Fog / backdrop colour", + "Field +52 (u16)", + "Field +54 (u16)", + "Music id" + }; + + private static final PropType[] TYPES = { + PropType.INT, + PropType.INT, + PropType.INT, + PropType.FIXED, + PropType.FIXED, + PropType.FIXED, + PropType.INT, + PropType.FLAGS, + PropType.FLAGS, + PropType.INT, + PropType.FLAGS, + PropType.INT, + PropType.INT, + PropType.INT, + PropType.INT, + PropType.FIXED, + PropType.FIXED, + PropType.FIXED, + PropType.FIXED, + PropType.COLOR, + PropType.COLOR, + PropType.INT, + PropType.INT, + PropType.INT + }; + + private static final String[] GROUPS = { + "Trigger", "Trigger", "Trigger", "Target", "Target", "Target", "Raw", "Target", "Zone", "Raw", "Zone", "Zone", + "Raw", "Fog", "Raw", "Fog", "Fog", "Fog", "Fog", "Fog", "Fog", "Raw", "Raw", "Music" + }; + + final int[] f = new int[FIELDS.length]; + + private static final List<Prop> PROPS = defineProps(); + + private static List<Prop> defineProps() { + Props<EnvZone> l = new Props<>(); + for (int i = 0; i < FIELDS.length; i++) { + int k = i; + l.group(GROUPS[i]).p(FIELDS[i], LABELS[i], TYPES[i], n -> n.f[k], (n, v) -> n.f[k] = v); + } + return l.build(); + } + + public EnvZone(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.ENV_ZONE; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return isDefault() ? "default zone" : "zone " + f[0] + "," + f[2]; + } + + /** All 24 fields in file order (copy). */ + public int[] fields() { + return f.clone(); + } + + public int field(int i) { + return f[i]; + } + + public boolean isDefault() { + return f[3] == 1 && f[4] == 1 && f[5] == 1; + } + + public int cellX() { + return f[0]; + } + + public int cellY() { + return f[1]; + } + + public int cellZ() { + return f[2]; + } + + public double targetX() { + return f[3] / 4096.0; + } + + public double targetY() { + return f[4] / 4096.0; + } + + public double targetZ() { + return f[5] / 4096.0; + } + + /** Facing angle at the target, 4096 = 360° (bits 4..11 of the angle field, as 1/256 turns). */ + public int facing() { + return ((f[7] >> 4) & 0xff) << 4; + } + + public int fogMode() { + return f[13]; + } + + public double fogNearLow() { + return f[15] / 4096.0; + } + + public double fogFarLow() { + return f[16] / 4096.0; + } + + public double fogNearHigh() { + return f[17] / 4096.0; + } + + public double fogFarHigh() { + return f[18] / 4096.0; + } + + public int ambientColor() { + return f[19]; + } + + public int fogColor() { + return f[20]; + } + + public int music() { + return f[23]; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/LevelSettings.java b/src/main/java/com/eliasmueller/llamamod/editor/model/LevelSettings.java new file mode 100644 index 0000000..a15d588 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/LevelSettings.java @@ -0,0 +1,265 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Level-wide settings (singleton node "settings:0"): name, grid, strat pool, project metadata, the + * lighting used to bake vertex colours of brushes, and verbatim MAP trailer data. + */ +public final class LevelSettings extends Node { + /** Project kinds. */ + public static final String EDIT = "edit"; + + public static final String NEW = "new"; + + String levelName = ""; + String kindName = EDIT; + String base = ""; + String author = ""; + String description = ""; + int targetChapter; + int wfpc; + int width; + int height; + int poolExtra; + int poolReserve; + int[] paramPool = new int[0]; + byte[] mapTail = new byte[0]; + byte[] nameChunkPrefix = new byte[4]; + byte[] textPalettes = new byte[4]; + byte[] textTail = new byte[0]; + byte[] lghtTail = new byte[0]; + long seed = 1; + double sunAzimuth = 215; + double sunElevation = 50; + double sunStrength = 0.62; + double[] sunColor = {1.0, 0.93, 0.80}; + double[] sky = {0.50, 0.55, 0.66}; + double[] ground = {0.30, 0.28, 0.24}; + double ao = 0.55; + double aoRadius = 1.3; + double shadow = 0.35; + + private static final List<Prop> PROPS = new Props<LevelSettings>() + .group("Level") + .str("name", "Level name", n -> n.levelName, (n, v) -> n.levelName = v) + .p("kind", "Project kind", PropType.CHOICE, n -> n.kindName, (n, v) -> n.kindName = v, b -> b.choices( + List.of(EDIT, NEW)) + .readOnly() + .desc("edit = changes to an original level, new = own level on a TestLevel chapter")) + .p("base", "Base level", PropType.STRING, n -> n.base, (n, v) -> n.base = v, b -> b.readOnly() + .desc("original level the project builds on (object code, textures, sound)")) + .integer("targetChapter", "TestLevel chapter", n -> n.targetChapter, (n, v) -> n.targetChapter = v) + .str("author", "Author", n -> n.author, (n, v) -> n.author = v) + .str("description", "Description", n -> n.description, (n, v) -> n.description = v) + .group("Grid") + .p( + "wfpc", + "Feature flags (WFPC)", + PropType.FLAGS, + n -> n.wfpc, + (n, v) -> n.wfpc = v, + Prop.Builder::readOnly) + .p("width", "Grid width", PropType.INT, n -> n.width, (n, v) -> n.width = v, Prop.Builder::readOnly) + .p("height", "Grid height", PropType.INT, n -> n.height, (n, v) -> n.height = v, Prop.Builder::readOnly) + .group("Objects") + .p( + "poolExtra", + "Strat pool extra", + PropType.INT, + n -> n.poolExtra, + (n, v) -> n.poolExtra = v, + b -> b.desc("strat slots = poolExtra + 30; the build uses max(poolExtra, objects + reserve)")) + .integer("poolReserve", "Strat pool reserve", n -> n.poolReserve, (n, v) -> n.poolReserve = v) + .group("Lighting") + .integer("seed", "Texture rotation seed", n -> (int) n.seed, (n, v) -> n.seed = v) + .dbl("sunAzimuth", "Sun azimuth (°)", n -> n.sunAzimuth, (n, v) -> n.sunAzimuth = v) + .dbl("sunElevation", "Sun elevation (°)", n -> n.sunElevation, (n, v) -> n.sunElevation = v) + .dbl("sunStrength", "Sun strength", n -> n.sunStrength, (n, v) -> n.sunStrength = v) + .doubles("sunColor", "Sun colour (r, g, b)", n -> n.sunColor, (n, v) -> n.sunColor = v) + .doubles("sky", "Sky light (r, g, b)", n -> n.sky, (n, v) -> n.sky = v) + .doubles("ground", "Ground light (r, g, b)", n -> n.ground, (n, v) -> n.ground = v) + .dbl("ao", "Ambient occlusion", n -> n.ao, (n, v) -> n.ao = v) + .dbl("aoRadius", "AO radius", n -> n.aoRadius, (n, v) -> n.aoRadius = v) + .dbl("shadow", "Shadow", n -> n.shadow, (n, v) -> n.shadow = v) + .group("Raw") + .p( + "paramPool", + "Original parameter pool", + PropType.INT_ARRAY, + n -> n.paramPool, + (n, v) -> n.paramPool = v, + b -> b.readOnly() + .desc("strats whose parameters are unchanged keep their pool index; changed ones are" + + " appended")) + .p( + "mapTail", + "MAP data after the strats (paths, u32, u16, rest)", + PropType.BYTES, + n -> n.mapTail, + (n, v) -> n.mapTail = v, + Prop.Builder::readOnly) + .p( + "nameChunkPrefix", + "NAME chunk prefix", + PropType.BYTES, + n -> n.nameChunkPrefix, + (n, v) -> n.nameChunkPrefix = v, + Prop.Builder::readOnly) + .p( + "textPalettes", + "TEXT palettes", + PropType.BYTES, + n -> n.textPalettes, + (n, v) -> n.textPalettes = v, + Prop.Builder::readOnly) + .p( + "textTail", + "TEXT trailer", + PropType.BYTES, + n -> n.textTail, + (n, v) -> n.textTail = v, + Prop.Builder::readOnly) + .p( + "lghtTail", + "LGHT trailer", + PropType.BYTES, + n -> n.lghtTail, + (n, v) -> n.lghtTail = v, + Prop.Builder::readOnly) + .build(); + + public LevelSettings(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.SETTINGS; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return "Level settings"; + } + + public String levelName() { + return levelName; + } + + public String projectKind() { + return kindName; + } + + public boolean isNewLevel() { + return NEW.equals(kindName); + } + + public String base() { + return base; + } + + public String author() { + return author; + } + + public String description() { + return description; + } + + /** TestLevel (world 9) chapter 1..49 for new levels, 0 = not chosen. */ + public int targetChapter() { + return targetChapter; + } + + public int wfpc() { + return wfpc; + } + + public int width() { + return width; + } + + public int height() { + return height; + } + + public int poolExtra() { + return poolExtra; + } + + public int poolReserve() { + return poolReserve; + } + + public int[] paramPool() { + return paramPool; + } + + public byte[] mapTail() { + return mapTail; + } + + public byte[] nameChunkPrefix() { + return nameChunkPrefix; + } + + /** Raw palette block of TEXT (u32 n + palettes; empty in all originals). */ + public byte[] textPalettes() { + return textPalettes; + } + + /** Bytes after the texture animations (none in the originals). */ + public byte[] textTail() { + return textTail; + } + + public byte[] lghtTail() { + return lghtTail; + } + + public long seed() { + return seed; + } + + public double sunAzimuth() { + return sunAzimuth; + } + + public double sunElevation() { + return sunElevation; + } + + public double sunStrength() { + return sunStrength; + } + + public double[] sunColor() { + return sunColor; + } + + public double[] sky() { + return sky; + } + + public double[] ground() { + return ground; + } + + public double ao() { + return ao; + } + + public double aoRadius() { + return aoRadius; + } + + public double shadow() { + return shadow; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Light.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Light.java new file mode 100644 index 0000000..44eeeac --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Light.java @@ -0,0 +1,108 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Light (LGHT chunk, FUN_0042c180; evaluated per object vertex by FUN_0041ba10, objects get the + * directional lights plus the nearest point lights, at most 6, FUN_0041bdb0): colour r, g, b + * (×2 / 255 in the engine, added to the zone's ambient colour); type 1 = directional (x, y, z = + * direction towards the light), types 2 and 4 = point lights at (x, y, z) in game space cells (z = + * grid row; the engine negates z for its render space) with full intensity up to radius {@code a} + * and a linear falloff to 0 at radius {@code b}; {@code c} = 1 scales the light ×1.5 / ×0.5 with + * the level's light state (DAT_005865d4 1 / 3). Type 4 is a negative light: colour −(1 + 2r/255)/2 per channel. + * Tiles are not lit by lights (baked vertex colours). Other types carry no data and are ignored. + */ +public final class Light extends Node { + public static final int DIRECTIONAL = 1; + public static final int POINT = 2; + public static final int POINT_BRIGHT = 4; + + int type = DIRECTIONAL; + int r = 128; + int g = 128; + int b = 128; + double x; + double y; + double z; + double a; + double b2; + int c; + + private static final List<Prop> PROPS = new Props<Light>() + .group("Light") + .integer("type", "Type (1 directional, 2/4 point)", n -> n.type, (n, v) -> n.type = v) + .integer("r", "Red", n -> n.r, (n, v) -> n.r = v) + .integer("g", "Green", n -> n.g, (n, v) -> n.g = v) + .integer("b", "Blue", n -> n.b, (n, v) -> n.b = v) + .group("Position / direction") + .dbl("x", "X", n -> n.x, (n, v) -> n.x = v) + .dbl("y", "Y", n -> n.y, (n, v) -> n.y = v) + .dbl("z", "Z", n -> n.z, (n, v) -> n.z = v) + .group("Range") + .dbl("a", "Range A", n -> n.a, (n, v) -> n.a = v) + .dbl("rangeB", "Range B", n -> n.b2, (n, v) -> n.b2 = v) + .integer("c", "Byte C", n -> n.c, (n, v) -> n.c = v) + .build(); + + public Light(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.LIGHT; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return (type == DIRECTIONAL + ? "directional light " + : type == POINT || type == POINT_BRIGHT ? "point light " : "light ") + + id(); + } + + public int type() { + return type; + } + + public int r() { + return r; + } + + public int g() { + return g; + } + + public int b() { + return b; + } + + public double x() { + return x; + } + + public double y() { + return y; + } + + public double z() { + return z; + } + + public double a() { + return a; + } + + public double rangeB() { + return b2; + } + + public int c() { + return c; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/MeshAsset.java b/src/main/java/com/eliasmueller/llamamod/editor/model/MeshAsset.java new file mode 100644 index 0000000..6adb176 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/MeshAsset.java @@ -0,0 +1,92 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Imported 3D model (OBJ + MTL or glTF 2.0) stored in the project's {@code assets/} folder. Placed + * with {@link BrushKind#MESH} brushes. {@code file} is the main file's asset path (e.g. + * "assets/rock/rock.glb"); referenced files (MTL, textures, .bin) live next to it. The parsed mesh + * is cached in {@link #mesh()} ({@code editor.io.MeshImport} fills it). + */ +public final class MeshAsset extends Node { + String name = ""; + String file = ""; + /** Source axis convention: "y" (glTF/OBJ default, y up) or "z" (z up, e.g. some CAD exports). */ + String upAxis = "y"; + + double unitScale = 1.0; + transient MeshData mesh; + + private static final List<Prop> PROPS = new Props<MeshAsset>() + .group("Mesh") + .str("name", "Name", n -> n.name, (n, v) -> n.name = v) + .p( + "file", + "File", + PropType.STRING, + n -> n.file, + (n, v) -> { + n.file = v; + n.mesh = null; + }, + Prop.Builder::readOnly) + .p( + "upAxis", + "Up axis", + PropType.CHOICE, + n -> n.upAxis, + (n, v) -> { + n.upAxis = v; + n.mesh = null; + }, + b -> b.choices(List.of("y", "z"))) + .dbl("unitScale", "Unit scale (file units → cells)", n -> n.unitScale, (n, v) -> { + n.unitScale = v; + n.mesh = null; + }) + .build(); + + public MeshAsset(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.MESH_ASSET; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return (name.isEmpty() ? file : name) + (mesh == null ? "" : " (" + mesh.triangleCount() + " triangles)"); + } + + public String name() { + return name; + } + + public String file() { + return file; + } + + public String upAxis() { + return upAxis; + } + + public double unitScale() { + return unitScale; + } + + /** Parsed mesh (game space, unplaced), or {@code null} until loaded. */ + public MeshData mesh() { + return mesh; + } + + public void setMesh(MeshData m) { + mesh = m; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/MeshData.java b/src/main/java/com/eliasmueller/llamamod/editor/model/MeshData.java new file mode 100644 index 0000000..69ebdff --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/MeshData.java @@ -0,0 +1,40 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * A triangle mesh in game space (x east, y up, z = grid row; unplaced, 1 unit = 1 cell). Triangles + * are wound so that (p1−p0)×(p2−p0) in export space (z mirrored) points outwards, which is the + * order {@code LevelBuilder.quad} expects. + * + * @param positions 3 doubles per vertex + * @param triangles 3 vertex indices per triangle + * @param uvs 6 doubles per triangle (u, v per corner; v = 0 at the top of the image), NaN = none + * @param materials material index per triangle + * @param colors per-vertex 0xRRGGBB colour or {@code null} + */ +public record MeshData( + double[] positions, int[] triangles, double[] uvs, int[] materials, int[] colors, List<Material> materialList) { + + /** + * @param image texture as 0xAARRGGBB, {@code null} = untextured (flat colour {@code rgb}) + */ + public record Material(String name, int rgb, int[] image, int width, int height, boolean doubleSided) {} + + public int vertexCount() { + return positions.length / 3; + } + + public int triangleCount() { + return triangles.length / 3; + } + + /** {x0, y0, z0, x1, y1, z1}. */ + public double[] bounds() { + double[] b = Bounds.empty(); + for (int i = 0; i < positions.length; i++) { + Bounds.grow(b, i % 3, positions[i]); + } + return b; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Node.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Node.java new file mode 100644 index 0000000..d7debcf --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Node.java @@ -0,0 +1,59 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Base of every scene element. Ids are stable: imported nodes get "kind:index" from their position + * in the original WAD, nodes created in the editor "kind:nN". All persistent state is exposed as + * {@link Prop}s. + */ +public abstract class Node { + private final String id; + Scene scene; + + protected Node(String id) { + this.id = id; + } + + public final String id() { + return id; + } + + public abstract NodeKind kind(); + + /** Property descriptors (same list for all nodes of a class, except brushes). */ + public abstract List<Prop> props(); + + /** Short human-readable name for outliners. */ + public String label() { + return id; + } + + /** The scene this node is part of, or {@code null} while detached. */ + public Scene scene() { + return scene; + } + + public final Prop prop(String name) { + for (Prop p : props()) { + if (p.name().equals(name)) { + return p; + } + } + throw new IllegalArgumentException(kind() + " has no property " + name); + } + + public final Object get(String name) { + return prop(name).get(this); + } + + /** {@link Prop#setRaw} by name: no undo, no events (import, loading, nodes not yet added). */ + public final void setRaw(String name, Object value) { + prop(name).setRaw(this, value); + } + + @Override + public String toString() { + return id; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/NodeKind.java b/src/main/java/com/eliasmueller/llamamod/editor/model/NodeKind.java new file mode 100644 index 0000000..45ad2d6 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/NodeKind.java @@ -0,0 +1,35 @@ +package com.eliasmueller.llamamod.editor.model; + +/** Node types; the id prefix is used for stable node ids ("tile:12", "strat:n3"). */ +public enum NodeKind { + SETTINGS("settings", "Level settings"), + REGIONS("regions", "Visibility regions"), + TILE_MODEL("model", "Tile model"), + TILE("tile", "Tile"), + STRAT("strat", "Object"), + PATH_NODE("wp", "Waypoint"), + ENV_ZONE("zone", "Zone"), + LIGHT("light", "Light"), + TEXTURE_PAGE("page", "Texture page"), + TEXTURE("tex", "Texture"), + TEXTURE_ANIM("anim", "Texture animation"), + BRUSH("brush", "Brush"), + MESH_ASSET("mesh", "Mesh asset"), + RAW_CHUNK("chunk", "Raw chunk"); + + private final String prefix; + private final String label; + + NodeKind(String prefix, String label) { + this.prefix = prefix; + this.label = label; + } + + public String prefix() { + return prefix; + } + + public String label() { + return label; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/NodeList.java b/src/main/java/com/eliasmueller/llamamod/editor/model/NodeList.java new file mode 100644 index 0000000..3c67564 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/NodeList.java @@ -0,0 +1,77 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.RandomAccess; + +/** + * Ordered node collection of a {@link Scene}. Read-only as a {@link List}; change it through the + * scene (events) or commands. The order is the order written to the WAD. + */ +public final class NodeList<T extends Node> extends AbstractList<T> implements RandomAccess { + private final NodeKind kind; + private final List<T> items = new ArrayList<>(); + private Map<Node, Integer> index; + + NodeList(NodeKind kind) { + this.kind = kind; + } + + public NodeKind kind() { + return kind; + } + + @Override + public T get(int i) { + return items.get(i); + } + + @Override + public int size() { + return items.size(); + } + + /** Position by identity (O(1) after the first call), -1 if absent. */ + @Override + public int indexOf(Object o) { + if (index == null) { + index = new IdentityHashMap<>(items.size() * 2); + for (int i = 0; i < items.size(); i++) { + index.put(items.get(i), i); + } + } + Integer i = index.get(o); + return i == null ? -1 : i; + } + + @Override + public boolean contains(Object o) { + return indexOf(o) >= 0; + } + + void insert(int i, T n) { + items.add(i, n); + index = null; + } + + void append(T n) { + items.add(n); + if (index != null) { + index.put(n, items.size() - 1); + } + } + + T delete(int i) { + T n = items.remove(i); + index = null; + return n; + } + + void clearAll() { + items.clear(); + index = null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/PathNode.java b/src/main/java/com/eliasmueller/llamamod/editor/model/PathNode.java new file mode 100644 index 0000000..225219d --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/PathNode.java @@ -0,0 +1,117 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Waypoint / path / camera node (MAP 34-byte record). Nodes form chains through {@code next} + * (written as the index of the next node, 0 = end of chain); strats point at the first node of + * their chain. Uses: patrol paths, camera start (first node of the llama's chain), checkpoint + * respawn points (one node per CheckPointPost), box areas (two nodes = corners). Position 20.12 + * fixed point in game space, angles 4096 = 360°. + */ +public final class PathNode extends Node { + PathNode next; + int nextRaw; + int unk1; + int rx; + int ry; + int rz; + int x; + int y; + int z; + int unk2; + int unk3; + + private static final List<Prop> PROPS = new Props<PathNode>() + .group("Transform") + .fixed("x", "X", n -> n.x, (n, v) -> n.x = v) + .fixed("y", "Y", n -> n.y, (n, v) -> n.y = v) + .fixed("z", "Z", n -> n.z, (n, v) -> n.z = v) + .angle("rx", "Rotation X", n -> n.rx, (n, v) -> n.rx = v) + .angle("ry", "Rotation Y", n -> n.ry, (n, v) -> n.ry = v) + .angle("rz", "Rotation Z", n -> n.rz, (n, v) -> n.rz = v) + .group("Chain") + .p("next", "Next node", PropType.REF, n -> n.next, (n, v) -> n.next = v, b -> b.ref(NodeKind.PATH_NODE) + .nullable()) + .group("Raw") + .integer("nextRaw", "Next index without node", n -> n.nextRaw, (n, v) -> n.nextRaw = v) + .integer("unk1", "Field +4", n -> n.unk1, (n, v) -> n.unk1 = v) + .integer("unk2", "Field +26", n -> n.unk2, (n, v) -> n.unk2 = v) + .integer("unk3", "Field +30", n -> n.unk3, (n, v) -> n.unk3 = v) + .build(); + + public PathNode(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.PATH_NODE; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return "waypoint " + id(); + } + + public PathNode next() { + return next; + } + + public int nextRaw() { + return nextRaw; + } + + public int unk1() { + return unk1; + } + + public int rx() { + return rx; + } + + public int ry() { + return ry; + } + + public int rz() { + return rz; + } + + public int x() { + return x; + } + + public int y() { + return y; + } + + public int z() { + return z; + } + + public double gx() { + return x / 4096.0; + } + + public double gy() { + return y / 4096.0; + } + + public double gz() { + return z / 4096.0; + } + + public int unk2() { + return unk2; + } + + public int unk3() { + return unk3; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Prop.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Prop.java new file mode 100644 index 0000000..1e0eb3b --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Prop.java @@ -0,0 +1,191 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * A typed, named property of a node. All persistent node state is reachable through props: the + * inspector edits them, {@link com.eliasmueller.llamamod.editor.model.cmd.SetProp} undoes them and + * the project format stores them. Array values are shared, not copied: never mutate an array + * returned by {@link #get}; set a new array instead. + */ +public final class Prop { + private final String name; + private final String label; + private final String group; + private final PropType type; + private final Function<Node, Object> getter; + private final BiConsumer<Node, Object> setter; + private final boolean readOnly; + private final boolean nullable; + private final boolean persistent; + private final NodeKind refKind; + private final List<String> choices; + private final String description; + + private Prop(Builder b) { + name = b.name; + label = b.label; + group = b.group; + type = b.type; + getter = b.getter; + setter = b.setter; + readOnly = b.readOnly; + nullable = b.nullable; + persistent = b.persistent; + refKind = b.refKind; + choices = List.copyOf(b.choices); + description = b.description; + } + + public String name() { + return name; + } + + public String label() { + return label; + } + + /** Inspector section, e.g. "Transform", "Type", "Fog". */ + public String group() { + return group; + } + + public PropType type() { + return type; + } + + /** Not editable in the UI (still set by import and project loading). */ + public boolean readOnly() { + return readOnly; + } + + public boolean nullable() { + return nullable; + } + + /** Stored in projects (false for derived values such as a strat's type name). */ + public boolean persistent() { + return persistent; + } + + public NodeKind refKind() { + return refKind; + } + + public List<String> choices() { + return choices; + } + + public String description() { + return description; + } + + public Object get(Node n) { + return getter.apply(n); + } + + /** + * Sets the value without undo or events (import/loading). Edits go through {@link + * Scene#set(Node, Prop, Object)} or a command. + */ + public void setRaw(Node n, Object v) { + if (setter == null) { + throw new UnsupportedOperationException("property " + name + " cannot be set"); + } + if (v == null && !nullable) { + throw new IllegalArgumentException("property " + name + " must not be null"); + } + if (v != null && !type.valueClass().isInstance(v)) { + throw new IllegalArgumentException( + "property " + name + " needs " + type.valueClass().getSimpleName() + ", not " + + v.getClass().getSimpleName()); + } + if (v != null && type == PropType.REF && refKind != null && ((Node) v).kind() != refKind) { + throw new IllegalArgumentException("property " + name + " needs a " + refKind + " node"); + } + if (v != null && type == PropType.CHOICE && !choices.contains(v)) { + throw new IllegalArgumentException("property " + name + ": '" + v + "' is not one of " + choices); + } + setter.accept(n, v); + } + + public boolean settable() { + return setter != null; + } + + @Override + public String toString() { + return name; + } + + @SuppressWarnings("unchecked") + public static <N extends Node> Builder of( + String name, String label, PropType type, Function<N, ?> get, BiConsumer<N, ?> set) { + Builder b = new Builder(); + b.name = name; + b.label = label; + b.type = type; + b.getter = n -> ((Function<N, Object>) get).apply((N) n); + b.setter = set == null ? null : (n, v) -> ((BiConsumer<N, Object>) set).accept((N) n, v); + return b; + } + + /** Fluent construction; finish with {@link #build()}. */ + public static final class Builder { + private String name; + private String label; + private String group = ""; + private PropType type; + private Function<Node, Object> getter; + private BiConsumer<Node, Object> setter; + private boolean readOnly; + private boolean nullable; + private boolean persistent = true; + private NodeKind refKind; + private final List<String> choices = new ArrayList<>(); + private String description = ""; + + public Builder group(String g) { + group = g; + return this; + } + + public Builder readOnly() { + readOnly = true; + return this; + } + + public Builder nullable() { + nullable = true; + return this; + } + + public Builder derived() { + persistent = false; + readOnly = true; + return this; + } + + public Builder ref(NodeKind k) { + refKind = k; + return this; + } + + public Builder choices(List<String> c) { + choices.addAll(c); + return this; + } + + public Builder desc(String d) { + description = d; + return this; + } + + public Prop build() { + return new Prop(this); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/PropType.java b/src/main/java/com/eliasmueller/llamamod/editor/model/PropType.java new file mode 100644 index 0000000..de6d8af --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/PropType.java @@ -0,0 +1,42 @@ +package com.eliasmueller.llamamod.editor.model; + +/** + * Semantics of a {@link Prop} value. The Java value class is fixed per type (see {@link + * #valueClass()}); the type tells the inspector how to show and edit it. + */ +public enum PropType { + /** Integer, shown decimal. */ + INT(Integer.class), + /** 20.12 fixed point integer (4096 = 1 cell); show as value / 4096. */ + FIXED(Integer.class), + /** Angle in 1/4096 turns (4096 = 360°); show in degrees. */ + ANGLE(Integer.class), + /** Bit flags, show hex. */ + FLAGS(Integer.class), + /** Colour 0x00BBGGRR as stored by the engine (byte order R, G, B, X). */ + COLOR(Integer.class), + DOUBLE(Double.class), + BOOL(Boolean.class), + STRING(String.class), + /** One of {@link Prop#choices()}. */ + CHOICE(String.class), + /** Reference to another node ({@link Prop#refKind()}); {@code null} if allowed. */ + REF(Node.class), + INT_ARRAY(int[].class), + DOUBLE_ARRAY(double[].class), + BYTES(byte[].class); + + private final Class<?> valueClass; + + PropType(Class<?> c) { + valueClass = c; + } + + public Class<?> valueClass() { + return valueClass; + } + + public boolean isArray() { + return this == INT_ARRAY || this == DOUBLE_ARRAY || this == BYTES; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Props.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Props.java new file mode 100644 index 0000000..088d4fa --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Props.java @@ -0,0 +1,74 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +/** Small helper to declare the prop lists of node classes; props get the current group. */ +final class Props<N extends Node> { + private final List<Prop> list = new ArrayList<>(); + private String group = ""; + + Props<N> group(String g) { + group = g; + return this; + } + + /** A prop with extra settings (read-only, nullable, ref kind, choices, description, …). */ + <T> Props<N> p( + String name, + String label, + PropType t, + Function<N, T> get, + BiConsumer<N, T> set, + UnaryOperator<Prop.Builder> mod) { + list.add(mod.apply(Prop.of(name, label, t, get, set).group(group)).build()); + return this; + } + + <T> Props<N> p(String name, String label, PropType t, Function<N, T> get, BiConsumer<N, T> set) { + return p(name, label, t, get, set, b -> b); + } + + Props<N> integer(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) { + return p(name, label, PropType.INT, get, set); + } + + Props<N> fixed(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) { + return p(name, label, PropType.FIXED, get, set); + } + + Props<N> angle(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) { + return p(name, label, PropType.ANGLE, get, set); + } + + Props<N> flags(String name, String label, Function<N, Integer> get, BiConsumer<N, Integer> set) { + return p(name, label, PropType.FLAGS, get, set); + } + + Props<N> dbl(String name, String label, Function<N, Double> get, BiConsumer<N, Double> set) { + return p(name, label, PropType.DOUBLE, get, set); + } + + Props<N> str(String name, String label, Function<N, String> get, BiConsumer<N, String> set) { + return p(name, label, PropType.STRING, get, set); + } + + Props<N> ints(String name, String label, Function<N, int[]> get, BiConsumer<N, int[]> set) { + return p(name, label, PropType.INT_ARRAY, get, set); + } + + Props<N> doubles(String name, String label, Function<N, double[]> get, BiConsumer<N, double[]> set) { + return p(name, label, PropType.DOUBLE_ARRAY, get, set); + } + + Props<N> bytes(String name, String label, Function<N, byte[]> get, BiConsumer<N, byte[]> set) { + return p(name, label, PropType.BYTES, get, set); + } + + List<Prop> build() { + return List.copyOf(list); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/RawChunk.java b/src/main/java/com/eliasmueller/llamamod/editor/model/RawChunk.java new file mode 100644 index 0000000..0de39ab --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/RawChunk.java @@ -0,0 +1,52 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * A WAD chunk the editor does not interpret (STPC strat code and object models, sound banks, LNFO, + * …), kept verbatim in its original position. Managed chunks (TRAK, MAP, TEXT, LGHT, NAME) appear + * in the list as placeholders with {@code data == null} so the chunk order is preserved. + */ +public final class RawChunk extends Node { + String tag = ""; + byte[] data; + + private static final List<Prop> PROPS = new Props<RawChunk>() + .group("Chunk") + .p("tag", "Tag", PropType.STRING, n -> n.tag, (n, v) -> n.tag = v, Prop.Builder::readOnly) + .p("data", "Data", PropType.BYTES, n -> n.data, (n, v) -> n.data = v, b -> b.nullable() + .readOnly()) + .build(); + + public RawChunk(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.RAW_CHUNK; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return tag.trim() + (data == null ? " (edited by the editor)" : " (" + data.length + " bytes)"); + } + + /** Four-character tag as in {@link com.eliasmueller.llamamod.format.Wad} ("MAP ", "STPC"). */ + public String tag() { + return tag; + } + + public byte[] data() { + return data; + } + + public boolean managed() { + return data == null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Regions.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Regions.java new file mode 100644 index 0000000..b52f222 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Regions.java @@ -0,0 +1,71 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Visibility regions (singleton node "regions:0"): every grid cell has a region 0..31; the camera's + * region selects a 32-bit mask of regions whose cells are drawn (MAP blocks 6/7, groove.exe + * FUN_0042bf40). Tiles in a cell whose region bit is not in the mask are invisible. The second + * value per region is read into DAT_00586424 (meaning unknown). + */ +public final class Regions extends Node { + public static final int COUNT = 32; + + int[] table = new int[2 * COUNT]; + int[] grid = new int[0]; + + private static final List<Prop> PROPS = new Props<Regions>() + .group("Regions") + .ints("table", "Region table (mask, value) × 32", n -> n.table, (n, v) -> n.table = v) + .ints("grid", "Region per cell (row = z)", n -> n.grid, (n, v) -> n.grid = v) + .build(); + + public Regions(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.REGIONS; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return "Visibility regions"; + } + + /** 64 ints: mask0, value0, mask1, value1, … */ + public int[] table() { + return table; + } + + public int[] grid() { + return grid; + } + + public int mask(int region) { + return table[2 * region]; + } + + public int value(int region) { + return table[2 * region + 1]; + } + + /** Region of cell (cx, cz) with the grid width w, or -1 outside. */ + public int regionAt(int cx, int cz, int w) { + int i = cx + cz * w; + return cx < 0 || cx >= w || i < 0 || i >= grid.length ? -1 : grid[i]; + } + + /** Whether cell (cx, cz) is drawn while the camera is in cell (camX, camZ). */ + public boolean visibleFrom(int camX, int camZ, int cx, int cz, int w) { + int rc = regionAt(camX, camZ, w); + int r = regionAt(cx, cz, w); + return rc >= 0 && r >= 0 && (mask(rc) & (1 << r)) != 0; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Scene.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Scene.java new file mode 100644 index 0000000..0758d32 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Scene.java @@ -0,0 +1,375 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A level as the editor sees it: every part of a level WAD the editor can touch, as nodes with + * stable ids, plus brushes and imported meshes that become tiles when the level is built ({@code + * editor.io.WadBuilder}). Pure data, not thread-safe; change it on one thread (the EDT) through + * {@link com.eliasmueller.llamamod.editor.model.cmd.History} commands to get undo and events. + * + * <p>Coordinates: tiles and tile models use export space (cells, z ≤ 0); strats, waypoints and + * zone targets use game space in 20.12 fixed point (z = −export z); brushes use game space in + * cells. + */ +public final class Scene { + private final LevelSettings settings = new LevelSettings("settings:0"); + private final Regions regions = new Regions("regions:0"); + private final Map<NodeKind, NodeList<?>> lists = new EnumMap<>(NodeKind.class); + private final NodeList<TileModel> tileModels = list(NodeKind.TILE_MODEL); + private final NodeList<Tile> tiles = list(NodeKind.TILE); + private final NodeList<Strat> strats = list(NodeKind.STRAT); + private final NodeList<PathNode> pathNodes = list(NodeKind.PATH_NODE); + private final NodeList<EnvZone> zones = list(NodeKind.ENV_ZONE); + private final NodeList<Light> lights = list(NodeKind.LIGHT); + private final NodeList<TexturePage> pages = list(NodeKind.TEXTURE_PAGE); + private final NodeList<TextureEntry> textures = list(NodeKind.TEXTURE); + private final NodeList<TextureAnim> textureAnims = list(NodeKind.TEXTURE_ANIM); + private final NodeList<Brush> brushes = list(NodeKind.BRUSH); + private final NodeList<MeshAsset> meshAssets = list(NodeKind.MESH_ASSET); + private final NodeList<RawChunk> chunks = list(NodeKind.RAW_CHUNK); + + private final Map<String, Node> byId = new HashMap<>(); + private final Map<String, byte[]> assets = new LinkedHashMap<>(); + private final List<SceneListener> listeners = new CopyOnWriteArrayList<>(); + private StratCatalog catalog = new StratCatalog(); + private int nextId = 1; + private int mute; + + public Scene() { + settings.scene = this; + regions.scene = this; + byId.put(settings.id(), settings); + byId.put(regions.id(), regions); + } + + private <T extends Node> NodeList<T> list(NodeKind k) { + NodeList<T> l = new NodeList<>(k); + lists.put(k, l); + return l; + } + + // ------------------------------------------------------------ collections + + public LevelSettings settings() { + return settings; + } + + public Regions regions() { + return regions; + } + + public NodeList<TileModel> tileModels() { + return tileModels; + } + + public NodeList<Tile> tiles() { + return tiles; + } + + /** + * The player start: the Llama, else the first object of category "player" (levels where the + * player rides or is carried: RollerCoaster, LlamaChase, KuscoAndPachaTogetherOnATree); null if none. + */ + public Strat playerStart() { + Strat other = null; + for (Strat st : strats) { + if ("Llama".equals(st.type())) { + return st; + } + if (other == null && "player".equals(StratCatalog.category(st.type()))) { + other = st; + } + } + return other; + } + + public NodeList<Strat> strats() { + return strats; + } + + public NodeList<PathNode> pathNodes() { + return pathNodes; + } + + public NodeList<EnvZone> zones() { + return zones; + } + + public NodeList<Light> lights() { + return lights; + } + + public NodeList<TexturePage> pages() { + return pages; + } + + /** Texture table; faces use the index in this list. */ + public NodeList<TextureEntry> textures() { + return textures; + } + + public NodeList<TextureAnim> textureAnims() { + return textureAnims; + } + + public NodeList<Brush> brushes() { + return brushes; + } + + public NodeList<MeshAsset> meshAssets() { + return meshAssets; + } + + /** WAD chunks in file order; managed chunks are placeholders ({@link RawChunk#managed()}). */ + public NodeList<RawChunk> chunks() { + return chunks; + } + + /** List holding nodes of a kind ({@code null} for the singletons SETTINGS and REGIONS). */ + @SuppressWarnings("unchecked") + public NodeList<Node> listOf(NodeKind k) { + return (NodeList<Node>) lists.get(k); + } + + /** Collections in a fixed order (project files and outliners). */ + public static final List<NodeKind> LIST_KINDS = List.of( + NodeKind.RAW_CHUNK, + NodeKind.TEXTURE_PAGE, + NodeKind.TEXTURE, + NodeKind.TEXTURE_ANIM, + NodeKind.TILE_MODEL, + NodeKind.TILE, + NodeKind.PATH_NODE, + NodeKind.STRAT, + NodeKind.ENV_ZONE, + NodeKind.LIGHT, + NodeKind.MESH_ASSET, + NodeKind.BRUSH); + + public Node node(String id) { + return byId.get(id); + } + + public boolean contains(Node n) { + return n != null && byId.get(n.id()) == n; + } + + /** Project assets (imported meshes, images): path inside the project zip → bytes. */ + public Map<String, byte[]> assets() { + return assets; + } + + public StratCatalog catalog() { + return catalog; + } + + public void setCatalog(StratCatalog c) { + catalog = Objects.requireNonNull(c); + for (Strat s : strats) { + s.setTypeName(c.nameOf(s.code())); + } + } + + // ------------------------------------------------------------ ids and creation + + /** Next id for a node created in the editor ("kind:nN"). */ + public String newId(NodeKind k) { + String id; + do { + id = k.prefix() + ":n" + nextId++; + } while (byId.containsKey(id)); + return id; + } + + public int nextIdCounter() { + return nextId; + } + + public void setNextIdCounter(int n) { + nextId = Math.max(1, n); + } + + /** New detached node of a list kind with a fresh id (add it with a command). */ + public Node create(NodeKind k) { + if (k == NodeKind.BRUSH) { + throw new IllegalArgumentException("use createBrush"); + } + return createWithId(k, newId(k), null); + } + + public Brush createBrush(BrushKind kind) { + return new Brush(newId(NodeKind.BRUSH), kind); + } + + /** Node with a given id (import and project loading). */ + public static Node createWithId(NodeKind k, String id, BrushKind brushKind) { + return switch (k) { + case TILE_MODEL -> new TileModel(id); + case TILE -> new Tile(id); + case STRAT -> new Strat(id); + case PATH_NODE -> new PathNode(id); + case ENV_ZONE -> new EnvZone(id); + case LIGHT -> new Light(id); + case TEXTURE_PAGE -> new TexturePage(id); + case TEXTURE -> new TextureEntry(id); + case TEXTURE_ANIM -> new TextureAnim(id); + case MESH_ASSET -> new MeshAsset(id); + case RAW_CHUNK -> new RawChunk(id); + case BRUSH -> new Brush(id, brushKind); + case SETTINGS, REGIONS -> throw new IllegalArgumentException(k + " is a singleton"); + }; + } + + // ------------------------------------------------------------ mutation (with events) + + /** Inserts a detached node at index (-1 = end) and fires ADDED. */ + public void add(Node n, int index) { + if (n.scene != null) { + throw new IllegalStateException(n.id() + " is already part of a scene"); + } + if (byId.containsKey(n.id())) { + throw new IllegalStateException("duplicate node id " + n.id()); + } + NodeList<Node> l = listOf(n.kind()); + int i = index < 0 || index > l.size() ? l.size() : index; + if (i == l.size()) { + l.append(n); + } else { + l.insert(i, n); + } + n.scene = this; + byId.put(n.id(), n); + if (n instanceof Strat s) { + s.setTypeName(catalog.nameOf(s.code())); + } + fire(SceneEvent.added(n, i)); + } + + /** Appends without an event (bulk import). */ + public void addQuiet(Node n) { + mute++; + try { + add(n, -1); + } finally { + mute--; + } + } + + /** Removes a node and fires REMOVED; returns its former index. References to it are not touched. */ + public int remove(Node n) { + NodeList<Node> l = listOf(n.kind()); + int i = l == null ? -1 : l.indexOf(n); + if (i < 0) { + throw new IllegalStateException(n.id() + " is not part of this scene"); + } + l.delete(i); + byId.remove(n.id()); + n.scene = null; + fire(SceneEvent.removed(n, i)); + return i; + } + + /** Sets a property and fires CHANGED (no undo; use {@code SetProp} for that). */ + public void set(Node n, Prop p, Object value) { + Object old = p.get(n); + p.setRaw(n, value); + if (n instanceof Strat s && p.name().equals("code")) { + s.setTypeName(catalog.nameOf(s.code())); + } + fire(SceneEvent.changed(n, p, old)); + } + + public void set(Node n, String prop, Object value) { + set(n, n.prop(prop), value); + } + + /** Removes all nodes of a list kind without events (templates, loading). */ + public void clearQuiet(NodeKind k) { + NodeList<Node> l = listOf(k); + for (Node n : l) { + byId.remove(n.id()); + n.scene = null; + } + l.clearAll(); + } + + /** Puts the nodes of a list kind into the given order (same set of nodes) and fires RELOADED. */ + public void reorder(NodeKind k, List<? extends Node> order) { + NodeList<Node> l = listOf(k); + if (order.size() != l.size() || !order.stream().allMatch(l::contains)) { + throw new IllegalArgumentException("reorder needs exactly the nodes of the list"); + } + List<Node> copy = new ArrayList<>(order); + l.clearAll(); + for (Node n : copy) { + l.append(n); + } + fire(SceneEvent.reloaded()); + } + + // ------------------------------------------------------------ queries + + /** Properties (of nodes in this scene) that reference {@code target}. */ + public List<Map.Entry<Node, Prop>> referencesTo(Node target) { + List<Map.Entry<Node, Prop>> out = new ArrayList<>(); + for (NodeKind k : LIST_KINDS) { + for (Node n : listOf(k)) { + for (Prop p : n.props()) { + if (p.type() == PropType.REF && p.get(n) == target) { + out.add(Map.entry(n, p)); + } + } + } + } + return out; + } + + /** Nodes of a chain from start, following {@code next} (stops at loops). */ + public List<PathNode> chain(PathNode start) { + List<PathNode> out = new ArrayList<>(); + Map<PathNode, Boolean> seen = new IdentityHashMap<>(); + for (PathNode p = start; p != null && seen.put(p, true) == null; p = p.next()) { + out.add(p); + } + return out; + } + + // ------------------------------------------------------------ events + + public void addListener(SceneListener l) { + listeners.add(l); + } + + public void removeListener(SceneListener l) { + listeners.remove(l); + } + + public void fire(SceneEvent e) { + if (mute > 0) { + return; + } + for (SceneListener l : listeners) { + l.sceneChanged(e); + } + } + + /** Runs r without events, then fires RELOADED (bulk changes). */ + public void bulk(Runnable r) { + mute++; + try { + r.run(); + } finally { + mute--; + } + fire(SceneEvent.reloaded()); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/SceneEvent.java b/src/main/java/com/eliasmueller/llamamod/editor/model/SceneEvent.java new file mode 100644 index 0000000..d6a8dc6 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/SceneEvent.java @@ -0,0 +1,30 @@ +package com.eliasmueller.llamamod.editor.model; + +/** + * Change notification. ADDED/REMOVED carry the list index; CHANGED the property and its previous + * value; RELOADED means "everything may have changed" (project loaded, bulk operation). + */ +public record SceneEvent(Type type, Node node, Prop prop, int index, Object oldValue) { + public enum Type { + ADDED, + REMOVED, + CHANGED, + RELOADED + } + + public static SceneEvent added(Node n, int i) { + return new SceneEvent(Type.ADDED, n, null, i, null); + } + + public static SceneEvent removed(Node n, int i) { + return new SceneEvent(Type.REMOVED, n, null, i, null); + } + + public static SceneEvent changed(Node n, Prop p, Object old) { + return new SceneEvent(Type.CHANGED, n, p, -1, old); + } + + public static SceneEvent reloaded() { + return new SceneEvent(Type.RELOADED, null, null, -1, null); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/SceneListener.java b/src/main/java/com/eliasmueller/llamamod/editor/model/SceneListener.java new file mode 100644 index 0000000..1797c11 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/SceneListener.java @@ -0,0 +1,7 @@ +package com.eliasmueller.llamamod.editor.model; + +/** Receives {@link SceneEvent}s on the thread that changed the scene (normally the Swing EDT). */ +@FunctionalInterface +public interface SceneListener { + void sceneChanged(SceneEvent e); +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Strat.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Strat.java new file mode 100644 index 0000000..90c90ed --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Strat.java @@ -0,0 +1,196 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Object instance ("strat", MAP 58-byte record). The type is the strat code offset in STPC + * ({@code code}); {@code type} is its name from the code. Position is 20.12 fixed point in game + * space (x east, y up, z = grid row, positive), angles 4096 = 360° (ry: 0 = facing +z, 1024 = +x). + * {@code nVars}, {@code slots}, {@code typeFlags}, {@code extra} are constant per type (copy them + * from {@link StratCatalog}). {@code params} are pushed on the strat's stack (MAP parameter pool). + */ +public final class Strat extends Node { + /** Flag bit: not spawned at level start (spawned by other strats). */ + public static final int FLAG_HIDDEN = 2; + + String type = ""; + int code; + int rx; + int ry; + int rz; + int x; + int y; + int z; + int[] params = new int[0]; + Integer paramIndex; + int nVars; + int slots; + int typeFlags; + int extra; + PathNode waypoint; + int waypointRaw = -1; + int zero; + int flags; + int last; + + private static final List<Prop> PROPS = new Props<Strat>() + .group("Type") + .p("type", "Type", PropType.STRING, n -> n.type, (n, v) -> n.type = v, Prop.Builder::derived) + .p( + "code", + "Code offset (STPC)", + PropType.FLAGS, + n -> n.code, + (n, v) -> n.code = v, + b -> b.desc("selects the type; set together with the type fields (StratCatalog)")) + .integer("nVars", "Variables", n -> n.nVars, (n, v) -> n.nVars = v) + .integer("slots", "Slots", n -> n.slots, (n, v) -> n.slots = v) + .flags("typeFlags", "Type flags", n -> n.typeFlags, (n, v) -> n.typeFlags = v) + .integer("extra", "Extra memory", n -> n.extra, (n, v) -> n.extra = v) + .group("Transform") + .fixed("x", "X", n -> n.x, (n, v) -> n.x = v) + .fixed("y", "Y", n -> n.y, (n, v) -> n.y = v) + .fixed("z", "Z", n -> n.z, (n, v) -> n.z = v) + .angle("rx", "Rotation X", n -> n.rx, (n, v) -> n.rx = v) + .angle("ry", "Rotation Y (yaw)", n -> n.ry, (n, v) -> n.ry = v) + .angle("rz", "Rotation Z", n -> n.rz, (n, v) -> n.rz = v) + .group("Behaviour") + .ints("params", "Parameters", n -> n.params, (n, v) -> n.params = v) + .p("waypoint", "Waypoint chain", PropType.REF, n -> n.waypoint, (n, v) -> n.waypoint = v, b -> b.ref( + NodeKind.PATH_NODE) + .nullable()) + .flags("flags", "Flags (2 = not spawned at start)", n -> n.flags, (n, v) -> n.flags = v) + .group("Raw") + .p( + "paramIndex", + "Original pool index", + PropType.INT, + n -> n.paramIndex, + (n, v) -> n.paramIndex = v, + b -> b.nullable().readOnly()) + .integer("waypointRaw", "Waypoint index without chain", n -> n.waypointRaw, (n, v) -> n.waypointRaw = v) + .integer("zero", "Field +50", n -> n.zero, (n, v) -> n.zero = v) + .integer("last", "Field +56", n -> n.last, (n, v) -> n.last = v) + .build(); + + public Strat(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.STRAT; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return (type.isEmpty() ? "?" : type) + " (" + id() + ")"; + } + + public String type() { + return type; + } + + /** Called when the code offset is resolved against a catalog (not undoable, derived). */ + public void setTypeName(String t) { + type = t == null ? "" : t; + } + + public int code() { + return code; + } + + public int rx() { + return rx; + } + + public int ry() { + return ry; + } + + public int rz() { + return rz; + } + + public int x() { + return x; + } + + public int y() { + return y; + } + + public int z() { + return z; + } + + public double gx() { + return x / 4096.0; + } + + public double gy() { + return y / 4096.0; + } + + public double gz() { + return z / 4096.0; + } + + /** Yaw in degrees (0 = facing +z, 90 = facing +x). */ + public double yawDegrees() { + return (ry & 0xfff) * 360.0 / 4096; + } + + public int[] params() { + return params; + } + + public Integer paramIndex() { + return paramIndex; + } + + public int nVars() { + return nVars; + } + + public int slots() { + return slots; + } + + public int typeFlags() { + return typeFlags; + } + + public int extra() { + return extra; + } + + public PathNode waypoint() { + return waypoint; + } + + public int waypointRaw() { + return waypointRaw; + } + + public int zero() { + return zero; + } + + public int flags() { + return flags; + } + + public int last() { + return last; + } + + public boolean hidden() { + return (flags & FLAG_HIDDEN) != 0; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/StratCatalog.java b/src/main/java/com/eliasmueller/llamamod/editor/model/StratCatalog.java new file mode 100644 index 0000000..2ad14f2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/StratCatalog.java @@ -0,0 +1,136 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * Object types of the base level (derived from its STPC code and MAP instances, not stored in + * projects). Only types with an original instance are placeable, because the per-type fields + * (variables, slots, flags, extra memory) come from the compiler and are copied from it. Each code + * entry of a type is a {@link Variant} (e.g. SmashPot has two spawn stubs). + */ +public final class StratCatalog { + /** + * Prototype of one code entry: type fields and default parameters of the first original + * instance, and its waypoint chain relative to the instance position. + * + * @param chain per node {dx, dy, dz, rx, ry, rz, unk1, unk2, unk3} (fixed point relative to the + * prototype position) + */ + public record Variant( + int code, + int nVars, + int slots, + int typeFlags, + int extra, + int flags, + int rx, + int ry, + int rz, + int[] params, + List<int[]> chain, + String sourceLevel) { + public int nParams() { + return params.length; + } + + public boolean hasWaypoints() { + return !chain.isEmpty(); + } + } + + public record Type(String name, List<Variant> variants) { + public Variant variant(int i) { + return variants.get(i); + } + } + + private final Map<String, Type> types = new TreeMap<>(); + private final Map<Integer, String> names = new LinkedHashMap<>(); + private final List<String> codeOnly = new ArrayList<>(); + + /** Adds a variant (types are created on first use; variants with a known code are ignored). */ + public void add(String name, Variant v) { + Type t = types.computeIfAbsent(name, n -> new Type(n, new ArrayList<>())); + if (t.variants.stream().noneMatch(x -> x.code == v.code)) { + t.variants.add(v); + } + names.putIfAbsent(v.code, name); + } + + /** A named strat entry of the code that has no instance (not placeable). */ + public void addCodeOnly(String name, int code) { + names.putIfAbsent(code, name); + if (!types.containsKey(name) && !codeOnly.contains(name)) { + codeOnly.add(name); + } + } + + public void addName(int code, String name) { + names.putIfAbsent(code, name); + } + + /** Placeable types, sorted by name. */ + public List<Type> types() { + return List.copyOf(types.values()); + } + + public Type type(String name) { + return types.get(name); + } + + public boolean has(String name) { + return types.containsKey(name); + } + + /** Type name for a code offset, or {@code null}. */ + public String nameOf(int code) { + return names.get(code); + } + + /** Every known code offset with its type name (read-only). */ + public Map<Integer, String> names() { + return Collections.unmodifiableMap(names); + } + + /** Strat names in the code without an instance to copy type fields from. */ + public List<String> codeOnly() { + return Collections.unmodifiableList(codeOnly); + } + + /** Debug-view category of a type name (same colours as mods.debug), for icons and filters. */ + public static String category(String name) { + if (name == null) { + return "other"; + } + if (name.equals("Llama") + || name.equals("KuscoAndPachaTogetherOnATree") + || name.equals("RollerCoaster") + || name.equals("LlamaChase")) { + return "player"; + } + if (name.contains("LevelDoor") || name.equals("VillageEndDoor") || name.equals("EndGameDoor")) { + return "exit"; + } + if (name.startsWith("CheckPoint")) { + return "checkpoint"; + } + if (name.startsWith("Camera") || name.startsWith("CutSceneCamera") || name.endsWith("Camera")) { + return "camera"; + } + if (name.contains("Trigger") || name.startsWith("Say")) { + return "trigger"; + } + if (name.startsWith("Coin") || name.equals("Secret") || name.startsWith("Health") || name.equals("RubyIdol")) { + return "collectible"; + } + if (name.startsWith("Force") || name.contains("Rebound") || name.contains("Current")) { + return "force"; + } + return "other"; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/TextureAnim.java b/src/main/java/com/eliasmueller/llamamod/editor/model/TextureAnim.java new file mode 100644 index 0000000..2fbec65 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/TextureAnim.java @@ -0,0 +1,82 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Texture animation (TEXT, FUN_00406e40): animates texture entry {@code texture}. {@code data} are + * the payload bytes by type: 1/2 = 3 bytes, 7/9 = u16, u16, u8, u8, 8 = u8, u8 n, u8, n × u16 + * texture index (flip-book), 10/11 = u8, u8, u8, u8 n, n × u16; other types have no payload. + */ +public final class TextureAnim extends Node { + int texture; + int type; + byte[] data = new byte[0]; + + private static final List<Prop> PROPS = new Props<TextureAnim>() + .group("Animation") + .integer("texture", "Texture index", n -> n.texture, (n, v) -> n.texture = v) + .integer("type", "Type", n -> n.type, (n, v) -> n.type = v) + .bytes("data", "Payload", n -> n.data, (n, v) -> n.data = v) + .build(); + + public TextureAnim(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.TEXTURE_ANIM; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return "animation type " + type + " of #" + texture; + } + + public int texture() { + return texture; + } + + public int type() { + return type; + } + + public byte[] data() { + return data; + } + + /** Payload length of a type, or -1 if it depends on a count byte. */ + public static int fixedPayload(int type) { + return switch (type) { + case 1, 2 -> 3; + case 7, 9 -> 6; + case 8, 10, 11 -> -1; + default -> 0; + }; + } + + /** Flip-book frames (types 8, 10, 11) as texture indices, else empty. */ + public int[] frames() { + int n; + int o; + if (type == 8 && data.length >= 3) { + n = data[1] & 0xff; + o = 3; + } else if ((type == 10 || type == 11) && data.length >= 4) { + n = data[3] & 0xff; + o = 4; + } else { + return new int[0]; + } + int[] f = new int[n]; + for (int i = 0; i < n && o + 2 * i + 1 < data.length; i++) { + f[i] = (data[o + 2 * i] & 0xff) | (data[o + 2 * i + 1] & 0xff) << 8; + } + return f; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/TextureEntry.java b/src/main/java/com/eliasmueller/llamamod/editor/model/TextureEntry.java new file mode 100644 index 0000000..d185e75 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/TextureEntry.java @@ -0,0 +1,114 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.List; + +/** + * Texture table entry (TEXT, 8 bytes): a rectangle u0..u1 × v0..v1 (inclusive) on page {@code page} + * (−1 = flat colour: u0, u1, v0 = r, g, b, v1 = alpha). Faces reference entries by their index in + * {@link Scene#textures()}, so entries are only ever appended. Flags: bit 0 flat colour, bit 2 + * one-pixel border, bits 12-14 blend mode (1 = alpha from v1 for flat colours). The engine has no + * free UVs: each triangle shows one half of the rectangle ({@link + * com.eliasmueller.llamamod.format.Geometry#faceUvs}). + */ +public final class TextureEntry extends Node { + public static final int FLAT = 0x1; + + int flags; + int page; + int page2 = -1; + int u0; + int u1; + int v0; + int v1; + String name = ""; + + private static final List<Prop> PROPS = new Props<TextureEntry>() + .group("Texture") + .str("name", "Name", n -> n.name, (n, v) -> n.name = v) + .flags("flags", "Flags", n -> n.flags, (n, v) -> n.flags = v) + .integer("page", "Page (−1 = flat colour)", n -> n.page, (n, v) -> n.page = v) + .integer("page2", "Second page / CLUT", n -> n.page2, (n, v) -> n.page2 = v) + .group("Rectangle") + .integer("u0", "U0 (red)", n -> n.u0, (n, v) -> n.u0 = v) + .integer("u1", "U1 (green, inclusive)", n -> n.u1, (n, v) -> n.u1 = v) + .integer("v0", "V0 (blue)", n -> n.v0, (n, v) -> n.v0 = v) + .integer("v1", "V1 (alpha, inclusive)", n -> n.v1, (n, v) -> n.v1 = v) + .build(); + + public TextureEntry(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.TEXTURE; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + Scene s = scene(); + String idx = s == null ? id() : "#" + s.textures().indexOf(this); + return (name.isEmpty() ? "texture " : name + " ") + idx; + } + + public String name() { + return name; + } + + public int flags() { + return flags; + } + + public int page() { + return page; + } + + public int page2() { + return page2; + } + + public int u0() { + return u0; + } + + public int u1() { + return u1; + } + + public int v0() { + return v0; + } + + public int v1() { + return v1; + } + + /** Width in texels (u0..u1 inclusive). */ + public int width() { + return u1 - u0 + 1; + } + + /** Height in texels (v0..v1 inclusive). */ + public int height() { + return v1 - v0 + 1; + } + + public boolean isFlat() { + return page < 0 || (flags & FLAT) != 0; + } + + /** Flat colour as 0xRRGGBB. */ + public int flatRgb() { + return u0 << 16 | u1 << 8 | v0; + } + + /** Same as {@link com.eliasmueller.llamamod.format.TextChunk.TextureEntry} for format code. */ + public com.eliasmueller.llamamod.format.TextChunk.TextureEntry toFormat() { + return new com.eliasmueller.llamamod.format.TextChunk.TextureEntry(flags, page, page2, u0, u1, v0, v1); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/TexturePage.java b/src/main/java/com/eliasmueller/llamamod/editor/model/TexturePage.java new file mode 100644 index 0000000..77d80b3 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/TexturePage.java @@ -0,0 +1,101 @@ +package com.eliasmueller.llamamod.editor.model; + +import com.eliasmueller.llamamod.format.TextChunk; +import java.util.List; + +/** + * Texture page (TEXT): w × h pixels (all originals 256 × 256), row-major. flags & 0x80 = stored + * RLE-compressed; flags & 0x7f = format/blend class (1 opaque, 4/7 alpha, 5 blend 3, …); flags 0 = + * 8-bit palettised (pixels are indices). Otherwise pixels are ABGR1555 (bits 0-4 R, 15 = STP; + * 0x0000 transparent), see {@link TextChunk#abgr1555ToRgba}. + */ +public final class TexturePage extends Node { + int flags = 0x81; + int width = 256; + int height = 256; + int[] pixels = new int[256 * 256]; + String name = ""; + /** Encoded page as read (cache for byte-exact rebuilds), dropped when pixels or flags change. */ + byte[] encoded; + + private static final List<Prop> PROPS = new Props<TexturePage>() + .group("Page") + .str("name", "Name", n -> n.name, (n, v) -> n.name = v) + .flags("flags", "Flags (0x80 RLE, 0x7f format)", n -> n.flags, (n, v) -> { + n.flags = v; + n.encoded = null; + }) + .p("width", "Width", PropType.INT, n -> n.width, (n, v) -> n.width = v, Prop.Builder::readOnly) + .p("height", "Height", PropType.INT, n -> n.height, (n, v) -> n.height = v, Prop.Builder::readOnly) + .ints("pixels", "Pixels (ABGR1555)", n -> n.pixels, (n, v) -> { + n.pixels = v; + n.encoded = null; + }) + .build(); + + public TexturePage(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.TEXTURE_PAGE; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return (name.isEmpty() ? "page " + id() : name) + " (" + width + "×" + height + ")"; + } + + public String name() { + return name; + } + + public int flags() { + return flags; + } + + public int width() { + return width; + } + + public int height() { + return height; + } + + public int[] pixels() { + return pixels; + } + + public byte[] encoded() { + return encoded; + } + + public void setEncodedCache(byte[] e) { + encoded = e; + } + + /** Pixel as 0xRRGGBBAA (palettised pages: grey from the index). */ + public int rgba(int px, int py) { + int v = pixels[py * width + px]; + if (flags == 0) { + return v << 24 | v << 16 | v << 8 | 0xff; + } + return TextChunk.abgr1555ToRgba(v); + } + + /** Whole page as 0xAARRGGBB for {@code BufferedImage.TYPE_INT_ARGB}. */ + public int[] argb() { + int[] out = new int[pixels.length]; + for (int i = 0; i < out.length; i++) { + int c = rgba(i % width, i / width); + out[i] = (c & 0xff) << 24 | c >>> 8; + } + return out; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/Tile.java b/src/main/java/com/eliasmueller/llamamod/editor/model/Tile.java new file mode 100644 index 0000000..16c0e9e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/Tile.java @@ -0,0 +1,232 @@ +package com.eliasmueller.llamamod.editor.model; + +import com.eliasmueller.llamamod.format.Geometry; +import com.eliasmueller.llamamod.format.PyMath; +import java.util.List; + +/** + * Placed tile (MAP): a {@link TileModel} at (x, y, z) in export space (cells; z ≤ 0, game z = −z) + * turned by {@code rot}/4096 turns about y. The tile belongs to grid cell (floor(x), floor(−z)); the + * engine tests collision only against the tiles of the player's cell. + * + * <p>Vertex colours: {@code colors} holds frames × vertexCount(model) RGBA words (0x80 = 1.0, the + * engine doubles them); byte 3 of the first word is the frame count (0/1 = one frame). A tile with + * a swap (WFPC 0x10000, e.g. doors: stMapreplace) has {@code swapCell} {cellX, y, cellZ}, the swap + * models and {@code altColors} for {@code swapAltModel}. + */ +public final class Tile extends Node { + public static final int GREY = 0x00808080; + + double x; + double y; + double z; + int rot; + TileModel model; + int alt; + int[] fixed; + int[] colors = new int[0]; + int[] swapCell; + TileModel swapModel; + TileModel swapAltModel; + int[] altColors; + boolean excluded; + + private static final List<Prop> PROPS = new Props<Tile>() + .group("Transform") + .dbl("x", "X (cells)", n -> n.x, (n, v) -> { + n.x = v; + n.fixed = null; + }) + .dbl("y", "Y (height)", n -> n.y, (n, v) -> { + n.y = v; + n.fixed = null; + }) + .dbl("z", "Z (cells, ≤ 0)", n -> n.z, (n, v) -> { + n.z = v; + n.fixed = null; + }) + .angle("rot", "Rotation", n -> n.rot, (n, v) -> { + n.rot = v; + n.fixed = null; + }) + .group("Model") + .p("model", "Model", PropType.REF, n -> n.model, (n, v) -> n.model = v, b -> b.ref(NodeKind.TILE_MODEL)) + .group("Swap") + .p( + "alt", + "Swap flag", + PropType.INT, + n -> n.alt, + (n, v) -> n.alt = v, + b -> b.desc("≠ 0: the tile has a swap entry (WFPC 0x10000)")) + .p( + "swapCell", + "Swap cell (x, y, z)", + PropType.INT_ARRAY, + n -> n.swapCell, + (n, v) -> n.swapCell = v, + b -> b.nullable()) + .p("swapModel", "Swap model", PropType.REF, n -> n.swapModel, (n, v) -> n.swapModel = v, b -> b.ref( + NodeKind.TILE_MODEL) + .nullable()) + .p( + "swapAltModel", + "Alternative model", + PropType.REF, + n -> n.swapAltModel, + (n, v) -> n.swapAltModel = v, + b -> b.ref(NodeKind.TILE_MODEL).nullable()) + .group("Build") + .p( + "excluded", + "Exclude from build", + PropType.BOOL, + n -> n.excluded, + (n, v) -> n.excluded = v, + b -> b.desc( + "the tile stays in the project but is left out of the built level (Hide only affects the" + + " editor view)")) + .group("Colours") + .ints("colors", "Vertex colours (frames × vertices)", n -> n.colors, (n, v) -> n.colors = v) + .p( + "altColors", + "Alternative model colours", + PropType.INT_ARRAY, + n -> n.altColors, + (n, v) -> n.altColors = v, + Prop.Builder::nullable) + .group("Raw") + .p( + "fixed", + "Fixed-point placement (rx ry rz x y z)", + PropType.INT_ARRAY, + n -> n.fixed, + (n, v) -> n.fixed = v, + b -> b.nullable() + .readOnly() + .desc("only kept when it differs from the derived value; cleared on every move")) + .build(); + + public Tile(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.TILE; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + return "tile " + cellX() + "," + cellZ() + " " + (model == null ? "?" : model.id()); + } + + /** True when the tile is kept in the project but not written to the built level. */ + public boolean excluded() { + return excluded; + } + + public double x() { + return x; + } + + public double y() { + return y; + } + + public double z() { + return z; + } + + public int rot() { + return rot; + } + + public TileModel model() { + return model; + } + + public int alt() { + return alt; + } + + public int[] fixed() { + return fixed; + } + + public int[] colors() { + return colors; + } + + public int[] swapCell() { + return swapCell; + } + + public TileModel swapModel() { + return swapModel; + } + + public TileModel swapAltModel() { + return swapAltModel; + } + + public int[] altColors() { + return altColors; + } + + public boolean hasSwap() { + return swapCell != null; + } + + /** + * Whether a swap model has collision of this material: door objects (RubyIdolDoor & co., ops + * 0x11a/0x11b) swap the tile when they open, and in many originals only the open model holds + * the exit floor (material 16) of the LevelDoor's cell. + */ + public boolean swapHasMaterial(int material) { + return hasSwap() + && (swapModel != null && swapModel.hasCollisionMaterial(material) + || swapAltModel != null && swapAltModel.hasCollisionMaterial(material)); + } + + /** Grid column, like MAP's cell lists (floor(x)). */ + public int cellX() { + return (int) Math.floor(x); + } + + /** Grid row (floor(−z)). */ + public int cellZ() { + return (int) Math.floor(-z); + } + + /** Fixed-point placement the engine uses: stored value or derived from x, y, z, rot. */ + public int[] effectiveFixed() { + return fixed != null ? fixed : deriveFixed(x, y, z, rot); + } + + public static int[] deriveFixed(double x, double y, double z, int rot) { + return new int[] { + 0, + rot, + 0, + Math.toIntExact(PyMath.round(x * 4096)), + Math.toIntExact(PyMath.round(y * 4096)), + Math.toIntExact(PyMath.round(-z * 4096)) + }; + } + + /** Collision polygons of the model in world export space (none without a model). */ + public List<TileModel.PlacedPolygon> worldCollisionPolygons() { + return model == null ? List.of() : model.collisionPolygons(transform()); + } + + /** Local (export space) vertex → world (export space). */ + public Geometry.TileXform transform() { + return Geometry.TileXform.of(x, y, z, Integer.toUnsignedLong(rot)); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/TileModel.java b/src/main/java/com/eliasmueller/llamamod/editor/model/TileModel.java new file mode 100644 index 0000000..3db98a7 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/TileModel.java @@ -0,0 +1,209 @@ +package com.eliasmueller.llamamod.editor.model; + +import com.eliasmueller.llamamod.format.Collision; +import com.eliasmueller.llamamod.format.Geometry; +import java.util.ArrayList; +import java.util.List; + +/** + * TRAK tile model in local export space (y up, z mirrored relative to game space; the tile's + * placement adds the cell position). Packed arrays: + * + * <ul> + * <li>{@code vertices}: 6 doubles per vertex (x, y, z, nx, ny, nz), all float32 values + * <li>{@code faces}: 6 ints per triangle (flags, v0, v1, v2, texture index, pad) + * <li>{@code planes}: 4 doubles per triangle (nx, ny, nz, d), used by the engine for culling + * <li>{@code collision0..2}: 32-byte collision records in game space ({@link Collision}); the + * engine always uses all three groups together + * </ul> + * + * {@code header} holds the stored bounding box (0x6c bytes); it is cleared when the vertices change + * and then recomputed by the build. + */ +public final class TileModel extends Node { + public static final int VERTEX_STRIDE = 6; + public static final int FACE_STRIDE = 6; + public static final int PLANE_STRIDE = 4; + public static final int COLLISION_SIZE = 32; + + double[] vertices = new double[0]; + int[] faces = new int[0]; + double[] planes = new double[0]; + byte[] collision0 = new byte[0]; + byte[] collision1 = new byte[0]; + byte[] collision2 = new byte[0]; + byte[] header; + String name = ""; + + private static final List<Prop> PROPS = new Props<TileModel>() + .group("Model") + .str("name", "Name", n -> n.name, (n, v) -> n.name = v) + .group("Geometry") + .doubles("vertices", "Vertices (x y z nx ny nz)", n -> n.vertices, (n, v) -> { + n.vertices = v; + n.header = null; + }) + .ints("faces", "Faces (flags v0 v1 v2 tex pad)", n -> n.faces, (n, v) -> n.faces = v) + .doubles("planes", "Face planes (nx ny nz d)", n -> n.planes, (n, v) -> n.planes = v) + .group("Collision") + .bytes("collision0", "Collision group A", n -> n.collision0, (n, v) -> n.collision0 = v) + .bytes("collision1", "Collision group B", n -> n.collision1, (n, v) -> n.collision1 = v) + .bytes("collision2", "Collision group C", n -> n.collision2, (n, v) -> n.collision2 = v) + .group("Raw") + .p("header", "Bounding box header", PropType.BYTES, n -> n.header, (n, v) -> n.header = v, b -> b.nullable() + .readOnly()) + .build(); + + public TileModel(String id) { + super(id); + } + + @Override + public NodeKind kind() { + return NodeKind.TILE_MODEL; + } + + @Override + public List<Prop> props() { + return PROPS; + } + + @Override + public String label() { + String n = name.isEmpty() ? id() : name; + return n + " (" + vertexCount() + " v, " + faceCount() + " f, " + collisionCount() + " c)"; + } + + public String name() { + return name; + } + + public double[] vertices() { + return vertices; + } + + public int[] faces() { + return faces; + } + + public double[] planes() { + return planes; + } + + public byte[] collision(int group) { + return switch (group) { + case 0 -> collision0; + case 1 -> collision1; + case 2 -> collision2; + default -> throw new IndexOutOfBoundsException(group); + }; + } + + public static String collisionProp(int group) { + return "collision" + group; + } + + public byte[] header() { + return header; + } + + public int vertexCount() { + return vertices.length / VERTEX_STRIDE; + } + + public int faceCount() { + return faces.length / FACE_STRIDE; + } + + public int collisionCount(int group) { + return collision(group).length / COLLISION_SIZE; + } + + public int collisionCount() { + return collisionCount(0) + collisionCount(1) + collisionCount(2); + } + + public double vx(int v) { + return vertices[v * VERTEX_STRIDE]; + } + + public double vy(int v) { + return vertices[v * VERTEX_STRIDE + 1]; + } + + public double vz(int v) { + return vertices[v * VERTEX_STRIDE + 2]; + } + + public int faceFlags(int f) { + return faces[f * FACE_STRIDE]; + } + + public int faceVertex(int f, int k) { + return faces[f * FACE_STRIDE + 1 + k]; + } + + public int faceTexture(int f) { + return faces[f * FACE_STRIDE + 4]; + } + + /** 32-byte record k of a group (copy). */ + public byte[] collisionRecord(int group, int k) { + byte[] g = collision(group); + byte[] r = new byte[COLLISION_SIZE]; + System.arraycopy(g, k * COLLISION_SIZE, r, 0, COLLISION_SIZE); + return r; + } + + /** Decoded collision polygon in local export space (planes; corners via {@link Collision#polygon}). */ + public Collision.Decoded collisionPolygon(int group, int k) { + return Collision.decode(collisionRecord(group, k)); + } + + /** A collision polygon placed by a tile transform: corners and face normal in world export space. */ + public record PlacedPolygon(int group, int index, int material, double[][] corners, double[] normal) {} + + /** Collision polygons with at least 3 corners, placed by xf (see {@link Tile#worldCollisionPolygons}). */ + public List<PlacedPolygon> collisionPolygons(Geometry.TileXform xf) { + List<PlacedPolygon> out = new ArrayList<>(); + for (int g = 0; g < 3; g++) { + for (int k = 0; k < collisionCount(g); k++) { + Collision.Decoded d = collisionPolygon(g, k); + double[][] local = Collision.polygon(d); + if (local.length < 3) { + continue; + } + double[][] w = new double[local.length][]; + for (int i = 0; i < local.length; i++) { + w[i] = xf.point(local[i][0], local[i][1], local[i][2]); + } + double[] n = xf.normal(d.plane()[0], d.plane()[1], d.plane()[2]); + out.add(new PlacedPolygon(g, k, d.mat(), w, n)); + } + } + return out; + } + + public int collisionMaterial(int group, int k) { + return collision(group)[k * COLLISION_SIZE + 1] & 0xff; + } + + /** Whether any collision polygon has this material. */ + public boolean hasCollisionMaterial(int material) { + for (int g = 0; g < 3; g++) { + for (int k = 0; k < collisionCount(g); k++) { + if (collisionMaterial(g, k) == material) { + return true; + } + } + } + return false; + } + + /** Copy of a collision group with the material of record k replaced (for {@code SetProp}). */ + public byte[] withCollisionMaterial(int group, int k, int material) { + byte[] g = collision(group).clone(); + g[k * COLLISION_SIZE + 1] = (byte) material; + return g; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/AddNode.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/AddNode.java new file mode 100644 index 0000000..f373937 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/AddNode.java @@ -0,0 +1,38 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; + +/** Adds a detached node (from {@link Scene#create} / {@link Scene#createBrush}) at an index (-1 = end). */ +public final class AddNode implements Command { + private final Node node; + private final int index; + + public AddNode(Node node, int index) { + this.node = node; + this.index = index; + } + + public AddNode(Node node) { + this(node, -1); + } + + public Node node() { + return node; + } + + @Override + public String label() { + return "Add " + node.kind().label().toLowerCase(java.util.Locale.ROOT); + } + + @Override + public void apply(Scene s) { + s.add(node, index); + } + + @Override + public void revert(Scene s) { + s.remove(node); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Batch.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Batch.java new file mode 100644 index 0000000..244d5bc --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Batch.java @@ -0,0 +1,70 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import com.eliasmueller.llamamod.editor.model.Scene; +import java.util.ArrayList; +import java.util.List; + +/** Several commands as one undo step (reverted in reverse order). */ +public final class Batch implements Command { + private final String label; + private final List<Command> commands; + private final Object mergeKey; + + public Batch(String label, List<Command> commands) { + this(label, commands, null); + } + + /** With a merge key, a following batch of the same shape (same targets) merges into this one. */ + public Batch(String label, List<Command> commands, Object mergeKey) { + this.label = label; + this.commands = List.copyOf(commands); + this.mergeKey = mergeKey; + } + + public List<Command> commands() { + return commands; + } + + @Override + public String label() { + return label; + } + + @Override + public void apply(Scene s) { + for (Command c : commands) { + c.apply(s); + } + } + + @Override + public void revert(Scene s) { + for (int i = commands.size() - 1; i >= 0; i--) { + commands.get(i).revert(s); + } + } + + @Override + public Object mergeKey() { + return mergeKey; + } + + @Override + public Command mergeWith(Command next) { + if (!(next instanceof Batch b) + || mergeKey == null + || !mergeKey.equals(b.mergeKey) + || b.commands.size() != commands.size()) { + return null; + } + List<Command> out = new ArrayList<>(); + for (int i = 0; i < commands.size(); i++) { + Command m = commands.get(i).mergeWith(b.commands.get(i)); + if (m == null) { + return null; + } + out.add(m); + } + return new Batch(label, out, mergeKey); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Command.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Command.java new file mode 100644 index 0000000..57487b2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Command.java @@ -0,0 +1,25 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import com.eliasmueller.llamamod.editor.model.Scene; + +/** An undoable change of a {@link Scene}. Commands fire the scene's events through {@link Scene}. */ +public interface Command { + String label(); + + void apply(Scene s); + + void revert(Scene s); + + /** + * Key for merging consecutive commands (e.g. one drag gesture); {@code null} = never merge. + * Commands with equal non-null keys are offered to {@link #mergeWith}. + */ + default Object mergeKey() { + return null; + } + + /** This command followed by {@code next} as one command, or {@code null} if not mergeable. */ + default Command mergeWith(Command next) { + return null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java new file mode 100644 index 0000000..8c5aa6a --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java @@ -0,0 +1,234 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.Prop; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.model.Tile; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Ready-made commands for common edits. Positions are in game space cells (x east, y up, z south), + * angles in degrees, unless noted. + */ +public final class Edits { + private Edits() {} + + public static int fixed(double cells) { + return (int) Math.round(cells * 4096); + } + + public static int angleUnits(double degrees) { + return (int) Math.round(((degrees % 360 + 360) % 360) / 360 * 4096) & 0xfff; + } + + /** Several property changes as one step; values map: node → (prop → value). */ + public static Command set(String label, Map<Node, Map<String, Object>> values, Object mergeKey) { + List<Command> l = flatten(values, mergeKey); + return l.size() == 1 && mergeKey == null ? l.getFirst() : new Batch(label, l, mergeKey); + } + + /** + * Moves nodes by (dx, dy, dz) cells in game space: strats, waypoints, tiles, brushes, zone + * targets, point lights. For a drag, pass the same mergeKey for every step of the gesture. + */ + public static Command translate( + Collection<? extends Node> nodes, double dx, double dy, double dz, Object mergeKey) { + Map<Node, Map<String, Object>> m = new IdentityHashMap<>(); + for (Node n : nodes) { + Map<String, Object> v = new LinkedHashMap<>(); + switch (n) { + case Strat s -> { + v.put("x", s.x() + fixed(dx)); + v.put("y", s.y() + fixed(dy)); + v.put("z", s.z() + fixed(dz)); + } + case PathNode p -> { + v.put("x", p.x() + fixed(dx)); + v.put("y", p.y() + fixed(dy)); + v.put("z", p.z() + fixed(dz)); + } + case Tile t -> { + v.put("x", (double) (float) (t.x() + dx)); + v.put("y", (double) (float) (t.y() + dy)); + v.put("z", (double) (float) (t.z() - dz)); + } + case Brush b -> v.putAll(b.translated(dx, dy, dz)); + case EnvZone z -> { + v.put("x", z.field(3) + fixed(dx)); + v.put("y", z.field(4) + fixed(dy)); + v.put("z", z.field(5) + fixed(dz)); + } + case Light l -> { + if (l.type() != Light.DIRECTIONAL) { + v.put("x", l.x() + dx); + v.put("y", l.y() + dy); + v.put("z", l.z() + dz); + } + } + default -> {} + } + if (!v.isEmpty()) { + m.put(n, v); + } + } + return new Batch("Move", flatten(m, mergeKey), mergeKey); + } + + private static List<Command> flatten(Map<Node, Map<String, Object>> m, Object mergeKey) { + List<Command> l = new ArrayList<>(); + m.forEach((n, vals) -> vals.forEach((p, v) -> l.add(SetProp.of(n, n.prop(p), v, mergeKey)))); + return l; + } + + /** Sets the yaw of a strat / waypoint (degrees, 0 = facing +z, 90 = +x) or a tile (quarter turns etc.). */ + public static Command rotateTo(Node n, double degrees, Object mergeKey) { + return SetProp.of(n, n.prop(n instanceof Tile ? "rot" : "ry"), angleUnits(degrees), mergeKey); + } + + /** + * Places a new object of a catalog type: type fields and default parameters from the + * prototype, its waypoint chain copied relative to the new position ("relative" mode of + * levelbuild, without snapping to the ground). + */ + public static Command placeStrat(Scene s, String type, int variant, double x, double y, double z, Double yawDeg) { + StratCatalog.Type t = s.catalog().type(type); + if (t == null) { + throw new IllegalArgumentException("object type '" + type + "' is not placeable in this level"); + } + StratCatalog.Variant v = t.variant(variant); + Strat st = (Strat) s.create(NodeKind.STRAT); + List<Command> cmds = new ArrayList<>(); + int fx = fixed(x); + int fy = fixed(y); + int fz = fixed(z); + List<PathNode> chain = new ArrayList<>(); + for (int[] c : v.chain()) { + PathNode p = (PathNode) s.create(NodeKind.PATH_NODE); + p.setRaw("x", fx + c[0]); + p.setRaw("y", fy + c[1]); + p.setRaw("z", fz + c[2]); + p.setRaw("rx", c[3]); + p.setRaw("ry", c[4]); + p.setRaw("rz", c[5]); + p.setRaw("unk1", c[6]); + p.setRaw("unk2", c[7]); + p.setRaw("unk3", c[8]); + if (!chain.isEmpty()) { + chain.getLast().setRaw("next", p); + } + chain.add(p); + cmds.add(new AddNode(p)); + } + st.setRaw("code", v.code()); + st.setRaw("nVars", v.nVars()); + st.setRaw("slots", v.slots()); + st.setRaw("typeFlags", v.typeFlags()); + st.setRaw("extra", v.extra()); + st.setRaw("flags", v.flags() & ~Strat.FLAG_HIDDEN); + st.setRaw("rx", v.rx()); + st.setRaw("ry", yawDeg == null ? v.ry() : angleUnits(yawDeg)); + st.setRaw("rz", v.rz()); + st.setRaw("x", fx); + st.setRaw("y", fy); + st.setRaw("z", fz); + st.setRaw("params", v.params().clone()); + st.setRaw("waypoint", chain.isEmpty() ? null : chain.getFirst()); + st.setTypeName(type); + cmds.add(new AddNode(st)); + return new Batch("Add " + type, cmds); + } + + /** + * Deletes nodes (and, for strats, the waypoint chains only they use). Tile models still used + * by tiles are refused. + */ + public static Command delete(Scene s, Collection<? extends Node> nodes) { + Set<Node> del = Collections.newSetFromMap(new IdentityHashMap<>()); + del.addAll(nodes); + for (Node n : nodes) { + if (n instanceof Strat st && st.waypoint() != null) { + List<PathNode> ch = s.chain(st.waypoint()); + boolean shared = s.strats().stream() + .anyMatch(o -> o != st + && !del.contains(o) + && o.waypoint() != null + && ch.contains(o.waypoint())) + || s.pathNodes().stream().anyMatch(p -> !ch.contains(p) && ch.contains(p.next())); + if (!shared) { + del.addAll(ch); + } + } + } + // users before the nodes they reference + List<Node> order = new ArrayList<>(del); + order.sort(Comparator.comparingInt(n -> + n instanceof Strat ? 0 : n instanceof Tile || n instanceof Brush ? 1 : n instanceof PathNode ? 2 : 3)); + return new DeferredRemove(s, order); + } + + /** Removes a set of nodes, computing reference unlinks at apply time (so earlier removals count). */ + private static final class DeferredRemove implements Command { + private final List<Node> nodes; + private final List<RemoveNode> done = new ArrayList<>(); + + DeferredRemove(Scene s, List<Node> nodes) { + this.nodes = nodes; + for (Node n : nodes) { + for (Map.Entry<Node, Prop> r : s.referencesTo(n)) { + if (!r.getValue().nullable() && !nodes.contains(r.getKey())) { + throw new IllegalStateException( + n.id() + " is still used by " + r.getKey().id()); + } + } + } + } + + @Override + public String label() { + return nodes.size() == 1 + ? "Delete " + nodes.getFirst().kind().label().toLowerCase(Locale.ROOT) + : "Delete " + nodes.size() + " items"; + } + + @Override + public void apply(Scene s) { + done.clear(); + for (Node n : nodes) { + RemoveNode r = new RemoveNode(s, n); + r.apply(s); + done.add(r); + } + } + + @Override + public void revert(Scene s) { + for (int i = done.size() - 1; i >= 0; i--) { + done.get(i).revert(s); + } + } + } + + /** New brush with parameters (missing ones keep their defaults). */ + public static Command addBrush(Scene s, BrushKind kind, Map<String, Object> params) { + Brush b = s.createBrush(kind); + params.forEach(b::setRaw); + return new AddNode(b); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/History.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/History.java new file mode 100644 index 0000000..9eb1338 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/History.java @@ -0,0 +1,176 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import com.eliasmueller.llamamod.editor.model.Scene; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Undo/redo stack of a scene. {@link #execute} applies a command; consecutive commands with equal + * {@link Command#mergeKey()} are merged into one step (drags). {@link #begin}/{@link #end} group + * everything executed in between into one {@link Batch}. + */ +public final class History { + private final Scene scene; + private final Deque<Command> undo = new ArrayDeque<>(); + private final Deque<Command> redo = new ArrayDeque<>(); + private final List<Runnable> listeners = new CopyOnWriteArrayList<>(); + private final int limit; + private List<Command> group; + private String groupLabel; + private int groupDepth; + private Command savedAt; + private boolean savedAtEmpty = true; + /** Stands for a saved state that is no longer on the stack (dropped by the limit or by clear). */ + private static final Command UNREACHABLE = new Batch("saved state", List.of()); + + public History(Scene scene) { + this(scene, 500); + } + + public History(Scene scene, int limit) { + this.scene = scene; + this.limit = limit; + } + + public Scene scene() { + return scene; + } + + public void execute(Command c) { + c.apply(scene); + if (group != null) { + group.add(c); + return; + } + push(c); + } + + private void push(Command c) { + redo.clear(); + Command top = undo.peek(); + if (top != null && c.mergeKey() != null && c.mergeKey().equals(top.mergeKey()) && top != savedAt) { + Command m = top.mergeWith(c); + if (m != null) { + undo.pop(); + undo.push(m); + changed(); + return; + } + } + undo.push(c); + while (undo.size() > limit) { + undo.removeLast(); + if (savedAtEmpty) { + savedAtEmpty = false; + savedAt = UNREACHABLE; + } + } + changed(); + } + + /** Starts a group; nested calls join the outer group. */ + public void begin(String label) { + if (groupDepth++ == 0) { + group = new ArrayList<>(); + groupLabel = label; + } + } + + public void end() { + if (groupDepth == 0) { + throw new IllegalStateException("end() without begin()"); + } + if (--groupDepth == 0) { + List<Command> g = group; + group = null; + if (!g.isEmpty()) { + push(g.size() == 1 ? g.getFirst() : new Batch(groupLabel, g)); + } + } + } + + public boolean canUndo() { + return !undo.isEmpty(); + } + + public boolean canRedo() { + return !redo.isEmpty(); + } + + public String undoLabel() { + return undo.isEmpty() ? "" : undo.peek().label(); + } + + public String redoLabel() { + return redo.isEmpty() ? "" : redo.peek().label(); + } + + /** Labels of the undo steps, most recent first. */ + public List<String> undoLabels() { + return undo.stream().map(Command::label).toList(); + } + + /** Labels of the redo steps, next first. */ + public List<String> redoLabels() { + return redo.stream().map(Command::label).toList(); + } + + public void undo() { + if (undo.isEmpty()) { + return; + } + Command c = undo.pop(); + c.revert(scene); + redo.push(c); + changed(); + } + + public void redo() { + if (redo.isEmpty()) { + return; + } + Command c = redo.pop(); + c.apply(scene); + undo.push(c); + changed(); + } + + /** Drops all steps. A dirty history stays dirty (the saved state can no longer be reached). */ + public void clear() { + boolean dirty = isDirty(); + undo.clear(); + redo.clear(); + savedAtEmpty = !dirty; + savedAt = dirty ? UNREACHABLE : null; + changed(); + } + + /** Remembers the current state as saved (see {@link #isDirty}). */ + public void markSaved() { + savedAt = undo.peek(); + savedAtEmpty = undo.isEmpty(); + changed(); + } + + public boolean isDirty() { + return savedAtEmpty ? !undo.isEmpty() : undo.peek() != savedAt; + } + + /** Called after every execute/undo/redo/save mark (update menus, title). */ + public void addListener(Runnable r) { + listeners.add(r); + } + + public void removeListener(Runnable r) { + listeners.remove(r); + } + + private void changed() { + for (Runnable r : listeners) { + r.run(); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/RemoveNode.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/RemoveNode.java new file mode 100644 index 0000000..b0b9a14 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/RemoveNode.java @@ -0,0 +1,55 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Prop; +import com.eliasmueller.llamamod.editor.model.Scene; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Removes a node. Optional references to it (a strat's waypoint, a node's next, a tile's swap + * model) are cleared and restored on undo; a required reference (a tile's model) refuses the + * removal with {@link IllegalStateException} when the command is created. + */ +public final class RemoveNode implements Command { + private final Node node; + private final List<SetProp> unlink = new ArrayList<>(); + private int index = -1; + + public RemoveNode(Scene s, Node node) { + this.node = node; + for (Map.Entry<Node, Prop> r : s.referencesTo(node)) { + if (!r.getValue().nullable()) { + throw new IllegalStateException(node.id() + " is still used by " + + r.getKey().id() + " (" + r.getValue().label() + ")"); + } + unlink.add(SetProp.of(r.getKey(), r.getValue(), null, null)); + } + } + + public Node node() { + return node; + } + + @Override + public String label() { + return "Delete " + node.kind().label().toLowerCase(java.util.Locale.ROOT); + } + + @Override + public void apply(Scene s) { + for (SetProp p : unlink) { + p.apply(s); + } + index = s.remove(node); + } + + @Override + public void revert(Scene s) { + s.add(node, index); + for (int i = unlink.size() - 1; i >= 0; i--) { + unlink.get(i).revert(s); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/SetProp.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/SetProp.java new file mode 100644 index 0000000..9fc99f7 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/SetProp.java @@ -0,0 +1,68 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Prop; +import com.eliasmueller.llamamod.editor.model.Scene; +import java.util.Objects; + +/** Sets one property. With a merge key, consecutive changes of the same node/property merge. */ +public final class SetProp implements Command { + private final Node node; + private final Prop prop; + private final Object oldValue; + private final Object newValue; + private final Object mergeKey; + + private SetProp(Node node, Prop prop, Object oldValue, Object newValue, Object mergeKey) { + this.node = node; + this.prop = prop; + this.oldValue = oldValue; + this.newValue = newValue; + this.mergeKey = mergeKey; + } + + public static SetProp of(Node n, String prop, Object value) { + return of(n, n.prop(prop), value, null); + } + + /** mergeKey: e.g. a drag gesture object, so all moves of one drag become one undo step. */ + public static SetProp of(Node n, Prop p, Object value, Object mergeKey) { + return new SetProp(n, p, Values.copy(p.get(n)), Values.copy(value), mergeKey); + } + + public Node node() { + return node; + } + + public Prop prop() { + return prop; + } + + @Override + public String label() { + return "Change " + prop.label(); + } + + @Override + public void apply(Scene s) { + s.set(node, prop, Values.copy(newValue)); + } + + @Override + public void revert(Scene s) { + s.set(node, prop, Values.copy(oldValue)); + } + + @Override + public Object mergeKey() { + return mergeKey == null ? null : java.util.List.of(mergeKey, node, prop.name()); + } + + @Override + public Command mergeWith(Command next) { + if (next instanceof SetProp o && o.node == node && o.prop == prop && Objects.equals(mergeKey, o.mergeKey)) { + return new SetProp(node, prop, oldValue, o.newValue, mergeKey); + } + return null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Values.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Values.java new file mode 100644 index 0000000..f207ecd --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Values.java @@ -0,0 +1,25 @@ +package com.eliasmueller.llamamod.editor.model.cmd; + +import java.util.Objects; + +/** Copy/compare helpers for property values (arrays are copied so commands own their values). */ +public final class Values { + private Values() {} + + public static Object copy(Object v) { + if (v instanceof int[] a) { + return a.clone(); + } + if (v instanceof double[] a) { + return a.clone(); + } + if (v instanceof byte[] a) { + return a.clone(); + } + return v; + } + + public static boolean same(Object a, Object b) { + return Objects.deepEquals(a, b); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Batch.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Batch.java new file mode 100644 index 0000000..479472b --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Batch.java @@ -0,0 +1,279 @@ +package com.eliasmueller.llamamod.editor.render; + +import static org.lwjgl.opengl.GL33C.*; + +import java.nio.ByteBuffer; +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.lwjgl.system.MemoryUtil; + +/** + * GPU buffers for a group of triangles (sorted into 6 ranges: blend class × double-sided) plus + * overlay lines and translucent overlay triangles. Built on the CPU with {@link Builder}. + */ +final class Batch { + static final int STRIDE = 36; + static final int LINE_STRIDE = 16; + static final int RANGES = 6; + + int vao; + int vbo; + int lineVao; + int lineVbo; + final int[] first = new int[RANGES]; + final int[] count = new int[RANGES]; + int lineVerts; + int fillVerts; + int triangles; + /** Local bounds of a single-instance batch (mesh + overlay), else unused. */ + float[] box; + + static int range(int blend, boolean doubleSided) { + return blend * 2 + (doubleSided ? 1 : 0); + } + + void delete() { + if (vao != 0) { + glDeleteVertexArrays(vao); + glDeleteBuffers(vbo); + } + if (lineVao != 0) { + glDeleteVertexArrays(lineVao); + glDeleteBuffers(lineVbo); + } + vao = vbo = lineVao = lineVbo = 0; + } + + boolean empty() { + return triangles == 0 && lineVerts == 0 && fillVerts == 0; + } + + void drawRange(int r) { + if (count[r] > 0) { + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, first[r], count[r]); + } + } + + void drawLines() { + if (lineVerts > 0) { + glBindVertexArray(lineVao); + glDrawArrays(GL_LINES, 0, lineVerts); + } + } + + void drawFill() { + if (fillVerts > 0) { + glBindVertexArray(lineVao); + glDrawArrays(GL_TRIANGLES, lineVerts, fillVerts); + } + } + + /** Growable direct buffer. */ + static final class Buf { + ByteBuffer b = MemoryUtil.memAlloc(1 << 14); + + void ensure(int n) { + if (b.remaining() < n) { + b = MemoryUtil.memRealloc(b, Math.max(b.capacity() * 2, b.position() + n)); + } + } + + void free() { + MemoryUtil.memFree(b); + } + } + + /** Collects instances (optionally transformed) and uploads them as one batch. */ + static final class Builder { + private final Buf[] tri = new Buf[RANGES]; + private final Buf lines = new Buf(); + private final Buf fill = new Buf(); + private final int whiteLayer; + private final int[] pageW; + private final float arraySize; + + Builder(int whiteLayer, int[] pageW, float arraySize) { + for (int i = 0; i < RANGES; i++) { + tri[i] = new Buf(); + } + this.whiteLayer = whiteLayer; + this.pageW = pageW; + this.arraySize = arraySize; + } + + /** Adds an instance; xform {@code null} keeps local coordinates. */ + void add(RenderInstance inst, Matrix4f xform, boolean withOverlay) { + RenderMesh m = inst.mesh; + int id = inst.id; + int[] vc = inst.vertexColors; + Vector3f p = new Vector3f(); + Vector3f n = new Vector3f(); + int nt = m.triangleCount(); + for (int t = 0; t < nt; t++) { + int fl = m.triFlags[t]; + Buf b = tri[range(m.blend[t], (fl & RenderMesh.DOUBLE_SIDED) != 0)]; + b.ensure(3 * STRIDE); + int tc = m.triColors[t]; + int page = m.pages[t]; + int layer = page < 0 || page >= pageW.length ? whiteLayer : page; + float uvScale = page < 0 || page >= pageW.length ? 0 : pageW[page] / arraySize; + for (int k = 0; k < 3; k++) { + int v = m.triangles[3 * t + k]; + p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]); + n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]); + if (xform != null) { + xform.transformPosition(p); + xform.transformDirection(n); + } + if (n.lengthSquared() > 0) { + n.normalize(); + } + ByteBuffer bb = b.b; + bb.putFloat(p.x).putFloat(p.y).putFloat(p.z); + bb.put(snorm(n.x)).put(snorm(n.y)).put(snorm(n.z)).put((byte) 0); + bb.putFloat(m.uvs[6 * t + 2 * k] * uvScale).putFloat(m.uvs[6 * t + 2 * k + 1] * uvScale); + int c = vc != null && (fl & RenderMesh.NO_VERTEX_COLOR) == 0 && v < vc.length + ? doubled(vc[v]) + : 0xffffffff; + bb.putInt(mul(c, tc)); + bb.putInt(id); + bb.putShort((short) layer).putShort((short) fl); + } + } + if (withOverlay && m.overlay != null) { + addOverlay(m.overlay, xform); + } + } + + void addOverlay(Overlay o, Matrix4f xform) { + Vector3f p = new Vector3f(); + float[] l = o.lines(); + int[] lc = o.lineColors(); + lines.ensure(o.lineCount() * 2 * LINE_STRIDE); + for (int i = 0; i < o.lineCount(); i++) { + for (int k = 0; k < 2; k++) { + p.set(l[6 * i + 3 * k], l[6 * i + 3 * k + 1], l[6 * i + 3 * k + 2]); + if (xform != null) { + xform.transformPosition(p); + } + lines.b.putFloat(p.x).putFloat(p.y).putFloat(p.z).putInt(rgbaToAbgr(lc[i])); + } + } + float[] tr = o.triangles(); + int[] tcol = o.triangleColors(); + fill.ensure(o.triangleCount() * 3 * LINE_STRIDE); + for (int i = 0; i < o.triangleCount(); i++) { + for (int k = 0; k < 3; k++) { + p.set(tr[9 * i + 3 * k], tr[9 * i + 3 * k + 1], tr[9 * i + 3 * k + 2]); + if (xform != null) { + xform.transformPosition(p); + } + fill.b.putFloat(p.x).putFloat(p.y).putFloat(p.z).putInt(rgbaToAbgr(tcol[i])); + } + } + } + + /** Uploads into target (existing buffers are replaced) and frees the CPU memory. */ + Batch upload(Batch target) { + Batch g = target != null ? target : new Batch(); + g.delete(); + int total = 0; + for (int r = 0; r < RANGES; r++) { + g.first[r] = total; + g.count[r] = tri[r].b.position() / STRIDE; + total += g.count[r]; + } + g.triangles = total / 3; + if (total > 0) { + ByteBuffer all = MemoryUtil.memAlloc(total * STRIDE); + for (Buf b : tri) { + b.b.flip(); + all.put(b.b); + } + all.flip(); + g.vao = glGenVertexArrays(); + g.vbo = glGenBuffers(); + glBindVertexArray(g.vao); + glBindBuffer(GL_ARRAY_BUFFER, g.vbo); + glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW); + MemoryUtil.memFree(all); + meshAttribs(); + } + g.lineVerts = lines.b.position() / LINE_STRIDE; + g.fillVerts = fill.b.position() / LINE_STRIDE; + if (g.lineVerts + g.fillVerts > 0) { + ByteBuffer all = MemoryUtil.memAlloc((g.lineVerts + g.fillVerts) * LINE_STRIDE); + all.put(lines.b.flip()).put(fill.b.flip()).flip(); + g.lineVao = glGenVertexArrays(); + g.lineVbo = glGenBuffers(); + glBindVertexArray(g.lineVao); + glBindBuffer(GL_ARRAY_BUFFER, g.lineVbo); + glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW); + MemoryUtil.memFree(all); + lineAttribs(); + } + glBindVertexArray(0); + free(); + return g; + } + + void free() { + for (Buf b : tri) { + b.free(); + } + lines.free(); + fill.free(); + } + } + + static void meshAttribs() { + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, false, STRIDE, 0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_BYTE, true, STRIDE, 12); + glEnableVertexAttribArray(2); + glVertexAttribPointer(2, 2, GL_FLOAT, false, STRIDE, 16); + glEnableVertexAttribArray(3); + glVertexAttribPointer(3, 4, GL_UNSIGNED_BYTE, true, STRIDE, 24); + glEnableVertexAttribArray(4); + glVertexAttribPointer(4, 4, GL_UNSIGNED_BYTE, true, STRIDE, 28); + glEnableVertexAttribArray(5); + glVertexAttribIPointer(5, 2, GL_SHORT, STRIDE, 32); + } + + static void lineAttribs() { + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, false, LINE_STRIDE, 0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 4, GL_UNSIGNED_BYTE, true, LINE_STRIDE, 12); + } + + static byte snorm(float f) { + return (byte) Math.round(Math.max(-1, Math.min(1, f)) * 127); + } + + /** Game vertex colour (0x80 = 1.0) to 0..255, doubled with clamp. */ + static int doubled(int rgba) { + int r = Math.min(255, ((rgba >>> 24) & 0xff) * 2); + int g = Math.min(255, ((rgba >>> 16) & 0xff) * 2); + int b = Math.min(255, ((rgba >>> 8) & 0xff) * 2); + return r << 24 | g << 16 | b << 8 | (rgba & 0xff); + } + + static int mul(int a, int b) { + if (b == 0xffffffff) { + return rgbaToAbgr(a); + } + int r = ((a >>> 24) & 0xff) * ((b >>> 24) & 0xff) / 255; + int g = ((a >>> 16) & 0xff) * ((b >>> 16) & 0xff) / 255; + int bl = ((a >>> 8) & 0xff) * ((b >>> 8) & 0xff) / 255; + int al = (a & 0xff) * (b & 0xff) / 255; + return rgbaToAbgr(r << 24 | g << 16 | bl << 8 | al); + } + + /** 0xRRGGBBAA to the little-endian int that stores bytes R, G, B, A in memory order. */ + static int rgbaToAbgr(int c) { + return Integer.reverseBytes(c); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Camera.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Camera.java new file mode 100644 index 0000000..fd9ac2d --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Camera.java @@ -0,0 +1,183 @@ +package com.eliasmueller.llamamod.editor.render; + +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.joml.Vector4f; + +/** + * Editor camera: position + yaw/pitch, perspective or orthographic. Units are level cells (export + * space). The view is <b>left-handed like the game's Direct3D renderer</b>: export coordinates are + * drawn mirrored (screen x flipped) compared to a plain OpenGL view, so the picture matches the + * game. Yaw and pitch equal the game camera's angles (0x5790a4/0x5790a0, 4096 = 2π): yaw 0 looks + * along export -z (= game +z), positive yaw turns <b>left</b> (towards +x); pitch positive looks + * up. Screen right = (-cos yaw, 0, -sin yaw) in export space (game: (-cos yaw, 0, sin yaw)). + */ +public final class Camera { + public enum View { + PERSPECTIVE, + TOP, + FRONT, + SIDE + } + + public final Vector3f position = new Vector3f(0, 5, 10); + public float yaw; + public float pitch = -0.4f; + public float fovY = (float) Math.toRadians(60); + public float near = 0.03f; + public float far = 600f; + public boolean ortho; + /** Visible half height in orthographic mode. */ + public float orthoSize = 10; + /** Distance to the orbit pivot (also used for pan speed and ortho framing). */ + public float distance = 10; + + public View view = View.PERSPECTIVE; + + private int width = 1; + private int height = 1; + + public void viewportSize(int w, int h) { + width = Math.max(1, w); + height = Math.max(1, h); + } + + public int width() { + return width; + } + + public int height() { + return height; + } + + public Vector3f forward() { + float cp = (float) Math.cos(pitch); + return new Vector3f((float) Math.sin(yaw) * cp, (float) Math.sin(pitch), -(float) Math.cos(yaw) * cp); + } + + /** World direction of screen right (horizontal). */ + public Vector3f right() { + return new Vector3f(-(float) Math.cos(yaw), 0, -(float) Math.sin(yaw)); + } + + /** World direction of screen up. */ + public Vector3f up() { + return forward().cross(right()).normalize(); + } + + public Vector3f pivot() { + return forward().mul(distance).add(position); + } + + public Matrix4f viewMatrix() { + Vector3f f = forward(); + // straight down: "screen up" is the yaw direction; straight up: its opposite (the same + // limit as with world up, so pitching through the threshold never flips the picture) + float sy = f.y < 0 ? 1 : -1; + Vector3f up = Math.abs(f.y) > 0.999f + ? new Vector3f(sy * (float) Math.sin(yaw), 0, -sy * (float) Math.cos(yaw)) + : new Vector3f(0, 1, 0); + Vector3f c = new Vector3f(position).add(f); + // scale(-1, 1, 1): left-handed (Direct3D) view, see the class comment; flips triangle winding + return new Matrix4f().scale(-1, 1, 1).lookAt(position, c, up); + } + + public Matrix4f projectionMatrix() { + float a = width / (float) height; + if (ortho) { + return new Matrix4f().ortho(-orthoSize * a, orthoSize * a, -orthoSize, orthoSize, -far, far); + } + return new Matrix4f().perspective(fovY, a, near, far); + } + + public Matrix4f viewProjection() { + return projectionMatrix().mul(viewMatrix()); + } + + /** Pick ray through a pixel (origin, direction) in world space; y grows downwards. */ + public Vector3f[] ray(double px, double py) { + Matrix4f inv = viewProjection().invert(); + float nx = (float) (2 * px / width - 1); + float ny = (float) (1 - 2 * py / height); + Vector4f a = new Vector4f(nx, ny, -1, 1).mul(inv); + Vector4f b = new Vector4f(nx, ny, 1, 1).mul(inv); + Vector3f o = new Vector3f(a.x / a.w, a.y / a.w, a.z / a.w); + Vector3f e = new Vector3f(b.x / b.w, b.y / b.w, b.z / b.w); + return new Vector3f[] {o, e.sub(o).normalize()}; + } + + /** World point to pixel coordinates {x, y, depth}; depth > 1 or < -1 = clipped. */ + public Vector3f project(Vector3f p) { + Vector4f v = new Vector4f(p, 1).mul(viewProjection()); + if (v.w <= 1e-6f && !ortho) { + return new Vector3f(Float.NaN, Float.NaN, 2); + } + return new Vector3f((v.x / v.w + 1) * 0.5f * width, (1 - v.y / v.w) * 0.5f * height, v.z / v.w); + } + + /** World units per pixel at a point (for constant screen-size handles). */ + public float pixelSize(Vector3f p) { + if (ortho) { + return 2 * orthoSize / height; + } + float d = Math.max(near, new Vector3f(p).sub(position).dot(forward())); + return (float) (2 * d * Math.tan(fovY / 2) / height); + } + + /** Places the camera so that the box fits the view, keeping the direction. */ + public void frame(float[] box) { + Vector3f c = new Vector3f((box[0] + box[3]) / 2, (box[1] + box[4]) / 2, (box[2] + box[5]) / 2); + Vector3f f = forward(), r = right(), u = up(); + float tanY = (float) Math.tan(fovY / 2), tanX = tanY * width / (float) height; + float d = 0.5f, half = 0.3f; + for (int k = 0; k < 8; k++) { + Vector3f p = new Vector3f(box[(k & 1) != 0 ? 3 : 0], box[(k & 2) != 0 ? 4 : 1], box[(k & 4) != 0 ? 5 : 2]) + .sub(c); + float x = Math.abs(p.dot(r)), y = Math.abs(p.dot(u)), z = p.dot(f); + d = Math.max(d, Math.max(y / tanY, x / tanX) - z); + half = Math.max(half, Math.max(y, x * height / (float) width)); + } + distance = d * 1.08f; + orthoSize = half * 1.08f; + position.set(f.mul(-distance).add(c)); + } + + /** Keeps the pivot and switches to a standard view (orthographic for top/front/side). */ + public void setView(View v) { + Vector3f p = pivot(); + view = v; + switch (v) { + case TOP -> { + // screen up = export +z (game -z): x to the right, grid rows (game z) downwards + yaw = (float) Math.PI; + pitch = (float) (-Math.PI / 2); + ortho = true; + } + case FRONT -> { + yaw = (float) Math.PI; + pitch = 0; + ortho = true; + } + case SIDE -> { + yaw = (float) (-Math.PI / 2); + pitch = 0; + ortho = true; + } + case PERSPECTIVE -> ortho = false; + } + if (ortho) { + orthoSize = Math.max(orthoSize, 0.5f); + } + position.set(forward().mul(-distance).add(p)); + } + + public void copyFrom(Camera o) { + position.set(o.position); + yaw = o.yaw; + pitch = o.pitch; + ortho = o.ortho; + orthoSize = o.orthoSize; + distance = o.distance; + view = o.view; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Culling.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Culling.java new file mode 100644 index 0000000..f863b26 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Culling.java @@ -0,0 +1,104 @@ +package com.eliasmueller.llamamod.editor.render; + +import org.joml.FrustumIntersection; +import org.joml.Matrix4f; +import org.joml.Matrix4fc; +import org.joml.Vector3f; + +/** + * Conservative view-frustum culling. Boxes are {minX, minY, minZ, maxX, maxY, maxZ}; an instance's + * box covers every triangle vertex <b>and</b> its helper overlay (collision lines/fills), so nothing + * that is drawn with an instance can be culled with it. The planes come from the current + * projection × view (the left-handed view only swaps the left/right planes, the test is + * orientation-independent). + */ +final class Culling { + /** Absolute + relative box growth, so faces exactly on a plane survive float rounding. */ + static final float MARGIN = 0.01f; + + static final float REL_MARGIN = 0.002f; + + /** -Dllamamod.editor.noCull=true draws everything (diagnostics: culled and unculled frames must match). */ + static final boolean DISABLED = Boolean.getBoolean("llamamod.editor.noCull"); + + private final FrustumIntersection fr; + + Culling(Matrix4fc viewProjection) { + fr = new FrustumIntersection(viewProjection); + } + + /** False only when the box lies completely outside one frustum plane. */ + boolean visible(float[] b) { + if (b == null || b[0] > b[3]) { + return false; + } + if (DISABLED) { + return true; + } + float m = MARGIN + REL_MARGIN * Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2])); + return fr.testAab(b[0] - m, b[1] - m, b[2] - m, b[3] + m, b[4] + m, b[5] + m); + } + + /** Local bounds of a mesh including its overlay; an empty box (min > max) when there is nothing. */ + static float[] localBox(RenderMesh m) { + float[] b = emptyBox(); + float[] p = m.positions; + for (int i = 0; i + 2 < p.length; i += 3) { + grow(b, p[i], p[i + 1], p[i + 2]); + } + Overlay o = m.overlay; + if (o != null) { + float[] l = o.lines(); + for (int i = 0; i < o.lineCount() * 6; i += 3) { + grow(b, l[i], l[i + 1], l[i + 2]); + } + float[] t = o.triangles(); + for (int i = 0; i < o.triangleCount() * 9; i += 3) { + grow(b, t[i], t[i + 1], t[i + 2]); + } + } + return b; + } + + /** World bounds of an instance (mesh + overlay, all 8 corners transformed). */ + static float[] worldBox(RenderInstance i) { + float[] l = localBox(i.mesh); + return l[0] > l[3] ? l : transform(l, i.transform); + } + + static float[] transform(float[] b, Matrix4f m) { + float[] o = emptyBox(); + Vector3f p = new Vector3f(); + for (int k = 0; k < 8; k++) { + p.set(b[(k & 1) != 0 ? 3 : 0], b[(k & 2) != 0 ? 4 : 1], b[(k & 4) != 0 ? 5 : 2]); + m.transformPosition(p); + grow(o, p.x, p.y, p.z); + } + return o; + } + + static float[] emptyBox() { + return new float[] { + Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE + }; + } + + static void union(float[] b, float[] o) { + if (o[0] > o[3]) { + return; + } + for (int k = 0; k < 3; k++) { + b[k] = Math.min(b[k], o[k]); + b[k + 3] = Math.max(b[k + 3], o[k + 3]); + } + } + + static void grow(float[] b, float x, float y, float z) { + b[0] = Math.min(b[0], x); + b[1] = Math.min(b[1], y); + b[2] = Math.min(b[2], z); + b[3] = Math.max(b[3], x); + b[4] = Math.max(b[4], y); + b[5] = Math.max(b[5], z); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Gizmo.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Gizmo.java new file mode 100644 index 0000000..d16e21a --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Gizmo.java @@ -0,0 +1,584 @@ +package com.eliasmueller.llamamod.editor.render; + +import org.joml.Matrix4f; +import org.joml.Vector2f; +import org.joml.Vector3f; + +/** + * Transform gizmo (translate / rotate / scale with axis and plane constraints). Pure math: the + * viewport draws {@link #build} and feeds mouse input; drags produce {@link GizmoEvent}s with the + * total delta since the drag started. The gizmo never changes the scene itself – listeners apply the + * delta (e.g. as an undoable command) and call {@link #setTarget} to move the gizmo along. + */ +public final class Gizmo { + public enum Mode { + TRANSLATE, + ROTATE, + SCALE + } + + public enum Space { + WORLD, + LOCAL + } + + public enum Handle { + NONE, + X, + Y, + Z, + XY, + YZ, + XZ, + CENTER; + + /** Axis index 0..2 for X/Y/Z, the plane normal for XY/YZ/XZ, -1 otherwise. */ + int axis() { + return switch (this) { + case X, YZ -> 0; + case Y, XZ -> 1; + case Z, XY -> 2; + default -> -1; + }; + } + + boolean plane() { + return this == XY || this == YZ || this == XZ; + } + } + + /** Snapping steps; a step ≤ 0 disables that kind. Holding Ctrl while dragging inverts {@link #enabled}. */ + public record Snap(boolean enabled, float translate, float rotateDegrees, float scale) { + public static final Snap DEFAULT = new Snap(false, 0.25f, 15f, 0.1f); + } + + private static final int[] AXIS_COLORS = { + Legend.rgb(232, 64, 64), Legend.rgb(120, 205, 40), Legend.rgb(60, 120, 245) + }; + private static final int ACTIVE = Legend.rgb(255, 222, 60); + private static final float HIT_PX = 9; + private static final Handle[] AXIS_HANDLES = {Handle.X, Handle.Y, Handle.Z}; + /** Translate plane handles by normal axis. */ + private static final Handle[] PLANE_HANDLES = {Handle.YZ, Handle.XZ, Handle.XY}; + + private Mode mode = Mode.TRANSLATE; + private Space space = Space.WORLD; + private boolean visible; + private final Matrix4f target = new Matrix4f(); + private Snap snap = Snap.DEFAULT; + /** Handle length in pixels. */ + public float sizePx = 95; + + private Handle hover = Handle.NONE; + private Handle active = Handle.NONE; + private final Vector3f dragPivot = new Vector3f(); + private final Vector3f[] dragAxes = {new Vector3f(), new Vector3f(), new Vector3f()}; + private final Matrix4f dragStart = new Matrix4f(); + private final Vector3f dragHit = new Vector3f(); + private float dragT0; + private float lastAngle; + private float totalAngle; + private final Vector2f dragMouse = new Vector2f(); + private GizmoEvent last; + + public Mode mode() { + return mode; + } + + public void setMode(Mode m) { + mode = m; + } + + public Space space() { + return space; + } + + public void setSpace(Space s) { + space = s; + } + + public boolean visible() { + return visible; + } + + public void setVisible(boolean v) { + visible = v; + if (!v) { + hover = Handle.NONE; + } + } + + public Matrix4f target() { + return new Matrix4f(target); + } + + /** Pivot (translation) and orientation (rotation part, used in local space) of the gizmo. */ + public void setTarget(Matrix4f m) { + target.set(m); + } + + public Snap snap() { + return snap; + } + + public void setSnap(Snap s) { + snap = s; + } + + public boolean dragging() { + return active != Handle.NONE; + } + + public Handle hover() { + return hover; + } + + Vector3f pivot() { + return target.getTranslation(new Vector3f()); + } + + Vector3f[] axes() { + Vector3f[] a = {new Vector3f(1, 0, 0), new Vector3f(0, 1, 0), new Vector3f(0, 0, 1)}; + if (space == Space.LOCAL || mode == Mode.SCALE) { + for (int i = 0; i < 3; i++) { + target.getColumn(i, a[i]); + if (a[i].lengthSquared() < 1e-12f) { + a[i].set(i == 0 ? 1 : 0, i == 1 ? 1 : 0, i == 2 ? 1 : 0); + } + a[i].normalize(); + } + } + return a; + } + + private float length(Camera cam) { + return sizePx * cam.pixelSize(pivot()); + } + + // ------------------------------------------------------------------ hit testing + + /** Handle under the pixel, updating the hover state. */ + public Handle hitTest(Camera cam, float mx, float my) { + hover = visible && !dragging() ? pick(cam, mx, my) : hover; + return hover; + } + + private Handle pick(Camera cam, float mx, float my) { + Vector3f p = pivot(); + Vector3f[] ax = axes(); + float len = length(cam); + Vector3f pp = cam.project(p); + if (Float.isNaN(pp.x) || pp.z > 1 || pp.z < -1) { + return Handle.NONE; + } + Vector2f m = new Vector2f(mx, my); + if (mode != Mode.ROTATE && m.distance(pp.x, pp.y) < HIT_PX + 2) { + return Handle.CENTER; + } + if (mode == Mode.TRANSLATE) { + for (int n = 0; n < 3; n++) { + if (inQuad(cam, planeQuad(p, ax, n, len), m)) { + return PLANE_HANDLES[n]; + } + } + } + Handle best = Handle.NONE; + float bestD = HIT_PX; + for (int i = 0; i < 3; i++) { + float d; + if (mode == Mode.ROTATE) { + d = ringDistance(cam, p, ax[i], len, m); + } else { + Vector3f a = cam.project(new Vector3f(ax[i]).mul(0.2f * len).add(p)); + Vector3f b = cam.project(new Vector3f(ax[i]).mul(len).add(p)); + d = segDist(m, a, b); + } + if (d < bestD) { + bestD = d; + best = AXIS_HANDLES[i]; + } + } + return best; + } + + /** Corners of the translate plane handle with normal axis n. */ + private static Vector3f[] planeQuad(Vector3f p, Vector3f[] ax, int n, float len) { + Vector3f a = ax[(n + 1) % 3], b = ax[(n + 2) % 3]; + return new Vector3f[] { + corner(p, a, b, 0.25f * len, 0.25f * len), + corner(p, a, b, 0.5f * len, 0.25f * len), + corner(p, a, b, 0.5f * len, 0.5f * len), + corner(p, a, b, 0.25f * len, 0.5f * len) + }; + } + + private static Vector3f corner(Vector3f p, Vector3f a, Vector3f b, float sa, float sb) { + return new Vector3f(a).mul(sa).add(new Vector3f(b).mul(sb)).add(p); + } + + /** Point at angle a on the circle of radius r about p spanned by u and v. */ + private static Vector3f circlePoint(Vector3f p, Vector3f u, Vector3f v, float r, double a) { + return new Vector3f(u) + .mul((float) Math.cos(a) * r) + .add(new Vector3f(v).mul((float) Math.sin(a) * r)) + .add(p); + } + + private static boolean inQuad(Camera cam, Vector3f[] q, Vector2f m) { + Vector3f[] s = new Vector3f[4]; + for (int i = 0; i < 4; i++) { + s[i] = cam.project(q[i]); + if (Float.isNaN(s[i].x)) { + return false; + } + } + int sign = 0; + for (int i = 0; i < 4; i++) { + Vector3f a = s[i], b = s[(i + 1) % 4]; + float c = (b.x - a.x) * (m.y - a.y) - (b.y - a.y) * (m.x - a.x); + int sg = c > 0 ? 1 : -1; + if (sign == 0) { + sign = sg; + } else if (sg != sign) { + return false; + } + } + return true; + } + + private static float segDist(Vector2f m, Vector3f a, Vector3f b) { + if (Float.isNaN(a.x) || Float.isNaN(b.x)) { + return Float.MAX_VALUE; + } + float dx = b.x - a.x, dy = b.y - a.y; + float l2 = dx * dx + dy * dy; + float t = l2 < 1e-6f ? 0 : Math.max(0, Math.min(1, ((m.x - a.x) * dx + (m.y - a.y) * dy) / l2)); + return m.distance(a.x + t * dx, a.y + t * dy); + } + + private static float ringDistance(Camera cam, Vector3f p, Vector3f axis, float r, Vector2f m) { + Vector3f u = perp(axis), v = new Vector3f(axis).cross(u); + float best = Float.MAX_VALUE; + Vector3f prev = null; + Vector3f toPivot = + cam.ortho ? cam.forward() : new Vector3f(p).sub(cam.position).normalize(); + for (int k = 0; k <= 64; k++) { + Vector3f w = circlePoint(p, u, v, r, k * Math.PI * 2 / 64); + boolean front = new Vector3f(w).sub(p).dot(toPivot) <= 0.05f * r; + Vector3f s = cam.project(w); + if (prev != null && front) { + best = Math.min(best, segDist(m, prev, s)); + } + prev = s; + } + return best; + } + + static Vector3f perp(Vector3f a) { + Vector3f t = Math.abs(a.y) < 0.9f ? new Vector3f(0, 1, 0) : new Vector3f(1, 0, 0); + return t.cross(a).normalize(); + } + + // ------------------------------------------------------------------ dragging + + /** Starts dragging the hovered handle; returns the START event or {@code null}. */ + public GizmoEvent begin(Camera cam, float mx, float my) { + Handle h = hitTest(cam, mx, my); + if (h == Handle.NONE) { + return null; + } + active = h; + dragPivot.set(pivot()); + Vector3f[] ax = axes(); + for (int i = 0; i < 3; i++) { + dragAxes[i].set(ax[i]); + } + dragStart.set(target); + dragMouse.set(mx, my); + totalAngle = 0; + Vector3f[] ray = cam.ray(mx, my); + if (mode == Mode.ROTATE) { + lastAngle = planeAngle(cam, ray, mx, my); + } else if (h.axis() >= 0 && !h.plane()) { + dragT0 = axisParam(ray, dragAxes[h.axis()]); + } else { + Vector3f hit = planeHit(cam, ray, h); + dragHit.set(hit != null ? hit : dragPivot); + } + last = event(GizmoEvent.Phase.START, new Vector3f(), 0, new Vector3f(1, 1, 1)); + return last; + } + + /** Continues the drag; invertSnap = Ctrl held. */ + public GizmoEvent drag(Camera cam, float mx, float my, boolean invertSnap) { + if (!dragging()) { + return null; + } + boolean sn = snap.enabled() != invertSnap; + Vector3f[] ray = cam.ray(mx, my); + Vector3f tr = new Vector3f(); + float angle = 0; + Vector3f sc = new Vector3f(1, 1, 1); + int ai = active.axis(); + switch (mode) { + case TRANSLATE -> { + if (ai >= 0 && !active.plane()) { + float t = axisParam(ray, dragAxes[ai]) - dragT0; + if (sn && snap.translate() > 0) { + t = Math.round(t / snap.translate()) * snap.translate(); + } + tr.set(dragAxes[ai]).mul(t); + } else { + Vector3f hit = planeHit(cam, ray, active); + if (hit != null) { + tr.set(hit).sub(dragHit); + if (sn && snap.translate() > 0) { + float s = snap.translate(); + tr.set(Math.round(tr.x / s) * s, Math.round(tr.y / s) * s, Math.round(tr.z / s) * s); + } + } + } + } + case ROTATE -> { + float a = planeAngle(cam, ray, mx, my); + float d = a - lastAngle; + if (d > Math.PI) { + d -= (float) (2 * Math.PI); + } else if (d < -Math.PI) { + d += (float) (2 * Math.PI); + } + totalAngle += d; + lastAngle = a; + angle = totalAngle; + if (sn && snap.rotateDegrees() > 0) { + float st = (float) Math.toRadians(snap.rotateDegrees()); + angle = Math.round(angle / st) * st; + } + } + case SCALE -> { + float f; + if (active == Handle.CENTER) { + Vector3f pp = cam.project(dragPivot); + float d0 = Math.max(4, dragMouse.distance(pp.x, pp.y)); + f = new Vector2f(mx, my).distance(pp.x, pp.y) / d0; + } else { + float t = axisParam(ray, dragAxes[ai]); + f = Math.abs(dragT0) < 1e-5f ? 1 : t / dragT0; + } + if (sn && snap.scale() > 0) { + f = 1 + Math.round((f - 1) / snap.scale()) * snap.scale(); + } + f = Math.max(0.01f, f); + if (active == Handle.CENTER) { + sc.set(f); + } else { + sc.setComponent(ai, f); + } + } + } + last = event(GizmoEvent.Phase.DRAG, tr, angle, sc); + return last; + } + + public GizmoEvent end() { + if (!dragging()) { + return null; + } + GizmoEvent e = last.withPhase(GizmoEvent.Phase.END); + active = Handle.NONE; + return e; + } + + public GizmoEvent cancel() { + if (!dragging()) { + return null; + } + GizmoEvent e = event(GizmoEvent.Phase.CANCEL, new Vector3f(), 0, new Vector3f(1, 1, 1)); + active = Handle.NONE; + return e; + } + + private GizmoEvent event(GizmoEvent.Phase ph, Vector3f tr, float angle, Vector3f sc) { + Vector3f axis = active.axis() >= 0 ? new Vector3f(dragAxes[active.axis()]) : new Vector3f(0, 1, 0); + return new GizmoEvent( + ph, mode, active, new Vector3f(dragPivot), tr, axis, angle, sc, new Matrix4f(dragStart), space); + } + + /** Parameter t of the point on the axis line through the pivot closest to the ray. */ + private float axisParam(Vector3f[] ray, Vector3f a) { + Vector3f o = ray[0], d = ray[1]; + Vector3f w = new Vector3f(dragPivot).sub(o); + float b = a.dot(d); + float den = 1 - b * b; + if (Math.abs(den) < 1e-5f) { + return dragT0; + } + return (b * d.dot(w) - a.dot(w)) / den; + } + + private Vector3f planeHit(Camera cam, Vector3f[] ray, Handle h) { + Vector3f n = h.plane() ? new Vector3f(dragAxes[h.axis()]) : cam.forward(); + if (mode == Mode.TRANSLATE && h.plane() && Math.abs(n.dot(ray[1])) < 0.02f) { + return null; + } + float den = n.dot(ray[1]); + if (Math.abs(den) < 1e-6f) { + return null; + } + float t = n.dot(new Vector3f(dragPivot).sub(ray[0])) / den; + return new Vector3f(ray[1]).mul(t).add(ray[0]); + } + + /** Angle of the mouse around the rotation axis (plane hit, or screen angle when edge-on). */ + private float planeAngle(Camera cam, Vector3f[] ray, float mx, float my) { + Vector3f axis = dragAxes[active.axis()]; + float den = axis.dot(ray[1]); + Vector3f pp = cam.project(dragPivot); + float screen = (float) Math.atan2(my - pp.y, mx - pp.x); + if (Math.abs(den) < 0.2f) { + // edge-on: screen-space angle, sign from which way the axis points at the viewer + // (the left-handed view mirrors screen angles) + return screen * Math.signum(axis.dot(cam.forward()) == 0 ? 1 : -axis.dot(cam.forward())); + } + float t = axis.dot(new Vector3f(dragPivot).sub(ray[0])) / den; + Vector3f hit = new Vector3f(ray[1]).mul(t).add(ray[0]).sub(dragPivot); + Vector3f u = perp(axis), v = new Vector3f(axis).cross(u); + return (float) Math.atan2(hit.dot(v), hit.dot(u)); + } + + // ------------------------------------------------------------------ geometry + + /** Handle geometry in world space for the current camera. */ + void build(Camera cam, Overlay lines, Overlay tris) { + if (!visible) { + return; + } + // the listener moves the target along while dragging; the axes stay as at drag start + Vector3f p = pivot(); + Vector3f[] ax = dragging() ? dragAxes : axes(); + float px = cam.pixelSize(p); + float len = sizePx * px; + Handle cur = dragging() ? active : hover; + for (int i = 0; i < 3; i++) { + int col = cur == AXIS_HANDLES[i] ? ACTIVE : AXIS_COLORS[i]; + if (dragging() + && active != AXIS_HANDLES[i] + && !(active.plane() && active.axis() != i) + && active != Handle.CENTER) { + col = Legend.withAlpha(col, 70); + } + Vector3f a = ax[i]; + if (mode == Mode.ROTATE) { + ring(cam, tris, p, a, len, 2.2f * px, col); + } else { + Vector3f s0 = new Vector3f(a).mul(0.2f * len).add(p); + Vector3f s1 = new Vector3f(a) + .mul(mode == Mode.SCALE ? 0.92f * len : 0.82f * len) + .add(p); + ribbon(cam, tris, s0, s1, 1.6f * px, col); + if (mode == Mode.TRANSLATE) { + cone(tris, s1, new Vector3f(a).mul(len).add(p), a, 0.065f * len, col); + } else { + cube(tris, new Vector3f(a).mul(len).add(p), 0.055f * len, ax, col); + } + } + } + if (mode == Mode.TRANSLATE) { + for (int n = 0; n < 3; n++) { + int col = cur == PLANE_HANDLES[n] ? ACTIVE : AXIS_COLORS[n]; + Vector3f[] q = planeQuad(p, ax, n, len); + int fill = Legend.withAlpha(col, cur == PLANE_HANDLES[n] ? 150 : 80); + tri(tris, q[0], q[1], q[2], fill); + tri(tris, q[0], q[2], q[3], fill); + for (int k = 0; k < 4; k++) { + line(lines, q[k], q[(k + 1) % 4], col); + } + } + } + if (mode != Mode.ROTATE) { + int col = cur == Handle.CENTER ? ACTIVE : Legend.rgb(235, 235, 235); + Vector3f r = cam.right().mul(5 * px), u = cam.up().mul(5 * px); + Vector3f c0 = new Vector3f(p).sub(r).sub(u), + c1 = new Vector3f(p).add(r).sub(u); + Vector3f c2 = new Vector3f(p).add(r).add(u), + c3 = new Vector3f(p).sub(r).add(u); + tri(tris, c0, c1, c2, col); + tri(tris, c0, c2, c3, col); + } + if (dragging() && mode == Mode.TRANSLATE && active.axis() >= 0 && !active.plane()) { + Vector3f a = dragAxes[active.axis()]; + line( + lines, + new Vector3f(a).mul(-1000).add(p), + new Vector3f(a).mul(1000).add(p), + Legend.withAlpha(AXIS_COLORS[active.axis()], 160)); + } + } + + private static void line(Overlay o, Vector3f a, Vector3f b, int col) { + o.line(a.x, a.y, a.z, b.x, b.y, b.z, col); + } + + private static void tri(Overlay o, Vector3f a, Vector3f b, Vector3f c, int col) { + o.triangle(new double[] {a.x, a.y, a.z}, new double[] {b.x, b.y, b.z}, new double[] {c.x, c.y, c.z}, col); + } + + /** Camera-facing quad strip for a thick segment. */ + private static void ribbon(Camera cam, Overlay o, Vector3f a, Vector3f b, float halfW, int col) { + Vector3f dir = new Vector3f(b).sub(a); + Vector3f view = cam.ortho ? cam.forward() : new Vector3f(a).sub(cam.position); + Vector3f side = dir.cross(view, new Vector3f()); + if (side.lengthSquared() < 1e-12f) { + side = perp(dir.normalize(new Vector3f())); + } + side.normalize().mul(halfW); + Vector3f a0 = new Vector3f(a).sub(side), a1 = new Vector3f(a).add(side); + Vector3f b0 = new Vector3f(b).sub(side), b1 = new Vector3f(b).add(side); + tri(o, a0, b0, b1, col); + tri(o, a0, b1, a1, col); + } + + private static void ring(Camera cam, Overlay o, Vector3f p, Vector3f axis, float r, float halfW, int col) { + Vector3f u = perp(axis), v = new Vector3f(axis).cross(u); + Vector3f prev = null; + Vector3f toPivot = + cam.ortho ? cam.forward() : new Vector3f(p).sub(cam.position).normalize(); + for (int k = 0; k <= 72; k++) { + Vector3f w = circlePoint(p, u, v, r, k * Math.PI * 2 / 72); + if (prev != null) { + Vector3f mid = new Vector3f(prev).add(w).mul(0.5f); + boolean front = new Vector3f(mid).sub(p).dot(toPivot) <= 0.05f * r; + ribbon(cam, o, prev, w, halfW, front ? col : Legend.withAlpha(col, 50)); + } + prev = w; + } + } + + private static void cone(Overlay o, Vector3f base, Vector3f tip, Vector3f axis, float r, int col) { + Vector3f u = perp(axis), v = new Vector3f(axis).cross(u); + int n = 14; + int shade = Legend.dim(col, 0.7); + for (int k = 0; k < n; k++) { + Vector3f q0 = circlePoint(base, u, v, r, k * Math.PI * 2 / n); + Vector3f q1 = circlePoint(base, u, v, r, (k + 1) * Math.PI * 2 / n); + tri(o, q0, q1, tip, k % 2 == 0 ? col : Legend.dim(col, 0.88)); + tri(o, q1, q0, base, shade); + } + } + + private static void cube(Overlay o, Vector3f c, float s, Vector3f[] ax, int col) { + Vector3f[] p = new Vector3f[8]; + for (int k = 0; k < 8; k++) { + p[k] = new Vector3f(c) + .add(new Vector3f(ax[0]).mul((k & 1) != 0 ? s : -s)) + .add(new Vector3f(ax[1]).mul((k & 2) != 0 ? s : -s)) + .add(new Vector3f(ax[2]).mul((k & 4) != 0 ? s : -s)); + } + int[][] f = {{0, 1, 3, 2}, {4, 6, 7, 5}, {0, 4, 5, 1}, {2, 3, 7, 6}, {0, 2, 6, 4}, {1, 5, 7, 3}}; + for (int i = 0; i < 6; i++) { + int cc = Legend.dim(col, 0.75 + 0.05 * i); + tri(o, p[f[i][0]], p[f[i][1]], p[f[i][2]], cc); + tri(o, p[f[i][0]], p[f[i][2]], p[f[i][3]], cc); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/GizmoEvent.java b/src/main/java/com/eliasmueller/llamamod/editor/render/GizmoEvent.java new file mode 100644 index 0000000..69c03a2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/GizmoEvent.java @@ -0,0 +1,58 @@ +package com.eliasmueller.llamamod.editor.render; + +import org.joml.Matrix4f; +import org.joml.Vector3f; + +/** + * Gizmo drag state. All deltas are totals since {@link Phase#START} (relative to {@link #start}), so + * a listener can show a live preview and emit a single undoable command at {@link Phase#END}; on + * {@link Phase#CANCEL} (Escape) it restores {@link #start}. + * + * @param pivot gizmo position at drag start (world) + * @param translation world-space offset (TRANSLATE) + * @param axis world-space rotation axis (ROTATE) or the dragged axis + * @param angle radians about axis, right-handed (ROTATE) + * @param scale factors along the target's local axes (SCALE) + * @param start target transform at drag start + */ +public record GizmoEvent( + Phase phase, + Gizmo.Mode mode, + Gizmo.Handle handle, + Vector3f pivot, + Vector3f translation, + Vector3f axis, + float angle, + Vector3f scale, + Matrix4f start, + Gizmo.Space space) { + public enum Phase { + START, + DRAG, + END, + CANCEL + } + + GizmoEvent withPhase(Phase p) { + return new GizmoEvent(p, mode, handle, pivot, translation, axis, angle, scale, start, space); + } + + /** The delta applied to a transform m (any object moved together with the gizmo target). */ + public Matrix4f applyTo(Matrix4f m) { + Matrix4f r = new Matrix4f(); + switch (mode) { + case TRANSLATE -> r.translation(translation).mul(m); + case ROTATE -> r.translation(pivot) + .rotate(angle, axis) + .translate(-pivot.x, -pivot.y, -pivot.z) + .mul(m); + case SCALE -> r.set(m).scale(scale); + } + return r; + } + + /** {@link #applyTo} for the gizmo target itself. */ + public Matrix4f result() { + return applyTo(start); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/GlRenderer.java b/src/main/java/com/eliasmueller/llamamod/editor/render/GlRenderer.java new file mode 100644 index 0000000..ccdb4b0 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/GlRenderer.java @@ -0,0 +1,1021 @@ +package com.eliasmueller.llamamod.editor.render; + +import static org.lwjgl.opengl.GL33C.*; + +import java.nio.ByteBuffer; +import java.nio.FloatBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; + +/** OpenGL 3.3 core renderer of a {@link RenderScene}. All methods need the GL context current. */ +final class GlRenderer { + static final float CHUNK = 8f; + + /** Per-frame statistics. */ + record Stats(int drawCalls, int triangles, int chunksDrawn, int chunksTotal, double cpuMs) {} + + private int meshProg; + private int lineProg; + private final Map<String, Integer> uniforms = new HashMap<>(); + private int texArray; + private int whiteLayer; + private int[] pageW = new int[0]; + private float arraySize = 256; + + private RenderScene scene; + private int sceneVersion = -1; + private int texVersion = -1; + + private final Map<Long, Chunk> chunks = new HashMap<>(); + private final Map<RenderInstance, Long> chunkOf = new IdentityHashMap<>(); + /** Instances drawn one by one (objects, previews, detached tiles) → their local-space batch. */ + private final Map<RenderInstance, Batch> singles = new IdentityHashMap<>(); + + private final Map<RenderMesh, Batch> meshBatches = new IdentityHashMap<>(); + private final Set<RenderInstance> detached = new HashSet<>(); + private Batch markerBatch; + private Set<String> markerLayers = Set.of(); + private final Map<String, Batch> overlayBatches = new HashMap<>(); + private int dynVao; + private int dynVbo; + private int dynCapacity; + + private int pickFbo; + private int pickColor; + private int pickDepth; + private int pickW; + private int pickH; + + private Stats stats = new Stats(0, 0, 0, 0, 0); + private int drawCalls; + private int triCount; + + private static final class Chunk { + final List<RenderInstance> members = new ArrayList<>(); + Batch batch; + boolean dirty = true; + /** World bounds of all members (triangles and collision overlays). */ + float[] box = Culling.emptyBox(); + } + + void init() { + meshProg = program(Shaders.MESH_VS, Shaders.MESH_FS); + lineProg = program(Shaders.LINE_VS, Shaders.LINE_FS); + dynVao = glGenVertexArrays(); + dynVbo = glGenBuffers(); + glBindVertexArray(dynVao); + glBindBuffer(GL_ARRAY_BUFFER, dynVbo); + Batch.lineAttribs(); + glBindVertexArray(0); + // line program: uModel is identity except while drawing an instance's collision lines + glUseProgram(lineProg); + setMat(lineProg, "uModel", new Matrix4f()); + uploadTextures(List.of()); + } + + Stats stats() { + return stats; + } + + // ------------------------------------------------------------------ scene sync + + void setScene(RenderScene s) { + if (s != scene) { + scene = s; + sceneVersion = -1; + texVersion = -1; + clearAll(); + } + } + + private void clearAll() { + chunks.values().forEach(c -> { + if (c.batch != null) { + c.batch.delete(); + } + }); + chunks.clear(); + chunkOf.clear(); + singles.values().forEach(Batch::delete); + singles.clear(); + meshBatches.values().forEach(Batch::delete); + meshBatches.clear(); + if (markerBatch != null) { + markerBatch.delete(); + markerBatch = null; + } + overlayBatches.values().forEach(Batch::delete); + overlayBatches.clear(); + } + + /** Instances that are drawn on their own (moved by the gizmo without rebuilding chunks). */ + void setDetached(Collection<RenderInstance> d) { + Set<RenderInstance> n = new HashSet<>(d); + if (n.equals(detached)) { + return; + } + Set<RenderInstance> changed = new HashSet<>(detached); + changed.addAll(n); + detached.clear(); + detached.addAll(n); + for (RenderInstance i : changed) { + if (i.kind == RenderInstance.Kind.TILE) { + rechunk(i); + dropSingle(i); + } + } + } + + /** Forgets an instance's own batch (shared mesh batches stay cached). */ + private void dropSingle(RenderInstance i) { + Batch b = singles.remove(i); + if (b != null && !meshBatches.containsValue(b)) { + b.delete(); + } + } + + private boolean batched(RenderInstance i) { + return i.kind == RenderInstance.Kind.TILE && i.visible && !detached.contains(i); + } + + private static long key(RenderInstance i) { + Matrix4f m = i.transform; + long cx = (long) Math.floor(m.m30() / CHUNK); + long cz = (long) Math.floor(m.m32() / CHUNK); + return cx << 32 ^ (cz & 0xffffffffL); + } + + private void rechunk(RenderInstance i) { + Long old = chunkOf.remove(i); + if (old != null) { + Chunk c = chunks.get(old); + c.members.remove(i); + c.dirty = true; + } + if (batched(i) && scene.instances.contains(i)) { + long k = key(i); + Chunk c = chunks.computeIfAbsent(k, kk -> new Chunk()); + c.members.add(i); + c.dirty = true; + chunkOf.put(i, k); + } + } + + private void sync() { + if (scene.textureVersion() != texVersion) { + texVersion = scene.textureVersion(); + uploadTextures(scene.pages); + clearAll(); + sceneVersion = -1; + } + if (scene.version() == sceneVersion && sceneVersion != -1) { + return; + } + sceneVersion = scene.version(); + Set<RenderInstance> dirty = scene.takeDirty(); + if (dirty == null || chunks.isEmpty() && chunkOf.isEmpty()) { + Set<RenderInstance> keep = new HashSet<>(detached); + clearAll(); + detached.clear(); + keep.retainAll(new HashSet<>(scene.instances)); + detached.addAll(keep); + for (RenderInstance i : scene.instances) { + if (batched(i)) { + long k = key(i); + chunks.computeIfAbsent(k, kk -> new Chunk()).members.add(i); + chunkOf.put(i, k); + } + } + } else { + for (RenderInstance i : dirty) { + if (batched(i) || chunkOf.containsKey(i)) { + rechunk(i); + } + dropSingle(i); + } + } + // markers and world overlays are cheap: rebuild on every change + if (markerBatch != null) { + markerBatch.delete(); + markerBatch = null; + } + overlayBatches.values().forEach(Batch::delete); + overlayBatches.clear(); + } + + private Batch.Builder builder() { + return new Batch.Builder(whiteLayer, pageW, arraySize); + } + + private Batch chunkBatch(Chunk c) { + if (c.dirty) { + Batch.Builder b = builder(); + for (RenderInstance i : c.members) { + b.add(i, i.transform, true); + } + c.batch = b.upload(c.batch); + c.dirty = false; + c.box = Culling.emptyBox(); + for (RenderInstance i : c.members) { + Culling.union(c.box, Culling.worldBox(i)); + } + } + return c.batch; + } + + private Batch single(RenderInstance i) { + Batch b = singles.get(i); + if (b == null) { + // without per-instance vertex colours the local-space batch can be shared per mesh + b = i.vertexColors == null ? meshBatches.computeIfAbsent(i.mesh, m -> localBatch(i)) : localBatch(i); + singles.put(i, b); + } + return b; + } + + private Batch localBatch(RenderInstance i) { + Batch.Builder bb = builder(); + bb.add(i, null, true); + Batch b = bb.upload(null); + b.box = Culling.localBox(i.mesh); + return b; + } + + // ------------------------------------------------------------------ textures + + private void uploadTextures(List<TexturePage> pages) { + if (texArray != 0) { + glDeleteTextures(texArray); + } + int size = 256; + for (TexturePage p : pages) { + size = Math.max(size, Math.max(p.width(), p.height())); + } + arraySize = size; + whiteLayer = pages.size(); + pageW = new int[pages.size()]; + texArray = glGenTextures(); + glBindTexture(GL_TEXTURE_2D_ARRAY, texArray); + glTexImage3D( + GL_TEXTURE_2D_ARRAY, + 0, + GL_RGBA8, + size, + size, + pages.size() + 1, + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + (ByteBuffer) null); + ByteBuffer buf = MemoryUtil.memAlloc(size * size * 4); + for (int l = 0; l <= pages.size(); l++) { + buf.clear(); + if (l == pages.size()) { + for (int i = 0; i < size * size; i++) { + buf.putInt(-1); + } + buf.flip(); + glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, l, size, size, 1, GL_RGBA, GL_UNSIGNED_BYTE, buf); + continue; + } + TexturePage p = pages.get(l); + pageW[l] = p.width(); + int[] px = dilate(p); + for (int c : px) { + buf.putInt(Batch.rgbaToAbgr(c)); + } + buf.flip(); + glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, l, p.width(), p.height(), 1, GL_RGBA, GL_UNSIGNED_BYTE, buf); + } + MemoryUtil.memFree(buf); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAX_LEVEL, 3); + glGenerateMipmap(GL_TEXTURE_2D_ARRAY); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } + + /** Transparent texels take the colour of an opaque neighbour, so filtering shows no dark fringes. */ + static int[] dilate(TexturePage p) { + int w = p.width(), h = p.height(); + int[] src = p.rgba(); + int[] out = src.clone(); + for (int pass = 0; pass < 2; pass++) { + int[] in = out.clone(); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int i = y * w + x; + if ((in[i] & 0xff) != 0 || (in[i] & 0xffffff00) != 0) { + continue; + } + int r = 0, g = 0, b = 0, n = 0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + int xx = x + dx, yy = y + dy; + if (xx < 0 || yy < 0 || xx >= w || yy >= h) { + continue; + } + int c = in[yy * w + xx]; + if ((c & 0xffffff00) != 0) { + r += (c >>> 24) & 0xff; + g += (c >>> 16) & 0xff; + b += (c >>> 8) & 0xff; + n++; + } + } + } + if (n > 0) { + out[i] = (r / n) << 24 | (g / n) << 16 | (b / n) << 8; + } + } + } + } + return out; + } + + // ------------------------------------------------------------------ frame + + /** Everything a frame needs besides the scene. */ + static final class FrameParams { + Camera camera; + Set<String> hiddenLayers = Set.of(); + Set<Integer> selection = Set.of(); + int hover; + boolean fog; + Viewport.FogOverride fogOverride; + int shading; + Overlay grid; + Overlay gizmoLines; + Overlay gizmoTris; + /** Tool helper geometry: depth-tested, and drawn on top of everything. */ + Overlay tool; + + Overlay toolTop; + /** Opacity of the hatched back sides of one-sided faces, 0 = culled as in the game. */ + float backFaceAlpha; + /** Orientation widget in a viewport corner (drawn last, own viewport), or null. */ + ViewCube.Frame viewCube; + + int width; + int height; + } + + void render(FrameParams fp) { + long t0 = System.nanoTime(); + drawCalls = 0; + triCount = 0; + glViewport(0, 0, fp.width, fp.height); + int bg = scene != null ? scene.background : 0x404040; + Viewport.FogOverride fo = fp.fogOverride != null && !fp.camera.ortho ? fp.fogOverride : null; + if (fo != null && fo.clip()) { + bg = fo.rgb(); + } + glClearColor(channel(bg, 16), channel(bg, 8), channel(bg, 0), 1); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + if (scene == null) { + stats = new Stats(0, 0, 0, 0, 0); + return; + } + sync(); + Matrix4f vp = fp.camera.viewProjection(); + Culling fr = new Culling(vp); + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LEQUAL); + glFrontFace(GL_CW); // the left-handed view (Camera) mirrors the winding + glCullFace(GL_BACK); + + boolean tilesHidden = fp.hiddenLayers.contains(LevelSceneAdapter.LAYER_TILES); + List<Batch> visChunks = new ArrayList<>(); + for (Chunk c : chunks.values()) { + if (c.members.isEmpty()) { + continue; + } + Batch b = chunkBatch(c); + if (!b.empty() && fr.visible(c.box)) { + visChunks.add(b); + } + } + List<RenderInstance> visSingles = new ArrayList<>(); + for (RenderInstance i : scene.instances) { + if (!i.visible || fp.hiddenLayers.contains(i.layer) || batched(i)) { + continue; + } + Batch b = single(i); + if (!b.empty() && fr.visible(Culling.transform(b.box, i.transform))) { + visSingles.add(i); + } + } + Visible vis = new Visible(tilesHidden ? List.of() : visChunks, visSingles); + Batch markers = markerBatch(fp); + boolean collision = !fp.hiddenLayers.contains(LevelSceneAdapter.LAYER_COLLISION); + + glUseProgram(meshProg); + setMat(meshProg, "uViewProj", vp); + setMat(meshProg, "uModel", new Matrix4f()); + glUniform1i(u(meshProg, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D_ARRAY, texArray); + setFogUniforms(fp, fo, bg); + glUniform1f(u(meshProg, "uBackAlpha"), fp.backFaceAlpha); + glUniform1i(u(meshProg, "uShade"), fp.shading); + glUniform1i(u(meshProg, "uUsePickId"), 0); + tint(meshProg, 0xffffffff); + glUniform4f(u(meshProg, "uHighlight"), 0, 0, 0, 0); + + // opaque + glDisable(GL_BLEND); + glDepthMask(true); + glUniform1i(u(meshProg, "uPass"), 0); + drawBlendClass(vis, RenderMesh.BLEND_OPAQUE); + if (fp.backFaceAlpha > 0) { + drawBackFaces(vis); + } + glDisable(GL_CULL_FACE); + if (markers != null) { + drawRange(markers, Batch.range(RenderMesh.BLEND_OPAQUE, true)); + } + + // overlay lines (depth tested) + glUseProgram(lineProg); + setMat(lineProg, "uViewProj", vp); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + if (collision) { + for (Batch b : visChunks) { + b.drawLines(); + drawCalls++; + } + for (RenderInstance i : visSingles) { + setMat(lineProg, "uModel", i.transform); + single(i).drawLines(); + } + setMat(lineProg, "uModel", new Matrix4f()); + } + if (markers != null) { + markers.drawLines(); + } + for (Map.Entry<String, Overlay> e : scene.overlays.entrySet()) { + if (!fp.hiddenLayers.contains(e.getKey())) { + Batch b = overlayBatches.computeIfAbsent(e.getKey(), k -> { + Batch.Builder bb = builder(); + bb.addOverlay(e.getValue(), null); + return bb.upload(null); + }); + b.drawLines(); + drawCalls++; + } + } + if (fp.grid != null) { + drawDynamic(fp.grid, false); + } + + // translucent + glUseProgram(meshProg); + glUniform1i(u(meshProg, "uPass"), 1); + glDepthMask(false); + for (int blend = RenderMesh.BLEND_ALPHA; blend <= RenderMesh.BLEND_ADD; blend++) { + if (blend == RenderMesh.BLEND_ADD) { + glBlendFunc(GL_SRC_ALPHA, GL_ONE); + } else { + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + } + drawBlendClass(vis, blend); + } + glDisable(GL_CULL_FACE); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glUseProgram(lineProg); + if (collision) { + for (Batch b : visChunks) { + b.drawFill(); + } + } + for (Map.Entry<String, Overlay> e : scene.overlays.entrySet()) { + Batch b = overlayBatches.get(e.getKey()); + if (b != null && !fp.hiddenLayers.contains(e.getKey())) { + b.drawFill(); + } + } + glDepthMask(true); + + highlight(fp, vp); + + if (fp.tool != null) { + glUseProgram(lineProg); + glDepthMask(false); + drawDynamic(fp.tool, true); + glDepthMask(true); + drawDynamic(fp.tool, false); + } + + // gizmo on top + if (fp.gizmoTris != null || fp.gizmoLines != null || fp.toolTop != null) { + glClear(GL_DEPTH_BUFFER_BIT); + glUseProgram(lineProg); + if (fp.toolTop != null) { + drawDynamic(fp.toolTop, true); + drawDynamic(fp.toolTop, false); + } + if (fp.gizmoTris != null) { + drawDynamic(fp.gizmoTris, true); + } + if (fp.gizmoLines != null) { + drawDynamic(fp.gizmoLines, false); + } + } + if (fp.viewCube != null) { + drawViewCube(fp.viewCube, fp); + } + glDisable(GL_BLEND); + glBindVertexArray(0); + stats = new Stats(drawCalls, triCount, visChunks.size(), chunks.size(), (System.nanoTime() - t0) / 1e6); + } + + /** Editor fog, or the previewed game fog of a {@link Viewport.FogOverride}. */ + private void setFogUniforms(FrameParams fp, Viewport.FogOverride fo, int bg) { + Vector3f eye = fp.camera.position; + int fc = fo != null ? fo.rgb() : bg; + glUniform3f(u(meshProg, "uFogColor"), channel(fc, 16), channel(fc, 8), channel(fc, 0)); + Vector3f fwd = fp.camera.forward(); + if (fo != null) { + glUniform2f(u(meshProg, "uFog"), fo.start(), Math.max(fo.end(), fo.start() + 1e-3f)); + if (fo.aroundPivot()) { + Vector3f pv = fp.camera.pivot(); + glUniform3f(u(meshProg, "uFogOrigin"), pv.x, pv.y, pv.z); + glUniform4f(u(meshProg, "uFogMode"), 0, 0, 0, fo.amount()); + } else { + glUniform3f(u(meshProg, "uFogOrigin"), eye.x, eye.y, eye.z); + glUniform4f(u(meshProg, "uFogMode"), fwd.x, fwd.y, fwd.z, fo.amount()); + } + glUniform1f(u(meshProg, "uFarClip"), fo.clip() ? fo.end() : 0); + // radial clip around the pivot would cut the scene at the screen edges: ghost it instead + glUniform1i(u(meshProg, "uClipGhost"), fo.aroundPivot() ? 1 : 0); + } else { + glUniform2f(u(meshProg, "uFog"), fp.fog && !fp.camera.ortho ? 40 : 0, fp.fog && !fp.camera.ortho ? 160 : 0); + glUniform3f(u(meshProg, "uFogOrigin"), eye.x, eye.y, eye.z); + glUniform4f(u(meshProg, "uFogMode"), 0, 0, 0, 0.85f); + glUniform1f(u(meshProg, "uFarClip"), 0); + glUniform1i(u(meshProg, "uClipGhost"), 0); + } + } + + /** + * Back sides of one-sided faces (the game culls them): front faces culled instead, hatched and + * translucent, depth-tested but not written, so the normal picture is unchanged where a front + * face is visible. + */ + private void drawBackFaces(Visible vis) { + glEnable(GL_CULL_FACE); + glCullFace(GL_FRONT); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glDepthMask(false); + glUniform1i(u(meshProg, "uPass"), 3); + // opaque / cut-out faces only: back sides of translucent skirts would show through them + drawRange(vis, Batch.range(RenderMesh.BLEND_OPAQUE, false)); + glUniform1i(u(meshProg, "uPass"), 0); + glDepthMask(true); + glDisable(GL_BLEND); + glCullFace(GL_BACK); + } + + private void drawViewCube(ViewCube.Frame vc, FrameParams fp) { + glViewport(vc.x(), vc.y(), vc.size(), vc.size()); + glEnable(GL_SCISSOR_TEST); + glScissor(vc.x(), vc.y(), vc.size(), vc.size()); + glClear(GL_DEPTH_BUFFER_BIT); + glDisable(GL_SCISSOR_TEST); + glUseProgram(lineProg); + setMat(lineProg, "uViewProj", vc.matrix()); + glEnable(GL_DEPTH_TEST); + glDepthMask(true); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glDisable(GL_CULL_FACE); + drawDynamic(vc.tris(), true); + drawDynamic(vc.lines(), false); + glViewport(0, 0, fp.width, fp.height); + if (vc.hud() != null) { + glDisable(GL_DEPTH_TEST); + setMat(lineProg, "uViewProj", vc.hudMatrix()); + drawDynamic(vc.hud(), true); + drawDynamic(vc.hud(), false); + glEnable(GL_DEPTH_TEST); + } + } + + private void highlight(FrameParams fp, Matrix4f vp) { + if (fp.selection.isEmpty() && fp.hover == 0) { + return; + } + glUseProgram(meshProg); + glUniform1i(u(meshProg, "uPass"), 0); + glDisable(GL_CULL_FACE); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + List<RenderInstance> marked = new ArrayList<>(); + for (RenderInstance i : scene.instances) { + boolean sel = fp.selection.contains(i.id); + if ((sel || i.id == fp.hover && i.id != 0) && i.visible && !fp.hiddenLayers.contains(i.layer)) { + marked.add(i); + } + } + // tint (selected only), hidden-line ghost, then the visible wireframe + for (int pass = 0; pass < 3; pass++) { + if (pass == 0) { + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + glDepthFunc(GL_LEQUAL); + } else { + glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); + glEnable(GL_POLYGON_OFFSET_LINE); + glPolygonOffset(-1, -1); + if (pass == 1) { + glDisable(GL_DEPTH_TEST); + } else { + glEnable(GL_DEPTH_TEST); + } + } + for (RenderInstance i : marked) { + boolean sel = fp.selection.contains(i.id); + if (pass == 0 && !sel) { + continue; + } + Batch b = single(i); + setMat(meshProg, "uModel", i.transform); + float a = pass == 0 ? 0.28f : sel ? 0.9f : 0.55f; + glUniform4f(u(meshProg, "uHighlight"), 1f, sel ? 0.6f : 0.93f, sel ? 0.1f : 0.55f, a); + tint(meshProg, pass == 1 ? 0xffffff40 : 0xffffffff); + if (pass == 1) { + // ghost lines: blended over everything + glUniform1i(u(meshProg, "uPass"), 1); + } + for (int r = 0; r < Batch.RANGES; r++) { + drawRange(b, r); + } + glUniform1i(u(meshProg, "uPass"), 0); + } + } + tint(meshProg, 0xffffffff); + glEnable(GL_DEPTH_TEST); + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + glDisable(GL_POLYGON_OFFSET_LINE); + glUniform4f(u(meshProg, "uHighlight"), 0, 0, 0, 0); + setMat(meshProg, "uModel", new Matrix4f()); + // selected / hovered markers: outline boxes + Overlay o = new Overlay(); + for (Marker m : scene.markers) { + boolean sel = fp.selection.contains(m.id); + if (!m.visible || fp.hiddenLayers.contains(m.layer) || !(sel || m.id == fp.hover && m.id != 0)) { + continue; + } + float s = m.size * 1.6f + 0.02f; + float y = m.y + m.pole; + o.box( + m.x - s, + y - s, + m.z - s, + m.x + s, + y + s, + m.z + s, + sel ? Legend.rgb(255, 160, 30) : Legend.rgb(255, 240, 160)); + } + if (o.lineCount() > 0) { + glUseProgram(lineProg); + glDisable(GL_DEPTH_TEST); + drawDynamic(o, false); + glEnable(GL_DEPTH_TEST); + } + } + + /** Frustum-visible tile chunks (empty when the tiles layer is hidden) and single instances. */ + private record Visible(List<Batch> chunks, List<RenderInstance> singles) {} + + /** One blend class: single-sided ranges with back-face culling, then double-sided ones. */ + private void drawBlendClass(Visible vis, int blend) { + for (int ds = 0; ds < 2; ds++) { + cull(ds == 0); + drawRange(vis, Batch.range(blend, ds == 1)); + } + } + + private void drawRange(Visible vis, int r) { + for (Batch b : vis.chunks()) { + drawRange(b, r); + } + drawSingles(vis.singles(), r); + } + + private void drawSingles(List<RenderInstance> vis, int r) { + for (RenderInstance i : vis) { + Batch b = single(i); + if (b.count[r] == 0) { + continue; + } + setMat(meshProg, "uModel", i.transform); + tint(meshProg, i.tint); + drawRange(b, r); + } + if (!vis.isEmpty()) { + setMat(meshProg, "uModel", new Matrix4f()); + tint(meshProg, 0xffffffff); + } + } + + private void drawRange(Batch b, int r) { + if (b.count[r] > 0) { + b.drawRange(r); + drawCalls++; + triCount += b.count[r] / 3; + } + } + + private void cull(boolean on) { + if (on) { + glEnable(GL_CULL_FACE); + } else { + glDisable(GL_CULL_FACE); + } + } + + private Batch markerBatch(FrameParams fp) { + if (markerBatch != null && !markerLayers.equals(fp.hiddenLayers)) { + markerBatch.delete(); + markerBatch = null; + } + if (markerBatch == null) { + markerBatch = MarkerMesh.build(scene.markers, fp.hiddenLayers, builder()); + markerLayers = fp.hiddenLayers; + } + return markerBatch; + } + + /** Streams an overlay through the dynamic buffer (grid, gizmo, highlight boxes). */ + private void drawDynamic(Overlay o, boolean tris) { + int n = tris ? o.triangleCount() * 3 : o.lineCount() * 2; + if (n == 0) { + return; + } + ByteBuffer b = MemoryUtil.memAlloc(n * Batch.LINE_STRIDE); + if (tris) { + float[] p = o.triangles(); + int[] c = o.triangleColors(); + for (int i = 0; i < n; i++) { + b.putFloat(p[3 * i]) + .putFloat(p[3 * i + 1]) + .putFloat(p[3 * i + 2]) + .putInt(Batch.rgbaToAbgr(c[i / 3])); + } + } else { + float[] p = o.lines(); + int[] c = o.lineColors(); + for (int i = 0; i < n; i++) { + b.putFloat(p[3 * i]) + .putFloat(p[3 * i + 1]) + .putFloat(p[3 * i + 2]) + .putInt(Batch.rgbaToAbgr(c[i / 2])); + } + } + b.flip(); + glBindVertexArray(dynVao); + glBindBuffer(GL_ARRAY_BUFFER, dynVbo); + if (b.remaining() > dynCapacity) { + dynCapacity = b.remaining() * 2; + glBufferData(GL_ARRAY_BUFFER, dynCapacity, GL_STREAM_DRAW); + } + glBufferSubData(GL_ARRAY_BUFFER, 0, b); + MemoryUtil.memFree(b); + glDrawArrays(tris ? GL_TRIANGLES : GL_LINES, 0, n); + drawCalls++; + } + + // ------------------------------------------------------------------ picking + + /** Result of a pick: id (0 = none) and depth-buffer value at the hit pixel. */ + record PickHit(int id, float depth, int px, int py, boolean backFace) {} + + /** + * Renders ids into an offscreen buffer and reads a (2r+1)² window around (x, y) (pixel + * coordinates, origin top-left). The centre wins; otherwise the nearest non-empty pixel. + */ + PickHit pick(FrameParams fp, int x, int y, int radius) { + if (scene == null) { + return new PickHit(0, 1, x, y, false); + } + int gy = fp.height - 1 - y; + beginOffscreen(fp, x - radius, gy - radius, 2 * radius + 1); + drawGeometry(fp, true); + Batch markers = markerBatch(fp); + if (markers != null) { + markers.drawRange(Batch.range(RenderMesh.BLEND_OPAQUE, true)); + } + int n = 2 * radius + 1; + int x0 = Math.max(0, x - radius), y0 = Math.max(0, gy - radius); + int w = Math.min(fp.width, x - radius + n) - x0, h = Math.min(fp.height, gy - radius + n) - y0; + PickHit best = new PickHit(0, 1, x, y, false); + if (w > 0 && h > 0) { + try (MemoryStack st = MemoryStack.stackPush()) { + ByteBuffer px = st.malloc(w * h * 4); + FloatBuffer dp = st.mallocFloat(w * h); + glReadPixels(x0, y0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, px); + glReadPixels(x0, y0, w, h, GL_DEPTH_COMPONENT, GL_FLOAT, dp); + long bestD = Long.MAX_VALUE; + for (int j = 0; j < h; j++) { + for (int i = 0; i < w; i++) { + int id = px.getInt((j * w + i) * 4); + if (id == 0) { + continue; + } + long dx = x0 + i - x, dy = y0 + j - gy; + long d2 = dx * dx + dy * dy; + if (d2 < bestD) { + bestD = d2; + best = new PickHit( + id & 0x7fffffff, dp.get(j * w + i), x0 + i, fp.height - 1 - (y0 + j), id < 0); + } + } + } + } + } + endOffscreen(); + return best; + } + + /** Depth of scene geometry (no markers) at a pixel, 1 = nothing. */ + float depthAt(FrameParams fp, int x, int y) { + if (scene == null) { + return 1; + } + int gy = fp.height - 1 - y; + beginOffscreen(fp, x, gy, 1); + drawGeometry(fp, false); + float d; + try (MemoryStack st = MemoryStack.stackPush()) { + FloatBuffer dp = st.mallocFloat(1); + glReadPixels(x, gy, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, dp); + d = dp.get(0); + } + endOffscreen(); + return d; + } + + /** Binds the pick FBO, clears a square scissor window (GL pixel coordinates) and sets up the pick pass. */ + private void beginOffscreen(FrameParams fp, int sx, int sy, int size) { + sync(); + ensurePickFbo(fp.width, fp.height); + glBindFramebuffer(GL_FRAMEBUFFER, pickFbo); + glViewport(0, 0, fp.width, fp.height); + glEnable(GL_SCISSOR_TEST); + glScissor(sx, sy, size, size); + glClearColor(0, 0, 0, 0); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + glEnable(GL_DEPTH_TEST); + glDepthMask(true); + glDisable(GL_BLEND); + glDisable(GL_CULL_FACE); + glFrontFace(GL_CW); + glUseProgram(meshProg); + setMat(meshProg, "uViewProj", fp.camera.viewProjection()); + setMat(meshProg, "uModel", new Matrix4f()); + glUniform1i(u(meshProg, "uPass"), 2); + glUniform1i(u(meshProg, "uUsePickId"), 0); + glBindTexture(GL_TEXTURE_2D_ARRAY, texArray); + } + + private void endOffscreen() { + glDisable(GL_SCISSOR_TEST); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + + /** + * All visible tile chunks (vertex pick ids) and single instances, unculled. With {@code pickIds} + * the singles write their instance id (instances with id 0 are skipped). + */ + private void drawGeometry(FrameParams fp, boolean pickIds) { + if (!fp.hiddenLayers.contains(LevelSceneAdapter.LAYER_TILES)) { + for (Chunk c : chunks.values()) { + if (!c.members.isEmpty()) { + drawAllRanges(chunkBatch(c)); + } + } + } + glUniform1i(u(meshProg, "uUsePickId"), pickIds ? 1 : 0); + for (RenderInstance i : scene.instances) { + if (!i.visible || fp.hiddenLayers.contains(i.layer) || batched(i) || pickIds && i.id == 0) { + continue; + } + if (pickIds) { + int id = i.id; + glUniform4f(u(meshProg, "uPickId"), channel(id, 0), channel(id, 8), channel(id, 16), channel(id, 24)); + } + setMat(meshProg, "uModel", i.transform); + drawAllRanges(single(i)); + } + glUniform1i(u(meshProg, "uUsePickId"), 0); + setMat(meshProg, "uModel", new Matrix4f()); + } + + private static void drawAllRanges(Batch b) { + for (int r = 0; r < Batch.RANGES; r++) { + b.drawRange(r); + } + } + + private void ensurePickFbo(int w, int h) { + if (pickFbo != 0 && w == pickW && h == pickH) { + return; + } + if (pickFbo != 0) { + glDeleteFramebuffers(pickFbo); + glDeleteRenderbuffers(pickColor); + glDeleteRenderbuffers(pickDepth); + } + pickW = w; + pickH = h; + pickFbo = glGenFramebuffers(); + pickColor = glGenRenderbuffers(); + pickDepth = glGenRenderbuffers(); + glBindRenderbuffer(GL_RENDERBUFFER, pickColor); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, w, h); + glBindRenderbuffer(GL_RENDERBUFFER, pickDepth); + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, w, h); + glBindFramebuffer(GL_FRAMEBUFFER, pickFbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, pickColor); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, pickDepth); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + + // ------------------------------------------------------------------ helpers + + /** World bounds of an instance (mesh box transformed). */ + static float[] worldBounds(RenderInstance i) { + return Culling.transform(i.mesh.bounds(), i.transform); + } + + private void tint(int prog, int rgba) { + glUniform4f(u(prog, "uTint"), channel(rgba, 24), channel(rgba, 16), channel(rgba, 8), channel(rgba, 0)); + } + + /** One 8-bit colour channel (at bit {@code shift}) as 0..1. */ + private static float channel(int c, int shift) { + return ((c >>> shift) & 0xff) / 255f; + } + + private int u(int prog, String name) { + return uniforms.computeIfAbsent(prog + ":" + name, k -> glGetUniformLocation(prog, name)); + } + + private void setMat(int prog, String name, Matrix4f m) { + try (MemoryStack st = MemoryStack.stackPush()) { + glUniformMatrix4fv(u(prog, name), false, m.get(st.mallocFloat(16))); + } + } + + private static int program(String vs, String fs) { + int v = shader(GL_VERTEX_SHADER, vs); + int f = shader(GL_FRAGMENT_SHADER, fs); + int p = glCreateProgram(); + glAttachShader(p, v); + glAttachShader(p, f); + glLinkProgram(p); + if (glGetProgrami(p, GL_LINK_STATUS) == 0) { + throw new IllegalStateException("shader link: " + glGetProgramInfoLog(p)); + } + glDeleteShader(v); + glDeleteShader(f); + return p; + } + + private static int shader(int type, String src) { + int s = glCreateShader(type); + glShaderSource(s, src); + glCompileShader(s); + if (glGetShaderi(s, GL_COMPILE_STATUS) == 0) { + throw new IllegalStateException("shader compile: " + glGetShaderInfoLog(s)); + } + return s; + } + + void dispose() { + clearAll(); + if (texArray != 0) { + glDeleteTextures(texArray); + } + if (pickFbo != 0) { + glDeleteFramebuffers(pickFbo); + glDeleteRenderbuffers(pickColor); + glDeleteRenderbuffers(pickDepth); + } + glDeleteProgram(meshProg); + glDeleteProgram(lineProg); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Legend.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Legend.java new file mode 100644 index 0000000..7834b00 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Legend.java @@ -0,0 +1,180 @@ +package com.eliasmueller.llamamod.editor.render; + +import java.util.Map; +import java.util.Set; + +/** Overlay colours (0xRRGGBBAA), same legend as the in-game debug wireframe (notes/debugviz.md). */ +public final class Legend { + private Legend() {} + + public static final int FLOOR = rgb(40, 255, 40); + public static final int SLOPE = rgb(200, 255, 0); + public static final int WALL = rgb(0, 150, 255); + public static final int CEILING = rgb(255, 130, 0); + public static final int MAT_OTHER = rgb(230, 230, 230); + + /** Collision materials with a fixed colour (16 exit floor, 17/18 selective, 30 no floor). */ + public static final Map<Integer, Integer> MATERIALS = Map.ofEntries( + Map.entry(30, rgb(255, 0, 255)), + Map.entry(16, rgb(190, 60, 255)), + Map.entry(17, rgb(255, 255, 0)), + Map.entry(18, rgb(255, 200, 120)), + Map.entry(1, rgb(0, 200, 200)), + Map.entry(2, rgb(255, 255, 255)), + Map.entry(3, rgb(255, 120, 170)), + Map.entry(5, rgb(120, 255, 200)), + Map.entry(6, rgb(160, 120, 255)), + Map.entry(7, rgb(255, 170, 80)), + Map.entry(8, rgb(0, 130, 60)), + Map.entry(9, rgb(150, 200, 255)), + Map.entry(10, rgb(150, 150, 0)), + Map.entry(11, rgb(150, 90, 40)), + Map.entry(13, rgb(255, 130, 110)), + Map.entry(15, rgb(255, 255, 150)), + Map.entry(20, rgb(0, 120, 140)), + Map.entry(26, rgb(110, 40, 160)), + Map.entry(27, rgb(190, 190, 190)), + Map.entry(28, rgb(255, 0, 140)), + Map.entry(33, rgb(150, 0, 0)), + Map.entry(255, rgb(20, 20, 20))); + + /** Edge colour of a collision polygon from its material and the y component of its normal. */ + public static int collision(int material, double ny) { + if (material != 0) { + return MATERIALS.getOrDefault(material, MAT_OTHER); + } + if (ny >= 0.7) { + return FLOOR; + } + if (ny >= 0.2) { + return SLOPE; + } + if (ny > -0.2) { + return WALL; + } + return CEILING; + } + + public enum Category { + START(rgb(255, 255, 255)), + EXIT(rgb(190, 60, 255)), + CHECKPOINT(rgb(0, 255, 140)), + CAMERA(rgb(0, 255, 255)), + TRIGGER(rgb(255, 255, 0)), + FORCE(rgb(255, 90, 0)), + SECRET(rgb(255, 200, 0)), + MISC(rgb(170, 170, 170)), + ENVIRONMENT(rgb(255, 100, 180)), + OBJECT(rgb(90, 90, 90)); + + public final int color; + + Category(int color) { + this.color = color; + } + } + + private static final Set<String> START_T = + Set.of("Llama", "KuscoAndPachaTogetherOnATree", "RollerCoaster", "LlamaChase"); + private static final Set<String> EXIT_T = Set.of("LevelDoor", "VillageEndDoor", "EndGameDoor", "TeleportDoor"); + private static final Set<String> FORCE_T = Set.of( + "FallOffNoHurt", + "ObjectDontDropInBox", + "RiverCurrentBox", + "RiverRebound", + "SuckingWPs", + "ObjectSuckPoints", + "RiverTrackChange", + "TrackSwitch", + "BackgroundRotationChanger", + "RiverWhirlpool"); + private static final Set<String> MISC_T = Set.of( + "CheatTeleport", + "Wompy", + "WompySimple", + "WompyRollerCoaster", + "Rain", + "DrippingWater", + "WaterfallMist", + "Smoker", + "CoinLine", + "CoinCircle", + "CoinRollerCoaster", + "CoinBat", + "CoinSpawnOracle", + "MusicBox", + "SpitPlatformTarget", + "NewBlockheadKidHelper", + "FlockOfBirds", + "Bats", + "RiverBats", + "Scorpions1", + "Scorpions3", + "Scorpions5", + "GhostCatacombs", + "LabDumbGuards", + "FlyStuck", + "AppearingPlatforms", + "AppearingPlatformsCity", + "AppearingPlatformsJungle", + "AppearingPlatformsLab", + "AppearingPlatformsVillage", + "fat_bird", + "Column2", + "TipoHelp"); + private static final Set<String> TRIG_T = Set.of("ChangeToBunny", "ChangeToTurtle", "RiverStartSign"); + + /** Marker category of a strat type (invisible logic objects), {@code null} for ordinary objects. */ + public static Category category(String name) { + String n = name.replace("(spawn)", ""); + if (START_T.contains(n)) { + return Category.START; + } + if (EXIT_T.contains(n)) { + return Category.EXIT; + } + if (n.startsWith("CheckPoint")) { + return Category.CHECKPOINT; + } + if (n.contains("Camera")) { + return Category.CAMERA; + } + if (n.equals("Secret")) { + return Category.SECRET; + } + if (FORCE_T.contains(n) || n.startsWith("Force") || n.startsWith("WaterRebound")) { + return Category.FORCE; + } + if (n.contains("Trigger") || n.startsWith("Say") || TRIG_T.contains(n)) { + return Category.TRIGGER; + } + if (MISC_T.contains(n) || n.endsWith("Spawner") || n.contains("Helper")) { + return Category.MISC; + } + return null; + } + + public static int rgb(int r, int g, int b) { + return rgba(r, g, b, 0xff); + } + + public static int rgba(int r, int g, int b, int a) { + return r << 24 | g << 16 | b << 8 | (a & 0xff); + } + + /** Colour 0xRRGGBB of a visibility region (golden-ratio hues). */ + public static int regionRgb(int region) { + return java.awt.Color.HSBtoRGB((region * 0.618034f) % 1f, 0.65f, 0.95f) & 0xffffff; + } + + public static int withAlpha(int rgba, int a) { + return (rgba & 0xffffff00) | (a & 0xff); + } + + public static int dim(int rgba, double f) { + int r = (int) (((rgba >>> 24) & 0xff) * f); + int g = (int) (((rgba >>> 16) & 0xff) * f); + int b = (int) (((rgba >>> 8) & 0xff) * f); + return r << 24 | g << 16 | b << 8 | (rgba & 0xff); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/LevelSceneAdapter.java b/src/main/java/com/eliasmueller/llamamod/editor/render/LevelSceneAdapter.java new file mode 100644 index 0000000..9c74de0 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/LevelSceneAdapter.java @@ -0,0 +1,359 @@ +package com.eliasmueller.llamamod.editor.render; + +import com.eliasmueller.llamamod.format.Collision; +import com.eliasmueller.llamamod.format.Face; +import com.eliasmueller.llamamod.format.Geometry; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.PyMath; +import com.eliasmueller.llamamod.format.Strats; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.format.Trak; +import com.eliasmueller.llamamod.format.Vertex; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +/** + * Builds a {@link RenderScene} straight from a level WAD (format API). Pick ids: {@link PickId#TILE} + * + tile index, {@link PickId#OBJECT} + strat index, {@link PickId#WAYPOINT} + path node index, + * {@link PickId#ZONE} + environment zone index. + */ +public final class LevelSceneAdapter { + public static final String LAYER_TILES = "tiles"; + public static final String LAYER_OBJECTS = "objects"; + public static final String LAYER_MARKERS = "markers"; + public static final String LAYER_WAYPOINTS = "waypoints"; + public static final String LAYER_ZONES = "zones"; + public static final String LAYER_COLLISION = "collision"; + + /** Converted level plus lookup data for labels. */ + public record Result( + RenderScene scene, + Level level, + List<String> stratNames, + List<RenderMesh> tileMeshes, + int gridW, + int gridH) {} + + private LevelSceneAdapter() {} + + public static Result fromWad(byte[] wadBytes) { + Level lv = Level.parse(wadBytes); + RenderScene sc = new RenderScene(); + TextChunk text = TextChunk.parse(lv.wad().require("TEXT")); + List<TextChunk.TextureEntry> tex = text.textureEntries(); + int[] pageClass = new int[text.pages.size()]; + for (int i = 0; i < text.pages.size(); i++) { + TextChunk.Page p = text.pages.get(i); + pageClass[i] = p.flags & 0x7f; + sc.pages.add(page(p)); + } + MapChunk map = lv.map(); + List<RenderMesh> tileMeshes = new ArrayList<>(); + for (int i = 0; i < lv.models().size(); i++) { + Model m = lv.models().get(i); + RenderMesh rm = mesh("trak" + i, m, tex, text, pageClass, 0); + rm.overlay = collision(m); + tileMeshes.add(rm); + } + addTiles(sc, lv, tileMeshes); + Overlay wp = new Overlay(); + List<String> names = + addStrats(sc, lv, wp, (name, model) -> mesh(name, model, tex, text, pageClass, RenderMesh.LIT)); + addWaypoints(sc, map, wp); + sc.overlays.put(LAYER_WAYPOINTS, wp); + addZones(sc, map); + return new Result(sc, lv, names, tileMeshes, map.width, map.height); + } + + private static void addTiles(RenderScene sc, Level lv, List<RenderMesh> tileMeshes) { + MapChunk map = lv.map(); + List<MapChunk.TileColors> cols = map.tileColors(Trak.vertCounts(lv.models())); + for (int i = 0; i < map.tiles.size(); i++) { + MapChunk.Tile t = map.tiles.get(i); + RenderInstance ri = + new RenderInstance(PickId.of(PickId.TILE, i), RenderInstance.Kind.TILE, tileMeshes.get(t.model())); + ri.transform + .translation((float) t.x(), (float) t.y(), (float) t.z()) + .rotateY((float) -angle(t.rotUnsigned())); + int[] c = cols.get(i).frames().get(0); + int[] vc = new int[c.length]; + for (int k = 0; k < c.length; k++) { + // u32 little endian R, G, B, frame count + vc[k] = (c[k] & 0xff) << 24 | ((c[k] >>> 8) & 0xff) << 16 | ((c[k] >>> 16) & 0xff) << 8 | 0xff; + } + ri.vertexColors = vc; + ri.layer = LAYER_TILES; + sc.instances.add(ri); + } + } + + /** Object instances and markers of all strats (+ strat → first waypoint lines); returns the names. */ + private static List<String> addStrats( + RenderScene sc, Level lv, Overlay wp, BiFunction<String, Model, RenderMesh> meshes) { + MapChunk map = lv.map(); + byte[] stpc = lv.wad().require("STPC"); + StratModels sm = new StratModels(stpc, lv.wfpc()); + Map<Long, RenderMesh> objMeshes = new HashMap<>(); + List<String> names = new ArrayList<>(); + for (int i = 0; i < map.strats.size(); i++) { + MapChunk.Strat s = map.strats.get(i); + String name = Strats.nameOr(stpc, s.code(), sm.codeStart(), "?@0x" + Integer.toHexString(s.code())); + names.add(name); + int id = PickId.of(PickId.OBJECT, i); + float x = (float) MapChunk.fx(s.x()), y = (float) MapChunk.fx(s.y()), z = (float) -MapChunk.fx(s.z()); + float yaw = (float) angle(s.ry() & 0xfff); + Legend.Category cat = Legend.category(name); + boolean withModel = cat == null || cat == Legend.Category.CHECKPOINT || cat == Legend.Category.START; + int mi = withModel ? sm.modelOf(s.code()) : -1; + Model model = mi >= 0 ? sm.models.get(mi) : null; + long key = mi; + boolean hasModel = model != null; + if (model != null && StratModels.skinned(model)) { + model = sm.posed(s.code(), mi); + key = (long) s.code() << 32 | mi; + } + boolean drawn = model != null && !model.faces.isEmpty(); + if (drawn) { + Model mdl = model; + RenderMesh rm = objMeshes.computeIfAbsent(key, k -> meshes.apply("stpc" + mi, mdl)); + RenderInstance ri = new RenderInstance(id, RenderInstance.Kind.OBJECT, rm); + ri.transform.translation(x, y, z).rotateY(-yaw); + ri.layer = LAYER_OBJECTS; + sc.instances.add(ri); + } + if (!drawn || cat != null) { + sc.markers.add(stratMarker(sc, id, name, cat, hasModel, x, y, z, yaw)); + } + if (s.waypoint() >= 0 && s.waypoint() < map.nodes.size()) { + MapChunk.PathNode nd = map.nodes.get(s.waypoint()); + int c = Legend.withAlpha(Legend.dim((cat != null ? cat : Legend.Category.MISC).color, 0.8), 110); + wp.line(x, y + 0.02f, z, nodeX(nd), nodeY(nd), nodeZ(nd), c); + } + } + return names; + } + + /** Marker of an invisible strat (category) or of an object whose model cannot be drawn. */ + private static Marker stratMarker( + RenderScene sc, + int id, + String name, + Legend.Category cat, + boolean hasModel, + float x, + float y, + float z, + float yaw) { + Marker mk = new Marker(id, x, y, z, (cat != null ? cat : Legend.Category.OBJECT).color); + mk.label = name; + if (cat == null) { + mk.size = 0.05f; + mk.pole = 0.06f; + if (hasModel) { + // model that could not be drawn (unposed character) + mk.shape = Marker.Shape.DIAMOND; + mk.size = 0.12f; + mk.pole = 0.3f; + } + } else if (cat == Legend.Category.START) { + mk.shape = Marker.Shape.ARROW; + mk.size = 0.3f; + mk.yaw = yaw; + } else { + mk.size = 0.06f; + mk.pole = 0.35f; + if (cat == Legend.Category.CAMERA || cat == Legend.Category.CHECKPOINT) { + mk.yaw = yaw; + } + } + if (cat == Legend.Category.EXIT) { + double cx = Math.floor(x), cz = Math.floor(z); + sc.overlays + .computeIfAbsent(LAYER_ZONES, k -> new Overlay()) + .box(cx, y - 0.05, cz, cx + 1, y + 1, cz + 1, cat.color); + } + return mk; + } + + private static void addWaypoints(RenderScene sc, MapChunk map, Overlay wp) { + for (int i = 0; i < map.nodes.size(); i++) { + MapChunk.PathNode nd = map.nodes.get(i); + float x = nodeX(nd), y = nodeY(nd), z = nodeZ(nd); + Marker mk = new Marker(PickId.of(PickId.WAYPOINT, i), x, y, z, Legend.rgb(255, 230, 120)); + mk.size = 0.035f; + mk.shape = Marker.Shape.DIAMOND; + mk.layer = LAYER_WAYPOINTS; + mk.label = "waypoint " + i; + sc.markers.add(mk); + int nx = nd.next(); + if (nx != 0 && nx < map.nodes.size()) { + MapChunk.PathNode b = map.nodes.get(nx); + wp.line(x, y, z, nodeX(b), nodeY(b), nodeZ(b), Legend.withAlpha(Legend.rgb(255, 210, 80), 150)); + } + } + } + + /** Export-space position of a path node (game z negated). */ + private static float nodeX(MapChunk.PathNode n) { + return (float) MapChunk.fx(n.x()); + } + + private static float nodeY(MapChunk.PathNode n) { + return (float) MapChunk.fx(n.y()); + } + + private static float nodeZ(MapChunk.PathNode n) { + return (float) -MapChunk.fx(n.z()); + } + + /** Backdrop colour of the default zone, boxes + markers of the other environment zones. */ + private static void addZones(RenderScene sc, MapChunk map) { + Overlay zones = sc.overlays.computeIfAbsent(LAYER_ZONES, k -> new Overlay()); + for (MapChunk.EnvZone z : map.envZones) { + if (z.isDefault()) { + // backdrop/fog colour (file offset 0x4e, 0x00BBGGRR), notes/mod-fog.md + int c = z.f()[20]; + sc.background = (c & 0xff) << 16 | (c & 0xff00) | (c >>> 16) & 0xff; + break; + } + } + int envCol = Legend.Category.ENVIRONMENT.color; + for (int i = 0; i < map.envZones.size(); i++) { + MapChunk.EnvZone z = map.envZones.get(i); + if (z.isDefault()) { + continue; + } + double cx = Integer.toUnsignedLong(z.f()[0]); + double cy = Integer.toUnsignedLong(z.f()[1]); + double cz = -Integer.toUnsignedLong(z.f()[2]); + zones.box(cx + 0.04, cy, cz - 0.04, cx + 0.96, cy + 0.92, cz - 0.96, envCol); + Marker mk = new Marker( + PickId.of(PickId.ZONE, i), (float) cx + 0.5f, (float) cy + 0.46f, (float) cz - 0.5f, envCol); + mk.layer = LAYER_ZONES; + mk.label = "environment zone " + i; + sc.markers.add(mk); + } + } + + /** Game angle units (4096 = 360°) to radians. */ + static double angle(long units) { + return PyMath.angle(units); + } + + public static TexturePage page(TextChunk.Page p) { + int[] out = new int[p.w * p.h]; + boolean blended = + switch (p.flags & 0x7f) { + case 4, 5, 7 -> true; + default -> false; + }; + for (int i = 0; i < out.length; i++) { + int px = p.pixels[i]; + if (p.flags == 0) { + out[i] = px << 24 | px << 16 | px << 8 | 0xff; + continue; + } + int c = TextChunk.abgr1555ToRgba(px); + if (!blended && (c & 0xff) != 0) { + c |= 0xff; + } + out[i] = c; + } + return new TexturePage(p.w, p.h, out); + } + + /** Game model to a render mesh: UVs from texture rectangles, flags, blend classes. */ + public static RenderMesh mesh( + String name, Model m, List<TextChunk.TextureEntry> tex, TextChunk text, int[] pageClass, int extraFlags) { + RenderMesh rm = new RenderMesh(name, m.verts.size(), m.faces.size()); + for (int i = 0; i < m.verts.size(); i++) { + Vertex v = m.verts.get(i); + rm.positions[3 * i] = (float) v.x(); + rm.positions[3 * i + 1] = (float) v.y(); + rm.positions[3 * i + 2] = (float) v.z(); + rm.normals[3 * i] = (float) v.nx(); + rm.normals[3 * i + 1] = (float) v.ny(); + rm.normals[3 * i + 2] = (float) v.nz(); + } + for (int t = 0; t < m.faces.size(); t++) { + Face f = m.faces.get(t); + for (int k = 0; k < 3; k++) { + int vi = f.v(k); + rm.triangles[3 * t + k] = vi < m.verts.size() ? vi : 0; + } + int fl = extraFlags; + if ((f.flags() & 0x400) != 0) { + fl |= RenderMesh.DOUBLE_SIDED; + } + if ((f.flags() & 0x4000) != 0) { + fl |= RenderMesh.NO_VERTEX_COLOR; + } + rm.triFlags[t] = fl; + TextChunk.TextureEntry te = f.tex() < tex.size() ? tex.get(f.tex()) : null; + int mode = te == null ? 0 : (te.flags() >> 12) & 7; + if (te == null || (te.flags() & 1) != 0 || te.page() < 0 || te.page() >= text.pages.size()) { + rm.pages[t] = -1; + int a = te != null && mode != 0 ? te.v1() : 255; + rm.triColors[t] = te == null ? 0xff00ffff : Legend.rgb(te.u0(), te.u1(), te.v0()) & 0xffffff00 | a; + for (int k = 0; k < 6; k++) { + rm.uvs[6 * t + k] = 0; + } + } else { + rm.pages[t] = te.page(); + rm.triColors[t] = 0xffffffff; + TextChunk.Page p = text.pages.get(te.page()); + double[][] uv = Geometry.faceUvs(f.flags(), Geometry.texUv(te, p.w, p.h)); + for (int k = 0; k < 3; k++) { + rm.uvs[6 * t + 2 * k] = (float) uv[k][0]; + rm.uvs[6 * t + 2 * k + 1] = (float) uv[k][1]; + } + if (mode == 0) { + mode = switch (pageClass[te.page()]) { + case 4, 7 -> 1; + case 5 -> 2; + default -> 0;}; + } + } + rm.blend[t] = switch (mode) { + case 0 -> RenderMesh.BLEND_OPAQUE; + case 2, 4 -> RenderMesh.BLEND_ADD; + default -> RenderMesh.BLEND_ALPHA;}; + } + return rm; + } + + /** Collision polygons of a tile model as coloured outlines (+ fill for special materials). */ + public static Overlay collision(Model m) { + Overlay o = new Overlay(); + for (List<byte[]> g : m.coll) { + for (byte[] r : g) { + Collision.Decoded c = Collision.decode(r); + double[][] pts = Collision.polygon(c); + if (pts.length < 3) { + continue; + } + double[] n = c.plane(); + double off = 0.01; + for (double[] p : pts) { + p[0] += n[0] * off; + p[1] += n[1] * off; + p[2] += n[2] * off; + } + int col = Legend.collision(c.mat(), n[1]); + for (int i = 0; i < pts.length; i++) { + o.line(pts[i], pts[(i + 1) % pts.length], col); + } + int fill = c.mat() == 16 ? 110 : c.mat() == 17 || c.mat() == 18 ? 28 : 0; + for (int i = 1; fill > 0 && i + 1 < pts.length; i++) { + o.triangle(pts[0], pts[i], pts[i + 1], Legend.withAlpha(col, fill)); + } + } + } + return o; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Marker.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Marker.java new file mode 100644 index 0000000..55e2309 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Marker.java @@ -0,0 +1,39 @@ +package com.eliasmueller.llamamod.editor.render; + +/** + * Pickable icon in the world: a small coloured shape, optionally on a pole, optionally with a + * direction tick. Used for invisible objects, waypoints, lights, zones. Mutable; call {@link + * RenderScene#changed()} after edits. + */ +public final class Marker { + public enum Shape { + CUBE, + DIAMOND, + ARROW + } + + public final int id; + public float x; + public float y; + public float z; + public int color; + /** Half edge length in world units. */ + public float size = 0.06f; + /** Height of the shape above the position (a pole is drawn down to it), 0 = none. */ + public float pole; + /** Facing angle about y in radians (direction tick), {@link Float#NaN} = none. */ + public float yaw = Float.NaN; + + public Shape shape = Shape.CUBE; + public String layer = "markers"; + public String label; + public boolean visible = true; + + public Marker(int id, float x, float y, float z, int color) { + this.id = id; + this.x = x; + this.y = y; + this.z = z; + this.color = color; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/MarkerMesh.java b/src/main/java/com/eliasmueller/llamamod/editor/render/MarkerMesh.java new file mode 100644 index 0000000..2c33d48 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/MarkerMesh.java @@ -0,0 +1,98 @@ +package com.eliasmueller.llamamod.editor.render; + +import java.util.List; +import java.util.Set; + +/** Marker shapes as one batch: coloured, slightly shaded solids plus poles and direction ticks. */ +final class MarkerMesh { + private MarkerMesh() {} + + private static final float[][] CUBE = { + {-1, -1, -1}, {1, -1, -1}, {1, 1, -1}, {-1, 1, -1}, {-1, -1, 1}, {1, -1, 1}, {1, 1, 1}, {-1, 1, 1} + }; + private static final int[] CUBE_TRIS = { + 0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 3, 7, 6, 3, 6, 2, 0, 4, 7, 0, 7, 3, 1, 2, 6, 1, 6, 5 + }; + private static final float[][] OCTA = {{1, 0, 0}, {-1, 0, 0}, {0, 1.4f, 0}, {0, -1.4f, 0}, {0, 0, 1}, {0, 0, -1}}; + private static final int[] OCTA_TRIS = {0, 2, 4, 4, 2, 1, 1, 2, 5, 5, 2, 0, 4, 3, 0, 1, 3, 4, 5, 3, 1, 0, 3, 5}; + + static Batch build(List<Marker> markers, Set<String> hidden, Batch.Builder b) { + Overlay lines = new Overlay(); + for (Marker m : markers) { + if (!m.visible || hidden.contains(m.layer)) { + continue; + } + float cy = m.y + m.pole; + RenderMesh mesh; + if (m.shape == Marker.Shape.ARROW) { + mesh = arrow(m); + } else { + boolean cube = m.shape == Marker.Shape.CUBE; + mesh = solid(cube ? CUBE : OCTA, cube ? CUBE_TRIS : OCTA_TRIS, m.x, cy, m.z, m.size, m.color); + } + b.add(new RenderInstance(m.id, RenderInstance.Kind.OBJECT, mesh), null, false); + if (m.pole > 0) { + lines.line(m.x, m.y, m.z, m.x, cy, m.z, m.color); + } + if (!Float.isNaN(m.yaw) && m.shape != Marker.Shape.ARROW) { + float dx = (float) Math.sin(m.yaw) * 0.3f, dz = -(float) Math.cos(m.yaw) * 0.3f; + lines.line(m.x, cy, m.z, m.x + dx, cy, m.z + dz, m.color); + } + } + b.addOverlay(lines, null); + return b.upload(null); + } + + private static RenderMesh solid(float[][] v, int[] tris, float x, float y, float z, float s, int color) { + int nt = tris.length / 3; + RenderMesh m = new RenderMesh("marker", v.length, nt); + for (int i = 0; i < v.length; i++) { + m.positions[3 * i] = x + v[i][0] * s; + m.positions[3 * i + 1] = y + v[i][1] * s; + m.positions[3 * i + 2] = z + v[i][2] * s; + } + System.arraycopy(tris, 0, m.triangles, 0, tris.length); + for (int t = 0; t < nt; t++) { + m.pages[t] = -1; + m.triFlags[t] = RenderMesh.DOUBLE_SIDED; + // fake shading per face: upward faces brighter + float[] a = v[tris[3 * t]], bb = v[tris[3 * t + 1]], c = v[tris[3 * t + 2]]; + float ny = ((bb[2] - a[2]) * (c[0] - a[0]) - (bb[0] - a[0]) * (c[2] - a[2])); + float len = (float) Math.sqrt(sq((bb[1] - a[1]) * (c[2] - a[2]) - (bb[2] - a[2]) * (c[1] - a[1])) + + sq(ny) + + sq((bb[0] - a[0]) * (c[1] - a[1]) - (bb[1] - a[1]) * (c[0] - a[0]))); + float f = 0.7f + 0.3f * (len > 0 ? ny / len : 0); + m.triColors[t] = Legend.dim(color, Math.max(0.45f, f)) | 0xff; + } + return m; + } + + /** Flat arrow on the ground pointing along yaw (player start). */ + private static RenderMesh arrow(Marker mk) { + float[][] p = { + {-0.12f, -0.3f}, {0.12f, -0.3f}, {0.12f, 0.15f}, {-0.12f, 0.15f}, {-0.3f, 0.15f}, {0.3f, 0.15f}, {0, 0.5f} + }; + int[] tris = {0, 1, 2, 0, 2, 3, 4, 5, 6}; + RenderMesh m = new RenderMesh("arrow", p.length, 3); + float c = (float) Math.cos(mk.yaw), s = (float) Math.sin(mk.yaw); + float k = mk.size / 0.3f; + for (int i = 0; i < p.length; i++) { + // local x = right, local y = forward + float rx = p[i][0] * k, fw = p[i][1] * k; + m.positions[3 * i] = mk.x + rx * c + fw * s; + m.positions[3 * i + 1] = mk.y + 0.03f; + m.positions[3 * i + 2] = mk.z + rx * s - fw * c; + } + System.arraycopy(tris, 0, m.triangles, 0, tris.length); + for (int t = 0; t < 3; t++) { + m.pages[t] = -1; + m.triFlags[t] = RenderMesh.DOUBLE_SIDED; + m.triColors[t] = mk.color; + } + return m; + } + + private static float sq(float f) { + return f * f; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Navigation.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Navigation.java new file mode 100644 index 0000000..7eb4782 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Navigation.java @@ -0,0 +1,194 @@ +package com.eliasmueller.llamamod.editor.render; + +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import org.joml.Vector3f; + +/** + * Viewport navigation: mouse bindings per preset and the camera math (pure, unit-tested in + * {@code NavigationTest}). Zoom is exponential (a constant percentage per wheel notch) towards a + * robust anchor point, orbit keeps the pivot at its screen position with the pitch clamped to + * ±89°, pan moves the point at the grab depth with the cursor. + */ +public final class Navigation { + private Navigation() {} + + public enum Preset { + FUSION("Fusion 360"), + BLENDER("Blender"), + UNITY("Unity / Unreal"); + + public final String label; + + Preset(String label) { + this.label = label; + } + + /** Preset by enum name or label; Fusion 360 when unknown. */ + public static Preset parse(String s) { + for (Preset p : values()) { + if (p.name().equalsIgnoreCase(s) || p.label.equalsIgnoreCase(s)) { + return p; + } + } + return FUSION; + } + } + + public enum Action { + NONE, + ORBIT, + PAN, + ZOOM, + /** Free look in place (+ WASD/QE fly while held). */ + LOOK + } + + /** Pitch limit of orbit and look (no flip over the poles). */ + public static final float PITCH_LIMIT = (float) Math.toRadians(89); + + /** Camera action of a mouse button press (left without Alt is never navigation). */ + public static Action drag(Preset p, int button, boolean shift, boolean ctrl, boolean alt) { + boolean l = button == MouseEvent.BUTTON1, m = button == MouseEvent.BUTTON2, r = button == MouseEvent.BUTTON3; + if (l) { + return alt ? Action.ORBIT : Action.NONE; + } + return switch (p) { + case FUSION -> m + ? (shift ? Action.ORBIT : ctrl ? Action.ZOOM : Action.PAN) + : r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE; + case BLENDER -> m + ? (shift ? Action.PAN : ctrl ? Action.ZOOM : alt ? Action.PAN : Action.ORBIT) + : r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE; + case UNITY -> m + ? (ctrl ? Action.ZOOM : Action.PAN) + : r && alt ? Action.ZOOM : r ? Action.LOOK : Action.NONE; + }; + } + + /** Rows {keys, action} for the help table and the status bar. */ + public static List<String[]> cheatSheet(Preset p) { + List<String[]> rows = new ArrayList<>(); + switch (p) { + case FUSION -> { + rows.add(new String[] {"Middle drag", "Pan"}); + rows.add(new String[] {"Shift+middle drag", "Orbit around the point under the cursor"}); + rows.add(new String[] {"Double-click middle", "Fit all"}); + } + case BLENDER -> { + rows.add(new String[] {"Middle drag", "Orbit around the point under the cursor"}); + rows.add(new String[] {"Shift+middle drag", "Pan"}); + rows.add(new String[] {"Ctrl+middle drag", "Zoom"}); + } + case UNITY -> { + rows.add(new String[] {"Alt+left drag", "Orbit around the point under the cursor"}); + rows.add(new String[] {"Middle drag", "Pan"}); + rows.add(new String[] {"Alt+right drag", "Zoom"}); + } + } + rows.add(new String[] {"Mouse wheel", "Zoom towards the cursor (smooth, constant steps)"}); + rows.add( + new String[] {"Right drag", "Look around; W A S D move, Q / E down / up, Shift faster, wheel = speed"}); + if (p != Preset.UNITY) { + rows.add(new String[] {"Alt+left drag", "Orbit (touchpads, no middle button)"}); + } + rows.add(new String[] {"View cube", "Click a face, edge or corner to snap; drag to orbit; house = home view"}); + rows.add(new String[] {"F / Home", "Frame the selection / the whole level"}); + rows.add(new String[] {"Numpad 7 / 1 / 3 / 5", "Top / front / side / perspective-ortho toggle"}); + return rows; + } + + /** One-line summary for the status bar. */ + public static String statusLine(Preset p) { + return switch (p) { + case FUSION -> "Fusion 360: MMB pan · Shift+MMB orbit · wheel zoom · RMB look+WASD"; + case BLENDER -> "Blender: MMB orbit · Shift+MMB pan · Ctrl+MMB zoom · RMB look+WASD"; + case UNITY -> "Unity/Unreal: RMB look+WASD · Alt+LMB orbit · MMB pan · wheel zoom"; + }; + } + + // ------------------------------------------------------------------ math + + /** Distance factor for wheel notches (> 0 = towards the user = out); percent per notch, e.g. 0.12. */ + public static float zoomFactor(double notches, float percent, boolean invert) { + double p = Math.max(0.01, Math.min(0.6, percent)); + return (float) Math.pow(1 - p, -(invert ? -notches : notches)); + } + + /** + * Distance along the cursor ray to zoom towards: the surface hit when it is in a sane range + * (in front of the near plane, not beyond 3× the pivot depth or the far plane), else the + * pivot's depth along the ray, so pointing at the sky or a far mountain never makes a huge step. + */ + public static float anchorDistance(float hit, float pivotDepth, float near, float far) { + float pd = Math.max(pivotDepth, 4 * near); + if (Float.isFinite(hit) && hit > 2 * near && hit < 3 * pd && hit < 0.9f * far) { + return hit; + } + return pd; + } + + /** Moves the camera towards/away from the point at {@code anchor} along {@code dir} by factor. */ + public static void zoom(Camera cam, Vector3f dir, float anchor, float factor) { + float min = 2 * cam.near; + float nd = Math.max(min, anchor * factor); + cam.position.add(new Vector3f(dir).mul(anchor - nd)); + float cos = Math.max(0.05f, dir.dot(cam.forward())); + // the anchor becomes the orbit pivot depth + cam.distance = Math.max(min, nd * cos); + } + + /** Orthographic zoom keeping the world point under the cursor fixed. */ + public static void zoomOrtho(Camera cam, double px, double py, float factor) { + Vector3f before = cam.ray(px, py)[0]; + cam.orthoSize = Math.max(0.05f, Math.min(5000, cam.orthoSize * factor)); + Vector3f after = cam.ray(px, py)[0]; + cam.position.add(before.sub(after)); + } + + /** + * Orbits about a world pivot: the camera keeps its position relative to the pivot in camera + * space, so the pivot stays at the same screen position. Pitch is clamped to ±89°. + */ + public static void orbit(Camera cam, Vector3f pivot, float dYaw, float dPitch) { + Vector3f rel = new Vector3f(cam.position).sub(pivot); + Vector3f r0 = cam.right(), u0 = cam.up(), f0 = cam.forward(); + float rr = rel.dot(r0), ru = rel.dot(u0), rf = rel.dot(f0); + cam.yaw = wrap(cam.yaw + dYaw); + cam.pitch = clampPitch(cam.pitch + dPitch); + Vector3f r1 = cam.right(), u1 = cam.up(), f1 = cam.forward(); + cam.position.set(pivot).add(r1.mul(rr)).add(u1.mul(ru)).add(f1.mul(rf)); + if (rf < 0) { + cam.distance = Math.max(2 * cam.near, -rf); + } + } + + /** Free look in place. */ + public static void look(Camera cam, float dYaw, float dPitch) { + cam.yaw = wrap(cam.yaw + dYaw); + cam.pitch = clampPitch(cam.pitch + dPitch); + } + + /** World units per pixel at a view depth. */ + public static float pixelSizeAt(Camera cam, float depth) { + if (cam.ortho) { + return 2 * cam.orthoSize / cam.height(); + } + return (float) (2 * Math.max(cam.near, depth) * Math.tan(cam.fovY / 2) / cam.height()); + } + + /** Pans so that a point at {@code depth} follows the cursor by (dx, dy) pixels. */ + public static void pan(Camera cam, float depth, float dx, float dy) { + float px = pixelSizeAt(cam, depth); + cam.position.add(cam.right().mul(-dx * px)).add(cam.up().mul(dy * px)); + } + + public static float clampPitch(float p) { + return Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, p)); + } + + private static float wrap(float a) { + return (float) Math.IEEEremainder(a, 2 * Math.PI); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Overlay.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Overlay.java new file mode 100644 index 0000000..370aa04 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Overlay.java @@ -0,0 +1,121 @@ +package com.eliasmueller.llamamod.editor.render; + +import java.util.Arrays; + +/** Unlit helper geometry: coloured line segments and (translucent) triangles. Colours 0xRRGGBBAA. */ +public final class Overlay { + private float[] lines = new float[0]; + private int[] lineColors = new int[0]; + private int nLines; + private float[] tris = new float[0]; + private int[] triColors = new int[0]; + private int nTris; + + public void line(float ax, float ay, float az, float bx, float by, float bz, int rgba) { + if (nLines == lineColors.length) { + int n = Math.max(16, nLines * 2); + lines = Arrays.copyOf(lines, n * 6); + lineColors = Arrays.copyOf(lineColors, n); + } + int o = nLines * 6; + lines[o] = ax; + lines[o + 1] = ay; + lines[o + 2] = az; + lines[o + 3] = bx; + lines[o + 4] = by; + lines[o + 5] = bz; + lineColors[nLines++] = rgba; + } + + public void line(double[] a, double[] b, int rgba) { + line((float) a[0], (float) a[1], (float) a[2], (float) b[0], (float) b[1], (float) b[2], rgba); + } + + public void triangle(double[] a, double[] b, double[] c, int rgba) { + growTris(); + int o = nTris * 9; + double[][] p = {a, b, c}; + for (int k = 0; k < 3; k++) { + tris[o + 3 * k] = (float) p[k][0]; + tris[o + 3 * k + 1] = (float) p[k][1]; + tris[o + 3 * k + 2] = (float) p[k][2]; + } + triColors[nTris++] = rgba; + } + + private void growTris() { + if (nTris == triColors.length) { + int n = Math.max(16, nTris * 2); + tris = Arrays.copyOf(tris, n * 9); + triColors = Arrays.copyOf(triColors, n); + } + } + + /** Twelve edges of an axis-aligned box. */ + public void box(double x0, double y0, double z0, double x1, double y1, double z1, int rgba) { + double[][] p = { + {x0, y0, z0}, + {x1, y0, z0}, + {x1, y0, z1}, + {x0, y0, z1}, + {x0, y1, z0}, + {x1, y1, z0}, + {x1, y1, z1}, + {x0, y1, z1} + }; + int[][] e = {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}}; + for (int[] k : e) { + line(p[k[0]], p[k[1]], rgba); + } + } + + /** Appends all lines and triangles of another overlay. */ + public void append(Overlay o) { + for (int i = 0; i < o.nLines; i++) { + int k = 6 * i; + line( + o.lines[k], + o.lines[k + 1], + o.lines[k + 2], + o.lines[k + 3], + o.lines[k + 4], + o.lines[k + 5], + o.lineColors[i]); + } + for (int i = 0; i < o.nTris; i++) { + growTris(); + System.arraycopy(o.tris, 9 * i, tris, 9 * nTris, 9); + triColors[nTris++] = o.triColors[i]; + } + } + + public int lineCount() { + return nLines; + } + + public int triangleCount() { + return nTris; + } + + /** Segment endpoints, 6 floats per line (valid up to {@link #lineCount()}). */ + public float[] lines() { + return lines; + } + + public int[] lineColors() { + return lineColors; + } + + public float[] triangles() { + return tris; + } + + public int[] triangleColors() { + return triColors; + } + + public void clear() { + nLines = 0; + nTris = 0; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/PickId.java b/src/main/java/com/eliasmueller/llamamod/editor/render/PickId.java new file mode 100644 index 0000000..d35b219 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/PickId.java @@ -0,0 +1,44 @@ +package com.eliasmueller.llamamod.editor.render; + +/** + * 32-bit pick ids: kind in the top 8 bits, index in the low 24 bits. 0 means "nothing". The ids are + * opaque to the renderer; this encoding is only a convention used by {@link LevelSceneAdapter}. + */ +public final class PickId { + public static final int TILE = 1; + public static final int OBJECT = 2; + public static final int WAYPOINT = 3; + public static final int ZONE = 4; + public static final int LIGHT = 5; + public static final int USER = 16; + + private PickId() {} + + public static int of(int kind, int index) { + return kind << 24 | (index & 0xffffff); + } + + public static int kind(int id) { + return id >>> 24; + } + + public static int index(int id) { + return id & 0xffffff; + } + + public static String toString(int id) { + if (id == 0) { + return "none"; + } + String k = + switch (kind(id)) { + case TILE -> "tile"; + case OBJECT -> "object"; + case WAYPOINT -> "waypoint"; + case ZONE -> "zone"; + case LIGHT -> "light"; + default -> "kind" + kind(id); + }; + return k + " " + index(id); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/RenderInstance.java b/src/main/java/com/eliasmueller/llamamod/editor/render/RenderInstance.java new file mode 100644 index 0000000..7bee832 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/RenderInstance.java @@ -0,0 +1,38 @@ +package com.eliasmueller.llamamod.editor.render; + +import org.joml.Matrix4f; + +/** + * Placed mesh. Instances of kind {@link Kind#TILE} are baked into static world batches (fast, many); + * all others are drawn one by one with their transform. After changing a field call {@link + * RenderScene#changed(RenderInstance)}. + */ +public final class RenderInstance { + public enum Kind { + TILE, + OBJECT, + PREVIEW + } + + /** Stable pick id ({@link PickId}), 0 = not pickable. */ + public final int id; + + public final Kind kind; + public RenderMesh mesh; + /** Local → world (export space: y up, z ≤ 0 for the level grid). */ + public final Matrix4f transform = new Matrix4f(); + /** 0xRRGGBBAA per mesh vertex with 0x80 = 1.0 (game convention), or {@code null} = white. */ + public int[] vertexColors; + /** Multiplied with the final colour (0xRRGGBBAA, 0xffffffff = none). */ + public int tint = 0xffffffff; + + public boolean visible = true; + /** Free-form group for visibility filters (e.g. "tiles", "objects"). */ + public String layer = ""; + + public RenderInstance(int id, Kind kind, RenderMesh mesh) { + this.id = id; + this.kind = kind; + this.mesh = mesh; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/RenderMesh.java b/src/main/java/com/eliasmueller/llamamod/editor/render/RenderMesh.java new file mode 100644 index 0000000..74fd2f4 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/RenderMesh.java @@ -0,0 +1,71 @@ +package com.eliasmueller.llamamod.editor.render; + +/** + * Triangle mesh in local space, shared by any number of {@link RenderInstance}s. Positions and + * normals are per vertex; texture coordinates, page and flags are per triangle corner / triangle, + * because the game assigns UVs per face (texture rectangles), not per vertex. + */ +public final class RenderMesh { + /** Triangle is drawn from both sides (face flag 0x400). */ + public static final int DOUBLE_SIDED = 1; + /** Ignore vertex colours (face flag 0x4000). */ + public static final int NO_VERTEX_COLOR = 2; + /** Shade with the vertex normals instead of baked vertex colours (objects). */ + public static final int LIT = 4; + + public static final int BLEND_OPAQUE = 0; + public static final int BLEND_ALPHA = 1; + public static final int BLEND_ADD = 2; + + public final String name; + /** x, y, z per vertex. */ + public final float[] positions; + /** nx, ny, nz per vertex (may be zero). */ + public final float[] normals; + /** Three vertex indices per triangle. */ + public final int[] triangles; + /** u, v per triangle corner (6 per triangle), normalised to the page. */ + public final float[] uvs; + /** Texture page per triangle, -1 = untextured (flat colour only). */ + public final int[] pages; + /** 0xRRGGBBAA per triangle, multiplied with the texel (flat colours, translucency). */ + public final int[] triColors; + /** Bit set of {@link #DOUBLE_SIDED}, {@link #NO_VERTEX_COLOR}, {@link #LIT} per triangle. */ + public final int[] triFlags; + /** {@link #BLEND_OPAQUE}, {@link #BLEND_ALPHA} or {@link #BLEND_ADD} per triangle. */ + public final int[] blend; + /** Optional local-space helper geometry that follows every instance (collision wireframe). */ + public Overlay overlay; + + public RenderMesh(String name, int vertexCount, int triangleCount) { + this.name = name; + positions = new float[vertexCount * 3]; + normals = new float[vertexCount * 3]; + triangles = new int[triangleCount * 3]; + uvs = new float[triangleCount * 6]; + pages = new int[triangleCount]; + triColors = new int[triangleCount]; + triFlags = new int[triangleCount]; + blend = new int[triangleCount]; + } + + public int vertexCount() { + return positions.length / 3; + } + + public int triangleCount() { + return pages.length; + } + + /** Local bounding box {minX, minY, minZ, maxX, maxY, maxZ}; zero box when empty. */ + public float[] bounds() { + if (positions.length == 0) { + return new float[6]; + } + float[] b = Culling.emptyBox(); + for (int i = 0; i < positions.length; i += 3) { + Culling.grow(b, positions[i], positions[i + 1], positions[i + 2]); + } + return b; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/RenderScene.java b/src/main/java/com/eliasmueller/llamamod/editor/render/RenderScene.java new file mode 100644 index 0000000..16cce83 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/RenderScene.java @@ -0,0 +1,85 @@ +package com.eliasmueller.llamamod.editor.render; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Everything the viewport draws. Plain mutable data; the renderer caches GPU buffers and rebuilds + * them after {@link #changed()} (everything), {@link #changed(RenderInstance)} (one instance) or + * {@link #texturesChanged()}. Not thread-safe: modify on the Swing thread. + */ +public final class RenderScene { + public final List<TexturePage> pages = new ArrayList<>(); + public final List<RenderInstance> instances = new ArrayList<>(); + public final List<Marker> markers = new ArrayList<>(); + /** World-space overlays by layer name (e.g. "waypoints", "zones"). */ + public final Map<String, Overlay> overlays = new LinkedHashMap<>(); + /** Background colour 0xRRGGBB. */ + public int background = 0x5d7186; + + private int version; + private int textureVersion; + private final Set<RenderInstance> dirty = new HashSet<>(); + private boolean allDirty = true; + + public void changed() { + allDirty = true; + version++; + } + + public void changed(RenderInstance inst) { + dirty.add(inst); + version++; + } + + /** Only markers and overlays changed (cheap: no chunk is rebuilt). */ + public void overlaysChanged() { + version++; + } + + public void texturesChanged() { + textureVersion++; + } + + public int version() { + return version; + } + + public int textureVersion() { + return textureVersion; + } + + /** Taken by the renderer: {@code null} means rebuild everything. */ + Set<RenderInstance> takeDirty() { + if (allDirty) { + allDirty = false; + dirty.clear(); + return null; + } + Set<RenderInstance> d = new HashSet<>(dirty); + dirty.clear(); + return d; + } + + public RenderInstance instance(int id) { + for (RenderInstance i : instances) { + if (i.id == id) { + return i; + } + } + return null; + } + + public Marker marker(int id) { + for (Marker m : markers) { + if (m.id == id) { + return m; + } + } + return null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Shaders.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Shaders.java new file mode 100644 index 0000000..3fd8db0 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Shaders.java @@ -0,0 +1,134 @@ +package com.eliasmueller.llamamod.editor.render; + +/** GLSL 3.30 core sources. */ +final class Shaders { + private Shaders() {} + + static final String MESH_VS = + """ + #version 330 core + layout(location = 0) in vec3 aPos; + layout(location = 1) in vec3 aNormal; + layout(location = 2) in vec2 aUv; + layout(location = 3) in vec4 aColor; + layout(location = 4) in vec4 aId; + layout(location = 5) in ivec2 aLayerFlags; + uniform mat4 uViewProj; + uniform mat4 uModel; + out vec2 vUv; + out vec4 vColor; + out vec3 vNormal; + out vec3 vWorld; + flat out vec4 vId; + flat out int vLayer; + flat out int vFlags; + void main() { + vec4 w = uModel * vec4(aPos, 1.0); + vWorld = w.xyz; + gl_Position = uViewProj * w; + vUv = aUv; + vColor = aColor; + vNormal = mat3(uModel) * aNormal; + vId = aId; + vLayer = aLayerFlags.x; + vFlags = aLayerFlags.y; + } + """; + + static final String MESH_FS = + """ + #version 330 core + in vec2 vUv; + in vec4 vColor; + in vec3 vNormal; + in vec3 vWorld; + flat in vec4 vId; + flat in int vLayer; + flat in int vFlags; + uniform sampler2DArray uTex; + uniform int uPass; // 0 opaque, 1 blended, 2 pick, 3 back sides of one-sided faces + uniform vec4 uTint; + uniform vec4 uPickId; + uniform int uUsePickId; + uniform vec3 uFogColor; + uniform vec2 uFog; // start, end (end <= start: off) + uniform vec4 uHighlight; // rgb, amount + uniform vec4 uFogMode; // view direction (0 = radial distance), max amount + uniform vec3 uFogOrigin; // distances are measured from here (normally the eye) + uniform float uFarClip; // > 0: discard beyond this view distance + uniform int uClipGhost; // 1: beyond uFarClip draw a faded ghost instead of discarding + uniform float uBackAlpha; // opacity of back sides (pass 3) + uniform int uShade; // 0 texture × colour, 1 vertex colours only + out vec4 fragColor; + void main() { + vec4 t = texture(uTex, vec3(vUv, float(vLayer))); + if (uPass != 1) { + if (t.a < 0.5) discard; + t.a = 1.0; + } + bool back = !gl_FrontFacing && (vFlags & 1) == 0; + if (uPass == 2) { + fragColor = uUsePickId != 0 ? uPickId : vId; + // bit 31 of the id (unused by pick ids) marks the back side of a one-sided face + if (back) fragColor.a = min(fragColor.a + 128.0 / 255.0, 1.0); + return; + } + float dist = dot(uFogMode.xyz, uFogMode.xyz) > 0.0 + ? dot(vWorld - uFogOrigin, uFogMode.xyz) : distance(vWorld, uFogOrigin); + bool beyond = uFarClip > 0.0 && dist > uFarClip; + if (beyond && uClipGhost == 0) discard; + if (uShade == 1) t.rgb = vec3(1.0); + vec4 c = t * vColor * uTint; + if ((vFlags & 4) != 0) { + vec3 n = normalize(vNormal); + float l = 0.62 + 0.38 * max(dot(n, normalize(vec3(0.35, 0.85, 0.4))), 0.0) + + 0.12 * max(dot(n, normalize(vec3(-0.5, 0.2, -0.6))), 0.0); + c.rgb *= l; + } + if (uPass == 1 && c.a < 0.004) discard; + c.rgb = mix(c.rgb, uHighlight.rgb, uHighlight.a); + if (beyond) { + // beyond the previewed far clip: a desaturated ghost instead of full fog colour + float g = dot(c.rgb, vec3(0.3, 0.59, 0.11)); + c.rgb = mix(mix(c.rgb, vec3(g), 0.6), uFogColor, 0.45); + c.a *= 0.55; + } else if (uFog.y > uFog.x) { + float f = clamp((dist - uFog.x) / (uFog.y - uFog.x), 0.0, 1.0); + c.rgb = mix(c.rgb, uFogColor, f * uFogMode.w); + } + if (uPass == 3) { + // back side: darkened, desaturated, translucent, fine diagonal hatch (screen space) + float g = dot(c.rgb, vec3(0.3, 0.59, 0.11)); + vec3 d = mix(vec3(g), c.rgb, 0.3) * 0.5; + float stripe = 1.0 - step(1.5, mod(gl_FragCoord.x + gl_FragCoord.y, 6.0)); + d = mix(d, min(d * 1.9 + 0.1, vec3(1.0)), stripe * 0.6); + c = vec4(d, clamp(uBackAlpha * (1.0 + 0.5 * stripe), 0.0, 1.0)); + } + fragColor = c; + } + """; + + static final String LINE_VS = + """ + #version 330 core + layout(location = 0) in vec3 aPos; + layout(location = 1) in vec4 aColor; + uniform mat4 uViewProj; + uniform mat4 uModel; + out vec4 vColor; + void main() { + gl_Position = uViewProj * uModel * vec4(aPos, 1.0); + vColor = aColor; + } + """; + + static final String LINE_FS = + """ + #version 330 core + in vec4 vColor; + out vec4 fragColor; + void main() { + fragColor = vColor; + } + """; +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/StratModels.java b/src/main/java/com/eliasmueller/llamamod/editor/render/StratModels.java new file mode 100644 index 0000000..563864f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/StratModels.java @@ -0,0 +1,221 @@ +package com.eliasmueller.llamamod.editor.render; + +import com.eliasmueller.llamamod.format.Bytes; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.Stpc; +import com.eliasmueller.llamamod.format.Strats; +import com.eliasmueller.llamamod.format.Trak; +import com.eliasmueller.llamamod.format.Vertex; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * STPC object models and which one a strat type shows. The strat bytecode sets its model with + * {@code 0xb2 <abs STPC offset of the model header>; 0x54} (push address, set model); the first such + * pair inside the strat's code is taken. Spawn stubs ({@code b2 <code> e0}) are followed to their + * target. Characters set skinned models through other opcodes (0x41 with animation sets) and are + * not resolved. + */ +public final class StratModels { + private static final int OP_PUSH_ADDR = 0xb2; + private static final int OP_SET_MODEL = 0x54; + private static final int OP_SPAWN = 0xe0; + private static final int SCAN_LIMIT = 2048; + + public final List<Model> models = new ArrayList<>(); + + private final byte[] stpc; + private final int codeStart; + private final int[] nameSites; + private final Map<Integer, Integer> byHeader = new HashMap<>(); + private final Map<Integer, Integer> cache = new HashMap<>(); + /** Animation set header offsets (FUN_0041f8b0 layout, see {@link Stpc}). */ + private final List<Integer> animSets = new ArrayList<>(); + + public StratModels(byte[] stpc, int wfpc) { + this.stpc = stpc; + Stpc.Layout lay = Stpc.walk(stpc, wfpc); + codeStart = lay.codeStart(); + for (Stpc.ModelSpan s : lay.models()) { + add( + s.header(), + Trak.parseModels(stpc, s.header(), 1, Trak.STPC_HEADER) + .models() + .get(0)); + } + for (Stpc.Extra e : lay.extra()) { + if (e.models().isEmpty()) { + continue; + } + List<Model> mm = Trak.parseModels( + stpc, e.models().get(0).header(), e.models().size(), Trak.STPC_HEADER) + .models(); + for (int i = 0; i < mm.size(); i++) { + add(e.models().get(i).header(), mm.get(i)); + } + } + int o = lay.models().isEmpty() + ? 4 + : lay.models().get(lay.models().size() - 1).end(); + int na = Bytes.u32(stpc, o); + o += 4; + for (int i = 0; i < na && o + 0x20 <= stpc.length; i++) { + animSets.add(o); + o = skipAnimSet(o); + } + nameSites = Strats.allNamed(stpc, codeStart).stream() + .mapToInt(Strats.Named::offset) + .sorted() + .toArray(); + } + + private void add(int header, Model m) { + byHeader.put(header, models.size()); + models.add(m); + } + + private int skipAnimSet(int h) { + int nfr = Bytes.u32(stpc, h + 8); + int a4 = Bytes.u32(stpc, h + 16); + int a6 = Bytes.u32(stpc, h + 24); + int o = firstMatrices(h); + if (a4 != 0) { + o += nfr * 4; + } + for (int f = 0; f < nfr; f++) { + if (a4 != 0) { + o += f == 0 ? a4 * 8 : a4 * 2 + ((a4 & 1) != 0 ? 2 : 0); + } + o += a6 * 0x40; + } + return o; + } + + /** Offset of the first bone matrix of frame 0 of an animation set (only sets without vertex deltas). */ + private int firstMatrices(int h) { + int a0 = Bytes.u32(stpc, h); + int nfr = Bytes.u32(stpc, h + 8); + int a3 = Bytes.i32(stpc, h + 12); + int o = h + 0x20 + a0 * 4; + if (a3 == 0) { + o += nfr * 8; + } + return o + nfr * 4; + } + + /** + * A skinned model posed with frame 0 of the first animation set referenced by the strat code that + * has the model's bone count (else any such set), or {@code null}. Bone matrices are 4×4 f32, + * row vectors (D3D): p' = p·M. + */ + public Model posed(int entry, int modelIndex) { + Model m = models.get(modelIndex); + int bones = m.extra.length / 2; + if (!skinned(m) || bones == 0) { + return null; + } + int set = -1; + int end = Math.min(stpc.length - 12, entry + SCAN_LIMIT * 2); + for (int o = entry; o >= codeStart && o < end && set < 0; o += 4) { + if ((Bytes.u32L(stpc, o) & 0xffff) == OP_PUSH_ADDR) { + int a = Bytes.i32(stpc, o + 4); + if (animSets.contains(a) && Bytes.u32(stpc, a + 24) == bones && Bytes.u32(stpc, a + 16) == 0) { + set = a; + } + } + } + if (set < 0) { + for (int a : animSets) { + if (Bytes.u32(stpc, a + 24) == bones && Bytes.u32(stpc, a + 16) == 0 && Bytes.u32(stpc, a + 8) > 0) { + set = a; + break; + } + } + } + if (set < 0) { + return null; + } + int mo = firstMatrices(set); + Model out = new Model(); + out.faces.addAll(m.faces); + int v = 0; + for (int b = 0; b < bones; b++) { + int cnt = Bytes.u16(m.extra, 2 * b); + float[] r = new float[16]; + for (int k = 0; k < 16; k++) { + r[k] = (float) Bytes.f32(stpc, mo + b * 0x40 + 4 * k); + } + for (int k = 0; k < cnt && v < m.verts.size(); k++, v++) { + Vertex p = m.verts.get(v); + double x = p.x(), y = p.y(), z = p.z(); + double nx = p.nx(), ny = p.ny(), nz = p.nz(); + out.verts.add(new Vertex( + x * r[0] + y * r[4] + z * r[8] + r[12], + x * r[1] + y * r[5] + z * r[9] + r[13], + x * r[2] + y * r[6] + z * r[10] + r[14], + nx * r[0] + ny * r[4] + nz * r[8], + nx * r[1] + ny * r[5] + nz * r[9], + nx * r[2] + ny * r[6] + nz * r[10])); + } + } + while (out.verts.size() < m.verts.size()) { + out.verts.add(m.verts.get(out.verts.size())); + } + return out; + } + + public int codeStart() { + return codeStart; + } + + /** Skinned (boned) models store bone-relative vertices; they are not drawn as rigid meshes. */ + public static boolean skinned(Model m) { + return m.extra.length > 0 && m.x84 == 0; + } + + /** Model index shown by the strat whose code starts at entry, or -1. */ + public int modelOf(int entry) { + Integer m = cache.get(entry); + if (m == null) { + cache.put(entry, -1); + m = resolve(entry); + cache.put(entry, m); + } + return m; + } + + private int resolve(int entry) { + if (entry < codeStart || entry + 32 > stpc.length) { + return -1; + } + for (int k = 0; k < 5; k++) { + int o = entry + 4 * k; + if (Bytes.u32L(stpc, o) == OP_PUSH_ADDR && Bytes.u32L(stpc, o + 8) == OP_SPAWN) { + int target = Bytes.i32(stpc, o + 4); + return target != entry ? modelOf(target) : -1; + } + } + int end = Math.min(stpc.length - 12, entry + SCAN_LIMIT); + // stop before the next strat: its name site lies a few words behind its first opcode + int own = -1; + for (int s : nameSites) { + if (own < 0 && s >= entry) { + own = s; + } else if (own >= 0 && s > own) { + end = Math.min(end, s - 16); + break; + } + } + for (int o = entry; o < end; o += 4) { + if ((Bytes.u32L(stpc, o) & 0xffff) == OP_PUSH_ADDR && (Bytes.u32L(stpc, o + 8) & 0xffff) == OP_SET_MODEL) { + Integer m = byHeader.get(Bytes.i32(stpc, o + 4)); + if (m != null) { + return m; + } + } + } + return -1; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/TexturePage.java b/src/main/java/com/eliasmueller/llamamod/editor/render/TexturePage.java new file mode 100644 index 0000000..9a7b8c5 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/TexturePage.java @@ -0,0 +1,7 @@ +package com.eliasmueller.llamamod.editor.render; + +/** + * Texture page: w·h pixels 0xRRGGBBAA, row-major from the top. Alpha 0 is transparent (cut out), + * alpha below 255 is translucent on blended triangles. + */ +public record TexturePage(int width, int height, int[] rgba) {} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/ViewCube.java b/src/main/java/com/eliasmueller/llamamod/editor/render/ViewCube.java new file mode 100644 index 0000000..1929c30 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/ViewCube.java @@ -0,0 +1,284 @@ +package com.eliasmueller.llamamod.editor.render; + +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.joml.Vector4f; + +/** + * Orientation cube in the top-right viewport corner (Fusion 360 style). Faces are named after the + * game views: TOP = +y, FRONT = export −z (the front view looks along game −z), RIGHT = + * +x. Clicking a face, edge or corner region snaps the view to look from that direction at the + * pivot; the house button above it goes home. Pure geometry/hit testing; the viewport draws it. + */ +public final class ViewCube { + private ViewCube() {} + + /** Geometry in cube space ([-1, 1]³), its matrix and the GL viewport rect (framebuffer pixels). */ + public record Frame( + Overlay lines, Overlay tris, Matrix4f matrix, int x, int y, int size, Overlay hud, Matrix4f hudMatrix) {} + + /** A region: direction from the cube centre (components -1/0/1), or the home button. */ + public record Hit(int dx, int dy, int dz, boolean home) { + static final Hit HOME = new Hit(0, 0, 0, true); + + public Vector3f direction() { + return new Vector3f(dx, dy, dz).normalize(); + } + } + + public static final int SIZE = 108; + public static final int MARGIN = 10; + static final int HOME = 22; + /** Face cells: |c| beyond this belongs to an edge/corner region. */ + static final float SPLIT = 0.56f; + + /** Cube rect in component pixels {x, y, size}. */ + public static int[] rect(int width) { + return new int[] {width - MARGIN - SIZE, MARGIN + HOME, SIZE}; + } + + /** Home button rect {x, y, w, h} (component pixels), left above the cube. */ + public static int[] homeRect(int width) { + int[] r = rect(width); + return new int[] {r[0] + 4, MARGIN - 4, HOME, HOME}; + } + + /** Rotation-only view (same handedness as the main view) with a fixed orthographic lens. */ + public static Matrix4f matrix(Camera cam) { + Matrix4f v = cam.viewMatrix(); + v.setTranslation(0, 0, 0); + return new Matrix4f().ortho(-1.8f, 1.8f, -1.8f, 1.8f, -10, 10).mul(v); + } + + /** Region under a component pixel, or null. */ + public static Hit hit(Camera cam, int width, int mx, int my) { + int[] h = homeRect(width); + if (mx >= h[0] && mx < h[0] + h[2] && my >= h[1] && my < h[1] + h[3]) { + return Hit.HOME; + } + int[] r = rect(width); + if (mx < r[0] || my < r[1] || mx >= r[0] + r[2] || my >= r[1] + r[2]) { + return null; + } + float nx = 2f * (mx - r[0] + 0.5f) / r[2] - 1, ny = 1 - 2f * (my - r[1] + 0.5f) / r[2]; + Matrix4f inv = matrix(cam).invert(); + Vector4f a = new Vector4f(nx, ny, -1, 1).mul(inv); + Vector4f b = new Vector4f(nx, ny, 1, 1).mul(inv); + Vector3f o = new Vector3f(a.x, a.y, a.z).div(a.w); + Vector3f d = new Vector3f(b.x, b.y, b.z).div(b.w).sub(o); + // slab test against [-1, 1]^3, nearest entry + float t0 = -Float.MAX_VALUE, t1 = Float.MAX_VALUE; + int axis = -1; + for (int k = 0; k < 3; k++) { + float ok = o.get(k), dk = d.get(k); + if (Math.abs(dk) < 1e-9f) { + if (ok < -1 || ok > 1) { + return null; + } + continue; + } + float ta = (-1 - ok) / dk, tb = (1 - ok) / dk; + float lo = Math.min(ta, tb), hi = Math.max(ta, tb); + if (lo > t0) { + t0 = lo; + axis = k; + } + t1 = Math.min(t1, hi); + } + if (axis < 0 || t0 > t1) { + return null; + } + Vector3f p = new Vector3f(d).mul(t0).add(o); + int[] c = new int[3]; + for (int k = 0; k < 3; k++) { + float v = p.get(k); + c[k] = k == axis ? (v > 0 ? 1 : -1) : v > SPLIT ? 1 : v < -SPLIT ? -1 : 0; + } + return new Hit(c[0], c[1], c[2], false); + } + + /** + * Camera angles looking from direction (dx, dy, dz) towards the pivot: {yaw, pitch}. Straight + * down/up keeps the top view's orientation (x right, game z downwards). + */ + public static float[] angles(Hit h) { + Vector3f f = h.direction().negate(); + float pitch = (float) Math.asin(Math.max(-1, Math.min(1, f.y))); + float yaw = Math.abs(f.x) + Math.abs(f.z) < 1e-6 ? (float) Math.PI : (float) Math.atan2(f.x, -f.z); + if (Math.abs(pitch) > Math.PI / 2 - 1e-3) { + pitch = (float) Math.copySign(Math.PI / 2, pitch); + } + return new float[] {yaw, pitch}; + } + + private static final int FACE = Legend.withAlpha(Legend.rgb(222, 226, 233), 238); + private static final int FACE_HOVER = Legend.withAlpha(Legend.rgb(120, 170, 245), 245); + private static final int EDGE = Legend.withAlpha(Legend.rgb(70, 76, 88), 255); + private static final int GRID = Legend.withAlpha(Legend.rgb(150, 156, 168), 140); + private static final int TEXT = Legend.withAlpha(Legend.rgb(40, 44, 54), 255); + + /** Face table: normal axis, sign, text right axis/sign, text up axis/sign, label. */ + private static final Object[][] FACES = { + {1, 1, 0, 1, 2, 1, "TOP"}, + {1, -1, 0, 1, 2, -1, "BOTTOM"}, + {2, -1, 0, 1, 1, 1, "FRONT"}, + {2, 1, 0, -1, 1, 1, "BACK"}, + {0, 1, 2, 1, 1, 1, "RIGHT"}, + {0, -1, 2, -1, 1, 1, "LEFT"}, + }; + + /** Builds the frame (framebuffer pixels: scale = framebuffer / component). */ + public static Frame build(Camera cam, int compWidth, int compHeight, float scale, Hit hover) { + Overlay lines = new Overlay(), tris = new Overlay(); + float[] cuts = {-1, -SPLIT, SPLIT, 1}; + for (Object[] f : FACES) { + int n = (int) f[0], ns = (int) f[1], ra = (int) f[2], rs = (int) f[3], ua = (int) f[4], us = (int) f[5]; + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + int[] reg = new int[3]; + reg[n] = ns; + reg[ra] = rs * (i - 1); + reg[ua] = us * (j - 1); + boolean hl = hover != null + && !hover.home() + && hover.dx() == reg[0] + && hover.dy() == reg[1] + && hover.dz() == reg[2]; + double[][] q = new double[4][]; + for (int k = 0; k < 4; k++) { + double[] p = new double[3]; + p[n] = ns; + p[ra] = rs * cuts[i + ((k == 1 || k == 2) ? 1 : 0)]; + p[ua] = us * cuts[j + (k >= 2 ? 1 : 0)]; + q[k] = p; + } + int col = hl ? FACE_HOVER : FACE; + tris.triangle(q[0], q[1], q[2], col); + tris.triangle(q[0], q[2], q[3], col); + } + } + for (int k = 1; k <= 2; k++) { + double[] a = new double[3], b = new double[3], c = new double[3], d = new double[3]; + a[n] = b[n] = c[n] = d[n] = ns * 1.001; + a[ra] = b[ra] = cuts[k]; + a[ua] = -1; + b[ua] = 1; + c[ua] = d[ua] = cuts[k]; + c[ra] = -1; + d[ra] = 1; + lines.line(a, b, GRID); + lines.line(c, d, GRID); + } + text(lines, (String) f[6], n, ns, ra, rs, ua, us); + } + for (int a = 0; a < 3; a++) { + for (int s1 = -1; s1 <= 1; s1 += 2) { + for (int s2 = -1; s2 <= 1; s2 += 2) { + double[] p = new double[3], q = new double[3]; + int b = (a + 1) % 3, c = (a + 2) % 3; + p[a] = -1; + q[a] = 1; + p[b] = q[b] = s1 * 1.002; + p[c] = q[c] = s2 * 1.002; + lines.line(p, q, EDGE); + } + } + } + int[] r = rect(compWidth); + int size = Math.round(r[2] * scale); + int x = Math.round(r[0] * scale); + int y = Math.round((compHeight - r[1] - r[2]) * scale); + Overlay hud = homeButton(compWidth, hover != null && hover.home()); + Matrix4f hm = new Matrix4f().ortho(0, compWidth, compHeight, 0, -1, 1); + return new Frame(lines, tris, matrix(cam), x, y, size, hud, hm); + } + + /** Home button (house glyph) in component pixels; pixel centres so axis-aligned lines rasterise. */ + private static Overlay homeButton(int width, boolean hl) { + Overlay o = new Overlay(); + int[] r = homeRect(width); + double x0 = r[0], y0 = r[1], x1 = r[0] + r[2], y1 = r[1] + r[3]; + int bg = hl ? FACE_HOVER : Legend.withAlpha(Legend.rgb(222, 226, 233), 215); + o.triangle(new double[] {x0, y0, 0}, new double[] {x1, y0, 0}, new double[] {x1, y1, 0}, bg); + o.triangle(new double[] {x0, y0, 0}, new double[] {x1, y1, 0}, new double[] {x0, y1, 0}, bg); + float cx = (float) (x0 + x1) / 2 + 0.5f, s = r[2] / 22f, hy = (float) y0 + 0.5f; + float[][] p = { + {cx - 7 * s, hy + 11 * s}, {cx, hy + 4 * s}, {cx + 7 * s, hy + 11 * s}, + {cx + 5 * s, hy + 9.5f * s}, {cx + 5 * s, hy + 18 * s}, {cx - 5 * s, hy + 18 * s}, + {cx - 5 * s, hy + 9.5f * s} + }; + int[][] seg = {{0, 1}, {1, 2}, {3, 4}, {4, 5}, {5, 6}}; + for (int[] g : seg) { + o.line(p[g[0]][0], p[g[0]][1], 0, p[g[1]][0], p[g[1]][1], 0, TEXT); + } + return o; + } + + // ------------------------------------------------------------------ stroke font + + /** Polylines per letter in a 0..1 × 0..1.4 box, pen-up between strokes (NaN). */ + private static final String[] GLYPHS = { + "T 0,1.4 1,1.4 N .5,1.4 .5,0", + "O 0,0 1,0 1,1.4 0,1.4 0,0", + "P 0,0 0,1.4 1,1.4 1,.7 0,.7", + "B 0,0 0,1.4 .8,1.4 .8,.7 0,.7 N .8,.7 1,.6 1,0 0,0", + "M 0,0 0,1.4 .5,.7 1,1.4 1,0", + "F 0,0 0,1.4 1,1.4 N 0,.7 .75,.7", + "R 0,0 0,1.4 1,1.4 1,.7 0,.7 1,0", + "N 0,0 0,1.4 1,0 1,1.4", + "A 0,0 0,1.4 1,1.4 1,0 N 0,.7 1,.7", + "C 1,0 0,0 0,1.4 1,1.4", + "K 0,0 0,1.4 N 1,1.4 0,.7 1,0", + "L 0,1.4 0,0 1,0", + "E 1,0 0,0 0,1.4 1,1.4 N 0,.7 .75,.7", + "I .5,0 .5,1.4", + "G 1,1.4 0,1.4 0,0 1,0 1,.7 .5,.7", + "H 0,0 0,1.4 N 1,0 1,1.4 N 0,.7 1,.7", + }; + + private static void text(Overlay o, String s, int n, int ns, int ra, int rs, int ua, int us) { + float h = 0.32f, w = 0.18f, gap = 0.07f; + float total = s.length() * w + (s.length() - 1) * gap; + float x0 = -total / 2, y0 = -h / 2; + for (int i = 0; i < s.length(); i++) { + String g = glyph(s.charAt(i)); + if (g == null) { + continue; + } + double[] prev = null; + for (String tok : g.substring(2).split(" ")) { + if (tok.equals("N")) { + prev = null; + continue; + } + String[] xy = tok.split(","); + float gx = x0 + i * (w + gap) + Float.parseFloat(xy[0]) * w; + float gy = y0 + Float.parseFloat(xy[1]) / 1.4f * h; + double[] p = new double[3]; + p[n] = ns * 1.004; + p[ra] = rs * gx; + p[ua] = us * gy; + if (prev != null) { + // twice, half a pixel apart: bolder, and never lost on a pixel edge + o.line(prev, p, TEXT); + double[] a = prev.clone(), b = p.clone(); + a[ua] += 0.016; + b[ua] += 0.016; + a[ra] += 0.016; + b[ra] += 0.016; + o.line(a, b, TEXT); + } + prev = p; + } + } + } + + private static String glyph(char c) { + for (String g : GLYPHS) { + if (g.charAt(0) == c) { + return g; + } + } + return null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/ViewerDemo.java b/src/main/java/com/eliasmueller/llamamod/editor/render/ViewerDemo.java new file mode 100644 index 0000000..0702043 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/ViewerDemo.java @@ -0,0 +1,343 @@ +package com.eliasmueller.llamamod.editor.render; + +import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import com.eliasmueller.llamamod.mods.testworld.TestWorld; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.event.MouseEvent; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.imageio.ImageIO; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import org.joml.Matrix4f; +import org.joml.Vector3f; + +/** + * Standalone level viewer for testing the viewport. + * + * <pre> + * ViewerDemo <level.wad | testworld> [--cd DIR] [--size WxH] [--collision] [--no-markers] + * [--view top|front|side] [--cam x,y,z,yawDeg,pitchDeg] [--fov vDeg] [--select ID[,ID]] [--frame] + * [--gizmo translate|rotate|scale] [--shot out.png] [--bench] + * </pre> + * + * IDs: {@code o12} = strat 12, {@code t5} = tile 5, {@code w3} = waypoint 3. With --shot the + * window renders once, saves and exits; --bench prints frame times while orbiting. + */ +public final class ViewerDemo { + private ViewerDemo() {} + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + System.err.println("usage: ViewerDemo <level.wad|testworld> [options]"); + System.exit(2); + } + Map<String, String> o = new HashMap<>(); + for (int i = 1; i < args.length; i++) { + String a = args[i]; + if (Set.of("--collision", "--no-markers", "--frame", "--bench", "--no-grid", "--no-fog", "--list") + .contains(a)) { + o.put(a, "1"); + } else if (i + 1 < args.length) { + o.put(a, args[++i]); + } + } + Path cd = Path.of(o.getOrDefault("--cd", System.getProperty("user.home") + "/.wine-lama/iso-dk/Setup/Eng/Cd")); + byte[] wad; + if (args[0].equals("testworld")) { + byte[] base = Files.readAllBytes(cd.resolve(LevelBuilder.BASE_WAD)); + wad = new TestWorld() + .buildWad( + base, + rel -> { + try { + Path p = cd.resolve(rel); + return Files.exists(p) ? Files.readAllBytes(p) : null; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }, + Map.of()); + } else { + wad = Files.readAllBytes(Path.of(args[0])); + } + long t0 = System.nanoTime(); + LevelSceneAdapter.Result res = LevelSceneAdapter.fromWad(wad); + System.out.printf( + "scene: %d instances, %d markers, %d pages, converted in %.0f ms%n", + res.scene().instances.size(), + res.scene().markers.size(), + res.scene().pages.size(), + (System.nanoTime() - t0) / 1e6); + if (o.containsKey("--list")) { + for (int i = 0; i < res.stratNames().size(); i++) { + var s = res.level().map().strats.get(i); + System.out.printf( + "o%d %s %.2f %.2f %.2f%n", + i, res.stratNames().get(i), s.x() / 4096.0, s.y() / 4096.0, -s.z() / 4096.0); + } + return; + } + SwingUtilities.invokeLater(() -> show(res, o)); + } + + static int parseId(String s) { + int k = + switch (s.charAt(0)) { + case 't' -> PickId.TILE; + case 'o' -> PickId.OBJECT; + case 'w' -> PickId.WAYPOINT; + case 'z' -> PickId.ZONE; + default -> throw new IllegalArgumentException("id " + s); + }; + return PickId.of(k, Integer.parseInt(s.substring(1))); + } + + private static void show(LevelSceneAdapter.Result res, Map<String, String> o) { + RenderScene scene = res.scene(); + JFrame f = new JFrame("Llama Level Viewer – " + res.level().wad().levelName()); + Viewport vp = new Viewport(); + String[] size = o.getOrDefault("--size", "1280x720").split("x"); + vp.setPreferredSize(new Dimension(Integer.parseInt(size[0]), Integer.parseInt(size[1]))); + vp.setScene(scene); + vp.setGrid(0, new float[] {0, -res.gridH(), res.gridW(), 0}); + vp.setLayerVisible(LevelSceneAdapter.LAYER_COLLISION, o.containsKey("--collision")); + if (o.containsKey("--no-markers")) { + for (String l : List.of( + LevelSceneAdapter.LAYER_MARKERS, + LevelSceneAdapter.LAYER_WAYPOINTS, + LevelSceneAdapter.LAYER_ZONES)) { + vp.setLayerVisible(l, false); + } + } + if (o.containsKey("--hide")) { + for (String l : o.get("--hide").split(",")) { + vp.setLayerVisible(l, false); + } + } + vp.setGridVisible(!o.containsKey("--no-grid")); + vp.setFog(!o.containsKey("--no-fog")); + + JLabel status = new JLabel(" "); + JPanel bar = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2)); + for (String l : List.of( + LevelSceneAdapter.LAYER_TILES, + LevelSceneAdapter.LAYER_OBJECTS, + LevelSceneAdapter.LAYER_MARKERS, + LevelSceneAdapter.LAYER_WAYPOINTS, + LevelSceneAdapter.LAYER_ZONES, + LevelSceneAdapter.LAYER_COLLISION)) { + JCheckBox cb = new JCheckBox(l, vp.isLayerVisible(l)); + cb.setFocusable(false); + cb.addActionListener(e -> vp.setLayerVisible(l, cb.isSelected())); + bar.add(cb); + } + JComboBox<Camera.View> views = new JComboBox<>(Camera.View.values()); + views.setFocusable(false); + views.addActionListener(e -> vp.setView((Camera.View) views.getSelectedItem())); + bar.add(views); + JComboBox<Gizmo.Mode> modes = new JComboBox<>(Gizmo.Mode.values()); + modes.setFocusable(false); + modes.addActionListener(e -> { + vp.gizmo().setMode((Gizmo.Mode) modes.getSelectedItem()); + vp.requestRender(); + }); + bar.add(modes); + JCheckBox snap = new JCheckBox("snap", false); + snap.setFocusable(false); + snap.addActionListener(e -> vp.gizmo().setSnap(new Gizmo.Snap(snap.isSelected(), 0.25f, 15f, 0.1f))); + bar.add(snap); + + vp.addViewportListener(new ViewportListener() { + @Override + public void picked(PickEvent e) { + if (e.button() != MouseEvent.BUTTON1) { + return; + } + select(vp, scene, e.id()); + status.setText(describe(res, e.id()) + + (e.world() != null + ? String.format(" @ %.2f %.2f %.2f", e.world().x, e.world().y, e.world().z) + : "")); + } + + @Override + public void hovered(int id) { + if (id != 0) { + status.setText("hover: " + describe(res, id)); + } + } + + @Override + public void gizmo(GizmoEvent e) { + // demo: apply the delta directly (the editor turns END into an undoable command) + Matrix4f m = e.phase() == GizmoEvent.Phase.CANCEL ? e.start() : e.result(); + for (int id : vp.selection()) { + RenderInstance ri = scene.instance(id); + if (ri != null) { + ri.transform.set(m); + scene.changed(ri); + } + Marker mk = scene.marker(id); + if (mk != null) { + Vector3f t = m.getTranslation(new Vector3f()); + mk.x = t.x; + mk.y = t.y; + mk.z = t.z; + scene.changed(); + } + } + if (e.phase() != GizmoEvent.Phase.START) { + vp.gizmo().setTarget(m); + } + vp.requestRender(); + } + }); + + f.getContentPane().add(bar, BorderLayout.NORTH); + f.getContentPane().add(vp, BorderLayout.CENTER); + f.getContentPane().add(status, BorderLayout.SOUTH); + f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + f.pack(); + f.setVisible(true); + + Gizmo.Mode gm = + o.containsKey("--gizmo") ? Gizmo.Mode.valueOf(o.get("--gizmo").toUpperCase()) : null; + Timer start = new Timer(600, e -> { + if (gm != null) { + vp.gizmo().setMode(gm); + } + List<Integer> sel = new ArrayList<>(); + if (o.containsKey("--select")) { + for (String s : o.get("--select").split(",")) { + sel.add(parseId(s)); + } + select(vp, scene, sel.get(0)); + vp.setSelection(sel); + } + vp.frameAll(); + if (o.containsKey("--view")) { + vp.setView(Camera.View.valueOf(o.get("--view").toUpperCase())); + vp.frameAll(); + } + if (o.containsKey("--frame") && !sel.isEmpty()) { + vp.frameSelection(); + } + if (o.containsKey("--cam")) { + vp.stopAnimation(); + String[] c = o.get("--cam").split(","); + Camera cam = vp.camera(); + cam.position.set(Float.parseFloat(c[0]), Float.parseFloat(c[1]), Float.parseFloat(c[2])); + cam.yaw = (float) Math.toRadians(Double.parseDouble(c[3])); + cam.pitch = (float) Math.toRadians(Double.parseDouble(c[4])); + if (c.length > 5) { + cam.distance = Float.parseFloat(c[5]); + } + } + if (o.containsKey("--fov")) { + vp.camera().fovY = (float) Math.toRadians(Double.parseDouble(o.get("--fov"))); + } + System.out.println("GL: " + vp.glInfo() + (vp.glError() != null ? " ERROR " + vp.glError() : "")); + if (o.containsKey("--bench")) { + bench(vp); + if (!o.containsKey("--shot")) { + System.exit(0); + } + } + if (o.containsKey("--shot")) { + // let the camera animation finish + Timer t = new Timer(400, ev -> { + try { + vp.renderNow(); + File out = new File(o.get("--shot")); + ImageIO.write(vp.screenshot(), out.getName().endsWith(".jpg") ? "jpg" : "png", out); + Viewport.GlRendererStats st = vp.stats(); + System.out.printf( + "shot %s: %d draw calls, %d triangles, chunks %d/%d, cpu %.1f ms%n", + out, st.drawCalls(), st.triangles(), st.chunksDrawn(), st.chunksTotal(), st.cpuMs()); + } catch (IOException ex) { + ex.printStackTrace(); + } + System.exit(0); + }); + t.setRepeats(false); + t.start(); + } + }); + start.setRepeats(false); + start.start(); + } + + private static void select(Viewport vp, RenderScene scene, int id) { + vp.setSelection(id == 0 ? List.of() : List.of(id)); + RenderInstance ri = scene.instance(id); + Marker mk = scene.marker(id); + Matrix4f m = null; + if (ri != null) { + m = new Matrix4f(ri.transform); + } else if (mk != null) { + m = new Matrix4f().translation(mk.x, mk.y, mk.z); + } + vp.gizmo().setVisible(m != null); + if (m != null) { + vp.gizmo().setTarget(m); + } + } + + private static String describe(LevelSceneAdapter.Result res, int id) { + int k = PickId.kind(id), i = PickId.index(id); + if (k == PickId.OBJECT && i < res.stratNames().size()) { + return "object " + i + " " + res.stratNames().get(i); + } + if (k == PickId.TILE && i < res.level().map().tiles.size()) { + return "tile " + i + " (model " + res.level().map().tiles.get(i).model() + ")"; + } + return PickId.toString(id); + } + + /** Orbits once around the level and prints frame statistics. */ + private static void bench(Viewport vp) { + Camera c = vp.camera(); + Vector3f piv = c.pivot(); + int frames = 120; + double total = 0, worst = 0, cpu = 0; + Viewport.GlRendererStats st = null; + for (int i = 0; i < frames; i++) { + c.yaw += (float) (Math.PI * 2 / frames); + c.position.set(c.forward().mul(-c.distance).add(piv)); + long t0 = System.nanoTime(); + vp.renderNow(); + double ms = (System.nanoTime() - t0) / 1e6; + total += ms; + worst = Math.max(worst, ms); + st = vp.stats(); + cpu += st.cpuMs(); + } + System.out.printf( + "bench: %d frames, avg %.1f ms (%.0f fps), CPU submit %.1f ms, worst %.1f ms, last: %d draw calls, %d triangles, chunks %d/%d%n", + frames, + total / frames, + 1000 / (total / frames), + cpu / frames, + worst, + st.drawCalls(), + st.triangles(), + st.chunksDrawn(), + st.chunksTotal()); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/Viewport.java b/src/main/java/com/eliasmueller/llamamod/editor/render/Viewport.java new file mode 100644 index 0000000..ef88a3a --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/Viewport.java @@ -0,0 +1,1301 @@ +package com.eliasmueller.llamamod.editor.render; + +import static org.lwjgl.opengl.GL33C.*; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Graphics; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.image.BufferedImage; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import org.joml.Vector3f; +import org.joml.Vector4f; +import org.lwjgl.opengl.GL; +import org.lwjgl.opengl.awt.AWTGLCanvas; +import org.lwjgl.opengl.awt.GLData; +import org.lwjgl.system.MemoryUtil; + +/** + * 3D viewport (Swing component). Wraps an LWJGL {@code AWTGLCanvas} with an OpenGL 3.3 core + * context. + * + * <p>Navigation follows a {@link Navigation.Preset} (default Fusion 360: middle = pan, Shift+middle = + * orbit about the point under the cursor, double-click middle = fit all); in every preset right + * mouse = look around + WASD/QE fly (Shift faster, wheel while flying = speed), Alt+left = orbit, + * wheel = smooth exponential zoom towards the cursor. F frame selection, Home frame all, numpad + * 7/1/3 top/front/side, 5 perspective/ortho toggle, a {@link ViewCube} in the top-right corner. + * Left click picks; the gizmo (W/E/R = translate/rotate/scale, Q = toggle local) takes left drags + * on its handles. + */ +public final class Viewport extends JPanel { + private final Camera camera = new Camera(); + private final Gizmo gizmo = new Gizmo(); + private final List<ViewportListener> listeners = new CopyOnWriteArrayList<>(); + private final Set<String> hidden = new HashSet<>(); + private final Set<Integer> selection = new LinkedHashSet<>(); + private RenderScene scene; + private int hover; + private boolean showGrid = true; + private float gridY; + private float[] gridBounds; + private boolean fog = true; + private FogOverride fogOverride; + private int shading = SHADE_NORMAL; + /** Fly speed in cells per second. */ + private float flySpeed = 6; + + private Navigation.Preset navPreset = Navigation.Preset.FUSION; + private float zoomPercent = 0.12f; + private boolean invertZoom; + private float orbitDegPerPx = 0.35f; + private boolean showBackFaces = !Boolean.getBoolean("llamamod.editor.noBackFaces"); + private float backFaceOpacity = 0.3f; + private boolean viewCubeVisible = true; + private ViewCube.Hit cubeHover; + private boolean hoverBack; + private boolean lastPickBack; + /** Orbit pivot shown while orbiting, else null. */ + private Vector3f pivotMarker; + + private final Camera zoomFrom = new Camera(); + private final Camera zoomTo = new Camera(); + private long zoomStart; + private static final long ZOOM_NS = 120_000_000L; + + private Canvas canvas; + private boolean fallbackTried; + private GlRenderer renderer; + private String glInfo = ""; + private String glError; + private final Timer timer; + private boolean dirty = true; + private long lastTick = System.nanoTime(); + private final Set<Integer> keys = new HashSet<>(); + private boolean flying; + private BufferedImage capture; + private boolean captureNext; + private int[] pendingHover; + private long lastHoverPick; + + private final Camera animFrom = new Camera(); + private final Camera animTo = new Camera(); + private long animStart; + private static final long ANIM_NS = 220_000_000L; + + private final List<Double> frameTimes = new ArrayList<>(); + private ViewportInput input; + private Overlay toolOverlay; + private Overlay toolTopOverlay; + private Overlay screenOverlay; + + public Viewport() { + super(new BorderLayout()); + setBackground(Color.DARK_GRAY); + canvas = createCanvas(Integer.getInteger("llamamod.editor.msaa", 4)); + timer = new Timer(15, e -> tick()); + timer.start(); + } + + private Canvas createCanvas(int samples) { + GLData d = new GLData(); + d.majorVersion = 3; + d.minorVersion = 3; + d.profile = GLData.Profile.CORE; + d.forwardCompatible = true; + d.samples = samples; + d.depthSize = 24; + Canvas c = new Canvas(d); + c.setFocusable(true); + add(c, BorderLayout.CENTER); + Controls ctl = new Controls(); + c.addMouseListener(ctl); + c.addMouseMotionListener(ctl); + c.addMouseWheelListener(ctl); + c.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (!flying && input != null && input.keyPressed(e)) { + e.consume(); + return; + } + keys.add(e.getKeyCode()); + handleKey(e); + } + + @Override + public void keyReleased(KeyEvent e) { + keys.remove(e.getKeyCode()); + if (!flying && input != null && input.keyReleased(e)) { + e.consume(); + } + } + }); + c.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + requestRender(); + } + }); + return c; + } + + // ------------------------------------------------------------------ public API + + /** Tool input hook (null = none), see {@link ViewportInput}. */ + public void setInput(ViewportInput in) { + input = in; + } + + /** + * Helper geometry of the active tool in world space: {@code depthTested} is hidden behind + * geometry, {@code onTop} is drawn over everything (like the gizmo). Null = none. + */ + public void setToolOverlay(Overlay depthTested, Overlay onTop) { + toolOverlay = depthTested; + toolTopOverlay = onTop; + requestRender(); + } + + /** Lines/triangles in component pixel coordinates (x, y, ignored z) drawn on top, e.g. a marquee. */ + public void setScreenOverlay(Overlay pixels) { + screenOverlay = pixels; + requestRender(); + } + + /** The GL canvas (keyboard focus owner while the viewport is active; drop target). */ + public Component canvas() { + return canvas; + } + + public void setScene(RenderScene s) { + scene = s; + hover = 0; + selection.clear(); + requestRender(); + } + + public RenderScene scene() { + return scene; + } + + public Camera camera() { + return camera; + } + + public Gizmo gizmo() { + return gizmo; + } + + public void addViewportListener(ViewportListener l) { + listeners.add(l); + } + + public void removeViewportListener(ViewportListener l) { + listeners.remove(l); + } + + /** Highlighted ids (instances and markers). Selected tiles are drawn unbatched so they move cheaply. */ + public void setSelection(Collection<Integer> ids) { + selection.clear(); + selection.addAll(ids); + requestRender(); + } + + public Set<Integer> selection() { + return Set.copyOf(selection); + } + + public int hover() { + return hover; + } + + public void setLayerVisible(String layer, boolean visible) { + if (visible ? hidden.remove(layer) : hidden.add(layer)) { + requestRender(); + } + } + + public boolean isLayerVisible(String layer) { + return !hidden.contains(layer); + } + + public void setGridVisible(boolean v) { + showGrid = v; + requestRender(); + } + + /** Grid height and optional bounds {x0, z0, x1, z1} (e.g. the level's cell grid). */ + public void setGrid(float y, float[] bounds) { + gridY = y; + gridBounds = bounds; + requestRender(); + } + + public void setFog(boolean f) { + fog = f; + requestRender(); + } + + /** Textures × vertex colours (default). */ + public static final int SHADE_NORMAL = 0; + + /** Vertex colours on white (textures only used for cut-out alpha). */ + public static final int SHADE_VERTEX_COLORS = 1; + + /** + * Game-like fog for perspective views: colour ramps from {@code start} to {@code end} (distance + * in cells along the view direction) up to {@code amount}; {@code clip} discards geometry beyond + * {@code end} (far plane) and clears with the fog colour; {@code aroundPivot} measures radial + * distances from the camera's orbit pivot instead of the eye. {@code null} = default editor fog. + */ + public record FogOverride(float start, float end, int rgb, float amount, boolean clip, boolean aroundPivot) {} + + public void setFogOverride(FogOverride f) { + fogOverride = f; + requestRender(); + } + + /** {@link #SHADE_NORMAL} or {@link #SHADE_VERTEX_COLORS}. */ + public void setShading(int mode) { + shading = mode; + requestRender(); + } + + public int shading() { + return shading; + } + + public void setFlySpeed(float s) { + flySpeed = Math.max(0.2f, Math.min(200, s)); + } + + public Navigation.Preset navigation() { + return navPreset; + } + + public void setNavigation(Navigation.Preset p) { + navPreset = p == null ? Navigation.Preset.FUSION : p; + } + + /** Zoom step per wheel notch (0.01 … 0.6, default 0.12 = 12 %). */ + public void setZoomSpeed(float percent) { + zoomPercent = Math.max(0.01f, Math.min(0.6f, percent)); + } + + public float zoomSpeed() { + return zoomPercent; + } + + public void setInvertZoom(boolean b) { + invertZoom = b; + } + + public boolean invertZoom() { + return invertZoom; + } + + /** Orbit speed in degrees per pixel (default 0.35). */ + public void setOrbitSpeed(float degPerPx) { + orbitDegPerPx = Math.max(0.02f, Math.min(3, degPerPx)); + } + + public float orbitSpeed() { + return orbitDegPerPx; + } + + /** + * Back sides of one-sided faces (invisible in the game) drawn hatched and translucent instead + * of culled; picking hits them either way ({@link #lastPickBackFace()}). + */ + public void setShowBackFaces(boolean b) { + showBackFaces = b; + requestRender(); + } + + public boolean showBackFaces() { + return showBackFaces; + } + + /** Opacity of the back-side style (0.05 … 1, default 0.3). */ + public void setBackFaceOpacity(float a) { + backFaceOpacity = Math.max(0.05f, Math.min(1, a)); + requestRender(); + } + + public float backFaceOpacity() { + return backFaceOpacity; + } + + public void setViewCubeVisible(boolean b) { + viewCubeVisible = b; + cubeHover = null; + requestRender(); + } + + public boolean viewCubeVisible() { + return viewCubeVisible; + } + + /** Whether the last pick (click or hover) hit the back side of a one-sided face. */ + public boolean lastPickBackFace() { + return lastPickBack; + } + + /** Whether the hovered id was hit on the back side of a one-sided face. */ + public boolean hoverBackFace() { + return hover != 0 && hoverBack; + } + + /** OpenGL vendor/renderer/version once the context exists, else "". */ + public String glInfo() { + return glInfo; + } + + /** Context creation error, or {@code null}. */ + public String glError() { + return glError; + } + + public GlRendererStats stats() { + GlRenderer.Stats s = renderer != null ? renderer.stats() : new GlRenderer.Stats(0, 0, 0, 0, 0); + double avg = 0; + synchronized (frameTimes) { + for (double d : frameTimes) { + avg += d; + } + avg = frameTimes.isEmpty() ? 0 : avg / frameTimes.size(); + } + return new GlRendererStats(s.drawCalls(), s.triangles(), s.chunksDrawn(), s.chunksTotal(), s.cpuMs(), avg); + } + + /** Frame statistics: last frame's draw calls/triangles/chunks, CPU time and the average frame time. */ + public record GlRendererStats( + int drawCalls, int triangles, int chunksDrawn, int chunksTotal, double cpuMs, double avgFrameMs) {} + + public void requestRender() { + dirty = true; + } + + /** Renders now (Swing thread). */ + public void renderNow() { + dirty = false; + if (canvas.isDisplayable() && glError == null) { + try { + canvas.render(); + } catch (RuntimeException e) { + fail(e); + } + } + } + + /** Renders a frame and returns it (Swing thread). */ + public BufferedImage screenshot() { + captureNext = true; + renderNow(); + BufferedImage img = capture; + capture = null; + return img; + } + + public void setView(Camera.View v) { + animate(() -> camera.setView(v)); + } + + /** Frames the selection (all when empty). */ + public void frameSelection() { + float[] b = bounds(selection.isEmpty() ? null : selection); + if (b != null) { + // small objects: keep some surroundings (and the gizmo) in view + for (int k = 0; k < 3; k++) { + float c = (b[k] + b[k + 3]) / 2, h = Math.max((b[k + 3] - b[k]) / 2, 1.2f); + b[k] = c - h; + b[k + 3] = c + h; + } + animate(() -> camera.frame(b)); + } + } + + /** Frames all visible tiles (or everything when there are none). */ + public void frameAll() { + float[] b = allBounds(); + if (b != null) { + animate(() -> camera.frame(b)); + } + } + + /** Home view: perspective from the front-right-top corner, whole level framed. */ + public void home() { + float[] b = allBounds(); + animate(() -> { + float[] a = ViewCube.angles(new ViewCube.Hit(1, 1, -1, false)); + camera.ortho = false; + camera.view = Camera.View.PERSPECTIVE; + camera.yaw = a[0]; + camera.pitch = a[1]; + if (b != null) { + camera.frame(b); + } else { + camera.position.set(camera.forward().mul(-camera.distance)); + } + }); + } + + /** Snaps the view direction (keeping the pivot, distance and projection). */ + public void snapView(ViewCube.Hit h) { + if (h.home()) { + home(); + return; + } + animate(() -> { + Vector3f p = camera.pivot(); + float[] a = ViewCube.angles(h); + camera.yaw = a[0]; + camera.pitch = a[1]; + camera.view = camera.ortho ? standardView(h) : Camera.View.PERSPECTIVE; + camera.position.set(camera.forward().mul(-camera.distance).add(p)); + }); + } + + private static Camera.View standardView(ViewCube.Hit h) { + if (h.dx() == 0 && h.dy() == 1 && h.dz() == 0) { + return Camera.View.TOP; + } + if (h.dx() == 0 && h.dy() == 0 && h.dz() == -1) { + return Camera.View.FRONT; + } + if (h.dx() == 1 && h.dy() == 0 && h.dz() == 0) { + return Camera.View.SIDE; + } + return Camera.View.PERSPECTIVE; + } + + private float[] allBounds() { + List<Integer> tiles = new ArrayList<>(); + if (scene != null && !hidden.contains(LevelSceneAdapter.LAYER_TILES)) { + for (RenderInstance i : scene.instances) { + if (i.kind == RenderInstance.Kind.TILE && i.visible) { + tiles.add(i.id); + } + } + } + return bounds(tiles.isEmpty() ? null : tiles); + } + + /** World bounds of the given ids (instances and markers), or all when ids is null. */ + public float[] bounds(Collection<Integer> ids) { + if (scene == null) { + return null; + } + float[] b = Culling.emptyBox(); + boolean any = false; + for (RenderInstance i : scene.instances) { + if ((ids == null ? i.visible && !hidden.contains(i.layer) : ids.contains(i.id)) + && i.mesh.vertexCount() > 0 + && i.mesh.triangleCount() > 0) { + Culling.union(b, GlRenderer.worldBounds(i)); + any = true; + } + } + for (Marker m : scene.markers) { + if (ids != null && ids.contains(m.id)) { + float s = Math.max(0.5f, m.size * 4); + Culling.union(b, new float[] {m.x - s, m.y - s, m.z - s, m.x + s, m.y + m.pole + s, m.z + s}); + any = true; + } + } + return any ? b : null; + } + + /** Synchronous pick at component coordinates (renders the id buffer). */ + public ViewportListener.PickEvent pick(int x, int y) { + return pick(x, y, MouseEvent.BUTTON1, 0, 1); + } + + private ViewportListener.PickEvent pick(int x, int y, int button, int mods, int clicks) { + if (renderer == null || scene == null) { + return new ViewportListener.PickEvent(0, null, x, y, button, mods, clicks); + } + float sc = scale(); + int px = Math.round(x * sc), py = Math.round(y * sc); + GlRenderer.PickHit[] h = new GlRenderer.PickHit[1]; + canvas.runInContext(() -> h[0] = renderer.pick(params(), px, py, Math.round(4 * sc))); + lastPickBack = h[0].backFace(); + Vector3f w = h[0].depth() < 1 ? unproject(h[0].px() / sc, h[0].py() / sc, h[0].depth()) : null; + return new ViewportListener.PickEvent(h[0].id(), w, x, y, button, mods, clicks); + } + + /** World point under a pixel on scene geometry (markers ignored), or {@code null}. */ + public Vector3f surfacePoint(int x, int y) { + if (renderer == null || scene == null) { + return null; + } + float sc = scale(); + float[] d = new float[1]; + canvas.runInContext(() -> d[0] = renderer.depthAt(params(), Math.round(x * sc), Math.round(y * sc))); + return d[0] < 1 ? unproject(x, y, d[0]) : null; + } + + /** Component pixel + depth buffer value (0 = near, 1 = far) to world space. */ + public Vector3f unproject(float x, float y, float depth) { + Vector4f v = new Vector4f(2 * x / camera.width() - 1, 1 - 2 * y / camera.height(), depth * 2 - 1, 1) + .mul(camera.viewProjection().invert()); + return new Vector3f(v.x / v.w, v.y / v.w, v.z / v.w); + } + + // ------------------------------------------------------------------ internals + + private float scale() { + int w = canvas.getWidth(); + return w > 0 && canvas.getFramebufferWidth() > 0 ? canvas.getFramebufferWidth() / (float) w : 1; + } + + private void fail(Throwable e) { + System.err.println("viewport: OpenGL failed"); + e.printStackTrace(); + if (!fallbackTried && renderer == null) { + // retry without multisampling (some drivers/visuals have none) + fallbackTried = true; + Canvas old = canvas; + SwingUtilities.invokeLater(() -> { + remove(old); + canvas = createCanvas(0); + revalidate(); + requestRender(); + }); + return; + } + glError = String.valueOf(e.getMessage() != null ? e.getMessage() : e); + timer.stop(); + SwingUtilities.invokeLater(() -> { + remove(canvas); + JLabel l = new JLabel( + "<html><center>OpenGL 3.3 is not available:<br>" + glError + "</center></html>", + SwingConstants.CENTER); + l.setForeground(Color.LIGHT_GRAY); + add(l, BorderLayout.CENTER); + revalidate(); + repaint(); + }); + } + + /** Applies a camera change as a smooth transition from the current camera. */ + private void animate(Runnable change) { + finishZoom(); + stopAnimation(); + animFrom.copyFrom(camera); + change.run(); + animTo.copyFrom(camera); + camera.copyFrom(animFrom); + camera.ortho = animTo.ortho; + camera.view = animTo.view; + animStart = System.nanoTime(); + requestRender(); + } + + /** Jumps to the end of a running camera transition (call before setting the camera directly). */ + public void stopAnimation() { + if (animStart != 0) { + camera.copyFrom(animTo); + animStart = 0; + } + } + + private void stepAnim() { + double t = Math.min(1, (System.nanoTime() - animStart) / (double) ANIM_NS); + float s = (float) (t * t * (3 - 2 * t)); + camera.position.set(animFrom.position).lerp(animTo.position, s); + float dy = (float) Math.IEEEremainder(animTo.yaw - animFrom.yaw, Math.PI * 2); + camera.yaw = animFrom.yaw + dy * s; + camera.pitch = animFrom.pitch + (animTo.pitch - animFrom.pitch) * s; + camera.distance = animFrom.distance + (animTo.distance - animFrom.distance) * s; + camera.orthoSize = animFrom.orthoSize + (animTo.orthoSize - animFrom.orthoSize) * s; + if (t >= 1) { + camera.copyFrom(animTo); + animStart = 0; + } + cameraChanged(); + } + + private void tick() { + long now = System.nanoTime(); + float dt = Math.min(0.1f, (now - lastTick) / 1e9f); + lastTick = now; + if (animStart != 0) { + stepAnim(); + dirty = true; + } + if (zoomStart != 0) { + stepZoom(now); + dirty = true; + } + if (flying && !keys.isEmpty()) { + Vector3f mv = new Vector3f(); + Vector3f f = camera.forward(), r = camera.right(); + if (keys.contains(KeyEvent.VK_W)) { + mv.add(f); + } + if (keys.contains(KeyEvent.VK_S)) { + mv.sub(f); + } + if (keys.contains(KeyEvent.VK_D)) { + mv.add(r); + } + if (keys.contains(KeyEvent.VK_A)) { + mv.sub(r); + } + if (keys.contains(KeyEvent.VK_E)) { + mv.y += 1; + } + if (keys.contains(KeyEvent.VK_Q)) { + mv.y -= 1; + } + if (mv.lengthSquared() > 0) { + float sp = flySpeed * (keys.contains(KeyEvent.VK_SHIFT) ? 4 : 1); + if (camera.ortho) { + camera.orthoSize = Math.max(0.2f, camera.orthoSize * (1 - mv.dot(f) * dt * 1.5f)); + mv.sub(new Vector3f(f).mul(mv.dot(f))); + } + camera.position.add(mv.normalize().mul(sp * dt)); + cameraChanged(); + dirty = true; + } + } + if (pendingHover != null && !flying && now - lastHoverPick > 40_000_000L) { + int[] p = pendingHover; + pendingHover = null; + lastHoverPick = now; + int id = pick(p[0], p[1]).id(); + if (id != hover || lastPickBack != hoverBack) { + hover = id; + hoverBack = lastPickBack; + listeners.forEach(l -> l.hovered(id)); + dirty = true; + } + } + if (dirty) { + renderNow(); + } + } + + private void cameraChanged() { + listeners.forEach(l -> l.cameraChanged(camera)); + } + + private GlRenderer.FrameParams params() { + GlRenderer.FrameParams fp = new GlRenderer.FrameParams(); + fp.camera = camera; + fp.hiddenLayers = Set.copyOf(hidden); + fp.selection = Set.copyOf(selection); + fp.hover = hover; + fp.fog = fog; + fp.fogOverride = fogOverride; + fp.shading = shading; + fp.backFaceAlpha = showBackFaces ? backFaceOpacity : 0; + fp.width = Math.max(1, canvas.getFramebufferWidth()); + fp.height = Math.max(1, canvas.getFramebufferHeight()); + camera.viewportSize(Math.max(1, canvas.getWidth()), Math.max(1, canvas.getHeight())); + return fp; + } + + private void paintFrame() { + long t0 = System.nanoTime(); + GlRenderer.FrameParams fp = params(); + if (scene != null) { + renderer.setScene(scene); + List<RenderInstance> det = new ArrayList<>(); + if (!selection.isEmpty()) { + for (RenderInstance i : scene.instances) { + if (i.kind == RenderInstance.Kind.TILE && selection.contains(i.id)) { + det.add(i); + } + } + } + renderer.setDetached(det); + } + if (showGrid) { + fp.grid = grid(); + } + fp.tool = toolOverlay; + fp.toolTop = toolTopOverlay; + Overlay px = screenOverlay; + if (viewCubeVisible) { + fp.viewCube = ViewCube.build(camera, canvas.getWidth(), canvas.getHeight(), scale(), cubeHover); + } + if (px != null && (px.lineCount() > 0 || px.triangleCount() > 0)) { + fp.toolTop = screenToWorld(px, toolTopOverlay); + } + if (gizmo.visible()) { + fp.gizmoLines = new Overlay(); + fp.gizmoTris = new Overlay(); + gizmo.build(camera, fp.gizmoLines, fp.gizmoTris); + } + if (pivotMarker != null) { + if (fp.gizmoLines == null) { + fp.gizmoLines = new Overlay(); + } + pivotMarker(fp.gizmoLines, pivotMarker); + } + renderer.render(fp); + if (captureNext) { + captureNext = false; + capture = readPixels(fp.width, fp.height); + } + glFinish(); + double ms = (System.nanoTime() - t0) / 1e6; + synchronized (frameTimes) { + frameTimes.add(ms); + if (frameTimes.size() > 60) { + frameTimes.remove(0); + } + } + } + + /** Small 3-axis cross at the orbit pivot (constant screen size). */ + private void pivotMarker(Overlay o, Vector3f p) { + float s = camera.pixelSize(p) * 9; + o.line(p.x - s, p.y, p.z, p.x + s, p.y, p.z, Legend.rgb(232, 64, 64)); + o.line(p.x, p.y - s, p.z, p.x, p.y + s, p.z, Legend.rgb(120, 205, 40)); + o.line(p.x, p.y, p.z - s, p.x, p.y, p.z + s, Legend.rgb(60, 120, 245)); + float d = s * 0.45f; + int w = Legend.rgb(255, 255, 255); + o.line(p.x - d, p.y, p.z, p.x, p.y + d, p.z, w); + o.line(p.x, p.y + d, p.z, p.x + d, p.y, p.z, w); + o.line(p.x + d, p.y, p.z, p.x, p.y - d, p.z, w); + o.line(p.x, p.y - d, p.z, p.x - d, p.y, p.z, w); + } + + /** World overlay = base + the pixel overlay unprojected just behind the near plane. */ + private Overlay screenToWorld(Overlay px, Overlay base) { + Overlay o = new Overlay(); + if (base != null) { + o.append(base); + } + float d = camera.ortho ? 0.5f : 0.05f; + float[] l = px.lines(); + int[] c = px.lineColors(); + for (int i = 0; i < px.lineCount(); i++) { + Vector3f a = unproject(l[6 * i], l[6 * i + 1], d), b = unproject(l[6 * i + 3], l[6 * i + 4], d); + o.line(a.x, a.y, a.z, b.x, b.y, b.z, c[i]); + } + float[] t = px.triangles(); + int[] tc = px.triangleColors(); + for (int i = 0; i < px.triangleCount(); i++) { + double[][] p = new double[3][]; + for (int k = 0; k < 3; k++) { + Vector3f w = unproject(t[9 * i + 3 * k], t[9 * i + 3 * k + 1], d); + p[k] = new double[] {w.x, w.y, w.z}; + } + o.triangle(p[0], p[1], p[2], tc[i]); + } + return o; + } + + private static BufferedImage readPixels(int w, int h) { + ByteBuffer b = MemoryUtil.memAlloc(w * h * 4); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, b); + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int o = ((h - 1 - y) * w + x) * 4; + img.setRGB(x, y, (b.get(o) & 0xff) << 16 | (b.get(o + 1) & 0xff) << 8 | (b.get(o + 2) & 0xff)); + } + } + MemoryUtil.memFree(b); + return img; + } + + /** Grid lines around the pivot in the plane of the current view. */ + private Overlay grid() { + Overlay o = new Overlay(); + Vector3f p = camera.pivot(); + float extent = camera.ortho + ? camera.orthoSize * 2.2f * camera.width() / Math.max(1, camera.height()) + : Math.max(24, camera.distance * 3); + float step = 1; + while (extent / step > 120) { + step *= 2; + } + int major = 8; + int plane = camera.view == Camera.View.FRONT ? 2 : camera.view == Camera.View.SIDE ? 0 : 1; + float ca, cb, fixed; + if (plane == 1) { + ca = p.x; + cb = p.z; + fixed = gridY; + } else if (plane == 2) { + ca = p.x; + cb = p.y; + fixed = (float) Math.floor(p.z); + } else { + ca = p.z; + cb = p.y; + fixed = (float) Math.floor(p.x); + } + float a0 = (float) Math.floor((ca - extent) / step) * step, a1 = a0 + 2 * extent; + float b0 = (float) Math.floor((cb - extent) / step) * step, b1 = b0 + 2 * extent; + if (plane == 1 && gridBounds != null) { + a0 = Math.max(a0, gridBounds[0]); + a1 = Math.min(a1, gridBounds[2]); + b0 = Math.max(b0, gridBounds[1]); + b1 = Math.min(b1, gridBounds[3]); + } + int minor = Legend.withAlpha(Legend.rgb(220, 225, 235), 34); + int maj = Legend.withAlpha(Legend.rgb(230, 235, 245), 80); + int red = Legend.withAlpha(Legend.rgb(232, 64, 64), 170); + int green = Legend.withAlpha(Legend.rgb(120, 205, 40), 170); + int blue = Legend.withAlpha(Legend.rgb(60, 120, 245), 170); + // axis lines: a = const 0 and b = const 0 in the respective plane + int axisA = plane == 1 ? blue : green; + int axisB = plane == 0 ? blue : red; + for (float a = (float) Math.ceil(a0 / step) * step; a <= a1; a += step) { + int c = Math.abs(a) < 1e-4 ? axisA : Math.round(a) % major == 0 ? maj : minor; + gridLine(o, plane, a, b0, a, b1, fixed, c); + } + for (float b = (float) Math.ceil(b0 / step) * step; b <= b1; b += step) { + int c = Math.abs(b) < 1e-4 ? axisB : Math.round(b) % major == 0 ? maj : minor; + gridLine(o, plane, a0, b, a1, b, fixed, c); + } + return o; + } + + private static void gridLine(Overlay o, int plane, float a0, float b0, float a1, float b1, float f, int c) { + switch (plane) { + case 1 -> o.line(a0, f, b0, a1, f, b1, c); + case 2 -> o.line(a0, b0, f, a1, b1, f, c); + default -> o.line(f, b0, a0, f, b1, a1, c); + } + } + + /** Keyboard shortcuts (focus on the canvas). */ + void handleKey(KeyEvent e) { + if (flying) { + return; + } + switch (e.getKeyCode()) { + case KeyEvent.VK_F -> frameSelection(); + case KeyEvent.VK_HOME -> frameAll(); + case KeyEvent.VK_NUMPAD7 -> setView(Camera.View.TOP); + case KeyEvent.VK_NUMPAD1 -> setView(Camera.View.FRONT); + case KeyEvent.VK_NUMPAD3 -> setView(Camera.View.SIDE); + case KeyEvent.VK_NUMPAD5 -> animate(() -> { + if (camera.ortho) { + camera.setView(Camera.View.PERSPECTIVE); + } else { + camera.ortho = true; + } + }); + case KeyEvent.VK_W -> setGizmoMode(Gizmo.Mode.TRANSLATE); + case KeyEvent.VK_E -> setGizmoMode(Gizmo.Mode.ROTATE); + case KeyEvent.VK_R -> setGizmoMode(Gizmo.Mode.SCALE); + case KeyEvent.VK_Q -> { + gizmo.setSpace(gizmo.space() == Gizmo.Space.WORLD ? Gizmo.Space.LOCAL : Gizmo.Space.WORLD); + requestRender(); + } + case KeyEvent.VK_ESCAPE -> { + GizmoEvent ge = gizmo.cancel(); + if (ge != null) { + listeners.forEach(l -> l.gizmo(ge)); + requestRender(); + } + } + default -> {} + } + } + + private void setGizmoMode(Gizmo.Mode m) { + gizmo.setMode(m); + requestRender(); + } + + /** Applies the rest of a running smooth zoom at once. */ + private void finishZoom() { + if (zoomStart != 0) { + applyZoomState(1); + zoomStart = 0; + } + } + + private void stepZoom(long now) { + double t = Math.min(1, (now - zoomStart) / (double) ZOOM_NS); + float s = (float) (1 - Math.pow(1 - t, 3)); + applyZoomState(s); + if (t >= 1) { + zoomStart = 0; + } + cameraChanged(); + } + + private void applyZoomState(float s) { + camera.position.set(zoomFrom.position).lerp(zoomTo.position, s); + camera.distance = logLerp(zoomFrom.distance, zoomTo.distance, s); + camera.orthoSize = logLerp(zoomFrom.orthoSize, zoomTo.orthoSize, s); + } + + private static float logLerp(float a, float b, float s) { + return (float) Math.exp(Math.log(a) + (Math.log(b) - Math.log(a)) * s); + } + + /** + * Wheel zoom by {@code notches} (positive = out) at a component pixel: exponential, towards a + * robust anchor ({@link Navigation#anchorDistance}), animated over 120 ms; successive notches + * accumulate on the animation target. + */ + public void zoomAt(int x, int y, double notches) { + stopAnimation(); + Camera base = new Camera(); + base.copyFrom(zoomStart != 0 ? zoomTo : camera); + base.fovY = camera.fovY; + base.near = camera.near; + base.far = camera.far; + base.viewportSize(camera.width(), camera.height()); + float f = Navigation.zoomFactor(notches, zoomPercent, invertZoom); + if (base.ortho) { + Navigation.zoomOrtho(base, x, y, f); + } else { + Vector3f dir = base.ray(x, y)[1]; + float cos = Math.max(0.05f, dir.dot(base.forward())); + float hit = Float.NaN; + Vector3f sp = surfacePoint(x, y); + if (sp != null) { + hit = new Vector3f(sp).sub(base.position).dot(dir); + } + float anchor = Navigation.anchorDistance(hit, base.distance / cos, base.near, base.far); + Navigation.zoom(base, dir, anchor, f); + } + zoomFrom.copyFrom(camera); + zoomTo.copyFrom(base); + zoomStart = System.nanoTime(); + requestRender(); + } + + /** Mouse navigation, picking and gizmo input. */ + private final class Controls extends MouseAdapter { + private int lastX; + private int lastY; + private int pressX; + private int pressY; + private boolean moved; + + private enum Drag { + NONE, + FLY, + ORBIT, + PAN, + ZOOM, + GIZMO, + TOOL, + CLICK, + CUBE + } + + private Drag drag = Drag.NONE; + private Vector3f orbitPivot; + private float panDepth; + private ViewCube.Hit cubePress; + + @Override + public void mousePressed(MouseEvent e) { + canvas.requestFocusInWindow(); + lastX = pressX = e.getX(); + lastY = pressY = e.getY(); + moved = false; + if (drag != Drag.NONE && Integer.bitCount(heldButtons(e)) > 1) { + return; // second button during a drag + } + boolean alt = e.isAltDown(); + int b = e.getButton(); + if (viewCubeVisible && b == MouseEvent.BUTTON1 && !alt) { + ViewCube.Hit h = ViewCube.hit(camera, canvas.getWidth(), e.getX(), e.getY()); + if (h != null) { + drag = Drag.CUBE; + cubePress = h; + return; + } + } + if (b == MouseEvent.BUTTON2 && e.getClickCount() == 2 && navPreset == Navigation.Preset.FUSION) { + frameAll(); + return; + } + Navigation.Action a = Navigation.drag(navPreset, b, e.isShiftDown(), e.isControlDown(), alt); + if (a != Navigation.Action.NONE) { + finishZoom(); + stopAnimation(); + } + switch (a) { + case LOOK -> { + drag = Drag.FLY; + flying = true; + canvas.setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR)); + } + case PAN -> { + drag = Drag.PAN; + Vector3f s = camera.ortho ? null : surfacePoint(e.getX(), e.getY()); + float d = s == null + ? -1 + : new Vector3f(s).sub(camera.position).dot(camera.forward()); + panDepth = d > camera.near ? d : camera.distance; + } + case ZOOM -> drag = Drag.ZOOM; + case ORBIT -> { + drag = Drag.ORBIT; + orbitPivot = orbitPivot(e.getX(), e.getY()); + pivotMarker = orbitPivot; + } + case NONE -> { + if (b == MouseEvent.BUTTON1) { + GizmoEvent ge = gizmo.visible() ? gizmo.begin(camera, e.getX(), e.getY()) : null; + if (ge != null) { + drag = Drag.GIZMO; + listeners.forEach(l -> l.gizmo(ge)); + } else if (input != null && input.mousePressed(e)) { + drag = Drag.TOOL; + } else { + drag = Drag.CLICK; + } + } + } + } + requestRender(); + } + + /** Point under the cursor; over empty space the selection centre, else the view centre. */ + private Vector3f orbitPivot(int x, int y) { + Vector3f s = surfacePoint(x, y); + if (s != null && s.distance(camera.position) < 0.9f * camera.far) { + return s; + } + float[] b = selection.isEmpty() ? null : bounds(selection); + if (b != null) { + return new Vector3f((b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2); + } + return camera.pivot(); + } + + @Override + public void mouseReleased(MouseEvent e) { + if (drag == Drag.TOOL) { + input.mouseReleased(e); + } else if (drag == Drag.GIZMO) { + GizmoEvent ge = gizmo.end(); + if (ge != null) { + listeners.forEach(l -> l.gizmo(ge)); + } + } else if ((drag == Drag.CLICK || drag == Drag.FLY) && !moved) { + ViewportListener.PickEvent pe = + pick(e.getX(), e.getY(), e.getButton(), e.getModifiersEx(), e.getClickCount()); + listeners.forEach(l -> l.picked(pe)); + } else if (drag == Drag.CUBE && !moved && cubePress != null) { + snapView(cubePress); + } + if (drag == Drag.FLY) { + flying = false; + canvas.setCursor(Cursor.getDefaultCursor()); + } + if (drag != Drag.NONE && buttonOf(drag, e)) { + drag = Drag.NONE; + pivotMarker = null; + } + requestRender(); + } + + private static int heldButtons(MouseEvent e) { + return e.getModifiersEx() + & (InputEvent.BUTTON1_DOWN_MASK | InputEvent.BUTTON2_DOWN_MASK | InputEvent.BUTTON3_DOWN_MASK); + } + + /** Whether the released button ends the current drag (other buttons are ignored). */ + private boolean buttonOf(Drag d, MouseEvent e) { + return heldButtons(e) == 0 || d == Drag.CLICK || d == Drag.TOOL || d == Drag.GIZMO || d == Drag.CUBE; + } + + @Override + public void mouseDragged(MouseEvent e) { + int dx = e.getX() - lastX, dy = e.getY() - lastY; + lastX = e.getX(); + lastY = e.getY(); + if (Math.abs(e.getX() - pressX) + Math.abs(e.getY() - pressY) > 3) { + moved = true; + } + float orbitRad = (float) Math.toRadians(orbitDegPerPx); + switch (drag) { + case FLY -> { + if (camera.ortho) { + Navigation.pan(camera, camera.distance, dx, dy); + } else { + Navigation.look(camera, -dx * 0.0045f, -dy * 0.0045f); + } + cameraChanged(); + } + case ORBIT -> orbit(orbitPivot, dx * orbitRad, dy * orbitRad); + case CUBE -> { + if (moved) { + orbit(camera.pivot(), dx * orbitRad, dy * orbitRad); + } + } + case PAN -> { + Navigation.pan(camera, panDepth, dx, dy); + cameraChanged(); + } + case ZOOM -> zoom((dy - dx) * 0.01f); + case TOOL -> input.mouseDragged(e); + case GIZMO -> { + GizmoEvent ge = gizmo.drag(camera, e.getX(), e.getY(), e.isControlDown()); + if (ge != null) { + listeners.forEach(l -> l.gizmo(ge)); + } + } + default -> {} + } + requestRender(); + } + + @Override + public void mouseMoved(MouseEvent e) { + if (viewCubeVisible) { + ViewCube.Hit h = ViewCube.hit(camera, canvas.getWidth(), e.getX(), e.getY()); + if (!Objects.equals(h, cubeHover)) { + cubeHover = h; + requestRender(); + } + if (h != null) { + pendingHover = null; + return; + } + } + if (input != null) { + input.mouseMoved(e); + } + if (gizmo.visible()) { + Gizmo.Handle before = gizmo.hover(); + Gizmo.Handle h = gizmo.hitTest(camera, e.getX(), e.getY()); + if (h != before) { + requestRender(); + } + if (h != Gizmo.Handle.NONE) { + return; + } + } + pendingHover = new int[] {e.getX(), e.getY()}; + } + + @Override + public void mouseExited(MouseEvent e) { + if (input != null) { + input.mouseExited(e); + } + pendingHover = null; + if (cubeHover != null) { + cubeHover = null; + requestRender(); + } + if (hover != 0) { + hover = 0; + listeners.forEach(l -> l.hovered(0)); + requestRender(); + } + } + + @Override + public void mouseWheelMoved(MouseWheelEvent e) { + double r = e.getPreciseWheelRotation(); + if (flying) { + setFlySpeed(flySpeed * (float) Math.pow(1.2, -r)); + return; + } + zoomAt(e.getX(), e.getY(), r); + cameraChanged(); + } + + private void orbit(Vector3f piv, float dYaw, float dPitch) { + Navigation.orbit(camera, piv != null ? piv : camera.pivot(), -dYaw, -dPitch); + if (camera.ortho && camera.view != Camera.View.PERSPECTIVE) { + camera.view = Camera.View.PERSPECTIVE; + } + cameraChanged(); + } + + private void zoom(float amount) { + if (camera.ortho) { + camera.orthoSize = Math.max(0.05f, camera.orthoSize * (float) Math.exp(amount)); + } else { + Vector3f piv = camera.pivot(); + camera.distance = Math.max(2 * camera.near, camera.distance * (float) Math.exp(amount)); + camera.position.set(camera.forward().mul(-camera.distance).add(piv)); + } + cameraChanged(); + } + } + + /** GL canvas delegating to the renderer. */ + private final class Canvas extends AWTGLCanvas { + Canvas(GLData d) { + super(d); + } + + @Override + public void initGL() { + GL.createCapabilities(); + glInfo = glGetString(GL_VENDOR) + " | " + glGetString(GL_RENDERER) + " | " + glGetString(GL_VERSION); + renderer = new GlRenderer(); + renderer.init(); + if (effective.samples > 0) { + glEnable(GL_MULTISAMPLE); + } + } + + @Override + public void paintGL() { + paintFrame(); + swapBuffers(); + } + + @Override + public void paint(Graphics g) { + if (glError != null) { + return; + } + try { + render(); + } catch (RuntimeException e) { + fail(e); + } + } + } + + /** Frees the GL resources (call before discarding the component). */ + public void dispose() { + timer.stop(); + if (renderer != null) { + canvas.runInContext(renderer::dispose); + } + canvas.disposeCanvas(); + } + + /** True while a mouse/keyboard navigation or gizmo drag is in progress. */ + public boolean interacting() { + return flying || gizmo.dragging(); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/ViewportInput.java b/src/main/java/com/eliasmueller/llamamod/editor/render/ViewportInput.java new file mode 100644 index 0000000..20c760f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/ViewportInput.java @@ -0,0 +1,33 @@ +package com.eliasmueller.llamamod.editor.render; + +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; + +/** + * Input hook for editor tools ({@link Viewport#setInput}). Gets left-button presses that do not hit + * the gizmo (and are not Alt-orbit), the following drag/release, every mouse move and key events + * before the viewport's own handling. Returning true consumes the event. Swing thread. + */ +public interface ViewportInput { + /** Left button pressed; true = this tool owns the following drag and release (no pick event). */ + default boolean mousePressed(MouseEvent e) { + return false; + } + + default void mouseDragged(MouseEvent e) {} + + default void mouseReleased(MouseEvent e) {} + + /** Mouse moved without a button (also while the gizmo is hovered). */ + default void mouseMoved(MouseEvent e) {} + + default void mouseExited(MouseEvent e) {} + + default boolean keyPressed(KeyEvent e) { + return false; + } + + default boolean keyReleased(KeyEvent e) { + return false; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/render/ViewportListener.java b/src/main/java/com/eliasmueller/llamamod/editor/render/ViewportListener.java new file mode 100644 index 0000000..70f893d --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/render/ViewportListener.java @@ -0,0 +1,25 @@ +package com.eliasmueller.llamamod.editor.render; + +import org.joml.Vector3f; + +/** Viewport callbacks (Swing thread). All methods have empty defaults. */ +public interface ViewportListener { + /** + * Click without drag (left button unless noted). + * + * @param id picked id or 0 + * @param world hit point on geometry (or marker), {@code null} if nothing was hit + * @param button {@link java.awt.event.MouseEvent} button + * @param modifiers {@link java.awt.event.InputEvent} extended modifiers (shift/ctrl for multi-select) + */ + record PickEvent(int id, Vector3f world, int x, int y, int button, int modifiers, int clickCount) {} + + default void picked(PickEvent e) {} + + /** Id under the mouse changed (0 = none). */ + default void hovered(int id) {} + + default void gizmo(GizmoEvent e) {} + + default void cameraChanged(Camera c) {} +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/AllWorldsTab.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/AllWorldsTab.java new file mode 100644 index 0000000..71f9bdf --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/AllWorldsTab.java @@ -0,0 +1,321 @@ +package com.eliasmueller.llamamod.editor.tools.catalog; + +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.ui.AssetDrop; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.NodeIcons; +import com.eliasmueller.llamamod.save.Worlds; +import com.eliasmueller.llamamod.util.SwingListeners; +import java.awt.AlphaComposite; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.imageio.ImageIO; +import javax.swing.BorderFactory; +import javax.swing.DefaultListModel; +import javax.swing.Icon; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ListCellRenderer; +import javax.swing.SwingConstants; +import javax.swing.UIManager; +import org.joml.Vector3f; + +/** + * Asset browser tab "All worlds": every object type of every level on the CD with thumbnails, + * search and world/category filters. Drag a type into the viewport to import and place it, + * double-click places it at the view centre. + */ +final class AllWorldsTab extends JPanel { + static final String DRAG_KIND = "xobj"; + private static final int THUMB = 84; + + private final EditorContext ctx; + private final DefaultListModel<ObjectCatalog.Entry> model = new DefaultListModel<>(); + private final JList<ObjectCatalog.Entry> list = new JList<>(model); + private final JTextField search = new JTextField(14); + private final JComboBox<String> world = new JComboBox<>(); + private final JComboBox<String> category = new JComboBox<>(); + private final JCheckBox importable = new JCheckBox("Importable only", true); + private final JLabel count = new JLabel(); + private final JLabel state = new JLabel(" ", SwingConstants.CENTER); + private final Map<String, Icon> thumbs = new HashMap<>(); + + AllWorldsTab(EditorContext ctx) { + super(new BorderLayout()); + this.ctx = ctx; + world.addItem("All worlds"); + for (int w = 1; w <= 8; w++) { + world.addItem(Worlds.name(w)); + } + world.addItem("Other levels"); + category.addItem("All categories"); + for (String c : ObjectCatalog.CATEGORIES) { + category.addItem(ObjectCatalog.categoryLabel(c)); + } + search.putClientProperty("JTextField.placeholderText", "Search all worlds…"); + search.putClientProperty("JTextField.leadingIcon", Icons.get("search", 13)); + search.putClientProperty("JTextField.showClearButton", true); + SwingListeners.onTextChange(search, () -> apply()); + world.addActionListener(e -> apply()); + category.addActionListener(e -> apply()); + importable.addActionListener(e -> apply()); + JButton bulk = new JButton("Import world…", Icons.get("import", 14)); + bulk.setToolTipText("Import many object types of one world or level at once (with a size preview)"); + bulk.addActionListener(e -> ImportWorldDialog.show(ctx, world.getSelectedIndex())); + JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 3)); + top.add(search); + top.add(world); + top.add(category); + top.add(importable); + top.add(bulk); + top.add(count); + add(top, BorderLayout.NORTH); + + list.setLayoutOrientation(JList.HORIZONTAL_WRAP); + list.setVisibleRowCount(-1); + list.setFixedCellWidth(THUMB + 46); + list.setFixedCellHeight(THUMB + 40); + list.setCellRenderer(new Cell()); + javax.swing.ToolTipManager.sharedInstance().registerComponent(list); + AssetDrop.dragSource(list, e -> AssetDrop.payload(DRAG_KIND, e.key())); + list.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + ObjectCatalog.Entry it = list.getSelectedValue(); + if (it != null && e.getClickCount() == 2 && e.getButton() == MouseEvent.BUTTON1) { + placeAtCentre(it); + } + } + + @Override + public void mousePressed(MouseEvent e) { + popup(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + popup(e); + } + }); + JPanel centre = new JPanel(new BorderLayout()); + centre.add(new JScrollPane(list), BorderLayout.CENTER); + state.setForeground(UIManager.getColor("Label.disabledForeground")); + centre.add(state, BorderLayout.SOUTH); + add(centre, BorderLayout.CENTER); + CatalogStore.addListener(this::reload); + ctx.addDocumentListener(d -> list.repaint()); + ctx.addSceneListener(e -> { + if (e.type() == SceneEvent.Type.RELOADED) { + list.repaint(); + } + }); + reload(); + } + + private void popup(MouseEvent e) { + if (!e.isPopupTrigger()) { + return; + } + int i = list.locationToIndex(e.getPoint()); + if (i < 0) { + return; + } + list.setSelectedIndex(i); + ObjectCatalog.Entry it = model.get(i); + JPopupMenu m = new JPopupMenu(); + JMenuItem place = new JMenuItem("Place at view centre"); + place.addActionListener(a -> placeAtCentre(it)); + place.setEnabled(ctx.document() != null && it.importable()); + JMenuItem imp = new JMenuItem("Import into the project"); + imp.addActionListener(a -> Importer.importTypes(ctx, List.of(it), o -> list.repaint())); + imp.setEnabled(ctx.document() != null && it.importable()); + JMenuItem lvl = new JMenuItem("Import all of " + it.levelName() + "…"); + lvl.addActionListener(a -> ImportWorldDialog.show(ctx, it.level())); + lvl.setEnabled(ctx.document() != null); + m.add(place); + m.add(imp); + m.addSeparator(); + m.add(lvl); + m.show(list, e.getX(), e.getY()); + } + + private void placeAtCentre(ObjectCatalog.Entry it) { + if (ctx.document() == null) { + return; + } + Vector3f p = ctx.viewCentre(); + Importer.importAndPlace(ctx, it, p); + } + + void reload() { + ObjectCatalog c = CatalogStore.get(); + if (c == null) { + model.clear(); + state.setText(CatalogStore.building() ? "Building the catalogue of all worlds…" : ""); + if (!CatalogStore.building()) { + CatalogStore.ensure(ctx); + } + return; + } + state.setText(c.entries().size() + " object types from " + + c.entries().stream() + .map(ObjectCatalog.Entry::level) + .distinct() + .count() + " levels · drag into the viewport to import and place"); + apply(); + } + + private void apply() { + ObjectCatalog c = CatalogStore.get(); + model.clear(); + if (c == null) { + return; + } + int w = world.getSelectedIndex(); + int wf = w == 0 ? 0 : w <= 8 ? w : -1; + String cat = + category.getSelectedIndex() <= 0 ? null : ObjectCatalog.CATEGORIES[category.getSelectedIndex() - 1]; + List<ObjectCatalog.Entry> l = c.filter(search.getText(), Math.max(wf, 0), cat, importable.isSelected()); + int n = 0; + for (ObjectCatalog.Entry e : l) { + if (wf < 0 && e.world() >= 1 && e.world() <= 8) { + continue; + } + model.addElement(e); + n++; + } + count.setText(n + " of " + c.entries().size()); + } + + private Icon thumb(ObjectCatalog.Entry e) { + return thumbs.computeIfAbsent(e.key(), k -> { + ObjectCatalog c = CatalogStore.get(); + Path f = c == null ? null : c.thumbFile(e); + try { + if (f != null && Files.isRegularFile(f)) { + BufferedImage img = ImageIO.read(f.toFile()); + if (img != null) { + BufferedImage s = new BufferedImage(THUMB, THUMB, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = s.createGraphics(); + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + if (!e.importable()) { + g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.35f)); + } + g.drawImage(img, 0, 0, THUMB, THUMB, null); + g.dispose(); + return new ImageIcon(s); + } + } + } catch (java.io.IOException ex) { + // fall back to the category icon + } + return NodeIcons.category(categoryForIcon(e.category()), 40); + }); + } + + static String categoryForIcon(String c) { + return switch (c) { + case "enemy", "mechanism", "character", "creature", "effect" -> "other"; + default -> c; + }; + } + + static String tooltip(ObjectCatalog.Entry e) { + StringBuilder sb = new StringBuilder("<html><b>") + .append(e.type()) + .append("</b><br>") + .append(e.levelName()) + .append(" (") + .append(e.level()) + .append(")<br>") + .append(ObjectCatalog.categoryLabel(e.category())) + .append(" · ") + .append(e.instances()) + .append(e.instances() == 1 ? " instance" : " instances") + .append(" · ") + .append(e.params()) + .append(" parameters<br>") + .append(String.format( + Locale.ROOT, + "needs %d code units, %d models, %d animations, %d sounds, %d textures (%.0f KB)", + e.units(), + e.models(), + e.anims(), + e.sounds(), + e.textures(), + e.bytes() / 1024.0)); + for (String r : e.reasons()) { + sb.append("<br><font color='#d93f3f'>✗ ").append(r).append("</font>"); + } + for (String r : e.notes()) { + sb.append("<br>• ").append(r); + } + return sb.append("</html>").toString(); + } + + private final class Cell extends JPanel implements ListCellRenderer<ObjectCatalog.Entry> { + private final JLabel img = new JLabel(); + private final JLabel title = new JLabel(); + private final JLabel sub = new JLabel(); + + Cell() { + super(new BorderLayout()); + setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + img.setHorizontalAlignment(SwingConstants.CENTER); + img.setPreferredSize(new Dimension(THUMB, THUMB)); + add(img, BorderLayout.CENTER); + JPanel t = new JPanel(new BorderLayout()); + t.setOpaque(false); + title.setHorizontalAlignment(SwingConstants.CENTER); + title.setFont(title.getFont().deriveFont(Font.BOLD, 11f)); + sub.setHorizontalAlignment(SwingConstants.CENTER); + sub.setFont(sub.getFont().deriveFont(10f)); + t.add(title, BorderLayout.NORTH); + t.add(sub, BorderLayout.SOUTH); + add(t, BorderLayout.SOUTH); + } + + @Override + public Component getListCellRendererComponent( + JList<? extends ObjectCatalog.Entry> l, ObjectCatalog.Entry e, int index, boolean sel, boolean focus) { + img.setIcon(thumb(e)); + boolean done = Importer.importedCode(ctx, Importer.ref(e)) >= 0; + title.setText((done ? "✓ " : "") + e.type()); + String w = e.world() >= 1 ? Worlds.name(e.world()) + " " + e.chapter() : e.level(); + sub.setText(w + " · " + (e.bytes() < 1024 ? e.bytes() + " B" : e.bytes() / 1024 + " KB")); + Color fg = sel ? UIManager.getColor("List.selectionForeground") : UIManager.getColor("List.foreground"); + title.setForeground( + !e.importable() ? UIManager.getColor("Label.disabledForeground") : done ? Icons.GREEN : fg); + sub.setForeground(sel ? fg : UIManager.getColor("Label.disabledForeground")); + setBackground(sel ? UIManager.getColor("List.selectionBackground") : l.getBackground()); + setToolTipText(tooltip(e)); + return this; + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CatalogEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CatalogEditorPlugin.java new file mode 100644 index 0000000..e5fdbc7 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CatalogEditorPlugin.java @@ -0,0 +1,125 @@ +package com.eliasmueller.llamamod.editor.tools.catalog; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.ui.ActionSpec; +import com.eliasmueller.llamamod.editor.ui.AssetDrop; +import com.eliasmueller.llamamod.editor.ui.AssetTab; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.EditorExtensions; +import com.eliasmueller.llamamod.editor.ui.EditorPlugin; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.InspectorSection; +import com.eliasmueller.llamamod.editor.ui.Problem; +import com.eliasmueller.llamamod.save.Worlds; +import java.awt.BorderLayout; +import java.util.List; +import java.util.Locale; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; + +/** + * Cross-level object import: asset tab "All worlds" (catalogue of every object type on the CD), + * drag & drop import + placement, "Import world…" bulk import, inspector note "imported from", + * limit checks. Merge logic: {@link CrossImport}, format: {@code format.stpcmerge}. + */ +public final class CatalogEditorPlugin implements EditorPlugin { + @Override + public void install(EditorExtensions x) { + CrossImport.init(); + x.assetTab(new AssetTab("allworlds", "All worlds", "world", 15, AllWorldsTab::new)); + AssetDrop.register(AllWorldsTab.DRAG_KIND, (ctx, data, ground) -> { + ObjectCatalog.Entry e = CatalogStore.find(data); + if (e == null) { + ctx.status("Unknown object type " + data); + return; + } + Importer.importAndPlace(ctx, e, ground); + }); + x.action(ActionSpec.of( + "xobj.importWorld", + "Level", + 40, + 10, + "Import objects of a world…", + "import", + "ctrl shift I", + c -> ImportWorldDialog.show(c, 0)) + .enabledWhen(c -> c.document() != null) + .tip("Copy object types (enemies, pickups, platforms …) of another world into this level")); + x.action(ActionSpec.of("xobj.rebuildCatalog", "Level", 40, 20, "Rebuild object catalogue", "world", null, c -> { + CatalogStore.rebuild(c); + }) + .tip("Scan all levels of the CD again (thumbnails, sizes)")); + x.inspector(new ImportedSection()); + x.check((scene, out) -> { + if (CrossImport.codes(scene).isEmpty()) { + return; + } + CrossImport.Limits l = CrossImport.limits(scene); + for (String p : l.problems()) { + out.add(Problem.error("Imported objects: " + p, null)); + } + for (String w : l.warnings()) { + out.add(Problem.warning("Imported objects: " + w, null)); + } + }); + x.onOpen(CatalogStore::ensure); + } + + /** Inspector: where an imported object type comes from. */ + private static final class ImportedSection implements InspectorSection { + @Override + public String title() { + return "Imported object"; + } + + @Override + public int order() { + return 20; + } + + @Override + public boolean appliesTo(List<Node> sel) { + return sel.size() == 1 + && sel.get(0) instanceof Strat s + && s.scene() != null + && CrossImport.codes(s.scene()).containsKey(s.code()); + } + + @Override + public JComponent create(EditorContext ctx, List<Node> sel) { + Strat s = (Strat) sel.get(0); + CrossImport.TypeRef t = CrossImport.codes(s.scene()).get(s.code()); + ObjectCatalog.Entry e = CatalogStore.find(t.level() + ":" + t.type() + "@" + Integer.toHexString(t.code())); + String where = e != null ? e.levelName() : levelLabel(t.level()); + JLabel l = new JLabel( + "<html>Imported from <b>" + where + "</b> (" + t.level() + ")" + + (e == null + ? "" + : String.format( + Locale.ROOT, + "<br>%s · %d models, %d animations, %d sounds, %.0f KB", + ObjectCatalog.categoryLabel(e.category()), + e.models(), + e.anims(), + e.sounds(), + e.bytes() / 1024.0)) + + "<br><font color='gray'>Code, models, textures and sounds are copied into this level when it is built.</font></html>", + Icons.get("import", 16), + JLabel.LEADING); + l.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6)); + JPanel p = new JPanel(new BorderLayout()); + p.add(l, BorderLayout.CENTER); + return p; + } + } + + static String levelLabel(String level) { + java.util.regex.Matcher m = + java.util.regex.Pattern.compile("t1l(\\d)m0*(\\d+)").matcher(level); + return m.matches() ? Worlds.name(Integer.parseInt(m.group(1))) + " – Chapter " + m.group(2) : level; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CatalogStore.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CatalogStore.java new file mode 100644 index 0000000..7879844 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CatalogStore.java @@ -0,0 +1,99 @@ +package com.eliasmueller.llamamod.editor.tools.catalog; + +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.swing.SwingUtilities; + +/** The global object catalogue of this session: loaded from the cache or built once in the background. */ +final class CatalogStore { + private CatalogStore() {} + + private static volatile ObjectCatalog catalog; + private static volatile boolean building; + private static final List<Runnable> LISTENERS = new CopyOnWriteArrayList<>(); + + static ObjectCatalog get() { + return catalog; + } + + static boolean building() { + return building; + } + + static void addListener(Runnable r) { + LISTENERS.add(r); + } + + static Path dir(EditorContext ctx) { + return ctx.env().editorDir().resolve("catalog"); + } + + /** Loads or builds the catalogue (needs the CD); listeners run on the Swing thread when it is ready. */ + static void ensure(EditorContext ctx) { + if (catalog != null || building || !ctx.env().hasCd()) { + return; + } + Path cd = ctx.env().cd(); + Path dir = dir(ctx); + building = true; + Thread t = new Thread( + () -> { + ObjectCatalog c = ObjectCatalog.cached(dir, cd); + try { + if (c == null) { + c = ObjectCatalog.build( + cd, + dir, + (a, b) -> SwingUtilities.invokeLater( + () -> ctx.status("Building the object catalogue of all worlds… " + a + "/" + + b + " levels"))); + } + } catch (Exception e) { + SwingUtilities.invokeLater(() -> ctx.error("Object catalogue", e)); + } + ObjectCatalog done = c; + SwingUtilities.invokeLater(() -> { + catalog = done; + building = false; + if (done != null) { + ctx.status("Object catalogue: " + done.entries().size() + " types of all worlds"); + } + LISTENERS.forEach(Runnable::run); + }); + }, + "object-catalogue"); + t.setDaemon(true); + t.start(); + } + + /** Drops the cache and builds the catalogue again. */ + static void rebuild(EditorContext ctx) { + if (building) { + return; + } + try { + java.nio.file.Files.deleteIfExists(dir(ctx).resolve("catalog.json")); + } catch (java.io.IOException e) { + // rebuilt anyway + } + catalog = null; + LISTENERS.forEach(Runnable::run); + ensure(ctx); + } + + /** Entry by key (level:type@code), or null. */ + static ObjectCatalog.Entry find(String key) { + ObjectCatalog c = catalog; + if (c == null) { + return null; + } + for (ObjectCatalog.Entry e : c.entries()) { + if (e.key().equals(key)) { + return e; + } + } + return null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CrossImport.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CrossImport.java new file mode 100644 index 0000000..a6a3fc0 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CrossImport.java @@ -0,0 +1,634 @@ +package com.eliasmueller.llamamod.editor.tools.catalog; + +import com.eliasmueller.llamamod.editor.io.Bases; +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.io.ProjectIO; +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Prop; +import com.eliasmueller.llamamod.editor.model.PropType; +import com.eliasmueller.llamamod.editor.model.RawChunk; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.model.TextureAnim; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge; +import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StpcMerge; +import com.eliasmueller.llamamod.format.stpcmerge.TextMerge; +import java.io.IOException; +import java.lang.ref.SoftReference; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Object types imported from other levels into a project (notes/editor-cross-import.md). The + * project stores only the list of imports (level.json key {@value #KEY}: batches of {level, type, + * code}); on load every batch is merged, in order, into the reference and the current scene alike + * (STPC code/models/animations, TEXT pages/entries, SMPC samples, object catalogue, strat code + * offsets), so the stored diff keeps describing only the user's edits and builds are deterministic. + */ +public final class CrossImport { + public static final String KEY = "importedTypes"; + + private CrossImport() {} + + static { + ProjectIO.addLoadHook(CrossImport::onLoad); + } + + /** Makes sure the load hook is registered. */ + public static void init() {} + + /** One imported type: source level, type name and its code entry in the source STPC. */ + public record TypeRef(String level, String type, int code) { + Map<String, Object> json() { + Map<String, Object> m = new LinkedHashMap<>(); + m.put("level", level); + m.put("type", type); + m.put("code", (long) code); + return m; + } + + static TypeRef of(Object o) { + @SuppressWarnings("unchecked") + Map<String, Object> m = (Map<String, Object>) o; + return new TypeRef( + ((String) m.get("level")).toLowerCase(Locale.ROOT), + (String) m.getOrDefault("type", ""), + ((Number) m.get("code")).intValue()); + } + } + + /** Result of merging one batch into a scene. */ + public record Outcome( + byte[] stpc, + byte[] smpc, + byte[] lgpc, + byte[] srpc, + StpcMerge.OffsetMap target, + List<TextChunk.Page> pages, + List<TextChunk.TextureEntry> entries, + List<TextMerge.Anim> anims, + Map<TypeRef, Integer> codes, + Map<TypeRef, StratCatalog.Variant> variants, + Map<Integer, String> names, + StpcMerge.Stats stats, + List<String> warnings) {} + + // ------------------------------------------------------------------ project records + + /** The import batches stored in a project (in order). */ + public static List<List<TypeRef>> records(Map<String, Object> extra) { + List<List<TypeRef>> out = new ArrayList<>(); + Object v = extra.get(KEY); + if (v instanceof List<?> l) { + for (Object b : l) { + if (b instanceof Map<?, ?> m && m.get("types") instanceof List<?> ts) { + List<TypeRef> batch = new ArrayList<>(); + for (Object t : ts) { + batch.add(TypeRef.of(t)); + } + out.add(batch); + } + } + } + return out; + } + + /** All imported types of a project. */ + public static List<TypeRef> imported(Project p) { + List<TypeRef> out = new ArrayList<>(); + records(p.extra()).forEach(out::addAll); + return out; + } + + /** Imported entries of a scene by their current code offset. */ + public static Map<Integer, TypeRef> codes(Scene s) { + Map<Integer, TypeRef> m = CODES.get(s); + return m == null ? Map.of() : Map.copyOf(m); + } + + /** Current code offsets of the imported entries per scene (rebuilt on every merge). */ + private static final Map<Scene, Map<Integer, TypeRef>> CODES = + java.util.Collections.synchronizedMap(new java.util.WeakHashMap<>()); + + private static void onLoad(Map<String, Object> json, Bases cd, Scene ref, Scene cur, List<String> warnings) + throws IOException { + List<List<TypeRef>> recs = records(json); + for (List<TypeRef> batch : recs) { + try { + Outcome o = compute(ref, batch, cd); + apply(o, ref, false, -1, -1, -1); + apply(o, cur, false, -1, -1, -1); + warnings.addAll(o.warnings); + } catch (RuntimeException e) { + warnings.add("imported object types " + names(batch) + " could not be merged: " + e.getMessage()); + } + } + } + + static String names(List<TypeRef> batch) { + List<String> n = new ArrayList<>(); + for (TypeRef t : batch) { + n.add(t.type + " (" + t.level + ")"); + } + return n.size() > 4 ? n.subList(0, 4) + " +" + (n.size() - 4) : n.toString(); + } + + /** + * Imports a batch into an open project: merges into its reference and its scene (events fire on + * the scene) and records the batch. Strat code offsets shift, so the caller should clear the + * undo history. Types already imported or present in the base with the same code are skipped. + */ + public static Outcome importInto(Project p, List<TypeRef> batch, Bases cd) throws IOException { + Outcome o = compute(p.reference(), batch, cd); + commit(p, batch, o); + return o; + } + + /** + * Second half of {@link #importInto}: writes an outcome computed from the project's reference + * (possibly on another thread) into reference and scene and records the batch. Swing thread. + */ + public static void commit(Project p, List<TypeRef> batch, Outcome o) { + Scene ref = p.reference(), cur = p.scene(); + int pages = ref.pages().size(), + tex = ref.textures().size(), + anims = ref.textureAnims().size(); + apply(o, ref, false, -1, -1, -1); + apply(o, cur, true, pages, tex, anims); + @SuppressWarnings("unchecked") + List<Object> l = p.extra().get(KEY) instanceof List<?> x ? (List<Object>) x : new ArrayList<>(); + List<Object> nl = new ArrayList<>(l); + Map<String, Object> rec = new LinkedHashMap<>(); + List<Object> ts = new ArrayList<>(); + for (TypeRef t : batch) { + ts.add(t.json()); + } + rec.put("types", ts); + nl.add(rec); + p.extra().put(KEY, nl); + } + + /** Engine limits of a level (notes/editor-cross-import.md). */ + public record Limits(int stpcBytes, int pages, int textures, int samples, int speech, int texts, long heapBytes) { + public static final long HEAP = 0x1000000; + + public List<String> problems() { + List<String> out = new ArrayList<>(); + if (samples > SoundMerge.MAX_SAMPLES) { + out.add("too many sound samples: " + samples + " (max " + SoundMerge.MAX_SAMPLES + ")"); + } + if (pages > 127) { + out.add("too many texture pages: " + pages + " (max 127)"); + } + if (heapBytes > HEAP) { + out.add(String.format( + Locale.ROOT, + "level data ≈ %.1f MB exceeds the game's 16 MB level memory", + heapBytes / 1048576.0)); + } + return out; + } + + public List<String> warnings() { + List<String> out = new ArrayList<>(); + if (heapBytes > HEAP * 3 / 4 && heapBytes <= HEAP) { + out.add(String.format( + Locale.ROOT, "level data ≈ %.1f MB of 16 MB level memory", heapBytes / 1048576.0)); + } + if (pages > 100 && pages <= 127) { + out.add(pages + " of 127 texture pages used"); + } + if (samples > 110 && samples <= SoundMerge.MAX_SAMPLES) { + out.add(samples + " of " + SoundMerge.MAX_SAMPLES + " sound samples used"); + } + return out; + } + + public String summary() { + return String.format( + Locale.ROOT, + "code+models %.1f MB · %d/127 texture pages · %d textures · %d/%d sounds · %d texts · level memory ≈ %.1f/16 MB", + stpcBytes / 1048576.0, + pages, + textures, + samples, + SoundMerge.MAX_SAMPLES, + texts, + heapBytes / 1048576.0); + } + } + + /** + * Current limits of a scene (after its imports). Level memory: the loader keeps STPC, TEXT + * (compressed pages + 20 bytes per entry), TRAK, MAP, LGPC and the strat pool in one 16 MB block + * (FUN_0041ef00); this sums those chunk sizes (an estimate, sound banks are not in it). + */ + public static Limits limits(Scene s) { + long heap = 0; + int stpc = 0; + for (RawChunk c : s.chunks()) { + switch (c.tag()) { + case "STPC" -> { + stpc = c.data().length; + heap += stpc; + } + case "SMPC", "AMPC", "SRPC", "END ", "TEXT", "TRAK", "MAP " -> {} + default -> heap += c.data() == null ? 0 : c.data().length; + } + } + for (TexturePage p : s.pages()) { + heap += p.encoded() != null ? p.encoded().length : (long) p.width() * p.height(); + } + heap += 20L * s.textures().size(); + for (TileModel m : s.tileModels()) { + heap += 0x84 + + m.vertexCount() * 24L + + m.faceCount() * 28L + + 32L * (m.collisionCount(0) + m.collisionCount(1) + m.collisionCount(2)); + } + heap += (long) s.tiles().size() * 64 + + (long) s.settings().width() * s.settings().height() * 8; + heap += (s.strats().size() + 30L + Math.max(0, s.settings().poolReserve())) * 0x154; + RawChunk sm = chunk(s, "SMPC"), lg = chunk(s, "LGPC"), sr = chunk(s, "SRPC"); + int samples = sm == null ? 0 : SoundMerge.parse(sm.data()).size(); + int texts = lg == null ? 0 : DialogMerge.Texts.parse(lg.data()).texts.size(); + int speech = + sr == null ? 0 : DialogMerge.Speech.parse(sr.data()).entries.size(); + return new Limits(stpc, s.pages().size(), s.textures().size(), samples, speech, texts, heap); + } + + // ------------------------------------------------------------------ merge + + record SourceLevel(StpcMerge.Source source, Level level) {} + + private static final Map<String, SoftReference<SourceLevel>> SOURCES = new ConcurrentHashMap<>(); + + static SourceLevel source(Bases cd, String level) throws IOException { + String k = level.toLowerCase(Locale.ROOT); + SoftReference<SourceLevel> r = SOURCES.get(k); + SourceLevel s = r == null ? null : r.get(); + if (s != null) { + return s; + } + Level lv = Level.parse(cd.wad(k)); + byte[] st = lv.wad().require("STPC"); + Set<Integer> roots = new TreeSet<>(); + for (MapChunk.Strat x : lv.map().strats) { + roots.add(x.code()); + } + StpcMerge.Analysis a = new StpcMerge.Analysis(new StpcImage(st, lv.wfpc()), roots); + s = new SourceLevel( + new StpcMerge.Source( + k, + a, + TextMerge.Textures.of(lv.wad().require("TEXT")), + SoundMerge.parse(lv.wad().get("SMPC")), + lv.wad().get("LGPC") == null + ? null + : DialogMerge.Texts.parse(lv.wad().get("LGPC")), + DialogMerge.Speech.parse(lv.wad().get("SRPC"))), + lv); + SOURCES.put(k, new SoftReference<>(s)); + return s; + } + + static RawChunk chunk(Scene s, String tag) { + for (RawChunk c : s.chunks()) { + if (c.tag().equals(tag)) { + return c; + } + } + return null; + } + + /** Analysis of a scene's (possibly already merged) STPC. */ + static StpcMerge.Analysis analysis(Scene s) { + RawChunk c = chunk(s, "STPC"); + if (c == null) { + throw new IllegalStateException("the level has no STPC chunk"); + } + Set<Integer> roots = new TreeSet<>(); + for (Strat x : s.strats()) { + roots.add(x.code()); + } + roots.addAll(s.catalog().names().keySet()); + StpcImage img = new StpcImage(c.data(), s.settings().wfpc()); + roots.removeIf(o -> o < img.codeStart || img.unitOf(o) < 0); + return new StpcMerge.Analysis(img, roots); + } + + static TextMerge.Textures textures(Scene s) { + List<TextChunk.Page> pages = new ArrayList<>(); + for (TexturePage p : s.pages()) { + pages.add(new TextChunk.Page(p.flags(), p.width(), p.height(), p.pixels())); + } + List<TextChunk.TextureEntry> entries = new ArrayList<>(); + for (TextureEntry e : s.textures()) { + entries.add(new TextChunk.TextureEntry( + (int) e.get("flags"), + (int) e.get("page"), + (int) e.get("page2"), + (int) e.get("u0"), + (int) e.get("u1"), + (int) e.get("v0"), + (int) e.get("v1"))); + } + List<TextMerge.Anim> anims = new ArrayList<>(); + for (TextureAnim a : s.textureAnims()) { + anims.add(new TextMerge.Anim(a.texture(), a.type(), a.data())); + } + return new TextMerge.Textures(pages, entries, anims); + } + + /** Merges a batch into (a copy of) the scene's level data; the scene is not changed. */ + public static Outcome compute(Scene s, List<TypeRef> batch, Bases cd) throws IOException { + StpcMerge.Analysis tgt = analysis(s); + TextMerge.Textures tex = textures(s); + int p0 = tex.pages.size(), e0 = tex.entries.size(), a0 = tex.anims.size(); + RawChunk sm = chunk(s, "SMPC"); + List<SoundMerge.Sample> snd = SoundMerge.parse(sm == null ? null : sm.data()); + RawChunk lg = chunk(s, "LGPC"), sr = chunk(s, "SRPC"); + DialogMerge.Texts texts = lg == null ? null : DialogMerge.Texts.parse(lg.data()); + DialogMerge.Speech speech = DialogMerge.Speech.parse(sr == null ? null : sr.data()); + List<StpcMerge.Request> reqs = new ArrayList<>(); + Map<TypeRef, StpcMerge.Request> byRef = new LinkedHashMap<>(); + for (TypeRef t : batch) { + SourceLevel src = source(cd, t.level); + StpcMerge.Request r = new StpcMerge.Request(src.source(), t.code); + reqs.add(r); + byRef.put(t, r); + } + StpcMerge.Result res = StpcMerge.merge(tgt, reqs, new StpcMerge.Target(tex, snd, texts, speech)); + Map<TypeRef, Integer> codes = new LinkedHashMap<>(); + Map<TypeRef, StratCatalog.Variant> variants = new LinkedHashMap<>(); + for (var e : byRef.entrySet()) { + int code = res.entries().get(e.getValue()); + codes.put(e.getKey(), code); + variants.put(e.getKey(), variant(source(cd, e.getKey().level).level(), e.getKey(), code)); + } + List<String> warn = new ArrayList<>(res.warnings()); + return new Outcome( + res.stpc(), + sm == null ? null : SoundMerge.build(snd), + lg == null || res.stats().addedTexts() == 0 ? null : texts.build(), + sr == null || res.stats().addedTexts() == 0 ? null : speech.build(), + res.target(), + new ArrayList<>(tex.pages.subList(p0, tex.pages.size())), + new ArrayList<>(tex.entries.subList(e0, tex.entries.size())), + new ArrayList<>(tex.anims.subList(a0, tex.anims.size())), + codes, + variants, + res.names(), + res.stats(), + warn); + } + + /** Catalog prototype of the source level's first instance of the entry (type fields, params, waypoints). */ + static StratCatalog.Variant variant(Level lv, TypeRef t, int newCode) { + MapChunk map = lv.map(); + for (MapChunk.Strat s : map.strats) { + if (s.code() != t.code) { + continue; + } + int[] prm = s.nParams() > 0 && s.paramIndex() >= 0 && s.paramIndex() + s.nParams() <= map.params.length + ? Arrays.copyOfRange(map.params, s.paramIndex(), s.paramIndex() + s.nParams()) + : new int[0]; + List<int[]> chain = new ArrayList<>(); + int k = s.waypoint(); + while (k >= 0 && k < map.nodes.size() && chain.size() <= 200) { + MapChunk.PathNode n = map.nodes.get(k); + chain.add(new int[] { + n.x() - s.x(), n.y() - s.y(), n.z() - s.z(), n.rx(), n.ry(), n.rz(), n.unk1(), n.unk2(), n.unk3() + }); + if (n.next() == 0) { + break; + } + k = n.next(); + } + return new StratCatalog.Variant( + newCode, + s.nVars(), + s.slots(), + s.typeFlags(), + s.extra(), + s.flags() & 0xffff, + s.rx(), + s.ry(), + s.rz(), + prm, + List.copyOf(chain), + t.level); + } + throw new IllegalArgumentException(t.type + " has no instance in " + t.level); + } + + /** + * Writes an outcome into a scene. pageAt/texAt/animAt: where the new pages/entries/animations go + * (−1 = end); items behind them (user textures) are renumbered. + */ + static void apply(Outcome o, Scene s, boolean events, int pageAt, int texAt, int animAt) { + Runnable r = () -> { + StratCatalog old = s.catalog(); + StratCatalog c = new StratCatalog(); + for (StratCatalog.Type t : old.types()) { + for (StratCatalog.Variant v : t.variants()) { + c.add(t.name(), withCode(v, o.target.map(v.code()))); + } + } + for (var e : o.variants.entrySet()) { + String name = e.getKey().type.isEmpty() ? "?" : e.getKey().type; + c.add(name, e.getValue()); + } + for (var e : old.names().entrySet()) { + int m = o.target.map(e.getKey()); + if (old.codeOnly().contains(e.getValue()) && !c.has(e.getValue())) { + c.addCodeOnly(e.getValue(), m); + } else { + c.addName(m, e.getValue()); + } + } + o.names.forEach((code, name) -> { + if (!c.has(name)) { + c.addCodeOnly(name, code); + } else { + c.addName(code, name); + } + }); + s.setCatalog(c); + chunk(s, "STPC").setRaw("data", o.stpc); + if (o.smpc != null) { + chunk(s, "SMPC").setRaw("data", o.smpc); + } + if (o.lgpc != null) { + chunk(s, "LGPC").setRaw("data", o.lgpc); + } + if (o.srpc != null && chunk(s, "SRPC") != null) { + chunk(s, "SRPC").setRaw("data", o.srpc); + } + for (Strat x : s.strats()) { + int m = o.target.map(x.code()); + if (m >= 0 && m != x.code()) { + x.setRaw("code", m); + x.setTypeName(s.catalog().nameOf(m)); + } + } + insertTextures(o, s, pageAt, texAt, animAt); + Map<Integer, TypeRef> codes = new java.util.HashMap<>(); + Map<Integer, TypeRef> prev = CODES.get(s); + if (prev != null) { + prev.forEach((k, v) -> codes.put(o.target.map(k), v)); + } + o.codes.forEach((t, k) -> codes.put(k, t)); + CODES.put(s, codes); + }; + if (events) { + s.bulk(r); + } else { + r.run(); + } + } + + static StratCatalog.Variant withCode(StratCatalog.Variant v, int code) { + return new StratCatalog.Variant( + code, + v.nVars(), + v.slots(), + v.typeFlags(), + v.extra(), + v.flags(), + v.rx(), + v.ry(), + v.rz(), + v.params(), + v.chain(), + v.sourceLevel()); + } + + private static final Pattern TEX_REF = Pattern.compile("#(\\d+)"); + + private static void insertTextures(Outcome o, Scene s, int pageAt, int texAt, int animAt) { + int np = o.pages.size(), ne = o.entries.size(), na = o.anims.size(); + int pAt = pageAt < 0 ? s.pages().size() : pageAt; + int tAt = texAt < 0 ? s.textures().size() : texAt; + int aAt = animAt < 0 ? s.textureAnims().size() : animAt; + boolean shiftPages = pAt < s.pages().size(), + shiftTex = tAt < s.textures().size(); + if (shiftPages) { + for (TextureEntry e : s.textures()) { + int pg = (int) e.get("page"); + if (pg >= pAt) { + e.setRaw("page", pg + np); + } + } + } + if (shiftTex && ne > 0) { + for (TileModel m : s.tileModels()) { + int[] f = m.faces().clone(); + boolean ch = false; + for (int i = 4; i < f.length; i += 6) { + if (f[i] >= tAt) { + f[i] += ne; + ch = true; + } + } + if (ch) { + m.setRaw("faces", f); + } + } + for (TextureAnim a : s.textureAnims()) { + if (a.texture() >= tAt) { + a.setRaw("texture", a.texture() + ne); + } + byte[] d = a.data().clone(); + boolean ch = false; + int[][] fr = new TextMerge.Anim(a.texture(), a.type(), d).frames(); + for (int[] f : fr) { + if (f[0] >= tAt) { + int v = f[0] + ne; + d[f[1]] = (byte) v; + d[f[1] + 1] = (byte) (v >> 8); + ch = true; + } + } + if (ch) { + a.setRaw("data", d); + } + } + for (Brush b : s.brushes()) { + for (Prop p : b.props()) { + if (p.type() == PropType.STRING && p.get(b) instanceof String str && str.startsWith("#")) { + Matcher m = TEX_REF.matcher(str); + if (m.matches() && Integer.parseInt(m.group(1)) >= tAt) { + p.setRaw(b, "#" + (Integer.parseInt(m.group(1)) + ne)); + } + } + } + } + } + int base = countImported(s, "page:x"); + for (int i = 0; i < np; i++) { + TextChunk.Page p = o.pages.get(i); + TexturePage n = new TexturePage("page:x" + (base + i)); + n.setRaw("flags", p.flags); + n.setRaw("width", p.w); + n.setRaw("height", p.h); + n.setRaw("pixels", p.pixels); + s.add(n, pAt + i); + } + int tb = countImported(s, "tex:x"); + for (int i = 0; i < ne; i++) { + TextChunk.TextureEntry e = o.entries.get(i); + TextureEntry n = new TextureEntry("tex:x" + (tb + i)); + n.setRaw("flags", e.flags()); + n.setRaw("page", e.page()); + n.setRaw("page2", e.page2()); + n.setRaw("u0", e.u0()); + n.setRaw("u1", e.u1()); + n.setRaw("v0", e.v0()); + n.setRaw("v1", e.v1()); + s.add(n, tAt + i); + } + int ab = countImported(s, "anim:x"); + for (int i = 0; i < na; i++) { + TextMerge.Anim a = o.anims.get(i); + TextureAnim n = new TextureAnim("anim:x" + (ab + i)); + n.setRaw("texture", a.texture()); + n.setRaw("type", a.type()); + n.setRaw("data", a.data()); + s.add(n, aAt + i); + } + } + + private static int countImported(Scene s, String prefix) { + int n = 0; + for (Node x : s.pages()) { + n += x.id().startsWith(prefix) ? 1 : 0; + } + for (Node x : s.textures()) { + n += x.id().startsWith(prefix) ? 1 : 0; + } + for (Node x : s.textureAnims()) { + n += x.id().startsWith(prefix) ? 1 : 0; + } + return n; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/ImportWorldDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/ImportWorldDialog.java new file mode 100644 index 0000000..71192f6 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/ImportWorldDialog.java @@ -0,0 +1,351 @@ +package com.eliasmueller.llamamod.editor.tools.catalog; + +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.ui.Document; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.save.Worlds; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.SwingWorker; +import javax.swing.table.AbstractTableModel; + +/** + * "Import world…": many object types of one world (all its chapters) or one level at once, for + * fresh maps. Shows the dependency sizes and, after "Preview", the exact merge result against the + * engine limits. + */ +final class ImportWorldDialog extends JDialog { + private final EditorContext ctx; + private final JComboBox<String> source = new JComboBox<>(); + private final List<Object> sourceKeys = new ArrayList<>(); + private final JCheckBox skipKnown = new JCheckBox("Skip types the level already has", true); + private final Rows rows = new Rows(); + private final JTable table = new JTable(rows); + private final JLabel preview = new JLabel(" "); + private final JButton importBtn = new JButton("Import", Icons.get("import", 14)); + private SwingWorker<?, ?> worker; + + static void show(EditorContext ctx, Object initial) { + if (ctx.document() == null) { + JOptionPane.showMessageDialog( + ctx.frame(), "Open a level first.", "Import world", JOptionPane.INFORMATION_MESSAGE); + return; + } + if (CatalogStore.get() == null) { + CatalogStore.ensure(ctx); + JOptionPane.showMessageDialog( + ctx.frame(), + "The object catalogue is still being built – try again in a moment.", + "Import world", + JOptionPane.INFORMATION_MESSAGE); + return; + } + ImportWorldDialog d = new ImportWorldDialog(ctx, initial); + d.setVisible(true); + } + + private ImportWorldDialog(EditorContext ctx, Object initial) { + super(ctx.frame(), "Import objects of a world", true); + this.ctx = ctx; + ObjectCatalog c = CatalogStore.get(); + for (int w = 1; w <= 8; w++) { + source.addItem(Worlds.name(w) + " – all chapters"); + sourceKeys.add(w); + } + Map<String, String> levels = new LinkedHashMap<>(); + for (ObjectCatalog.Entry e : c.entries()) { + levels.putIfAbsent(e.level(), e.levelName() + " (" + e.level() + ")"); + } + levels.forEach((k, v) -> { + source.addItem(v); + sourceKeys.add(k); + }); + if (initial instanceof Integer w && w >= 1 && w <= 8) { + source.setSelectedIndex(w - 1); + } else if (initial instanceof String l && sourceKeys.contains(l)) { + source.setSelectedIndex(sourceKeys.indexOf(l)); + } + source.addActionListener(e -> fill()); + skipKnown.addActionListener(e -> fill()); + JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 6)); + top.add(new JLabel("Source:")); + top.add(source); + top.add(skipKnown); + JButton all = new JButton("All"); + all.addActionListener(e -> rows.setAll(true)); + JButton none = new JButton("None"); + none.addActionListener(e -> rows.setAll(false)); + top.add(all); + top.add(none); + + table.setRowHeight(22); + table.getColumnModel().getColumn(0).setMaxWidth(36); + table.getColumnModel().getColumn(1).setPreferredWidth(200); + table.getColumnModel().getColumn(2).setPreferredWidth(170); + table.getColumnModel().getColumn(3).setPreferredWidth(170); + table.getColumnModel().getColumn(4).setPreferredWidth(70); + table.getColumnModel().getColumn(5).setPreferredWidth(320); + rows.addTableModelListener(e -> preview.setText(rows.summary())); + + JButton prev = new JButton("Preview"); + prev.setToolTipText("Merge in the background and show the exact sizes against the engine limits"); + prev.addActionListener(e -> preview()); + importBtn.addActionListener(e -> doImport()); + JButton cancel = new JButton("Cancel"); + cancel.addActionListener(e -> dispose()); + JPanel bottom = new JPanel(new BorderLayout()); + preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)); + bottom.add(preview, BorderLayout.CENTER); + JPanel btns = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + btns.add(prev); + btns.add(importBtn); + btns.add(cancel); + bottom.add(btns, BorderLayout.SOUTH); + JPanel root = new JPanel(new BorderLayout()); + root.add(top, BorderLayout.NORTH); + root.add(new JScrollPane(table), BorderLayout.CENTER); + root.add(bottom, BorderLayout.SOUTH); + setContentPane(root); + setSize(new Dimension(1060, 640)); + setLocationRelativeTo(ctx.frame()); + fill(); + } + + private void fill() { + ObjectCatalog c = CatalogStore.get(); + Object key = sourceKeys.get(Math.max(0, source.getSelectedIndex())); + Map<String, ObjectCatalog.Entry> byName = new LinkedHashMap<>(); + for (ObjectCatalog.Entry e : c.entries()) { + boolean match = + key instanceof Integer w ? e.world() == w : e.level().equals(key); + if (!match) { + continue; + } + if (skipKnown.isSelected() + && ctx.scene() != null + && ctx.scene().catalog().has(e.type())) { + continue; + } + byName.putIfAbsent(e.type(), e); + } + rows.set(new ArrayList<>(byName.values())); + preview.setText(rows.summary()); + } + + private List<ObjectCatalog.Entry> chosen() { + List<ObjectCatalog.Entry> out = new ArrayList<>(); + for (int i = 0; i < rows.list.size(); i++) { + if (rows.on.get(i) && rows.list.get(i).importable()) { + out.add(rows.list.get(i)); + } + } + return out; + } + + private void preview() { + Document d = ctx.document(); + List<ObjectCatalog.Entry> ch = chosen(); + if (d == null || ch.isEmpty()) { + return; + } + List<CrossImport.TypeRef> batch = new ArrayList<>(); + for (ObjectCatalog.Entry e : ch) { + if (Importer.importedCode(ctx, Importer.ref(e)) < 0) { + batch.add(Importer.ref(e)); + } + } + Project p = d.project(); + CrossImport.Limits before = CrossImport.limits(p.scene()); + preview.setText("Merging " + batch.size() + " types…"); + if (worker != null) { + worker.cancel(true); + } + worker = new SwingWorker<CrossImport.Outcome, Void>() { + @Override + protected CrossImport.Outcome doInBackground() throws Exception { + return CrossImport.compute(p.reference(), batch, d.bases()); + } + + @Override + protected void done() { + try { + CrossImport.Outcome o = get(); + preview.setText(describe(before, o)); + } catch (Exception ex) { + if (!isCancelled()) { + Throwable t = ex.getCause() != null ? ex.getCause() : ex; + preview.setText( + "<html><font color='#d93f3f'>Cannot import: " + t.getMessage() + "</font></html>"); + } + } + } + }; + worker.execute(); + } + + static String describe(CrossImport.Limits b, CrossImport.Outcome o) { + StpcMergeStats s = new StpcMergeStats(o); + CrossImport.Limits a = new CrossImport.Limits( + o.stpc().length, + b.pages() + o.pages().size(), + b.textures() + o.entries().size(), + o.smpc() == null + ? b.samples() + : com.eliasmueller.llamamod.format.stpcmerge.SoundMerge.parse(o.smpc()) + .size(), + b.speech(), + b.texts() + o.stats().addedTexts(), + b.heapBytes() + + (o.stpc().length - b.stpcBytes()) + + 65536L * o.pages().size() + + 20L * o.entries().size()); + StringBuilder sb = new StringBuilder("<html>"); + sb.append(String.format( + Locale.ROOT, + "Adds %d code units (%d shared with the level), %d models, %d animations, %d texture pages, %d textures, %d sounds, %d texts: +%.0f KB<br>", + s.units, + s.shared, + o.stats().models(), + o.stats().anims(), + o.pages().size(), + o.entries().size(), + o.stats().addedSamples(), + o.stats().addedTexts(), + o.stats().bytesAdded() / 1024.0)); + sb.append("After: ").append(a.summary()); + for (String p : a.problems()) { + sb.append("<br><font color='#d93f3f'>✗ ").append(p).append("</font>"); + } + for (String w : a.warnings()) { + sb.append("<br><font color='#e0a820'>! ").append(w).append("</font>"); + } + for (String w : o.warnings()) { + sb.append("<br>• ").append(w); + } + return sb.append("</html>").toString(); + } + + private record StpcMergeStats(int units, int shared) { + StpcMergeStats(CrossImport.Outcome o) { + this(o.stats().units(), o.stats().sharedUnits()); + } + } + + private void doImport() { + List<ObjectCatalog.Entry> ch = chosen(); + if (ch.isEmpty()) { + return; + } + dispose(); + Importer.importTypes(ctx, ch, o -> { + if (o != null) { + ctx.status("Imported " + o.codes().size() + " object types – find them in Assets → Objects. " + + CrossImport.limits(ctx.scene()).summary()); + } + }); + } + + private static final class Rows extends AbstractTableModel { + final List<ObjectCatalog.Entry> list = new ArrayList<>(); + final List<Boolean> on = new ArrayList<>(); + + void set(List<ObjectCatalog.Entry> l) { + list.clear(); + on.clear(); + list.addAll(l); + for (ObjectCatalog.Entry e : l) { + on.add(e.importable() + && !e.category().equals("trigger") + && !e.category().equals("force")); + } + fireTableDataChanged(); + } + + void setAll(boolean v) { + for (int i = 0; i < on.size(); i++) { + on.set(i, v && list.get(i).importable()); + } + fireTableDataChanged(); + } + + String summary() { + int n = 0; + long b = 0; + for (int i = 0; i < list.size(); i++) { + if (on.get(i)) { + n++; + b += list.get(i).bytes(); + } + } + return String.format( + Locale.ROOT, + "%d of %d types selected, ≤ %.0f KB before sharing with the level (Preview for exact numbers)", + n, + list.size(), + b / 1024.0); + } + + @Override + public int getRowCount() { + return list.size(); + } + + @Override + public int getColumnCount() { + return 6; + } + + @Override + public String getColumnName(int c) { + return new String[] {"", "Type", "Category", "Level", "Size", "Notes"}[c]; + } + + @Override + public Class<?> getColumnClass(int c) { + return c == 0 ? Boolean.class : String.class; + } + + @Override + public boolean isCellEditable(int r, int c) { + return c == 0 && list.get(r).importable(); + } + + @Override + public Object getValueAt(int r, int c) { + ObjectCatalog.Entry e = list.get(r); + return switch (c) { + case 0 -> on.get(r); + case 1 -> e.type(); + case 2 -> ObjectCatalog.categoryLabel(e.category()); + case 3 -> e.levelName(); + case 4 -> e.bytes() / 1024 + " KB"; + default -> e.importable() ? String.join("; ", e.notes()) : "✗ " + String.join("; ", e.reasons()); + }; + } + + @Override + public void setValueAt(Object v, int r, int c) { + if (c == 0) { + on.set(r, Boolean.TRUE.equals(v)); + fireTableRowsUpdated(r, r); + } + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/Importer.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/Importer.java new file mode 100644 index 0000000..d669239 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/Importer.java @@ -0,0 +1,136 @@ +package com.eliasmueller.llamamod.editor.tools.catalog; + +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.ui.Document; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.tools.EditOps; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; +import org.joml.Vector3f; + +/** Editor side of cross-level imports: background merge, commit, placement. */ +final class Importer { + private Importer() {} + + static CrossImport.TypeRef ref(ObjectCatalog.Entry e) { + return new CrossImport.TypeRef(e.level(), e.type(), e.code()); + } + + /** Variant index of the catalog type whose code equals the imported entry, or −1. */ + static int variant(Scene s, String type, int code) { + StratCatalog.Type t = s.catalog().type(type); + if (t == null) { + return -1; + } + for (int i = 0; i < t.variants().size(); i++) { + if (t.variants().get(i).code() == code) { + return i; + } + } + return -1; + } + + /** Code of an already imported type in this project, or −1. */ + static int importedCode(EditorContext ctx, CrossImport.TypeRef t) { + Document d = ctx.document(); + if (d == null) { + return -1; + } + java.util.Map<Integer, CrossImport.TypeRef> m = + CrossImport.codes(d.project().scene()); + for (var e : m.entrySet()) { + if (e.getValue().equals(t)) { + return e.getKey(); + } + } + return -1; + } + + /** + * Imports the entries not yet imported (in the background), then calls done on the Swing thread + * with the outcome (null if nothing had to be imported). + */ + static void importTypes(EditorContext ctx, List<ObjectCatalog.Entry> entries, Consumer<CrossImport.Outcome> done) { + Document d = ctx.document(); + if (d == null) { + return; + } + List<CrossImport.TypeRef> batch = new ArrayList<>(); + for (ObjectCatalog.Entry e : entries) { + CrossImport.TypeRef t = ref(e); + if (e.importable() && importedCode(ctx, t) < 0 && !batch.contains(t)) { + batch.add(t); + } + } + if (batch.isEmpty()) { + done.accept(null); + return; + } + Project p = d.project(); + String what = batch.size() == 1 ? batch.get(0).type() : batch.size() + " object types"; + ctx.runTask( + "Importing " + what, + pr -> { + pr.update(0.1, "Merging " + what + " into the level"); + return CrossImport.compute(p.reference(), batch, d.bases()); + }, + res -> { + if (!(res instanceof CrossImport.Outcome o) || ctx.document() != d) { + return; + } + CrossImport.commit(p, batch, o); + d.history().clear(); + ctx.execute(new ImportMark(CrossImport.names(batch))); + CrossImport.Limits lim = CrossImport.limits(p.scene()); + ctx.status(String.format( + Locale.ROOT, + "Imported %s: +%d KB code/models, +%d textures, +%d sounds, +%d texts · %s", + what, + o.stats().bytesAdded() / 1024, + o.stats().addedTextures(), + o.stats().addedSamples(), + o.stats().addedTexts(), + lim.summary())); + done.accept(o); + }); + } + + /** Imports (if needed) and places one instance at ground (export space). */ + static void importAndPlace(EditorContext ctx, ObjectCatalog.Entry e, Vector3f ground) { + if (!e.importable()) { + ctx.status(e.type() + " cannot be imported: " + String.join("; ", e.reasons())); + return; + } + importTypes(ctx, List.of(e), o -> { + int code = importedCode(ctx, ref(e)); + int v = code < 0 ? -1 : variant(ctx.scene(), e.type(), code); + if (v < 0) { + ctx.status(e.type() + ": imported, but no placeable variant found"); + return; + } + EditOps.placeObject(ctx, e.type(), v, ground); + }); + } + + /** + * Undo-history marker for an import. Imports move code offsets and cannot be undone, so the + * history is cleared before; undoing this entry does nothing. + */ + record ImportMark(String what) implements Command { + @Override + public String label() { + return "Import " + what + " (permanent)"; + } + + @Override + public void apply(Scene s) {} + + @Override + public void revert(Scene s) {} + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/ObjectCatalog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/ObjectCatalog.java new file mode 100644 index 0000000..ba009ce --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/ObjectCatalog.java @@ -0,0 +1,491 @@ +package com.eliasmueller.llamamod.editor.tools.catalog; + +import com.eliasmueller.llamamod.editor.io.Bases; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.render.LevelSceneAdapter; +import com.eliasmueller.llamamod.editor.render.RenderMesh; +import com.eliasmueller.llamamod.editor.render.StratModels; +import com.eliasmueller.llamamod.editor.render.TexturePage; +import com.eliasmueller.llamamod.editor.ui.OriginalLevels; +import com.eliasmueller.llamamod.editor.ui.view.Thumbnails; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.Strats; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StpcMerge; +import com.eliasmueller.llamamod.platform.Json; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeSet; +import java.util.function.BiConsumer; +import javax.imageio.ImageIO; + +/** + * Global catalogue of the placeable object types of all levels on the CD: name, source levels, + * category, dependency size, thumbnail and whether it can be imported (with reasons). Built once in + * the background and cached in {@code <data>/editor/catalog/} (catalog.json + thumbs/*.png). + */ +public final class ObjectCatalog { + static final int VERSION = 2; + public static final int THUMB = 96; + + /** One type of one level (the first instance's code entry). */ + public record Entry( + String level, + int world, + int chapter, + String levelName, + String type, + int code, + String category, + int instances, + int params, + boolean waypoints, + int units, + int models, + int anims, + int sounds, + int textures, + int bytes, + boolean importable, + List<String> reasons, + List<String> notes, + String thumb) { + public String key() { + return level + ":" + type + "@" + Integer.toHexString(code); + } + + Map<String, Object> json() { + Map<String, Object> m = new LinkedHashMap<>(); + m.put("level", level); + m.put("world", (long) world); + m.put("chapter", (long) chapter); + m.put("levelName", levelName); + m.put("type", type); + m.put("code", (long) code); + m.put("category", category); + m.put("instances", (long) instances); + m.put("params", (long) params); + m.put("waypoints", waypoints); + m.put("units", (long) units); + m.put("models", (long) models); + m.put("anims", (long) anims); + m.put("sounds", (long) sounds); + m.put("textures", (long) textures); + m.put("bytes", (long) bytes); + m.put("importable", importable); + m.put("reasons", new ArrayList<Object>(reasons)); + m.put("notes", new ArrayList<Object>(notes)); + m.put("thumb", thumb == null ? "" : thumb); + return m; + } + + @SuppressWarnings("unchecked") + static Entry of(Map<String, Object> m) { + return new Entry( + (String) m.get("level"), + i(m, "world"), + i(m, "chapter"), + (String) m.get("levelName"), + (String) m.get("type"), + i(m, "code"), + (String) m.get("category"), + i(m, "instances"), + i(m, "params"), + Boolean.TRUE.equals(m.get("waypoints")), + i(m, "units"), + i(m, "models"), + i(m, "anims"), + i(m, "sounds"), + i(m, "textures"), + i(m, "bytes"), + Boolean.TRUE.equals(m.get("importable")), + (List<String>) (List<?>) m.getOrDefault("reasons", List.of()), + (List<String>) (List<?>) m.getOrDefault("notes", List.of()), + (String) m.get("thumb")); + } + + private static int i(Map<String, Object> m, String k) { + Object v = m.get(k); + return v instanceof Number n ? n.intValue() : 0; + } + } + + private final List<Entry> entries; + private final Path dir; + + private ObjectCatalog(List<Entry> entries, Path dir) { + this.entries = entries; + this.dir = dir; + } + + public List<Entry> entries() { + return entries; + } + + public Path thumbFile(Entry e) { + return e.thumb == null || e.thumb.isEmpty() || dir == null + ? null + : dir.resolve("thumbs").resolve(e.thumb); + } + + /** Cached catalogue if it matches the CD, else null. */ + @SuppressWarnings("unchecked") + public static ObjectCatalog cached(Path dir, Path cd) { + try { + Path f = dir.resolve("catalog.json"); + if (!Files.isRegularFile(f)) { + return null; + } + Map<String, Object> j = (Map<String, Object>) Json.parse(Files.readString(f, StandardCharsets.UTF_8)); + if (((Number) j.getOrDefault("version", 0L)).intValue() != VERSION + || !fingerprint(cd).equals(j.get("cd"))) { + return null; + } + List<Entry> out = new ArrayList<>(); + for (Object o : (List<Object>) j.get("entries")) { + out.add(Entry.of((Map<String, Object>) o)); + } + return new ObjectCatalog(out, dir); + } catch (IOException | RuntimeException e) { + return null; + } + } + + /** Names and sizes of the CD's level WADs (cache key). */ + static String fingerprint(Path cd) throws IOException { + StringBuilder sb = new StringBuilder(); + for (OriginalLevels.Level l : OriginalLevels.list(cd)) { + Path p = Bases.resolveIgnoreCase(cd, "wads/" + l.base() + ".wad"); + sb.append(l.base()) + .append(':') + .append(p == null ? 0 : Files.size(p)) + .append(';'); + } + return Integer.toHexString(sb.toString().hashCode()) + ":" + sb.length(); + } + + /** Builds the catalogue (all levels), writes the cache; progress(done, total). */ + public static ObjectCatalog build(Path cd, Path dir, BiConsumer<Integer, Integer> progress) throws IOException { + Bases bases = Bases.fromCd(cd); + List<OriginalLevels.Level> levels = new ArrayList<>(OriginalLevels.list(cd)); + Files.createDirectories(dir.resolve("thumbs")); + List<Entry> out = new ArrayList<>(); + int done = 0; + for (OriginalLevels.Level l : levels) { + progress.accept(done++, levels.size()); + byte[] wad = bases.fileOrNull("wads/" + l.base() + ".wad"); + if (wad == null) { + continue; + } + try { + scanLevel(l, wad, dir, out); + } catch (RuntimeException e) { + // front-end WADs without MAP and the like + } + } + progress.accept(levels.size(), levels.size()); + Map<String, Object> j = new LinkedHashMap<>(); + j.put("version", (long) VERSION); + j.put("cd", fingerprint(cd)); + List<Object> es = new ArrayList<>(); + out.forEach(e -> es.add(e.json())); + j.put("entries", es); + Files.writeString(dir.resolve("catalog.json"), Json.write(j), StandardCharsets.UTF_8); + return new ObjectCatalog(out, dir); + } + + static void scanLevel(OriginalLevels.Level l, byte[] wad, Path dir, List<Entry> out) throws IOException { + Level lv = Level.parse(wad); + byte[] st = lv.wad().require("STPC"); + StpcImage img = new StpcImage(st, lv.wfpc()); + TreeSet<Integer> roots = new TreeSet<>(); + Map<Integer, Integer> count = new LinkedHashMap<>(); + Map<Integer, MapChunk.Strat> first = new LinkedHashMap<>(); + for (MapChunk.Strat s : lv.map().strats) { + roots.add(s.code()); + count.merge(s.code(), 1, Integer::sum); + first.putIfAbsent(s.code(), s); + } + StpcMerge.Analysis a = new StpcMerge.Analysis(img, roots); + byte[] text = lv.wad().require("TEXT"); + TextChunk tc = TextChunk.parse(text); + List<TextChunk.TextureEntry> tex = TextChunk.textureEntries(text); + int[] pageClass = new int[tc.pages.size()]; + List<TexturePage> pages = new ArrayList<>(); + for (int i = 0; i < tc.pages.size(); i++) { + pageClass[i] = tc.pages.get(i).flags & 0x7f; + pages.add(LevelSceneAdapter.page(tc.pages.get(i))); + } + StratModels sm = new StratModels(st, lv.wfpc()); + Map<String, Integer> seenName = new LinkedHashMap<>(); + for (var e : first.entrySet()) { + int code = e.getKey(); + MapChunk.Strat s = e.getValue(); + String name = Strats.nameOr(st, code, img.codeStart, "?").replace("(spawn)", ""); + if (seenName.containsKey(name)) { + continue; // further variants (spawn stubs) of a type: the first one represents it + } + seenName.put(name, code); + StpcMerge.Closure c = StpcMerge.closure(a, code, null); + List<String> reasons = new ArrayList<>(c.problems()); + List<String> notes = new ArrayList<>(); + String cat = category(name); + if (cat.equals("player")) { + reasons.add("player start: every level has its own"); + } + if (cat.equals("camera") || name.startsWith("CutScene")) { + reasons.add("camera / cut scene of its level"); + } + if (c.text()) { + notes.add("speaks/shows " + c.texts().size() + " texts of its level (copied with speech)"); + } + if (c.tileSwap()) { + notes.add("swaps tiles of its cell (needs alternative tile models there)"); + } + if (s.waypoint() >= 0) { + notes.add("uses a waypoint chain (copied relative to the object)"); + } + String thumb = null; + try { + BufferedImage img2 = thumbnail(sm, code, name, tex, tc, pageClass, pages); + if (img2 != null) { + thumb = l.base() + "_" + Integer.toHexString(code) + ".png"; + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + ImageIO.write(img2, "png", bo); + Files.write(dir.resolve("thumbs").resolve(thumb), bo.toByteArray()); + } + } catch (RuntimeException ex) { + thumb = null; + } + out.add(new Entry( + l.base(), + l.world(), + l.chapter(), + l.name(), + name, + code, + cat, + count.get(code), + s.nParams(), + s.waypoint() >= 0, + c.units().size(), + c.models().size(), + c.anims().size(), + c.sounds().size(), + c.textures().size(), + c.bytes(), + reasons.isEmpty(), + reasons, + notes, + thumb)); + } + } + + /** Catalogue categories: the editor's ones, with "other" split by name. */ + public static final String[] CATEGORIES = { + "enemy", + "collectible", + "mechanism", + "character", + "creature", + "effect", + "checkpoint", + "exit", + "force", + "trigger", + "camera", + "player", + "other" + }; + + public static String categoryLabel(String c) { + return switch (c) { + case "enemy" -> "Enemies"; + case "collectible" -> "Collectibles & pickups"; + case "mechanism" -> "Platforms, doors & mechanisms"; + case "character" -> "Characters"; + case "creature" -> "Animals"; + case "effect" -> "Effects & ambience"; + case "checkpoint" -> "Checkpoints"; + case "exit" -> "Exits"; + case "force" -> "Forces & currents"; + case "trigger" -> "Triggers & dialogue"; + case "camera" -> "Cameras & cut scenes"; + case "player" -> "Player & start"; + default -> "Other"; + }; + } + + private static final String[][] WORDS = { + { + "enemy", + "Guard", + "Spider", + "Beetle", + "Scorpion", + "Bat", + "Monkey", + "Croc", + "Snake", + "Wasp", + "Bee", + "Dart", + "Blade", + "Boulder", + "Gorilla", + "Warthog", + "Lizard", + "Jaguar", + "Piranha", + "Needle", + "Cannon", + "Hammer" + }, + {"collectible", "Grapes", "Banana", "Fruit", "Potion", "Idol", "Booster", "Pot", "Jar", "Box", "Pinata"}, + { + "mechanism", + "Platform", + "Door", + "Lift", + "Bridge", + "Switch", + "Pad", + "Column", + "Statue", + "Log", + "Gate", + "Lever", + "Wall", + "Block", + "Treadmill", + "MerryGoRound", + "Rope", + "Pole", + "Tree", + "Rock", + "Wheel", + "Puzzle", + "Sinking", + "Falling", + "Rising", + "Mouth", + "Timer", + "Pump", + "Seesaw", + "Spring", + "Trampoline", + "Elevator" + }, + { + "character", + "Pacha", + "Tipo", + "Chaca", + "Kid", + "Yzma", + "Kronk", + "Bucky", + "Villager", + "Folk", + "Kusco", + "Kuzco", + "Man", + "Girl", + "Naked", + "General", + "Herder", + "Condor", + "Blockhead" + }, + {"creature", "Bird", "Bunny", "Butterfly", "Moth", "Ladybird", "Fly", "Frog", "Turtle", "Squirrel", "Wompy"}, + { + "effect", + "Mist", + "Rain", + "Water", + "Firework", + "Sparkle", + "Smoke", + "Dripping", + "Fire", + "Sound", + "Steam", + "Light" + } + }; + + public static String category(String name) { + String c = StratCatalog.category(name); + if (!c.equals("other")) { + return c; + } + for (String[] w : WORDS) { + for (int i = 1; i < w.length; i++) { + if (name.contains(w[i])) { + return w[0]; + } + } + } + return "other"; + } + + static BufferedImage thumbnail( + StratModels sm, + int code, + String name, + List<TextChunk.TextureEntry> tex, + TextChunk tc, + int[] pageClass, + List<TexturePage> pages) { + int mi = sm.modelOf(code); + if (mi < 0) { + return null; + } + Model m = sm.models.get(mi); + if (StratModels.skinned(m)) { + m = sm.posed(code, mi); + } + if (m == null || m.faces.isEmpty()) { + return null; + } + RenderMesh rm = LevelSceneAdapter.mesh(name, m, tex, tc, pageClass, RenderMesh.LIT); + return Thumbnails.render(rm, pages, THUMB); + } + + /** Entries matching a query (name or level, case-insensitive), world (0 = all) and category (null = all). */ + public List<Entry> filter(String q, int world, String category, boolean importableOnly) { + String ql = q == null ? "" : q.trim().toLowerCase(Locale.ROOT); + List<Entry> out = new ArrayList<>(); + for (Entry e : entries) { + if (world > 0 && e.world != world) { + continue; + } + if (category != null && !category.equals(e.category)) { + continue; + } + if (importableOnly && !e.importable) { + continue; + } + if (!ql.isEmpty() + && !e.type.toLowerCase(Locale.ROOT).contains(ql) + && !e.level.contains(ql) + && !e.levelName.toLowerCase(Locale.ROOT).contains(ql)) { + continue; + } + out.add(e); + } + return out; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/BakePresets.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/BakePresets.java new file mode 100644 index 0000000..007aac2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/BakePresets.java @@ -0,0 +1,101 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import java.util.List; + +/** Lighting presets for the bake tool, plus the fog/ambient colours that go with them. */ +public final class BakePresets { + private BakePresets() {} + + /** + * @param fogBgr zone fog/backdrop colour (0x00BBGGRR) + * @param ambientBgr zone ambient colour for objects (0x00BBGGRR) + */ + public record Preset(String name, VertexBake.Rig rig, int fogBgr, int ambientBgr) { + @Override + public String toString() { + return name; + } + } + + private static double[] c(double r, double g, double b) { + return new double[] {r, g, b}; + } + + private static int bgr(int r, int g, int b) { + return r | g << 8 | b << 16; + } + + public static final List<Preset> ALL = List.of( + new Preset( + "Day", + new VertexBake.Rig( + 215, + 50, + 0.62, + c(1.0, 0.93, 0.80), + c(0.50, 0.55, 0.66), + c(0.30, 0.28, 0.24), + 0.55, + 1.3, + 0.35, + false), + bgr(0xa8, 0xc8, 0xe8), + bgr(0x40, 0x40, 0x40)), + new Preset( + "Dusk", + new VertexBake.Rig( + 255, + 14, + 0.70, + c(1.0, 0.58, 0.32), + c(0.36, 0.30, 0.44), + c(0.22, 0.16, 0.15), + 0.55, + 1.3, + 0.30, + true), + bgr(0xc0, 0x70, 0x50), + bgr(0x40, 0x30, 0x30)), + new Preset( + "Night", + new VertexBake.Rig( + 140, + 45, + 0.28, + c(0.55, 0.65, 1.0), + c(0.13, 0.16, 0.30), + c(0.06, 0.06, 0.10), + 0.6, + 1.3, + 0.25, + true), + bgr(0x08, 0x0c, 0x24), + bgr(0x18, 0x1c, 0x30)), + new Preset( + "Cave", + new VertexBake.Rig( + 0, 80, 0.0, c(1, 1, 1), c(0.34, 0.30, 0.25), c(0.20, 0.17, 0.14), 0.85, 2.0, 0.0, true), + bgr(0x10, 0x0c, 0x08), + bgr(0x30, 0x2c, 0x28)), + new Preset( + "Flat (no lighting)", + new VertexBake.Rig(0, 90, 0.0, c(1, 1, 1), c(1, 1, 1), c(1, 1, 1), 0.0, 1.0, 1.0, false), + bgr(0xa8, 0xc8, 0xe8), + bgr(0x40, 0x40, 0x40))); + + /** The level's own sun (LevelSettings "Lighting" group, used for brushes). */ + public static VertexBake.Rig fromSettings(LevelSettings s, boolean points) { + return new VertexBake.Rig( + s.sunAzimuth(), + s.sunElevation(), + s.sunStrength(), + s.sunColor(), + s.sky(), + s.ground(), + s.ao(), + s.aoRadius(), + s.shadow(), + points); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Binder.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Binder.java new file mode 100644 index 0000000..d192dcb --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Binder.java @@ -0,0 +1,168 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.SceneListener; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.DoubleFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.ToDoubleFunction; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JSpinner; +import javax.swing.SwingUtilities; + +/** + * Binds form widgets to node properties: edits go through undoable {@link SetProp}s (one merge key + * per widget, so repeated spinner clicks are one undo step) and the widgets follow scene changes + * (undo, gizmo drags) while the form is shown. + */ +final class Binder { + private final EditorContext ctx; + private final Node node; + private final List<Runnable> refreshers = new ArrayList<>(); + private boolean updating; + private final SceneListener listener; + private boolean alive = true; + + Binder(EditorContext ctx, Node node) { + this.ctx = ctx; + this.node = node; + listener = this::changed; + ctx.addSceneListener(listener); + } + + private void changed(SceneEvent e) { + if (!alive || e.node() != node || updating) { + return; + } + SwingUtilities.invokeLater(this::refresh); + } + + /** Stops following the scene (the shell rebuilds sections on every selection change). */ + void attach(JComponent root) { + boolean[] shown = {false}; + root.addHierarchyListener(e -> { + if (root.isDisplayable()) { + shown[0] = true; + } else if (shown[0]) { + alive = false; + } + }); + } + + void refresh() { + updating = true; + try { + for (Runnable r : refreshers) { + r.run(); + } + } finally { + updating = false; + } + } + + void onRefresh(Runnable r) { + refreshers.add(r); + } + + /** Runs r now and on every refresh. */ + void live(Runnable r) { + r.run(); + onRefresh(r); + } + + /** Label whose text follows the node. */ + JLabel label(Supplier<String> text) { + JLabel l = new JLabel(); + live(() -> l.setText(text.get())); + return l; + } + + void set(String prop, Object value, Object mergeKey) { + if (updating) { + return; + } + Object old = node.get(prop); + if (java.util.Objects.equals(old, value)) { + return; + } + updating = true; + try { + ctx.execute(SetProp.of(node, node.prop(prop), value, mergeKey)); + } finally { + updating = false; + } + SwingUtilities.invokeLater(this::refresh); + } + + /** Spinner showing f(node) in display units; writes toProp(display value). */ + JSpinner number( + double min, + double max, + double step, + String pattern, + ToDoubleFunction<Node> get, + DoubleFunction<Object> toProp, + String prop) { + Object key = new Object(); + JSpinner[] sp = new JSpinner[1]; + sp[0] = Ui.number(get.applyAsDouble(node), min, max, step, pattern, v -> { + Object pv = toProp.apply(v); + if (!updating && pv != null) { + set(prop, pv, key); + } + }); + onRefresh(() -> sp[0].setValue(Ui.clamp(get.applyAsDouble(node), min, max))); + return sp[0]; + } + + /** Generic custom edit: run with this binder's echo guard. */ + void edit(Runnable r) { + if (!updating) { + r.run(); + } + } + + JCheckBox check(String label, Function<Node, Boolean> get, Consumer<Boolean> set) { + JCheckBox c = new JCheckBox(label, get.apply(node)); + c.addActionListener(e -> { + if (!updating) { + set.accept(c.isSelected()); + } + }); + onRefresh(() -> c.setSelected(get.apply(node))); + return c; + } + + <T> JComboBox<T> combo(T[] items, Function<Node, Integer> index, Consumer<Integer> set) { + JComboBox<T> c = new JComboBox<>(items); + c.setMaximumRowCount(20); + int i = index.apply(node); + c.setSelectedIndex(i >= 0 && i < items.length ? i : -1); + c.addActionListener(e -> { + if (!updating && c.getSelectedIndex() >= 0) { + set.accept(c.getSelectedIndex()); + } + }); + onRefresh(() -> { + int k = index.apply(node); + c.setSelectedIndex(k >= 0 && k < items.length ? k : -1); + }); + return c; + } + + Ui.ColorButton color(String title, Function<Node, Color> get, java.util.function.BiConsumer<Color, Object> set) { + Ui.ColorButton b = new Ui.ColorButton(title, () -> get.apply(node), set); + onRefresh(b::refresh); + return b; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvChecks.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvChecks.java new file mode 100644 index 0000000..62e1c77 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvChecks.java @@ -0,0 +1,242 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Regions; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.ui.LevelCheck; +import com.eliasmueller.llamamod.editor.ui.Problem; +import java.util.List; +import java.util.Locale; + +/** Problems checks for zones/fog, visibility regions, vertex lighting and lights. */ +final class EnvChecks implements LevelCheck { + /** Fog end below this (cells, detail Maximum) makes the level a fog wall. */ + static final double FOG_NEAR = 3; + + /** Above this the engine draws and wakes up a lot (the originals stay ≤ 23). */ + static final double FOG_FAR = 30; + + @Override + public void check(Scene s, List<Problem> out) { + zones(s, out); + regions(s, out); + unlit(s, out); + lights(s, out); + } + + static void zones(Scene s, List<Problem> out) { + int w = s.settings().width(), h = s.settings().height(); + int starts = 0; + for (EnvZone z : s.zones()) { + if (EnvMath.isStart(z)) { + starts++; + } + } + if (!s.zones().isEmpty() && starts == 0) { + out.add(Problem.warning( + "No start zone (zone flag 8): the level starts without its fog, colours and music settings", + s.zones().getFirst())); + } else if (starts > 1) { + out.add(Problem.warning(starts + " zones are marked as start zone; the last one wins", null)); + } + for (EnvZone z : s.zones()) { + String name = EnvMath.isStart(z) ? "Start zone" : "Zone " + z.cellX() + "," + z.cellZ(); + double[] hi = EnvMath.fog(z, EnvMath.DETAIL_MAX); + double[] lo = EnvMath.fog(z, EnvMath.DETAIL_MIN); + if (z.fogMode() != 0 || EnvMath.isStart(z)) { + if (hi[1] < FOG_NEAR) { + out.add(Problem.warning( + String.format( + Locale.ROOT, + "%s: fog ends after %.1f cells even at detail Maximum (%.1f at Minimum) – a wall of" + + " fog, objects pop in right in front of the player", + name, + hi[1], + lo[1]), + z)); + } else if (hi[1] > FOG_FAR) { + out.add(Problem.warning( + String.format( + Locale.ROOT, + "%s: draw distance %.1f cells at detail Maximum – slow, and enemies wake up from" + + " far away (the original levels use 7–23)", + name, + hi[1]), + z)); + } + if (hi[0] >= hi[1] || lo[0] >= lo[1]) { + out.add(Problem.warning(name + ": fog start is not before fog end", z)); + } + } + if (EnvMath.isStart(z)) { + continue; + } + boolean cellIn = z.cellX() >= 0 && z.cellZ() >= 0 && z.cellX() < w && z.cellZ() < h; + if (!cellIn) { + out.add(Problem.warning( + name + ": trigger cell outside the " + w + "×" + h + " grid, never entered", z)); + } + if (EnvMath.teleports(z)) { + if (z.targetX() < 0 || z.targetZ() < 0 || z.targetX() >= w || z.targetZ() >= h) { + out.add(Problem.error( + String.format( + Locale.ROOT, + "%s: teleport target %.1f, %.1f is outside the grid", + name, + z.targetX(), + z.targetZ()), + z)); + } + if (cellIn && !hasExitFloor(s, z.cellX(), z.cellZ())) { + out.add(Problem.info( + name + ": no exit floor (collision material 16) or door object in the trigger cell – the" + + " teleport only fires when the player steps on an exit floor", + z)); + } + } + } + } + + static boolean hasExitFloor(Scene s, int cx, int cz) { + for (Tile t : s.tiles()) { + if (t.cellX() != cx || t.cellZ() != cz) { + continue; + } + if (t.swapHasMaterial(16)) { + return true; + } + TileModel m = t.model(); + if (m != null) { + for (int g = 0; g < 3; g++) { + for (int k = 0; k < m.collisionCount(g); k++) { + if (m.collisionMaterial(g, k) == 16) { + return true; + } + } + } + } + } + for (Strat st : s.strats()) { + if ((int) Math.floor(st.gx()) == cx && (int) Math.floor(st.gz()) == cz) { + return true; + } + } + return !s.brushes().isEmpty(); + } + + static void regions(Scene s, List<Problem> out) { + Regions r = s.regions(); + int w = s.settings().width(), h = s.settings().height(); + if (r.grid().length != w * h || r.table().length < 2 * Regions.COUNT) { + return; + } + Strat p = s.playerStart(); + if (p != null) { + int cx = (int) Math.floor(p.gx()), cz = (int) Math.floor(p.gz()); + int reg = r.regionAt(cx, cz, w); + if (reg >= 0 && r.mask(reg) != 0 && (r.mask(reg) & (1 << reg)) == 0) { + out.add(Problem.error( + String.format( + Locale.ROOT, + "The player start (cell %d, %d) is in visibility region %d whose mask does not include" + + " region %d itself – the ground and the llama would be invisible", + cx, + cz, + reg, + reg), + p)); + } + } + boolean[] used = RegionGen.used(r.grid()); + long[] tris = RegionGen.trianglesPerRegion(s, r.grid()); + int seen = 0; + for (int a = 0; a < Regions.COUNT; a++) { + if (used[a]) { + seen |= r.mask(a) == 0 ? -1 : r.mask(a); + } + } + for (int b = 0; b < Regions.COUNT; b++) { + if (used[b] && tris[b] > 0 && (seen & (1 << b)) == 0) { + out.add(Problem.warning( + "Visibility region " + b + " (" + tris[b] + " triangles) is not drawn from any region – its" + + " geometry is never visible", + r)); + } + } + long[] vis = RegionGen.visibleTriangles(s, r.grid(), r.table()); + for (int a = 0; a < Regions.COUNT; a++) { + if (used[a] && vis[a] > RegionGen.HEAVY) { + out.add(Problem.info( + String.format( + Locale.ROOT, + "Visibility region %d draws %,d triangles (heavy for the original engine; the fog" + + " distance limits it further)", + a, + vis[a]), + r)); + break; + } + } + } + + static void unlit(Scene s, List<Problem> out) { + int grey = 0, black = 0; + Tile first = null; + for (Tile t : s.tiles()) { + TileModel m = t.model(); + if (m == null || m.faceCount() == 0) { + continue; + } + int nv = m.vertexCount(); + int[] c = t.colors(); + if (c.length < nv || nv == 0) { + continue; + } + boolean allGrey = true, allBlack = true; + for (int i = 0; i < nv; i++) { + int v = c[i] & 0xffffff; + allGrey &= v == 0x808080; + allBlack &= v == 0; + } + if (allGrey || allBlack) { + if (first == null) { + first = t; + } + if (allGrey) { + grey++; + } else { + black++; + } + } + } + if (grey + black > 0) { + out.add(Problem.warning( + (grey > 0 ? grey + " tiles have flat grey (unlit) vertex colours" : "") + + (grey > 0 && black > 0 ? ", " : "") + + (black > 0 ? black + " tiles are completely black" : "") + + " – use Environment → Lighting → Bake lighting", + first)); + } + } + + static void lights(Scene s, List<Problem> out) { + for (Light l : s.lights()) { + if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) { + if (l.rangeB() <= 0) { + out.add(Problem.warning(l.label() + ": falloff radius 0, the light has no effect", l)); + } else if (l.rangeB() < l.a()) { + out.add(Problem.info(l.label() + ": falloff radius smaller than the full radius", l)); + } + } else if (l.type() != Light.DIRECTIONAL) { + out.add(Problem.info(l.label() + ": type " + l.type() + " is ignored by the engine", l)); + } + if (l.r() == 0 && l.g() == 0 && l.b() == 0) { + out.add(Problem.info(l.label() + ": colour is black (no effect)", l)); + } + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvMath.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvMath.java new file mode 100644 index 0000000..913b1cd --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvMath.java @@ -0,0 +1,325 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.render.Legend; +import java.awt.Color; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** + * Engine rules for zones, fog and lights (groove.exe), shared by the panels, the previews and the + * checks. Zone colours are stored as 0x00BBGGRR, tile vertex colours as R | G<<8 | B<<16 | frames<<24 + * with 0x80 = 1.0. + */ +public final class EnvMath { + private EnvMath() {} + + // ------------------------------------------------------------ zone fields + + public static final int F_CELL_X = 0; + public static final int F_CELL_Y = 1; + public static final int F_CELL_Z = 2; + public static final int F_X = 3; + public static final int F_Y = 4; + public static final int F_Z = 5; + public static final int F_ANGLE = 7; + public static final int F_FLAGS = 8; + public static final int F_SKY = 10; + public static final int F_SKY_YAW = 11; + public static final int F_MODE = 13; + public static final int F_PUSH = 14; + public static final int F_NEAR_LO = 15; + public static final int F_FAR_LO = 16; + public static final int F_NEAR_HI = 17; + public static final int F_FAR_HI = 18; + public static final int F_AMBIENT = 19; + public static final int F_FOG = 20; + public static final int F_MUSIC = 23; + + /** flags (+0x20) bit 2: only switch environment, do not move the player (FUN_0054de00). */ + public static final int FLAG_NO_TELEPORT = 4; + + /** flags bit 3: zone active at level start (loader FUN_0042ac50 → DAT_00584644). */ + public static final int FLAG_START = 8; + + /** angleFlags bit 0: trigger also one cell below; bit 1: one cell above + no forward step. */ + public static final int ANGLE_Y_BELOW = 1; + + public static final int ANGLE_Y_ABOVE = 2; + + /** Fog modes (zone +0x34 → DAT_005865cc, FUN_00438180). */ + public static final String[] FOG_MODES = { + "0 – no fog", + "1 – darkness, light around the player (radius = fog start…end)", + "2 – fog (table)", + "3 – fog (vertex, most levels)" + }; + + public static boolean isStart(EnvZone z) { + return (z.field(F_FLAGS) & FLAG_START) != 0; + } + + public static boolean teleports(EnvZone z) { + return (z.field(F_FLAGS) & FLAG_NO_TELEPORT) == 0 && !isStart(z); + } + + /** The zone the level starts with (flag 8; else the (1,1,1) default entry, else the first). */ + public static EnvZone startZone(Scene s) { + for (EnvZone z : s.zones()) { + if (isStart(z)) { + return z; + } + } + for (EnvZone z : s.zones()) { + if (z.isDefault()) { + return z; + } + } + return s.zones().isEmpty() ? null : s.zones().getFirst(); + } + + public static String describe(EnvZone z) { + if (isStart(z)) { + return "Start zone (level default)"; + } + String cell = z.cellX() + "," + z.cellZ(); + if (!teleports(z)) { + return "Environment " + cell; + } + return String.format(Locale.ROOT, "Teleport %s → %.1f, %.1f", cell, z.targetX(), z.targetZ()); + } + + /** Facing at the target in degrees (0 = +z, 90 = +x), from bits 4..11 of the angle field. */ + public static double facingDegrees(EnvZone z) { + return (z.field(F_ANGLE) & 0xff0) * 360.0 / 4096; + } + + public static int withFacing(int angleField, double degrees) { + int a = (int) Math.round(((degrees % 360 + 360) % 360) * 4096 / 360.0) & 0xff0; + return (angleField & ~0xff0) | a; + } + + // ------------------------------------------------------------ fog + + /** Detail level value t of the game's detail setting (0x5848cc). */ + public static final double DETAIL_MIN = 0; + + public static final double DETAIL_MED = 0.5; + public static final double DETAIL_MAX = 1; + + /** + * Fog start and end (cells) at detail t, as FUN_0041a480 computes them: the low pair belongs to + * detail Minimum, the high pair to Maximum (if high start ≤ low start the loader uses 1.5 × the + * low pair), interpolated and then scaled by t + (1 − t)·0.5. + */ + public static double[] fog(EnvZone z, double t) { + double ns = z.field(F_NEAR_LO), fs = z.field(F_FAR_LO); + double nh = z.field(F_NEAR_HI), fh = z.field(F_FAR_HI); + if (nh <= ns) { + nh = ns + Math.floor(ns / 2); + fh = fs + Math.floor(fs / 2); + } + double k = t + (1 - t) * 0.5; + double start = (ns + (nh - ns) * t) * k / 4096; + double end = (fs + (fh - fs) * t) * k / 4096; + return new double[] {start, end}; + } + + public static Color zoneColor(int bgr) { + return new Color(bgr & 0xff, (bgr >> 8) & 0xff, (bgr >> 16) & 0xff); + } + + public static int zoneBgr(Color c) { + return c.getRed() | c.getGreen() << 8 | c.getBlue() << 16; + } + + /** 0x00BBGGRR → 0xRRGGBB. */ + public static int bgrToRgb(int c) { + return (c & 0xff) << 16 | (c & 0xff00) | (c >>> 16) & 0xff; + } + + // ------------------------------------------------------------ music + + /** + * Music ids: table at 0x578960 in groove.exe (20 bytes per id, name pointer + 8), played from + * {@code music/<name>.ASF} (FUN_00546b70). 0 stops the music. + */ + public static final String[] MUSIC = { + "(none)", + "VILLAGE", + "JUNGLE1", + "RIVER", + "JUNGLE2", + "MOUNTAIN", + "CITY", + "CATACOMB", + "LAB", + "TITLE", + "GAMEOVER", + "TALLY", + "TURRACE", + "PACHASNK", + "MOUNT2", + "BALLOON", + "JUNGSPID", + "JAGCHASE", + "RIVER", + "RIVRACE", + "SNKJUNG", + "FROG", + "KRONK", + "KIDLLAMA", + "TUNE8", + "ROLLER", + "BUNNY", + "BLOCK", + "TUNE5", + "CITYPUZZ", + "TUNE1", + "CHACHA", + "JJUNGLE2", + "TUNE9", + "TUNE6", + "VICTORY", + "PUZZLE2", + "PLATFORM", + "(none)", + "ENDGAME" + }; + + public static String musicLabel(int id) { + return id >= 0 && id < MUSIC.length ? id + " – " + MUSIC[id] : id + " – (unknown)"; + } + + // ------------------------------------------------------------ lights + + /** Light colour as shown (engine factor ×2/255, clamped for display). */ + public static Color lightColor(Light l) { + return new Color(Math.min(255, 2 * l.r()), Math.min(255, 2 * l.g()), Math.min(255, 2 * l.b())); + } + + /** Engine colour factor of a light (type 4 negative). */ + public static double[] lightFactor(Light l) { + double[] c = {2.0 * l.r() / 255, 2.0 * l.g() / 255, 2.0 * l.b() / 255}; + if (l.type() == Light.POINT_BRIGHT) { + for (int k = 0; k < 3; k++) { + c[k] = -(c[k] + 1) * 0.5; + } + } + return c; + } + + /** Direction of a directional light (game space, towards the light), normalised. */ + public static double[] direction(Light l) { + double x = l.x(), y = l.y(), z = l.z(); + double len = Math.sqrt(x * x + y * y + z * z); + return len < 1e-9 ? new double[] {0, 1, 0} : new double[] {x / len, y / len, z / len}; + } + + /** Lights the engine applies to an object at p (game space): all directional, nearest points, max 6. */ + public static List<Light> lightsFor(Scene s, double px, double py, double pz) { + List<Light> out = new ArrayList<>(); + List<Light> points = new ArrayList<>(); + for (Light l : s.lights()) { + if (l.type() == Light.DIRECTIONAL) { + if (out.size() < 6) { + out.add(l); + } + } else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) { + points.add(l); + } + } + points.sort(Comparator.comparingDouble(l -> sq(l.x() - px) + sq(l.y() - py) + sq(l.z() - pz))); + for (Light l : points) { + if (out.size() >= 6) { + break; + } + out.add(l); + } + return out; + } + + /** + * Light at a vertex (FUN_0041ba10): ambient + Σ colour·max(0, n·L), point lights with full + * strength up to radius a and a linear falloff to b. Game space; result 1.0 = texture colour. + */ + public static double[] shade(double[] ambient, List<Light> lights, double[] p, double[] n) { + double[] c = ambient.clone(); + for (Light l : lights) { + double f; + if (l.type() == Light.DIRECTIONAL) { + double[] d = direction(l); + f = d[0] * n[0] + d[1] * n[1] + d[2] * n[2]; + } else { + double dx = l.x() - p[0], dy = l.y() - p[1], dz = l.z() - p[2]; + double d2 = dx * dx + dy * dy + dz * dz; + if (d2 > l.rangeB() * l.rangeB() || d2 < 1e-12) { + continue; + } + double d = Math.sqrt(d2); + double cos = (dx * n[0] + dy * n[1] + dz * n[2]) / d; + if (cos < 0) { + continue; + } + f = cos; + if (d2 > l.a() * l.a() && l.rangeB() > l.a()) { + f *= (l.rangeB() - d) / (l.rangeB() - l.a()); + } + } + if (f <= 0) { + continue; + } + double[] lc = lightFactor(l); + for (int k = 0; k < 3; k++) { + c[k] += f * lc[k]; + } + } + return c; + } + + /** Ambient of a zone for objects (FUN_0041bcf0: byte / 255). */ + public static double[] ambient(EnvZone z) { + int a = z == null ? 0x404040 : z.field(F_AMBIENT); + return new double[] {(a & 0xff) / 255.0, ((a >> 8) & 0xff) / 255.0, ((a >> 16) & 0xff) / 255.0}; + } + + // ------------------------------------------------------------ misc + + /** Cells → 1/4096 fixed point (zone fields). */ + static int fixed(double cells) { + return (int) Math.round(cells * 4096); + } + + /** Unit vector towards a light at azimuth/elevation (degrees; 0 = +z, 90 = +x). */ + static double[] directionFrom(double azDeg, double elDeg) { + double az = Math.toRadians(azDeg), el = Math.toRadians(elDeg); + return new double[] {Math.cos(el) * Math.sin(az), Math.sin(el), Math.cos(el) * Math.cos(az)}; + } + + static double sq(double v) { + return v * v; + } + + /** Colour of a visibility region (as in the shell's region layer). */ + public static Color regionColor(int r) { + return new Color(Legend.regionRgb(r)); + } + + /** Tile colour word → RGB (0x80 = 1.0 kept as stored, i.e. half brightness). */ + public static int[] unpack(int w) { + return new int[] {w & 0xff, (w >> 8) & 0xff, (w >> 16) & 0xff}; + } + + /** RGB 0..255 (0x80 = 1.0) into a colour word, keeping byte 3 (frame count) of {@code keep}. */ + public static int pack(int r, int g, int b, int keep) { + return clamp(r) | clamp(g) << 8 | clamp(b) << 16 | (keep & 0xff000000); + } + + static int clamp(int v) { + return Math.max(0, Math.min(255, v)); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvOverlays.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvOverlays.java new file mode 100644 index 0000000..6be82e8 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvOverlays.java @@ -0,0 +1,218 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.render.Marker; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.RenderContributor; +import java.awt.Color; + +/** Viewport helpers: zone targets with facing arrows, light direction arrows and radius spheres. */ +final class EnvOverlays { + private EnvOverlays() {} + + static final String LAYER_ZONES = "envZoneHandles"; + static final String LAYER_LIGHTS = "envLightGizmos"; + + static int rgba(Color c, int a) { + return c.getRed() << 24 | c.getGreen() << 16 | c.getBlue() << 8 | (a & 0xff); + } + + static final Color ZONE = new Color(0xE0, 0x5A, 0xA0); + static final Color TARGET = new Color(0x30, 0xD0, 0xFF); + + /** Circle in the plane spanned by unit vectors u, v around c (export space). */ + static void circle(Overlay o, double[] c, double r, double[] u, double[] v, int rgba) { + int n = 40; + double[] prev = null; + for (int i = 0; i <= n; i++) { + double a = 2 * Math.PI * i / n; + double[] p = { + c[0] + r * (Math.cos(a) * u[0] + Math.sin(a) * v[0]), + c[1] + r * (Math.cos(a) * u[1] + Math.sin(a) * v[1]), + c[2] + r * (Math.cos(a) * u[2] + Math.sin(a) * v[2]) + }; + if (prev != null) { + o.line(prev, p, rgba); + } + prev = p; + } + } + + /** Horizontal circle (export space). */ + static void ring(Overlay o, double[] c, double r, int rgba) { + circle(o, c, r, new double[] {1, 0, 0}, new double[] {0, 0, 1}, rgba); + } + + static void sphere(Overlay o, double[] c, double r, int rgba) { + ring(o, c, r, rgba); + circle(o, c, r, new double[] {1, 0, 0}, new double[] {0, 1, 0}, rgba); + circle(o, c, r, new double[] {0, 0, 1}, new double[] {0, 1, 0}, rgba); + } + + /** Arrow from a to b with a head at b (export space). */ + static void arrow(Overlay o, double[] a, double[] b, double head, int rgba) { + o.line(a, b, rgba); + double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2]; + double len = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (len < 1e-6) { + return; + } + dx /= len; + dy /= len; + dz /= len; + double[] side = Math.abs(dy) > 0.9 ? new double[] {1, 0, 0} : new double[] {-dz, 0, dx}; + double sl = Math.sqrt(side[0] * side[0] + side[1] * side[1] + side[2] * side[2]); + for (int s = -1; s <= 1; s += 2) { + double[] h = { + b[0] - dx * head + s * side[0] / sl * head * 0.5, + b[1] - dy * head + s * side[1] / sl * head * 0.5, + b[2] - dz * head + s * side[2] / sl * head * 0.5 + }; + o.line(b, h, rgba); + } + } + + /** Anchor of directional light arrows: above the level centre (the shell's light markers). */ + static double[] anchor(Scene s) { + return new double[] {s.settings().width() / 2.0, 8, -s.settings().height() / 2.0}; + } + + static final RenderContributor ZONES = new RenderContributor() { + @Override + public String layer() { + return LAYER_ZONES; + } + + @Override + public boolean affectedBy(SceneEvent e) { + return e.node() instanceof EnvZone || e.node() instanceof Tile && e.type() != SceneEvent.Type.CHANGED; + } + + @Override + public boolean followsSelection() { + return true; + } + + @Override + public void build(EditorContext ctx, Builder b) { + Scene s = ctx.scene(); + Overlay o = b.overlay(); + for (EnvZone z : s.zones()) { + if (EnvMath.isStart(z) || ctx.isHidden(z)) { + continue; + } + boolean sel = ctx.selection().contains(z); + int a = sel ? 255 : 120; + double cx = z.cellX(), cy = z.cellY(), cz = -z.cellZ(); + if (sel) { + int fill = rgba(ZONE, 70); + double y0 = cy + 0.02; + o.triangle( + new double[] {cx, y0, cz}, + new double[] {cx + 1, y0, cz}, + new double[] {cx + 1, y0, cz - 1}, + fill); + o.triangle( + new double[] {cx, y0, cz}, + new double[] {cx + 1, y0, cz - 1}, + new double[] {cx, y0, cz - 1}, + fill); + o.box(cx, cy, cz - 1, cx + 1, cy + 1, cz, rgba(ZONE, 255)); + } + if (!EnvMath.teleports(z)) { + continue; + } + double tx = z.targetX(), ty = z.targetY(), tz = -z.targetZ(); + double f = Math.toRadians(EnvMath.facingDegrees(z)); + double[] t0 = {tx, ty + 0.1, tz}; + double[] t1 = {tx + Math.sin(f) * 0.8, ty + 0.1, tz - Math.cos(f) * 0.8}; + arrow(o, t0, t1, 0.25, rgba(TARGET, a)); + ring(o, new double[] {tx, ty + 0.02, tz}, 0.3, rgba(TARGET, a)); + if (sel) { + double[] from = {cx + 0.5, cy + 0.5, cz - 0.5}; + double[] mid = {(from[0] + tx) / 2, Math.max(from[1], ty) + 1.5, (from[2] + tz) / 2}; + double[] prev = from; + for (int i = 1; i <= 16; i++) { + double u = i / 16.0; + double[] p = { + (1 - u) * (1 - u) * from[0] + 2 * u * (1 - u) * mid[0] + u * u * tx, + (1 - u) * (1 - u) * from[1] + 2 * u * (1 - u) * mid[1] + u * u * (ty + 0.1), + (1 - u) * (1 - u) * from[2] + 2 * u * (1 - u) * mid[2] + u * u * tz + }; + o.line(prev, p, rgba(TARGET, 230)); + prev = p; + } + } + Marker m = b.marker(z, (float) tx, (float) ty + 0.1f, (float) tz, rgba(TARGET, 255)); + m.shape = Marker.Shape.DIAMOND; + m.size = 0.09f; + m.label = "target of " + z.label(); + } + } + }; + + static final RenderContributor LIGHTS = new RenderContributor() { + @Override + public String layer() { + return LAYER_LIGHTS; + } + + @Override + public boolean affectedBy(SceneEvent e) { + return e.node() instanceof Light; + } + + @Override + public boolean followsSelection() { + return true; + } + + @Override + public void build(EditorContext ctx, Builder b) { + Scene s = ctx.scene(); + Overlay o = b.overlay(); + double[] an = anchor(s); + for (Light l : s.lights()) { + if (ctx.isHidden(l)) { + continue; + } + boolean sel = ctx.selection().contains(l); + Color c = EnvMath.lightColor(l); + if (c.getRed() + c.getGreen() + c.getBlue() < 90) { + c = new Color(200, 200, 200); + } + if (l.type() == Light.DIRECTIONAL) { + double[] d = EnvMath.direction(l); + double len = sel ? 4 : 2.5; + double[] from = {an[0] + d[0] * len, an[1] + d[1] * len, an[2] - d[2] * len}; + arrow(o, from, an, sel ? 0.5 : 0.3, rgba(c, sel ? 255 : 170)); + if (sel) { + arrow( + o, + new double[] {from[0] + 0.3, from[1], from[2]}, + new double[] {an[0] + 0.3, an[1], an[2]}, + 0.4, + rgba(c, 200)); + } + } else if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) { + double[] p = {l.x(), l.y(), -l.z()}; + if (sel) { + sphere(o, p, l.a(), rgba(c, 230)); + sphere(o, p, l.rangeB(), rgba(c, 110)); + o.line(p, new double[] {p[0], p[1] - 3, p[2]}, rgba(c, 120)); + } else { + ring(o, p, l.rangeB(), rgba(c, 60)); + } + if (l.type() == Light.POINT_BRIGHT) { + circle(o, p, 0.25, new double[] {1, 0, 0}, new double[] {0, 1, 0}, rgba(Color.BLACK, 200)); + } + } + } + } + }; +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvSession.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvSession.java new file mode 100644 index 0000000..1f30baf --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvSession.java @@ -0,0 +1,411 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Regions; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.render.Camera; +import com.eliasmueller.llamamod.editor.render.RenderInstance; +import com.eliasmueller.llamamod.editor.render.RenderScene; +import com.eliasmueller.llamamod.editor.render.Viewport; +import com.eliasmueller.llamamod.editor.render.ViewportListener; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.swing.SwingUtilities; +import javax.swing.Timer; + +/** + * Per-window state of the environment tools: the live fog preview (fog of the zone the camera is + * in), the region preview (only what the game draws for a camera in a region), the object lighting + * preview and the viewport shading mode. Panels listen with {@link #addListener}. + */ +public final class EnvSession { + private static final Map<EditorContext, EnvSession> SESSIONS = new WeakHashMap<>(); + + public enum FogSource { + AUTO("Camera (nearest zone area)"), + START("Start zone"), + SELECTED("Selected zone only"); + + final String label; + + FogSource(String l) { + label = l; + } + + @Override + public String toString() { + return label; + } + } + + final EditorContext ctx; + private final List<Runnable> listeners = new CopyOnWriteArrayList<>(); + + boolean fogPreview; + FogSource fogSource = FogSource.AUTO; + double detail = EnvMath.DETAIL_MAX; + EnvZone previewZone; + EnvZone pinnedZone; + double[] fogRange = {0, 0}; + /** Camera too far from its pivot: the fog preview is paused (overview). */ + boolean fogOverview; + /** Fog of the previewed zone; null = no preview. */ + private Viewport.FogOverride fogBase; + + /** -1 = off, -2 = follow the camera, else the region. */ + int regionPreview = -1; + + int regionPreviewActive = -1; + private final Set<Node> regionHidden = new HashSet<>(); + + boolean objectLighting; + private final Map<RenderInstance, int[]> litInstances = new WeakHashMap<>(); + private boolean lightingQueued; + private final Timer cameraTimer; + + private EnvSession(EditorContext ctx) { + this.ctx = ctx; + fogPreview = ctx.prefs().getBool("env.fogPreview", true); + detail = ctx.prefs().getDouble("env.fogDetail", EnvMath.DETAIL_MAX); + cameraTimer = new Timer(120, e -> cameraMoved()); + cameraTimer.setRepeats(false); + ctx.viewport().addViewportListener(new ViewportListener() { + @Override + public void cameraChanged(Camera c) { + if (fogPreview && applyFog()) { + fire(); + } + cameraTimer.restart(); + } + }); + ctx.addDocumentListener(d -> { + pinnedZone = null; + regionHidden.clear(); + regionPreview = -1; + regionPreviewActive = -1; + litInstances.clear(); + SwingUtilities.invokeLater(() -> { + updateFog(); + queueLighting(); + fire(); + }); + }); + ctx.addSceneListener(this::sceneChanged); + ctx.selection().addListener(ch -> { + if (ctx.selection().primary() instanceof EnvZone z) { + pinnedZone = z; + updateFog(); + } + }); + } + + public static EnvSession of(EditorContext ctx) { + synchronized (SESSIONS) { + return SESSIONS.computeIfAbsent(ctx, EnvSession::new); + } + } + + public void addListener(Runnable r) { + listeners.add(r); + } + + void fire() { + for (Runnable r : listeners) { + r.run(); + } + } + + private void sceneChanged(SceneEvent e) { + Object n = e.node(); + if (n instanceof EnvZone || e.type() == SceneEvent.Type.RELOADED) { + if (pinnedZone != null && ctx.scene() != null && !ctx.scene().contains(pinnedZone)) { + pinnedZone = null; + } + SwingUtilities.invokeLater(this::updateFog); + } + if (n instanceof Regions && regionPreviewActive >= 0) { + int r = regionPreviewActive; + regionPreviewActive = -1; + SwingUtilities.invokeLater(() -> applyRegion(r)); + } + if (objectLighting && (n instanceof Light || n instanceof Strat || n instanceof EnvZone)) { + queueLighting(); + } + SwingUtilities.invokeLater(this::fire); + } + + private void cameraMoved() { + if (fogPreview) { + updateFog(); + } + if (regionPreview == -2) { + applyRegion(cameraRegion()); + } + } + + // ------------------------------------------------------------ fog preview + + public void setFogPreview(boolean on) { + fogPreview = on; + ctx.prefs().put("env.fogPreview", on); + updateFog(); + fire(); + } + + public void setFogSource(FogSource s) { + fogSource = s; + updateFog(); + fire(); + } + + public void setDetail(double t) { + detail = t; + ctx.prefs().put("env.fogDetail", t); + updateFog(); + fire(); + } + + /** + * Where the "player" is for the previews: the orbit pivot of the camera (the game camera sits a + * few cells behind the llama), game space cells. + */ + double[] cameraGame() { + return ToolEvent.toGame(ctx.viewport().camera().pivot()); + } + + /** Zone whose environment the preview shows. */ + EnvZone zoneForPreview() { + Scene s = ctx.scene(); + if (s == null || s.zones().isEmpty()) { + return null; + } + EnvZone start = EnvMath.startZone(s); + if (fogSource == FogSource.START) { + return start; + } + if (pinnedZone != null && s.contains(pinnedZone) && ctx.selection().contains(pinnedZone)) { + return pinnedZone; + } + if (fogSource == FogSource.SELECTED) { + return pinnedZone != null && s.contains(pinnedZone) ? pinnedZone : start; + } + return nearestArea(s, cameraGame()); + } + + /** + * The zone whose area contains p: the game switches the environment when the player teleports + * through a zone, so the area of a zone is approximated by its teleport target (the start zone's + * by the player start); the nearest one (xz) wins. + */ + static EnvZone nearestArea(Scene s, double[] p) { + EnvZone best = EnvMath.startZone(s); + Strat player = s.playerStart(); + double bd = player == null ? Double.MAX_VALUE : EnvMath.sq(player.gx() - p[0]) + EnvMath.sq(player.gz() - p[2]); + for (EnvZone z : s.zones()) { + if (!EnvMath.teleports(z)) { + continue; + } + double d = EnvMath.sq(z.targetX() - p[0]) + EnvMath.sq(z.targetZ() - p[2]); + if (d < bd) { + bd = d; + best = z; + } + } + return best; + } + + void updateFog() { + Viewport vp = ctx.viewport(); + EnvZone z = fogPreview && ctx.scene() != null ? zoneForPreview() : null; + previewZone = z; + if (z == null) { + fogBase = null; + vp.setFogOverride(null); + fogRange = new double[] {0, 0}; + fire(); + return; + } + double[] f = EnvMath.fog(z, detail); + fogRange = f; + int mode = z.fogMode(); + int rgb = mode == 1 ? 0x000000 : EnvMath.bgrToRgb(z.fogColor()); + float amount = mode == 0 ? 0f : 1f; + float end = (float) Math.max(f[1], 0.5); + fogBase = new Viewport.FogOverride((float) Math.min(f[0], end - 0.01), end, rgb, amount, true, true); + applyFog(); + fire(); + } + + /** + * Fog override for the current camera distance: beyond max(10, 1.6 × fog end) from the pivot + * the fog fades out over another half of that distance (radius grows, amount drops) instead of + * switching off at once; the overview (no override) starts when the fade is complete and ends + * again at 90 % of it (hysteresis). Returns whether the overview state changed. + */ + private boolean applyFog() { + if (fogBase == null) { + return false; + } + Viewport vp = ctx.viewport(); + Camera cam = vp.camera(); + double t = Math.max(10, 1.6 * fogBase.end()); + double u = cam.ortho ? 0 : Math.clamp((cam.position.distance(cam.pivot()) - t) / (0.5 * t), 0, 1); + boolean was = fogOverview; + fogOverview = was ? u > 0.9 : u >= 1; + if (fogOverview) { + vp.setFogOverride(null); + } else { + float grow = (float) (1 + 30 * u * u); + vp.setFogOverride(new Viewport.FogOverride( + fogBase.start() * grow, + fogBase.end() * grow, + fogBase.rgb(), + (float) (fogBase.amount() * (1 - u)), + true, + true)); + } + return was != fogOverview; + } + + // ------------------------------------------------------------ region preview + + int cameraRegion() { + Scene s = ctx.scene(); + if (s == null) { + return -1; + } + double[] p = cameraGame(); + return s.regions() + .regionAt( + (int) Math.floor(p[0]), + (int) Math.floor(p[2]), + s.settings().width()); + } + + /** -1 off, -2 follow camera, 0..31 fixed region. */ + public void setRegionPreview(int r) { + regionPreview = r; + applyRegion(r == -2 ? cameraRegion() : r); + fire(); + } + + void applyRegion(int region) { + Scene s = ctx.scene(); + if (s == null || region == regionPreviewActive) { + return; + } + regionPreviewActive = region; + Set<Node> hide = new HashSet<>(); + if (region >= 0 && regionPreview != -1) { + int w = s.settings().width(), h = s.settings().height(); + int mask = s.regions().mask(region); + if (mask != 0) { + for (Tile t : s.tiles()) { + if (!visible(s.regions(), mask, t.cellX(), t.cellZ(), w, h)) { + hide.add(t); + } + } + for (Strat st : s.strats()) { + int cx = (int) Math.floor(st.gx()), cz = (int) Math.floor(st.gz()); + if (!visible(s.regions(), mask, cx, cz, w, h)) { + hide.add(st); + } + } + } + } + List<Node> show = new ArrayList<>(); + for (Node n : regionHidden) { + if (!hide.contains(n) && s.contains(n)) { + show.add(n); + } + } + List<Node> add = new ArrayList<>(); + for (Node n : hide) { + if (!regionHidden.contains(n) && !ctx.isHidden(n)) { + add.add(n); + } + } + if (!show.isEmpty()) { + ctx.setHidden(show, false); + } + if (!add.isEmpty()) { + ctx.setHidden(add, true); + } + regionHidden.removeAll(show); + regionHidden.addAll(add); + if (regionPreview == -1) { + regionHidden.clear(); + } + } + + /** Engine rule (FUN_0042bf40 / FUN_0041e020): cells outside the grid are drawn always. */ + static boolean visible(Regions r, int mask, int cx, int cz, int w, int h) { + if (cx < 0 || cz < 0 || cx >= w || cz >= h) { + return true; + } + int reg = r.regionAt(cx, cz, w); + return reg < 0 || (mask & (1 << (reg & 31))) != 0; + } + + // ------------------------------------------------------------ object lighting preview + + public void setObjectLighting(boolean on) { + objectLighting = on; + if (!on) { + RenderScene rs = ctx.viewport().scene(); + for (Map.Entry<RenderInstance, int[]> e : litInstances.entrySet()) { + if (rs != null && rs.instances.contains(e.getKey())) { + e.getKey().vertexColors = e.getValue(); + rs.changed(e.getKey()); + } + } + litInstances.clear(); + ctx.viewport().requestRender(); + } else { + queueLighting(); + } + fire(); + } + + void queueLighting() { + if (!objectLighting || lightingQueued) { + return; + } + lightingQueued = true; + SwingUtilities.invokeLater(() -> SwingUtilities.invokeLater(() -> { + lightingQueued = false; + applyObjectLighting(); + })); + } + + /** Colours every object model with the engine's per-vertex lighting (zone ambient + ≤ 6 lights). */ + void applyObjectLighting() { + Scene s = ctx.scene(); + RenderScene rs = ctx.viewport().scene(); + if (!objectLighting || s == null || rs == null) { + return; + } + double[] amb = EnvMath.ambient(previewZone != null ? previewZone : EnvMath.startZone(s)); + for (RenderInstance ri : List.copyOf(rs.instances)) { + if (ri.kind != RenderInstance.Kind.OBJECT || !(ctx.nodeOf(ri.id) instanceof Strat st)) { + continue; + } + litInstances.putIfAbsent(ri, ri.vertexColors); + ri.vertexColors = ObjectLighting.colors(s, st, ri, amb); + rs.changed(ri); + } + ctx.viewport().requestRender(); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvironmentEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvironmentEditorPlugin.java new file mode 100644 index 0000000..b7a1de4 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvironmentEditorPlugin.java @@ -0,0 +1,96 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.render.Viewport; +import com.eliasmueller.llamamod.editor.ui.ActionSpec; +import com.eliasmueller.llamamod.editor.ui.Dock; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.EditorExtensions; +import com.eliasmueller.llamamod.editor.ui.EditorPlugin; +import com.eliasmueller.llamamod.editor.ui.LayerSpec; +import com.eliasmueller.llamamod.editor.ui.PanelSpec; +import java.util.function.Predicate; + +/** + * Environment, lighting and visibility tools: zone / teleport editing with a live fog preview, + * lights with gizmos and an object lighting preview, the vertex lighting bake and colour brush, + * visibility regions (paint, mask matrix, preview, auto-generate), level settings and checks. + */ +public final class EnvironmentEditorPlugin implements EditorPlugin { + private static final Predicate<EditorContext> DOC = c -> c.document() != null; + + @Override + public void install(EditorExtensions x) { + Ui.init(); + x.panel(new PanelSpec("environment", "Environment", "sun", Dock.LEFT, 30, true, EnvironmentPanel::new)); + x.panel(new PanelSpec("visibility", "Visibility", "region", Dock.RIGHT, 30, true, VisibilityPanel::new)); + x.tool(new ZoneTool()); + x.tool(new LightTool()); + x.tool(new VertexPaintTool()); + x.tool(new RegionPaintTool()); + x.inspector(new ZoneSection()); + x.inspector(new LightSection()); + x.layer(new LayerSpec(EnvOverlays.LAYER_ZONES, "Zone targets & facing", "zone", true, 61)); + x.layer(new LayerSpec(EnvOverlays.LAYER_LIGHTS, "Light gizmos", "light", true, 71)); + x.renderContributor(EnvOverlays.ZONES); + x.renderContributor(EnvOverlays.LIGHTS); + x.check(new EnvChecks()); + x.onOpen(EnvSession::of); + + x.action(ActionSpec.of( + "level.settings", + "Level", + 10, + 10, + "Level settings…", + "settings", + null, + LevelSettingsDialog::show) + .enabledWhen(DOC) + .tip("Name, chapter, grid size, death plane, feature flags")); + x.action(ActionSpec.of("env.bake", "Level", 30, 10, "Bake lighting…", "sun", null, c -> { + c.showPanel("environment"); + c.status("Environment → Lighting: choose a preset and press Bake lighting"); + }) + .enabledWhen(DOC)); + x.action(ActionSpec.of( + "env.zones", + "Level", + 30, + 20, + "Environment zones", + "zone", + null, + c -> c.showPanel("environment")) + .enabledWhen(DOC)); + x.action(ActionSpec.of( + "env.visibility", + "Level", + 30, + 30, + "Visibility regions", + "region", + null, + c -> c.showPanel("visibility")) + .enabledWhen(DOC)); + x.action(ActionSpec.of("env.fogPreview", "View", 50, 10, "Fog preview", "fog", null, c -> { + EnvSession s = EnvSession.of(c); + s.setFogPreview(!s.fogPreview); + }) + .checked(c -> EnvSession.of(c).fogPreview) + .tip("Game fog of the zone the camera is in")); + x.action(ActionSpec.of( + "env.objectLighting", "View", 50, 20, "Light objects (LGHT preview)", "light", null, c -> { + EnvSession s = EnvSession.of(c); + s.setObjectLighting(!s.objectLighting); + }) + .checked(c -> EnvSession.of(c).objectLighting)); + x.action(ActionSpec.of( + "env.shadeVertex", "View", 50, 30, "Vertex colours only", "paint", "shift V", c -> c.viewport() + .setShading( + c.viewport().shading() == Viewport.SHADE_VERTEX_COLORS + ? Viewport.SHADE_NORMAL + : Viewport.SHADE_VERTEX_COLORS)) + .checked(c -> c.viewport().shading() == Viewport.SHADE_VERTEX_COLORS) + .tip("Show the baked/painted vertex colours without textures")); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvironmentPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvironmentPanel.java new file mode 100644 index 0000000..5dd2893 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/EnvironmentPanel.java @@ -0,0 +1,494 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.render.Viewport; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.Widgets; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JRadioButton; +import javax.swing.JScrollPane; +import javax.swing.JTabbedPane; +import javax.swing.ListSelectionModel; +import javax.swing.Timer; +import org.joml.Vector3f; + +/** Left dock panel: zones (with the live fog preview), lights and the vertex lighting bake. */ +final class EnvironmentPanel extends JPanel { + private final EditorContext ctx; + private final EnvSession env; + private final DefaultListModel<EnvZone> zoneModel = new DefaultListModel<>(); + private final JList<EnvZone> zoneList = new JList<>(zoneModel); + private final DefaultListModel<Light> lightModel = new DefaultListModel<>(); + private final JList<Light> lightList = new JList<>(lightModel); + private final JLabel fogInfo = new JLabel(" "); + private boolean syncing; + private final Timer rebuild; + + EnvironmentPanel(EditorContext ctx) { + super(new BorderLayout()); + this.ctx = ctx; + this.env = EnvSession.of(ctx); + JTabbedPane tabs = new JTabbedPane(JTabbedPane.TOP, JTabbedPane.SCROLL_TAB_LAYOUT); + tabs.addTab("Zones", Icons.get("zone", 14), zonesTab()); + tabs.addTab("Lights", Icons.get("light", 14), lightsTab()); + tabs.addTab("Lighting", Icons.get("sun", 14), new BakePanel(ctx)); + add(tabs); + rebuild = new Timer(60, e -> reload()); + rebuild.setRepeats(false); + ctx.addDocumentListener(d -> rebuild.restart()); + ctx.addSceneListener(this::sceneChanged); + ctx.selection().addListener(ch -> { + if (ch.source() != this) { + syncSelection(); + } + }); + env.addListener(this::updateFogInfo); + reload(); + } + + private void sceneChanged(SceneEvent e) { + boolean env = e.node() instanceof EnvZone || e.node() instanceof Light; + if (env || (e.type() != SceneEvent.Type.CHANGED && e.node() == null)) { + rebuild.restart(); + } + } + + private void reload() { + syncing = true; + try { + zoneModel.clear(); + lightModel.clear(); + Scene s = ctx.scene(); + if (s != null) { + EnvZone st = EnvMath.startZone(s); + if (st != null) { + zoneModel.addElement(st); + } + for (EnvZone z : s.zones()) { + if (z != st) { + zoneModel.addElement(z); + } + } + for (Light l : s.lights()) { + lightModel.addElement(l); + } + } + } finally { + syncing = false; + } + syncSelection(); + zoneList.repaint(); + lightList.repaint(); + updateFogInfo(); + } + + private void syncSelection() { + syncing = true; + try { + select(zoneList, zoneModel); + select(lightList, lightModel); + } finally { + syncing = false; + } + } + + private <T extends Node> void select(JList<T> list, DefaultListModel<T> model) { + List<Integer> idx = new ArrayList<>(); + for (int i = 0; i < model.size(); i++) { + if (ctx.selection().contains(model.get(i))) { + idx.add(i); + } + } + list.setSelectedIndices(idx.stream().mapToInt(Integer::intValue).toArray()); + if (!idx.isEmpty()) { + list.ensureIndexIsVisible(idx.getFirst()); + } + } + + /** List selection → editor selection; double-click frames it. */ + private <T extends Node> void bindList(JList<T> list) { + list.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + list.addListSelectionListener(e -> { + List<T> sel = list.getSelectedValuesList(); + if (!e.getValueIsAdjusting() && !syncing && !sel.isEmpty()) { + ctx.selection().set(sel, this); + } + }); + list.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + if (e.getClickCount() == 2) { + ctx.focusSelection(); + } + } + }); + } + + // ------------------------------------------------------------ zones + + private JComponent zonesTab() { + JPanel p = new JPanel(new BorderLayout()); + Ui.Form f = new Ui.Form(); + f.header("Fog preview"); + JCheckBox live = new JCheckBox("Live fog in the viewport", env.fogPreview); + live.addActionListener(a -> env.setFogPreview(live.isSelected())); + env.addListener(() -> live.setSelected(env.fogPreview)); + f.full(live); + JComboBox<EnvSession.FogSource> src = new JComboBox<>(EnvSession.FogSource.values()); + src.setSelectedItem(env.fogSource); + src.addActionListener(a -> env.setFogSource((EnvSession.FogSource) src.getSelectedItem())); + f.row("Zone", src, "Which zone's fog is shown; a selected zone is always previewed"); + String[] names = {"Minimum", "Medium", "Maximum"}; + double[] vals = {EnvMath.DETAIL_MIN, EnvMath.DETAIL_MED, EnvMath.DETAIL_MAX}; + JComboBox<String> det = new JComboBox<>(names); + det.setSelectedIndex(env.detail <= 0.25 ? 0 : env.detail < 0.75 ? 1 : 2); + det.addActionListener(a -> env.setDetail(vals[det.getSelectedIndex()])); + f.row("Detail", det, "The game's detail setting: fog distances are interpolated between the Min and Max pairs"); + f.full(fogInfo); + p.add(f, BorderLayout.NORTH); + + bindList(zoneList); + zoneList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean fo) { + EnvZone z = (EnvZone) v; + JLabel lab = (JLabel) super.getListCellRendererComponent(l, "", i, s, fo); + String music = EnvMath.MUSIC[Math.max(0, Math.min(EnvMath.MUSIC.length - 1, z.music()))]; + double far = EnvMath.fog(z, 1)[1]; + lab.setText(String.format( + Locale.ROOT, + "<html><b>%s</b><br><span style='font-size:90%%'>fog %.1f cells · %s</span></html>", + EnvMath.describe(z), + far, + music)); + lab.setIcon(Icons.swatch(EnvMath.zoneColor(z.fogColor()), 18, 18)); + lab.setIconTextGap(8); + lab.setBorder(BorderFactory.createEmptyBorder(3, 4, 3, 4)); + return lab; + } + }); + p.add(new JScrollPane(zoneList), BorderLayout.CENTER); + JButton tele = Widgets.button( + "Teleport", "plus", "Add a teleport zone: click a trigger cell, then the target (Zone tool)", () -> { + ctx.selection().clear(this); + ctx.setTool("env.zones"); + ctx.status("Click the cell that triggers the teleport (it needs an exit floor, material 16)"); + }); + JButton envz = Widgets.button( + "Environment", + "plus", + "Add a zone that only switches fog, colours and music (at the camera focus)", + this::addEnvZone); + JButton del = Widgets.button("", "delete", "Delete the selected zones", () -> ctx.run("edit.delete")); + JButton frame = Widgets.button("", "focus", "Frame the selection", ctx::focusSelection); + JPanel b = Ui.line(tele, envz, del, frame); + b.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + p.add(b, BorderLayout.SOUTH); + return p; + } + + private void addEnvZone() { + Scene s = ctx.scene(); + if (s == null) { + return; + } + Vector3f c = ctx.viewport().camera().pivot(); + EnvZone z = ZoneTool.create(s, (int) Math.floor(c.x), (int) Math.round(c.y), (int) Math.floor(-c.z)); + z.setRaw("flags", EnvMath.FLAG_NO_TELEPORT); + ctx.execute(new AddNode(z)); + ctx.selection().set(z, this); + } + + private void updateFogInfo() { + EnvZone z = env.previewZone; + if (!env.fogPreview) { + fogInfo.setText("<html><i>Fog preview off</i></html>"); + } else if (z == null) { + fogInfo.setText("<html><i>No zone</i></html>"); + } else { + fogInfo.setText(String.format( + Locale.ROOT, + "<html>%s <b>%s</b><br>fog %.1f → %.1f cells, %s</html>", + env.fogOverview ? "Zoom in to see the fog of" : "Showing", + EnvMath.describe(z), + env.fogRange[0], + env.fogRange[1], + EnvMath.FOG_MODES[Math.max(0, Math.min(3, z.fogMode()))].substring(4))); + } + } + + // ------------------------------------------------------------ lights + + private JComponent lightsTab() { + JPanel p = new JPanel(new BorderLayout()); + Ui.Form f = new Ui.Form(); + f.header("Object lighting"); + JCheckBox prev = new JCheckBox("Preview lights on objects", env.objectLighting); + prev.addActionListener(a -> env.setObjectLighting(prev.isSelected())); + env.addListener(() -> prev.setSelected(env.objectLighting)); + f.full(prev); + f.note("The engine lights object models per vertex: zone ambient colour + all directional lights +" + + " the nearest point lights (6 at most). Tiles keep their baked vertex colours."); + p.add(f, BorderLayout.NORTH); + bindList(lightList); + lightList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean fo) { + Light li = (Light) v; + JLabel lab = (JLabel) super.getListCellRendererComponent(l, "", i, s, fo); + String what = li.type() == Light.DIRECTIONAL + ? String.format( + Locale.ROOT, + "Directional · az %.0f°, el %.0f°", + LightSection.azimuth(li), + LightSection.elevation(li)) + : String.format( + Locale.ROOT, + "%s · %.1f, %.1f, %.1f · r %.1f", + li.type() == Light.POINT_BRIGHT ? "Negative" : "Point", + li.x(), + li.y(), + li.z(), + li.rangeB()); + lab.setText(what); + lab.setIcon(Icons.swatch(EnvMath.lightColor(li), 16, 16)); + lab.setIconTextGap(8); + return lab; + } + }); + p.add(new JScrollPane(lightList), BorderLayout.CENTER); + JButton point = Widgets.button( + "Point", "plus", "Place point lights in the viewport (Lights tool)", () -> ctx.setTool("env.lights")); + JButton dir = Widgets.button("Directional", "plus", "Add a directional light (sun)", () -> { + if (ctx.scene() != null) { + Light l = LightTool.directional(ctx.scene(), 215, 50, 110, 100, 80); + ctx.execute(new AddNode(l)); + ctx.selection().set(l, this); + } + }); + JButton del = Widgets.button("", "delete", "Delete the selected lights", () -> ctx.run("edit.delete")); + JPanel b = Ui.line(point, dir, del); + b.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + p.add(b, BorderLayout.SOUTH); + return p; + } + + // ------------------------------------------------------------ lighting bake + + /** Bake settings, presets and the vertex colour display mode. */ + static final class BakePanel extends JPanel { + private final EditorContext ctx; + double az = 215, el = 50, strength = 0.62, ao = 0.55, aoRadius = 1.3, shadow = 0.35; + Color sun = new Color(255, 237, 204), sky = new Color(128, 140, 168), ground = new Color(77, 71, 61); + int fogBgr = -1, ambBgr = -1; + private final List<Runnable> refresh = new ArrayList<>(); + private final JRadioButton all = new JRadioButton("All tiles", true); + private final JRadioButton sel = new JRadioButton("Selected tiles"); + private final JCheckBox points = new JCheckBox("Add point lights (LGHT) to tiles", false); + private final JCheckBox brushes = new JCheckBox("Also light brushes (level setting)", true); + private final JCheckBox zone = new JCheckBox("Set start zone fog & ambient colour", false); + private final JLabel info = new JLabel(" "); + + BakePanel(EditorContext ctx) { + super(new BorderLayout()); + this.ctx = ctx; + Ui.Form f = new Ui.Form(); + f.header("Preset"); + JComboBox<BakePresets.Preset> pre = new JComboBox<>(BakePresets.ALL.toArray(new BakePresets.Preset[0])); + pre.setSelectedIndex(-1); + pre.addActionListener(a -> { + if (pre.getSelectedItem() instanceof BakePresets.Preset p) { + load(p.rig()); + fogBgr = p.fogBgr(); + ambBgr = p.ambientBgr(); + points.setSelected(p.rig().pointLights()); + zone.setSelected(true); + } + }); + f.row("Preset", pre, "Fills in the values below (nothing changes until you bake)"); + f.full(Ui.line(Widgets.button( + "Level's current", null, "Load the level's lighting settings (used for brushes)", () -> { + if (ctx.scene() != null) { + load(BakePresets.fromSettings(ctx.scene().settings(), points.isSelected())); + } + }))); + f.header("Sun"); + f.row("Azimuth (°)", num(0, 360, 5, () -> az, v -> az = v), "0 = from +z (south), 90 = from +x (east)"); + f.row("Elevation (°)", num(-10, 90, 5, () -> el, v -> el = v)); + f.row("Strength", num(0, 2, 0.05, () -> strength, v -> strength = v)); + f.row("Sun colour", color("Sun colour", () -> sun, c -> sun = c)); + f.header("Sky"); + f.row("Sky colour", color("Sky light", () -> sky, c -> sky = c), "Light from above (hemisphere)"); + f.row("Ground colour", color("Ground light", () -> ground, c -> ground = c), "Light from below"); + f.row("Ambient occlusion", num(0, 1, 0.05, () -> ao, v -> ao = v), "Darkening of corners and crevices"); + f.row("AO radius", num(0.2, 6, 0.1, () -> aoRadius, v -> aoRadius = v), "cells"); + f.row("Shadow light", num(0, 1, 0.05, () -> shadow, v -> shadow = v), "0 = black shadows, 1 = no shadows"); + f.header("Bake"); + ButtonGroup g = new ButtonGroup(); + g.add(all); + g.add(sel); + f.full(Ui.line(all, sel)); + f.full(points); + f.full(brushes); + f.full(zone); + JButton bake = Widgets.button("Bake lighting", "sun", "Compute vertex colours (one undo step)", this::bake); + bake.putClientProperty("JButton.buttonType", "default"); + f.full(Ui.line(bake)); + f.full(info); + f.header("Display"); + JRadioButton tex = new JRadioButton("Textured", ctx.viewport().shading() == Viewport.SHADE_NORMAL); + JRadioButton vc = + new JRadioButton("Vertex colours only", ctx.viewport().shading() == Viewport.SHADE_VERTEX_COLORS); + ButtonGroup dg = new ButtonGroup(); + dg.add(tex); + dg.add(vc); + tex.addActionListener(a -> ctx.viewport().setShading(Viewport.SHADE_NORMAL)); + vc.addActionListener(a -> ctx.viewport().setShading(Viewport.SHADE_VERTEX_COLORS)); + f.full(Ui.line(tex, vc)); + f.full(Ui.line(Widgets.button( + "Paint vertex colours", "paint", "Vertex colour brush (V)", () -> ctx.setTool("env.vpaint")))); + f.end(); + JScrollPane sp = new JScrollPane(f); + sp.setBorder(null); + sp.getVerticalScrollBar().setUnitIncrement(16); + add(sp); + setPreferredSize(new Dimension(260, 400)); + } + + private JComponent num( + double min, + double max, + double step, + java.util.function.DoubleSupplier get, + java.util.function.DoubleConsumer set) { + javax.swing.JSpinner s = Ui.number(get.getAsDouble(), min, max, step, "0.00", set); + refresh.add(() -> s.setValue(Ui.clamp(get.getAsDouble(), min, max))); + return s; + } + + private JComponent color( + String title, java.util.function.Supplier<Color> get, java.util.function.Consumer<Color> set) { + Ui.ColorButton b = new Ui.ColorButton(title, get, (c, k) -> set.accept(c)); + refresh.add(b::refresh); + return b; + } + + void load(VertexBake.Rig r) { + az = r.azimuth(); + el = r.elevation(); + strength = r.strength(); + sun = col(r.sun()); + sky = col(r.sky()); + ground = col(r.ground()); + ao = r.ao(); + aoRadius = r.aoRadius(); + shadow = r.shadow(); + refresh.forEach(Runnable::run); + } + + static Color col(double[] c) { + return new Color(unit(c[0]), unit(c[1]), unit(c[2])); + } + + private static int unit(double v) { + return (int) Math.round(Ui.clamp(v, 0, 1) * 255); + } + + static double[] vec(Color c) { + return new double[] {c.getRed() / 255.0, c.getGreen() / 255.0, c.getBlue() / 255.0}; + } + + VertexBake.Rig rig() { + return new VertexBake.Rig( + az, el, strength, vec(sun), vec(sky), vec(ground), ao, aoRadius, shadow, points.isSelected()); + } + + private void bake() { + Scene s = ctx.scene(); + if (s == null) { + return; + } + List<Tile> tiles = new ArrayList<>(); + if (sel.isSelected()) { + for (Node n : ctx.selection().nodes()) { + if (n instanceof Tile t) { + tiles.add(t); + } + } + } else { + tiles.addAll(s.tiles()); + } + VertexBake.Rig rig = rig(); + boolean doBrushes = brushes.isSelected(), doZone = zone.isSelected(); + int fog = fogBgr, amb = ambBgr; + long t0 = System.nanoTime(); + info.setText("Baking " + tiles.size() + " tiles…"); + ctx.runTask( + "Baking lighting", + p -> { + VertexBake vb = new VertexBake(s); + return vb.bake(s, tiles, rig, f -> p.update(f, "Baking lighting")); + }, + res -> { + @SuppressWarnings("unchecked") + Map<Tile, int[]> out = (Map<Tile, int[]>) res; + List<Command> cmds = new ArrayList<>(); + if (!out.isEmpty()) { + cmds.add(new PropMapCommand("Bake lighting", "colors", out, null)); + } + if (doBrushes && !s.brushes().isEmpty()) { + LevelSettings st = s.settings(); + cmds.add(SetProp.of(st, "sunAzimuth", rig.azimuth())); + cmds.add(SetProp.of(st, "sunElevation", rig.elevation())); + cmds.add(SetProp.of(st, "sunStrength", rig.strength())); + cmds.add(SetProp.of(st, "sunColor", rig.sun())); + cmds.add(SetProp.of(st, "sky", rig.sky())); + cmds.add(SetProp.of(st, "ground", rig.ground())); + cmds.add(SetProp.of(st, "ao", rig.ao())); + cmds.add(SetProp.of(st, "aoRadius", rig.aoRadius())); + cmds.add(SetProp.of(st, "shadow", rig.shadow())); + } + EnvZone z = EnvMath.startZone(s); + if (doZone && z != null && fog >= 0) { + cmds.add(SetProp.of(z, "fogColor", fog)); + cmds.add(SetProp.of(z, "ambientColor", amb)); + } + if (!cmds.isEmpty()) { + ctx.execute(new Batch("Bake lighting", cmds)); + } + info.setText(String.format( + Locale.ROOT, "Baked %d tiles in %.1f s", out.size(), (System.nanoTime() - t0) / 1e9)); + ctx.status(info.getText()); + }); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LevelSettingsDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LevelSettingsDialog.java new file mode 100644 index 0000000..2988e8f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LevelSettingsDialog.java @@ -0,0 +1,358 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.io.ProjectIO; +import com.eliasmueller.llamamod.editor.io.ProjectInfo; +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.LevelSettings; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Regions; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.mods.levels.LevelProjects; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.GridLayout; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.JTextArea; +import javax.swing.JTextField; + +/** Level → Settings: name, chapter, grid size, death plane, WFPC flags, object pool (one undo step). */ +final class LevelSettingsDialog { + private LevelSettingsDialog() {} + + /** WFPC bits: mask, label, changes the file layout (not editable). */ + record Flag(int mask, String label, boolean layout) {} + + static final List<Flag> FLAGS = List.of( + new Flag(0x10, "MAP has a path buffer size (block 14)", true), + new Flag(0x100, "STPC has an extra model block", true), + new Flag(0x800, "Fog flag of the renderer (FUN_00424b90, face flag 0x4000 handling)", false), + new Flag(0x10000, "Tile swaps (doors / bridges, stMapreplace)", true), + new Flag(0x80000, "Script flag (strat opcode 0x8e reads it)", false), + new Flag(0x100000, "TRPS chunk has extra entries", true), + new Flag(0x200000, "MAP has a path block", true), + new Flag(0x4000000, "Path entries with an extra word", true), + new Flag(0x8000000, "Collision materials 17/18 are ignored", false), + new Flag(0x10000000, "Two vertex colour pointer tables (light states)", true)); + + static void show(EditorContext ctx) { + Scene s = ctx.scene(); + if (s == null) { + return; + } + LevelSettings st = s.settings(); + JDialog d = new JDialog(ctx.frame(), "Level settings", true); + Ui.Form f = new Ui.Form(); + f.header("Level"); + JTextField name = new JTextField(st.levelName(), 24); + f.row("Name", name, "Shown in the level list / loading screen (NAME chunk)"); + JTextField author = new JTextField(st.author()); + f.row("Author", author); + JTextArea desc = new JTextArea(st.description(), 3, 24); + desc.setLineWrap(true); + desc.setWrapStyleWord(true); + f.row("Description", new JScrollPane(desc)); + f.row("Project", new JLabel(st.isNewLevel() ? "new level" : "changes to an original level")); + f.row( + "Base level", + new JLabel(st.base() + " (object code, textures, sounds)"), + "Fixed when the project is created (the project stores only the differences to it)"); + JSpinner chapter = Ui.number(Math.max(1, st.targetChapter()), 1, 49, 1, "0", v -> {}); + JLabel used = new JLabel(usedChapters(ctx, st.targetChapter())); + f.row("TestLevel chapter", chapter, "New levels replace this chapter of world 9 (TestLevel)"); + f.full(used); + chapter.setEnabled(st.isNewLevel()); + + f.header("Grid"); + int w0 = st.width(), h0 = st.height(); + int[] need = extent(s); + JSpinner gw = Ui.number(w0, Math.max(1, need[0]), 1024, 1, "0", v -> {}); + JSpinner gh = Ui.number(h0, Math.max(1, need[1]), 1024, 1, "0", v -> {}); + f.row("Width (cells, x)", gw, "Columns; growing adds cells in the east"); + f.row("Height (cells, z)", gh, "Rows; growing adds cells at the far end (higher z)"); + f.note(String.format( + Locale.ROOT, + "Current %d × %d. Content needs at least %d × %d. New cells get region 0.", + w0, + h0, + need[0], + need[1])); + + f.header("Death plane"); + List<Node> kill = killPlanes(s); + double ky = killHeight(s, kill); + JSpinner killY = Ui.number(Double.isNaN(ky) ? -1.5 : ky, -64, 256, 0.25, "0.00", v -> {}); + killY.setEnabled(!kill.isEmpty()); + f.row("Height", killY, "Falling below / touching it kills the player (collision material 30)"); + JCheckBox addKill = new JCheckBox("Add a death plane over the whole grid", false); + addKill.addActionListener(e -> killY.setEnabled(addKill.isSelected())); + if (kill.isEmpty()) { + f.full(addKill); + f.note("No death plane in this level."); + } else { + f.note(kill.size() + " death plane(s): " + summary(kill)); + } + + f.header("Feature flags (WFPC 0x" + Integer.toHexString(st.wfpc()) + ")"); + Map<Integer, JCheckBox> boxes = new LinkedHashMap<>(); + JPanel grid = new JPanel(new GridLayout(0, 1)); + int known = 0; + for (Flag fl : FLAGS) { + known |= fl.mask(); + JCheckBox cb = new JCheckBox( + String.format(Locale.ROOT, "0x%08X %s", fl.mask(), fl.label()), (st.wfpc() & fl.mask()) != 0); + cb.setEnabled(!fl.layout()); + if (fl.layout()) { + cb.setToolTipText("Changes the layout of the level data – fixed by the base level"); + } + boxes.put(fl.mask(), cb); + grid.add(cb); + } + for (int b = 0; b < 32; b++) { + int m = 1 << b; + if ((known & m) == 0 && (st.wfpc() & m) != 0) { + JCheckBox cb = new JCheckBox(String.format(Locale.ROOT, "0x%08X (unknown)", m), true); + cb.setToolTipText("Meaning unknown; keep it unless you know better"); + boxes.put(m, cb); + grid.add(cb); + } + } + f.full(grid); + + f.header("Objects"); + JSpinner pool = Ui.number(st.poolExtra(), 0, 4096, 1, "0", v -> {}); + f.row("Strat pool extra", pool, "Slots for objects = this + 30 (also for spawned objects)"); + f.end(); + + JButton ok = new JButton("OK"); + JButton cancel = new JButton("Cancel"); + ok.putClientProperty("JButton.buttonType", "default"); + d.getRootPane().setDefaultButton(ok); + cancel.addActionListener(a -> d.dispose()); + ok.addActionListener(a -> { + List<Command> c = new ArrayList<>(); + put(c, st, "name", name.getText().trim()); + put(c, st, "author", author.getText().trim()); + put(c, st, "description", desc.getText().trim()); + if (st.isNewLevel()) { + put(c, st, "targetChapter", ((Number) chapter.getValue()).intValue()); + } + int wfpc = st.wfpc(); + for (Map.Entry<Integer, JCheckBox> e : boxes.entrySet()) { + wfpc = e.getValue().isSelected() ? wfpc | e.getKey() : wfpc & ~e.getKey(); + } + put(c, st, "wfpc", wfpc); + put(c, st, "poolExtra", ((Number) pool.getValue()).intValue()); + int nw = ((Number) gw.getValue()).intValue(), nh = ((Number) gh.getValue()).intValue(); + if (nw != w0 || nh != h0) { + c.addAll(resize(s, nw, nh)); + } + try { + killY.commitEdit(); + } catch (java.text.ParseException ignored) { + // keep the last valid value + } + double y = ((Number) killY.getValue()).doubleValue(); + if (!kill.isEmpty() && Math.abs(y - ky) > 1e-9) { + c.addAll(moveKill(kill, y - ky)); + } + if (kill.isEmpty() && addKill.isSelected()) { + Brush b = (Brush) s.createBrush(BrushKind.KILL_PLANE); + b.setRaw("x1", (double) nw); + b.setRaw("z1", (double) nh); + b.setRaw("y", y); + c.add(new AddNode(b)); + } + if (!c.isEmpty()) { + ctx.execute(new Batch("Level settings", c)); + } + d.dispose(); + }); + JPanel buttons = Ui.line(ok, cancel); + buttons.setLayout(new java.awt.FlowLayout(java.awt.FlowLayout.RIGHT)); + JScrollPane sp = new JScrollPane(f); + sp.setBorder(BorderFactory.createEmptyBorder()); + sp.getVerticalScrollBar().setUnitIncrement(16); + JPanel root = new JPanel(new BorderLayout()); + root.add(sp); + root.add(buttons, BorderLayout.SOUTH); + d.setContentPane(root); + d.setSize(new Dimension(560, 720)); + d.setLocationRelativeTo(ctx.frame()); + d.setVisible(true); + } + + private static void put(List<Command> c, Node n, String prop, Object v) { + if (!java.util.Objects.equals(n.get(prop), v)) { + c.add(SetProp.of(n, prop, v)); + } + } + + /** Minimum grid size {w, h} for the tiles and objects of the level. */ + static int[] extent(Scene s) { + int w = 1, h = 1; + for (Tile t : s.tiles()) { + w = Math.max(w, t.cellX() + 1); + h = Math.max(h, t.cellZ() + 1); + } + for (Strat st : s.strats()) { + w = Math.max(w, (int) Math.floor(st.gx()) + 1); + h = Math.max(h, (int) Math.floor(st.gz()) + 1); + } + for (Brush b : s.brushes()) { + double[] bb = b.bounds(); + if (bb != null && b.brushKind() != BrushKind.KILL_PLANE) { + w = Math.max(w, (int) Math.ceil(bb[3])); + h = Math.max(h, (int) Math.ceil(bb[5])); + } + } + return new int[] {w, h}; + } + + /** Commands that change the grid size (region grid rows copied, new cells region 0). */ + static List<Command> resize(Scene s, int nw, int nh) { + LevelSettings st = s.settings(); + int w = st.width(), h = st.height(); + int[] old = s.regions().grid(); + int[] g = new int[nw * nh]; + for (int z = 0; z < Math.min(h, nh); z++) { + for (int x = 0; x < Math.min(w, nw); x++) { + if (x + z * w < old.length) { + g[x + z * nw] = old[x + z * w]; + } + } + } + Regions rg = s.regions(); + return List.of(SetProp.of(st, "width", nw), SetProp.of(st, "height", nh), SetProp.of(rg, "grid", g)); + } + + /** Death plane brushes (KILL_PLANE, SEA with a death plane) and tiles whose model only has material 30. */ + static List<Node> killPlanes(Scene s) { + List<Node> out = new ArrayList<>(); + for (Brush b : s.brushes()) { + if (b.brushKind() == BrushKind.KILL_PLANE + || b.brushKind() == BrushKind.SEA && !"none".equals(b.s("kill"))) { + out.add(b); + } + } + for (Tile t : s.tiles()) { + if (isKillModel(t.model())) { + out.add(t); + } + } + return out; + } + + static boolean isKillModel(TileModel m) { + if (m == null || m.faceCount() > 0 || m.collisionCount() == 0) { + return false; + } + for (int g = 0; g < 3; g++) { + for (int k = 0; k < m.collisionCount(g); k++) { + if (m.collisionMaterial(g, k) != 30) { + return false; + } + } + } + return true; + } + + /** Height of the first death plane (tiles: tile y + the model's collision height). */ + static double killHeight(Scene s, List<Node> kill) { + for (Node n : kill) { + if (n instanceof Brush b) { + return b.d("y"); + } + if (n instanceof Tile t) { + return t.y() + collisionY(t.model()); + } + } + return Double.NaN; + } + + static double collisionY(TileModel m) { + double[] v = m.vertices(); + return m.vertexCount() > 0 ? v[1] : 0; + } + + static List<Command> moveKill(List<Node> kill, double dy) { + List<Command> c = new ArrayList<>(); + for (Node n : kill) { + if (n instanceof Brush b) { + c.add(SetProp.of(b, "y", b.d("y") + dy)); + } else if (n instanceof Tile t) { + c.add(SetProp.of(t, "y", t.y() + dy)); + } + } + return c; + } + + private static String summary(List<Node> kill) { + int b = 0, t = 0; + for (Node n : kill) { + if (n instanceof Brush) { + b++; + } else { + t++; + } + } + return (b > 0 ? b + " brush" + (b > 1 ? "es" : "") : "") + (b > 0 && t > 0 ? ", " : "") + + (t > 0 ? t + " no-floor tile" + (t > 1 ? "s" : "") : "") + " (all move together)"; + } + + private static String usedChapters(EditorContext ctx, int own) { + TreeMap<Integer, String> used = new TreeMap<>(); + try { + for (Path p : LevelProjects.list(ctx.env().levelsDir())) { + try { + ProjectInfo i = ProjectIO.info(Files.readAllBytes(p)); + if (i.isNewLevel() + && (ctx.document() == null + || !p.equals(ctx.document().file())) + && i.targetChapter() > 0) { + used.put(i.targetChapter(), i.name()); + } + } catch (Exception e) { + // skip broken files + } + } + } catch (RuntimeException e) { + return " "; + } + if (used.isEmpty()) { + return "<html><i>No other level project uses a chapter.</i></html>"; + } + StringBuilder sb = new StringBuilder("<html>Used by other projects: "); + used.forEach((k, v) -> sb.append(k == own ? "<b><font color='#d9603f'>" : "") + .append(k) + .append(" (") + .append(v) + .append(")") + .append(k == own ? "</font></b>" : "") + .append(" ")); + return sb.append("</html>").toString(); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LightSection.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LightSection.java new file mode 100644 index 0000000..08a680c --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LightSection.java @@ -0,0 +1,153 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.InspectorSection; +import com.eliasmueller.llamamod.editor.ui.Widgets; +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; +import javax.swing.JComponent; + +/** Inspector content for a light: type, colour + intensity, direction or position and radii. */ +final class LightSection implements InspectorSection { + static final String[] TYPES = {"Directional (sun)", "Point light", "Negative point light (darkens)"}; + + @Override + public String title() { + return "Light"; + } + + @Override + public int order() { + return 20; + } + + @Override + public boolean appliesTo(List<Node> selection) { + return selection.size() == 1 && selection.getFirst() instanceof Light; + } + + @Override + public JComponent create(EditorContext ctx, List<Node> selection) { + return build(ctx, (Light) selection.getFirst()); + } + + static int typeIndex(Light l) { + return l.type() == Light.DIRECTIONAL ? 0 : l.type() == Light.POINT_BRIGHT ? 2 : 1; + } + + /** Intensity = brightest channel (128 = 1.0 in the engine, i.e. ×2/255). */ + static double intensity(Light l) { + return Math.max(l.r(), Math.max(l.g(), l.b())) / 127.5; + } + + static Color hue(Light l) { + int m = Math.max(1, Math.max(l.r(), Math.max(l.g(), l.b()))); + return new Color(l.r() * 255 / m, l.g() * 255 / m, l.b() * 255 / m); + } + + static void setColor(EditorContext ctx, Light l, Color hue, double intensity, Object key) { + double k = intensity * 127.5 / 255; + int r = EnvMath.clamp((int) Math.round(hue.getRed() * k)); + int g = EnvMath.clamp((int) Math.round(hue.getGreen() * k)); + int b = EnvMath.clamp((int) Math.round(hue.getBlue() * k)); + List<Command> c = List.of( + SetProp.of(l, l.prop("r"), r, key), + SetProp.of(l, l.prop("g"), g, key), + SetProp.of(l, l.prop("b"), b, key)); + ctx.execute(new Batch("Light colour", c, key)); + } + + static JComponent build(EditorContext ctx, Light l) { + Binder b = new Binder(ctx, l); + Ui.Form f = new Ui.Form(); + b.attach(f); + f.full(b.label(l::label)); + f.full(Ui.line( + Widgets.button("Frame", "focus", "Show the light", ctx::focusSelection), + Widgets.button("Duplicate", "duplicate", null, () -> ctx.run("edit.duplicate")))); + f.header("Light"); + f.row("Type", b.combo(TYPES, n -> typeIndex(l), i -> { + int t = i == 0 ? Light.DIRECTIONAL : i == 2 ? Light.POINT_BRIGHT : Light.POINT; + List<Command> c = new ArrayList<>(); + c.add(SetProp.of(l, "type", t)); + if (t != Light.DIRECTIONAL && l.rangeB() <= 0) { + c.add(SetProp.of(l, "a", 1.0)); + c.add(SetProp.of(l, "rangeB", 3.0)); + } + if (t == Light.DIRECTIONAL && l.y() == 0 && l.x() == 0 && l.z() == 0) { + c.add(SetProp.of(l, "y", 1.0)); + } + ctx.execute(new Batch("Light type", c)); + javax.swing.SwingUtilities.invokeLater(() -> { + ctx.selection().clear(LightSection.class); + ctx.selection().set(l, LightSection.class); + }); + })); + Object ck = new Object(); + f.row("Colour", b.color("Light colour", n -> hue(l), (c, key) -> setColor(ctx, l, c, intensity(l), key))); + javax.swing.JSpinner inten = b.number(0, 2, 0.05, "0.00", n -> intensity(l), v -> null, "r"); + inten.addChangeListener( + e -> b.edit(() -> setColor(ctx, l, hue(l), ((Number) inten.getValue()).doubleValue(), ck))); + f.row("Intensity", inten, "1.0 = colour value 128 (engine factor ×2/255); 2.0 = maximum"); + f.row("Red / green / blue", b.label(() -> l.r() + " / " + l.g() + " / " + l.b())); + if (l.type() == Light.DIRECTIONAL) { + f.header("Direction"); + f.note("Lights object models from this direction (towards the light). Tiles are not lit by" + + " lights – bake their vertex colours in the Environment panel."); + javax.swing.JSpinner az = b.number(-360, 360, 5, "0.#", n -> azimuth(l), v -> null, "x"); + javax.swing.JSpinner el = b.number(-90, 90, 5, "0.#", n -> elevation(l), v -> null, "y"); + f.row("Azimuth (°)", az, "0 = from +z, 90 = from +x (east)"); + f.row("Elevation (°)", el); + Object dk = new Object(); + javax.swing.event.ChangeListener dir = e -> b.edit(() -> setDirection( + ctx, l, ((Number) az.getValue()).doubleValue(), ((Number) el.getValue()).doubleValue(), dk)); + az.addChangeListener(dir); + el.addChangeListener(dir); + } else { + f.header("Position & range"); + f.row("X", b.number(-64, 4096, 0.25, "0.00", n -> l.x(), v -> v, "x")); + f.row("Y (height)", b.number(-64, 256, 0.25, "0.00", n -> l.y(), v -> v, "y")); + f.row("Z (row)", b.number(-64, 4096, 0.25, "0.00", n -> l.z(), v -> v, "z")); + f.row( + "Full radius", + b.number(0, 64, 0.1, "0.00", n -> l.a(), v -> v, "a"), + "Full strength up to this distance (cells)"); + f.row( + "Falloff radius", + b.number(0, 64, 0.1, "0.00", n -> l.rangeB(), v -> v, "rangeB"), + "Linear falloff to zero at this distance; nothing beyond"); + f.row( + "", + b.check("Flicker", n -> l.c() == 1, on -> b.set("c", on ? 1 : 0, null)), + "c = 1: the engine randomly scales the light ×1.5 / ×0.5 every frame (DAT_005865d4)"); + f.note("Each object is lit by all directional lights and its nearest point lights, 6 at most."); + } + f.end(); + return f; + } + + static double azimuth(Light l) { + double[] d = EnvMath.direction(l); + return Math.toDegrees(Math.atan2(d[0], d[2])); + } + + static double elevation(Light l) { + double[] d = EnvMath.direction(l); + return Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, d[1])))); + } + + static void setDirection(EditorContext ctx, Light l, double azDeg, double elDeg, Object key) { + double[] d = EnvMath.directionFrom(azDeg, elDeg); + List<Command> c = List.of( + SetProp.of(l, l.prop("x"), d[0], key), + SetProp.of(l, l.prop("y"), d[1], key), + SetProp.of(l, l.prop("z"), d[2], key)); + ctx.execute(new Batch("Light direction", c, key)); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LightTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LightTool.java new file mode 100644 index 0000000..d24d26c --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/LightTool.java @@ -0,0 +1,203 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Tool; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import java.awt.Color; +import java.awt.Cursor; +import java.util.Locale; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JSpinner; +import org.joml.Vector3f; + +/** Light tool: click to place a point light (1 cell above the surface), drag to set its radius. */ +final class LightTool implements Tool { + record Preset(String name, int r, int g, int b) { + @Override + public String toString() { + return name; + } + } + + static final Preset[] PRESETS = { + new Preset("Torch (orange)", 123, 86, 0), + new Preset("Fire (red)", 160, 40, 0), + new Preset("Lamp (warm white)", 128, 112, 80), + new Preset("Magic (cyan)", 0, 120, 110), + new Preset("Moon (blue)", 40, 60, 120), + new Preset("Glow (green)", 20, 128, 40) + }; + + private Preset preset = PRESETS[0]; + private double height = 1.0; + private double radius = 3.0; + private Light dragging; + private Object key; + private Vector3f press; + private Vector3f hover; + + @Override + public String id() { + return "env.lights"; + } + + @Override + public String name() { + return "Lights"; + } + + @Override + public String icon() { + return "light"; + } + + @Override + public javax.swing.KeyStroke shortcut() { + return javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_U, 0); + } + + @Override + public String tooltip() { + return "Place point lights: click to place, drag to set the radius"; + } + + @Override + public String group() { + return "env"; + } + + @Override + public int order() { + return 310; + } + + @Override + public void activate(EditorContext ctx) { + ctx.setLayerVisible("lights", true); + ctx.setLayerVisible(EnvOverlays.LAYER_LIGHTS, true); + } + + @Override + public boolean mousePressed(ToolEvent e) { + EditorContext ctx = e.ctx(); + if (ctx.scene() == null || e.pick() instanceof Light || e.pick() instanceof EnvZone) { + return false; + } + Vector3f g = e.ground(); + if (g == null) { + return false; + } + Light l = point(ctx.scene(), g.x, g.y + height, -g.z, preset, Math.min(1, radius / 3), radius); + ctx.execute(new AddNode(l)); + ctx.selection().set(l, this); + dragging = l; + key = new Object(); + press = g; + return true; + } + + static Light point(Scene s, double x, double y, double z, Preset p, double a, double b) { + Light l = (Light) s.create(NodeKind.LIGHT); + l.setRaw("type", Light.POINT); + l.setRaw("r", p.r()); + l.setRaw("g", p.g()); + l.setRaw("b", p.b()); + l.setRaw("x", x); + l.setRaw("y", y); + l.setRaw("z", z); + l.setRaw("a", a); + l.setRaw("rangeB", b); + return l; + } + + static Light directional(Scene s, double azDeg, double elDeg, int r, int g, int b) { + Light l = (Light) s.create(NodeKind.LIGHT); + double[] d = EnvMath.directionFrom(azDeg, elDeg); + l.setRaw("type", Light.DIRECTIONAL); + l.setRaw("r", r); + l.setRaw("g", g); + l.setRaw("b", b); + l.setRaw("x", d[0]); + l.setRaw("y", d[1]); + l.setRaw("z", d[2]); + return l; + } + + @Override + public void mouseDragged(ToolEvent e) { + if (dragging == null || !e.ctx().scene().contains(dragging)) { + return; + } + Vector3f g = e.onPlane(press.y); + if (g == null) { + return; + } + double r = Math.max(0.25, Math.hypot(g.x - press.x, g.z - press.z)); + e.ctx() + .execute(new com.eliasmueller.llamamod.editor.model.cmd.Batch( + "Light radius", + java.util.List.of( + SetProp.of(dragging, dragging.prop("rangeB"), r, key), + SetProp.of(dragging, dragging.prop("a"), Math.min(1.0, r * 0.4), key)), + key)); + } + + @Override + public void mouseReleased(ToolEvent e) { + if (dragging != null) { + e.ctx() + .status(String.format( + Locale.ROOT, + "Point light at %.1f, %.1f (radius %.1f)", + dragging.x(), + dragging.z(), + dragging.rangeB())); + } + dragging = null; + } + + @Override + public void mouseMoved(ToolEvent e) { + hover = e.ctx().scene() == null ? null : e.ground(); + e.ctx().repaintTool(); + } + + @Override + public void drawOverlay(Overlay depthTested, Overlay onTop) { + if (hover == null || dragging != null) { + return; + } + Color c = + new Color(Math.min(255, preset.r() * 2), Math.min(255, preset.g() * 2), Math.min(255, preset.b() * 2)); + double[] p = {hover.x, hover.y + height, hover.z}; + EnvOverlays.sphere(onTop, p, 0.15, EnvOverlays.rgba(c, 255)); + EnvOverlays.ring( + depthTested, new double[] {hover.x, hover.y + 0.03, hover.z}, radius, EnvOverlays.rgba(c, 200)); + onTop.line(p, new double[] {hover.x, hover.y, hover.z}, EnvOverlays.rgba(c, 160)); + } + + @Override + public JComponent optionsBar(EditorContext ctx) { + JComboBox<Preset> p = new JComboBox<>(PRESETS); + p.setSelectedItem(preset); + p.addActionListener(a -> preset = (Preset) p.getSelectedItem()); + p.setFocusable(false); + JSpinner h = Ui.number(height, 0, 10, 0.25, "0.00", v -> height = v); + JSpinner r = Ui.number(radius, 0.25, 32, 0.25, "0.00", v -> radius = v); + return Ui.line(new JLabel("Colour"), p, new JLabel("Height"), h, new JLabel("Radius"), r); + } + + @Override + public Cursor cursor() { + return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ObjectLighting.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ObjectLighting.java new file mode 100644 index 0000000..9da4aca --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ObjectLighting.java @@ -0,0 +1,38 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.render.RenderInstance; +import com.eliasmueller.llamamod.editor.render.RenderMesh; +import java.util.List; +import org.joml.Vector3f; + +/** Per-vertex object lighting like the engine (preview of LGHT lights on object models). */ +final class ObjectLighting { + private ObjectLighting() {} + + /** Vertex colours (0xRRGGBBAA, 0x80 = 1.0) of an object instance lit by the level's lights. */ + static int[] colors(Scene s, Strat st, RenderInstance ri, double[] ambient) { + RenderMesh m = ri.mesh; + List<Light> lights = EnvMath.lightsFor(s, st.gx(), st.gy(), st.gz()); + int nv = m.vertexCount(); + int[] out = new int[nv]; + Vector3f p = new Vector3f(), n = new Vector3f(); + for (int v = 0; v < nv; v++) { + p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]); + n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]); + ri.transform.transformPosition(p); + ri.transform.transformDirection(n); + if (n.lengthSquared() > 0) { + n.normalize(); + } + double[] c = EnvMath.shade(ambient, lights, new double[] {p.x, p.y, -p.z}, new double[] {n.x, n.y, -n.z}); + int r = EnvMath.clamp((int) Math.round(c[0] * 128)); + int g = EnvMath.clamp((int) Math.round(c[1] * 128)); + int b = EnvMath.clamp((int) Math.round(c[2] * 128)); + out[v] = r << 24 | g << 16 | b << 8 | 0xff; + } + return out; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/PropMapCommand.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/PropMapCommand.java new file mode 100644 index 0000000..12e3ef4 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/PropMapCommand.java @@ -0,0 +1,88 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Sets one property on many nodes (e.g. vertex colours of the tiles under a paint brush). Commands + * with the same merge key merge even if they touch different nodes (one undo step per stroke): the + * first old value and the last new value of every node are kept. + */ +public final class PropMapCommand implements Command { + private final String label; + private final String prop; + private final Map<Node, Object[]> values; + private final Object mergeKey; + + /** newValues: node → value; old values are read now. */ + public PropMapCommand(String label, String prop, Map<? extends Node, ?> newValues, Object mergeKey) { + this.label = label; + this.prop = prop; + this.mergeKey = mergeKey; + this.values = new LinkedHashMap<>(); + for (Map.Entry<? extends Node, ?> e : newValues.entrySet()) { + values.put(e.getKey(), new Object[] {copy(e.getKey().get(prop)), copy(e.getValue())}); + } + } + + private PropMapCommand(String label, String prop, Map<Node, Object[]> values, Object mergeKey, boolean raw) { + this.label = label; + this.prop = prop; + this.values = values; + this.mergeKey = mergeKey; + } + + private static Object copy(Object v) { + if (v instanceof int[] a) { + return a.clone(); + } + if (v instanceof double[] a) { + return a.clone(); + } + return v; + } + + @Override + public String label() { + return label; + } + + @Override + public void apply(Scene s) { + for (Map.Entry<Node, Object[]> e : values.entrySet()) { + s.set(e.getKey(), prop, copy(e.getValue()[1])); + } + } + + @Override + public void revert(Scene s) { + for (Map.Entry<Node, Object[]> e : values.entrySet()) { + s.set(e.getKey(), prop, copy(e.getValue()[0])); + } + } + + @Override + public Object mergeKey() { + return mergeKey; + } + + @Override + public Command mergeWith(Command next) { + if (!(next instanceof PropMapCommand o) || mergeKey == null || !Objects.equals(mergeKey, o.mergeKey)) { + return null; + } + if (!prop.equals(o.prop)) { + return null; + } + Map<Node, Object[]> m = new LinkedHashMap<>(values); + for (Map.Entry<Node, Object[]> e : o.values.entrySet()) { + Object[] mine = m.get(e.getKey()); + m.put(e.getKey(), new Object[] {mine != null ? mine[0] : e.getValue()[0], e.getValue()[1]}); + } + return new PropMapCommand(label, prop, m, mergeKey, true); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/RegionGen.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/RegionGen.java new file mode 100644 index 0000000..caaf0d2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/RegionGen.java @@ -0,0 +1,288 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Regions; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Tile; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Visibility region helpers: automatic layouts (all visible, rectangular blocks, connected areas) + * and the cost of a layout (triangles the engine draws for a camera in each region; the tile + * renderer FUN_0042bf40 skips cells whose region bit is not in the camera region's mask, a mask of + * 0 means everything). + */ +public final class RegionGen { + private RegionGen() {} + + public enum Mode { + ALL_VISIBLE("One region, everything visible"), + BLOCKS("Rectangular blocks, visible within a distance"), + CONNECTED("Connected areas (islands / rooms), visible within a distance"); + + public final String label; + + Mode(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } + } + + /** Region grid (W·H) and table (mask, value) × 32. */ + public record Layout(int[] grid, int[] table) {} + + public static Layout generate(Scene s, Mode mode, double distance) { + int w = s.settings().width(), h = s.settings().height(); + int[] table = s.regions().table().length == 2 * Regions.COUNT + ? s.regions().table().clone() + : new int[2 * Regions.COUNT]; + int[] grid = new int[w * h]; + int n; + switch (mode) { + case ALL_VISIBLE -> { + for (int r = 0; r < Regions.COUNT; r++) { + table[2 * r] = r == 0 ? -1 : 0; + } + return new Layout(grid, table); + } + case BLOCKS -> n = blocks(w, h, grid); + default -> n = connected(s, w, h, grid); + } + int[][] box = new int[n][]; + for (int i = 0; i < n; i++) { + box[i] = new int[] {Integer.MAX_VALUE, Integer.MAX_VALUE, -1, -1}; + } + for (int cz = 0; cz < h; cz++) { + for (int cx = 0; cx < w; cx++) { + int r = grid[cx + cz * w]; + int[] b = box[r]; + b[0] = Math.min(b[0], cx); + b[1] = Math.min(b[1], cz); + b[2] = Math.max(b[2], cx); + b[3] = Math.max(b[3], cz); + } + } + for (int r = 0; r < Regions.COUNT; r++) { + table[2 * r] = 0; + } + for (int a = 0; a < n; a++) { + int m = 0; + for (int b = 0; b < n; b++) { + if (a == b || boxDistance(box[a], box[b]) <= distance) { + m |= 1 << b; + } + } + table[2 * a] = m; + } + return new Layout(grid, table); + } + + private static double boxDistance(int[] a, int[] b) { + double dx = Math.max(0, Math.max(a[0] - b[2], b[0] - a[2]) - 1); + double dz = Math.max(0, Math.max(a[1] - b[3], b[1] - a[3]) - 1); + return Math.sqrt(dx * dx + dz * dz); + } + + private static int blocks(int w, int h, int[] grid) { + int bx = 1, bz = 1; + while (bx * bz < Regions.COUNT) { + if ((double) w / bx >= (double) h / bz && (bx + 1) * bz <= Regions.COUNT) { + bx++; + } else if (bx * (bz + 1) <= Regions.COUNT) { + bz++; + } else { + break; + } + } + for (int cz = 0; cz < h; cz++) { + for (int cx = 0; cx < w; cx++) { + grid[cx + cz * w] = Math.min(bx - 1, cx * bx / w) + Math.min(bz - 1, cz * bz / h) * bx; + } + } + return bx * bz; + } + + /** Flood fill over cells with tiles (neighbours within 2.5 height), ≤ 32 areas, empty cells to the nearest. */ + private static int connected(Scene s, int w, int h, int[] grid) { + double[] top = topHeights(s, w, h); + int[] lab = new int[w * h]; + Arrays.fill(lab, -1); + List<List<Integer>> areas = new ArrayList<>(); + int[] dx = {1, -1, 0, 0}, dz = {0, 0, 1, -1}; + for (int i = 0; i < w * h; i++) { + if (Double.isNaN(top[i]) || lab[i] >= 0) { + continue; + } + List<Integer> area = new ArrayList<>(); + ArrayDeque<Integer> q = new ArrayDeque<>(); + q.add(i); + lab[i] = areas.size(); + while (!q.isEmpty()) { + int c = q.poll(); + area.add(c); + int cx = c % w, cz = c / w; + for (int k = 0; k < 4; k++) { + int nx = cx + dx[k], nz = cz + dz[k]; + if (nx < 0 || nz < 0 || nx >= w || nz >= h) { + continue; + } + int nc = nx + nz * w; + if (lab[nc] < 0 && !Double.isNaN(top[nc]) && Math.abs(top[nc] - top[c]) < 2.5) { + lab[nc] = lab[i]; + q.add(nc); + } + } + } + areas.add(area); + } + if (areas.isEmpty()) { + return 1; + } + // merge the smallest areas into their nearest neighbour until 32 remain + int[] owner = new int[areas.size()]; + for (int i = 0; i < owner.length; i++) { + owner[i] = i; + } + int alive = areas.size(); + int[] size = areas.stream().mapToInt(List::size).toArray(); + double[][] centre = new double[areas.size()][2]; + for (int a = 0; a < areas.size(); a++) { + for (int c : areas.get(a)) { + centre[a][0] += c % w; + centre[a][1] += c / w; + } + centre[a][0] /= size[a]; + centre[a][1] /= size[a]; + } + while (alive > Regions.COUNT) { + int small = -1; + for (int a = 0; a < owner.length; a++) { + if (owner[a] == a && (small < 0 || size[a] < size[small])) { + small = a; + } + } + int best = -1; + double bd = Double.MAX_VALUE; + for (int a = 0; a < owner.length; a++) { + if (owner[a] == a && a != small) { + double d = + EnvMath.sq(centre[a][0] - centre[small][0]) + EnvMath.sq(centre[a][1] - centre[small][1]); + if (d < bd) { + bd = d; + best = a; + } + } + } + for (int a = 0; a < owner.length; a++) { + if (owner[a] == small) { + owner[a] = best; + } + } + centre[best][0] = + (centre[best][0] * size[best] + centre[small][0] * size[small]) / (size[best] + size[small]); + centre[best][1] = + (centre[best][1] * size[best] + centre[small][1] * size[small]) / (size[best] + size[small]); + size[best] += size[small]; + alive--; + } + int[] index = new int[owner.length]; + Arrays.fill(index, -1); + int n = 0; + for (int a = 0; a < owner.length; a++) { + if (owner[a] == a) { + index[a] = n++; + } + } + // multi-source BFS: every cell gets the region of the nearest occupied cell + Arrays.fill(grid, -1); + ArrayDeque<Integer> q = new ArrayDeque<>(); + for (int i = 0; i < w * h; i++) { + if (lab[i] >= 0) { + grid[i] = index[owner[lab[i]]]; + q.add(i); + } + } + while (!q.isEmpty()) { + int c = q.poll(); + int cx = c % w, cz = c / w; + for (int k = 0; k < 4; k++) { + int nx = cx + dx[k], nz = cz + dz[k]; + if (nx >= 0 && nz >= 0 && nx < w && nz < h && grid[nx + nz * w] < 0) { + grid[nx + nz * w] = grid[c]; + q.add(nx + nz * w); + } + } + } + return n; + } + + /** Highest tile y per cell (NaN = no tile). */ + static double[] topHeights(Scene s, int w, int h) { + double[] top = new double[w * h]; + Arrays.fill(top, Double.NaN); + for (Tile t : s.tiles()) { + int cx = t.cellX(), cz = t.cellZ(); + if (cx >= 0 + && cz >= 0 + && cx < w + && cz < h + && t.model() != null + && t.model().faceCount() > 0) { + int i = cx + cz * w; + top[i] = Double.isNaN(top[i]) ? t.y() : Math.max(top[i], t.y()); + } + } + return top; + } + + /** Triangles per region (cells of the region), index = region. */ + public static long[] trianglesPerRegion(Scene s, int[] grid) { + int w = s.settings().width(), h = s.settings().height(); + long[] tris = new long[Regions.COUNT]; + for (Tile t : s.tiles()) { + int cx = t.cellX(), cz = t.cellZ(); + if (cx >= 0 && cz >= 0 && cx < w && cz < h && t.model() != null && cx + cz * w < grid.length) { + tris[grid[cx + cz * w] & 31] += t.model().faceCount(); + } + } + return tris; + } + + /** Triangles drawn for a camera in each region (all regions in its mask; mask 0 = all). */ + public static long[] visibleTriangles(Scene s, int[] grid, int[] table) { + long[] per = trianglesPerRegion(s, grid); + long all = Arrays.stream(per).sum(); + long[] out = new long[Regions.COUNT]; + for (int r = 0; r < Regions.COUNT; r++) { + int m = table[2 * r]; + if (m == 0) { + out[r] = all; + continue; + } + for (int b = 0; b < Regions.COUNT; b++) { + if ((m & (1 << b)) != 0) { + out[r] += per[b]; + } + } + } + return out; + } + + /** Regions that occur in the grid. */ + public static boolean[] used(int[] grid) { + boolean[] u = new boolean[Regions.COUNT]; + for (int g : grid) { + u[g & 31] = true; + } + return u; + } + + /** Above this many triangles visible from one region the game gets slow on old hardware. */ + public static final long HEAVY = 30000; +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/RegionPaintTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/RegionPaintTool.java new file mode 100644 index 0000000..024b1eb --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/RegionPaintTool.java @@ -0,0 +1,200 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Regions; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.Tool; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import java.awt.Component; +import java.awt.Cursor; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JList; +import org.joml.Vector3f; + +/** Paints visibility regions onto grid cells (square brush); Ctrl+click picks the region of a cell. */ +final class RegionPaintTool implements Tool { + int region = 1; + int size = 1; + private Object stroke; + private Vector3f hover; + private JComboBox<Integer> regionBox; + + @Override + public String id() { + return "env.regions"; + } + + @Override + public String name() { + return "Paint regions"; + } + + @Override + public String icon() { + return "region"; + } + + @Override + public javax.swing.KeyStroke shortcut() { + return javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_Y, 0); + } + + @Override + public String tooltip() { + return "Paint visibility regions on grid cells (Ctrl+click picks a region)"; + } + + @Override + public String group() { + return "env"; + } + + @Override + public int order() { + return 330; + } + + @Override + public void activate(EditorContext ctx) { + ctx.setLayerVisible("regions", true); + } + + void setRegion(int r) { + region = r & 31; + if (regionBox != null && !Integer.valueOf(region).equals(regionBox.getSelectedItem())) { + regionBox.setSelectedItem(region); + } + } + + private int[] cell(Vector3f p) { + return new int[] {(int) Math.floor(p.x), (int) Math.floor(-p.z)}; + } + + @Override + public boolean mousePressed(ToolEvent e) { + Scene s = e.ctx().scene(); + Vector3f p = e.ground(); + if (s == null || p == null) { + return s != null; + } + int[] c = cell(p); + if (e.ctrl()) { + int r = s.regions().regionAt(c[0], c[1], s.settings().width()); + if (r >= 0) { + setRegion(r); + e.ctx().status("Region " + r); + } + return true; + } + stroke = new Object(); + paint(e.ctx(), c); + return true; + } + + @Override + public void mouseDragged(ToolEvent e) { + Vector3f p = e.ground(); + hover = p; + if (stroke != null && p != null) { + paint(e.ctx(), cell(p)); + } + e.ctx().repaintTool(); + } + + @Override + public void mouseReleased(ToolEvent e) { + stroke = null; + } + + @Override + public void mouseMoved(ToolEvent e) { + hover = e.ctx().scene() == null ? null : e.ground(); + e.ctx().repaintTool(); + } + + private void paint(EditorContext ctx, int[] c) { + Scene s = ctx.scene(); + Regions rg = s.regions(); + int w = s.settings().width(), h = s.settings().height(); + int[] grid = rg.grid(); + if (grid.length != w * h) { + return; + } + int[] g = null; + int half = size / 2; + for (int z = c[1] - half; z <= c[1] + half; z++) { + for (int x = c[0] - half; x <= c[0] + half; x++) { + if (x < 0 || z < 0 || x >= w || z >= h || grid[x + z * w] == region) { + continue; + } + if (g == null) { + g = grid.clone(); + } + g[x + z * w] = region; + } + } + if (g != null) { + ctx.execute(SetProp.of(rg, rg.prop("grid"), g, stroke)); + } + } + + @Override + public void drawOverlay(Overlay depthTested, Overlay onTop) { + if (hover == null) { + return; + } + int[] c = cell(hover); + int half = size / 2; + double y = hover.y + 0.05; + int col = EnvOverlays.rgba(EnvMath.regionColor(region), 255); + onTop.box(c[0] - half, y, -(c[1] + half) - 1, c[0] + half + 1, y + 0.2, -(c[1] - half), col); + } + + @Override + public JComponent optionsBar(EditorContext ctx) { + Integer[] items = new Integer[Regions.COUNT]; + for (int i = 0; i < items.length; i++) { + items[i] = i; + } + regionBox = new JComboBox<>(items); + regionBox.setMaximumRowCount(16); + regionBox.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean f) { + JLabel lab = (JLabel) super.getListCellRendererComponent(l, "Region " + v, i, s, f); + lab.setIcon(Icons.swatch(EnvMath.regionColor((Integer) v), 14, 12)); + return lab; + } + }); + regionBox.setSelectedItem(region); + regionBox.addActionListener(a -> { + if (regionBox.getSelectedItem() instanceof Integer r && r != region) { + setRegion(r); + } + }); + regionBox.setFocusable(false); + JComboBox<String> sz = new JComboBox<>(new String[] {"1 × 1", "3 × 3", "5 × 5", "9 × 9", "17 × 17"}); + int[] sizes = {1, 3, 5, 9, 17}; + for (int i = 0; i < sizes.length; i++) { + if (sizes[i] == size) { + sz.setSelectedIndex(i); + } + } + sz.addActionListener(a -> size = sizes[sz.getSelectedIndex()]); + sz.setFocusable(false); + JLabel hint = new JLabel("Ctrl+click: pick · masks in the Visibility panel"); + hint.setEnabled(false); + return Ui.line(new JLabel("Region"), regionBox, new JLabel("Brush"), sz, hint); + } + + @Override + public Cursor cursor() { + return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Ui.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Ui.java new file mode 100644 index 0000000..666115b --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Ui.java @@ -0,0 +1,270 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.ui.Icons; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.util.Locale; +import java.util.function.BiConsumer; +import java.util.function.DoubleConsumer; +import java.util.function.Supplier; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JColorChooser; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.JSpinner; +import javax.swing.Scrollable; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingConstants; +import javax.swing.UIManager; + +/** Small Swing helpers shared by the environment panels (forms, number fields, colour buttons). */ +final class Ui { + private Ui() {} + + static { + Icons.register("paint", (g, c) -> { + g.drawLine(10, 2, 14, 6); + g.drawLine(14, 6, 7, 13); + g.drawLine(10, 2, 3, 9); + g.fillOval(1, 10, 6, 5); + }); + Icons.register("fog", (g, c) -> { + g.drawLine(2, 5, 14, 5); + g.drawLine(4, 8, 12, 8); + g.drawLine(2, 11, 14, 11); + g.drawLine(5, 14, 11, 14); + }); + Icons.register("teleport", (g, c) -> { + g.drawOval(1, 9, 6, 4); + g.drawOval(9, 3, 6, 4); + g.drawLine(5, 9, 10, 6); + g.drawLine(10, 6, 8, 6); + g.drawLine(10, 6, 10, 8); + }); + } + + static void init() {} + + /** Two-column form: label left, editor right (fills). */ + static final class Form extends JPanel implements Scrollable { + private int row; + + Form() { + super(new GridBagLayout()); + setBorder(BorderFactory.createEmptyBorder(4, 8, 8, 8)); + } + + Form header(String text) { + JLabel l = new JLabel(text); + l.setFont(l.getFont().deriveFont(Font.BOLD)); + GridBagConstraints c = gbc(0, row++, 2); + c.insets = new Insets(row == 1 ? 2 : 10, 0, 2, 0); + add(l, c); + GridBagConstraints s = gbc(0, row++, 2); + s.insets = new Insets(0, 0, 4, 0); + add(new JSeparator(), s); + return this; + } + + Form row(String label, JComponent comp) { + return row(label, comp, null); + } + + Form row(String label, JComponent comp, String tip) { + JLabel l = new JLabel(label); + if (tip != null) { + l.setToolTipText(tip); + comp.setToolTipText(tip); + } + GridBagConstraints a = gbc(0, row, 1); + a.weightx = 0; + a.fill = GridBagConstraints.NONE; + a.anchor = GridBagConstraints.WEST; + a.insets = new Insets(2, 0, 2, 8); + add(l, a); + GridBagConstraints b = gbc(1, row++, 1); + b.insets = new Insets(2, 0, 2, 0); + add(comp, b); + return this; + } + + Form full(JComponent comp) { + GridBagConstraints c = gbc(0, row++, 2); + c.insets = new Insets(2, 0, 2, 0); + add(comp, c); + return this; + } + + Form note(String text) { + JLabel l = new JLabel("<html><body style='width:230px'>" + text + "</body></html>"); + l.setForeground(UIManager.getColor("Label.disabledForeground")); + l.setFont(l.getFont().deriveFont(l.getFont().getSize2D() - 1f)); + return full(l); + } + + Form end() { + GridBagConstraints c = gbc(0, row++, 2); + c.weighty = 1; + add(new JPanel(), c); + return this; + } + + private static GridBagConstraints gbc(int x, int y, int w) { + GridBagConstraints c = new GridBagConstraints(); + c.gridx = x; + c.gridy = y; + c.gridwidth = w; + c.weightx = 1; + c.fill = GridBagConstraints.HORIZONTAL; + return c; + } + + @Override + public Dimension getPreferredScrollableViewportSize() { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle r, int o, int d) { + return 16; + } + + @Override + public int getScrollableBlockIncrement(Rectangle r, int o, int d) { + return r.height - 16; + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + return false; + } + } + + /** Number spinner; onChange gets every committed value. */ + static JSpinner number(double value, double min, double max, double step, String pattern, DoubleConsumer onChange) { + JSpinner s = new JSpinner(new SpinnerNumberModel(clamp(value, min, max), min, max, step)); + s.setEditor(new JSpinner.NumberEditor(s, pattern)); + Dimension d = s.getPreferredSize(); + s.setPreferredSize(new Dimension(Math.max(70, Math.min(d.width, 110)), d.height)); + s.addChangeListener(e -> onChange.accept(((Number) s.getValue()).doubleValue())); + return s; + } + + static JSpinner small(JSpinner s, String tip) { + Dimension d = s.getPreferredSize(); + s.setPreferredSize(new Dimension(62, d.height)); + s.setToolTipText(tip); + return s; + } + + static double clamp(double v, double lo, double hi) { + return Math.max(lo, Math.min(hi, v)); + } + + static JPanel line(Component... cs) { + JPanel p = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 4, 0)); + for (Component c : cs) { + p.add(c); + } + return p; + } + + /** + * Colour swatch button; the chooser previews live (set gets a merge key per dialog, so a picking + * session is one undo step) and Cancel restores the start colour. + */ + static final class ColorButton extends JButton { + private final Supplier<Color> get; + boolean compact; + + ColorButton(String title, Supplier<Color> get, BiConsumer<Color, Object> set) { + this.get = get; + setHorizontalAlignment(SwingConstants.LEFT); + setFocusable(false); + refresh(); + addActionListener(e -> { + Color start = get.get(); + Object key = new Object(); + JColorChooser ch = new JColorChooser(start); + ch.getSelectionModel().addChangeListener(ev -> { + set.accept(ch.getColor(), key); + refresh(); + }); + JDialog d = JColorChooser.createDialog(this, title, true, ch, ok -> {}, cancel -> { + set.accept(start, key); + refresh(); + }); + d.setVisible(true); + }); + } + + void refresh() { + Color c = get.get(); + setIcon(Icons.swatch(c, 28, 14)); + String hex = String.format(Locale.ROOT, "#%02X%02X%02X", c.getRed(), c.getGreen(), c.getBlue()); + setText(compact ? null : hex); + setToolTipText(hex); + } + } + + /** Horizontal fog ramp: clear up to start, colour ramps to end, then the backdrop. */ + static final class FogBar extends JComponent { + double start; + double end; + double max = 30; + Color color = Color.GRAY; + + FogBar() { + setPreferredSize(new Dimension(220, 30)); + } + + @Override + protected void paintComponent(Graphics g0) { + Graphics2D g = (Graphics2D) g0.create(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int w = getWidth() - 2, h = 16; + double sx = w / Math.max(1, max); + int xs = (int) (start * sx), xe = (int) Math.max(xs + 1, end * sx); + Color clear = new Color(0x6f9b4f); + g.setColor(clear); + g.fillRect(1, 1, Math.min(xs, w), h); + if (xs < w) { + g.setPaint(new java.awt.GradientPaint(xs, 0, clear, xe, 0, color)); + g.fillRect(xs + 1, 1, Math.min(xe, w) - xs, h); + } + if (xe < w) { + g.setColor(color); + g.fillRect(xe + 1, 1, w - xe, h); + } + g.setColor(UIManager.getColor("Label.foreground")); + g.setStroke(new BasicStroke(1f)); + g.drawRect(0, 0, w + 1, h + 1); + g.setFont(getFont().deriveFont(10f)); + for (int c = 0; c <= max; c += 5) { + int x = (int) (c * sx) + 1; + g.drawLine(x, h + 1, x, h + 4); + g.drawString(Integer.toString(c), Math.min(x, w - 12), h + 13); + } + g.dispose(); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VertexBake.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VertexBake.java new file mode 100644 index 0000000..4b2dc2f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VertexBake.java @@ -0,0 +1,365 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.format.Geometry; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.DoubleConsumer; + +/** + * Vertex lighting bake for placed tiles: the shading model of {@code levelbuild.Lighting} (sky/ground + * hemisphere, sun with Lambert + shadow ray, ambient occlusion with 24 rays) traced against the + * actual triangles of all tiles (uniform xz grid, 1 cell), optionally plus the level's point lights. + * Works in export space; the sun direction follows the levelbuild convention (azimuth/elevation in + * game space). + */ +public final class VertexBake { + /** Bake parameters; colours as linear factors (1.0 = texture colour). */ + public record Rig( + double azimuth, + double elevation, + double strength, + double[] sun, + double[] sky, + double[] ground, + double ao, + double aoRadius, + double shadow, + boolean pointLights) {} + + private static final double[][] HEMI = hemi(); + + private static double[][] hemi() { + int n = 24; + double[][] d = new double[n][]; + for (int i = 0; i < n; i++) { + double y = 1 - 2 * (i + 0.5) / n; + double r = Math.sqrt(1 - y * y); + double a = i * Math.PI * (3 - Math.sqrt(5)); + d[i] = new double[] {r * Math.cos(a), y, r * Math.sin(a)}; + } + return d; + } + + private final float[] tri; + private final int nTri; + private final int x0; + private final int z0; + private final int gw; + private final int gh; + private final int[][] cells; + private final float[] cellTop; + private final ThreadLocal<int[]> stamps; + private final ThreadLocal<int[]> stampId = ThreadLocal.withInitial(() -> new int[1]); + + /** Builds the occluder grid from all tiles of the scene (opaque faces only). */ + public VertexBake(Scene s) { + List<float[]> tris = new ArrayList<>(); + double minX = Double.POSITIVE_INFINITY, minZ = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY, maxZ = Double.NEGATIVE_INFINITY; + for (Tile t : s.tiles()) { + TileModel m = t.model(); + if (m == null || m.faceCount() == 0) { + continue; + } + double[][] w = VertexPaintTool.worldVerts(t); + for (int f = 0; f < m.faceCount(); f++) { + if (!opaque(s, m.faceTexture(f))) { + continue; + } + double[] a = w[m.faceVertex(f, 0)], b = w[m.faceVertex(f, 1)], c = w[m.faceVertex(f, 2)]; + tris.add(new float[] { + (float) a[0], + (float) a[1], + (float) a[2], + (float) b[0], + (float) b[1], + (float) b[2], + (float) c[0], + (float) c[1], + (float) c[2] + }); + for (double[] p : new double[][] {a, b, c}) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minZ = Math.min(minZ, p[2]); + maxZ = Math.max(maxZ, p[2]); + } + } + } + nTri = tris.size(); + tri = new float[nTri * 9]; + for (int i = 0; i < nTri; i++) { + System.arraycopy(tris.get(i), 0, tri, 9 * i, 9); + } + if (nTri == 0) { + x0 = z0 = 0; + gw = gh = 1; + } else { + x0 = (int) Math.floor(minX); + z0 = (int) Math.floor(minZ); + gw = (int) Math.floor(maxX) - x0 + 1; + gh = (int) Math.floor(maxZ) - z0 + 1; + } + List<List<Integer>> bins = new ArrayList<>(); + for (int i = 0; i < gw * gh; i++) { + bins.add(null); + } + cellTop = new float[gw * gh]; + java.util.Arrays.fill(cellTop, Float.NEGATIVE_INFINITY); + for (int i = 0; i < nTri; i++) { + int o = 9 * i; + float lx = Math.min(tri[o], Math.min(tri[o + 3], tri[o + 6])); + float hx = Math.max(tri[o], Math.max(tri[o + 3], tri[o + 6])); + float lz = Math.min(tri[o + 2], Math.min(tri[o + 5], tri[o + 8])); + float hz = Math.max(tri[o + 2], Math.max(tri[o + 5], tri[o + 8])); + float hy = Math.max(tri[o + 1], Math.max(tri[o + 4], tri[o + 7])); + for (int cz = (int) Math.floor(lz) - z0; cz <= (int) Math.floor(hz) - z0; cz++) { + for (int cx = (int) Math.floor(lx) - x0; cx <= (int) Math.floor(hx) - x0; cx++) { + int c = cx + cz * gw; + if (bins.get(c) == null) { + bins.set(c, new ArrayList<>()); + } + bins.get(c).add(i); + cellTop[c] = Math.max(cellTop[c], hy); + } + } + } + cells = new int[gw * gh][]; + for (int i = 0; i < cells.length; i++) { + List<Integer> b = bins.get(i); + cells[i] = b == null + ? new int[0] + : b.stream().mapToInt(Integer::intValue).toArray(); + } + stamps = ThreadLocal.withInitial(() -> new int[nTri]); + } + + public int triangles() { + return nTri; + } + + private static boolean opaque(Scene s, int tex) { + if (tex < 0 || tex >= s.textures().size()) { + return true; + } + TextureEntry e = s.textures().get(tex); + return ((e.flags() >> 12) & 7) == 0; + } + + /** Sun direction in export space (towards the sun). */ + public static double[] sunDir(Rig r) { + double az = Math.toRadians(r.azimuth()), el = Math.toRadians(r.elevation()); + return new double[] {Math.cos(el) * Math.sin(az), Math.sin(el), -Math.cos(el) * Math.cos(az)}; + } + + /** + * New first-frame colours for the tiles (other frames and byte 3 are kept). progress gets 0..1 + * (any thread). + */ + public Map<Tile, int[]> bake(Scene s, Collection<Tile> tiles, Rig rig, DoubleConsumer progress) { + List<Tile> list = new ArrayList<>(tiles); + Map<Tile, int[]> out = new HashMap<>(); + double[] L = sunDir(rig); + List<Light> points = new ArrayList<>(); + if (rig.pointLights()) { + for (Light l : s.lights()) { + if (l.type() == Light.POINT || l.type() == Light.POINT_BRIGHT) { + points.add(l); + } + } + } + AtomicInteger done = new AtomicInteger(); + List<int[]> results = list.parallelStream() + .map(t -> { + int[] r = bakeTile(t, rig, L, points); + int d = done.incrementAndGet(); + if (progress != null && (d & 15) == 0) { + progress.accept(d / (double) list.size()); + } + return r; + }) + .toList(); + for (int i = 0; i < list.size(); i++) { + if (results.get(i) != null) { + out.put(list.get(i), results.get(i)); + } + } + return new LinkedHashMap<>(out); + } + + private int[] bakeTile(Tile t, Rig rig, double[] L, List<Light> points) { + TileModel m = t.model(); + if (m == null || m.vertexCount() == 0) { + return null; + } + int nv = m.vertexCount(); + int[] old = t.colors(); + int[] col = old.length >= nv ? old.clone() : new int[nv]; + Geometry.TileXform x = t.transform(); + double[] v = m.vertices(); + for (int i = 0; i < nv; i++) { + double[] p = x.point(v[6 * i], v[6 * i + 1], v[6 * i + 2]); + double[] n = x.normal(v[6 * i + 3], v[6 * i + 4], v[6 * i + 5]); + double ln = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + if (ln > 1e-9) { + n[0] /= ln; + n[1] /= ln; + n[2] /= ln; + } else { + n = new double[] {0, 1, 0}; + } + double[] c = shade(p, n, rig, L, points); + int keep = i < old.length ? old[i] : (i == 0 ? 0 : 0); + col[i] = EnvMath.pack( + (int) Math.round(c[0] * 128), (int) Math.round(c[1] * 128), (int) Math.round(c[2] * 128), keep); + } + return col; + } + + /** Light at one vertex (export space), 1.0 = texture colour. */ + double[] shade(double[] p, double[] n, Rig r, double[] L, List<Light> points) { + double[] o = {p[0] + n[0] * 0.03, p[1] + n[1] * 0.03, p[2] + n[2] * 0.03}; + double ndl = Math.max(0, n[0] * L[0] + n[1] * L[1] + n[2] * L[2]); + double shadow = 1; + if (ndl > 0 && r.shadow() < 1 && hit(o, L, 60)) { + shadow = 0; + } + double ao = 1; + if (r.ao() > 0 && r.aoRadius() > 0) { + double occ = 0, sum = 0; + for (double[] h : HEMI) { + double w = Math.max(0, n[0] * h[0] + n[1] * h[1] + n[2] * h[2]); + sum += w; + if (w > 0.05 && hit(o, h, r.aoRadius())) { + occ += w; + } + } + ao = 1 - r.ao() * occ / Math.max(sum, 1e-6); + } + double hemi = n[1] * 0.5 + 0.5; + double sh = 1 - (1 - shadow) * (1 - r.shadow()); + double f = r.strength() * ndl * sh; + double g = 0.6 + 0.4 * ao; + double[] c = new double[3]; + for (int k = 0; k < 3; k++) { + double amb = r.ground()[k] + (r.sky()[k] - r.ground()[k]) * hemi; + c[k] = amb * ao + r.sun()[k] * f * g; + } + for (Light l : points) { + // light positions are game space (z = grid row) → export space + double lx = l.x(), ly = l.y(), lz = -l.z(); + double dx = lx - p[0], dy = ly - p[1], dz = lz - p[2]; + double d2 = dx * dx + dy * dy + dz * dz; + double b = l.rangeB(); + if (d2 > b * b || d2 < 1e-12) { + continue; + } + double d = Math.sqrt(d2); + double cos = (dx * n[0] + dy * n[1] + dz * n[2]) / d; + if (cos <= 0) { + continue; + } + double fl = cos; + if (d > l.a() && b > l.a()) { + fl *= (b - d) / (b - l.a()); + } + if (hit(o, new double[] {dx / d, dy / d, dz / d}, d - 0.05)) { + continue; + } + double[] lc = EnvMath.lightFactor(l); + for (int k = 0; k < 3; k++) { + c[k] += fl * lc[k] * 0.5; + } + } + return c; + } + + /** Whether a ray from o along unit d hits a triangle within tmax (2D DDA over the xz grid). */ + boolean hit(double[] o, double[] d, double tmax) { + if (nTri == 0) { + return false; + } + int[] stamp = stamps.get(); + int[] sid = stampId.get(); + int ray = ++sid[0]; + if (ray == Integer.MAX_VALUE) { + java.util.Arrays.fill(stamp, 0); + sid[0] = ray = 1; + } + double ox = o[0] - x0, oz = o[2] - z0; + int cx = (int) Math.floor(ox), cz = (int) Math.floor(oz); + int sx = d[0] > 0 ? 1 : -1, sz = d[2] > 0 ? 1 : -1; + double tdx = Math.abs(d[0]) < 1e-12 ? Double.POSITIVE_INFINITY : Math.abs(1 / d[0]); + double tdz = Math.abs(d[2]) < 1e-12 ? Double.POSITIVE_INFINITY : Math.abs(1 / d[2]); + double tmx = Math.abs(d[0]) < 1e-12 ? Double.POSITIVE_INFINITY : ((sx > 0 ? cx + 1 - ox : ox - cx) * tdx); + double tmz = Math.abs(d[2]) < 1e-12 ? Double.POSITIVE_INFINITY : ((sz > 0 ? cz + 1 - oz : oz - cz) * tdz); + double t0 = 0; + while (t0 <= tmax) { + if (cx >= 0 && cz >= 0 && cx < gw && cz < gh) { + int c = cx + cz * gw; + double yEntry = o[1] + d[1] * t0; + if (!(d[1] > 0 && yEntry > cellTop[c])) { + for (int i : cells[c]) { + if (stamp[i] == ray) { + continue; + } + stamp[i] = ray; + if (intersect(i, o, d, tmax)) { + return true; + } + } + } + } else if ((cx < 0 && sx < 0) || (cz < 0 && sz < 0) || (cx >= gw && sx > 0) || (cz >= gh && sz > 0)) { + return false; + } + if (tmx < tmz) { + t0 = tmx; + tmx += tdx; + cx += sx; + } else { + t0 = tmz; + tmz += tdz; + cz += sz; + } + if (Double.isInfinite(t0)) { + return false; + } + } + return false; + } + + private boolean intersect(int i, double[] o, double[] d, double tmax) { + int k = 9 * i; + double ax = tri[k], ay = tri[k + 1], az = tri[k + 2]; + double e1x = tri[k + 3] - ax, e1y = tri[k + 4] - ay, e1z = tri[k + 5] - az; + double e2x = tri[k + 6] - ax, e2y = tri[k + 7] - ay, e2z = tri[k + 8] - az; + double px = d[1] * e2z - d[2] * e2y, py = d[2] * e2x - d[0] * e2z, pz = d[0] * e2y - d[1] * e2x; + double det = e1x * px + e1y * py + e1z * pz; + if (Math.abs(det) < 1e-12) { + return false; + } + double inv = 1 / det; + double tx = o[0] - ax, ty = o[1] - ay, tz = o[2] - az; + double u = (tx * px + ty * py + tz * pz) * inv; + if (u < 0 || u > 1) { + return false; + } + double qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x; + double v = (d[0] * qx + d[1] * qy + d[2] * qz) * inv; + if (v < 0 || u + v > 1) { + return false; + } + double t = (e2x * qx + e2y * qy + e2z * qz) * inv; + return t > 1e-4 && t < tmax; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VertexPaintTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VertexPaintTool.java new file mode 100644 index 0000000..9c7062c --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VertexPaintTool.java @@ -0,0 +1,381 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Tool; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import com.eliasmueller.llamamod.format.Geometry; +import java.awt.Color; +import java.awt.Cursor; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JSpinner; +import javax.swing.KeyStroke; +import org.joml.Vector3f; + +/** + * Vertex colour brush for tiles: paints / lightens / darkens / smooths the first colour frame of the + * tile vertices within the radius (3D distance), one undo step per stroke. Ctrl+click picks the + * colour under the cursor. + */ +final class VertexPaintTool implements Tool { + enum Mode { + PAINT("Paint"), + LIGHTEN("Lighten"), + DARKEN("Darken"), + SMOOTH("Smooth"); + + final String label; + + Mode(String l) { + label = l; + } + + @Override + public String toString() { + return label; + } + } + + enum Falloff { + SMOOTH("Smooth"), + LINEAR("Linear"), + CONSTANT("Constant"); + + final String label; + + Falloff(String l) { + label = l; + } + + @Override + public String toString() { + return label; + } + + double weight(double u) { + if (u >= 1) { + return 0; + } + return switch (this) { + case SMOOTH -> { + double t = 1 - u; + yield t * t * (3 - 2 * t); + } + case LINEAR -> 1 - u; + case CONSTANT -> 1; + }; + } + } + + Color color = new Color(255, 200, 140); + double brightness = 1.0; + double radius = 1.5; + double strength = 0.5; + Mode mode = Mode.PAINT; + Falloff falloff = Falloff.SMOOTH; + boolean selectedOnly; + private Object stroke; + private Vector3f last; + private Vector3f hover; + private List<Tile>[] index; + private int iw; + private int ih; + private Ui.ColorButton colorButton; + + @Override + public String id() { + return "env.vpaint"; + } + + @Override + public String name() { + return "Paint vertex colours"; + } + + @Override + public String icon() { + return "paint"; + } + + @Override + public String tooltip() { + return "Vertex colour brush for tiles (Ctrl+click picks a colour)"; + } + + @Override + public KeyStroke shortcut() { + return KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_V, 0); + } + + @Override + public String group() { + return "env"; + } + + @Override + public int order() { + return 320; + } + + @Override + public void deactivate(EditorContext ctx) { + hover = null; + index = null; + } + + @Override + public boolean mousePressed(ToolEvent e) { + if (e.ctx().scene() == null) { + return false; + } + Vector3f p = e.surface(); + if (p == null) { + return true; + } + if (e.ctrl()) { + pick(e.ctx(), p); + return true; + } + buildIndex(e.ctx().scene()); + stroke = new Object(); + last = null; + dab(e.ctx(), p); + return true; + } + + @Override + public void mouseDragged(ToolEvent e) { + if (stroke == null) { + return; + } + Vector3f p = e.surface(); + hover = p; + if (p != null && (last == null || p.distance(last) >= Math.max(0.05, radius * 0.2))) { + dab(e.ctx(), p); + } + e.ctx().repaintTool(); + } + + @Override + public void mouseReleased(ToolEvent e) { + stroke = null; + last = null; + } + + @Override + public void mouseMoved(ToolEvent e) { + hover = e.ctx().scene() == null ? null : e.surface(); + e.ctx().repaintTool(); + } + + @SuppressWarnings("unchecked") + private void buildIndex(Scene s) { + iw = Math.max(1, s.settings().width()); + ih = Math.max(1, s.settings().height()); + index = new List[iw * ih]; + for (Tile t : s.tiles()) { + int cx = Math.max(0, Math.min(iw - 1, t.cellX())), cz = Math.max(0, Math.min(ih - 1, t.cellZ())); + int i = cx + cz * iw; + if (index[i] == null) { + index[i] = new ArrayList<>(2); + } + index[i].add(t); + } + } + + /** Tiles whose cell is within r + 3 cells of p (export space). */ + private List<Tile> near(Vector3f p, double r) { + List<Tile> out = new ArrayList<>(); + int reach = (int) Math.ceil(r) + 3; + int cx = (int) Math.floor(p.x), cz = (int) Math.floor(-p.z); + for (int z = Math.max(0, cz - reach); z <= Math.min(ih - 1, cz + reach); z++) { + for (int x = Math.max(0, cx - reach); x <= Math.min(iw - 1, cx + reach); x++) { + List<Tile> l = index[x + z * iw]; + if (l != null) { + out.addAll(l); + } + } + } + return out; + } + + private void dab(EditorContext ctx, Vector3f p) { + last = new Vector3f(p); + Set<Node> sel = selectedOnly ? new HashSet<>(ctx.selection().nodes()) : null; + Map<Tile, int[]> changes = new LinkedHashMap<>(); + int tr = (int) Math.round(color.getRed() * brightness * 128 / 255.0); + int tg = (int) Math.round(color.getGreen() * brightness * 128 / 255.0); + int tb = (int) Math.round(color.getBlue() * brightness * 128 / 255.0); + List<double[]> smoothSamples = new ArrayList<>(); + Map<Tile, double[][]> world = new HashMap<>(); + for (Tile t : near(p, radius)) { + if (sel != null && !sel.contains(t) || ctx.isHidden(t)) { + continue; + } + TileModel m = t.model(); + if (m == null || m.vertexCount() == 0 || t.colors().length < m.vertexCount()) { + continue; + } + double[][] w = worldVerts(t); + world.put(t, w); + if (mode == Mode.SMOOTH) { + for (int i = 0; i < w.length; i++) { + if (dist(w[i], p) < radius) { + int[] c = EnvMath.unpack(t.colors()[i]); + smoothSamples.add(new double[] {c[0], c[1], c[2]}); + } + } + } + } + double[] avg = {0, 0, 0}; + if (mode == Mode.SMOOTH) { + if (smoothSamples.isEmpty()) { + return; + } + for (double[] c : smoothSamples) { + for (int k = 0; k < 3; k++) { + avg[k] += c[k] / smoothSamples.size(); + } + } + } + for (Map.Entry<Tile, double[][]> en : world.entrySet()) { + Tile t = en.getKey(); + double[][] w = en.getValue(); + int[] col = null; + for (int i = 0; i < w.length; i++) { + double d = dist(w[i], p); + double f = strength * falloff.weight(d / radius); + if (f <= 0) { + continue; + } + if (col == null) { + col = t.colors().clone(); + } + int[] c = EnvMath.unpack(col[i]); + double[] to = + switch (mode) { + case PAINT -> new double[] {tr, tg, tb}; + case LIGHTEN -> new double[] {c[0] + 32, c[1] + 32, c[2] + 32}; + case DARKEN -> new double[] {c[0] * 0.75, c[1] * 0.75, c[2] * 0.75}; + case SMOOTH -> avg; + }; + col[i] = EnvMath.pack( + (int) Math.round(c[0] + (to[0] - c[0]) * f), + (int) Math.round(c[1] + (to[1] - c[1]) * f), + (int) Math.round(c[2] + (to[2] - c[2]) * f), + col[i]); + } + if (col != null) { + changes.put(t, col); + } + } + if (!changes.isEmpty()) { + ctx.execute(new PropMapCommand("Paint vertex colours", "colors", changes, stroke)); + } + } + + private static double dist(double[] a, Vector3f p) { + double dx = a[0] - p.x, dy = a[1] - p.y, dz = a[2] - p.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + + static double[][] worldVerts(Tile t) { + TileModel m = t.model(); + Geometry.TileXform x = t.transform(); + double[] v = m.vertices(); + double[][] w = new double[m.vertexCount()][]; + for (int i = 0; i < w.length; i++) { + w[i] = x.point(v[6 * i], v[6 * i + 1], v[6 * i + 2]); + } + return w; + } + + /** Eyedropper: colour of the nearest tile vertex. */ + private void pick(EditorContext ctx, Vector3f p) { + buildIndex(ctx.scene()); + double best = Double.MAX_VALUE; + int found = -1; + for (Tile t : near(p, 1)) { + if (t.model() == null || t.colors().length < t.model().vertexCount()) { + continue; + } + double[][] w = worldVerts(t); + for (int i = 0; i < w.length; i++) { + double d = dist(w[i], p); + if (d < best) { + best = d; + found = t.colors()[i]; + } + } + } + if (found != -1) { + int[] c = EnvMath.unpack(found); + int m = Math.max(1, Math.max(c[0], Math.max(c[1], c[2]))); + brightness = m / 128.0; + color = new Color(c[0] * 255 / m, c[1] * 255 / m, c[2] * 255 / m); + if (colorButton != null) { + colorButton.refresh(); + } + ctx.status(String.format( + Locale.ROOT, "Picked colour %d / %d / %d (brightness %.2f)", c[0], c[1], c[2], brightness)); + } + } + + @Override + public void drawOverlay(Overlay depthTested, Overlay onTop) { + if (hover == null) { + return; + } + double[] c = {hover.x, hover.y + 0.03, hover.z}; + int col = EnvOverlays.rgba(mode == Mode.PAINT ? color : Color.WHITE, 230); + EnvOverlays.ring(onTop, c, radius, col); + EnvOverlays.ring(onTop, c, radius * 0.5, EnvOverlays.rgba(Color.WHITE, 110)); + onTop.line(c, new double[] {c[0], c[1] + 0.4, c[2]}, col); + } + + @Override + public JComponent optionsBar(EditorContext ctx) { + JComboBox<Mode> m = new JComboBox<>(Mode.values()); + m.setSelectedItem(mode); + m.addActionListener(a -> mode = (Mode) m.getSelectedItem()); + colorButton = new Ui.ColorButton("Paint colour", () -> color, (c, k) -> color = c); + colorButton.compact = true; + colorButton.refresh(); + JSpinner br = Ui.small( + Ui.number(brightness, 0, 2, 0.05, "0.00", v -> brightness = v), + "Brightness: 1.0 = texture colour, 2.0 = maximum (the engine doubles vertex colours)"); + JSpinner r = Ui.small(Ui.number(radius, 0.1, 16, 0.25, "0.00", v -> radius = v), "Brush radius (cells)"); + JSpinner s = Ui.small(Ui.number(strength, 0.01, 1, 0.05, "0.00", v -> strength = v), "Strength per dab"); + JComboBox<Falloff> f = new JComboBox<>(Falloff.values()); + f.setSelectedItem(falloff); + f.addActionListener(a -> falloff = (Falloff) f.getSelectedItem()); + f.setToolTipText("Falloff towards the brush edge"); + JCheckBox so = new JCheckBox("Selection only", selectedOnly); + so.setToolTipText("Only paint the selected tiles"); + so.addActionListener(a -> selectedOnly = so.isSelected()); + for (JComponent c : new JComponent[] {m, f, so, colorButton}) { + c.setFocusable(false); + } + return Ui.line(m, colorButton, new JLabel("Bright"), br, new JLabel("Radius"), r, new JLabel("Str."), s, f, so); + } + + @Override + public Cursor cursor() { + return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VisibilityPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VisibilityPanel.java new file mode 100644 index 0000000..82f7605 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/VisibilityPanel.java @@ -0,0 +1,465 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import com.eliasmueller.llamamod.editor.model.Regions; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Widgets; +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.swing.BorderFactory; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.UIManager; + +/** + * Bottom dock panel: the 32 × 32 region visibility matrix (row = camera region, column = region + * drawn), region statistics, presets, auto-generation and the "preview from region" mode. + */ +final class VisibilityPanel extends JPanel { + private final EditorContext ctx; + private final EnvSession env; + private final Matrix matrix = new Matrix(); + private final JLabel stats = new JLabel(" "); + private final JLabel rowInfo = new JLabel(" "); + private final JCheckBox symmetric = new JCheckBox("Symmetric", true); + private final JComboBox<String> preview = new JComboBox<>(); + private int current = 0; + private long[] visible = new long[Regions.COUNT]; + private long[] own = new long[Regions.COUNT]; + private int[] cellCount = new int[Regions.COUNT]; + private final Timer refresh; + private boolean syncing; + + VisibilityPanel(EditorContext ctx) { + super(new BorderLayout()); + this.ctx = ctx; + this.env = EnvSession.of(ctx); + JPanel top = new JPanel(new BorderLayout()); + top.add(matrix, BorderLayout.WEST); + top.setBorder(BorderFactory.createEmptyBorder(6, 4, 2, 4)); + add(top, BorderLayout.NORTH); + add(controls(), BorderLayout.CENTER); + refresh = new Timer(80, e -> recompute()); + refresh.setRepeats(false); + ctx.addDocumentListener(d -> refresh.restart()); + ctx.addSceneListener(e -> { + if (e.node() instanceof Regions || e.type() != SceneEvent.Type.CHANGED) { + refresh.restart(); + } + }); + env.addListener(this::syncPreview); + recompute(); + } + + private JComponent controls() { + Ui.Form f = new Ui.Form(); + f.header("Preview"); + preview.addItem("Off (draw everything)"); + preview.addItem("Follow the camera"); + for (int r = 0; r < Regions.COUNT; r++) { + preview.addItem("From region " + r); + } + preview.addActionListener(a -> { + if (!syncing) { + int i = preview.getSelectedIndex(); + env.setRegionPreview(i == 0 ? -1 : i == 1 ? -2 : i - 2); + } + }); + f.row("Show", preview, "Hide what the game would not draw for a camera in that region (tiles and objects)"); + f.full(rowInfo); + f.header("Masks"); + f.full(symmetric); + f.full(Ui.line( + Widgets.button("All visible", null, "Every region sees every region", () -> setMasks(r -> -1)), + Widgets.button("Self only", null, "Every region sees only itself", () -> setMasks(r -> 1 << r)))); + f.full(Ui.line( + Widgets.button("Make symmetric", null, "If A sees B, B sees A", this::makeSymmetric), + Widgets.button( + "Auto-generate…", + "region", + "Compute regions and masks from the level layout", + this::generate))); + f.full(Ui.line(Widgets.button( + "Paint regions", "region", "Region brush in the viewport", () -> ctx.setTool("env.regions")))); + f.header("Statistics"); + f.full(stats); + f.note("Click a cell to toggle, drag to paint. Row = region the camera is in, column = region that is" + + " drawn. A row without any bit means \"everything\" (engine rule). Click a row label to pick it."); + f.end(); + JScrollPane sp = new JScrollPane(f); + sp.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, UIManager.getColor("Separator.foreground"))); + sp.getVerticalScrollBar().setUnitIncrement(16); + return sp; + } + + private void syncPreview() { + syncing = true; + try { + int r = env.regionPreview; + preview.setSelectedIndex(r == -1 ? 0 : r == -2 ? 1 : r + 2); + } finally { + syncing = false; + } + matrix.repaint(); + updateRow(); + } + + void recompute() { + Scene s = ctx.scene(); + cellCount = new int[Regions.COUNT]; + if (s == null || s.regions().grid().length == 0) { + stats.setText("No level"); + matrix.repaint(); + return; + } + for (int g : s.regions().grid()) { + cellCount[g & 31]++; + } + own = RegionGen.trianglesPerRegion(s, s.regions().grid()); + visible = RegionGen.visibleTriangles(s, s.regions().grid(), s.regions().table()); + long max = 0, sum = 0; + int used = 0, worst = 0; + for (int r = 0; r < Regions.COUNT; r++) { + if (cellCount[r] > 0) { + used++; + sum += visible[r]; + if (visible[r] > max) { + max = visible[r]; + worst = r; + } + } + } + String warn = max > RegionGen.HEAVY + ? "<br><font color='#d9603f'>⚠ region " + worst + " draws " + max + + " triangles – slow on old PCs; split the level into more regions</font>" + : ""; + stats.setText(String.format( + Locale.ROOT, + "<html>%d regions in use · triangles drawn per camera region: max %,d (region %d), mean %,d%s</html>", + used, + max, + worst, + used == 0 ? 0 : sum / used, + warn)); + matrix.revalidate(); + matrix.repaint(); + updateRow(); + } + + private void updateRow() { + Scene s = ctx.scene(); + if (s == null) { + return; + } + int m = s.regions().mask(current); + rowInfo.setText(String.format( + Locale.ROOT, + "<html>Region <b>%d</b>: %d cells, %,d own triangles; camera here draws %,d (%s)</html>", + current, + cellCount[current], + own[current], + visible[current], + m == 0 ? "mask 0 = everything" : Integer.bitCount(m) + " regions")); + } + + // ------------------------------------------------------------ edits + + private void setTable(int[] table, Object key) { + Regions rg = ctx.scene().regions(); + ctx.execute(SetProp.of(rg, rg.prop("table"), table, key)); + } + + /** Sets every region's mask to mask(region). */ + private void setMasks(java.util.function.IntUnaryOperator mask) { + if (ctx.scene() == null) { + return; + } + int[] t = ctx.scene().regions().table().clone(); + for (int r = 0; r < Regions.COUNT; r++) { + t[2 * r] = mask.applyAsInt(r); + } + setTable(t, null); + } + + private void makeSymmetric() { + if (ctx.scene() == null) { + return; + } + int[] t = ctx.scene().regions().table().clone(); + for (int a = 0; a < Regions.COUNT; a++) { + for (int b = 0; b < Regions.COUNT; b++) { + if (bit(t, a, b)) { + t[2 * b] |= 1 << a; + } + } + } + setTable(t, null); + } + + /** Effective visibility (mask 0 = all). */ + static boolean bit(int[] t, int row, int col) { + int m = t[2 * row]; + return m == 0 || (m & (1 << col)) != 0; + } + + private void generate() { + Scene s = ctx.scene(); + if (s == null) { + return; + } + JComboBox<RegionGen.Mode> mode = new JComboBox<>(RegionGen.Mode.values()); + mode.setSelectedItem(RegionGen.Mode.CONNECTED); + JSpinner dist = Ui.number(12, 0, 200, 1, "0", v -> {}); + Ui.Form f = new Ui.Form(); + f.row("Layout", mode); + f.row("Visible within (cells)", dist, "Regions closer than this see each other (try the fog end distance)"); + f.note("Replaces the region of every cell and all masks (one undo step). More regions = less to draw," + + " but areas can pop in when the camera crosses a border; check with the preview."); + int ok = JOptionPane.showConfirmDialog( + ctx.frame(), + f, + "Auto-generate visibility regions", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.PLAIN_MESSAGE); + if (ok != JOptionPane.OK_OPTION) { + return; + } + RegionGen.Layout l = RegionGen.generate( + s, (RegionGen.Mode) mode.getSelectedItem(), ((Number) dist.getValue()).doubleValue()); + long[] vis = RegionGen.visibleTriangles(s, l.grid(), l.table()); + long max = 0; + for (long v : vis) { + max = Math.max(max, v); + } + Regions rg = s.regions(); + List<Command> c = new ArrayList<>(); + c.add(SetProp.of(rg, "grid", l.grid())); + c.add(SetProp.of(rg, "table", l.table())); + ctx.execute(new Batch("Auto-generate regions", c)); + String msg = String.format(Locale.ROOT, "Regions generated: at most %,d triangles drawn from one region", max); + if (max > RegionGen.HEAVY) { + JOptionPane.showMessageDialog( + ctx.frame(), + msg + ".\nThat is a lot for the original engine (the original levels stay below ~" + + String.format(Locale.ROOT, "%,d", RegionGen.HEAVY) + + ").\nUse a smaller distance or more, smaller areas.", + "Visibility regions", + JOptionPane.WARNING_MESSAGE); + } + ctx.status(msg); + } + + // ------------------------------------------------------------ matrix component + + private final class Matrix extends JComponent { + static final int CELL = 9; + static final int HEAD = 30; + private Object dragKey; + private boolean dragValue; + private int hoverRow = -1; + private int hoverCol = -1; + + Matrix() { + setPreferredSize(new Dimension(HEAD + CELL * Regions.COUNT + 8, HEAD + CELL * Regions.COUNT + 8)); + MouseAdapter m = new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + int r = row(e.getY()), c = col(e.getX()); + if (ctx.scene() == null) { + return; + } + if (r >= 0 && c < 0 && e.getX() < HEAD) { + current = r; + updateRow(); + repaint(); + return; + } + if (r < 0 || c < 0) { + return; + } + current = r; + dragKey = new Object(); + dragValue = !bit(ctx.scene().regions().table(), r, c); + toggle(r, c); + } + + @Override + public void mouseDragged(MouseEvent e) { + int r = row(e.getY()), c = col(e.getX()); + if (dragKey != null + && r >= 0 + && c >= 0 + && bit(ctx.scene().regions().table(), r, c) != dragValue) { + toggle(r, c); + } + } + + @Override + public void mouseReleased(MouseEvent e) { + dragKey = null; + } + + @Override + public void mouseMoved(MouseEvent e) { + hoverRow = row(e.getY()); + hoverCol = col(e.getX()); + setToolTipText( + hoverRow >= 0 && hoverCol >= 0 + ? "Camera in region " + hoverRow + + (bit(table(), hoverRow, hoverCol) ? " draws" : " hides") + " region " + + hoverCol + : hoverRow >= 0 + ? "Region " + hoverRow + ": " + cellCount[hoverRow] + " cells" + : null); + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) { + hoverRow = hoverCol = -1; + repaint(); + } + }; + addMouseListener(m); + addMouseMotionListener(m); + } + + private int[] table() { + Scene s = ctx.scene(); + return s == null || s.regions().table().length < 2 * Regions.COUNT + ? new int[2 * Regions.COUNT] + : s.regions().table(); + } + + private int row(int y) { + int r = (y - HEAD) / CELL; + return y < HEAD || r >= Regions.COUNT ? -1 : r; + } + + private int col(int x) { + int c = (x - HEAD) / CELL; + return x < HEAD || c >= Regions.COUNT ? -1 : c; + } + + private void toggle(int r, int c) { + int[] t = table().clone(); + boolean on = dragValue; + setBit(t, r, c, on); + if (symmetric.isSelected() && r != c) { + setBit(t, c, r, on); + } + setTable(t, dragKey); + SwingUtilities.invokeLater(this::repaint); + } + + private static void setBit(int[] t, int r, int c, boolean on) { + int m = t[2 * r]; + if (m == 0) { + m = -1; + } + m = on ? m | (1 << c) : m & ~(1 << c); + t[2 * r] = m == -1 ? -1 : m; + } + + @Override + protected void paintComponent(Graphics g0) { + Graphics2D g = (Graphics2D) g0.create(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + Color fg = UIManager.getColor("Label.foreground"); + Color bg = UIManager.getColor("Panel.background"); + g.setColor(bg); + g.fillRect(0, 0, getWidth(), getHeight()); + int[] t = table(); + g.setFont(getFont().deriveFont(8f)); + int active = env.regionPreviewActive; + for (int i = 0; i < Regions.COUNT; i++) { + boolean used = cellCount[i] > 0; + Color rc = EnvMath.regionColor(i); + // column header + int x = HEAD + i * CELL; + g.setColor(used ? rc : blend(rc, bg, 0.75f)); + g.fillRect(x + 1, HEAD - 12, CELL - 2, 10); + g.setColor(used ? fg : blend(fg, bg, 0.6f)); + String s = Integer.toString(i); + if (i % 4 == 0) { + g.drawString(s, x + (CELL - g.getFontMetrics().stringWidth(s)) / 2, HEAD - 15); + } + // row header + int y = HEAD + i * CELL; + g.setColor(used ? rc : blend(rc, bg, 0.75f)); + g.fillRect(HEAD - 12, y + 1, 10, CELL - 2); + g.setColor(i == current ? Color.decode("#C85A1E") : used ? fg : blend(fg, bg, 0.6f)); + g.setFont(getFont().deriveFont(i == current ? Font.BOLD : Font.PLAIN, 8f)); + g.drawString(s, HEAD - 14 - g.getFontMetrics().stringWidth(s), y + CELL - 1); + g.setFont(getFont().deriveFont(8f)); + } + for (int r = 0; r < Regions.COUNT; r++) { + boolean all = t[2 * r] == 0; + for (int c = 0; c < Regions.COUNT; c++) { + int x = HEAD + c * CELL, y = HEAD + r * CELL; + boolean on = bit(t, r, c); + boolean used = cellCount[r] > 0 && cellCount[c] > 0; + Color cell = on ? (all ? blend(fg, bg, 0.55f) : EnvMath.regionColor(c)) : blend(fg, bg, 0.93f); + if (!used) { + cell = blend(cell, bg, 0.6f); + } + g.setColor(cell); + g.fillRect(x + 1, y + 1, CELL - 2, CELL - 2); + if (r == c) { + g.setColor(blend(fg, bg, 0.3f)); + g.drawRect(x + 1, y + 1, CELL - 3, CELL - 3); + } + } + } + if (hoverRow >= 0) { + g.setColor(new Color(255, 255, 255, 40)); + g.fillRect(HEAD, HEAD + hoverRow * CELL, CELL * Regions.COUNT, CELL); + } + if (hoverCol >= 0) { + g.setColor(new Color(255, 255, 255, 40)); + g.fillRect(HEAD + hoverCol * CELL, HEAD, CELL, CELL * Regions.COUNT); + } + g.setStroke(new BasicStroke(2f)); + g.setColor(Color.decode("#C85A1E")); + g.drawRect(HEAD, HEAD + current * CELL, CELL * Regions.COUNT, CELL); + if (active >= 0) { + g.setColor(Color.decode("#3C82E6")); + g.drawRect(HEAD - 13, HEAD + active * CELL, CELL * Regions.COUNT + 13, CELL); + } + g.setColor(blend(fg, bg, 0.4f)); + g.setFont(getFont().deriveFont(9f)); + g.drawString("cam↓ draw→", 1, 9); + g.dispose(); + } + } + + static Color blend(Color a, Color b, float t) { + return new Color( + Math.round(a.getRed() * (1 - t) + b.getRed() * t), + Math.round(a.getGreen() * (1 - t) + b.getGreen() * t), + Math.round(a.getBlue() * (1 - t) + b.getBlue() * t)); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ZoneSection.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ZoneSection.java new file mode 100644 index 0000000..f07cb2a --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ZoneSection.java @@ -0,0 +1,201 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import static com.eliasmueller.llamamod.editor.tools.environment.EnvMath.*; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.InspectorSection; +import com.eliasmueller.llamamod.editor.ui.Widgets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.TreeSet; +import javax.swing.JComboBox; +import javax.swing.JComponent; + +/** Inspector content for an environment / teleport zone: trigger, teleport target, fog, colours, music. */ +final class ZoneSection implements InspectorSection { + @Override + public String title() { + return "Environment zone"; + } + + @Override + public int order() { + return 20; + } + + @Override + public boolean appliesTo(List<Node> selection) { + return selection.size() == 1 && selection.getFirst() instanceof EnvZone; + } + + @Override + public JComponent create(EditorContext ctx, List<Node> selection) { + EnvZone z = (EnvZone) selection.getFirst(); + return build(ctx, z); + } + + static JComponent build(EditorContext ctx, EnvZone z) { + Binder b = new Binder(ctx, z); + Ui.Form f = new Ui.Form(); + b.attach(f); + f.full(b.label(() -> describe(z))); + f.full(Ui.line( + Widgets.button( + "Frame", "focus", "Show the trigger cell and the target in the viewport", ctx::focusSelection), + Widgets.button( + "Make start zone", + "flag", + "The level starts with this zone's fog, colours and music", + () -> makeStart(ctx, z)), + Widgets.button( + "Duplicate", "duplicate", "Copy of this zone one cell east", () -> ctx.run("edit.duplicate")))); + + boolean start = isStart(z); + f.header("Trigger"); + if (start) { + f.note("The start zone is active from the beginning of the level; its trigger cell is not used" + + " (the originals put it outside the playfield at 3, y, 0)."); + } else { + f.note( + "Entered through an exit floor (collision material 16) in this cell: the game looks up the" + + " zone of the player's cell, moves the player to the target and switches fog, colours and music."); + } + f.row("Cell X", b.number(-1, 4096, 1, "0", n -> z.cellX(), v -> (int) v, "cellX")); + f.row("Cell Z (row)", b.number(-1, 4096, 1, "0", n -> z.cellZ(), v -> (int) v, "cellZ")); + f.row( + "Cell Y (height)", + b.number(-64, 256, 1, "0", n -> z.cellY(), v -> (int) v, "cellY"), + "Matches if the player's cell height is at most this value (the lowest matching zone of the cell wins)"); + String[] yModes = {"player y ≤ cell Y", "player y ≤ cell Y − 1", "player y ≤ cell Y + 1 (no step)"}; + f.row( + "Height test", + b.combo( + yModes, + n -> (z.field(F_ANGLE) & ANGLE_Y_BELOW) != 0 + ? 1 + : (z.field(F_ANGLE) & ANGLE_Y_ABOVE) != 0 ? 2 : 0, + i -> b.set( + "angleFlags", + (z.field(F_ANGLE) & ~3) | (i == 1 ? ANGLE_Y_BELOW : i == 2 ? ANGLE_Y_ABOVE : 0), + null)), + "angle field bits 0/1 (FUN_0054d560)"); + + f.header("Teleport target"); + f.row( + "", + b.check( + "Move the player here", + n -> (z.field(F_FLAGS) & FLAG_NO_TELEPORT) == 0, + on -> b.set( + "flags", + on ? z.field(F_FLAGS) & ~FLAG_NO_TELEPORT : z.field(F_FLAGS) | FLAG_NO_TELEPORT, + null)), + "Off (flag 4): only fog, colours and music change"); + f.row("Target X", b.number(-64, 4096, 0.25, "0.00", n -> z.targetX(), v -> fixed(v), "x")); + f.row("Target Y", b.number(-64, 256, 0.25, "0.00", n -> z.targetY(), v -> fixed(v), "y")); + f.row("Target Z", b.number(-64, 4096, 0.25, "0.00", n -> z.targetZ(), v -> fixed(v), "z")); + f.row( + "Facing (°)", + b.number( + 0, 360, 22.5, "0.#", n -> facingDegrees(z), v -> withFacing(z.field(F_ANGLE), v), "angleFlags"), + "0 = +z (down the grid), 90 = +x (east)"); + f.row( + "Step forward", + b.number(-8, 8, 0.25, "0.00", n -> (short) z.field(F_PUSH) / 4096.0, v -> fixed(v) & 0xffff, "unk14"), + "Cells the player is moved along the facing after the teleport (u16 at +0x38; not with height test 'Y + 1')"); + + f.header("Fog & draw distance"); + f.row( + "Mode", + b.combo(FOG_MODES, n -> Math.max(0, Math.min(3, z.fogMode())), i -> b.set("fogMode", i, null)), + "zone +0x34 (DAT_005865cc)"); + Ui.FogBar bar = new Ui.FogBar(); + b.live(() -> { + double[] r = fog(z, EnvSession.of(ctx).detail); + bar.start = r[0]; + bar.end = r[1]; + bar.max = Math.max(20, Math.ceil(fog(z, 1)[1] / 5 + 1) * 5); + bar.color = z.fogMode() == 1 ? java.awt.Color.BLACK : zoneColor(z.fogColor()); + bar.repaint(); + }); + f.full(bar); + f.full(b.label(() -> String.format( + Locale.ROOT, + "<html>Draw distance (cells): Min %.1f · Medium %.1f · Max <b>%.1f</b></html>", + fog(z, DETAIL_MIN)[1], + fog(z, DETAIL_MED)[1], + fog(z, DETAIL_MAX)[1]))); + f.row("Start (Min)", b.number(0, 200, 0.25, "0.00", n -> z.fogNearLow(), v -> fixed(v), "fogNearLow")); + f.row("End (Min)", b.number(0, 200, 0.25, "0.00", n -> z.fogFarLow(), v -> fixed(v), "fogFarLow")); + f.row( + "Start (Max)", + b.number(0, 200, 0.25, "0.00", n -> z.fogNearHigh(), v -> fixed(v), "fogNearHigh"), + "If not above the Minimum start, the game uses 1.5 × the Minimum pair"); + f.row("End (Max)", b.number(0, 200, 0.25, "0.00", n -> z.fogFarHigh(), v -> fixed(v), "fogFarHigh")); + f.row( + "Fog / sky colour", + b.color( + "Fog and backdrop colour", + n -> zoneColor(z.fogColor()), + (c, key) -> b.set("fogColor", zoneBgr(c), key)), + "Backdrop clear colour and fog colour (zone +0x50)"); + f.row( + "Ambient (objects)", + b.color( + "Ambient light of objects", + n -> zoneColor(z.ambientColor()), + (c, key) -> b.set("ambientColor", zoneBgr(c), key)), + "Added to the light of all object models (zone +0x4c, FUN_0041bcf0); tiles use their vertex colours"); + + f.header("Music & sky"); + String[] music = new String[MUSIC.length]; + for (int i = 0; i < music.length; i++) { + music[i] = musicLabel(i); + } + JComboBox<String> mus = b.combo(music, n -> z.music(), i -> b.set("music", i, null)); + f.row("Music", mus, "Track started when the zone becomes active (music/<name>.ASF, 0 = stop)"); + f.row("Sky model", skyCombo(ctx, b, z), "STPC model drawn as the sky dome (zone +0x28, 0 = none)"); + f.row( + "Sky yaw", + b.number(-4096, 4096, 64, "0", n -> z.field(F_SKY_YAW), v -> (int) v, "skyYaw"), + "1/4096 turns"); + f.note("Raw fields (unknown values) are listed below in the generic properties."); + f.end(); + return f; + } + + private static JComponent skyCombo(EditorContext ctx, Binder b, EnvZone z) { + Scene s = ctx.scene(); + TreeSet<Integer> offs = new TreeSet<>(); + for (EnvZone o : s.zones()) { + offs.add(o.field(F_SKY)); + } + offs.add(0); + List<Integer> l = new ArrayList<>(offs); + String[] items = l.stream() + .map(o -> o == 0 ? "none" : String.format(Locale.ROOT, "model at STPC 0x%x", o)) + .toArray(String[]::new); + return b.combo(items, n -> l.indexOf(z.field(F_SKY)), i -> b.set("stpcOffset", l.get(i), null)); + } + + static void makeStart(EditorContext ctx, EnvZone z) { + List<Command> cmds = new ArrayList<>(); + for (EnvZone o : ctx.scene().zones()) { + int fl = o.field(F_FLAGS); + int want = o == z ? fl | FLAG_START : fl & ~FLAG_START; + if (want != fl) { + cmds.add(SetProp.of(o, "flags", want)); + } + } + if (!cmds.isEmpty()) { + ctx.execute(new Batch("Set start zone", cmds)); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ZoneTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ZoneTool.java new file mode 100644 index 0000000..81e8003 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/ZoneTool.java @@ -0,0 +1,259 @@ +package com.eliasmueller.llamamod.editor.tools.environment; + +import static com.eliasmueller.llamamod.editor.tools.environment.EnvMath.*; + +import com.eliasmueller.llamamod.editor.model.EnvZone; +import com.eliasmueller.llamamod.editor.model.Light; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Tool; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import java.awt.Cursor; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.List; +import javax.swing.ButtonGroup; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JToggleButton; +import javax.swing.KeyStroke; +import org.joml.Vector3f; + +/** + * Zone tool: click a cell to create a teleport zone (trigger cell), then click/drag where the player + * should arrive (Shift+drag turns the facing); with "Trigger" mode clicks move the trigger cell. + * [ and ] turn the facing by 22.5°. + */ +final class ZoneTool implements Tool { + enum Mode { + TARGET, + TRIGGER + } + + private Mode mode = Mode.TARGET; + private Object dragKey; + private EnvZone dragZone; + private Vector3f hover; + private JToggleButton bTarget; + private JToggleButton bTrigger; + + @Override + public String id() { + return "env.zones"; + } + + @Override + public String name() { + return "Zones"; + } + + @Override + public String icon() { + return "zone"; + } + + @Override + public String tooltip() { + return "Teleport / environment zones: click a cell to add a zone, click again for its target"; + } + + @Override + public String group() { + return "env"; + } + + @Override + public int order() { + return 300; + } + + @Override + public void activate(EditorContext ctx) { + ctx.setLayerVisible("zones", true); + ctx.setLayerVisible(EnvOverlays.LAYER_ZONES, true); + } + + private static EnvZone selected(EditorContext ctx) { + return ctx.selection().primary() instanceof EnvZone z && !isStart(z) ? z : null; + } + + @Override + public boolean mousePressed(ToolEvent e) { + EditorContext ctx = e.ctx(); + if (ctx.scene() == null) { + return false; + } + if (e.pick() instanceof EnvZone || e.pick() instanceof Light) { + return false; + } + Vector3f g = e.ground(); + if (g == null) { + return false; + } + EnvZone z = selected(ctx); + dragKey = new Object(); + if (z == null) { + EnvZone nz = create(ctx.scene(), (int) Math.floor(g.x), (int) Math.round(g.y), (int) Math.floor(-g.z)); + ctx.execute(new AddNode(nz)); + ctx.selection().set(nz, this); + setMode(Mode.TARGET); + ctx.status("New zone at cell " + nz.cellX() + ", " + nz.cellZ() + + " – now click where the player should arrive"); + dragZone = null; + return true; + } + dragZone = z; + apply(ctx, z, g, e.shift()); + return true; + } + + @Override + public void mouseDragged(ToolEvent e) { + if (dragZone == null || e.ctx().scene() == null || !e.ctx().scene().contains(dragZone)) { + return; + } + Vector3f g = e.ground(); + if (g != null) { + apply(e.ctx(), dragZone, g, e.shift()); + } + hover = g; + e.ctx().repaintTool(); + } + + @Override + public void mouseReleased(ToolEvent e) { + dragZone = null; + dragKey = null; + } + + @Override + public void mouseMoved(ToolEvent e) { + hover = e.ctx().scene() == null ? null : e.ground(); + e.ctx().repaintTool(); + } + + private void apply(EditorContext ctx, EnvZone z, Vector3f g, boolean facing) { + List<Command> c = new ArrayList<>(); + if (mode == Mode.TRIGGER) { + c.add(SetProp.of(z, z.prop("cellX"), (int) Math.floor(g.x), dragKey)); + c.add(SetProp.of(z, z.prop("cellY"), (int) Math.round(g.y), dragKey)); + c.add(SetProp.of(z, z.prop("cellZ"), (int) Math.floor(-g.z), dragKey)); + ctx.execute(new Batch("Move zone trigger", c, dragKey)); + } else if (facing) { + double dx = g.x - z.targetX(), dz = -g.z - z.targetZ(); + if (dx * dx + dz * dz > 0.01) { + double deg = Math.toDegrees(Math.atan2(dx, dz)); + deg = Math.round(deg / 22.5) * 22.5; + ctx.execute(SetProp.of(z, z.prop("angleFlags"), withFacing(z.field(F_ANGLE), deg), dragKey)); + } + } else { + int flags = z.field(F_FLAGS) & ~FLAG_NO_TELEPORT; + c.add(SetProp.of(z, z.prop("x"), fixed(g.x), dragKey)); + c.add(SetProp.of(z, z.prop("y"), fixed(g.y), dragKey)); + c.add(SetProp.of(z, z.prop("z"), fixed(-g.z), dragKey)); + c.add(SetProp.of(z, z.prop("flags"), flags, dragKey)); + ctx.execute(new Batch("Move zone target", c, dragKey)); + } + } + + @Override + public boolean keyPressed(ToolEvent e) { + KeyEvent k = e.key(); + EnvZone z = selected(e.ctx()); + if (k == null || z == null) { + return false; + } + double step = k.getKeyChar() == '[' ? -22.5 : k.getKeyChar() == ']' ? 22.5 : 0; + if (step == 0) { + return false; + } + e.ctx().execute(SetProp.of(z, "angleFlags", withFacing(z.field(F_ANGLE), facingDegrees(z) + step))); + return true; + } + + /** New teleport zone at a trigger cell, environment copied from the start zone. */ + static EnvZone create(Scene s, int cx, int cy, int cz) { + EnvZone z = (EnvZone) s.create(NodeKind.ENV_ZONE); + EnvZone st = startZone(s); + if (st != null) { + for (int i = 0; i < EnvZone.FIELDS.length; i++) { + z.setRaw(EnvZone.FIELDS[i], st.field(i)); + } + } else { + z.setRaw("fogMode", 3); + z.setRaw("fogNearLow", 5 * 4096); + z.setRaw("fogFarLow", 6 * 4096); + z.setRaw("fogNearHigh", 10 * 4096); + z.setRaw("fogFarHigh", 12 * 4096); + z.setRaw("ambientColor", 0x404040); + z.setRaw("fogColor", 0xe8c8a8); + } + z.setRaw("cellX", cx); + z.setRaw("cellY", cy); + z.setRaw("cellZ", cz); + z.setRaw("x", fixed(cx + 0.5)); + z.setRaw("y", cy * 4096); + z.setRaw("z", fixed(cz + 0.5)); + z.setRaw("flags", 0); + z.setRaw("angleFlags", 0); + z.setRaw("unk14", 0); + return z; + } + + @Override + public void drawOverlay(Overlay depthTested, Overlay onTop) { + if (hover == null) { + return; + } + int cx = (int) Math.floor(hover.x), cz = (int) Math.floor(-hover.z); + double y = Math.round(hover.y); + int col = EnvOverlays.rgba(mode == Mode.TRIGGER ? EnvOverlays.ZONE : EnvOverlays.TARGET, 230); + if (mode == Mode.TRIGGER) { + onTop.box(cx, y, -cz - 1, cx + 1, y + 1, -cz, col); + } else { + EnvOverlays.ring(onTop, new double[] {hover.x, hover.y + 0.05, hover.z}, 0.3, col); + } + } + + private void setMode(Mode m) { + mode = m; + if (bTarget != null) { + bTarget.setSelected(m == Mode.TARGET); + bTrigger.setSelected(m == Mode.TRIGGER); + } + } + + @Override + public JComponent optionsBar(EditorContext ctx) { + bTarget = new JToggleButton("Target", mode == Mode.TARGET); + bTarget.setToolTipText("Clicks place the arrival point of the selected zone (Shift+drag: facing)"); + bTrigger = new JToggleButton("Trigger cell", mode == Mode.TRIGGER); + bTrigger.setToolTipText("Clicks move the selected zone's trigger cell"); + ButtonGroup g = new ButtonGroup(); + g.add(bTarget); + g.add(bTrigger); + bTarget.addActionListener(a -> mode = Mode.TARGET); + bTrigger.addActionListener(a -> mode = Mode.TRIGGER); + bTarget.setFocusable(false); + bTrigger.setFocusable(false); + JLabel hint = new JLabel("Nothing selected: a click creates a zone · [ ] turn the facing"); + hint.setEnabled(false); + return Ui.line(bTarget, bTrigger, hint); + } + + @Override + public KeyStroke shortcut() { + return KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_Z, 0); + } + + @Override + public Cursor cursor() { + return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushMath.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushMath.java new file mode 100644 index 0000000..8dff477 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushMath.java @@ -0,0 +1,606 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.joml.Matrix3d; +import org.joml.Vector3d; + +/** + * Brush geometry operations as parameter maps (game space cells, no UI): creating primitives from a + * drawn footprint, resize handles, rotation (free and 90° steps), scaling, mirroring. Rotations are + * {@link Brush#rotation()} Euler angles (x·y·z, JOML {@code rotateXYZ} order) about {@link + * Brush#pivot()}; whatever can be expressed by the primitive's own parameters (90° turns, mirrors of + * unrotated brushes) is, so textures stay aligned to the grid. + */ +public final class BrushMath { + private BrushMath() {} + + // ------------------------------------------------------------------ rotation helpers + + public static Matrix3d matrix(double[] deg) { + return new Matrix3d().rotateXYZ(Math.toRadians(deg[0]), Math.toRadians(deg[1]), Math.toRadians(deg[2])); + } + + /** Euler angles (degrees, rounded to 1e-6, −180..180) of a rotation matrix. */ + public static double[] euler(Matrix3d m) { + Vector3d e = m.getEulerAnglesXYZ(new Vector3d()); + return new double[] {clean(Math.toDegrees(e.x)), clean(Math.toDegrees(e.y)), clean(Math.toDegrees(e.z))}; + } + + static double clean(double d) { + double r = Math.round(d * 1e6) / 1e6; + r = ((r + 180) % 360 + 360) % 360 - 180; + return r == -180 ? 180 : r + 0.0; + } + + static boolean identity(double[] r) { + return r[0] % 360 == 0 && r[1] % 360 == 0 && r[2] % 360 == 0; + } + + static boolean multipleOf90(double deg) { + double q = deg / 90; + return Math.abs(q - Math.rint(q)) < 1e-9; + } + + /** World position of a local (unrotated parameter space) point. */ + public static double[] world(Brush b, double[] local) { + return Brush.rotate(Brush.rotationMatrix(b.rotation()), b.pivot(), local); + } + + /** Local direction → world direction. */ + public static double[] worldDir(Brush b, double[] dir) { + return Brush.rotate(Brush.rotationMatrix(b.rotation()), new double[3], dir); + } + + // ------------------------------------------------------------------ parameter access + + /** Current value map of all parameters (arrays cloned). */ + public static Map<String, Object> values(Brush b) { + Map<String, Object> m = new LinkedHashMap<>(); + for (BrushKind.Param p : b.brushKind().params()) { + m.put(p.name(), copy(b.get(p.name()))); + } + return m; + } + + static Object copy(Object v) { + if (v instanceof double[] a) { + return a.clone(); + } + if (v instanceof int[] a) { + return a.clone(); + } + return v; + } + + /** Detached brush with the values (for computing pivots and bounds of a candidate state). */ + public static Brush apply(Brush b, Map<String, Object> changes) { + Brush c = new Brush(b.id(), b.brushKind()); + for (BrushKind.Param p : b.brushKind().params()) { + c.setRaw(p.name(), copy(b.get(p.name()))); + } + c.setRaw("name", b.name()); + changes.forEach((k, v) -> c.setRaw(k, copy(v))); + return c; + } + + /** The changed entries of after relative to b (for a minimal command). */ + public static Map<String, Object> diff(Brush b, Brush after) { + Map<String, Object> m = new LinkedHashMap<>(); + for (BrushKind.Param p : b.brushKind().params()) { + Object x = b.get(p.name()); + Object y = after.get(p.name()); + if (!java.util.Objects.deepEquals(x, y)) { + m.put(p.name(), copy(y)); + } + } + return m; + } + + static void translate(Brush c, double dx, double dy, double dz) { + c.translated(dx, dy, dz).forEach((k, v) -> c.setRaw(k, v)); + } + + /** Translates c so that its local point f lands where it was in brush b (keeps a face fixed). */ + static void keepWorld(Brush b, Brush c, double[] fLocalB, double[] fLocalC) { + double[] wb = world(b, fLocalB); + double[] wc = world(c, fLocalC); + translate(c, wb[0] - wc[0], wb[1] - wc[1], wb[2] - wc[2]); + } + + // ------------------------------------------------------------------ rotation + + /** + * Turns the brush by angle (radians, right-handed) about a world axis through pivot (game + * space); returns the changed parameters. Quarter turns about the vertical axis of an unrotated + * brush change its parameters instead of the rotation. + */ + public static Map<String, Object> rotate(Brush b, double[] axis, double angle, double[] pivot) { + Brush c = apply(b, Map.of()); + double deg = Math.toDegrees(angle); + boolean vertical = Math.abs(axis[0]) < 1e-9 && Math.abs(axis[2]) < 1e-9; + if (vertical && !b.hasRotation() && multipleOf90(deg)) { + int q = (int) Math.floorMod(Math.round(deg / 90 * Math.signum(axis[1])), 4); + for (int i = 0; i < q; i++) { + quarter(c); + } + } else { + if (!b.brushKind().transformable()) { + return Map.of(); + } + Matrix3d d = new Matrix3d().rotation(angle, axis[0], axis[1], axis[2]); + Matrix3d r = d.mul(matrix(b.rotation()), new Matrix3d()); + c.setRaw("rotation", euler(r)); + if (identity((double[]) c.get("rotation"))) { + c.setRaw("rotation", new double[3]); + } + } + // the brush's own pivot moves with the turn about the given pivot + double[] p0 = b.pivot(); + Vector3d rel = new Vector3d(p0[0] - pivot[0], p0[1] - pivot[1], p0[2] - pivot[2]); + new Matrix3d().rotation(angle, axis[0], axis[1], axis[2]).transform(rel); + double[] p1 = c.pivot(); + translate(c, pivot[0] + rel.x - p1[0], pivot[1] + rel.y - p1[1], pivot[2] + rel.z - p1[2]); + return diff(b, c); + } + + /** Quarter turn (+90° yaw: +z → +x) of the parameters about the pivot, in place. */ + static void quarter(Brush c) { + double[] pv = c.pivot(); + double px = pv[0]; + double pz = pv[2]; + BrushKind k = c.brushKind(); + switch (k) { + case PLATFORM -> { + double x = c.d("x"), z = c.d("z"), w = c.d("w"), d = c.d("d"); + // corner → rotated rectangle (x, z, w, d) with w/d swapped + double[] r = rotRect(x, z, x + w, z + d, px, pz); + c.setRaw("x", r[0]); + c.setRaw("z", r[1]); + c.setRaw("w", r[2] - r[0]); + c.setRaw("d", r[3] - r[1]); + } + case MESH -> c.setRaw("rotY", clean360(c.d("rotY") + 90)); + default -> { + for (BrushKind.Param p : k.params()) { + Object v = c.get(p.name()); + if (v == null) { + continue; + } + switch (p.role()) { + case XZ_LIST, XZ_RECT -> { + double[] a = ((double[]) v).clone(); + for (int i = 0; i + 1 < a.length; i += 2) { + double rx = a[i] - px, rz = a[i + 1] - pz; + a[i] = px + rz; + a[i + 1] = pz - rx; + } + c.setRaw(p.name(), a); + } + case XYZ_LIST -> { + double[] a = ((double[]) v).clone(); + for (int i = 0; i + 2 < a.length; i += 3) { + double rx = a[i] - px, rz = a[i + 2] - pz; + a[i] = px + rz; + a[i + 2] = pz - rx; + } + c.setRaw(p.name(), a); + } + default -> {} + } + } + if (hasRect(k)) { + double[] r = rotRect(c.d("x0"), c.d("z0"), c.d("x1"), c.d("z1"), px, pz); + c.setRaw("x0", r[0]); + c.setRaw("z0", r[1]); + c.setRaw("x1", r[2]); + c.setRaw("z1", r[3]); + } + if (has(c, "x") && has(c, "z") && !hasRect(k)) { + double rx = c.d("x") - px, rz = c.d("z") - pz; + c.setRaw("x", px + rz); + c.setRaw("z", pz - rx); + } + if (k == BrushKind.COLLIDER) { + double[] n = c.a("normal"); + c.setRaw("normal", new double[] {n[2], n[1], -n[0]}); + } + } + } + if (has(c, "direction")) { + c.setRaw( + "direction", + switch (c.s("direction")) { + case "+z" -> "+x"; + case "+x" -> "-z"; + case "-z" -> "-x"; + default -> "+z"; + }); + } + if (has(c, "sides")) { + c.setRaw("sides", mapFaces(c.s("sides"), "nesw", "wnes")); + } + if (has(c, "collFaces")) { + c.setRaw("collFaces", mapFaces(c.s("collFaces"), "nesw", "wnes")); + } + if (has(c, "faceMats") && c.get("faceMats") instanceof int[] fm && fm.length == 6) { + // t b n e s w: s→e, e→n, n→w, w→s + c.setRaw("faceMats", new int[] {fm[0], fm[1], fm[3], fm[4], fm[5], fm[2]}); + } + if (k == BrushKind.SEA && c.s("kill").equals("area")) { + double[] a = c.a("killArea"); + c.setRaw("killArea", rotRect(a[0], a[1], a[2], a[3], px, pz)); + } + } + + private static double clean360(double d) { + return ((d % 360) + 360) % 360; + } + + /** Characters of faces mapped: each char of from becomes the char at the same index of to. */ + static String mapFaces(String s, String from, String to) { + StringBuilder sb = new StringBuilder(); + for (char ch : s.toCharArray()) { + int i = from.indexOf(ch); + sb.append(i < 0 ? ch : to.charAt(i)); + } + char[] a = sb.toString().toCharArray(); + java.util.Arrays.sort(a); + StringBuilder o = new StringBuilder(); + for (char ch : BrushKind.FACES.toCharArray()) { + if (new String(a).indexOf(ch) >= 0) { + o.append(ch); + } + } + return o.toString(); + } + + private static double[] rotRect(double x0, double z0, double x1, double z1, double px, double pz) { + // (x, z) → (px + (z − pz), pz − (x − px)) + double ax = px + (z0 - pz), az = pz - (x0 - px); + double bx = px + (z1 - pz), bz = pz - (x1 - px); + return new double[] {Math.min(ax, bx), Math.min(az, bz), Math.max(ax, bx), Math.max(az, bz)}; + } + + static boolean hasRect(BrushKind k) { + for (BrushKind.Param p : k.params()) { + if (p.name().equals("x0")) { + return true; + } + } + return false; + } + + static boolean has(Brush c, String p) { + for (BrushKind.Param x : c.brushKind().params()) { + if (x.name().equals(p)) { + return true; + } + } + return false; + } + + // ------------------------------------------------------------------ scaling + + /** + * Scales the brush in its local frame by factors {x, y, z} about its pivot (sizes and coordinates + * of the parameters; meshes: stretch), keeping the bottom centre in place. + */ + public static Map<String, Object> scale(Brush b, double[] f) { + Brush c = apply(b, Map.of()); + double[] pv = b.pivot(); + double[] lb = b.localBounds(); + double[] anchor = {pv[0], lb[1], pv[2]}; + BrushKind k = b.brushKind(); + double sh = (Math.abs(f[0]) + Math.abs(f[2])) / 2; + if (k == BrushKind.MESH) { + double[] st = c.get("stretch") instanceof double[] s ? s.clone() : new double[] {1, 1, 1}; + for (int i = 0; i < 3; i++) { + st[i] *= f[i]; + } + c.setRaw("stretch", st); + } else { + for (BrushKind.Param p : k.params()) { + Object v = c.get(p.name()); + if (v == null) { + continue; + } + switch (p.role()) { + case X -> c.setRaw(p.name(), anchor[0] + ((Double) v - anchor[0]) * f[0]); + case Y -> c.setRaw(p.name(), anchor[1] + ((Double) v - anchor[1]) * f[1]); + case Z -> c.setRaw(p.name(), anchor[2] + ((Double) v - anchor[2]) * f[2]); + case XZ_LIST, XZ_RECT -> { + double[] a = ((double[]) v).clone(); + for (int i = 0; i + 1 < a.length; i += 2) { + a[i] = anchor[0] + (a[i] - anchor[0]) * f[0]; + a[i + 1] = anchor[2] + (a[i + 1] - anchor[2]) * f[2]; + } + c.setRaw(p.name(), a); + } + case XYZ_LIST -> { + double[] a = ((double[]) v).clone(); + for (int i = 0; i + 2 < a.length; i += 3) { + a[i] = anchor[0] + (a[i] - anchor[0]) * f[0]; + a[i + 1] = anchor[1] + (a[i + 1] - anchor[1]) * f[1]; + a[i + 2] = anchor[2] + (a[i + 2] - anchor[2]) * f[2]; + } + c.setRaw(p.name(), a); + } + case NONE -> { + Double nv = + switch (p.name()) { + case "w" -> c.d("w") * f[0]; + case "d" -> c.d("d") * f[2]; + case "thick" -> k == BrushKind.FENCE ? c.d("thick") * sh : c.d("thick") * f[1]; + case "size", "radius", "crown", "trunkSize" -> c.d(p.name()) * sh; + case "height" -> c.d("height") * f[1]; + default -> null; + }; + if (nv != null) { + c.setRaw(p.name(), Math.abs(nv)); + } + } + } + } + if (k == BrushKind.PLATFORM) { + // the corner scales about the centre, not the corner itself + double w0 = b.d("w"), d0 = b.d("d"); + c.setRaw("x", pv[0] + (b.d("x") - pv[0]) * f[0] - (f[0] < 0 ? Math.abs(w0 * f[0]) : 0)); + c.setRaw("z", pv[2] + (b.d("z") - pv[2]) * f[2] - (f[2] < 0 ? Math.abs(d0 * f[2]) : 0)); + double top = anchor[1] + (b.d("y") - anchor[1]) * f[1]; + c.setRaw("y", top); + } + } + double[] lc = c.localBounds(); + keepWorld(b, c, anchor, new double[] {c.pivot()[0], lc[1], c.pivot()[2]}); + return diff(b, c); + } + + // ------------------------------------------------------------------ mirror + + /** + * Mirrors the brush about the world plane axis = c (axis 0 = x, 2 = z, game space). Unrotated + * primitives are mirrored through their parameters; the rotation is mirrored as M·R·M. + */ + public static Map<String, Object> mirror(Brush b, int axis, double plane) { + Brush c = apply(b, Map.of()); + BrushKind k = b.brushKind(); + double[] pv = b.pivot(); + // local mirror about the pivot along the same axis, then move the pivot to its mirror image + mirrorLocal(c, axis, pv[axis]); + if (k.transformable() && b.hasRotation()) { + double[] r = b.rotation(); + c.setRaw("rotation", axis == 0 ? new double[] {r[0], -r[1], -r[2]} : new double[] {-r[0], -r[1], r[2]}); + } + double[] p1 = c.pivot(); + double target = 2 * plane - pv[axis]; + translate(c, axis == 0 ? target - p1[0] : 0, 0, axis == 2 ? target - p1[2] : 0); + return diff(b, c); + } + + static void mirrorLocal(Brush c, int axis, double m) { + BrushKind k = c.brushKind(); + String ax = axis == 0 ? "x" : "z"; + if (k == BrushKind.MESH) { + double[] st = c.get("stretch") instanceof double[] s ? s.clone() : new double[] {1, 1, 1}; + st[axis] = -st[axis]; + c.setRaw("stretch", st); + c.setRaw("rotY", clean360(-c.d("rotY"))); + return; + } + if (k == BrushKind.PLATFORM) { + double v = c.d(ax), size = c.d(axis == 0 ? "w" : "d"); + c.setRaw(ax, 2 * m - v - size); + } + for (BrushKind.Param p : k.params()) { + Object v = c.get(p.name()); + if (v == null || k == BrushKind.PLATFORM) { + continue; + } + switch (p.role()) { + case X -> { + if (axis == 0) { + c.setRaw(p.name(), 2 * m - (Double) v); + } + } + case Z -> { + if (axis == 2) { + c.setRaw(p.name(), 2 * m - (Double) v); + } + } + case XZ_LIST, XZ_RECT -> { + double[] a = ((double[]) v).clone(); + for (int i = axis == 0 ? 0 : 1; i < a.length; i += 2) { + a[i] = 2 * m - a[i]; + } + c.setRaw(p.name(), a); + } + case XYZ_LIST -> { + double[] a = ((double[]) v).clone(); + for (int i = axis; i < a.length; i += 3) { + a[i] = 2 * m - a[i]; + } + int n = a.length / 3; + double[] r = new double[a.length]; + for (int i = 0; i < n; i++) { + int src = (n - i) % n; + System.arraycopy(a, 3 * src, r, 3 * i, 3); + } + c.setRaw(p.name(), r); + } + default -> {} + } + } + if (hasRect(k)) { + double a0 = c.d(ax + "0"), a1 = c.d(ax + "1"); + c.setRaw(ax + "0", Math.min(a0, a1)); + c.setRaw(ax + "1", Math.max(a0, a1)); + } + if (k == BrushKind.COLLIDER) { + double[] n = c.a("normal").clone(); + n[axis] = -n[axis]; + c.setRaw("normal", n); + } + if (has(c, "direction")) { + String d = c.s("direction"); + if (d.endsWith(ax)) { + c.setRaw("direction", (d.charAt(0) == '+' ? "-" : "+") + ax); + } + } + String from = axis == 0 ? "ew" : "ns"; + String to = axis == 0 ? "we" : "sn"; + if (has(c, "sides")) { + c.setRaw("sides", mapFaces(c.s("sides"), from, to)); + } + if (has(c, "collFaces")) { + c.setRaw("collFaces", mapFaces(c.s("collFaces"), from, to)); + } + if (has(c, "faceMats") && c.get("faceMats") instanceof int[] fm && fm.length == 6) { + int[] r = fm.clone(); + if (axis == 0) { + r[3] = fm[5]; + r[5] = fm[3]; + } else { + r[2] = fm[4]; + r[4] = fm[2]; + } + c.setRaw("faceMats", r); + } + if (k == BrushKind.SEA && c.s("kill").equals("area")) { + double[] a = c.a("killArea").clone(); + int i0 = axis == 0 ? 0 : 1; + double u = 2 * m - a[i0], w = 2 * m - a[i0 + 2]; + a[i0] = Math.min(u, w); + a[i0 + 2] = Math.max(u, w); + c.setRaw("killArea", a); + } + } + + // ------------------------------------------------------------------ handles + + /** A resize handle: local position, local outward axis (unit), what it changes. */ + public record Handle(String id, double[] local, double[] axis, String tip) {} + + /** Resize handles of a brush (local space, before rotation). */ + public static List<Handle> handles(Brush b) { + List<Handle> l = new ArrayList<>(); + double[] lb = b.localBounds(); + if (!Double.isFinite(lb[0])) { + return l; + } + double cx = (lb[0] + lb[3]) / 2, cy = (lb[1] + lb[4]) / 2, cz = (lb[2] + lb[5]) / 2; + BrushKind k = b.brushKind(); + boolean sides = + switch (k) { + case FLOOR, BOX, PLATFORM, RAMP, STAIRS, SEA, KILL_PLANE, PILLAR, CYLINDER, TREE -> true; + default -> false; + }; + if (sides) { + l.add(new Handle("+x", new double[] {lb[3], cy, cz}, new double[] {1, 0, 0}, "Width")); + l.add(new Handle("-x", new double[] {lb[0], cy, cz}, new double[] {-1, 0, 0}, "Width")); + l.add(new Handle("+z", new double[] {cx, cy, lb[5]}, new double[] {0, 0, 1}, "Depth")); + l.add(new Handle("-z", new double[] {cx, cy, lb[2]}, new double[] {0, 0, -1}, "Depth")); + } + boolean top = + switch (k) { + case MESH, QUAD, COLLIDER -> false; + default -> true; + }; + if (top) { + l.add(new Handle("+y", new double[] {cx, lb[4], cz}, new double[] {0, 1, 0}, "Height")); + } + boolean bottom = + switch (k) { + case BOX, PLATFORM, PILLAR, CYLINDER, RAMP, STAIRS -> true; + default -> false; + }; + if (bottom) { + l.add(new Handle("-y", new double[] {cx, lb[1], cz}, new double[] {0, -1, 0}, "Bottom")); + } + return l; + } + + /** Parameter changes for dragging handle id outwards by amount (cells, local axis). */ + public static Map<String, Object> drag(Brush b, String id, double amount) { + Brush c = apply(b, Map.of()); + BrushKind k = b.brushKind(); + double[] lb = b.localBounds(); + int axis = id.charAt(1) == 'x' ? 0 : id.charAt(1) == 'y' ? 1 : 2; + boolean plus = id.charAt(0) == '+'; + double a = plus ? amount : -amount; + switch (axis) { + case 0, 2 -> { + String ax = axis == 0 ? "x" : "z"; + if (hasRect(k)) { + String lo = c.d(ax + "0") <= c.d(ax + "1") ? ax + "0" : ax + "1"; + String hi = lo.endsWith("0") ? ax + "1" : ax + "0"; + String p = plus ? hi : lo; + double v = c.d(p) + a; + double other = c.d(plus ? lo : hi); + v = plus ? Math.max(other + 0.05, v) : Math.min(other - 0.05, v); + c.setRaw(p, v); + } else if (k == BrushKind.PLATFORM) { + String size = axis == 0 ? "w" : "d"; + double s = Math.max(0.05, c.d(size) + amount); + if (!plus) { + c.setRaw(ax, c.d(ax) - (s - c.d(size))); + } + c.setRaw(size, s); + } else if (k == BrushKind.PILLAR) { + c.setRaw("size", Math.max(0.05, c.d("size") + 2 * amount)); + } else if (k == BrushKind.CYLINDER) { + c.setRaw("radius", Math.max(0.05, c.d("radius") + amount)); + } else if (k == BrushKind.TREE) { + c.setRaw("crown", Math.max(0.1, c.d("crown") + 2 * amount)); + } + } + default -> { + if (plus) { + String p = + switch (k) { + case BOX, PILLAR, CYLINDER -> "y1"; + case RAMP -> c.d("yb") >= c.d("ya") ? "yb" : "ya"; + case STAIRS -> "y1"; + case TREE, FENCE -> "height"; + default -> "y"; + }; + double v = c.d(p) + amount; + if (p.equals("y1") && (k == BrushKind.BOX || k == BrushKind.PILLAR || k == BrushKind.CYLINDER)) { + v = Math.max(c.d("y0"), v); + } + if (p.equals("height")) { + v = Math.max(0.05, v); + } + if (k == BrushKind.PLATFORM) { + // top moves, bottom stays: the platform gets thicker + c.setRaw("thick", Math.max(0.02, c.d("thick") + amount)); + } + c.setRaw(p, v); + } else { + switch (k) { + case BOX, PILLAR, CYLINDER -> c.setRaw("y0", Math.min(c.d("y1"), c.d("y0") - amount)); + case PLATFORM -> c.setRaw("thick", Math.max(0.02, c.d("thick") + amount)); + case RAMP, STAIRS -> c.setRaw("base", lb[1] - amount); + default -> {} + } + } + } + } + // the opposite face stays where it is + double[] lc = c.localBounds(); + double[] fb = opposite(lb, axis, plus); + double[] fc = opposite(lc, axis, plus); + keepWorld(b, c, fb, fc); + return diff(b, c); + } + + private static double[] opposite(double[] lb, int axis, boolean plus) { + double[] p = {(lb[0] + lb[3]) / 2, (lb[1] + lb[4]) / 2, (lb[2] + lb[5]) / 2}; + p[axis] = plus ? lb[axis] : lb[axis + 3]; + return p; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushShapes.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushShapes.java new file mode 100644 index 0000000..7c8448f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushShapes.java @@ -0,0 +1,194 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.render.Overlay; +import java.util.ArrayList; +import java.util.List; + +/** + * Approximate solid shapes of brushes for previews and helper drawing (live preview while drawing, + * invisible collision volumes): convex pieces as corner lists in game space, drawn into an overlay + * in export space (z mirrored). + */ +public final class BrushShapes { + private BrushShapes() {} + + /** A convex piece: 8 box corners (index bits x, y, z) or a flat polygon. */ + record Piece(double[][] corners, boolean box) {} + + static List<Piece> pieces(Brush b) { + List<Piece> l = new ArrayList<>(); + double[] lb = b.localBounds(); + if (!Double.isFinite(lb[0])) { + return l; + } + switch (b.brushKind()) { + case FLOOR, SEA, KILL_PLANE -> l.add(flat(lb[0], lb[2], lb[3], lb[5], b.d("y"))); + case RAMP -> l.add(ramp(b, lb)); + case STAIRS -> stairs(b, lb, l); + case CYLINDER -> cylinder(b, l); + case TREE -> { + double y = b.d("y"), h = b.d("height"), c = b.d("crown") / 2; + double t = b.d("trunkSize") > 0 ? b.d("trunkSize") / 2 : Math.max(0.15, b.d("crown") * 0.25) / 2; + double x = b.d("x"), z = b.d("z"); + l.add(box(x - t, y, z - t, x + t, y + h, z + t)); + l.add(box(x - c, y + h - 0.2, z - c, x + c, y + h + b.d("crown") * 0.7, z + c)); + double c2 = c * 0.6; + l.add(box(x - c2, y + h + b.d("crown") * 0.7, z - c2, x + c2, y + h + b.d("crown") * 1.1, z + c2)); + } + case FENCE -> { + double[] p = b.a("points"); + double y = b.d("y"), h = b.d("height"), t = Math.max(0.03, b.d("thick")) / 2; + for (int i = 0; i + 3 < p.length; i += 2) { + l.add(wall(p[i], p[i + 1], p[i + 2], p[i + 3], y + h - 0.1, y + h, t)); + } + for (int i = 0; i + 1 < p.length; i += 2) { + l.add(box(p[i] - t, y, p[i + 1] - t, p[i] + t, y + h, p[i + 1] + t)); + } + } + case QUAD, COLLIDER -> { + double[] p = b.a("points"); + double[][] c = new double[p.length / 3][]; + for (int i = 0; i < c.length; i++) { + c[i] = new double[] {p[3 * i], p[3 * i + 1], p[3 * i + 2]}; + } + l.add(new Piece(c, false)); + } + default -> l.add(box(lb[0], lb[1], lb[2], lb[3], lb[4], lb[5])); + } + return l; + } + + static Piece box(double x0, double y0, double z0, double x1, double y1, double z1) { + double[][] c = new double[8][]; + for (int i = 0; i < 8; i++) { + c[i] = new double[] {(i & 1) != 0 ? x1 : x0, (i & 2) != 0 ? y1 : y0, (i & 4) != 0 ? z1 : z0}; + } + return new Piece(c, true); + } + + static Piece flat(double x0, double z0, double x1, double z1, double y) { + return new Piece(new double[][] {{x0, y, z0}, {x1, y, z0}, {x1, y, z1}, {x0, y, z1}}, false); + } + + /** Wall piece from (ax, az) to (bx, bz), half thickness t. */ + static Piece wall(double ax, double az, double bx, double bz, double y0, double y1, double t) { + double dx = bx - ax, dz = bz - az, len = Math.max(1e-9, Math.hypot(dx, dz)); + double nx = -dz / len * t, nz = dx / len * t; + double[][] c = new double[8][]; + for (int i = 0; i < 8; i++) { + boolean end = (i & 1) != 0; + boolean up = (i & 2) != 0; + boolean side = (i & 4) != 0; + double x = (end ? bx : ax) + (side ? nx : -nx); + double z = (end ? bz : az) + (side ? nz : -nz); + c[i] = new double[] {x, up ? y1 : y0, z}; + } + return new Piece(c, true); + } + + /** Box corners with the top lowered towards the low end (direction = rising side). */ + static Piece ramp(Brush b, double[] lb) { + Piece p = box(lb[0], lb[1], lb[2], lb[3], lb[4], lb[5]); + String d = b.s("direction"); + double lo = Math.min(b.d("ya"), b.d("yb")), hi = Math.max(b.d("ya"), b.d("yb")); + boolean rising = b.d("yb") >= b.d("ya"); + for (int i = 0; i < 8; i++) { + if ((i & 2) == 0) { + continue; + } + boolean plusSide = d.endsWith("x") ? (i & 1) != 0 : (i & 4) != 0; + boolean high = (d.startsWith("+") == plusSide) == rising; + p.corners()[i][1] = high ? hi : lo; + } + return p; + } + + static void stairs(Brush b, double[] lb, List<Piece> l) { + int n = Math.max(1, b.i("steps")); + String d = b.s("direction"); + double y0 = b.d("y0"), y1 = b.d("y1"), base = lb[1]; + for (int i = 0; i < n; i++) { + double t0 = (double) i / n, t1 = (double) (i + 1) / n; + double top = y0 + (y1 - y0) * (i + 1) / n; + double a0, a1; + boolean x = d.endsWith("x"); + double lo = x ? lb[0] : lb[2], hi = x ? lb[3] : lb[5]; + if (d.startsWith("+")) { + a0 = lo + (hi - lo) * t0; + a1 = lo + (hi - lo) * t1; + } else { + a0 = hi - (hi - lo) * t1; + a1 = hi - (hi - lo) * t0; + } + l.add(x ? box(a0, base, lb[2], a1, top, lb[5]) : box(lb[0], base, a0, lb[3], top, a1)); + } + } + + static void cylinder(Brush b, List<Piece> l) { + int n = Math.max(3, Math.min(64, b.i("segments"))); + double x = b.d("x"), z = b.d("z"), r = b.d("radius"); + double y0 = Math.min(b.d("y0"), b.d("y1")), y1 = Math.max(b.d("y0"), b.d("y1")); + for (int i = 0; i < n; i++) { + double a0 = 2 * Math.PI * i / n, a1 = 2 * Math.PI * (i + 1) / n; + double[] p = {x + r * Math.sin(a0), z + r * Math.cos(a0)}; + double[] q = {x + r * Math.sin(a1), z + r * Math.cos(a1)}; + // wedge as a degenerate box: centre edge + outer edge + double[][] c = new double[8][]; + for (int k = 0; k < 8; k++) { + boolean outer = (k & 1) != 0; + boolean up = (k & 2) != 0; + boolean second = (k & 4) != 0; + double[] pt = outer ? (second ? q : p) : new double[] {x, z}; + c[k] = new double[] {pt[0], up ? y1 : y0, pt[1]}; + } + l.add(new Piece(c, true)); + } + } + + private static final int[][] EDGES = { + {0, 1}, {2, 3}, {4, 5}, {6, 7}, {0, 2}, {1, 3}, {4, 6}, {5, 7}, {0, 4}, {1, 5}, {2, 6}, {3, 7} + }; + private static final int[][] QUADS = { + {0, 1, 3, 2}, {4, 6, 7, 5}, {0, 2, 6, 4}, {1, 5, 7, 3}, {0, 4, 5, 1}, {2, 3, 7, 6} + }; + + /** Draws the brush (rotated) into o: translucent faces in fill, edges in line (export space). */ + public static void draw(Brush b, Overlay o, int fill, int line) { + for (Piece p : pieces(b)) { + double[][] w = new double[p.corners().length][]; + for (int i = 0; i < w.length; i++) { + double[] g = BrushMath.world(b, p.corners()[i]); + w[i] = new double[] {g[0], g[1], -g[2]}; + } + if (p.box()) { + for (int[] e : EDGES) { + if (line != 0 && !same(w[e[0]], w[e[1]])) { + o.line(w[e[0]], w[e[1]], line); + } + } + if (fill != 0) { + for (int[] q : QUADS) { + o.triangle(w[q[0]], w[q[1]], w[q[2]], fill); + o.triangle(w[q[0]], w[q[2]], w[q[3]], fill); + } + } + } else { + for (int i = 0; i < w.length; i++) { + if (line != 0) { + o.line(w[i], w[(i + 1) % w.length], line); + } + } + if (fill != 0) { + for (int i = 1; i + 1 < w.length; i++) { + o.triangle(w[0], w[i], w[i + 1], fill); + } + } + } + } + } + + private static boolean same(double[] a, double[] b) { + return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) < 1e-9; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTool.java new file mode 100644 index 0000000..7148d67 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTool.java @@ -0,0 +1,852 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.render.Camera; +import com.eliasmueller.llamamod.editor.render.Legend; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.EditorPrefs; +import com.eliasmueller.llamamod.editor.ui.EditorWindow; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.NodeCopy; +import com.eliasmueller.llamamod.editor.ui.Tool; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import com.eliasmueller.llamamod.editor.ui.tools.TransformSupport; +import java.awt.Cursor; +import java.awt.GridLayout; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.JCheckBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.JToggleButton; +import javax.swing.KeyStroke; +import javax.swing.SwingConstants; +import org.joml.Vector3f; + +/** + * Draws brushes in the viewport: drag a footprint on the ground or on any surface (snapped), then + * move the mouse to set the height and click. A selected brush shows resize handles (drag a face + * handle; the opposite face stays). [ / ] turn the selection by 90°, Page Up / Down raise or lower + * it by the snap step. + */ +final class BrushTool implements Tool { + private enum State { + IDLE, + FOOTPRINT, + HEIGHT, + POLY, + HANDLE, + PLACE + } + + final Primitive.Options opt = new Primitive.Options(); + Primitive prim = Primitive.BOX; + boolean dragHeight = true; + boolean onWorkPlane; + double workPlane; + + private EditorContext ctx; + private State state = State.IDLE; + private double ax, az, bx, bz, baseY, height; + private int pressX, pressY; + private boolean moved; + private Node pressed; + private final List<double[]> poly = new ArrayList<>(); + private Brush hBrush; + private Brush hSnap; + private BrushMath.Handle hHandle; + private Object mergeKey; + private float[] hScreen; + private float[] hScreenDir; + private BrushMath.Handle hover; + private final java.util.Map<Primitive, JToggleButton> barButtons = new java.util.EnumMap<>(Primitive.class); + private final java.util.Map<Primitive, JToggleButton> paletteButtons = new java.util.EnumMap<>(Primitive.class); + private JPanel optionsHolder; + private MeshImportDialog.Result place; + private double[] placeAt; + + /** Starts placing an imported mesh: it follows the cursor, a click puts it down. */ + void placeMesh(EditorContext c, MeshImportDialog.Result r) { + ctx = c; + cancel(); + place = r; + state = State.PLACE; + c.status("Click in the viewport to place " + r.asset().name() + " (Esc cancels)"); + } + + @Override + public String id() { + return "geo.brush"; + } + + @Override + public String name() { + return "Brush"; + } + + @Override + public String icon() { + return "prim-box"; + } + + @Override + public String tooltip() { + return "Brush: draw blocks, platforms, ramps, stairs, walls, water … on the ground or on surfaces;" + + " drag the handles of a selected brush to resize it"; + } + + @Override + public KeyStroke shortcut() { + return KeyStroke.getKeyStroke('B', 0); + } + + @Override + public String group() { + return "geometry"; + } + + @Override + public int order() { + return 210; + } + + @Override + public Cursor cursor() { + return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR); + } + + @Override + public void activate(EditorContext c) { + ctx = c; + load(c.prefs()); + } + + @Override + public void deactivate(EditorContext c) { + cancel(); + save(c.prefs()); + } + + private void load(EditorPrefs p) { + try { + prim = Primitive.valueOf(p.getString("geo.brush.primitive", prim.name())); + } catch (IllegalArgumentException e) { + prim = Primitive.BOX; + } + opt.top = p.getString("geo.brush.top", opt.top); + opt.side = p.getString("geo.brush.side", opt.side); + opt.edge = p.getString("geo.brush.edge", opt.edge); + opt.bottom = p.getString("geo.brush.bottom", opt.bottom); + opt.height = p.getDouble("geo.brush.height", opt.height); + opt.steps = p.getInt("geo.brush.steps", opt.steps); + dragHeight = p.getBool("geo.brush.dragHeight", dragHeight); + } + + private void save(EditorPrefs p) { + p.put("geo.brush.primitive", prim.name()); + p.put("geo.brush.top", opt.top); + p.put("geo.brush.side", opt.side); + p.put("geo.brush.edge", opt.edge); + p.put("geo.brush.bottom", opt.bottom); + p.put("geo.brush.height", opt.height); + p.put("geo.brush.steps", opt.steps); + p.put("geo.brush.dragHeight", dragHeight); + } + + private void cancel() { + if (state == State.HANDLE) { + mergeKey = null; + } + state = State.IDLE; + place = null; + poly.clear(); + if (ctx != null) { + ctx.repaintTool(); + } + } + + // ------------------------------------------------------------------ snapping + + private boolean snapOn() { + return ctx.snapping().enabled() != TransformSupport.ctrlDown(); + } + + private double snap(double v) { + return snapOn() ? ctx.snapping().snap(v) : v; + } + + /** Ground point under the cursor in game space (snapped), null if none. */ + private double[] ground(ToolEvent e) { + Vector3f p = onWorkPlane ? e.onPlane((float) workPlane) : e.ground(); + if (p == null) { + return null; + } + double y = onWorkPlane ? workPlane : snapY(p.y); + return new double[] {snap(p.x), y, snap(-p.z)}; + } + + private double snapY(double y) { + if (!snapOn()) { + return y; + } + double st = Math.min(ctx.snapping().step(), 0.125); + double r = Math.round(y / st) * st; + return Math.abs(r - y) < 0.03 ? r : Math.round(y * 100) / 100.0; + } + + // ------------------------------------------------------------------ mouse + + @Override + public boolean mousePressed(ToolEvent e) { + ctx = e.ctx(); + if (ctx.scene() == null) { + return false; + } + if (state == State.HEIGHT) { + commit(); + return true; + } + if (state == State.PLACE) { + double[] g = ground(e); + if (g != null) { + List<Brush> made = new ArrayList<>(); + ctx.execute(new Batch( + "Import " + place.asset().name(), + MeshImportDialog.place(ctx.scene(), place, g[0], g[1], g[2], made))); + ctx.selection().set(made.getFirst(), this); + ctx.status("Placed " + place.asset().name() + " – move/turn/scale it with the gizmo tools"); + } + place = null; + state = State.IDLE; + ctx.repaintTool(); + return true; + } + hover = handleAt(e.x(), e.y()); + if (hover != null && ctx.selection().primary() instanceof Brush b) { + startHandle(b, hover, e); + return true; + } + double[] g = ground(e); + if (g == null) { + return true; + } + if (prim.shape == Primitive.Shape.POLYLINE) { + if (state != State.POLY) { + poly.clear(); + baseY = g[1]; + state = State.POLY; + } + if (e.clickCount() >= 2 && poly.size() >= 2) { + finishPoly(); + return true; + } + poly.add(new double[] {g[0], g[2]}); + bx = g[0]; + bz = g[2]; + ctx.status("Fence: " + poly.size() + " point(s) – click the next corner, double-click or Enter to finish"); + ctx.repaintTool(); + return true; + } + pressX = e.x(); + pressY = e.y(); + pressed = e.pick(); + moved = false; + ax = bx = g[0]; + az = bz = g[2]; + baseY = g[1]; + height = opt.height; + state = State.FOOTPRINT; + return true; + } + + @Override + public void mouseDragged(ToolEvent e) { + switch (state) { + case FOOTPRINT -> { + if (!moved && Math.abs(e.x() - pressX) + Math.abs(e.y() - pressY) < 5) { + return; + } + moved = true; + Vector3f p = e.onPlane((float) baseY); + if (p != null) { + bx = snap(p.x); + bz = snap(-p.z); + if (prim.shape == Primitive.Shape.LINE && snapOn()) { + double len = Math.hypot(p.x - ax, -p.z - az); + double st = Math.toRadians(ctx.snapping().angle()); + double ang = Math.round(Math.atan2(-p.z - az, p.x - ax) / st) * st; + len = ctx.snapping().snap(len); + bx = ax + len * Math.cos(ang); + bz = az + len * Math.sin(ang); + } + } + ctx.status(footprintText()); + ctx.repaintTool(); + } + case HANDLE -> dragHandle(e); + default -> {} + } + } + + @Override + public void mouseReleased(ToolEvent e) { + switch (state) { + case FOOTPRINT -> { + if (!moved) { + state = State.IDLE; + boolean place = !e.shift() + && (prim.shape == Primitive.Shape.POINT + || prim == Primitive.PILLAR + || prim == Primitive.CYLINDER); + if (place) { + if (prim == Primitive.CYLINDER) { + ax -= 1; + az -= 1; + bx += 1; + bz += 1; + } + if (prim.height && dragHeight) { + state = State.HEIGHT; + ctx.status("Move the mouse up/down to set the height, click to confirm (Esc cancels)"); + ctx.repaintTool(); + } else { + commit(); + } + } else { + select(pressed, e.shift(), e.ctrl()); + } + return; + } + if (prim.height && dragHeight) { + state = State.HEIGHT; + ctx.status("Move the mouse up/down to set the height, click to confirm (Enter = " + fmt(opt.height) + + ", Esc cancels)"); + ctx.repaintTool(); + } else { + commit(); + } + } + case HANDLE -> { + state = State.IDLE; + mergeKey = null; + ctx.repaintTool(); + } + default -> {} + } + } + + @Override + public void mouseMoved(ToolEvent e) { + ctx = e.ctx(); + switch (state) { + case HEIGHT -> { + height = heightAt(e.x(), e.y()); + ctx.status(footprintText()); + ctx.repaintTool(); + } + case PLACE -> { + placeAt = ground(e); + ctx.repaintTool(); + } + case POLY -> { + Vector3f p = e.onPlane((float) baseY); + if (p != null) { + bx = snap(p.x); + bz = snap(-p.z); + ctx.repaintTool(); + } + } + case IDLE -> { + BrushMath.Handle h = handleAt(e.x(), e.y()); + if (h != hover && (h == null || hover == null || !h.id().equals(hover.id()))) { + hover = h; + if (h != null) { + ctx.status(h.tip() + ": drag to resize (" + h.id() + " face)"); + } + ctx.repaintTool(); + } + } + default -> {} + } + } + + @Override + public boolean keyPressed(ToolEvent e) { + KeyEvent k = e.key(); + if (k == null) { + return false; + } + switch (k.getKeyCode()) { + case KeyEvent.VK_ESCAPE -> { + if (state != State.IDLE) { + cancel(); + ctx.status("Cancelled"); + return true; + } + } + case KeyEvent.VK_ENTER -> { + if (state == State.HEIGHT) { + height = opt.height; + commit(); + return true; + } + if (state == State.POLY) { + finishPoly(); + return true; + } + } + case KeyEvent.VK_OPEN_BRACKET, KeyEvent.VK_CLOSE_BRACKET -> { + GeometryActions.rotateSelection(ctx, k.getKeyCode() == KeyEvent.VK_CLOSE_BRACKET ? 90 : -90); + return true; + } + case KeyEvent.VK_PAGE_UP, KeyEvent.VK_PAGE_DOWN -> { + double st = ctx.snapping().step() * (k.getKeyCode() == KeyEvent.VK_PAGE_UP ? 1 : -1); + GeometryActions.raise(ctx, st); + return true; + } + default -> {} + } + return false; + } + + private void select(Node n, boolean shift, boolean ctrl) { + if (ctx instanceof EditorWindow w) { + w.clickSelect(n, shift, ctrl); + } else if (n == null) { + ctx.selection().clear(this); + } else { + ctx.selection().set(n, this); + } + } + + // ------------------------------------------------------------------ height + + private double heightAt(int x, int y) { + Camera cam = ctx.viewport().camera(); + Vector3f[] r = cam.ray(x, y); + Vector3f f = cam.forward(); + double nx = f.x, nz = f.z, l = Math.hypot(nx, nz); + double h; + if (l < 0.2 || cam.ortho && Math.abs(f.y) > 0.9) { + h = opt.height + (pressY - y) * cam.pixelSize(ToolEvent.toExport(bx, baseY, bz)); + } else { + nx /= l; + nz /= l; + double qx = bx, qz = -bz; + double den = r[1].x * nx + r[1].z * nz; + if (Math.abs(den) < 1e-6) { + return height; + } + double t = ((qx - r[0].x) * nx + (qz - r[0].z) * nz) / den; + h = r[0].y + t * r[1].y - baseY; + } + h = snapOn() ? Math.round(h / ctx.snapping().step()) * ctx.snapping().step() : h; + return Math.max(prim == Primitive.PLATFORM ? 0.1 : 0.05, Math.min(40, h)); + } + + // ------------------------------------------------------------------ create + + private Map<String, Object> params() { + return prim.params(ax, az, bx, bz, baseY, prim.height ? height : opt.height, opt); + } + + private void commit() { + state = State.IDLE; + Map<String, Object> m = params(); + if (m == null) { + ctx.status("Too small – drag a larger footprint"); + ctx.repaintTool(); + return; + } + Brush b = ctx.scene().createBrush(prim.kind); + m.forEach((k, v) -> b.setRaw(k, v)); + b.setRaw("name", prim.label); + ctx.execute(new Batch("Draw " + prim.label.toLowerCase(Locale.ROOT), List.of(new AddNode(b)))); + ctx.selection().set(b, this); + double[] bb = b.bounds(); + ctx.status(prim.label + " " + fmt(bb[3] - bb[0]) + " × " + fmt(bb[5] - bb[2]) + " × " + fmt(bb[4] - bb[1]) + + " created – drag its handles to resize, [ ] to turn"); + ctx.repaintTool(); + } + + private void finishPoly() { + if (poly.size() < 2) { + cancel(); + return; + } + double[] pts = new double[poly.size() * 2]; + for (int i = 0; i < poly.size(); i++) { + pts[2 * i] = poly.get(i)[0]; + pts[2 * i + 1] = poly.get(i)[1]; + } + Brush b = ctx.scene().createBrush(prim.kind); + b.setRaw("points", pts); + b.setRaw("y", baseY); + b.setRaw("name", prim.label); + ctx.execute(new Batch("Draw " + prim.label.toLowerCase(Locale.ROOT), List.of(new AddNode(b)))); + ctx.selection().set(b, this); + poly.clear(); + state = State.IDLE; + ctx.repaintTool(); + } + + private String footprintText() { + double w = Math.abs(bx - ax), d = Math.abs(bz - az); + String s = prim.label + ": " + fmt(w) + " × " + fmt(d); + if (prim.shape == Primitive.Shape.LINE) { + s = prim.label + ": length " + fmt(Math.hypot(bx - ax, bz - az)); + } + if (state == State.HEIGHT) { + s += ", height " + fmt(height) + " (top at " + fmt(baseY + height) + ")"; + } + return s + " · base " + fmt(baseY); + } + + static String fmt(double v) { + return String.format(Locale.ROOT, "%.3f", v).replaceAll("0+$", "").replaceAll("\\.$", ""); + } + + // ------------------------------------------------------------------ handles + + private BrushMath.Handle handleAt(int x, int y) { + if (ctx == null + || !(ctx.selection().primary() instanceof Brush b) + || ctx.selection().size() != 1) { + return null; + } + if (ctx.isLocked(b)) { + return null; + } + Camera cam = ctx.viewport().camera(); + BrushMath.Handle best = null; + double bd = 10; + for (BrushMath.Handle h : BrushMath.handles(b)) { + double[] w = BrushMath.world(b, h.local()); + Vector3f s = cam.project(ToolEvent.toExport(w[0], w[1], w[2])); + if (Float.isNaN(s.x) || s.z > 1) { + continue; + } + double d = Math.hypot(s.x - x, s.y - y); + if (d < bd) { + bd = d; + best = h; + } + } + return best; + } + + private void startHandle(Brush b, BrushMath.Handle h, ToolEvent e) { + hBrush = b; + hSnap = (Brush) NodeCopy.snapshot(b); + hHandle = h; + mergeKey = new Object(); + Camera cam = ctx.viewport().camera(); + double[] w = BrushMath.world(b, h.local()); + double[] a = BrushMath.worldDir(b, h.axis()); + Vector3f s0 = cam.project(ToolEvent.toExport(w[0], w[1], w[2])); + Vector3f s1 = cam.project(ToolEvent.toExport(w[0] + a[0], w[1] + a[1], w[2] + a[2])); + hScreen = new float[] {e.x(), e.y()}; + hScreenDir = new float[] {s1.x - s0.x, s1.y - s0.y}; + state = State.HANDLE; + } + + private void dragHandle(ToolEvent e) { + double l2 = hScreenDir[0] * hScreenDir[0] + hScreenDir[1] * hScreenDir[1]; + if (l2 < 1) { + return; + } + double amount = ((e.x() - hScreen[0]) * hScreenDir[0] + (e.y() - hScreen[1]) * hScreenDir[1]) / l2; + if (snapOn()) { + amount = Math.round(amount / ctx.snapping().step()) * ctx.snapping().step(); + } + Brush target = BrushMath.apply(hSnap, BrushMath.drag(hSnap, hHandle.id(), amount)); + Map<String, Object> d = BrushMath.diff(hBrush, target); + if (!d.isEmpty()) { + ctx.execute(Edits.set("Resize " + hBrush.label(), Map.of(hBrush, d), mergeKey)); + } + double[] bb = hBrush.localBounds(); + ctx.status(hHandle.tip() + ": " + fmt(bb[3] - bb[0]) + " × " + fmt(bb[5] - bb[2]) + " × " + fmt(bb[4] - bb[1])); + ctx.repaintTool(); + } + + // ------------------------------------------------------------------ drawing + + private static final int ORANGE = Legend.rgb(255, 150, 60); + + @Override + public void drawOverlay(Overlay depthTested, Overlay onTop) { + if (ctx == null || ctx.scene() == null) { + return; + } + if (state == State.FOOTPRINT && moved || state == State.HEIGHT) { + Map<String, Object> m = params(); + if (m != null) { + Brush pb = new Brush("preview", prim.kind); + m.forEach((k, v) -> pb.setRaw(k, v)); + BrushShapes.draw(pb, depthTested, Legend.withAlpha(ORANGE, 90), 0); + BrushShapes.draw(pb, onTop, 0, Legend.withAlpha(ORANGE, 235)); + } + // footprint on the base plane + double y = baseY + 0.01; + int c = Legend.withAlpha(Legend.rgb(255, 230, 120), 200); + line(onTop, ax, y, -az, bx, y, -bz, Legend.withAlpha(c, 90)); + } + if (state == State.PLACE && place != null && placeAt != null) { + Brush pb = new Brush("preview", com.eliasmueller.llamamod.editor.model.BrushKind.MESH); + place.params().forEach((k, v) -> pb.setRaw(k, v)); + double y = placeAt[1]; + if (place.onGround() && place.asset().mesh() != null) { + y -= place.asset().mesh().bounds()[1]; + } + pb.setRaw("x", placeAt[0]); + pb.setRaw("y", y); + pb.setRaw("z", placeAt[2]); + BrushShapes.draw(pb, depthTested, Legend.withAlpha(ORANGE, 60), 0); + BrushShapes.draw(pb, onTop, 0, Legend.withAlpha(ORANGE, 235)); + } + if (state == State.POLY) { + int c = Legend.withAlpha(ORANGE, 235); + double y = baseY + 0.02; + for (int i = 0; i + 1 < poly.size(); i++) { + line(onTop, poly.get(i)[0], y, -poly.get(i)[1], poly.get(i + 1)[0], y, -poly.get(i + 1)[1], c); + } + if (!poly.isEmpty()) { + double[] l = poly.getLast(); + line(onTop, l[0], y, -l[1], bx, y, -bz, Legend.withAlpha(c, 120)); + } + for (double[] p : poly) { + cross(onTop, p[0], y, -p[1], 0.12, c); + } + } + if ((state == State.IDLE || state == State.HANDLE) + && ctx.selection().size() == 1 + && ctx.selection().primary() instanceof Brush b) { + Camera cam = ctx.viewport().camera(); + for (BrushMath.Handle h : BrushMath.handles(b)) { + double[] w = BrushMath.world(b, h.local()); + Vector3f wp = ToolEvent.toExport(w[0], w[1], w[2]); + double s = cam.pixelSize(wp) * 5; + boolean hot = hover != null && hover.id().equals(h.id()) + || state == State.HANDLE + && hHandle != null + && hHandle.id().equals(h.id()); + int col = hot ? Legend.rgb(255, 235, 80) : h.axis()[1] != 0 ? Legend.rgb(90, 220, 90) : ORANGE; + cube(onTop, w[0], w[1], -w[2], s, Legend.withAlpha(col, 255), Legend.withAlpha(col, hot ? 200 : 130)); + } + } + } + + static void line(Overlay o, double ax, double ay, double az, double bx, double by, double bz, int c) { + o.line(new double[] {ax, ay, az}, new double[] {bx, by, bz}, c); + } + + static void cross(Overlay o, double x, double y, double z, double s, int c) { + line(o, x - s, y, z, x + s, y, z, c); + line(o, x, y, z - s, x, y, z + s, c); + line(o, x, y - s, z, x, y + s, z, c); + } + + static void cube(Overlay o, double x, double y, double z, double s, int line, int fill) { + o.box(x - s, y - s, z - s, x + s, y + s, z + s, line); + double[][] c = new double[8][]; + for (int i = 0; i < 8; i++) { + c[i] = new double[] {x + ((i & 1) != 0 ? s : -s), y + ((i & 2) != 0 ? s : -s), z + ((i & 4) != 0 ? s : -s)}; + } + int[][] q = {{0, 1, 3, 2}, {4, 6, 7, 5}, {0, 2, 6, 4}, {1, 5, 7, 3}, {0, 4, 5, 1}, {2, 3, 7, 6}}; + for (int[] f : q) { + o.triangle(c[f[0]], c[f[1]], c[f[2]], fill); + o.triangle(c[f[0]], c[f[2]], c[f[3]], fill); + } + } + + // ------------------------------------------------------------------ options UI + + @Override + public JComponent optionsBar(EditorContext c) { + ctx = c; + JPanel p = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 2, 0)); + ButtonGroup g = new ButtonGroup(); + for (Primitive pr : Primitive.values()) { + JToggleButton b = new JToggleButton(Icons.get(pr.icon, 18), pr == prim); + b.setToolTipText(pr.label + " – " + pr.tip); + b.setFocusable(false); + b.putClientProperty("JButton.buttonType", "toolBarButton"); + b.addActionListener(e -> setPrimitive(pr)); + g.add(b); + p.add(b); + barButtons.put(pr, b); + } + p.add(new JLabel(" Height")); + JSpinner h = GeoWidgets.spinner(opt.height, 0.05, 40, 0.25, v -> opt.height = v); + h.setToolTipText("Height used for Enter and for a click without dragging"); + p.add(h); + JCheckBox dh = new JCheckBox("Drag height", dragHeight); + dh.setToolTipText("After the footprint, move the mouse to set the height (off: use the height field)"); + dh.setFocusable(false); + dh.addActionListener(e -> dragHeight = dh.isSelected()); + p.add(dh); + JCheckBox wp = new JCheckBox("Work plane", onWorkPlane); + wp.setToolTipText("Draw on a horizontal plane at this height instead of on surfaces"); + wp.setFocusable(false); + JSpinner wps = GeoWidgets.spinner(workPlane, -20, 60, 0.25, v -> workPlane = v); + wp.addActionListener(e -> { + onWorkPlane = wp.isSelected(); + ctx.viewport().setGrid((float) (onWorkPlane ? workPlane : 0), gridBounds()); + }); + p.add(wp); + p.add(wps); + return p; + } + + private float[] gridBounds() { + var st = ctx.scene().settings(); + return new float[] {0, -st.height(), st.width(), 0}; + } + + private void setPrimitive(Primitive p) { + prim = p; + cancel(); + if (p == Primitive.WALL) { + opt.side = opt.side.isEmpty() ? "stone" : opt.side; + } + ctx.status(p.label + ": " + p.tip); + for (var m : List.of(barButtons, paletteButtons)) { + JToggleButton b = m.get(p); + if (b != null && !b.isSelected()) { + b.setSelected(true); + } + } + if (optionsHolder != null) { + optionsHolder.removeAll(); + optionsHolder.add(options(ctx)); + optionsHolder.revalidate(); + optionsHolder.repaint(); + } + } + + /** Tool section in the Inspector: primitive palette and its options. */ + @Override + public JComponent inspectorSection(EditorContext c, List<Node> selection) { + ctx = c; + JPanel root = new JPanel(); + root.setLayout(new javax.swing.BoxLayout(root, javax.swing.BoxLayout.Y_AXIS)); + root.setOpaque(false); + JPanel grid = new JPanel(new GridLayout(0, 2, 3, 3)); + grid.setOpaque(false); + grid.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6)); + ButtonGroup g = new ButtonGroup(); + for (Primitive pr : Primitive.values()) { + JToggleButton b = new JToggleButton(pr.label, Icons.get(pr.icon, 18), pr == prim); + b.setHorizontalAlignment(SwingConstants.LEFT); + b.setToolTipText(pr.tip); + b.setFocusable(false); + b.addActionListener(e -> setPrimitive(pr)); + g.add(b); + grid.add(b); + paletteButtons.put(pr, b); + } + root.add(GeoWidgets.section("Draw", grid)); + optionsHolder = new JPanel(new java.awt.BorderLayout()); + optionsHolder.setOpaque(false); + optionsHolder.setAlignmentX(0); + optionsHolder.add(options(c)); + root.add(optionsHolder); + return root; + } + + private JComponent options(EditorContext c) { + List<Object> f = new ArrayList<>(); + boolean tex = !prim.invisible() && prim != Primitive.DEATH && prim != Primitive.TREE && prim != Primitive.FENCE; + if (tex) { + f.add(prim == Primitive.FLOOR || prim == Primitive.WATER ? "Texture" : "Top"); + f.add( + prim == Primitive.WATER + ? GeoWidgets.texturePicker(c, null, opt.water, false, v -> opt.water = v) + : GeoWidgets.texturePicker(c, null, opt.top, false, v -> opt.top = v)); + if (prim != Primitive.FLOOR && prim != Primitive.WATER) { + f.add("Sides"); + f.add(GeoWidgets.texturePicker(c, null, opt.side, false, v -> opt.side = v)); + f.add("Top edge"); + f.add(GeoWidgets.texturePicker(c, null, opt.edge, true, v -> opt.edge = v)); + f.add("Bottom"); + f.add(GeoWidgets.texturePicker(c, null, opt.bottom, true, v -> opt.bottom = v)); + } + } + switch (prim) { + case PLATFORM -> { + f.add("Thickness"); + f.add(GeoWidgets.spinner(opt.thickness, 0.05, 10, 0.05, v -> opt.thickness = v)); + } + case WALL, INVISIBLE_WALL -> { + f.add("Thickness"); + f.add(GeoWidgets.spinner(opt.wallThickness, 0.02, 4, 0.05, v -> opt.wallThickness = v)); + } + case STAIRS -> { + f.add("Steps"); + f.add(GeoWidgets.intSpinner(opt.steps, 1, 64, v -> opt.steps = v)); + JCheckBox w = new JCheckBox("Walkable (invisible slope)", opt.walkable); + w.setToolTipText("The engine has no step-up logic: an invisible slope lets the llama walk up"); + w.addActionListener(e -> opt.walkable = w.isSelected()); + f.add(null); + f.add(w); + } + case CYLINDER -> { + f.add("Segments"); + f.add(GeoWidgets.intSpinner(opt.segments, 3, 64, v -> opt.segments = v)); + } + default -> {} + } + boolean collOpts = + switch (prim) { + case BOX, CYLINDER, WALL, FLOOR, RAMP -> true; + default -> false; + }; + if (collOpts) { + JCheckBox co = new JCheckBox("Collision", opt.coll); + co.addActionListener(e -> opt.coll = co.isSelected()); + f.add(null); + f.add(co); + if (prim != Primitive.FLOOR && prim != Primitive.RAMP) { + JCheckBox sc = new JCheckBox("Solid sides", opt.sideColl); + sc.setToolTipText("Off: the sides have no collision, so the llama can walk up onto low plinths"); + sc.addActionListener(e -> opt.sideColl = sc.isSelected()); + f.add(null); + f.add(sc); + } + } + boolean mat = + switch (prim) { + case BOX, PLATFORM, CYLINDER, WALL, FLOOR, INVISIBLE_WALL, INVISIBLE_BOX -> true; + default -> false; + }; + if (mat) { + f.add("Material"); + f.add(GeoWidgets.materialCombo(opt.material, v -> opt.material = v)); + } + return GeoWidgets.section(prim.label + " options", GeoWidgets.form(f.toArray()), GeoWidgets.hint(hintText())); + } + + private String hintText() { + return switch (prim.shape) { + case RECT -> "Drag the footprint on the ground or on top of other geometry"; + case LINE -> "Drag along the wall (any angle; snapped to the angle step)"; + case POLYLINE -> "Click the corner points; double-click or Enter finishes"; + case POINT -> "Click to place"; + } + + (prim.height ? ", then move the mouse to set the height and click." : ".") + + " Shift+click selects; a selected brush shows handles: drag them to resize." + + " [ ] turn by 90°, Page Up/Down raise/lower, Esc cancels. Hold Ctrl to toggle snapping."; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTransformer.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTransformer.java new file mode 100644 index 0000000..978ecf9 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/BrushTransformer.java @@ -0,0 +1,71 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.ui.NodeTransformer; +import java.util.LinkedHashMap; +import java.util.Map; +import org.joml.Vector3d; + +/** + * Gizmo/arrange support for brushes (replaces the built-in one): position = bottom centre of the + * pivot column, yaw = rotation about the vertical axis (quarter turns of unrotated brushes change + * their parameters), scale = factors in the brush's own frame (sizes of the primitive, meshes + * stretch). + */ +final class BrushTransformer implements NodeTransformer { + @Override + public boolean handles(Node n) { + if (!(n instanceof Brush b)) { + return false; + } + double[] bb = b.bounds(); + for (double d : bb) { + if (!Double.isFinite(d)) { + return false; + } + } + return true; + } + + static Vector3d pos(Brush b) { + double[] pv = b.pivot(); + double[] bb = b.bounds(); + return new Vector3d(pv[0], bb[1], pv[2]); + } + + @Override + public Vector3d position(Node n) { + return pos((Brush) n); + } + + @Override + public double yaw(Node n) { + Brush b = (Brush) n; + return b.brushKind().transformable() ? b.rotation()[1] : 0; + } + + @Override + public double[] scale(Node n) { + return new double[] {1, 1, 1}; + } + + @Override + public Map<String, Object> set(Node n, Vector3d p, double yaw, double[] scale) { + Brush b = (Brush) n; + Brush c = b; + double y0 = yaw(b); + if (!Double.isNaN(yaw) && Math.abs(yaw - y0) > 1e-9) { + c = BrushMath.apply(c, BrushMath.rotate(c, new double[] {0, 1, 0}, Math.toRadians(yaw - y0), c.pivot())); + } + if (scale != null && (scale[0] != 1 || scale[1] != 1 || scale[2] != 1)) { + c = BrushMath.apply(c, BrushMath.scale(c, scale)); + } + Vector3d cur = pos(c); + if (c == b) { + c = BrushMath.apply(b, Map.of()); + } + BrushMath.translate(c, p.x - cur.x, p.y - cur.y, p.z - cur.z); + return new LinkedHashMap<>(BrushMath.diff(b, c)); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionIndex.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionIndex.java new file mode 100644 index 0000000..8623d86 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionIndex.java @@ -0,0 +1,216 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.io.BrushCompiler; +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.function.Predicate; + +/** + * Collision polygons of the level in world space (export coordinates): the scene's tiles plus the + * tiles the brushes compile to (cached until the scene changes). Used for picking polygons and for + * the geometry checks. + */ +final class CollisionIndex { + /** One polygon: owner tile, generated (from brushes) or not, group/index in the model. */ + record Poly(Tile tile, boolean generated, int group, int index, double[][] corners, double[] normal, int material) { + double centreY() { + double y = 0; + for (double[] c : corners) { + y += c[1]; + } + return y / corners.length; + } + } + + record Hit(Poly poly, double t, double[] point) {} + + private static final Map<Scene, CollisionIndex> CACHE = new WeakHashMap<>(); + + private final Scene scene; + private BrushCompiler.Output compiled; + private List<String> warnings = new ArrayList<>(); + private final Map<Tile, List<Poly>> polys = new IdentityHashMap<>(); + private boolean brushesDirty = true; + + private CollisionIndex(Scene s) { + scene = s; + } + + /** True if the tile's origin lies within the bounds (game space, 1 cell margin). */ + static boolean near(Tile t, double[] bb) { + return t.x() >= bb[0] - 1 && t.x() <= bb[3] + 1 && -t.z() >= bb[2] - 1 && -t.z() <= bb[5] + 1; + } + + /** The enabled brush of smallest volume whose bounds (game space) pass the test, or null. */ + static Brush smallestBrush(Scene s, Predicate<double[]> inBounds) { + Brush best = null; + double vol = Double.MAX_VALUE; + for (Brush b : s.brushes()) { + double[] bb = b.bounds(); + if (b.enabled() && inBounds.test(bb)) { + double v = (bb[3] - bb[0] + 0.01) * (bb[4] - bb[1] + 0.01) * (bb[5] - bb[2] + 0.01); + if (v < vol) { + vol = v; + best = b; + } + } + } + return best; + } + + static synchronized CollisionIndex of(Scene s) { + return CACHE.computeIfAbsent(s, CollisionIndex::new); + } + + /** Scene change: forget what it touched. */ + void changed(SceneEvent e) { + Object n = e.node(); + if (n instanceof Tile t) { + polys.remove(t); + } else if (n instanceof TileModel) { + polys.keySet().removeIf(t -> !t.id().startsWith("gen:")); + } else if (n instanceof Brush + || n instanceof com.eliasmueller.llamamod.editor.model.MeshAsset + || n instanceof com.eliasmueller.llamamod.editor.model.TextureEntry + || n instanceof com.eliasmueller.llamamod.editor.model.LevelSettings) { + brushesDirty = true; + } + if (e.type() == SceneEvent.Type.RELOADED) { + polys.clear(); + brushesDirty = true; + } + } + + BrushCompiler.Output compiled() { + if (brushesDirty || compiled == null) { + brushesDirty = false; + polys.keySet().removeIf(t -> t.id().startsWith("gen:")); + warnings = new ArrayList<>(); + try { + compiled = BrushCompiler.compile(scene, warnings); + } catch (RuntimeException ex) { + warnings.add(ex.getMessage()); + compiled = new BrushCompiler.Output(List.of(), List.of(), List.of(), List.of()); + } + } + return compiled; + } + + List<String> warnings() { + compiled(); + return warnings; + } + + List<Tile> allTiles() { + List<Tile> l = new ArrayList<>(scene.tiles()); + l.addAll(compiled().tiles()); + return l; + } + + List<Poly> polys(Tile t) { + List<Poly> l = polys.get(t); + if (l == null) { + l = new ArrayList<>(); + for (TileModel.PlacedPolygon p : t.worldCollisionPolygons()) { + l.add(new Poly( + t, t.id().startsWith("gen:"), p.group(), p.index(), p.corners(), p.normal(), p.material())); + } + polys.put(t, l); + } + return l; + } + + /** Nearest polygon hit by the ray (export space), or null; skip filters out tiles. */ + Hit pick(double[] o, double[] d, java.util.function.Predicate<Tile> skip) { + Hit best = null; + for (Tile t : allTiles()) { + if (skip != null && skip.test(t)) { + continue; + } + // tiles hold their collision within a few cells of the origin + double[] c = {t.x(), t.y(), t.z()}; + double[] w = {c[0] - o[0], c[1] - o[1], c[2] - o[2]}; + double along = w[0] * d[0] + w[1] * d[1] + w[2] * d[2]; + double dist2 = w[0] * w[0] + w[1] * w[1] + w[2] * w[2] - along * along; + if (dist2 > 16 * 16) { + continue; + } + for (Poly p : polys(t)) { + double[] h = hit(p, o, d); + if (h != null && (best == null || h[3] < best.t())) { + best = new Hit(p, h[3], new double[] {h[0], h[1], h[2]}); + } + } + } + return best; + } + + /** {x, y, z, t} of the ray hitting the convex polygon, or null. */ + static double[] hit(Poly p, double[] o, double[] d) { + double[] n = p.normal(); + double[] a = p.corners()[0]; + double den = n[0] * d[0] + n[1] * d[1] + n[2] * d[2]; + if (Math.abs(den) < 1e-9) { + return null; + } + double t = ((a[0] - o[0]) * n[0] + (a[1] - o[1]) * n[1] + (a[2] - o[2]) * n[2]) / den; + if (t <= 0) { + return null; + } + double[] x = {o[0] + d[0] * t, o[1] + d[1] * t, o[2] + d[2] * t}; + double[][] c = p.corners(); + int sign = 0; + for (int i = 0; i < c.length; i++) { + double[] u = c[i], v = c[(i + 1) % c.length]; + double[] e = {v[0] - u[0], v[1] - u[1], v[2] - u[2]}; + double[] w = {x[0] - u[0], x[1] - u[1], x[2] - u[2]}; + double[] cr = {e[1] * w[2] - e[2] * w[1], e[2] * w[0] - e[0] * w[2], e[0] * w[1] - e[1] * w[0]}; + double s = cr[0] * n[0] + cr[1] * n[1] + cr[2] * n[2]; + int sg = s > 1e-9 ? 1 : s < -1e-9 ? -1 : 0; + if (sg != 0) { + if (sign != 0 && sg != sign) { + return null; + } + sign = sg; + } + } + return new double[] {x[0], x[1], x[2], t}; + } + + /** + * Height of the highest floor polygon (normal up, not material 30) at (x, z) game space below + * yMax, or NaN; cellOnly = only tiles of that grid cell (what the engine tests). + */ + double floorAt(double x, double z, double yMax, boolean cellOnly) { + int cx = (int) Math.floor(x), cz = (int) Math.floor(z); + double[] o = {x, yMax, -z}; + double[] d = {0, -1, 0}; + double best = Double.NaN; + for (Tile t : allTiles()) { + if (Math.abs(t.x() - x) > 9 || Math.abs(-t.z() - z) > 9) { + continue; + } + if (cellOnly && (t.cellX() != cx || t.cellZ() != cz)) { + continue; + } + for (Poly p : polys(t)) { + if (p.normal()[1] < 0.3 || p.material() == 30) { + continue; + } + double[] h = hit(p, o, d); + if (h != null && (Double.isNaN(best) || h[1] > best)) { + best = h[1]; + } + } + } + return best; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionTool.java new file mode 100644 index 0000000..7c8ed21 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/CollisionTool.java @@ -0,0 +1,454 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.render.Legend; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.NodeCopy; +import com.eliasmueller.llamamod.editor.ui.Tool; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import java.awt.BorderLayout; +import java.awt.Cursor; +import java.awt.GridLayout; +import java.util.ArrayList; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JToggleButton; +import javax.swing.KeyStroke; +import javax.swing.SwingConstants; +import org.joml.Vector3f; + +/** + * Collision painting: hover shows the collision polygon under the cursor; click (or drag) paints its + * material – on brushes per face (top, bottom, north, east, south, west), on tile models per + * polygon. Toggle mode switches the collision of brush faces on and off. Shift+click picks the + * material under the cursor. + */ +final class CollisionTool implements Tool { + enum Mode { + PAINT, + TOGGLE + } + + Mode mode = Mode.PAINT; + int material = 16; + boolean copyShared; + private EditorContext ctx; + private CollisionIndex.Hit hover; + private Node hoverOwner; + private Object stroke; + private final List<Object> done = new ArrayList<>(); + private final List<JToggleButton> paletteButtons = new ArrayList<>(); + private JComboBox<Integer> combo; + + @Override + public String id() { + return "geo.collision"; + } + + @Override + public String name() { + return "Collision"; + } + + @Override + public String icon() { + return "geo-collision"; + } + + @Override + public String tooltip() { + return "Collision: paint collision materials (exit floor 16, death 30, …) per polygon or brush face," + + " switch brush faces' collision on/off"; + } + + @Override + public KeyStroke shortcut() { + return KeyStroke.getKeyStroke('K', 0); + } + + @Override + public String group() { + return "geometry"; + } + + @Override + public int order() { + return 230; + } + + @Override + public Cursor cursor() { + return Cursor.getPredefinedCursor(Cursor.HAND_CURSOR); + } + + @Override + public void activate(EditorContext c) { + ctx = c; + material = c.prefs().getInt("geo.collision.material", material); + if (c.document() != null && !c.isLayerVisible("collision")) { + c.setLayerVisible("collision", true); + } + } + + @Override + public void deactivate(EditorContext c) { + c.prefs().put("geo.collision.material", material); + hover = null; + } + + private CollisionIndex.Hit pick(int x, int y) { + Scene s = ctx.scene(); + if (s == null) { + return null; + } + Vector3f[] r = ctx.viewport().camera().ray(x, y); + return CollisionIndex.of(s) + .pick( + new double[] {r[0].x, r[0].y, r[0].z}, + new double[] {r[1].x, r[1].y, r[1].z}, + t -> !t.id().startsWith("gen:") && ctx.isHidden(t)); + } + + /** The brush that generated a polygon (pick buffer first, else the smallest brush containing it). */ + private Brush ownerBrush(CollisionIndex.Hit h, int x, int y) { + Node n = ctx.pickNode(x, y); + if (n instanceof Brush b) { + return b; + } + double gx = h.point()[0], gy = h.point()[1], gz = -h.point()[2]; + double e = 0.05; + return CollisionIndex.smallestBrush( + ctx.scene(), + bb -> gx >= bb[0] - e + && gx <= bb[3] + e + && gy >= bb[1] - e + && gy <= bb[4] + e + && gz >= bb[2] - e + && gz <= bb[5] + e); + } + + /** Face class (index in "tbnesw") of a polygon of brush b, in the brush's own frame. */ + static int faceOf(Brush b, double[] exportNormal) { + double[] n = {exportNormal[0], exportNormal[1], -exportNormal[2]}; + double[] m = Brush.rotationMatrix(b.rotation()); + double[] l = { + m[0] * n[0] + m[3] * n[1] + m[6] * n[2], + m[1] * n[0] + m[4] * n[1] + m[7] * n[2], + m[2] * n[0] + m[5] * n[1] + m[8] * n[2] + }; + return com.eliasmueller.llamamod.editor.io.BrushCompiler.faceClass(l); + } + + static final String[] FACE_NAMES = {"top", "bottom", "north", "east", "south", "west"}; + + private String describe(CollisionIndex.Hit h, Node owner) { + CollisionIndex.Poly p = h.poly(); + String mat = "material " + GeoWidgets.materialName(p.material()); + if (owner instanceof Brush b) { + String face = b.brushKind().transformable() ? ", " + FACE_NAMES[faceOf(b, p.normal())] + " face" : ""; + return b.label() + face + " – " + mat; + } + Tile t = p.tile(); + int shared = 0; + for (Tile o : ctx.scene().tiles()) { + if (o.model() == t.model()) { + shared++; + } + } + return t.label() + " polygon " + p.index() + " – " + mat + + (shared > 1 ? " (model shared by " + shared + " tiles)" : ""); + } + + @Override + public void mouseMoved(ToolEvent e) { + ctx = e.ctx(); + CollisionIndex.Hit h = pick(e.x(), e.y()); + hover = h; + hoverOwner = h == null + ? null + : h.poly().generated() ? ownerBrush(h, e.x(), e.y()) : h.poly().tile(); + ctx.status(h == null ? "No collision under the cursor" : describe(h, hoverOwner)); + ctx.repaintTool(); + } + + @Override + public boolean mousePressed(ToolEvent e) { + ctx = e.ctx(); + if (ctx.scene() == null) { + return false; + } + stroke = new Object(); + done.clear(); + mouseMoved(e); + if (hover == null) { + return true; + } + if (e.shift()) { + setMaterial(hover.poly().material()); + ctx.status("Picked material " + GeoWidgets.materialName(material)); + return true; + } + apply(hover, hoverOwner); + return true; + } + + @Override + public void mouseDragged(ToolEvent e) { + if (stroke == null || mode == Mode.TOGGLE) { + return; + } + mouseMoved(e); + if (hover != null) { + apply(hover, hoverOwner); + } + } + + @Override + public void mouseReleased(ToolEvent e) { + stroke = null; + done.clear(); + } + + private void apply(CollisionIndex.Hit h, Node owner) { + CollisionIndex.Poly p = h.poly(); + if (owner instanceof Brush b) { + if (ctx.isLocked(b)) { + return; + } + if (!b.brushKind().transformable()) { + if (mode == Mode.PAINT && BrushMath.has(b, "mat") && b.i("mat") != material) { + ctx.execute(SetProp.of(b, b.prop("mat"), material, stroke)); + } + return; + } + int k = faceOf(b, p.normal()); + Object key = b.id() + "/" + k; + if (done.contains(key)) { + return; + } + done.add(key); + if (mode == Mode.TOGGLE) { + String cf = b.s("collFaces"); + char c = BrushKind.FACES.charAt(k); + String n = cf.indexOf(c) >= 0 ? cf.replace(String.valueOf(c), "") : BrushMath.mapFaces(cf + c, "", ""); + ctx.execute(SetProp.of(b, b.prop("collFaces"), n, stroke)); + ctx.status(b.label() + ": " + FACE_NAMES[k] + " face collision " + (cf.indexOf(c) >= 0 ? "off" : "on")); + return; + } + int[] fm = b.get("faceMats") instanceof int[] a && a.length == 6 + ? a.clone() + : new int[] {-1, -1, -1, -1, -1, -1}; + int brushMat = BrushMath.has(b, "mat") ? b.i("mat") : 0; + int want = material == brushMat ? -1 : material; + if (fm[k] == want) { + return; + } + fm[k] = want; + ctx.execute(SetProp.of(b, b.prop("faceMats"), fm, stroke)); + ctx.status(b.label() + ": " + FACE_NAMES[k] + " face → " + GeoWidgets.materialName(material)); + return; + } + if (p.generated()) { + return; + } + Tile t = p.tile(); + if (mode == Mode.TOGGLE) { + ctx.status("Collision of tile models cannot be switched off here – paint it 30 (no floor) or hide" + + " the tile; brush faces can be toggled"); + return; + } + if (p.material() == material || ctx.isLocked(t)) { + return; + } + Object key = t.id() + "/" + p.group() + "/" + p.index(); + if (done.contains(key)) { + return; + } + done.add(key); + TileModel m = t.model(); + int shared = 0; + for (Tile o : ctx.scene().tiles()) { + if (o.model() == m) { + shared++; + } + } + List<Command> cmds = new ArrayList<>(); + TileModel target = m; + if (copyShared && shared > 1) { + target = (TileModel) NodeCopy.duplicate(ctx.scene(), m); + cmds.add(new AddNode(target)); + cmds.add(SetProp.of(t, "model", target)); + } + cmds.add(SetProp.of( + target, + TileModel.collisionProp(p.group()), + target.withCollisionMaterial(p.group(), p.index(), material))); + ctx.execute(new Batch("Paint collision " + material, cmds, stroke)); + ctx.status(t.label() + " polygon " + p.index() + " → " + GeoWidgets.materialName(material) + + (shared > 1 && !copyShared ? " (all " + shared + " tiles of this model)" : "")); + } + + @Override + public boolean keyPressed(ToolEvent e) { + return false; + } + + @Override + public void drawOverlay(Overlay depthTested, Overlay onTop) { + if (hover == null || ctx == null || ctx.scene() == null) { + return; + } + int col = Legend.MATERIALS.getOrDefault(material, material == 0 ? Legend.FLOOR : Legend.MAT_OTHER); + if (mode == Mode.TOGGLE) { + col = Legend.rgb(255, 80, 80); + } + List<CollisionIndex.Poly> polys = new ArrayList<>(); + if (hoverOwner instanceof Brush b && b.brushKind().transformable()) { + int k = faceOf(b, hover.poly().normal()); + double[] bb = b.bounds(); + for (Tile t : CollisionIndex.of(ctx.scene()).compiled().tiles()) { + if (!CollisionIndex.near(t, bb)) { + continue; + } + for (CollisionIndex.Poly p : CollisionIndex.of(ctx.scene()).polys(t)) { + if (faceOf(b, p.normal()) == k && inside(p, bb)) { + polys.add(p); + } + } + } + } else { + polys.add(hover.poly()); + } + for (CollisionIndex.Poly p : polys) { + double[][] c = lift(p); + for (int i = 1; i + 1 < c.length; i++) { + onTop.triangle(c[0], c[i], c[i + 1], Legend.withAlpha(col, 110)); + } + for (int i = 0; i < c.length; i++) { + onTop.line(c[i], c[(i + 1) % c.length], Legend.withAlpha(Legend.rgb(255, 255, 255), 230)); + } + } + } + + private static boolean inside(CollisionIndex.Poly p, double[] bb) { + for (double[] c : p.corners()) { + double gx = c[0], gy = c[1], gz = -c[2]; + double e = 0.05; + if (gx < bb[0] - e + || gx > bb[3] + e + || gy < bb[1] - e + || gy > bb[4] + e + || gz < bb[2] - e + || gz > bb[5] + e) { + return false; + } + } + return true; + } + + private static double[][] lift(CollisionIndex.Poly p) { + double[] n = p.normal(); + double[][] c = new double[p.corners().length][]; + for (int i = 0; i < c.length; i++) { + double[] a = p.corners()[i]; + c[i] = new double[] {a[0] + n[0] * 0.015, a[1] + n[1] * 0.015, a[2] + n[2] * 0.015}; + } + return c; + } + + // ------------------------------------------------------------------ UI + + private void setMaterial(int m) { + material = m; + if (combo != null && !Integer.valueOf(m).equals(combo.getSelectedItem())) { + combo.setSelectedItem(m); + } + for (JToggleButton b : paletteButtons) { + if (Integer.valueOf(m).equals(b.getClientProperty("material")) && !b.isSelected()) { + b.setSelected(true); + } + } + } + + @Override + public JComponent optionsBar(EditorContext c) { + ctx = c; + JPanel p = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 6, 0)); + ButtonGroup g = new ButtonGroup(); + JToggleButton paint = new JToggleButton("Paint material", Icons.get("geo-paint", 14), mode == Mode.PAINT); + paint.setToolTipText("Click or drag over polygons to give them the material (Shift+click picks one)"); + JToggleButton toggle = + new JToggleButton("Toggle collision", Icons.get("geo-collision", 14), mode == Mode.TOGGLE); + toggle.setToolTipText("Click a brush face to switch its collision on or off"); + for (JToggleButton b : List.of(paint, toggle)) { + b.setFocusable(false); + g.add(b); + p.add(b); + } + paint.addActionListener(e -> mode = Mode.PAINT); + toggle.addActionListener(e -> mode = Mode.TOGGLE); + p.add(new JLabel("Material")); + combo = GeoWidgets.materialCombo(material, this::setMaterial); + p.add(combo); + JComboBox<String> shared = + new JComboBox<>(new String[] {"Shared models: change all tiles", "Shared models: copy for this tile"}); + shared.setSelectedIndex(copyShared ? 1 : 0); + shared.setFocusable(false); + shared.setToolTipText("Original tile models are often used by many tiles: change the model (all of them)" + + " or give the painted tile its own copy"); + shared.addActionListener(e -> copyShared = shared.getSelectedIndex() == 1); + p.add(shared); + return p; + } + + @Override + public JComponent inspectorSection(EditorContext c, List<Node> selection) { + ctx = c; + paletteButtons.clear(); + JPanel grid = new JPanel(new GridLayout(0, 1, 2, 2)); + grid.setOpaque(false); + grid.setBorder(BorderFactory.createEmptyBorder(2, 6, 4, 6)); + ButtonGroup g = new ButtonGroup(); + for (int m : GeoWidgets.MATERIALS.keySet()) { + JToggleButton b = new JToggleButton( + GeoWidgets.materialName(m), GeoWidgets.swatch(GeoWidgets.materialColor(m), 16), m == material); + b.putClientProperty("material", m); + b.setHorizontalAlignment(SwingConstants.LEFT); + b.setFocusable(false); + b.addActionListener(e -> setMaterial(m)); + g.add(b); + grid.add(b); + paletteButtons.add(b); + } + JPanel p = new JPanel(new BorderLayout()); + p.setOpaque(false); + p.add( + GeoWidgets.section( + "Collision materials", + grid, + GeoWidgets.hint( + "<b>16</b> exit floor: the LevelDoor only fires while the llama stands on it in the" + + " door's cell. <b>30</b> no floor: the llama falls through and dies (death planes)." + + " 17/18 only affect some objects; the other numbers are read by object scripts." + + "<br>Brushes: one material per face (top, sides, bottom) – change the whole brush with its" + + " Material property. Tiles: per polygon of the tile model.")), + BorderLayout.NORTH); + return p; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoIcons.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoIcons.java new file mode 100644 index 0000000..befb3cd --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoIcons.java @@ -0,0 +1,256 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.ui.Icons; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Line2D; +import java.awt.geom.Path2D; + +/** 16×16 vector icons of the geometry tools (isometric primitives). */ +final class GeoIcons { + private GeoIcons() {} + + private static final Color TOP = new Color(0x6FBF57); + private static final Color SIDE = new Color(0xA0764A); + private static final Color STONE = new Color(0x9A9A9A); + private static final Color WATER = new Color(0x3C82E6); + private static final Color RED = new Color(0xD93F3F); + + static void register() { + Icons.register("prim-box", (g, fg) -> block(g, 2, 5, 12, 8, TOP, SIDE)); + Icons.register("prim-platform", (g, fg) -> block(g, 1, 4, 14, 4, TOP, SIDE)); + Icons.register("prim-floor", (g, fg) -> { + Path2D p = poly(1, 10, 8, 6.5, 15, 10, 8, 13.5); + g.setColor(TOP); + g.fill(p); + g.setColor(fg); + g.draw(p); + }); + Icons.register("prim-ramp", (g, fg) -> { + Path2D side = poly(1.5, 13.5, 14.5, 13.5, 14.5, 4); + g.setColor(SIDE); + g.fill(side); + Path2D top = poly(1.5, 13.5, 14.5, 4, 14.5, 4); + g.setColor(fg); + g.draw(side); + g.setColor(new Color(0xE0C070)); + g.setStroke(new BasicStroke(2f)); + g.draw(new Line2D.Double(2, 13, 14, 4.5)); + g.draw(top); + }); + Icons.register("prim-stairs", (g, fg) -> { + Path2D p = poly(1.5, 14, 1.5, 11, 5, 11, 5, 8, 8.5, 8, 8.5, 5, 12, 5, 12, 2, 14.5, 2, 14.5, 14); + g.setColor(STONE); + g.fill(p); + g.setColor(fg); + g.draw(p); + }); + Icons.register("prim-pillar", (g, fg) -> block(g, 5, 2, 6, 12, STONE, STONE.darker())); + Icons.register("prim-cylinder", (g, fg) -> { + g.setColor(STONE.darker()); + g.fill(new java.awt.geom.Rectangle2D.Double(2, 6, 12, 5)); + g.fill(new Ellipse2D.Double(2, 8.5, 12, 5)); + g.setColor(STONE); + g.fill(new Ellipse2D.Double(2, 3.5, 12, 5)); + g.setColor(fg); + g.draw(new Ellipse2D.Double(2, 3.5, 12, 5)); + g.draw(new Line2D.Double(2, 6, 2, 11)); + g.draw(new Line2D.Double(14, 6, 14, 11)); + g.draw(new java.awt.geom.Arc2D.Double(2, 8.5, 12, 5, 180, 180, java.awt.geom.Arc2D.OPEN)); + }); + Icons.register("prim-wall", (g, fg) -> { + Path2D p = poly(1, 12, 1, 5, 12, 2, 15, 3.5, 15, 10.5, 4, 13.5); + g.setColor(new Color(0xB0A090)); + g.fill(p); + g.setColor(fg); + g.draw(p); + g.draw(new Line2D.Double(4, 13.5, 4, 6.5)); + g.draw(new Line2D.Double(1, 5, 4, 6.5)); + g.draw(new Line2D.Double(4, 6.5, 15, 3.5)); + }); + Icons.register("prim-fence", (g, fg) -> { + g.setColor(SIDE); + g.setStroke(new BasicStroke(1.8f)); + for (double x : new double[] {2.5, 8, 13.5}) { + g.draw(new Line2D.Double(x, 4, x, 14)); + } + g.draw(new Line2D.Double(1, 6.5, 15, 6.5)); + g.draw(new Line2D.Double(1, 10.5, 15, 10.5)); + }); + Icons.register("prim-tree", (g, fg) -> { + g.setColor(SIDE); + g.fill(new java.awt.geom.Rectangle2D.Double(6.5, 9, 3, 6)); + g.setColor(new Color(0x3F8A3A)); + g.fill(new java.awt.geom.Rectangle2D.Double(2.5, 2, 11, 8)); + g.setColor(fg); + g.draw(new java.awt.geom.Rectangle2D.Double(2.5, 2, 11, 8)); + }); + Icons.register("prim-water", (g, fg) -> { + g.setColor(WATER); + g.setStroke(new BasicStroke(1.8f)); + for (double y : new double[] {5, 9, 13}) { + Path2D w = new Path2D.Double(); + w.moveTo(1, y); + w.curveTo(4, y - 2.5, 5, y - 2.5, 8, y); + w.curveTo(11, y + 2.5, 12, y + 2.5, 15, y); + g.draw(w); + } + }); + Icons.register("prim-death", (g, fg) -> { + Path2D p = poly(1, 10, 8, 6.5, 15, 10, 8, 13.5); + g.setColor(new Color(255, 0, 255, 70)); + g.fill(p); + g.setColor(new Color(0xD040D0)); + g.setStroke( + new BasicStroke(1.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1, new float[] {2, 1.5f}, 0)); + g.draw(p); + g.setStroke(new BasicStroke(1.6f)); + g.draw(new Line2D.Double(6, 1.5, 10, 5.5)); + g.draw(new Line2D.Double(10, 1.5, 6, 5.5)); + }); + Icons.register("prim-invwall", (g, fg) -> { + Path2D p = poly(1, 12, 1, 4, 13, 1, 15, 2.5, 15, 10.5, 3, 13.5); + g.setColor(new Color(255, 60, 60, 60)); + g.fill(p); + g.setColor(RED); + g.setStroke( + new BasicStroke(1.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1, new float[] {2, 1.5f}, 0)); + g.draw(p); + }); + Icons.register("prim-invbox", (g, fg) -> { + g.setStroke( + new BasicStroke(1.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1, new float[] {2, 1.5f}, 0)); + outlineBlock(g, 2, 5, 12, 8, new Color(255, 60, 60, 60), RED); + }); + Icons.register("geo-collision", (g, fg) -> { + Path2D p = poly(1, 10, 8, 6.5, 15, 10, 8, 13.5); + g.setColor(new Color(40, 220, 40, 90)); + g.fill(p); + g.setColor(new Color(0x28B428)); + g.draw(p); + g.setColor(fg); + g.draw(new Line2D.Double(8, 2, 8, 9)); + g.draw(poly(5.5, 6.5, 8, 9.5, 10.5, 6.5)); + }); + Icons.register("geo-paint", (g, fg) -> { + g.setColor(new Color(0xBE3CFF)); + g.fill(poly(2, 14, 5, 9.5, 7.5, 12)); + g.setColor(fg); + g.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.draw(new Line2D.Double(6.5, 10.5, 14, 2.5)); + }); + Icons.register("geo-pick", (g, fg) -> { + g.setStroke(new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.draw(new Line2D.Double(2.5, 13.5, 9, 7)); + g.fill(poly(9, 4, 12, 7, 14.5, 4.5, 11.5, 1.5)); + g.draw(new Line2D.Double(8, 5, 11, 8)); + }); + Icons.register("geo-height", (g, fg) -> { + g.setColor(TOP); + g.fill(poly(1, 12, 6, 9.5, 11, 12, 6, 14.5)); + g.setColor(fg); + g.setStroke(new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.draw(new Line2D.Double(12.5, 2, 12.5, 14)); + g.draw(poly(10, 4.5, 12.5, 2, 15, 4.5)); + g.draw(poly(10, 11.5, 12.5, 14, 15, 11.5)); + }); + Icons.register("geo-swap", (g, fg) -> { + g.setStroke(new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.draw(new Line2D.Double(2, 5, 13, 5)); + g.draw(poly(10.5, 2.5, 13, 5, 10.5, 7.5)); + g.draw(new Line2D.Double(14, 11, 3, 11)); + g.draw(poly(5.5, 8.5, 3, 11, 5.5, 13.5)); + }); + Icons.register("geo-tiles", (g, fg) -> { + for (int i = 0; i < 2; i++) { + for (int k = 0; k < 2; k++) { + g.setColor(i == k ? TOP : SIDE); + g.fill(new java.awt.geom.Rectangle2D.Double(2 + 6.5 * i, 2 + 6.5 * k, 5.5, 5.5)); + } + } + g.setColor(fg); + g.draw(new java.awt.geom.Rectangle2D.Double(2, 2, 12, 12)); + }); + Icons.register("geo-align", (g, fg) -> { + g.draw(new Line2D.Double(2, 1.5, 2, 14.5)); + g.fill(new java.awt.geom.Rectangle2D.Double(3.5, 3, 10, 3.5)); + g.fill(new java.awt.geom.Rectangle2D.Double(3.5, 9.5, 6, 3.5)); + }); + Icons.register("geo-distribute", (g, fg) -> { + for (double x : new double[] {2, 7, 12}) { + g.fill(new java.awt.geom.Rectangle2D.Double(x, 4, 2.5, 8)); + } + g.draw(new Line2D.Double(1, 14.5, 15, 14.5)); + }); + Icons.register("geo-mirror", (g, fg) -> { + g.setStroke(new BasicStroke( + 1.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1, new float[] {1.5f, 1.5f}, 0)); + g.draw(new Line2D.Double(8, 1, 8, 15)); + g.setStroke(new BasicStroke(1.4f)); + g.fill(poly(6.5, 3, 6.5, 13, 1.5, 13)); + g.draw(poly(9.5, 3, 9.5, 13, 14.5, 13)); + }); + Icons.register("geo-array", (g, fg) -> { + for (int i = 0; i < 3; i++) { + g.setColor(new Color(fg.getRed(), fg.getGreen(), fg.getBlue(), 255 - 70 * i)); + g.fill(new java.awt.geom.Rectangle2D.Double(1.5 + 4.5 * i, 9.5 - 3.5 * i, 5, 5)); + } + }); + Icons.register("geo-grid", (g, fg) -> { + for (double v : new double[] {2, 6, 10, 14}) { + g.draw(new Line2D.Double(v, 1.5, v, 14.5)); + g.draw(new Line2D.Double(1.5, v, 14.5, v)); + } + }); + Icons.register("geo-turn", (g, fg) -> { + g.draw(new java.awt.geom.Arc2D.Double(2.5, 2.5, 11, 11, 90, 270, java.awt.geom.Arc2D.OPEN)); + g.fill(poly(8, 0.5, 11.5, 3, 8, 5.5)); + }); + } + + private static Path2D poly(double... xy) { + Path2D p = new Path2D.Double(); + p.moveTo(xy[0], xy[1]); + for (int i = 2; i < xy.length; i += 2) { + p.lineTo(xy[i], xy[i + 1]); + } + p.closePath(); + return p; + } + + /** Isometric block: x, y of the top-left, width w, side height h. */ + private static void block(Graphics2D g, double x, double y, double w, double h, Color top, Color side) { + Color fg = g.getColor(); + double d = w / 4; + Path2D t = poly(x, y + d, x + w / 2, y, x + w, y + d, x + w / 2, y + 2 * d); + Path2D l = poly(x, y + d, x + w / 2, y + 2 * d, x + w / 2, y + 2 * d + h, x, y + d + h); + Path2D r = poly(x + w / 2, y + 2 * d, x + w, y + d, x + w, y + d + h, x + w / 2, y + 2 * d + h); + g.setColor(top); + g.fill(t); + g.setColor(side); + g.fill(l); + g.setColor(side.darker()); + g.fill(r); + g.setColor(fg); + g.draw(t); + g.draw(l); + g.draw(r); + } + + private static void outlineBlock(Graphics2D g, double x, double y, double w, double h, Color fill, Color line) { + double d = w / 4; + Path2D t = poly(x, y + d, x + w / 2, y, x + w, y + d, x + w / 2, y + 2 * d); + Path2D l = poly(x, y + d, x + w / 2, y + 2 * d, x + w / 2, y + 2 * d + h, x, y + d + h); + Path2D r = poly(x + w / 2, y + 2 * d, x + w, y + d, x + w, y + d + h, x + w / 2, y + 2 * d + h); + g.setColor(fill); + g.fill(t); + g.fill(l); + g.fill(r); + g.setColor(line); + g.draw(t); + g.draw(l); + g.draw(r); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java new file mode 100644 index 0000000..453c623 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoWidgets.java @@ -0,0 +1,419 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.render.Legend; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import com.eliasmueller.llamamod.util.SwingListeners; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Graphics2D; +import java.awt.GridLayout; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.function.Consumer; +import java.util.function.IntConsumer; +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.Icon; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.JTextField; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingConstants; + +/** Small shared widgets: texture picker with swatches, collision material chooser, number fields. */ +final class GeoWidgets { + private GeoWidgets() {} + + // ------------------------------------------------------------------ materials + + /** Known collision materials (notes/debugviz.md); others are shown as "unknown n". */ + static final Map<Integer, String> MATERIALS = materials(); + + private static Map<Integer, String> materials() { + Map<Integer, String> m = new LinkedHashMap<>(); + m.put(0, "Normal"); + m.put(16, "Exit floor (LevelDoor)"); + m.put(30, "No floor / death"); + m.put(17, "Selective: only flagged objects"); + m.put(18, "Selective: not flagged objects"); + for (int k : new int[] {1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 15, 20, 26, 27, 28, 33, 255}) { + m.put(k, "special, meaning unknown"); + } + return m; + } + + static String materialName(int m) { + String s = MATERIALS.get(m); + return s != null ? m + " · " + s : m + " · unknown " + m; + } + + static Color materialColor(int m) { + int c = m == 0 ? Legend.FLOOR : Legend.MATERIALS.getOrDefault(m, Legend.MAT_OTHER); + return new Color(c >>> 24 & 0xff, c >>> 16 & 0xff, c >>> 8 & 0xff); + } + + static Icon swatch(Color c, int size) { + BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(c); + g.fillRoundRect(1, 1, size - 2, size - 2, 4, 4); + g.setColor(c.darker()); + g.drawRoundRect(1, 1, size - 3, size - 3, 4, 4); + g.dispose(); + return new ImageIcon(img); + } + + /** Combo box of collision materials (known ones + "Other…"). */ + static JComboBox<Integer> materialCombo(int value, IntConsumer onChange) { + JComboBox<Integer> cb = new JComboBox<>(); + for (int k : MATERIALS.keySet()) { + cb.addItem(k); + } + cb.addItem(-1); + if (!MATERIALS.containsKey(value)) { + cb.insertItemAt(value, cb.getItemCount() - 1); + } + cb.setSelectedItem(value); + cb.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean f) { + super.getListCellRendererComponent(l, v, i, s, f); + int m = (Integer) v; + if (m < 0) { + setText("Other material…"); + setIcon(null); + } else { + setText(materialName(m)); + setIcon(swatch(materialColor(m), 14)); + } + return this; + } + }); + cb.setFocusable(false); + cb.setPrototypeDisplayValue(30); + cb.setPreferredSize(new Dimension(150, cb.getPreferredSize().height)); + cb.setToolTipText("Collision material: 16 = exit floor (needed by the LevelDoor), 30 = no floor (death)"); + cb.addActionListener(e -> { + Integer m = (Integer) cb.getSelectedItem(); + if (m == null) { + return; + } + if (m < 0) { + String s = javax.swing.JOptionPane.showInputDialog(cb, "Material number (0-255):", "0"); + try { + int v = Math.max(0, Math.min(255, Integer.parseInt(s.trim()))); + if (((javax.swing.DefaultComboBoxModel<Integer>) cb.getModel()).getIndexOf(v) < 0) { + cb.insertItemAt(v, cb.getItemCount() - 1); + } + cb.setSelectedItem(v); + } catch (RuntimeException ex) { + cb.setSelectedIndex(0); + } + return; + } + onChange.accept(m); + }); + return cb; + } + + // ------------------------------------------------------------------ textures + + private static final Map<Scene, Map<String, BufferedImage>> CACHE = new WeakHashMap<>(); + + static void clearCache(Scene s) { + CACHE.remove(s); + } + + /** Texture index of a brush texture name (names, "#n", flats → -1), or -1. */ + static int index(String tex) { + if (tex == null || tex.isEmpty()) { + return -1; + } + if (tex.startsWith("#")) { + try { + return Integer.parseInt(tex.substring(1)); + } catch (NumberFormatException e) { + return -1; + } + } + Integer i = LevelBuilder.TEX.get(tex); + return i == null ? -1 : i; + } + + static Color flatColor(String tex) { + int[] c = LevelBuilder.FLAT.get(tex); + if (c != null) { + return new Color(c[0], c[1], c[2]); + } + if (tex.matches("rgb[0-9a-f]{6}")) { + return new Color(Integer.parseInt(tex.substring(3), 16)); + } + return null; + } + + /** Swatch image of a texture (cropped from its page), or null. */ + static BufferedImage image(Scene s, String tex) { + if (s == null || tex == null || tex.isEmpty()) { + return null; + } + Map<String, BufferedImage> m = CACHE.computeIfAbsent(s, k -> new LinkedHashMap<>()); + if (m.containsKey(tex)) { + return m.get(tex); + } + BufferedImage img = null; + Color flat = flatColor(tex); + int i = index(tex); + if (flat != null) { + img = new BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setColor(flat); + g.fillRect(0, 0, 8, 8); + g.dispose(); + } else if (i >= 0 && i < s.textures().size()) { + TextureEntry te = s.textures().get(i); + if (te.isFlat()) { + img = new BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setColor(new Color(te.flatRgb())); + g.fillRect(0, 0, 8, 8); + g.dispose(); + } else if (te.page() >= 0 && te.page() < s.pages().size()) { + TexturePage p = s.pages().get(te.page()); + int w = Math.max(1, Math.min(p.width(), te.u1() + 1) - te.u0()); + int h = Math.max(1, Math.min(p.height(), te.v1() + 1) - te.v0()); + int[] argb = p.argb(); + img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int px = te.u0() + x, py = te.v0() + y; + if (px < p.width() && py < p.height()) { + img.setRGB(x, y, argb[py * p.width() + px]); + } + } + } + } + } + m.put(tex, img); + return img; + } + + static Icon textureIcon(Scene s, String tex, int size) { + BufferedImage img = image(s, tex); + BufferedImage out = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = out.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + if (img != null) { + g.drawImage(img.getScaledInstance(size, size, Image.SCALE_FAST), 0, 0, null); + } else { + g.setColor(new Color(128, 128, 128, 90)); + g.drawRect(0, 0, size - 1, size - 1); + g.drawLine(0, size - 1, size - 1, 0); + } + g.setColor(new Color(0, 0, 0, 70)); + g.drawRect(0, 0, size - 1, size - 1); + g.dispose(); + return new ImageIcon(out); + } + + /** All texture choices of the scene: named village textures, flat colours, then "#n". */ + static List<String> choices(Scene s) { + List<String> l = new ArrayList<>(LevelBuilder.TEX.keySet()); + l.addAll(new java.util.TreeSet<>(LevelBuilder.FLAT.keySet())); + Map<Integer, Boolean> named = new IdentityHashMap<>(); + for (int v : LevelBuilder.TEX.values()) { + named.put(v, true); + } + if (s != null) { + for (int i = 0; i < s.textures().size(); i++) { + if (!LevelBuilder.TEX.containsValue(i)) { + l.add("#" + i); + } + } + } + return l; + } + + /** Button showing a texture swatch; click opens a searchable grid of all textures. */ + static JButton texturePicker( + EditorContext ctx, String label, String value, boolean allowNone, Consumer<String> set) { + JButton b = new JButton(); + String[] cur = {value}; + Runnable upd = () -> { + b.setIcon(textureIcon(ctx.scene(), cur[0], 16)); + b.setText(label == null ? shortName(cur[0]) : label); + b.setToolTipText((label == null ? "" : label + ": ") + (cur[0].isEmpty() ? "none" : cur[0]) + + " – click to choose a texture"); + }; + upd.run(); + b.setFocusable(false); + b.setHorizontalAlignment(SwingConstants.LEFT); + b.addActionListener(e -> { + JPopupMenu pop = new JPopupMenu(); + JPanel grid = new JPanel(new GridLayout(0, 8, 2, 2)); + JTextField filter = new JTextField(); + filter.putClientProperty("JTextField.placeholderText", "Filter (name or #number)"); + List<String> all = new ArrayList<>(choices(ctx.scene())); + if (allowNone) { + all.addFirst(""); + } + Runnable fill = () -> { + grid.removeAll(); + String f = filter.getText().trim().toLowerCase(Locale.ROOT); + for (String t : all) { + if (!f.isEmpty() && !t.toLowerCase(Locale.ROOT).contains(f)) { + continue; + } + JButton x = new JButton(textureIcon(ctx.scene(), t, 36)); + x.setToolTipText(t.isEmpty() ? "none (leave this part out)" : t); + x.setMargin(new java.awt.Insets(1, 1, 1, 1)); + x.setFocusable(false); + if (t.equals(cur[0])) { + x.setBorder(BorderFactory.createLineBorder(new Color(0xC8, 0x5A, 0x1E), 2)); + } + x.addActionListener(ev -> { + cur[0] = t; + upd.run(); + pop.setVisible(false); + set.accept(t); + }); + grid.add(x); + } + grid.revalidate(); + grid.repaint(); + }; + fill.run(); + SwingListeners.onTextChange(filter, fill); + JPanel p = new JPanel(new BorderLayout(4, 4)); + p.setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6)); + p.add(filter, BorderLayout.NORTH); + JScrollPane sp = new JScrollPane(grid); + sp.setPreferredSize(new Dimension(8 * 42 + 24, 300)); + sp.getVerticalScrollBar().setUnitIncrement(20); + p.add(sp, BorderLayout.CENTER); + JLabel hint = new JLabel("Names are the village textures; #n = texture number of this base level"); + hint.setFont(hint.getFont().deriveFont(11f)); + p.add(hint, BorderLayout.SOUTH); + pop.add(p); + pop.show(b, 0, b.getHeight()); + filter.requestFocusInWindow(); + }); + return b; + } + + static String shortName(String t) { + if (t == null || t.isEmpty()) { + return "none"; + } + return t.length() > 12 ? t.substring(0, 11) + "…" : t; + } + + // ------------------------------------------------------------------ numbers + + static JSpinner spinner(double value, double min, double max, double step, Consumer<Double> set) { + JSpinner sp = new JSpinner(new SpinnerNumberModel(value, min, max, step)); + JSpinner.NumberEditor ed = new JSpinner.NumberEditor(sp, "0.###"); + sp.setEditor(ed); + ed.getTextField().setColumns(4); + sp.addChangeListener(e -> set.accept(((Number) sp.getValue()).doubleValue())); + return sp; + } + + static JSpinner intSpinner(int value, int min, int max, Consumer<Integer> set) { + JSpinner sp = new JSpinner(new SpinnerNumberModel(value, min, max, 1)); + ((JSpinner.DefaultEditor) sp.getEditor()).getTextField().setColumns(3); + sp.addChangeListener(e -> set.accept(((Number) sp.getValue()).intValue())); + return sp; + } + + static JPanel row(Component... cs) { + JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2)); + p.setOpaque(false); + for (Component c : cs) { + p.add(c); + } + return p; + } + + /** Two-column form: label (String or null) and component pairs; components fill the width. */ + static JPanel form(Object... pairs) { + JPanel p = new JPanel(new java.awt.GridBagLayout()); + p.setOpaque(false); + java.awt.GridBagConstraints c = new java.awt.GridBagConstraints(); + c.insets = new java.awt.Insets(2, 6, 2, 6); + c.anchor = java.awt.GridBagConstraints.WEST; + for (int i = 0; i + 1 < pairs.length; i += 2) { + c.gridy = i / 2; + c.gridx = 0; + c.weightx = 0; + c.fill = java.awt.GridBagConstraints.NONE; + c.gridwidth = 1; + if (pairs[i] != null) { + p.add(new JLabel((String) pairs[i]), c); + c.gridx = 1; + } else { + c.gridwidth = 2; + } + c.weightx = 1; + c.fill = pairs[i + 1] instanceof javax.swing.JCheckBox + ? java.awt.GridBagConstraints.NONE + : java.awt.GridBagConstraints.HORIZONTAL; + p.add((Component) pairs[i + 1], c); + } + return p; + } + + static JLabel label(String s) { + JLabel l = new JLabel(s); + return l; + } + + static JLabel hint(String html) { + JLabel l = new JLabel("<html><div style='width:215px'>" + html + "</div></html>"); + l.setFont(l.getFont().deriveFont(11.5f)); + l.setForeground(javax.swing.UIManager.getColor("Label.disabledForeground")); + return l; + } + + /** Titled block for inspector sections. */ + static JPanel section(String title, JComponent... rows) { + JPanel p = new JPanel(); + p.setLayout(new javax.swing.BoxLayout(p, javax.swing.BoxLayout.Y_AXIS)); + p.setOpaque(false); + if (title != null) { + JLabel t = new JLabel(title); + t.setFont(t.getFont().deriveFont(java.awt.Font.BOLD)); + t.setBorder(BorderFactory.createEmptyBorder(6, 6, 2, 6)); + t.setAlignmentX(0); + p.add(t); + } + for (JComponent r : rows) { + r.setAlignmentX(0); + p.add(r); + } + return p; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java new file mode 100644 index 0000000..07dea88 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryActions.java @@ -0,0 +1,542 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.MeshAsset; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.RemoveNode; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.NodeTransformer; +import com.eliasmueller.llamamod.editor.ui.Snapping; +import com.eliasmueller.llamamod.editor.ui.tools.EditOps; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.swing.JCheckBox; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.filechooser.FileNameExtensionFilter; +import org.joml.Vector3d; + +/** Arrange and geometry commands: turn, raise, align, distribute, mirror, array, grid, death plane, re-import. */ +final class GeometryActions { + private GeometryActions() {} + + /** Selected nodes some transformer can move (not locked). */ + static List<Node> movable(EditorContext ctx) { + List<Node> l = new ArrayList<>(); + for (Node n : ctx.selection().nodes()) { + if (ctx.transformer(n) != null && !ctx.isLocked(n)) { + l.add(n); + } + } + return l; + } + + /** Game-space bounds {x0, y0, z0, x1, y1, z1} of nodes. */ + static double[] gameBounds(EditorContext ctx, List<Node> nodes) { + float[] b = ctx.bounds(nodes); + if (b == null) { + return null; + } + return new double[] {b[0], b[1], -b[5], b[3], b[4], -b[2]}; + } + + // ------------------------------------------------------------------ turn / raise + + /** Turns the selection by deg about the vertical axis through its centre. */ + static void rotateSelection(EditorContext ctx, double deg) { + List<Node> l = movable(ctx); + double[] bb = gameBounds(ctx, l); + if (l.isEmpty() || bb == null) { + ctx.status("Select something to turn"); + return; + } + double cx = (bb[0] + bb[3]) / 2, cz = (bb[2] + bb[5]) / 2; + if (ctx.snapping().enabled()) { + cx = ctx.snapping().snap(cx); + cz = ctx.snapping().snap(cz); + } + double a = Math.toRadians(deg), cos = Math.cos(a), sin = Math.sin(a); + Map<Node, Map<String, Object>> values = new LinkedHashMap<>(); + int fixed = 0; + for (Node n : l) { + NodeTransformer t = ctx.transformer(n); + Vector3d p0 = t.position(n); + double rx = p0.x - cx, rz = p0.z - cz; + Vector3d p = new Vector3d(cx + rx * cos + rz * sin, p0.y, cz - rx * sin + rz * cos); + double y0 = t.yaw(n); + if (Double.isNaN(y0)) { + fixed++; + } + values.put(n, t.set(n, p, Double.isNaN(y0) ? y0 : y0 + deg, null)); + } + values.values().removeIf(Map::isEmpty); + if (!values.isEmpty()) { + ctx.execute(Edits.set("Turn " + (deg > 0 ? "+" : "") + (int) deg + "°", values, null)); + } + ctx.status("Turned " + l.size() + " item(s) by " + (int) deg + "°" + + (fixed > 0 ? " (" + fixed + " cannot turn, only moved)" : "")); + } + + static void raise(EditorContext ctx, double dy) { + List<Node> l = movable(ctx); + if (l.isEmpty()) { + return; + } + ctx.execute(Edits.translate(l, 0, dy, 0, null)); + ctx.status((dy > 0 ? "Raised" : "Lowered") + " " + l.size() + " item(s) by " + BrushTool.fmt(Math.abs(dy))); + } + + // ------------------------------------------------------------------ align / distribute + + static final String[] AXES = {"X", "Y", "Z"}; + + /** Aligns the selection's min (mode −1), centre (0) or max (+1) on an axis to the primary node. */ + static void align(EditorContext ctx, int axis, int mode) { + List<Node> l = movable(ctx); + Node ref = ctx.selection().primary(); + if (l.size() < 2 || ref == null) { + ctx.status("Align: select two or more items (the last selected one is the reference)"); + return; + } + double[] rb = gameBounds(ctx, List.of(ref)); + double target = value(rb, axis, mode); + List<Command> cmds = new ArrayList<>(); + for (Node n : l) { + if (n == ref) { + continue; + } + double[] b = gameBounds(ctx, List.of(n)); + if (b == null) { + continue; + } + double d = target - value(b, axis, mode); + if (Math.abs(d) > 1e-9) { + cmds.add(Edits.translate(List.of(n), axis == 0 ? d : 0, axis == 1 ? d : 0, axis == 2 ? d : 0, null)); + } + } + if (!cmds.isEmpty()) { + ctx.execute(new Batch("Align " + AXES[axis], cmds)); + } + ctx.status("Aligned " + (l.size() - 1) + " item(s) on " + AXES[axis] + " to " + ref.label()); + } + + static double value(double[] b, int axis, int mode) { + return mode < 0 ? b[axis] : mode > 0 ? b[axis + 3] : (b[axis] + b[axis + 3]) / 2; + } + + /** Spaces the centres of three or more items evenly between the outermost ones. */ + static void distribute(EditorContext ctx, int axis) { + List<Node> l = movable(ctx); + if (l.size() < 3) { + ctx.status("Distribute: select three or more items"); + return; + } + List<Object[]> items = new ArrayList<>(); + for (Node n : l) { + double[] b = gameBounds(ctx, List.of(n)); + if (b != null) { + items.add(new Object[] {n, value(b, axis, 0)}); + } + } + items.sort(Comparator.comparingDouble(o -> (Double) o[1])); + double a = (Double) items.getFirst()[1], z = (Double) items.getLast()[1]; + List<Command> cmds = new ArrayList<>(); + for (int i = 1; i < items.size() - 1; i++) { + double want = a + (z - a) * i / (items.size() - 1); + double d = want - (Double) items.get(i)[1]; + if (Math.abs(d) > 1e-9) { + Node n = (Node) items.get(i)[0]; + cmds.add(Edits.translate(List.of(n), axis == 0 ? d : 0, axis == 1 ? d : 0, axis == 2 ? d : 0, null)); + } + } + if (!cmds.isEmpty()) { + ctx.execute(new Batch("Distribute " + AXES[axis], cmds)); + } + ctx.status("Distributed " + l.size() + " items evenly on " + AXES[axis]); + } + + // ------------------------------------------------------------------ mirror + + /** Mirrors the selection about the plane through its centre (axis 0 = x, 2 = z). */ + static void mirror(EditorContext ctx, int axis) { + List<Node> l = movable(ctx); + double[] bb = gameBounds(ctx, l); + if (l.isEmpty() || bb == null) { + ctx.status("Select something to mirror"); + return; + } + double c = (bb[axis] + bb[axis + 3]) / 2; + Map<Node, Map<String, Object>> values = new LinkedHashMap<>(); + int models = 0; + for (Node n : l) { + if (n instanceof Brush b) { + values.put(n, BrushMath.mirror(b, axis, c)); + continue; + } + NodeTransformer t = ctx.transformer(n); + Vector3d p = t.position(n); + if (axis == 0) { + p.x = 2 * c - p.x; + } else { + p.z = 2 * c - p.z; + } + double y = t.yaw(n); + double ny = Double.isNaN(y) ? y : axis == 0 ? -y : 180 - y; + values.put(n, t.set(n, p, Double.isNaN(ny) ? ny : ((ny % 360) + 360) % 360, null)); + if (n instanceof Tile) { + models++; + } + } + values.values().removeIf(Map::isEmpty); + if (!values.isEmpty()) { + ctx.execute(Edits.set("Mirror " + AXES[axis], values, null)); + } + ctx.status("Mirrored " + l.size() + " item(s) on " + AXES[axis] + + (models > 0 + ? " – tile models cannot be mirrored (engine): " + models + " tile(s) were moved and turned" + : "")); + } + + // ------------------------------------------------------------------ array + + static void array(EditorContext ctx) { + Scene s = ctx.scene(); + List<Node> sel = new ArrayList<>(ctx.selection().nodes()); + if (s == null || sel.isEmpty()) { + ctx.status("Array: select what to repeat"); + return; + } + double[] bb = gameBounds(ctx, movable(ctx)); + double w = bb == null ? 1 : Math.max(ctx.snapping().step(), bb[3] - bb[0]); + JSpinner count = + new JSpinner(new javax.swing.SpinnerNumberModel(ctx.prefs().getInt("geo.array.count", 4), 1, 200, 1)); + JSpinner dx = num(ctx.prefs().getDouble("geo.array.dx", w)); + JSpinner dy = num(ctx.prefs().getDouble("geo.array.dy", 0)); + JSpinner dz = num(ctx.prefs().getDouble("geo.array.dz", 0)); + JSpinner yaw = num(ctx.prefs().getDouble("geo.array.yaw", 0)); + JCheckBox circle = new JCheckBox( + "Turn about the selection centre (circular array)", ctx.prefs().getBool("geo.array.circle", false)); + JPanel p = form( + new Object[] {"Copies", count}, + new Object[] {"Offset X (cells)", dx}, + new Object[] {"Offset Y", dy}, + new Object[] {"Offset Z", dz}, + new Object[] {"Turn per copy (°)", yaw}, + new Object[] {"", circle}, + new Object[] { + "", + new JLabel("<html><small>Each copy is offset (and turned) once more than the previous one." + + "<br>Objects get a copy of their waypoint chain.</small></html>") + }); + if (JOptionPane.showConfirmDialog( + ctx.frame(), p, "Array duplicate", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) + != JOptionPane.OK_OPTION) { + return; + } + int n = (Integer) count.getValue(); + double ox = d(dx), oy = d(dy), oz = d(dz), turn = d(yaw); + ctx.prefs().put("geo.array.count", n); + ctx.prefs().put("geo.array.dx", ox); + ctx.prefs().put("geo.array.dy", oy); + ctx.prefs().put("geo.array.dz", oz); + ctx.prefs().put("geo.array.yaw", turn); + ctx.prefs().put("geo.array.circle", circle.isSelected()); + double cx = bb == null ? 0 : (bb[0] + bb[3]) / 2, cz = bb == null ? 0 : (bb[2] + bb[5]) / 2; + List<Command> cmds = new ArrayList<>(); + List<Node> made = new ArrayList<>(); + for (int k = 1; k <= n; k++) { + List<Node> out = new ArrayList<>(); + cmds.addAll(EditOps.copies(ctx, s, sel, new Vector3d(ox * k, oy * k, oz * k), out)); + if (turn != 0) { + double deg = turn * k, a = Math.toRadians(deg), cos = Math.cos(a), sin = Math.sin(a); + for (Node c : out) { + NodeTransformer t = ctx.transformer(c); + if (t == null) { + continue; + } + Vector3d p0 = t.position(c); + Vector3d np = p0; + if (circle.isSelected()) { + double rx = p0.x - (cx + ox * k), rz = p0.z - (cz + oz * k); + np = new Vector3d(cx + ox * k + rx * cos + rz * sin, p0.y, cz + oz * k - rx * sin + rz * cos); + } + double y0 = t.yaw(c); + t.set(c, np, Double.isNaN(y0) ? y0 : y0 + deg, null).forEach((key, v) -> c.setRaw(key, v)); + } + } + made.addAll(out); + } + if (cmds.isEmpty()) { + ctx.status("Nothing in the selection can be duplicated"); + return; + } + ctx.execute(new Batch("Array × " + n, cmds)); + ctx.selection().set(made, ctx); + ctx.status("Created " + n + " copies (" + made.size() + " items)"); + } + + private static JSpinner num(double v) { + JSpinner s = new JSpinner(new javax.swing.SpinnerNumberModel(v, -1000.0, 1000.0, 0.25)); + s.setEditor(new JSpinner.NumberEditor(s, "0.###")); + return s; + } + + private static double d(JSpinner s) { + try { + s.commitEdit(); + } catch (java.text.ParseException ignored) { + // keep the last valid value + } + return ((Number) s.getValue()).doubleValue(); + } + + static JPanel form(Object[]... rows) { + JPanel p = new JPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(3, 4, 3, 4); + c.anchor = GridBagConstraints.WEST; + int y = 0; + for (Object[] r : rows) { + c.gridy = y++; + c.gridx = 0; + c.fill = GridBagConstraints.NONE; + c.weightx = 0; + p.add(new JLabel((String) r[0]), c); + c.gridx = 1; + c.fill = GridBagConstraints.HORIZONTAL; + c.weightx = 1; + p.add((JComponent) r[1], c); + } + return p; + } + + // ------------------------------------------------------------------ grid + + static void gridSettings(EditorContext ctx) { + Snapping sn = ctx.snapping(); + JSpinner step = new JSpinner(new javax.swing.SpinnerNumberModel(sn.step(), 1 / 64.0, 16.0, 0.125)); + step.setEditor(new JSpinner.NumberEditor(step, "0.#####")); + JSpinner angle = new JSpinner(new javax.swing.SpinnerNumberModel(sn.angle(), 0.5, 180.0, 5.0)); + JSpinner scale = new JSpinner(new javax.swing.SpinnerNumberModel(sn.scale(), 0.01, 4.0, 0.05)); + JCheckBox en = new JCheckBox("Snapping on (G; hold Ctrl while dragging to invert)", sn.enabled()); + JCheckBox grid = new JCheckBox("Show the grid", ctx.isLayerVisible("grid")); + JPanel p = form( + new Object[] {"", en}, + new Object[] {"Move step (cells)", step}, + new Object[] {"Angle step (°)", angle}, + new Object[] {"Scale step", scale}, + new Object[] {"", grid}, + new Object[] { + "", + new JLabel( + "<html><small>1 cell = one grid square of the level map (4096 game units)." + + "<br>The engine places tiles on whole cells; brushes and objects can sit anywhere.</small></html>") + }); + if (JOptionPane.showConfirmDialog( + ctx.frame(), p, "Grid & snapping", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) + != JOptionPane.OK_OPTION) { + return; + } + sn.setEnabled(en.isSelected()); + sn.setStep(d(step)); + sn.setAngle(d(angle)); + sn.setScale(d(scale)); + ctx.setLayerVisible("grid", grid.isSelected()); + ctx.status(String.format( + Locale.ROOT, + "Snapping: %s cells, %s°, scale %s", + BrushTool.fmt(d(step)), + BrushTool.fmt(d(angle)), + BrushTool.fmt(d(scale)))); + } + + // ------------------------------------------------------------------ death plane + + /** Tiles of the level's death plane: a model without faces whose collision is all material 30. */ + static List<Tile> killTiles(Scene s) { + List<Tile> l = new ArrayList<>(); + for (Tile t : s.tiles()) { + if (isKillModel(t.model())) { + l.add(t); + } + } + return l; + } + + static boolean isKillModel(TileModel m) { + if (m == null || m.faceCount() != 0 || m.collisionCount() == 0) { + return false; + } + for (int g = 0; g < 3; g++) { + for (int k = 0; k < m.collisionCount(g); k++) { + if (m.collisionMaterial(g, k) != 30) { + return false; + } + } + } + return true; + } + + static double killLocalY(TileModel m) { + for (int g = 0; g < 3; g++) { + if (m.collisionCount(g) > 0) { + double[][] poly = com.eliasmueller.llamamod.format.Collision.polygon(m.collisionPolygon(g, 0)); + if (poly.length > 0) { + return poly[0][1]; + } + } + } + return 0; + } + + static void deathPlane(EditorContext ctx) { + Scene s = ctx.scene(); + if (s == null) { + return; + } + List<Tile> tiles = killTiles(s); + List<Brush> planes = new ArrayList<>(); + for (Brush b : s.brushes()) { + if (b.brushKind() == BrushKind.KILL_PLANE) { + planes.add(b); + } + } + double cur = Double.NaN; + if (!planes.isEmpty()) { + cur = planes.getFirst().d("y"); + } else if (!tiles.isEmpty()) { + cur = tiles.getFirst().y() + killLocalY(tiles.getFirst().model()); + } + JSpinner h = num(Double.isNaN(cur) ? -1.5 : cur); + JCheckBox add = new JCheckBox("Add a death plane over the whole grid", planes.isEmpty() && tiles.isEmpty()); + String info = "<html>" + tiles.size() + " death-plane tile(s) of the original level, " + planes.size() + + " death-plane brush(es).<br><small>Falling onto material 30 (no floor) loses a life; it is placed in" + + " every cell<br>that is not completely covered by floor. Water brushes carry their own plane" + + " just above the water.</small></html>"; + JPanel p = form(new Object[] {"", new JLabel(info)}, new Object[] {"Height", h}, new Object[] {"", add}); + if (JOptionPane.showConfirmDialog( + ctx.frame(), p, "Death plane height", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) + != JOptionPane.OK_OPTION) { + return; + } + double y = d(h); + List<Command> cmds = new ArrayList<>(); + for (Brush b : planes) { + cmds.add(SetProp.of(b, "y", y)); + } + for (Tile t : tiles) { + cmds.add(SetProp.of(t, "y", (double) (float) (y - killLocalY(t.model())))); + } + if (add.isSelected()) { + cmds.add(Edits.addBrush( + s, + BrushKind.KILL_PLANE, + Map.of( + "x0", + 0.0, + "z0", + 0.0, + "x1", + (double) s.settings().width(), + "z1", + (double) s.settings().height(), + "y", + y))); + } + if (!cmds.isEmpty()) { + ctx.execute(new Batch("Death plane height " + BrushTool.fmt(y), cmds)); + } + ctx.status("Death plane at " + BrushTool.fmt(y)); + } + + // ------------------------------------------------------------------ re-import + + static MeshAsset selectedAsset(EditorContext ctx) { + for (Node n : ctx.selection().nodes()) { + if (n instanceof Brush b && b.brushKind() == BrushKind.MESH && b.ref("asset") instanceof MeshAsset a) { + return a; + } + if (n instanceof MeshAsset a) { + return a; + } + } + return null; + } + + /** Replaces a mesh asset with a new import of a file; all brushes using it switch to the new one. */ + static void reimport(EditorContext ctx) { + Scene s = ctx.scene(); + MeshAsset old = selectedAsset(ctx); + if (s == null || old == null) { + ctx.status("Select a mesh brush to re-import"); + return; + } + String src = ctx.prefs().getString("geo.meshSource." + old.file(), ""); + Path f = null; + if (!src.isEmpty() && java.nio.file.Files.isRegularFile(Path.of(src))) { + int r = JOptionPane.showConfirmDialog( + ctx.frame(), + "Reload " + src + " ?\n(No = choose another file)", + "Re-import mesh", + JOptionPane.YES_NO_CANCEL_OPTION); + if (r == JOptionPane.CANCEL_OPTION || r == JOptionPane.CLOSED_OPTION) { + return; + } + if (r == JOptionPane.YES_OPTION) { + f = Path.of(src); + } + } + if (f == null) { + JFileChooser fc = new JFileChooser( + src.isEmpty() + ? ctx.prefs().getString("importDir", System.getProperty("user.home")) + : Path.of(src).getParent().toString()); + fc.setFileFilter(new FileNameExtensionFilter("3D models (OBJ, glTF)", "obj", "gltf", "glb")); + if (fc.showOpenDialog(ctx.frame()) != JFileChooser.APPROVE_OPTION) { + return; + } + f = fc.getSelectedFile().toPath(); + } + try { + MeshAsset a = com.eliasmueller.llamamod.editor.io.MeshImport.importFile(s, f); + a.setRaw("upAxis", old.upAxis()); + a.setRaw("unitScale", old.unitScale()); + a.setRaw("name", old.name()); + com.eliasmueller.llamamod.editor.io.MeshImport.load(s, a); + List<Command> cmds = new ArrayList<>(); + cmds.add(new AddNode(a)); + for (Brush b : s.brushes()) { + if (b.ref("asset") == old) { + cmds.add(SetProp.of(b, "asset", a)); + } + } + cmds.add(new RemoveNode(s, old)); + ctx.execute(new Batch("Re-import " + f.getFileName(), cmds)); + ctx.prefs().put("geo.meshSource." + a.file(), f.toString()); + ctx.status("Re-imported " + f.getFileName() + " (" + a.mesh().triangleCount() + " triangles)"); + } catch (java.io.IOException | RuntimeException e) { + ctx.error("Re-import failed", e); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecks.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecks.java new file mode 100644 index 0000000..1bc522a --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecks.java @@ -0,0 +1,208 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.ui.LevelCheck; +import com.eliasmueller.llamamod.editor.ui.Problem; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** + * Geometry problems: tiles outside the grid, tile models over the engine's vertex limit, duplicate + * (overlapping) tiles, no floor collision under the player start or no exit floor (16) under the + * LevelDoor, small gaps between neighbouring brush floors, brush compile warnings. + */ +final class GeometryChecks implements LevelCheck { + static final int MAX_VERTICES = 8000; + private static final int MAX_EACH = 12; + + @Override + public void check(Scene s, List<Problem> out) { + int w = s.settings().width(), h = s.settings().height(); + // tiles outside the grid + int n = 0; + for (Tile t : s.tiles()) { + if (t.cellX() < 0 || t.cellZ() < 0 || t.cellX() >= w || t.cellZ() >= h) { + if (n++ < MAX_EACH) { + out.add(Problem.error(t.label() + " lies outside the " + w + "×" + h + " grid", t)); + } + } + } + more(out, n, "tiles outside the grid"); + // vertex limit of scene models + Map<TileModel, Tile> firstUse = new IdentityHashMap<>(); + for (Tile t : s.tiles()) { + if (t.model() != null) { + firstUse.putIfAbsent(t.model(), t); + } + } + for (TileModel m : s.tileModels()) { + if (m.vertexCount() > MAX_VERTICES) { + out.add(Problem.error( + m.label() + " has " + m.vertexCount() + " vertices – the engine allows at most " + MAX_VERTICES + + " per tile model", + firstUse.getOrDefault(m, null))); + } + } + CollisionIndex idx = CollisionIndex.of(s); + var compiled = idx.compiled(); + for (TileModel m : compiled.models()) { + if (m.vertexCount() > MAX_VERTICES) { + Tile t = compiled.tiles().stream() + .filter(x -> x.model() == m) + .findFirst() + .orElse(null); + Brush b = t == null ? null : owner(s, t); + out.add(Problem.error( + "Brush geometry in cell " + (t == null ? "?" : t.cellX() + "," + t.cellZ()) + + " needs " + m.vertexCount() + " vertices in one tile (max " + MAX_VERTICES + + ") – simplify or scale up the mesh", + b)); + } + } + for (String wmsg : idx.warnings()) { + out.add(Problem.warning("Brushes: " + wmsg, null)); + } + // duplicate tiles + Map<String, Tile> seen = new HashMap<>(); + n = 0; + for (Tile t : s.tiles()) { + String key = t.cellX() + "," + t.cellZ() + "," + Math.round(t.y() * 100) + "," + t.rot() + "," + + (t.model() == null ? "" : t.model().id()); + Tile o = seen.putIfAbsent(key, t); + if (o != null && n++ < MAX_EACH) { + out.add(Problem.warning( + t.label() + " overlaps " + o.id() + " exactly (same model, cell, height and" + + " rotation) – z-fighting and double collision", + t)); + } + } + more(out, n, "overlapping tiles"); + // player start and exit + for (Strat st : s.strats()) { + String cat = StratCatalog.category(st.type()); + if (cat.equals("player")) { + double f = idx.floorAt(st.gx(), st.gz(), st.gy() + 1.0, true); + if (Double.isNaN(f)) { + out.add(Problem.error( + "No floor collision under the player start (cell " + (int) Math.floor(st.gx()) + "," + + (int) Math.floor(st.gz()) + "): the game crashes while loading the level" + + " when the start cell has no floor polygon at or below the start (above" + + " a death floor the llama dies at once)", + st)); + } else if (st.gy() - f > 1.5) { + out.add(Problem.warning( + "The player start is " + BrushTool.fmt(st.gy() - f) + " cells above the floor", st)); + } + } else if (cat.equals("exit") && st.type().equals("LevelDoor")) { + if (!hasMaterial(idx, st.gx(), st.gz(), 16)) { + out.add(Problem.warning( + "No exit floor (collision material 16) in the LevelDoor's cell " + + (int) Math.floor(st.gx()) + "," + (int) Math.floor(st.gz()) + + " – the door only fires while the llama stands on material 16 there" + + " (Collision tool: paint 16)", + st)); + } + } + } + gaps(s, out); + } + + private static void more(List<Problem> out, int n, String what) { + if (n > MAX_EACH) { + out.add(Problem.warning("… and " + (n - MAX_EACH) + " more " + what, null)); + } + } + + static Brush owner(Scene s, Tile t) { + return CollisionIndex.smallestBrush(s, bb -> CollisionIndex.near(t, bb)); + } + + /** Any height: the LevelDoor compares grid cells only (t1l3m002–4: exit floor 3 cells below it). */ + static boolean hasMaterial(CollisionIndex idx, double x, double z, int mat) { + int cx = (int) Math.floor(x), cz = (int) Math.floor(z); + for (Tile t : idx.allTiles()) { + if (t.cellX() != cx || t.cellZ() != cz) { + continue; + } + for (CollisionIndex.Poly p : idx.polys(t)) { + if (p.material() == mat) { + return true; + } + } + if (t.swapHasMaterial(mat)) { + return true; + } + } + return false; + } + + /** Top rectangle {x0, z0, x1, z1, y} of an unrotated floor-like brush, or null. */ + static double[] top(Brush b) { + if (!b.enabled() || b.hasRotation()) { + return null; + } + return switch (b.brushKind()) { + case FLOOR -> new double[] {mn(b, "x"), mn(b, "z"), mx(b, "x"), mx(b, "z"), b.d("y")}; + case BOX -> !b.visible() + ? null + : new double[] {mn(b, "x"), mn(b, "z"), mx(b, "x"), mx(b, "z"), Math.max(b.d("y0"), b.d("y1"))}; + case PLATFORM -> new double[] {b.d("x"), b.d("z"), b.d("x") + b.d("w"), b.d("z") + b.d("d"), b.d("y")}; + default -> null; + }; + } + + private static double mn(Brush b, String a) { + return Math.min(b.d(a + "0"), b.d(a + "1")); + } + + private static double mx(Brush b, String a) { + return Math.max(b.d(a + "0"), b.d(a + "1")); + } + + /** Neighbouring floors at the same height with a narrow gap between them (the llama trips into it). */ + static void gaps(Scene s, List<Problem> out) { + List<Brush> l = new ArrayList<>(); + List<double[]> tops = new ArrayList<>(); + for (Brush b : s.brushes()) { + double[] t = top(b); + if (t != null && b.brushKind() != BrushKind.KILL_PLANE) { + l.add(b); + tops.add(t); + } + } + int n = 0; + for (int i = 0; i < l.size() && l.size() < 2000; i++) { + for (int k = i + 1; k < l.size(); k++) { + double[] a = tops.get(i), b = tops.get(k); + if (Math.abs(a[4] - b[4]) > 0.05) { + continue; + } + double gx = Math.max(a[0], b[0]) - Math.min(a[2], b[2]); + double gz = Math.max(a[1], b[1]) - Math.min(a[3], b[3]); + double gap = -1; + if (gx > 1e-4 && gx < 0.2 && gz < -0.2) { + gap = gx; + } else if (gz > 1e-4 && gz < 0.2 && gx < -0.2) { + gap = gz; + } + if (gap > 0 && n++ < MAX_EACH) { + out.add(Problem.warning( + "Gap of " + BrushTool.fmt(gap) + " between " + + l.get(i).label() + " and " + l.get(k).label() + + " – close it (drag a handle) or make it a real jump", + l.get(k))); + } + } + } + more(out, n, "gaps"); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryEditorPlugin.java new file mode 100644 index 0000000..dbc6552 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryEditorPlugin.java @@ -0,0 +1,233 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.ui.ActionSpec; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.EditorExtensions; +import com.eliasmueller.llamamod.editor.ui.EditorPlugin; +import com.eliasmueller.llamamod.editor.ui.LayerSpec; +import com.eliasmueller.llamamod.editor.ui.Tool; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import org.joml.Vector3f; + +/** + * Geometry toolset: Brush tool (draw primitives, resize handles), Tiles tool (paint/pick/erase/raise/ + * swap tile models), Collision tool (materials per polygon/face, face collision), 3-axis rotation of + * brushes, arrange commands (align, distribute, mirror, array, 90° turns), grid settings, death plane + * height, mesh import dialog with re-import, geometry checks. See notes/editor-tools-geometry.md. + */ +public final class GeometryEditorPlugin implements EditorPlugin { + private static final Predicate<EditorContext> DOC = c -> c.document() != null; + private static final Predicate<EditorContext> SEL = + c -> c.document() != null && !c.selection().isEmpty(); + + @Override + public void install(EditorExtensions x) { + GeoIcons.register(); + BrushTool brush = new BrushTool(); + TilePaintTool tiles = new TilePaintTool(); + CollisionTool coll = new CollisionTool(); + x.tool(brush); + x.tool(tiles); + x.tool(coll); + for (Tool t : x.tools()) { + if (t.id().equals("rotate") && !(t instanceof RotateTool3D)) { + x.tool(new RotateTool3D(t)); + break; + } + } + x.transformer(new BrushTransformer()); + x.inspector(new GeometrySections.BrushSection()); + x.inspector(new GeometrySections.TileSection(tiles)); + x.layer(new LayerSpec( + GeometrySections.LAYER_INVISIBLE, "Invisible collision volumes", "prim-invbox", true, 45)); + x.layer(new LayerSpec(GeometrySections.LAYER_SWAP, "Tile swap alternatives", "geo-swap", true, 46)); + x.renderContributor(new GeometrySections.InvisibleBrushes()); + x.renderContributor(new GeometrySections.SwapPreview()); + x.check(new GeometryChecks()); + x.onOpen(ctx -> ctx.addSceneListener(e -> { + if (ctx.scene() != null) { + CollisionIndex.of(ctx.scene()).changed(e); + } + LiveSection.changed(e); + })); + x.assetTab(new com.eliasmueller.llamamod.editor.ui.AssetTab( + "geoLibrary", "Library", "geo-tiles", 60, LibraryTab::new)); + com.eliasmueller.llamamod.editor.ui.AssetDrop.register( + "geolib", (ctx, data, g) -> LibraryTab.insertAt(ctx, java.nio.file.Path.of(data), g)); + actions(x, brush); + } + + private static void actions(EditorExtensions x, BrushTool brush) { + String m = "Geometry"; + x.action(ActionSpec.of("geo.tool.brush", m, 10, 10, "Brush tool", "prim-box", null, c -> c.setTool("geo.brush")) + .enabledWhen(DOC) + .tip("Draw blocks, platforms, ramps, stairs, walls, water … (B)")); + x.action( + ActionSpec.of("geo.tool.tiles", m, 10, 20, "Tiles tool", "geo-tiles", null, c -> c.setTool("geo.tiles")) + .enabledWhen(DOC) + .tip("Paint, pick, erase, raise and swap tiles (T)")); + x.action(ActionSpec.of( + "geo.tool.collision", + m, + 10, + 30, + "Collision tool", + "geo-collision", + null, + c -> c.setTool("geo.collision")) + .enabledWhen(DOC) + .tip("Paint collision materials, switch brush faces' collision (K)")); + + x.action(ActionSpec.of( + "geo.turnLeft", + m, + 20, + 10, + "Turn left 90°", + "geo-turn", + "ctrl OPEN_BRACKET", + c -> GeometryActions.rotateSelection(c, -90)) + .enabledWhen(SEL)); + x.action(ActionSpec.of( + "geo.turnRight", + m, + 20, + 20, + "Turn right 90°", + "rotate", + "ctrl CLOSE_BRACKET", + c -> GeometryActions.rotateSelection(c, 90)) + .enabledWhen(SEL)); + x.action(ActionSpec.of( + "geo.mirrorX", + m, + 20, + 30, + "Mirror X (east ↔ west)", + "geo-mirror", + null, + c -> GeometryActions.mirror(c, 0)) + .enabledWhen(SEL)); + x.action(ActionSpec.of( + "geo.mirrorZ", + m, + 20, + 40, + "Mirror Z (north ↔ south)", + "geo-mirror", + null, + c -> GeometryActions.mirror(c, 2)) + .enabledWhen(SEL)); + x.action(ActionSpec.of( + "geo.array", m, 20, 50, "Array duplicate…", "geo-array", "ctrl shift D", GeometryActions::array) + .enabledWhen(SEL) + .tip("n copies with an offset (and turn) each")); + + String al = m + "/Align"; + String[] modes = {"min", "centre", "max"}; + String[][] labels = { + {"West (min X)", "Centre X", "East (max X)"}, + {"Bottom", "Middle Y", "Top"}, + {"North (min Z)", "Centre Z", "South (max Z)"} + }; + for (int a = 0; a < 3; a++) { + for (int k = 0; k < 3; k++) { + int axis = a, mode = k - 1; + x.action(ActionSpec.of( + "geo.align." + GeometryActions.AXES[a] + modes[k], + al, + 10 + 10 * a, + k, + "Align " + labels[a][k], + k == 0 && a == 0 ? "geo-align" : null, + null, + c -> GeometryActions.align(c, axis, mode)) + .enabledWhen(SEL) + .tip("Line the selection up with the last selected item")); + } + } + x.action(ActionSpec.of( + "geo.distributeX", + al, + 50, + 10, + "Distribute along X", + "geo-distribute", + null, + c -> GeometryActions.distribute(c, 0)) + .enabledWhen(SEL)); + x.action(ActionSpec.of( + "geo.distributeZ", + al, + 50, + 20, + "Distribute along Z", + "geo-distribute", + null, + c -> GeometryActions.distribute(c, 2)) + .enabledWhen(SEL)); + + x.action(ActionSpec.of( + "geo.grid", m, 40, 10, "Grid & snapping…", "geo-grid", null, GeometryActions::gridSettings) + .tip("Move/angle/scale steps (any value), grid display")); + x.action(ActionSpec.of( + "geo.deathPlane", + m, + 40, + 20, + "Death plane height…", + "prim-death", + null, + GeometryActions::deathPlane) + .enabledWhen(DOC)); + x.action(ActionSpec.of("geo.reimport", m, 40, 30, "Re-import mesh…", "import", null, GeometryActions::reimport) + .enabledWhen(c -> c.document() != null && GeometryActions.selectedAsset(c) != null) + .tip("Load the selected mesh brush's file again (or another file) and keep its placement")); + + x.action(ActionSpec.of( + "geo.libSave", m, 50, 10, "Save selection to library…", "save", null, LibraryTab::saveSelection) + .enabledWhen(SEL) + .tip("Tiles, brushes and meshes as a reusable asset (with textures) for any level")); + x.action(ActionSpec.of( + "geo.exportObj", + "File", + 30, + 40, + "Export selection as OBJ…", + "export", + null, + LibraryTab::exportObj) + .enabledWhen(SEL) + .tip("Mesh, UVs, vertex colours, textures (PNG) and collision for Blender")); + + // File → Import mesh: the dialog with preview, options and placement + x.action(ActionSpec.of("file.importMesh", "File", 30, 10, "Import mesh (OBJ, glTF)…", "import", null, c -> { + MeshImportDialog.Result r = MeshImportDialog.show(c); + if (r == null) { + return; + } + if (r.clickToPlace()) { + c.setTool("geo.brush"); + brush.placeMesh(c, r); + } else { + Vector3f p = c.viewCentre(); + List<Brush> made = new ArrayList<>(); + c.execute(new com.eliasmueller.llamamod.editor.model.cmd.Batch( + "Import " + r.asset().name(), + MeshImportDialog.place( + c.scene(), + r, + c.snapping().snap(p.x), + p.y, + c.snapping().snap(-p.z), + made))); + c.selection().set(made.getFirst(), c); + } + }) + .enabledWhen(DOC) + .tip("Import an OBJ or glTF model as level geometry (preview, size, textures, collision)")); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometrySections.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometrySections.java new file mode 100644 index 0000000..ecfbc73 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometrySections.java @@ -0,0 +1,408 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.render.Legend; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.InspectorSection; +import com.eliasmueller.llamamod.editor.ui.RenderContributor; +import com.eliasmueller.llamamod.editor.ui.Widgets; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import javax.swing.JCheckBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; + +/** Inspector sections (brush shape/faces, tile limits and swaps) and helper render layers. */ +final class GeometrySections { + private GeometrySections() {} + + static final String LAYER_INVISIBLE = "geoInvisible"; + static final String LAYER_SWAP = "geoSwap"; + + static JPanel grid2(JComponent... cs) { + JPanel p = new JPanel(new java.awt.GridLayout(0, 2, 4, 4)); + p.setOpaque(false); + p.setBorder(javax.swing.BorderFactory.createEmptyBorder(2, 6, 2, 6)); + for (JComponent c : cs) { + p.add(c); + } + return p; + } + + // ------------------------------------------------------------------ brush + + /** Shape, rotation and per-face collision of one brush. */ + static final class BrushSection implements InspectorSection { + @Override + public String title() { + return "Brush shape"; + } + + @Override + public int order() { + return 40; + } + + @Override + public boolean appliesTo(List<Node> sel) { + return sel.size() == 1 && sel.getFirst() instanceof Brush; + } + + @Override + public JComponent create(EditorContext ctx, List<Node> sel) { + return new LiveSection(sel, () -> build(ctx, (Brush) sel.getFirst())); + } + + private JComponent build(EditorContext ctx, Brush b) { + double[] bb = b.bounds(); + List<JComponent> rows = new ArrayList<>(); + rows.add(new JLabel(String.format( + java.util.Locale.ROOT, + "<html><b>%s</b> · %s × %s × %s cells</html>", + b.brushKind().label(), + BrushTool.fmt(bb[3] - bb[0]), + BrushTool.fmt(bb[5] - bb[2]), + BrushTool.fmt(bb[4] - bb[1])))); + if (b.brushKind().transformable()) { + double[] r = b.rotation(); + JSpinner rx = GeoWidgets.spinner(r[0], -360, 360, 15, v -> {}); + JSpinner ry = GeoWidgets.spinner(r[1], -360, 360, 15, v -> {}); + JSpinner rz = GeoWidgets.spinner(r[2], -360, 360, 15, v -> {}); + Runnable apply = () -> { + double[] nr = { + ((Number) rx.getValue()).doubleValue(), + ((Number) ry.getValue()).doubleValue(), + ((Number) rz.getValue()).doubleValue() + }; + if (!java.util.Arrays.equals(nr, b.rotation())) { + // keep the bottom centre where it is + var p0 = BrushTransformer.pos(b); + Brush c = BrushMath.apply(b, Map.of("rotation", nr)); + var p1 = BrushTransformer.pos(c); + BrushMath.translate(c, p0.x - p1.x, p0.y - p1.y, p0.z - p1.z); + ctx.execute(Edits.set("Rotate " + b.label(), Map.of(b, BrushMath.diff(b, c)), null)); + } + }; + for (JSpinner s : List.of(rx, ry, rz)) { + s.addChangeListener(e -> apply.run()); + } + JPanel rp = new JPanel(new java.awt.GridLayout(1, 3, 4, 0)); + rp.setOpaque(false); + rp.add(rx); + rp.add(ry); + rp.add(rz); + rp.setToolTipText("Degrees about x (east), y (up), z (south); applied x·y·z"); + rows.add(GeoWidgets.form("Rotation°", rp)); + } + rows.add(grid2( + Widgets.button( + "Left 90°", + "geo-turn", + "Turn left by 90° (about the vertical axis) ([)", + () -> GeometryActions.rotateSelection(ctx, -90)), + Widgets.button( + "Right 90°", + "rotate", + "Turn right by 90° (])", + () -> GeometryActions.rotateSelection(ctx, 90)), + Widgets.button( + "Mirror X", "geo-mirror", "Mirror east ↔ west", () -> GeometryActions.mirror(ctx, 0)), + Widgets.button( + "Mirror Z", "geo-mirror", "Mirror north ↔ south", () -> GeometryActions.mirror(ctx, 2)))); + if (b.brushKind().transformable()) { + boolean vis = b.visible(); + if (BrushMath.has(b, "visible")) { + JCheckBox cb = new JCheckBox("Visible (off = invisible collision volume)", vis); + cb.addActionListener(e -> ctx.execute(SetProp.of(b, b.prop("visible"), cb.isSelected(), null))); + rows.add(GeoWidgets.row(cb)); + } + rows.add(faces(ctx, b)); + } else { + rows.add(GeoWidgets.hint( + b.brushKind() == BrushKind.COLLIDER + ? "A collider is a free polygon: edit its corners below." + : "Water and death planes are laid out per grid cell: they can be resized and moved," + + " turned only by 90°.")); + } + rows.add(GeoWidgets.hint("Brushes are baked into tiles when the level is built, so any rotation and" + + " scale works. Use the Brush tool (B) to drag the handles.")); + return GeoWidgets.section(null, rows.toArray(new JComponent[0])); + } + + private static JComponent faces(EditorContext ctx, Brush b) { + JPanel p = new JPanel(new GridBagLayout()); + p.setOpaque(false); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(1, 6, 1, 4); + c.anchor = GridBagConstraints.WEST; + String cf = b.s("collFaces"); + int[] fm = b.get("faceMats") instanceof int[] a && a.length == 6 ? a : new int[] {-1, -1, -1, -1, -1, -1}; + int brushMat = BrushMath.has(b, "mat") ? b.i("mat") : 0; + c.gridy = 0; + c.gridx = 0; + JLabel h = new JLabel("Face"); + p.add(h, c); + c.gridx = 1; + p.add(new JLabel("Collision"), c); + c.gridx = 2; + p.add(new JLabel("Material"), c); + for (int k = 0; k < 6; k++) { + int face = k; + char ch = BrushKind.FACES.charAt(k); + c.gridy = k + 1; + c.gridx = 0; + c.weightx = 0; + c.fill = GridBagConstraints.NONE; + p.add(new JLabel(CollisionTool.FACE_NAMES[k]), c); + c.gridx = 1; + JCheckBox on = new JCheckBox("", cf.indexOf(ch) >= 0); + on.setToolTipText("Collision of the " + CollisionTool.FACE_NAMES[k] + " face(s)"); + on.addActionListener(e -> { + String cur = b.s("collFaces"); + String n = on.isSelected() + ? BrushMath.mapFaces(cur + ch, "", "") + : cur.replace(String.valueOf(ch), ""); + ctx.execute(SetProp.of(b, b.prop("collFaces"), n, null)); + }); + p.add(on, c); + c.gridx = 2; + c.weightx = 1; + c.fill = GridBagConstraints.HORIZONTAL; + int cur = fm[k] >= 0 ? fm[k] : brushMat; + p.add( + GeoWidgets.materialCombo(cur, v -> { + int[] a = b.get("faceMats") instanceof int[] x && x.length == 6 + ? x.clone() + : new int[] {-1, -1, -1, -1, -1, -1}; + int want = v == brushMat ? -1 : v; + if (a[face] != want) { + a[face] = want; + ctx.execute(SetProp.of(b, b.prop("faceMats"), a, null)); + } + }), + c); + } + JPanel w = GeoWidgets.section("Faces (collision and material)", p); + return w; + } + } + + // ------------------------------------------------------------------ tiles + + static boolean swapsSupported(Scene s) { + return (s.settings().wfpc() & 0x10000) != 0; + } + + /** What a tile can do (yaw only, no scale) plus turn/raise buttons and its swap entry. */ + static final class TileSection implements InspectorSection { + private final TilePaintTool tiles; + + TileSection(TilePaintTool tiles) { + this.tiles = tiles; + } + + @Override + public String title() { + return "Tile"; + } + + @Override + public int order() { + return 40; + } + + @Override + public boolean appliesTo(List<Node> sel) { + return !sel.isEmpty() && sel.stream().allMatch(n -> n instanceof Tile); + } + + @Override + public JComponent create(EditorContext ctx, List<Node> sel) { + List<JComponent> rows = new ArrayList<>(); + rows.add(grid2( + Widgets.button( + "Left 90°", + "geo-turn", + "Turn left by 90°", + () -> GeometryActions.rotateSelection(ctx, -90)), + Widgets.button( + "Right 90°", "rotate", "Turn right by 90°", () -> GeometryActions.rotateSelection(ctx, 90)), + Widgets.button( + "Up", + "geo-height", + "Raise by the snap step", + () -> GeometryActions.raise(ctx, ctx.snapping().step())), + Widgets.button( + "Down", + "geo-height", + "Lower by the snap step", + () -> GeometryActions.raise(ctx, -ctx.snapping().step())))); + rows.add(GeoWidgets.row(Widgets.button( + "Use palette model", + "geo-swap", + "Replace the model of the selected tiles with the Tiles tool's palette model", + () -> { + TileModel m = tiles.model; + if (m == null) { + ctx.status("Choose a palette model in the Tiles tool (T) first"); + return; + } + List<Command> cmds = new ArrayList<>(); + for (Node n : sel) { + Tile t = (Tile) n; + cmds.add(SetProp.of(t, "model", m)); + cmds.add(SetProp.of(t, "colors", TilePaintTool.colours(ctx.scene(), m))); + } + ctx.execute(new Batch("Swap tile model", cmds)); + }))); + rows.add(GeoWidgets.hint("Tiles are placed models of the level's tile set: they turn only about the" + + " vertical axis and cannot be scaled or mirrored (the MAP stores a position and one angle)." + + " For free shapes use brushes.")); + if (sel.size() == 1) { + rows.add(swap(ctx, (Tile) sel.getFirst())); + } + return GeoWidgets.section(null, rows.toArray(new JComponent[0])); + } + + private JComponent swap(EditorContext ctx, Tile t) { + Scene s = ctx.scene(); + List<JComponent> rows = new ArrayList<>(); + if (t.hasSwap()) { + int[] c = t.swapCell(); + rows.add(new JLabel("<html>Swap entry in cell " + c[0] + "," + c[2] + " (y " + c[1] + ")<br>model " + + id(t.swapModel()) + " ⇄ alternative " + id(t.swapAltModel()) + "</html>")); + rows.add(GeoWidgets.row( + Widgets.button( + "Alternative = palette", + "geo-swap", + "Strat scripts (stMapreplace) switch the tile to this model, e.g. an opened door", + () -> { + TileModel m = tiles.model; + if (m == null) { + ctx.status("Choose a palette model in the Tiles tool (T) first"); + return; + } + int[] col = TilePaintTool.colours(s, m); + ctx.execute(new Batch( + "Set alternative model", + List.of( + SetProp.of(t, "swapAltModel", m), + SetProp.of(t, "altColors", col)))); + }), + Widgets.button( + "Remove swap", + "delete", + "Delete the swap entry of this tile", + () -> ctx.execute(new Batch( + "Remove tile swap", + List.of( + SetProp.of(t, "alt", 0), + SetProp.of(t, "swapCell", null), + SetProp.of(t, "swapModel", null), + SetProp.of(t, "swapAltModel", null), + SetProp.of(t, "altColors", null))))))); + rows.add(GeoWidgets.hint("Layer “Tile swap alternatives” shows the alternative model in cyan.")); + } else if (swapsSupported(s)) { + rows.add(GeoWidgets.row(Widgets.button( + "Add swap (door / bridge)", + "geo-swap", + "Give this tile an alternative model that strat scripts can switch to", + () -> { + TileModel m = tiles.model != null ? tiles.model : t.model(); + ctx.execute(new Batch( + "Add tile swap", + List.of( + SetProp.of(t, "alt", 1), + SetProp.of(t, "swapCell", new int[] { + t.cellX(), (int) Math.round(t.y()), t.cellZ() + }), + SetProp.of(t, "swapModel", t.model()), + SetProp.of(t, "swapAltModel", m), + SetProp.of(t, "altColors", TilePaintTool.colours(s, m))))); + }))); + } else { + rows.add(GeoWidgets.hint("Tile swaps (alternative models for doors/bridges) need a base level" + + " with feature flag WFPC 0x10000; this one has none.")); + } + return GeoWidgets.section("Tile swap", rows.toArray(new JComponent[0])); + } + + private static String id(TileModel m) { + return m == null ? "–" : m.id(); + } + } + + // ------------------------------------------------------------------ render layers + + /** Invisible collision volumes (brushes with visible = off, colliders) in translucent red. */ + static final class InvisibleBrushes implements RenderContributor { + @Override + public String layer() { + return LAYER_INVISIBLE; + } + + @Override + public boolean affectedBy(com.eliasmueller.llamamod.editor.model.SceneEvent e) { + return e.node() == null || e.node() instanceof Brush; + } + + @Override + public void build(EditorContext ctx, Builder b) { + Scene s = ctx.scene(); + if (s == null) { + return; + } + Overlay o = b.overlay(); + int red = Legend.rgb(255, 60, 60); + for (Brush br : s.brushes()) { + boolean invisible = br.brushKind() == BrushKind.COLLIDER || !br.visible(); + if (!invisible || !br.enabled() || ctx.isHidden(br)) { + continue; + } + BrushShapes.draw(br, o, Legend.withAlpha(red, 45), Legend.withAlpha(red, 200)); + } + } + } + + /** Alternative models of selected swap tiles as cyan wireframes. */ + static final class SwapPreview implements RenderContributor { + @Override + public String layer() { + return LAYER_SWAP; + } + + @Override + public boolean followsSelection() { + return true; + } + + @Override + public void build(EditorContext ctx, Builder b) { + for (Node n : ctx.selection().nodes()) { + if (n instanceof Tile t && t.hasSwap() && t.swapAltModel() != null) { + TilePaintTool.wire( + b.overlay(), + t.swapAltModel(), + t.transform(), + Legend.withAlpha(Legend.rgb(80, 220, 255), 200)); + } + } + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/LibraryTab.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/LibraryTab.java new file mode 100644 index 0000000..081150b --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/LibraryTab.java @@ -0,0 +1,243 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.ui.AssetDrop; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Icons; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.FlowLayout; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Stream; +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; +import javax.swing.filechooser.FileNameExtensionFilter; +import org.joml.Vector3f; + +/** Asset browser tab "Library": saved mesh assets; drag into the viewport or insert at the view centre. */ +final class LibraryTab extends JPanel { + private static final List<Runnable> REFRESH = new CopyOnWriteArrayList<>(); + + private record Item(Path file, String name, String info) {} + + private final EditorContext ctx; + private final DefaultListModel<Item> model = new DefaultListModel<>(); + private final JList<Item> list = new JList<>(model); + + LibraryTab(EditorContext ctx) { + super(new BorderLayout()); + this.ctx = ctx; + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + list.setLayoutOrientation(JList.HORIZONTAL_WRAP); + list.setVisibleRowCount(-1); + list.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean f) { + super.getListCellRendererComponent(l, v, i, s, f); + Item it = (Item) v; + setText("<html><b>" + it.name() + "</b><br><small>" + it.info() + "</small></html>"); + setIcon(Icons.get("geo-tiles", 32)); + setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 12)); + return this; + } + }); + AssetDrop.dragSource(list, it -> AssetDrop.payload("geolib", it.file().toString())); + list.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + if (e.getClickCount() == 2 && list.getSelectedValue() != null) { + insertAtCentre(ctx, list.getSelectedValue().file()); + } + } + }); + JPanel bar = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2)); + JButton save = new JButton("Save selection…", Icons.get("save", 14)); + save.setToolTipText("Save the selected tiles, brushes and meshes as a library asset"); + save.addActionListener(e -> saveSelection(ctx)); + JButton ins = new JButton("Insert", Icons.get("import", 14)); + ins.setToolTipText("Insert at the view centre (or drag an entry into the viewport)"); + ins.addActionListener(e -> { + if (list.getSelectedValue() != null) { + insertAtCentre(ctx, list.getSelectedValue().file()); + } + }); + JButton del = new JButton("Delete", Icons.get("delete", 14)); + del.addActionListener(e -> { + Item it = list.getSelectedValue(); + if (it != null + && JOptionPane.showConfirmDialog( + this, + "Delete " + it.name() + " from the library?", + "Library", + JOptionPane.OK_CANCEL_OPTION) + == JOptionPane.OK_OPTION) { + try { + Files.deleteIfExists(it.file()); + } catch (IOException ex) { + ctx.error("Delete", ex); + } + refreshAll(); + } + }); + JLabel where = new JLabel(" " + dir(ctx)); + where.setFont(where.getFont().deriveFont(11f)); + bar.add(save); + bar.add(ins); + bar.add(del); + bar.add(where); + add(bar, BorderLayout.NORTH); + add(new JScrollPane(list), BorderLayout.CENTER); + REFRESH.add(this::reload); + reload(); + } + + static Path dir(EditorContext ctx) { + return ctx.env().editorDir().getParent().resolve("library"); + } + + static void refreshAll() { + REFRESH.forEach(Runnable::run); + } + + private void reload() { + model.clear(); + Path d = dir(ctx); + if (!Files.isDirectory(d)) { + return; + } + try (Stream<Path> s = Files.list(d)) { + for (Path p : s.filter(x -> x.toString().endsWith(MeshLibrary.EXT)) + .sorted() + .toList()) { + try { + MeshLibrary.Asset a = MeshLibrary.load(p); + model.addElement(new Item( + p, + a.name(), + a.tileCount() + " tiles · " + a.triangles() + " triangles · from " + + a.json().get("sourceBase"))); + } catch (IOException | RuntimeException e) { + model.addElement(new Item(p, p.getFileName().toString(), "unreadable: " + e.getMessage())); + } + } + } catch (IOException e) { + // empty library + } + } + + // ------------------------------------------------------------------ commands + + static void saveSelection(EditorContext ctx) { + Scene s = ctx.scene(); + if (s == null || ctx.selection().isEmpty()) { + ctx.status("Select tiles, brushes or meshes to save"); + return; + } + MeshLibrary.Tables[] tables = new MeshLibrary.Tables[1]; + List<MeshLibrary.Placed> placed = MeshLibrary.collect(s, ctx.selection().nodes(), tables); + if (placed.isEmpty()) { + ctx.status("Nothing to save: the selection has no tiles, brushes or meshes"); + return; + } + String name = JOptionPane.showInputDialog( + ctx.frame(), + "Name of the library asset (" + placed.size() + " tiles):", + "Save to library", + JOptionPane.PLAIN_MESSAGE); + if (name == null || name.isBlank()) { + return; + } + try { + Path d = dir(ctx); + Files.createDirectories(d); + Path f = d.resolve(name.trim().replaceAll("[^A-Za-z0-9 _.-]", "_") + MeshLibrary.EXT); + Files.write(f, MeshLibrary.save(name.trim(), s.settings().base(), placed, tables[0])); + refreshAll(); + ctx.showPanel("assets"); + ctx.status("Saved " + placed.size() + " tiles to the library: " + f.getFileName()); + } catch (IOException | RuntimeException e) { + ctx.error("Save to library", e); + } + } + + static void insertAt(EditorContext ctx, Path file, Vector3f ground) { + Scene s = ctx.scene(); + if (s == null) { + return; + } + try { + MeshLibrary.Asset a = MeshLibrary.load(file); + int cx = (int) Math.floor(ground.x), cz = (int) Math.floor(-ground.z); + double y = ctx.snapping().enabled() ? ctx.snapping().snap(ground.y) : ground.y; + List<Node> out = new ArrayList<>(); + List<Command> cmds = MeshLibrary.insert(s, a, cx, cz, y, out); + ctx.execute(new Batch("Insert " + a.name(), cmds)); + ctx.selection().set(out, ctx); + ctx.status("Inserted " + a.name() + " (" + out.size() + " tiles) at cell " + cx + "," + cz); + } catch (IOException | RuntimeException e) { + ctx.error("Insert library asset", e); + } + } + + static void insertAtCentre(EditorContext ctx, Path file) { + insertAt(ctx, file, ctx.viewCentre()); + } + + static void exportObj(EditorContext ctx) { + Scene s = ctx.scene(); + if (s == null || ctx.selection().isEmpty()) { + ctx.status("Select tiles, brushes or meshes to export"); + return; + } + MeshLibrary.Tables[] tables = new MeshLibrary.Tables[1]; + List<MeshLibrary.Placed> placed = MeshLibrary.collect(s, ctx.selection().nodes(), tables); + if (placed.isEmpty()) { + ctx.status("Nothing to export"); + return; + } + JFileChooser fc = new JFileChooser(ctx.prefs().getString("geo.exportDir", System.getProperty("user.home"))); + fc.setFileFilter(new FileNameExtensionFilter("Wavefront OBJ", "obj")); + fc.setSelectedFile(new java.io.File("level_export.obj")); + JCheckBox coll = new JCheckBox("Also write the collision polygons (…_collision.obj)", true); + JPanel acc = new JPanel(new BorderLayout()); + acc.add(coll, BorderLayout.NORTH); + acc.add( + new JLabel("<html><div style='width:180px'><small>The exported textures are game data:" + + " fine for your own editing, please don't share them.</small></div></html>"), + BorderLayout.CENTER); + fc.setAccessory(acc); + if (fc.showSaveDialog(ctx.frame()) != JFileChooser.APPROVE_OPTION) { + return; + } + Path f = fc.getSelectedFile().toPath(); + if (!f.toString().toLowerCase(java.util.Locale.ROOT).endsWith(".obj")) { + f = f.resolveSibling(f.getFileName() + ".obj"); + } + ctx.prefs().put("geo.exportDir", f.getParent().toString()); + try { + List<Path> w = MeshLibrary.exportObj(placed, tables[0], f, coll.isSelected()); + ctx.status("Exported " + placed.size() + " tiles to " + f.getFileName() + " (" + w.size() + + " files) – textures are game data: personal use only"); + } catch (IOException | RuntimeException e) { + ctx.error("Export OBJ", e); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/LiveSection.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/LiveSection.java new file mode 100644 index 0000000..d490599 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/LiveSection.java @@ -0,0 +1,69 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.KeyboardFocusManager; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; + +/** + * Inspector content that rebuilds itself when its nodes change (inspector sections are otherwise + * only rebuilt on selection changes); skipped while the user is typing inside it. + */ +final class LiveSection extends JPanel { + private static final List<WeakReference<LiveSection>> LIVE = new ArrayList<>(); + + private final List<Node> nodes; + private final Supplier<JComponent> build; + private boolean pending; + + LiveSection(List<Node> nodes, Supplier<JComponent> build) { + super(new BorderLayout()); + setOpaque(false); + this.nodes = List.copyOf(nodes); + this.build = build; + add(build.get()); + synchronized (LIVE) { + LIVE.removeIf(r -> r.get() == null); + LIVE.add(new WeakReference<>(this)); + } + } + + static void changed(SceneEvent e) { + List<LiveSection> l = new ArrayList<>(); + synchronized (LIVE) { + for (WeakReference<LiveSection> r : LIVE) { + LiveSection s = r.get(); + if (s != null && (e.node() == null || s.nodes.contains(e.node()))) { + l.add(s); + } + } + } + l.forEach(LiveSection::schedule); + } + + private void schedule() { + if (pending || !isShowing()) { + return; + } + pending = true; + SwingUtilities.invokeLater(() -> { + pending = false; + Component f = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + if (f != null && SwingUtilities.isDescendingFrom(f, this)) { + return; + } + removeAll(); + add(build.get()); + revalidate(); + repaint(); + }); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java new file mode 100644 index 0000000..f46bae8 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java @@ -0,0 +1,512 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.io.MeshImport; +import com.eliasmueller.llamamod.editor.model.MeshAsset; +import com.eliasmueller.llamamod.editor.model.MeshData; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.format.Lin3; +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JRadioButton; +import javax.swing.JSpinner; +import javax.swing.filechooser.FileNameExtensionFilter; + +/** + * File → Import mesh: preview (drag to orbit), orientation and size, texture and collision options, + * then placement by a click in the viewport (or at the view centre). + */ +final class MeshImportDialog extends JDialog { + /** What the user chose (null = cancelled). */ + record Result( + MeshAsset asset, Map<String, Object> params, String collision, boolean clickToPlace, boolean onGround) {} + + private final EditorContext ctx; + private final Scene scene; + private final MeshAsset asset; + private final Path file; + private final Preview preview = new Preview(); + private final JLabel info = new JLabel(); + private final JLabel warn = new JLabel(); + private final JComboBox<String> up = + new JComboBox<>(new String[] {"Y up (glTF, most OBJ)", "Z up (some CAD exports)"}); + private final JSpinner unit; + private final JSpinner fit = GeoWidgets.spinner(4, 0.1, 200, 0.5, v -> {}); + private final JComboBox<String> turn = new JComboBox<>(new String[] {"0°", "90°", "180°", "270°"}); + private final JCheckBox mirror = new JCheckBox("Mirror (left ↔ right)"); + private final JCheckBox ground = new JCheckBox("Stand on the ground (bottom at the click point)", true); + private final JComboBox<Integer> texSize = new JComboBox<>(new Integer[] {32, 64, 128, 256}); + private final JComboBox<Integer> uvSteps = new JComboBox<>(new Integer[] {4, 8, 16, 32}); + private final JCheckBox dbl = new JCheckBox("Double-sided"); + private final JCheckBox lit = new JCheckBox("Lit (baked vertex colours)", true); + private final JComboBox<String> coll = new JComboBox<>(new String[] { + "From the mesh (every triangle)", "Simplified: invisible box around it", "None (decoration only)" + }); + private int material; + private final JRadioButton click = new JRadioButton("Click in the viewport to place", true); + private final JRadioButton centre = new JRadioButton("At the view centre"); + private Result result; + + private MeshImportDialog(EditorContext ctx, MeshAsset asset, Path file) { + super(ctx.frame(), "Import mesh – " + file.getFileName(), true); + this.ctx = ctx; + this.scene = ctx.scene(); + this.asset = asset; + this.file = file; + unit = GeoWidgets.spinner(asset.unitScale(), 0.0001, 10000, 0.1, v -> reparse()); + ((JSpinner.NumberEditor) unit.getEditor()).getFormat().applyPattern("0.#####"); + up.setSelectedIndex("z".equals(asset.upAxis()) ? 1 : 0); + up.addActionListener(e -> reparse()); + texSize.setSelectedItem(128); + uvSteps.setSelectedItem(8); + turn.addActionListener(e -> update()); + mirror.addActionListener(e -> update()); + ButtonGroup g = new ButtonGroup(); + g.add(click); + g.add(centre); + + JPanel form = new JPanel(); + form.setLayout(new javax.swing.BoxLayout(form, javax.swing.BoxLayout.Y_AXIS)); + form.add(GeoWidgets.section("Source", info)); + JButton fitB = new JButton("Fit"); + fitB.setToolTipText("Set the unit scale so that the largest side is this many cells"); + fitB.addActionListener(e -> fitTo(((Number) fit.getValue()).doubleValue())); + form.add(GeoWidgets.section( + "Orientation & size", + GeoWidgets.row(new JLabel("Up axis"), up), + GeoWidgets.row(new JLabel("Unit scale (file units → cells)"), unit), + GeoWidgets.row(new JLabel("Largest side"), fit, new JLabel("cells"), fitB), + GeoWidgets.row(new JLabel("Turn"), turn, mirror), + GeoWidgets.row(ground))); + form.add(GeoWidgets.section( + "Textures", + GeoWidgets.row(new JLabel("Texture size on the page"), texSize), + GeoWidgets.row(new JLabel("UV precision (steps per texture)"), uvSteps), + GeoWidgets.row(dbl, lit), + GeoWidgets.hint("The engine has no free UVs: every triangle shows one half of a texture rectangle." + + " Rectangular quads map exactly, other layouts are approximated; images are packed onto" + + " new 256×256 texture pages."))); + form.add(GeoWidgets.section( + "Collision", + GeoWidgets.row(coll), + GeoWidgets.row(new JLabel("Material"), GeoWidgets.materialCombo(0, v -> material = v)))); + form.add(GeoWidgets.section("Placement", GeoWidgets.row(click), GeoWidgets.row(centre), warn)); + form.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 8)); + + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton ok = new JButton("Import"); + JButton cancel = new JButton("Cancel"); + ok.addActionListener(e -> { + result = build(); + dispose(); + }); + cancel.addActionListener(e -> dispose()); + buttons.add(cancel); + buttons.add(ok); + getRootPane().setDefaultButton(ok); + + JPanel left = new JPanel(new BorderLayout()); + left.add(preview, BorderLayout.CENTER); + JLabel hint = new JLabel("Drag to orbit · grid = 1 cell · the llama is 0.6 cells tall", JLabel.CENTER); + hint.setFont(hint.getFont().deriveFont(11f)); + left.add(hint, BorderLayout.SOUTH); + left.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 4)); + JPanel root = new JPanel(new BorderLayout()); + root.add(left, BorderLayout.CENTER); + root.add(form, BorderLayout.EAST); + root.add(buttons, BorderLayout.SOUTH); + setContentPane(root); + update(); + pack(); + setLocationRelativeTo(ctx.frame()); + } + + /** File chooser + dialog; null if cancelled. */ + static Result show(EditorContext ctx) { + JFileChooser fc = new JFileChooser(ctx.prefs().getString("importDir", System.getProperty("user.home"))); + fc.setFileFilter(new FileNameExtensionFilter("3D models (OBJ, glTF, GLB)", "obj", "gltf", "glb")); + if (fc.showOpenDialog(ctx.frame()) != JFileChooser.APPROVE_OPTION) { + return null; + } + return show(ctx, fc.getSelectedFile().toPath()); + } + + static Result show(EditorContext ctx, Path f) { + ctx.prefs().put("importDir", f.getParent().toString()); + MeshAsset a; + try { + a = MeshImport.importFile(ctx.scene(), f); + } catch (java.io.IOException | RuntimeException e) { + ctx.error("Mesh import failed", e); + return null; + } + MeshImportDialog d = new MeshImportDialog(ctx, a, f); + d.setVisible(true); + if (d.result != null) { + ctx.prefs().put("geo.meshSource." + a.file(), f.toString()); + } else { + String dir = a.file().contains("/") ? a.file().substring(0, a.file().lastIndexOf('/') + 1) : null; + boolean shared = ctx.scene().meshAssets().stream() + .anyMatch(o -> dir != null && o.file().startsWith(dir)); + if (dir != null && !shared) { + ctx.scene().assets().keySet().removeIf(k -> k.startsWith(dir)); + } + } + return d.result; + } + + private void reparse() { + asset.setRaw("upAxis", up.getSelectedIndex() == 1 ? "z" : "y"); + asset.setRaw("unitScale", ((Number) unit.getValue()).doubleValue()); + try { + MeshImport.load(scene, asset); + } catch (java.io.IOException | RuntimeException e) { + warn.setText("Cannot read: " + e.getMessage()); + } + update(); + } + + private void fitTo(double cells) { + MeshData m = asset.mesh(); + if (m == null) { + return; + } + double[] b = m.bounds(); + double size = Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2])); + if (size <= 0) { + return; + } + double us = ((Number) unit.getValue()).doubleValue() * cells / size; + unit.setValue(us); + } + + private void update() { + MeshData m = asset.mesh(); + if (m == null) { + info.setText("(no mesh)"); + return; + } + double[] b = m.bounds(); + int textured = 0; + for (MeshData.Material mt : m.materialList()) { + if (mt.image() != null) { + textured++; + } + } + info.setText(String.format( + Locale.ROOT, + "<html>%s<br>%d triangles, %d vertices<br>%d material(s), %d textured<br>size %.2f × %.2f × %.2f cells</html>", + file.getFileName(), + m.triangleCount(), + m.vertexCount(), + m.materialList().size(), + textured, + b[3] - b[0], + b[4] - b[1], + b[5] - b[2])); + // the builder splits triangles into 1×1 cells; one tile model holds ≤ 8000 vertices + Map<Long, Integer> perCell = new HashMap<>(); + double[] p = m.positions(); + for (int t = 0; t < m.triangleCount(); t++) { + double cx = 0, cz = 0; + for (int k = 0; k < 3; k++) { + int v = m.triangles()[3 * t + k]; + cx += p[3 * v] / 3; + cz += p[3 * v + 2] / 3; + } + perCell.merge((long) Math.floor(cx) << 32 ^ ((long) Math.floor(cz) & 0xffffffffL), 1, Integer::sum); + } + int max = perCell.values().stream().mapToInt(Integer::intValue).max().orElse(0); + String w = "<html><small>" + perCell.size() + " grid cells; densest cell " + max + " triangles"; + if (max * 3 > 8000) { + w += "<br><font color='#d04040'>too dense: a cell may exceed the engine's 8000 vertices per tile –" + + " simplify the mesh or scale it up</font>"; + } + if (Math.max(b[3] - b[0], b[5] - b[2]) + > Math.min(scene.settings().width(), scene.settings().height())) { + w += "<br><font color='#d04040'>larger than the level grid</font>"; + } + warn.setText(w + "</small></html>"); + preview.set(m, turn.getSelectedIndex() * 90, mirror.isSelected()); + } + + private Result build() { + MeshData m = asset.mesh(); + Map<String, Object> pm = new LinkedHashMap<>(); + pm.put("asset", asset); + pm.put("rotY", turn.getSelectedIndex() * 90.0); + pm.put("scale", 1.0); + if (mirror.isSelected()) { + pm.put("stretch", new double[] {-1, 1, 1}); + } + pm.put("coll", coll.getSelectedIndex() == 0); + pm.put("mat", material); + pm.put("doubleSided", dbl.isSelected()); + pm.put("shade", lit.isSelected()); + pm.put("texSize", texSize.getSelectedItem()); + pm.put("uvSteps", uvSteps.getSelectedItem()); + String name = file.getFileName().toString().replaceFirst("\\.[^.]+$", ""); + asset.setRaw("name", name); + pm.put("name", name); + String c = + switch (coll.getSelectedIndex()) { + case 1 -> "box"; + case 2 -> "none"; + default -> "mesh"; + }; + if (m == null) { + return null; + } + return new Result(asset, pm, c, click.isSelected(), ground.isSelected()); + } + + // ------------------------------------------------------------------ preview + + /** Software preview: flat-shaded triangles, painter's algorithm, orbit by dragging. */ + static final class Preview extends JPanel { + private MeshData mesh; + private double yaw = Math.toRadians(35); + private double pitch = Math.toRadians(25); + private double turn; + private boolean mirror; + private int lx, ly; + private final Map<Integer, Color> colours = new HashMap<>(); + + Preview() { + setPreferredSize(new Dimension(420, 420)); + setBackground(new Color(0x2b2d31)); + MouseAdapter m = new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + lx = e.getX(); + ly = e.getY(); + } + + @Override + public void mouseDragged(MouseEvent e) { + yaw += (e.getX() - lx) * 0.01; + pitch = Math.max(-1.5, Math.min(1.5, pitch + (e.getY() - ly) * 0.01)); + lx = e.getX(); + ly = e.getY(); + repaint(); + } + }; + addMouseListener(m); + addMouseMotionListener(m); + } + + void set(MeshData m, double turnDeg, boolean mirror) { + mesh = m; + turn = Math.toRadians(turnDeg); + this.mirror = mirror; + colours.clear(); + repaint(); + } + + private Color colour(int mat) { + return colours.computeIfAbsent(mat, k -> { + MeshData.Material mt = mesh.materialList().get(k); + if (mt.image() == null) { + return new Color(mt.rgb()); + } + long r = 0, g = 0, b = 0, n = 0; + int[] img = mt.image(); + for (int i = 0; i < img.length; i += Math.max(1, img.length / 4096)) { + if ((img[i] >>> 24) > 16) { + r += img[i] >> 16 & 0xff; + g += img[i] >> 8 & 0xff; + b += img[i] & 0xff; + n++; + } + } + return n == 0 ? Color.LIGHT_GRAY : new Color((int) (r / n), (int) (g / n), (int) (b / n)); + }); + } + + @Override + protected void paintComponent(Graphics g0) { + super.paintComponent(g0); + if (mesh == null || mesh.triangleCount() == 0) { + return; + } + Graphics2D g = (Graphics2D) g0; + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + double[] b = mesh.bounds(); + double cx = (b[0] + b[3]) / 2, cy = (b[1] + b[4]) / 2, cz = (b[2] + b[5]) / 2; + double size = Math.max(1e-6, Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2]))); + double sc = Math.min(getWidth(), getHeight()) * 0.62 / size; + double ct = Math.cos(turn), st = Math.sin(turn); + double cyw = Math.cos(yaw), syw = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch); + int w2 = getWidth() / 2, h2 = getHeight() / 2; + double[] pos = mesh.positions(); + int nv = mesh.vertexCount(); + double[] sx = new double[nv], sy = new double[nv], sd = new double[nv]; + double[][] cam = new double[nv][]; + for (int v = 0; v < nv; v++) { + double x = pos[3 * v] - cx, y = pos[3 * v + 1] - cy, z = pos[3 * v + 2] - cz; + if (mirror) { + x = -x; + } + double tx = x * ct + z * st, tz = -x * st + z * ct; + double rx = tx * cyw - tz * syw, rz = tx * syw + tz * cyw; + double ry = y * cp - rz * sp, rd = y * sp + rz * cp; + sx[v] = w2 + rx * sc; + sy[v] = h2 - ry * sc; + sd[v] = rd; + cam[v] = new double[] {rx, ry, rd}; + } + // grid on the ground (1 cell) + g.setColor(new Color(255, 255, 255, 28)); + double gy = b[1] - cy; + int n = (int) Math.min(40, Math.ceil(size / 2) + 1); + for (int i = -n; i <= n; i++) { + line(g, i, gy, -n, i, gy, n, ct, st, cyw, syw, cp, sp, sc, w2, h2, false); + line(g, -n, gy, i, n, gy, i, ct, st, cyw, syw, cp, sp, sc, w2, h2, false); + } + int nt = mesh.triangleCount(); + Integer[] order = new Integer[nt]; + double[] depth = new double[nt]; + int[] tri = mesh.triangles(); + for (int t = 0; t < nt; t++) { + order[t] = t; + depth[t] = sd[tri[3 * t]] + sd[tri[3 * t + 1]] + sd[tri[3 * t + 2]]; + } + java.util.Arrays.sort(order, (a, c) -> Double.compare(depth[c], depth[a])); + int limit = Math.min(nt, 60000); + for (int i = nt - limit; i < nt; i++) { + int t = order[i]; + int a = tri[3 * t], bb = tri[3 * t + 1], c = tri[3 * t + 2]; + double[] u = Lin3.sub(cam[bb], cam[a]), v = Lin3.sub(cam[c], cam[a]); + double[] nn = {u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]}; + double ln = Math.sqrt(nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]); + double shade = + ln < 1e-12 ? 0.6 : 0.35 + 0.65 * Math.abs((nn[0] * 0.3 + nn[1] * 0.8 - nn[2] * 0.5) / ln / 1.0); + Color base = colour(mesh.materials()[t]); + Color col = new Color( + (int) Math.min(255, base.getRed() * shade), (int) Math.min(255, base.getGreen() * shade), (int) + Math.min(255, base.getBlue() * shade)); + Polygon p = new Polygon( + new int[] {(int) sx[a], (int) sx[bb], (int) sx[c]}, + new int[] {(int) sy[a], (int) sy[bb], (int) sy[c]}, + 3); + g.setColor(col); + g.fillPolygon(p); + if (nt < 3000) { + g.setColor(new Color(0, 0, 0, 40)); + g.drawPolygon(p); + } + } + // llama for scale: 0.6 cells tall + g.setStroke(new BasicStroke(2f)); + g.setColor(new Color(255, 170, 60, 200)); + line(g, b[3] - cx + 0.5, gy, 0, b[3] - cx + 0.5, gy + 0.6, 0, ct, st, cyw, syw, cp, sp, sc, w2, h2, true); + } + + private static void line( + Graphics2D g, + double x0, + double y0, + double z0, + double x1, + double y1, + double z1, + double ct, + double st, + double cyw, + double syw, + double cp, + double sp, + double sc, + int w2, + int h2, + boolean noTurn) { + int[] a = proj(x0, y0, z0, noTurn ? 1 : ct, noTurn ? 0 : st, cyw, syw, cp, sp, sc, w2, h2); + int[] b = proj(x1, y1, z1, noTurn ? 1 : ct, noTurn ? 0 : st, cyw, syw, cp, sp, sc, w2, h2); + g.drawLine(a[0], a[1], b[0], b[1]); + } + + private static int[] proj( + double x, + double y, + double z, + double ct, + double st, + double cyw, + double syw, + double cp, + double sp, + double sc, + int w2, + int h2) { + double tx = x * ct + z * st, tz = -x * st + z * ct; + double rx = tx * cyw - tz * syw, rz = tx * syw + tz * cyw; + double ry = y * cp - rz * sp; + return new int[] {(int) (w2 + rx * sc), (int) (h2 - ry * sc)}; + } + } + + /** + * Commands that add the asset and its brush (plus an invisible collision box if chosen) at a game + * position; out receives the new brushes. + */ + static List<com.eliasmueller.llamamod.editor.model.cmd.Command> place( + Scene s, + Result r, + double gx, + double gy, + double gz, + List<com.eliasmueller.llamamod.editor.model.Brush> out) { + var b = s.createBrush(com.eliasmueller.llamamod.editor.model.BrushKind.MESH); + r.params().forEach((k, v) -> b.setRaw(k, v instanceof double[] a ? a.clone() : v)); + double y = gy; + if (r.onGround() && r.asset().mesh() != null) { + y = gy - r.asset().mesh().bounds()[1]; + } + b.setRaw("x", gx); + b.setRaw("y", y); + b.setRaw("z", gz); + List<com.eliasmueller.llamamod.editor.model.cmd.Command> cmds = new ArrayList<>(); + if (!s.contains(r.asset())) { + cmds.add(new com.eliasmueller.llamamod.editor.model.cmd.AddNode(r.asset())); + } + cmds.add(new com.eliasmueller.llamamod.editor.model.cmd.AddNode(b)); + out.add(b); + if (r.collision().equals("box")) { + double[] bb = b.bounds(); + var c = s.createBrush(com.eliasmueller.llamamod.editor.model.BrushKind.BOX); + Map<String, Object> m = Primitive.INVISIBLE_BOX.params( + bb[0], bb[2], bb[3], bb[5], bb[1], bb[4] - bb[1], new Primitive.Options()); + m.put("mat", b.i("mat")); + m.forEach((k, v) -> c.setRaw(k, v)); + c.setRaw("name", b.name() + " collision"); + cmds.add(new com.eliasmueller.llamamod.editor.model.cmd.AddNode(c)); + out.add(c); + } + return cmds; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshLibrary.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshLibrary.java new file mode 100644 index 0000000..5cbaa8b --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshLibrary.java @@ -0,0 +1,583 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.io.BrushCompiler; +import com.eliasmueller.llamamod.editor.io.Images; +import com.eliasmueller.llamamod.editor.io.TextureImport; +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.TextureEntry; +import com.eliasmueller.llamamod.editor.model.TexturePage; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.format.Geometry; +import com.eliasmueller.llamamod.platform.Json; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +/** + * Reusable level geometry: tiles (original TRAK models with collision and vertex colours) and what + * brushes/meshes compile to, stored as a {@code .llamamesh} zip (library.json + one PNG per used + * texture) and inserted into any level – textures are matched to identical ones of the target level + * or imported onto new pages. Also exports the same geometry as OBJ + MTL + PNG for Blender. + */ +final class MeshLibrary { + private MeshLibrary() {} + + static final String EXT = ".llamamesh"; + + /** A tile in world space (export coordinates) with the texture tables it refers to. */ + record Placed(TileModel model, double x, double y, double z, int rot, int[] colors) {} + + /** Texture tables including what the brushes generated (indices ≥ scene size). */ + record Tables(List<TextureEntry> entries, List<TexturePage> pages) {} + + /** Selection → placed tiles (selected tiles plus the compiled tiles of selected brushes). */ + static List<Placed> collect(Scene s, List<Node> sel, Tables[] tables) { + List<Placed> out = new ArrayList<>(); + List<TextureEntry> te = new ArrayList<>(s.textures()); + List<TexturePage> tp = new ArrayList<>(s.pages()); + for (Node n : sel) { + if (n instanceof Tile t && t.model() != null && !GeometryActions.isKillModel(t.model())) { + out.add(new Placed(t.model(), t.x(), t.y(), t.z(), t.rot(), firstFrame(t))); + } + } + List<Brush> brushes = new ArrayList<>(); + for (Node n : sel) { + if (n instanceof Brush b && b.enabled()) { + brushes.add(b); + } + } + if (!brushes.isEmpty()) { + // compile only the selected brushes + Map<Brush, Boolean> was = new IdentityHashMap<>(); + for (Brush b : s.brushes()) { + was.put(b, b.enabled()); + if (!brushes.contains(b)) { + b.setRaw("enabled", false); + } + } + try { + BrushCompiler.Output o = BrushCompiler.compile(s, new ArrayList<>()); + te.addAll(o.textures()); + tp.addAll(o.pages()); + for (Tile t : o.tiles()) { + if (!GeometryActions.isKillModel(t.model())) { + out.add(new Placed(t.model(), t.x(), t.y(), t.z(), t.rot(), firstFrame(t))); + } + } + } finally { + was.forEach((b, e) -> b.setRaw("enabled", e)); + } + } + tables[0] = new Tables(te, tp); + return out; + } + + private static int[] firstFrame(Tile t) { + int nv = t.model().vertexCount(); + return t.colors().length >= nv ? Arrays.copyOf(t.colors(), nv) : grey(nv); + } + + private static int[] grey(int n) { + int[] c = new int[n]; + Arrays.fill(c, Tile.GREY); + return c; + } + + /** Pixels of a texture entry (flat colours as a small solid image), or null. */ + static BufferedImage image(Tables t, int index) { + if (index < 0 || index >= t.entries().size()) { + return null; + } + TextureEntry e = t.entries().get(index); + if (e.isFlat()) { + BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB); + int rgb = 0xff000000 | e.flatRgb(); + for (int y = 0; y < 16; y++) { + for (int x = 0; x < 16; x++) { + img.setRGB(x, y, rgb); + } + } + return img; + } + if (e.page() < 0 || e.page() >= t.pages().size()) { + return null; + } + TexturePage p = t.pages().get(e.page()); + int w = Math.max(1, Math.min(p.width(), e.u1() + 1) - e.u0()); + int h = Math.max(1, Math.min(p.height(), e.v1() + 1) - e.v0()); + int[] argb = p.argb(); + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + img.setRGB(x, y, argb[(e.v0() + y) * p.width() + e.u0() + x]); + } + } + return img; + } + + // ------------------------------------------------------------------ save + + /** Anchor of a selection in game space: lowest cell corner in x/z, lowest y rounded down to 1/4. */ + private static double[] anchor(List<Placed> placed) { + double minX = Double.MAX_VALUE, minZ = Double.MAX_VALUE, minY = Double.MAX_VALUE; + for (Placed p : placed) { + minX = Math.min(minX, p.x()); + minZ = Math.min(minZ, -p.z()); + minY = Math.min(minY, p.y()); + } + return new double[] {Math.floor(minX), Math.floor(minY * 4) / 4, Math.floor(minZ)}; + } + + static byte[] save(String name, String sourceBase, List<Placed> placed, Tables tables) throws IOException { + double[] anchor = anchor(placed); + double ax = anchor[0], ay = anchor[1], az = anchor[2]; + Map<TileModel, Integer> models = new LinkedHashMap<>(); + TreeMap<Integer, Integer> texIds = new TreeMap<>(); + List<Object> tiles = new ArrayList<>(); + for (Placed p : placed) { + models.putIfAbsent(p.model(), models.size()); + TileModel m = p.model(); + for (int f = 0; f < m.faceCount(); f++) { + texIds.putIfAbsent(m.faceTexture(f), texIds.size()); + } + Map<String, Object> t = new LinkedHashMap<>(); + t.put("model", models.get(m)); + t.put("x", p.x() - ax); + t.put("y", p.y() - ay); + t.put("z", -p.z() - az); + t.put("rot", p.rot()); + t.put("colors", b64(p.colors())); + tiles.add(t); + } + Map<String, byte[]> files = new LinkedHashMap<>(); + List<Object> tex = new ArrayList<>(); + for (Map.Entry<Integer, Integer> e : texIds.entrySet()) { + Map<String, Object> t = new LinkedHashMap<>(); + t.put("id", e.getValue()); + t.put("source", e.getKey()); + BufferedImage img = image(tables, e.getKey()); + if (img != null) { + String fn = "tex/" + e.getValue() + ".png"; + files.put(fn, Images.png(img)); + t.put("file", fn); + TextureEntry te = tables.entries().get(e.getKey()); + t.put("flags", te.flags()); + t.put("flat", te.isFlat()); + } + tex.add(t); + } + List<Object> ms = new ArrayList<>(); + for (TileModel m : models.keySet()) { + Map<String, Object> o = new LinkedHashMap<>(); + o.put("name", m.name()); + o.put("vertices", b64(m.vertices())); + int[] faces = m.faces().clone(); + for (int f = 0; f < m.faceCount(); f++) { + faces[f * TileModel.FACE_STRIDE + 4] = texIds.get(faces[f * TileModel.FACE_STRIDE + 4]); + } + o.put("faces", b64(faces)); + o.put("planes", b64(m.planes())); + for (int g = 0; g < 3; g++) { + o.put("collision" + g, Base64.getEncoder().encodeToString(m.collision(g))); + } + ms.add(o); + } + Map<String, Object> root = new LinkedHashMap<>(); + root.put("formatVersion", 1); + root.put("app", "LlamaMod level editor – mesh library"); + root.put("name", name); + root.put("sourceBase", sourceBase); + root.put("tiles", tiles); + root.put("models", ms); + root.put("textures", tex); + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + try (ZipOutputStream z = new ZipOutputStream(bo)) { + put(z, "library.json", Json.write(root).getBytes(StandardCharsets.UTF_8)); + for (Map.Entry<String, byte[]> f : files.entrySet()) { + put(z, f.getKey(), f.getValue()); + } + } + return bo.toByteArray(); + } + + private static void put(ZipOutputStream z, String name, byte[] data) throws IOException { + ZipEntry e = new ZipEntry(name); + e.setTime(0); + z.putNextEntry(e); + z.write(data); + z.closeEntry(); + } + + // ------------------------------------------------------------------ load / insert + + /** A loaded library asset. */ + record Asset(String name, Map<String, Object> json, Map<String, byte[]> files) { + int tileCount() { + return ((List<?>) json.get("tiles")).size(); + } + + int triangles() { + int n = 0; + for (Object o : (List<?>) json.get("models")) { + n += ints((String) ((Map<?, ?>) o).get("faces")).length / TileModel.FACE_STRIDE; + } + return n; + } + } + + @SuppressWarnings("unchecked") + static Asset load(byte[] zip) throws IOException { + Map<String, byte[]> files = new HashMap<>(); + try (ZipInputStream z = new ZipInputStream(new ByteArrayInputStream(zip))) { + ZipEntry e; + while ((e = z.getNextEntry()) != null) { + files.put(e.getName(), z.readAllBytes()); + } + } + byte[] j = files.get("library.json"); + if (j == null) { + throw new IOException("not a mesh library file (no library.json)"); + } + Map<String, Object> root = (Map<String, Object>) Json.parse(new String(j, StandardCharsets.UTF_8)); + return new Asset(String.valueOf(root.get("name")), root, files); + } + + static Asset load(Path p) throws IOException { + return load(Files.readAllBytes(p)); + } + + /** + * Commands that insert the asset with its anchor at cell (cx, cz) and height y: texture pages and + * entries for textures the level does not have yet (identical pixels are reused), tile models + * (deduplicated against identical models of the level) and tiles. Returns the new tiles in out. + */ + @SuppressWarnings("unchecked") + static List<Command> insert(Scene s, Asset a, int cx, int cz, double y, List<Node> out) throws IOException { + List<Command> cmds = new ArrayList<>(); + // textures: reuse identical ones, import the others + Map<Long, Integer> existing = new HashMap<>(); + Tables own = new Tables(new ArrayList<>(s.textures()), new ArrayList<>(s.pages())); + Map<Integer, Integer> map = new HashMap<>(); + List<Object> tex = (List<Object>) a.json().get("textures"); + Map<Integer, BufferedImage> missing = new LinkedHashMap<>(); + Map<Integer, Integer> flags = new HashMap<>(); + for (Object o : tex) { + Map<String, Object> t = (Map<String, Object>) o; + int id = ((Number) t.get("id")).intValue(); + byte[] png = t.get("file") == null ? null : a.files().get((String) t.get("file")); + if (png == null) { + map.put(id, 0); + continue; + } + BufferedImage img = Images.read(png); + if (existing.isEmpty()) { + for (int i = 0; i < own.entries().size(); i++) { + BufferedImage e = image(own, i); + if (e != null) { + existing.putIfAbsent(hash(e), i); + } + } + } + Integer hit = existing.get(hash(img)); + if (hit != null) { + map.put(id, hit); + } else { + missing.put(id, img); + flags.put(id, t.get("flags") == null ? 0 : ((Number) t.get("flags")).intValue()); + } + } + if (!missing.isEmpty()) { + List<TextureImport.NamedImage> imgs = new ArrayList<>(); + int size = 16; + for (BufferedImage img : missing.values()) { + imgs.add(new TextureImport.NamedImage(a.name(), img)); + size = Math.max(size, Integer.highestOneBit(Math.max(img.getWidth(), img.getHeight()) * 2 - 1)); + } + size = Math.min(256, size); + TextureImport.Result r = TextureImport.importImages(s, imgs, size); + cmds.add(r.command()); + int k = 0; + for (Map.Entry<Integer, BufferedImage> e : missing.entrySet()) { + map.put(e.getKey(), r.entries()[k++]); + } + // keep the blend mode (translucent water, leaves …) + int k2 = 0; + for (Map.Entry<Integer, BufferedImage> e : missing.entrySet()) { + int blend = flags.get(e.getKey()) & 0x7000; + int idx = k2++; + if (blend != 0) { + cmds.add(new Command() { + private Integer old; + + @Override + public String label() { + return "Texture blend mode"; + } + + @Override + public void apply(Scene sc) { + TextureEntry te = sc.textures().get(r.entries()[idx]); + old = te.flags(); + sc.set(te, "flags", old | blend); + } + + @Override + public void revert(Scene sc) { + sc.set(sc.textures().get(r.entries()[idx]), "flags", old); + } + }); + } + } + } + // models + List<TileModel> models = new ArrayList<>(); + for (Object o : (List<Object>) a.json().get("models")) { + Map<String, Object> m = (Map<String, Object>) o; + int[] faces = ints((String) m.get("faces")); + for (int f = 0; f + 4 < faces.length; f += TileModel.FACE_STRIDE) { + faces[f + 4] = map.getOrDefault(faces[f + 4], 0); + } + double[] verts = doubles((String) m.get("vertices")); + byte[][] coll = new byte[3][]; + for (int g = 0; g < 3; g++) { + coll[g] = Base64.getDecoder().decode((String) m.get("collision" + g)); + } + TileModel same = null; + for (TileModel e : s.tileModels()) { + if (Arrays.equals(e.vertices(), verts) + && Arrays.equals(e.faces(), faces) + && Arrays.equals(e.collision(0), coll[0]) + && Arrays.equals(e.collision(1), coll[1]) + && Arrays.equals(e.collision(2), coll[2])) { + same = e; + break; + } + } + if (same == null) { + TileModel t = (TileModel) s.create(NodeKind.TILE_MODEL); + t.setRaw("name", a.name()); + t.setRaw("vertices", verts); + t.setRaw("faces", faces); + t.setRaw("planes", doubles((String) m.get("planes"))); + for (int g = 0; g < 3; g++) { + t.setRaw(TileModel.collisionProp(g), coll[g]); + } + cmds.add(new AddNode(t)); + same = t; + } + models.add(same); + } + for (Object o : (List<Object>) a.json().get("tiles")) { + Map<String, Object> m = (Map<String, Object>) o; + Tile t = (Tile) s.create(NodeKind.TILE); + t.setRaw("x", (double) (float) (cx + num(m, "x"))); + t.setRaw("y", (double) (float) (y + num(m, "y"))); + t.setRaw("z", (double) (float) -(cz + num(m, "z"))); + t.setRaw("rot", ((Number) m.get("rot")).intValue()); + TileModel md = models.get(((Number) m.get("model")).intValue()); + t.setRaw("model", md); + int[] col = ints((String) m.get("colors")); + t.setRaw("colors", col.length == md.vertexCount() ? col : grey(md.vertexCount())); + cmds.add(new AddNode(t)); + out.add(t); + } + return cmds; + } + + private static double num(Map<String, Object> m, String k) { + return ((Number) m.get(k)).doubleValue(); + } + + private static long hash(BufferedImage img) { + long h = img.getWidth() * 31L + img.getHeight(); + for (int y = 0; y < img.getHeight(); y++) { + for (int x = 0; x < img.getWidth(); x++) { + int c = img.getRGB(x, y); + // compare in the page format (ABGR1555): alpha 0 or 1, 5 bits per channel + int q = (c >>> 24) < 64 ? 0 : (c >> 3 & 0x1f1f1f) | 0x1000000; + h = h * 1_000_003L + q; + } + } + return h; + } + + // ------------------------------------------------------------------ base64 arrays + + static String b64(int[] a) { + ByteBuffer b = ByteBuffer.allocate(a.length * 4).order(ByteOrder.LITTLE_ENDIAN); + for (int v : a) { + b.putInt(v); + } + return Base64.getEncoder().encodeToString(b.array()); + } + + static String b64(double[] a) { + ByteBuffer b = ByteBuffer.allocate(a.length * 8).order(ByteOrder.LITTLE_ENDIAN); + for (double v : a) { + b.putDouble(v); + } + return Base64.getEncoder().encodeToString(b.array()); + } + + static int[] ints(String s) { + ByteBuffer b = ByteBuffer.wrap(Base64.getDecoder().decode(s)).order(ByteOrder.LITTLE_ENDIAN); + int[] a = new int[b.remaining() / 4]; + for (int i = 0; i < a.length; i++) { + a[i] = b.getInt(); + } + return a; + } + + static double[] doubles(String s) { + ByteBuffer b = ByteBuffer.wrap(Base64.getDecoder().decode(s)).order(ByteOrder.LITTLE_ENDIAN); + double[] a = new double[b.remaining() / 8]; + for (int i = 0; i < a.length; i++) { + a[i] = b.getDouble(); + } + return a; + } + + // ------------------------------------------------------------------ OBJ export + + /** + * Writes the placed tiles as an OBJ (export space = the importer's convention: y up, file z = −game + * z) relative to the anchor cell, with UVs per texture, vertex colours ("v x y z r g b", 0x80 = + * 1.0 → 0.5), an MTL and one PNG per texture; collision optionally as a separate OBJ. + */ + static List<Path> exportObj(List<Placed> placed, Tables tables, Path obj, boolean collision) throws IOException { + double[] anchor = anchor(placed); + double ax = anchor[0], ay = anchor[1], az = -anchor[2]; + String stem = obj.getFileName().toString().replaceFirst("\\.obj$", ""); + Path dir = obj.toAbsolutePath().getParent(); + StringBuilder v = new StringBuilder(); + StringBuilder vt = new StringBuilder(); + Map<Integer, StringBuilder> byTex = new TreeMap<>(); + int nv = 0, nt = 0; + for (Placed p : placed) { + TileModel m = p.model(); + Geometry.TileXform xf = + Geometry.TileXform.of(p.x() - ax, p.y() - ay, p.z() - az, Integer.toUnsignedLong(p.rot())); + int base = nv; + for (int i = 0; i < m.vertexCount(); i++) { + double[] w = xf.point(m.vx(i), m.vy(i), m.vz(i)); + int c = i < p.colors().length ? p.colors()[i] : Tile.GREY; + v.append(String.format( + Locale.ROOT, + "v %.5f %.5f %.5f %.4f %.4f %.4f%n", + w[0], + w[1], + w[2], + Math.min(1, (c & 0xff) / 128.0), + Math.min(1, (c >> 8 & 0xff) / 128.0), + Math.min(1, (c >> 16 & 0xff) / 128.0))); + nv++; + } + for (int f = 0; f < m.faceCount(); f++) { + int tex = m.faceTexture(f); + double[] uv = faceUv(tables, tex, m.faceFlags(f)); + StringBuilder sb = byTex.computeIfAbsent(tex, k -> new StringBuilder()); + sb.append("f"); + for (int k = 0; k < 3; k++) { + vt.append(String.format(Locale.ROOT, "vt %.5f %.5f%n", uv[2 * k], 1 - uv[2 * k + 1])); + nt++; + sb.append(' ') + .append(base + m.faceVertex(f, k) + 1) + .append('/') + .append(nt); + } + sb.append('\n'); + } + } + List<Path> written = new ArrayList<>(); + StringBuilder mtl = new StringBuilder(); + StringBuilder o = + new StringBuilder("# LlamaMod level editor export (contains game textures – personal use only)\n"); + o.append("mtllib ") + .append(stem) + .append(".mtl\no ") + .append(stem) + .append('\n') + .append(v) + .append(vt); + for (Map.Entry<Integer, StringBuilder> e : byTex.entrySet()) { + String mat = "tex_" + e.getKey(); + BufferedImage img = image(tables, e.getKey()); + mtl.append("newmtl ").append(mat).append("\nKd 1 1 1\n"); + if (img != null) { + String png = stem + "_" + mat + ".png"; + Files.write(dir.resolve(png), Images.png(img)); + written.add(dir.resolve(png)); + mtl.append("map_Kd ").append(png).append('\n'); + } + o.append("usemtl ").append(mat).append('\n').append(e.getValue()); + } + Files.writeString(dir.resolve(stem + ".mtl"), mtl); + Files.writeString(obj, o); + written.add(obj); + written.add(dir.resolve(stem + ".mtl")); + if (collision) { + StringBuilder c = new StringBuilder("# collision polygons (o mat<material>)\n"); + int n = 0; + Map<Integer, StringBuilder> byMat = new TreeMap<>(); + StringBuilder cv = new StringBuilder(); + for (Placed p : placed) { + Geometry.TileXform xf = + Geometry.TileXform.of(p.x() - ax, p.y() - ay, p.z() - az, Integer.toUnsignedLong(p.rot())); + for (TileModel.PlacedPolygon poly : p.model().collisionPolygons(xf)) { + StringBuilder sb = byMat.computeIfAbsent(poly.material(), x -> new StringBuilder()); + sb.append('f'); + for (double[] w : poly.corners()) { + cv.append(String.format(Locale.ROOT, "v %.5f %.5f %.5f%n", w[0], w[1], w[2])); + sb.append(' ').append(++n); + } + sb.append('\n'); + } + } + c.append(cv); + byMat.forEach( + (mat, sb) -> c.append("o mat").append(mat).append('\n').append(sb)); + Path cp = dir.resolve(stem + "_collision.obj"); + Files.writeString(cp, c); + written.add(cp); + } + return written; + } + + /** UVs (u, v per corner, v = 0 at the top) of a face inside its texture rectangle. */ + static double[] faceUv(Tables t, int tex, int flags) { + boolean m = (flags & 0x20) != 0; + double a = m ? 0 : 1, b = m ? 1 : 0; + if ((flags & 0x800) == 0) { + return new double[] {a, 1, b, 1, a, 0}; + } + if ((flags & 0x10) == 0) { + return new double[] {a, 1, b, 1, b, 0}; + } + return new double[] {b, 0, a, 0, a, 1}; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/Primitive.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/Primitive.java new file mode 100644 index 0000000..7b726ae --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/Primitive.java @@ -0,0 +1,307 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.BrushKind; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * What the Brush tool draws: a brush kind plus how it is drawn (footprint rectangle, line or single + * click) and whether a height is set by a second mouse move. + */ +public enum Primitive { + BOX("Block", "prim-box", BrushKind.BOX, Shape.RECT, true, "Solid block: drag the footprint, then the height"), + PLATFORM( + "Platform", + "prim-platform", + BrushKind.PLATFORM, + Shape.RECT, + true, + "Floating platform with edge and bottom: drag the footprint, then the top height"), + FLOOR("Floor", "prim-floor", BrushKind.FLOOR, Shape.RECT, false, "Flat floor (top only): drag the area"), + RAMP( + "Ramp", + "prim-ramp", + BrushKind.RAMP, + Shape.RECT, + true, + "Ramp rising in the drag direction: drag the footprint, then the high end"), + STAIRS( + "Stairs", + "prim-stairs", + BrushKind.STAIRS, + Shape.RECT, + true, + "Stairs rising in the drag direction: drag the footprint, then the top height"), + PILLAR( + "Pillar", + "prim-pillar", + BrushKind.PILLAR, + Shape.RECT, + true, + "Square pillar: click (default size) or drag its size, then the height"), + CYLINDER( + "Round", + "prim-cylinder", + BrushKind.CYLINDER, + Shape.RECT, + true, + "Round platform or column: drag the diameter, then the height"), + WALL( + "Wall", + "prim-wall", + BrushKind.BOX, + Shape.LINE, + true, + "Wall: drag along its length (any direction), then the height"), + FENCE( + "Fence", + "prim-fence", + BrushKind.FENCE, + Shape.POLYLINE, + false, + "Fence: click the corner points, double-click or Enter to finish"), + TREE("Tree", "prim-tree", BrushKind.TREE, Shape.POINT, false, "Block tree: click to plant"), + WATER( + "Water", + "prim-water", + BrushKind.SEA, + Shape.RECT, + false, + "Water surface without collision, with a death plane just above it: drag the area"), + DEATH( + "Death plane", + "prim-death", + BrushKind.KILL_PLANE, + Shape.RECT, + false, + "Invisible no-floor plane (material 30) – falling onto it kills: drag the area"), + INVISIBLE_WALL( + "Invisible wall", + "prim-invwall", + BrushKind.BOX, + Shape.LINE, + true, + "Invisible collision wall: drag along its length, then the height"), + INVISIBLE_BOX( + "Invisible block", + "prim-invbox", + BrushKind.BOX, + Shape.RECT, + true, + "Invisible collision volume (floor, ceiling or blocker): drag the footprint, then the height"); + + public enum Shape { + RECT, + LINE, + POLYLINE, + POINT + } + + public final String label; + public final String icon; + public final BrushKind kind; + public final Shape shape; + public final boolean height; + public final String tip; + + Primitive(String label, String icon, BrushKind kind, Shape shape, boolean height, String tip) { + this.label = label; + this.icon = icon; + this.kind = kind; + this.shape = shape; + this.height = height; + this.tip = tip; + } + + public boolean invisible() { + return this == INVISIBLE_WALL || this == INVISIBLE_BOX; + } + + /** Options of the Brush tool that feed the parameters. */ + public static final class Options { + public String top = "grass"; + public String side = "earth"; + public String edge = "grass_edge"; + public String bottom = ""; + public double height = 1.0; + public double thickness = 0.5; + public double wallThickness = 0.25; + public int steps = 4; + public int segments = 16; + public boolean coll = true; + public boolean sideColl = true; + public boolean walkable = true; + public int material = 0; + public String water = "sea"; + } + + /** + * Parameters for a primitive drawn from (ax, az) to (bx, bz) on ground height y with height h + * (game space cells). Returns null if the footprint is degenerate. + */ + public Map<String, Object> params(double ax, double az, double bx, double bz, double y, double h, Options o) { + Map<String, Object> m = new LinkedHashMap<>(); + double x0 = Math.min(ax, bx), x1 = Math.max(ax, bx), z0 = Math.min(az, bz), z1 = Math.max(az, bz); + double dx = bx - ax, dz = bz - az; + h = Math.max(0.02, h); + if (shape == Shape.RECT && this != PILLAR && this != CYLINDER && (x1 - x0 < 0.02 || z1 - z0 < 0.02)) { + return null; + } + switch (this) { + case BOX, INVISIBLE_BOX -> { + rect(m, x0, z0, x1, z1); + m.put("y0", y); + m.put("y1", y + h); + if (this == BOX) { + textures(m, o, true); + m.put("coll", o.coll); + m.put("sideColl", o.sideColl); + m.put("mat", o.material); + } else { + invisible(m, o); + } + } + case PLATFORM -> { + m.put("x", x0); + m.put("z", z0); + m.put("w", x1 - x0); + m.put("d", z1 - z0); + m.put("y", y + h); + m.put("thick", Math.min(o.thickness, Math.max(0.05, h))); + m.put("top", o.top); + m.put("side", o.side); + m.put("sideTop", o.edge); + m.put("bottom", o.bottom.isEmpty() ? o.side : o.bottom); + m.put("mat", o.material); + } + case FLOOR -> { + rect(m, x0, z0, x1, z1); + m.put("y", y); + m.put("tex", o.top); + m.put("coll", o.coll); + m.put("mat", o.material); + } + case RAMP, STAIRS -> { + rect(m, x0, z0, x1, z1); + String dir = Math.abs(dx) >= Math.abs(dz) ? (dx >= 0 ? "+x" : "-x") : (dz >= 0 ? "+z" : "-z"); + m.put("direction", dir); + m.put("top", o.top); + m.put("side", o.side); + if (this == RAMP) { + m.put("ya", y); + m.put("yb", y + h); + m.put("coll", o.coll); + } else { + // the walkable slope starts at y0 (the floor in front), the first step is one step higher + m.put("y0", y); + m.put("y1", y + h); + m.put("steps", Math.max(1, o.steps)); + m.put("walkable", o.walkable); + } + m.put("base", y - 0.5); + } + case PILLAR -> { + double size = Math.max(x1 - x0, z1 - z0); + if (size < 0.05) { + size = 0.5; + } + m.put("x", (x0 + x1) / 2); + m.put("z", (z0 + z1) / 2); + m.put("size", size); + m.put("y0", y); + m.put("y1", y + h); + m.put("side", o.side); + m.put("top", o.top); + } + case CYLINDER -> { + double r = Math.max(x1 - x0, z1 - z0) / 2; + if (r < 0.05) { + r = 1; + } + m.put("x", (x0 + x1) / 2); + m.put("z", (z0 + z1) / 2); + m.put("radius", r); + m.put("y0", y); + m.put("y1", y + h); + m.put("segments", Math.max(3, Math.min(64, o.segments))); + textures(m, o, false); + m.put("coll", o.coll); + m.put("sideColl", o.sideColl); + m.put("mat", o.material); + } + case WALL, INVISIBLE_WALL -> { + double len = Math.hypot(dx, dz); + if (len < 0.05) { + return null; + } + double t = Math.max(0.02, o.wallThickness); + double cx = (ax + bx) / 2, cz = (az + bz) / 2; + double ang = Math.toDegrees(Math.atan2(-dz, dx)); + double q = ang / 90; + if (Math.abs(q - Math.rint(q)) < 1e-6) { + boolean alongX = Math.abs(dx) > Math.abs(dz); + double hx = alongX ? len / 2 : t / 2, hz = alongX ? t / 2 : len / 2; + rect(m, cx - hx, cz - hz, cx + hx, cz + hz); + } else { + rect(m, cx - len / 2, cz - t / 2, cx + len / 2, cz + t / 2); + m.put("rotation", new double[] {0, BrushMath.clean(ang), 0}); + } + m.put("y0", y); + m.put("y1", y + h); + if (this == WALL) { + textures(m, o, true); + m.put("top", o.side); + m.put("coll", o.coll); + m.put("mat", o.material); + } else { + invisible(m, o); + } + } + case FENCE -> { + m.put("points", new double[] {ax, az, bx, bz}); + m.put("y", y); + } + case TREE -> { + m.put("x", bx); + m.put("z", bz); + m.put("y", y); + } + case WATER -> { + rect(m, x0, z0, x1, z1); + m.put("y", y); + m.put("tex", o.water); + m.put("kill", "whole"); + } + case DEATH -> { + rect(m, x0, z0, x1, z1); + m.put("y", y); + } + } + return m; + } + + private static void rect(Map<String, Object> m, double x0, double z0, double x1, double z1) { + m.put("x0", x0); + m.put("z0", z0); + m.put("x1", x1); + m.put("z1", z1); + } + + private static void textures(Map<String, Object> m, Options o, boolean box) { + m.put("top", o.top); + m.put("side", o.side); + m.put("sideTop", o.edge); + m.put("bottom", o.bottom); + } + + private static void invisible(Map<String, Object> m, Options o) { + m.put("visible", false); + m.put("top", "stone"); + m.put("side", "stone"); + m.put("bottom", "stone"); + m.put("solid", false); + m.put("coll", true); + m.put("sideColl", true); + m.put("mat", o.material); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/RotateTool3D.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/RotateTool3D.java new file mode 100644 index 0000000..a6da974 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/RotateTool3D.java @@ -0,0 +1,223 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Brush; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.render.Gizmo; +import com.eliasmueller.llamamod.editor.render.GizmoEvent; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.NodeCopy; +import com.eliasmueller.llamamod.editor.ui.Tool; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import com.eliasmueller.llamamod.editor.ui.tools.TransformSupport; +import java.awt.Cursor; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.swing.JComponent; +import javax.swing.KeyStroke; +import org.joml.Vector3f; + +/** + * The Rotate tool with all three rings: turning about the vertical axis works for everything (as + * the built-in tool); the red and blue rings turn brushes freely (tiles and objects can only turn + * about the vertical axis in the engine – they are left alone and the status bar says so). + */ +final class RotateTool3D implements Tool { + private final Tool base; + private EditorContext ctx; + private boolean free; + private boolean executed; + private Object mergeKey; + private final Map<Brush, Brush> start = new IdentityHashMap<>(); + private int skipped; + + RotateTool3D(Tool base) { + this.base = base; + } + + @Override + public String id() { + return base.id(); + } + + @Override + public String name() { + return base.name(); + } + + @Override + public String icon() { + return base.icon(); + } + + @Override + public String tooltip() { + return "Rotate: green ring = turn about the vertical axis (everything); red/blue rings turn brushes" + + " freely (tiles and objects only turn about the vertical axis)"; + } + + @Override + public KeyStroke shortcut() { + return base.shortcut(); + } + + @Override + public String group() { + return base.group(); + } + + @Override + public int order() { + return base.order(); + } + + @Override + public void activate(EditorContext c) { + ctx = c; + base.activate(c); + } + + @Override + public void deactivate(EditorContext c) { + base.deactivate(c); + } + + @Override + public Gizmo.Mode gizmoMode() { + return base.gizmoMode(); + } + + @Override + public void placeGizmo(EditorContext c) { + base.placeGizmo(c); + } + + @Override + public boolean mousePressed(ToolEvent e) { + return base.mousePressed(e); + } + + @Override + public void mouseDragged(ToolEvent e) { + base.mouseDragged(e); + } + + @Override + public void mouseReleased(ToolEvent e) { + base.mouseReleased(e); + } + + @Override + public void mouseMoved(ToolEvent e) { + base.mouseMoved(e); + } + + @Override + public boolean keyPressed(ToolEvent e) { + return base.keyPressed(e); + } + + @Override + public void drawOverlay(Overlay depthTested, Overlay onTop) { + base.drawOverlay(depthTested, onTop); + } + + @Override + public JComponent optionsBar(EditorContext c) { + return base.optionsBar(c); + } + + @Override + public JComponent inspectorSection(EditorContext c, List<Node> selection) { + return base.inspectorSection(c, selection); + } + + @Override + public Cursor cursor() { + return base.cursor(); + } + + @Override + public void gizmo(EditorContext c, GizmoEvent e) { + ctx = c; + if (e.phase() == GizmoEvent.Phase.START) { + free = e.mode() == Gizmo.Mode.ROTATE && Math.abs(e.axis().y) < 0.9f; + if (free) { + begin(); + return; + } + } + if (!free) { + base.gizmo(c, e); + return; + } + switch (e.phase()) { + case DRAG, END -> { + drag(e); + if (e.phase() == GizmoEvent.Phase.END) { + finish(); + } else { + c.viewport().gizmo().setTarget(e.result()); + } + } + case CANCEL -> { + if (executed && c.history() != null) { + c.history().undo(); + } + finish(); + } + default -> {} + } + } + + private void begin() { + start.clear(); + skipped = 0; + for (Node n : ctx.selection().nodes()) { + if (n instanceof Brush b && b.brushKind().transformable() && !ctx.isLocked(b)) { + start.put(b, (Brush) NodeCopy.snapshot(b)); + } else if (ctx.transformer(n) != null) { + skipped++; + } + } + mergeKey = new Object(); + executed = false; + } + + private void drag(GizmoEvent e) { + double deg = -Math.toDegrees(e.angle()); + boolean snap = ctx.snapping().enabled() != TransformSupport.ctrlDown(); + if (snap) { + deg = ctx.snapping().snapAngle(deg); + } + Vector3f ax = e.axis(); + double[] axis = {ax.x, ax.y, -ax.z}; + Vector3f pv = e.pivot(); + double[] pivot = {pv.x, pv.y, -pv.z}; + Map<Node, Map<String, Object>> values = new LinkedHashMap<>(); + for (Map.Entry<Brush, Brush> en : start.entrySet()) { + Brush target = + BrushMath.apply(en.getValue(), BrushMath.rotate(en.getValue(), axis, Math.toRadians(deg), pivot)); + Map<String, Object> d = BrushMath.diff(en.getKey(), target); + if (!d.isEmpty()) { + values.put(en.getKey(), d); + } + } + if (!values.isEmpty()) { + ctx.execute(Edits.set("Rotate", values, mergeKey)); + executed = true; + } + ctx.status(String.format(java.util.Locale.ROOT, "Turned %d brush(es) by %.1f°", start.size(), deg) + + (skipped > 0 ? " – " + skipped + " tile(s)/object(s) can only turn about the vertical axis" : "")); + } + + private void finish() { + free = false; + mergeKey = null; + start.clear(); + base.placeGizmo(ctx); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java new file mode 100644 index 0000000..6eefdd8 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java @@ -0,0 +1,561 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.Tile; +import com.eliasmueller.llamamod.editor.model.TileModel; +import com.eliasmueller.llamamod.editor.model.cmd.AddNode; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.render.Legend; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.EditorWindow; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.Tool; +import com.eliasmueller.llamamod.editor.ui.ToolEvent; +import com.eliasmueller.llamamod.editor.ui.view.Thumbnails; +import com.eliasmueller.llamamod.format.Geometry; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.GridLayout; +import java.awt.event.KeyEvent; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.ImageIcon; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.JTextField; +import javax.swing.JToggleButton; +import javax.swing.KeyStroke; +import org.joml.Vector3f; + +/** + * Tile tools for original-style levels (TRAK models on the MAP grid): paint the palette model into + * grid cells at a height and rotation, pick a tile (eyedropper), erase, raise/lower and swap the + * model of tiles. Tiles can only turn about the vertical axis and cannot be scaled (engine). + */ +final class TilePaintTool implements Tool { + enum Mode { + PAINT("Paint", "geo-paint", "Click or drag over cells to place the palette model"), + PICK("Pick", "geo-pick", "Click a tile: its model, height and rotation become the brush (also Shift+click)"), + ERASE("Erase", "delete", "Click or drag over tiles to delete them"), + HEIGHT("Raise / lower", "geo-height", "Click a tile to raise it by the height step, Shift+click lowers"), + SWAP("Swap model", "geo-swap", "Click a tile to replace its model with the palette model"); + + final String label; + final String icon; + final String tip; + + Mode(String label, String icon, String tip) { + this.label = label; + this.icon = icon; + this.tip = tip; + } + } + + Mode mode = Mode.PAINT; + TileModel model; + double height = 0; + int rot = 0; + double heightStep = 0.25; + boolean skipOccupied = true; + /** Paint on the surface under the cursor (height = picked tile's height there) instead of a fixed height. */ + boolean onSurface = true; + + private double hoverY; + private EditorContext ctx; + private int[] hoverCell; + private Tile hoverTile; + private Object stroke; + private final List<String> painted = new ArrayList<>(); + private JLabel current; + private JSpinner heightField; + private JComboBox<String> rotField; + private static final Map<TileModel, ImageIcon> THUMBS = new IdentityHashMap<>(); + + @Override + public String id() { + return "geo.tiles"; + } + + @Override + public String name() { + return "Tiles"; + } + + @Override + public String icon() { + return "geo-tiles"; + } + + @Override + public String tooltip() { + return "Tiles: paint tile models onto grid cells, pick, erase, raise/lower and swap tiles"; + } + + @Override + public KeyStroke shortcut() { + return KeyStroke.getKeyStroke('T', 0); + } + + @Override + public String group() { + return "geometry"; + } + + @Override + public int order() { + return 220; + } + + @Override + public Cursor cursor() { + return Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR); + } + + @Override + public void activate(EditorContext c) { + ctx = c; + if (model != null && (c.scene() == null || !c.scene().contains(model))) { + model = null; + } + if (model == null && c.selection().primary() instanceof Tile t) { + pickFrom(t); + } + } + + @Override + public void deactivate(EditorContext c) { + hoverCell = null; + hoverTile = null; + } + + // ------------------------------------------------------------------ helpers + + private Tile tileAt(ToolEvent e) { + Node n = e.pick(); + return n instanceof Tile t ? t : null; + } + + private int[] cellAt(ToolEvent e) { + Vector3f p = onSurface ? e.ground() : e.onPlane((float) height); + hoverY = height; + if (onSurface && p != null) { + Node n = e.pick(); + hoverY = n instanceof Tile t ? t.y() : ctx.snapping().snap(p.y); + } + if (p == null) { + return null; + } + int cx = (int) Math.floor(p.x), cz = (int) Math.floor(-p.z); + Scene s = ctx.scene(); + if (cx < 0 || cz < 0 || cx >= s.settings().width() || cz >= s.settings().height()) { + return null; + } + return new int[] {cx, cz}; + } + + private void pickFrom(Tile t) { + model = t.model(); + height = t.y(); + hoverY = t.y(); + rot = Math.floorMod(Math.round(t.rot() / 1024f), 4); + syncUi(); + ctx.status("Picked " + (model == null ? "?" : model.id()) + " at height " + BrushTool.fmt(height) + ", " + + rot * 90 + "°"); + } + + private void syncUi() { + if (current != null) { + current.setText(model == null ? "No model – pick one below or Shift+click a tile" : model.label()); + current.setIcon(model == null ? null : thumb(model, 48)); + } + if (heightField != null) { + heightField.setValue(height); + } + if (rotField != null) { + rotField.setSelectedIndex(rot); + } + } + + /** Vertex colours for a new tile of the model: copied from a tile that uses it, else grey. */ + static int[] colours(Scene s, TileModel m) { + for (Tile t : s.tiles()) { + if (t.model() == m && t.colors().length >= m.vertexCount()) { + return java.util.Arrays.copyOf(t.colors(), m.vertexCount()); + } + } + int[] c = new int[m.vertexCount()]; + java.util.Arrays.fill(c, Tile.GREY); + return c; + } + + private void paint(int[] cell) { + Scene s = ctx.scene(); + if (model == null || !s.contains(model)) { + ctx.status("Choose a tile model first (palette in the Inspector, or Shift+click a tile)"); + return; + } + String key = cell[0] + "," + cell[1]; + if (painted.contains(key)) { + return; + } + painted.add(key); + if (skipOccupied) { + for (Tile t : s.tiles()) { + if (t.cellX() == cell[0] + && t.cellZ() == cell[1] + && Math.abs(t.y() - hoverY) < 0.01 + && t.rot() == rot * 1024) { + if (t.model() != model && !ctx.isLocked(t)) { + // a tile at this height: swap its model (keeps the tile's index for strat scripts) + ctx.execute(new Batch( + "Paint tiles", + List.of(SetProp.of(t, "model", model), SetProp.of(t, "colors", colours(s, model))), + stroke)); + } + return; + } + } + } + Tile t = (Tile) s.create(NodeKind.TILE); + t.setRaw("x", cell[0] + 0.5); + t.setRaw("y", (double) (float) hoverY); + t.setRaw("z", -(cell[1] + 0.5)); + t.setRaw("rot", rot * 1024); + t.setRaw("model", model); + t.setRaw("colors", colours(s, model)); + ctx.execute(new Batch("Paint tiles", List.<Command>of(new AddNode(t)), stroke)); + ctx.status("Painted " + model.id() + " in cell " + key); + } + + // ------------------------------------------------------------------ mouse + + @Override + public boolean mousePressed(ToolEvent e) { + ctx = e.ctx(); + if (ctx.scene() == null) { + return false; + } + stroke = new Object(); + painted.clear(); + Tile t = tileAt(e); + if (e.shift() && mode != Mode.HEIGHT || mode == Mode.PICK) { + if (t != null) { + pickFrom(t); + } + return true; + } + act(e, t); + return true; + } + + private void act(ToolEvent e, Tile t) { + switch (mode) { + case PAINT -> { + int[] c = cellAt(e); + if (c != null) { + paint(c); + } + } + case ERASE -> { + if (t != null && !ctx.isLocked(t) && !painted.contains(t.id())) { + painted.add(t.id()); + ctx.execute(new Batch("Erase tiles", List.of(Edits.delete(ctx.scene(), List.of(t))), stroke)); + ctx.status( + "Erased " + t.label() + + (t.id().contains(":n") + ? "" + : " (an original tile: strat scripts" + + " that address tiles by number may now hit other tiles – hiding is safer)")); + } + } + case HEIGHT -> { + if (t != null && !painted.contains(t.id())) { + painted.add(t.id()); + List<Node> l = ctx.selection().contains(t) + ? new ArrayList<>(ctx.selection().of(Tile.class)) + : List.of(t); + double d = e.shift() ? -heightStep : heightStep; + ctx.execute(Edits.translate(l, 0, d, 0, null)); + ctx.status((d > 0 ? "Raised " : "Lowered ") + l.size() + " tile(s) to " + BrushTool.fmt(t.y())); + } + } + case SWAP -> { + if (t != null && model != null && t.model() != model && !painted.contains(t.id())) { + painted.add(t.id()); + List<Command> cmds = new ArrayList<>(); + cmds.add(SetProp.of(t, "model", model)); + cmds.add(SetProp.of(t, "colors", colours(ctx.scene(), model))); + ctx.execute(new Batch("Swap tile model", cmds, stroke)); + ctx.status(t.label() + " now uses " + model.id()); + } + } + default -> {} + } + } + + @Override + public void mouseDragged(ToolEvent e) { + if (stroke == null || mode == Mode.PICK || mode == Mode.HEIGHT) { + return; + } + mouseMoved(e); + act(e, mode == Mode.PAINT ? null : tileAt(e)); + } + + @Override + public void mouseReleased(ToolEvent e) { + stroke = null; + painted.clear(); + } + + @Override + public void mouseMoved(ToolEvent e) { + ctx = e.ctx(); + if (ctx.scene() == null) { + return; + } + if (mode == Mode.PAINT) { + hoverCell = cellAt(e); + hoverTile = null; + } else { + hoverTile = tileAt(e); + hoverCell = null; + if (hoverTile != null) { + ctx.status(hoverTile.label() + ", height " + BrushTool.fmt(hoverTile.y()) + ", " + + Math.round(hoverTile.rot() * 360.0 / 4096) + "°"); + } + } + ctx.repaintTool(); + } + + @Override + public boolean keyPressed(ToolEvent e) { + KeyEvent k = e.key(); + if (k == null) { + return false; + } + switch (k.getKeyCode()) { + case KeyEvent.VK_OPEN_BRACKET, KeyEvent.VK_CLOSE_BRACKET -> { + rot = Math.floorMod(rot + (k.getKeyCode() == KeyEvent.VK_CLOSE_BRACKET ? 1 : -1), 4); + syncUi(); + ctx.status("Tile rotation " + rot * 90 + "°"); + ctx.repaintTool(); + return true; + } + case KeyEvent.VK_PAGE_UP, KeyEvent.VK_PAGE_DOWN -> { + height += k.getKeyCode() == KeyEvent.VK_PAGE_UP ? heightStep : -heightStep; + syncUi(); + ctx.status("Paint height " + BrushTool.fmt(height)); + ctx.repaintTool(); + return true; + } + default -> {} + } + return false; + } + + @Override + public void drawOverlay(Overlay depthTested, Overlay onTop) { + if (ctx == null || ctx.scene() == null) { + return; + } + int cyan = Legend.rgb(80, 220, 255); + if (hoverCell != null) { + double y = hoverY + 0.01; + double x0 = hoverCell[0], z0 = -hoverCell[1], x1 = x0 + 1, z1 = z0 - 1; + int c = Legend.withAlpha(cyan, 230); + onTop.line(new double[] {x0, y, z0}, new double[] {x1, y, z0}, c); + onTop.line(new double[] {x1, y, z0}, new double[] {x1, y, z1}, c); + onTop.line(new double[] {x1, y, z1}, new double[] {x0, y, z1}, c); + onTop.line(new double[] {x0, y, z1}, new double[] {x0, y, z0}, c); + if (model != null && ctx.scene().contains(model)) { + wire( + onTop, + model, + Geometry.TileXform.of(hoverCell[0] + 0.5, hoverY, -(hoverCell[1] + 0.5), rot * 1024L), + Legend.withAlpha(cyan, 140)); + } + } + if (hoverTile != null && hoverTile.model() != null) { + int c = + switch (mode) { + case ERASE -> Legend.rgb(255, 80, 80); + case SWAP -> Legend.rgb(255, 200, 60); + default -> cyan; + }; + wire(onTop, hoverTile.model(), hoverTile.transform(), Legend.withAlpha(c, 200)); + if (mode == Mode.SWAP && model != null) { + wire(depthTested, model, hoverTile.transform(), Legend.withAlpha(Legend.rgb(255, 255, 255), 140)); + } + } + } + + /** Edges of a tile model's triangles (at most a few thousand). */ + static void wire(Overlay o, TileModel m, Geometry.TileXform xf, int c) { + int nf = Math.min(m.faceCount(), 1500); + for (int f = 0; f < nf; f++) { + double[][] p = new double[3][]; + for (int k = 0; k < 3; k++) { + int v = m.faceVertex(f, k); + p[k] = xf.point(m.vx(v), m.vy(v), m.vz(v)); + } + o.line(p[0], p[1], c); + o.line(p[1], p[2], c); + o.line(p[2], p[0], c); + } + } + + // ------------------------------------------------------------------ UI + + @Override + public JComponent optionsBar(EditorContext c) { + ctx = c; + JPanel p = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 4, 0)); + ButtonGroup g = new ButtonGroup(); + for (Mode m : Mode.values()) { + JToggleButton b = new JToggleButton(m.label, Icons.get(m.icon, 14), m == mode); + b.setToolTipText(m.tip); + b.setFocusable(false); + b.addActionListener(e -> { + mode = m; + ctx.status(m.tip); + }); + g.add(b); + p.add(b); + } + JCheckBox surf = new JCheckBox("On surface", onSurface); + surf.setToolTipText("Paint at the height of the tile/ground under the cursor (off: the fixed height)"); + surf.setFocusable(false); + surf.addActionListener(e -> onSurface = surf.isSelected()); + p.add(new JLabel(" ")); + p.add(surf); + p.add(new JLabel("Height")); + heightField = GeoWidgets.spinner(height, -50, 100, 0.25, v -> height = v); + heightField.setToolTipText("Height of painted tiles (Page Up / Down)"); + p.add(heightField); + p.add(new JLabel("Rotation")); + rotField = new JComboBox<>(new String[] {"0°", "90°", "180°", "270°"}); + rotField.setSelectedIndex(rot); + rotField.setFocusable(false); + rotField.setToolTipText("Rotation of painted tiles ([ and ]); tiles only turn about the vertical axis"); + rotField.addActionListener(e -> rot = rotField.getSelectedIndex()); + p.add(rotField); + p.add(new JLabel("Step")); + p.add(GeoWidgets.spinner(heightStep, 0.01, 10, 0.05, v -> heightStep = v)); + return p; + } + + ImageIcon thumb(TileModel m, int size) { + ImageIcon ic = THUMBS.get(m); + if (ic == null && ctx instanceof EditorWindow w) { + try { + BufferedImage img = + Thumbnails.render(w.renderer().tileMesh(m), w.renderer().pages(), 64); + ic = new ImageIcon(img); + } catch (RuntimeException e) { + ic = new ImageIcon(new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB)); + } + THUMBS.put(m, ic); + } + if (ic == null) { + return null; + } + return size == 64 + ? ic + : new ImageIcon(ic.getImage().getScaledInstance(size, size, java.awt.Image.SCALE_SMOOTH)); + } + + @Override + public JComponent inspectorSection(EditorContext c, List<Node> selection) { + ctx = c; + Scene s = c.scene(); + if (s == null) { + return null; + } + current = new JLabel(); + current.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6)); + syncUi(); + Map<TileModel, Integer> uses = new IdentityHashMap<>(); + for (Tile t : s.tiles()) { + if (t.model() != null) { + uses.merge(t.model(), 1, Integer::sum); + } + } + JPanel grid = new JPanel(new GridLayout(0, 4, 3, 3)); + grid.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3)); + JTextField filter = new JTextField(); + filter.putClientProperty("JTextField.placeholderText", "Filter (model number)"); + JCheckBox used = new JCheckBox("Only models used in the level", true); + used.setFocusable(false); + Runnable fill = () -> { + grid.removeAll(); + String f = filter.getText().trim(); + int n = 0; + for (TileModel m : s.tileModels()) { + if (used.isSelected() && !uses.containsKey(m) || !f.isEmpty() && !m.id().contains(f)) { + continue; + } + if (n++ >= 240) { + break; + } + JToggleButton b = new JToggleButton(thumb(m, 48), m == model); + b.setToolTipText( + m.label() + " – " + m.vertexCount() + " vertices, " + uses.getOrDefault(m, 0) + " tile(s)"); + b.setPreferredSize(new Dimension(56, 56)); + b.setFocusable(false); + b.addActionListener(e -> { + model = m; + syncUi(); + if (mode != Mode.SWAP) { + mode = Mode.PAINT; + } + ctx.status("Palette: " + m.label()); + }); + grid.add(b); + } + grid.revalidate(); + grid.repaint(); + }; + fill.run(); + used.addActionListener(e -> fill.run()); + filter.addActionListener(e -> fill.run()); + JScrollPane sp = new JScrollPane(grid); + sp.setPreferredSize(new Dimension(260, 260)); + sp.getVerticalScrollBar().setUnitIncrement(24); + sp.setAlignmentX(0); + JPanel top = new JPanel(new BorderLayout(4, 4)); + top.setOpaque(false); + top.add(used, BorderLayout.NORTH); + top.add(filter, BorderLayout.SOUTH); + top.setAlignmentX(0); + JPanel root = GeoWidgets.section( + "Tile palette", + current, + top, + sp, + GeoWidgets.hint("Paint into the cell under the cursor at the paint height. Shift+click picks a tile." + + " [ ] rotate by 90°, Page Up/Down change the height. The engine turns tiles only about" + + " the vertical axis in 90° steps in practice and cannot scale them; new tiles copy the" + + " vertex colours of a tile with the same model.")); + root.setBackground(new Color(0, 0, 0, 0)); + return root; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ContextHelp.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ContextHelp.java new file mode 100644 index 0000000..6234f9d --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/ContextHelp.java @@ -0,0 +1,216 @@ +package com.eliasmueller.llamamod.editor.tools.help; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.ui.ActionSpec; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.HelpTopics; +import com.eliasmueller.llamamod.editor.ui.PanelSpec; +import com.eliasmueller.llamamod.editor.ui.Tool; +import java.awt.Component; +import java.awt.Dialog; +import java.awt.Frame; +import java.awt.KeyboardFocusManager; +import java.awt.MouseInfo; +import java.awt.Point; +import java.awt.PointerInfo; +import java.awt.Window; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.swing.AbstractButton; +import javax.swing.Action; +import javax.swing.JComponent; +import javax.swing.JTabbedPane; +import javax.swing.SwingUtilities; + +/** + * Maps what the user is pointing at (or what has the focus) to a manual page: help topics such as + * "tool.geo.brush", "panel.textures", "inspector.x:Brush shape", "action.geo.array", "dialog.Level + * settings", "node.STRAT" → "file.md#anchor" (resource {@code context-help.txt}; a key ending in + * '*' matches every topic with that prefix). + */ +public final class ContextHelp { + public static final String HOME = Manual.INDEX; + + private static Map<String, String> map; + + private ContextHelp() {} + + /** topic → target, in file order. */ + public static synchronized Map<String, String> map() { + if (map == null) { + map = parse(read()); + } + return map; + } + + static String read() { + try (InputStream in = ContextHelp.class.getResourceAsStream("context-help.txt")) { + return in == null ? "" : new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + return ""; + } + } + + static Map<String, String> parse(String text) { + Map<String, String> m = new LinkedHashMap<>(); + for (String l : text.split("\n")) { + String t = l.strip(); + int eq = t.lastIndexOf(" = "); + if (t.isEmpty() || t.startsWith("#") || eq < 0) { + continue; + } + m.put(t.substring(0, eq).strip(), t.substring(eq + 3).strip()); + } + return m; + } + + /** Target of a topic: exact key, else the longest matching "prefix*" key, else null. */ + public static String target(String topic) { + return target(map(), topic, true); + } + + static String target(Map<String, String> m, String topic, boolean wildcards) { + if (topic == null) { + return null; + } + String t = m.get(topic); + if (t != null || !wildcards) { + return t; + } + String best = null; + int len = -1; + for (var e : m.entrySet()) { + String k = e.getKey(); + if (k.endsWith("*") && topic.startsWith(k.substring(0, k.length() - 1)) && k.length() > len) { + best = e.getValue(); + len = k.length(); + } + } + return best; + } + + /** First candidate with an exact mapping, else the first with a wildcard mapping, else home. */ + public static String resolve(List<String> candidates) { + for (String c : candidates) { + String t = target(map(), c, false); + if (t != null) { + return t; + } + } + for (String c : candidates) { + String t = target(map(), c, true); + if (t != null) { + return t; + } + } + return HOME; + } + + // ------------------------------------------------------------------ what is the user pointing at + + /** Topics for the component under the mouse (in the window that has the focus), best first. */ + public static List<String> candidates(EditorContext ctx) { + Window w = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow(); + if (w == null) { + w = ctx.frame(); + } + Component c = null; + PointerInfo pi = MouseInfo.getPointerInfo(); + if (pi != null && w.isShowing()) { + Point p = new Point(pi.getLocation()); + SwingUtilities.convertPointFromScreen(p, w); + if (p.x >= 0 && p.y >= 0 && p.x < w.getWidth() && p.y < w.getHeight()) { + c = SwingUtilities.getDeepestComponentAt(w, p.x, p.y); + } + } + if (c == null) { + c = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + } + return candidates(ctx, c, w); + } + + static List<String> candidates(EditorContext ctx, Component c, Window w) { + List<String> out = new ArrayList<>(); + Component child = null; + boolean nodeAdded = false; + boolean inViewport = false; + Component canvas = ctx.viewport() == null ? null : ctx.viewport().canvas(); + for (Component x = c; x != null; child = x, x = x.getParent()) { + if (x == canvas) { + inViewport = true; + } + if (x instanceof JComponent jc && jc.getClientProperty(HelpTopics.PROP) instanceof String t) { + out.add(t); + } + if (x instanceof AbstractButton b) { + Action a = b.getAction(); + if (a != null && a.getValue("spec") instanceof ActionSpec s) { + out.add("action." + s.id()); + } + String tip = b.getToolTipText(); + if (tip != null) { + for (Tool t : ctx.extensions().tools()) { + if (t.tooltip() != null && tip.startsWith(t.tooltip())) { + out.add("tool." + t.id()); + } + } + } + } + if (x instanceof JTabbedPane tp) { + int i = child != null ? tp.indexOfComponent(child) : tp.getSelectedIndex(); + if (i >= 0) { + String title = tp.getTitleAt(i); + PanelSpec p = tp.getClientProperty("JTabbedPane.tabCloseCallback") != null + ? panelByTitle(ctx, title) + : null; + if (p != null) { + if (!nodeAdded && (p.id().equals("inspector") || p.id().equals("outliner"))) { + addNode(ctx, out); + nodeAdded = true; + } + out.add("panel." + p.id()); + } else { + out.add("tab." + title); + } + } + } + if (x instanceof Window) { + break; + } + } + if (inViewport && ctx.activeTool() != null) { + out.add("tool." + ctx.activeTool().id()); + } + if (w instanceof Dialog d && d.getTitle() != null) { + out.add("dialog." + d.getTitle()); + } else if (w instanceof Frame f && w != ctx.frame() && f.getTitle() != null) { + out.add("dialog." + f.getTitle()); + } + if (w == ctx.frame() && ctx.document() == null) { + out.add("window.empty"); + } + out.add("window.editor"); + return out; + } + + private static void addNode(EditorContext ctx, List<String> out) { + List<Node> sel = ctx.selection() == null ? List.of() : ctx.selection().nodes(); + if (!sel.isEmpty()) { + out.add("node." + sel.getLast().kind().name()); + } + } + + private static PanelSpec panelByTitle(EditorContext ctx, String title) { + for (PanelSpec p : ctx.extensions().panels()) { + if (p.title().equals(title)) { + return p; + } + } + return null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/help/HelpEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/HelpEditorPlugin.java new file mode 100644 index 0000000..56dd94e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/HelpEditorPlugin.java @@ -0,0 +1,426 @@ +package com.eliasmueller.llamamod.editor.tools.help; + +import com.eliasmueller.llamamod.editor.ui.ActionSpec; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.EditorExtensions; +import com.eliasmueller.llamamod.editor.ui.EditorPlugin; +import com.eliasmueller.llamamod.editor.ui.HelpTopics; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.Tool; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Dimension; +import java.awt.EventQueue; +import java.awt.FlowLayout; +import java.awt.KeyEventDispatcher; +import java.awt.KeyboardFocusManager; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JEditorPane; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTabbedPane; +import javax.swing.KeyStroke; + +/** + * Help → Getting started (an illustrated five-step guide), a printable keyboard shortcut sheet, + * and tooltips for menu/toolbar actions that were registered without one (runs after the other + * plugins). + */ +public final class HelpEditorPlugin implements EditorPlugin { + @Override + public int order() { + return 1000; + } + + /** Tooltips for actions registered without one (id → tip). */ + static final Map<String, String> TIPS = new LinkedHashMap<>(); + + static { + TIPS.put("file.new", "Create a new level from a template (empty, flat floor, island)"); + TIPS.put("file.openOriginal", "Open one of the game's own levels to change it"); + TIPS.put("file.open", "Open a .llamalevel project"); + TIPS.put("file.save", "Save the project (.llamalevel: only your changes, no game data)"); + TIPS.put("file.saveAs", "Save the project under a new name"); + TIPS.put("file.close", "Close the project"); + TIPS.put("file.importMesh", "Import an OBJ or glTF model as level geometry (with textures and collision)"); + TIPS.put("file.exportWad", "Build the level and save the game file (.wad) somewhere"); + TIPS.put("file.quit", "Close the editor"); + TIPS.put("edit.undo", "Undo the last change"); + TIPS.put("edit.redo", "Redo what was undone"); + TIPS.put("edit.history", "Show all changes; click one to go back to it"); + TIPS.put("edit.cut", "Cut the selection"); + TIPS.put("edit.copy", "Copy the selection"); + TIPS.put("edit.paste", "Paste next to the original"); + TIPS.put("edit.duplicate", "Duplicate the selection (objects keep their waypoint chains)"); + TIPS.put("edit.delete", "Delete the selection"); + TIPS.put("edit.selectAll", "Select everything that is visible"); + TIPS.put("edit.selectNone", "Clear the selection"); + TIPS.put("edit.selectSimilar", "Select all nodes of the same type (objects of one kind, tiles of one model)"); + TIPS.put("edit.hide", "Hide the selection in the viewport (editor only: hidden items are still built)"); + TIPS.put("edit.unhide", "Show everything that was hidden"); + TIPS.put("edit.lock", "Lock the selection: it is drawn but cannot be clicked"); + TIPS.put("edit.prefs", "Autosave, play-test save slot, theme"); + TIPS.put("view.focus", "Frame the selection (F)"); + TIPS.put("view.frameAll", "Show the whole level (Home)"); + TIPS.put("view.top", "Look straight down (orthographic)"); + TIPS.put("view.front", "Front view (orthographic)"); + TIPS.put("view.side", "Side view (orthographic)"); + TIPS.put("view.persp", "Back to the perspective view"); + TIPS.put("view.space", "Gizmo axes in world or local space (X)"); + TIPS.put("view.snap", "Snap moves and rotations to the grid (G; Ctrl while dragging inverts it)"); + TIPS.put("view.collision", "Show collision polygons coloured by material (C)"); + TIPS.put("view.theme", "Switch between the light and dark theme"); + TIPS.put("level.settings", "Level name, music, kill plane, TestLevel chapter …"); + TIPS.put("level.check", "List problems that would break the level (missing start, exits …)"); + TIPS.put("help.shortcuts", "All keyboard and mouse shortcuts (the complete list is in the manual)"); + TIPS.put("help.about", "Version, OpenGL and plugin information"); + } + + @Override + public void install(EditorExtensions x) { + HelpIcons.register(); + HelpTopics.setHandler((c, topic) -> ManualWindow.open(c, ContextHelp.resolve(List.of(topic)))); + x.action(ActionSpec.of("help.manual", "Help", 5, 5, "Manual", "help-book", "F1", HelpEditorPlugin::manual) + .tip("The complete user manual: contents, search, tutorials. F1 over a panel, tool or dialog" + + " opens the matching chapter")); + x.action(ActionSpec.of( + "help.exportManual", + "Help", + 5, + 6, + "Export manual as HTML…", + "export", + null, + c -> ManualExport.exportFolder( + c, c.frame(), ManualWindow.open(c, null).manual())) + .tip("Save the manual as HTML pages (one per chapter) to share it or read it in a browser")); + ActionSpec shortcuts = x.action("help.shortcuts"); + if (shortcuts != null && shortcuts.key() != null && shortcuts.key().getKeyCode() == KeyEvent.VK_F1) { + x.action(new ActionSpec( + shortcuts.id(), + shortcuts.menu(), + shortcuts.group(), + shortcuts.order(), + shortcuts.label(), + shortcuts.icon(), + KeyStroke.getKeyStroke("ctrl F1"), + shortcuts.tooltip(), + shortcuts.run(), + shortcuts.enabled(), + shortcuts.toggle())); + } + x.onOpen(HelpEditorPlugin::hook); + x.action(ActionSpec.of( + "help.gettingStarted", + "Help", + 5, + 10, + "Getting started", + "help", + "shift F1", + HelpEditorPlugin::guide) + .tip("A short illustrated guide: from a new level to play-testing and sharing it")); + x.action(ActionSpec.of( + "help.sheet", "Help", 10, 20, "Shortcut sheet (printable)…", "keyboard", null, c -> sheet(c, x)) + .tip("All shortcuts on one page – print it or save it as HTML")); + for (ActionSpec a : new ArrayList<>(x.actions())) { + if ((a.tooltip() == null || a.tooltip().isBlank()) && TIPS.containsKey(a.id())) { + x.action(a.tip(TIPS.get(a.id()))); + } + } + } + + // ------------------------------------------------------------------ manual & context help + + /** F1 (key) = context help for what is under the mouse; the menu item opens the manual where it was. */ + static void manual(EditorContext c) { + if (EventQueue.getCurrentEvent() instanceof KeyEvent k && ManualWindow.isHelpKey(k)) { + ManualWindow.open(c, ContextHelp.resolve(ContextHelp.candidates(c))); + } else { + ManualWindow.open(c, ManualWindow.current() == null ? Manual.INDEX : null); + } + } + + private static void hook(EditorContext c) { + KeyEventDispatcher d = e -> { + if (e.getID() != KeyEvent.KEY_PRESSED || !ManualWindow.isHelpKey(e)) { + return false; + } + Window w = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow(); + if (w == null || w == ManualWindow.current() || !ownedBy(w, c.frame())) { + return false; + } + e.consume(); + ManualWindow.open(c, ContextHelp.resolve(ContextHelp.candidates(c))); + return true; + }; + KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(d); + c.frame().addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosed(java.awt.event.WindowEvent e) { + KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(d); + if (ManualWindow.current() != null) { + ManualWindow.current().dispose(); + } + } + }); + // "?" at the right end of every dock's tab row: help on the panel shown there + EventQueue.invokeLater(() -> dockButtons(c, c.frame().getContentPane())); + } + + private static boolean ownedBy(Window w, Window frame) { + for (Window x = w; x != null; x = x.getOwner()) { + if (x == frame) { + return true; + } + } + return false; + } + + private static void dockButtons(EditorContext c, java.awt.Container root) { + for (java.awt.Component k : root.getComponents()) { + if (k instanceof JTabbedPane tp && tp.getClientProperty("JTabbedPane.tabCloseCallback") != null) { + if (tp.getClientProperty("JTabbedPane.trailingComponent") == null) { + JButton b = new JButton(Icons.get("help", 14)); + b.putClientProperty("JButton.buttonType", "borderless"); + b.setFocusable(false); + b.setToolTipText("Help on this panel (F1)"); + b.addActionListener(e -> { + int i = tp.getSelectedIndex(); + String title = i < 0 ? "" : tp.getTitleAt(i); + String topic = "window.editor"; + for (var p : c.extensions().panels()) { + if (p.title().equals(title)) { + topic = "panel." + p.id(); + } + } + ManualWindow.open(c, ContextHelp.resolve(List.of(topic))); + }); + tp.putClientProperty("JTabbedPane.trailingComponent", b); + } + } else if (k instanceof java.awt.Container ct) { + dockButtons(c, ct); + } + } + } + + // ------------------------------------------------------------------ getting started + + record Page(String title, String image, String html) {} + + static final List<Page> PAGES = List.of( + new Page( + "1 · Start a level", + "guide_new.jpg", + "<b>File → New level</b> creates a level from a template (empty, flat floor or island) on top of a" + + " <i>base level</i> that provides object types, textures and sounds. It gets a free" + + " <b>TestLevel chapter</b> (world 9 on the game's map).<br><br>" + + "<b>File → Open original level</b> opens one of the game's own levels to change it." + + " Projects (<code>.llamalevel</code>) store only your changes – never game data –" + + " so you can share them freely."), + new Page( + "2 · Move around and edit", + "guide_edit.jpg", + "Hold the <b>right mouse button</b> and use <b>W A S D Q E</b> to fly; the <b>middle mouse" + + " button</b> orbits, <b>Shift+middle</b> pans, the wheel zooms. <b>F</b> frames the" + + " selection.<br><br>Click to select, drag in empty space for a box selection. <b>W</b>" + + " move, <b>E</b> rotate, <b>R</b> scale; <b>G</b> toggles snapping. Everything can be" + + " undone with <b>Ctrl+Z</b>; the <b>Inspector</b> shows all properties."), + new Page( + "3 · Build with blocks and objects", + "guide_objects.jpg", + "The <b>Assets</b> panel lists tile models, objects (coins, checkpoints, enemies …) and" + + " brushes – drag them into the viewport. A level needs a <b>Llama</b> (player start)" + + " and an <b>exit</b> (LevelDoor on an exit floor).<br><br><b>File → Import mesh</b>" + + " brings in OBJ/glTF models with their textures and collision."), + new Page( + "4 · Textures", + "guide_textures.jpg", + "The <b>Textures</b> panel shows every texture page with its rectangles, how often each" + + " texture is used and its animation. <b>Import…</b> adds PNG/JPG images (choose" + + " opaque, cut-out, semi-transparent or glow).<br><br>The <b>Paint texture</b> tool" + + " (<b>T</b>) puts the current texture on the face you click; <b>Shift+click</b> or" + + " <b>I</b> picks a texture from a face. Rotate / Mirror / Other half turn the texture" + + " on the selected triangle."), + new Page( + "5 · Play-test and share", + "guide_play.jpg", + "<b>Play (F5)</b> builds the level, installs it as a LlamaMod mod and starts the game" + + " <b>directly in the level</b>. <b>Shift+F5</b> starts with the llama at the point in" + + " the middle of the view. Press <b>Escape</b> in the game to come straight back.<br><br>" + + "To share a level, send the <code>.llamalevel</code> file: others copy it into LlamaMod's" + + " <code>levels</code> folder (or open it here and press F5) and it is built from their" + + " own game CD. <b>Help → Manual</b> (F1) explains everything else.")); + + static void guide(EditorContext ctx) { + JDialog d = new JDialog(ctx.frame(), "Getting started", false); + CardLayout cards = new CardLayout(); + JPanel pages = new JPanel(cards); + for (int i = 0; i < PAGES.size(); i++) { + pages.add(page(PAGES.get(i)), String.valueOf(i)); + } + int[] at = {0}; + JLabel pos = new JLabel(); + JButton back = new JButton("Back"), next = new JButton("Next"), close = new JButton("Close"); + JButton manual = new JButton("Open the manual"); + manual.addActionListener(e -> { + d.dispose(); + ManualWindow.open(ctx, "tutorial-first-level.md"); + }); + Runnable show = () -> { + cards.show(pages, String.valueOf(at[0])); + pos.setText((at[0] + 1) + " / " + PAGES.size()); + back.setEnabled(at[0] > 0); + next.setEnabled(at[0] < PAGES.size() - 1); + }; + back.addActionListener(e -> { + at[0]--; + show.run(); + }); + next.addActionListener(e -> { + at[0]++; + show.run(); + }); + close.addActionListener(e -> d.dispose()); + JPanel bar = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + bar.add(manual); + bar.add(pos); + bar.add(back); + bar.add(next); + bar.add(close); + d.setLayout(new BorderLayout()); + d.add(pages, BorderLayout.CENTER); + d.add(bar, BorderLayout.SOUTH); + show.run(); + d.setSize(560, 600); + d.setLocationRelativeTo(ctx.frame()); + d.setVisible(true); + } + + private static JScrollPane page(Page p) { + URL img = HelpEditorPlugin.class.getResource(p.image()); + JEditorPane ep = new JEditorPane( + "text/html", + "<html><body style='font-family: sans-serif; margin: 10px'><h2>" + p.title() + "</h2>" + + (img == null ? "" : "<img src='" + img + "' width='440'><br><br>") + p.html() + + "</body></html>"); + ep.setEditable(false); + ep.setBorder(BorderFactory.createEmptyBorder()); + JScrollPane sp = new JScrollPane(ep); + sp.setBorder(BorderFactory.createEmptyBorder()); + return sp; + } + + // ------------------------------------------------------------------ shortcut sheet + + static String sheetHtml(EditorExtensions x) { + Map<String, List<String[]>> groups = new LinkedHashMap<>(); + add(groups, "Camera", "Right mouse + W A S D", "Fly (Q / E down / up, Shift faster, wheel = speed)"); + add(groups, "Camera", "Middle mouse / Alt+left", "Orbit"); + add(groups, "Camera", "Shift+middle mouse", "Pan"); + add(groups, "Camera", "Wheel", "Zoom towards the cursor"); + add(groups, "Camera", "F / Home", "Frame selection / whole level"); + add(groups, "Camera", "Numpad 7 / 1 / 3 / 5", "Top / front / side / perspective"); + add(groups, "Selection", "Click / Shift / Ctrl", "Select / add / toggle"); + add(groups, "Selection", "Drag in empty space", "Box selection"); + add(groups, "Selection", "Ctrl while dragging", "Invert snapping"); + add(groups, "Selection", "Esc while dragging", "Cancel"); + for (Tool t : x.tools()) { + if (t.shortcut() != null) { + add(groups, "Tools", ManualContent.keyText(t.shortcut()), t.name()); + } + } + for (ActionSpec a : x.actions()) { + if (a.key() != null && a.menu() != null && !a.menu().isEmpty()) { + add( + groups, + ManualContent.topMenu(a.menu()), + ManualContent.keyText(a.key()), + a.label().replace("…", "")); + } + } + add(groups, "Paint texture", "Click", "Give the face the current texture"); + add(groups, "Paint texture", "Shift+click / I", "Pick the texture of a face"); + add(groups, "In the game (play-test)", "Escape", "Quit back to the editor"); + StringBuilder sb = new StringBuilder("<html><head><meta charset='utf-8'><title>Level editor shortcuts" + + "" + + "

LlamaMod level editor – shortcuts

"); + int n = 0, half = groups.values().stream().mapToInt(List::size).sum() / 2; + boolean split = false; + for (var e : groups.entrySet()) { + if (!split && n >= half) { + sb.append(""); + split = true; + } + sb.append("

").append(Markdown.esc(e.getKey())).append("

"); + for (String[] r : e.getValue()) { + sb.append(""); + n++; + } + sb.append("
") + .append(Markdown.esc(r[0])) + .append("") + .append(Markdown.esc(r[1])) + .append("
"); + } + return sb.append("
").toString(); + } + + private static void add(Map> g, String area, String keys, String what) { + g.computeIfAbsent(area, k -> new ArrayList<>()).add(new String[] {keys, what}); + } + + static void sheet(EditorContext ctx, EditorExtensions x) { + String html = sheetHtml(x); + JEditorPane ep = new JEditorPane("text/html", html); + ep.setEditable(false); + JDialog d = new JDialog(ctx.frame(), "Shortcut sheet", false); + JButton print = new JButton("Print…"), save = new JButton("Save as HTML…"), close = new JButton("Close"); + print.addActionListener(e -> { + try { + ep.print(); + } catch (java.awt.print.PrinterException ex) { + ctx.error("Print", ex); + } + }); + save.addActionListener(e -> { + JFileChooser fc = new JFileChooser(); + fc.setSelectedFile(new java.io.File("llama-editor-shortcuts.html")); + if (fc.showSaveDialog(d) == JFileChooser.APPROVE_OPTION) { + try { + Files.writeString(fc.getSelectedFile().toPath(), html, StandardCharsets.UTF_8); + ctx.status("Saved " + fc.getSelectedFile().getName()); + } catch (java.io.IOException ex) { + ctx.error("Save", ex); + } + } + }); + close.addActionListener(e -> d.dispose()); + JPanel bar = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + bar.add(print); + bar.add(save); + bar.add(close); + d.setLayout(new BorderLayout()); + JScrollPane sp = new JScrollPane(ep); + sp.setPreferredSize(new Dimension(820, 600)); + d.add(sp, BorderLayout.CENTER); + d.add(bar, BorderLayout.SOUTH); + d.pack(); + d.setLocationRelativeTo(ctx.frame()); + d.setVisible(true); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/help/HelpIcons.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/HelpIcons.java new file mode 100644 index 0000000..0b57c2f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/HelpIcons.java @@ -0,0 +1,47 @@ +package com.eliasmueller.llamamod.editor.tools.help; + +import com.eliasmueller.llamamod.editor.ui.Icons; +import java.awt.geom.Line2D; +import java.awt.geom.Path2D; +import java.awt.geom.RoundRectangle2D; + +/** Icons of the manual window (16×16 vector, drawn in the current foreground colour). */ +final class HelpIcons { + private HelpIcons() {} + + static void register() { + Icons.register("help-book", (g, c) -> { + Path2D p = new Path2D.Double(); + p.moveTo(8, 4); + p.curveTo(6, 2.6, 3.5, 2.6, 1.5, 3.2); + p.lineTo(1.5, 13); + p.curveTo(3.5, 12.4, 6, 12.4, 8, 13.8); + p.curveTo(10, 12.4, 12.5, 12.4, 14.5, 13); + p.lineTo(14.5, 3.2); + p.curveTo(12.5, 2.6, 10, 2.6, 8, 4); + p.closePath(); + g.draw(p); + g.draw(new Line2D.Double(8, 4, 8, 13.8)); + }); + Icons.register("help-back", (g, c) -> arrow(g, -1)); + Icons.register("help-forward", (g, c) -> arrow(g, 1)); + Icons.register("help-home", (g, c) -> { + Path2D p = new Path2D.Double(); + p.moveTo(2, 8); + p.lineTo(8, 2.5); + p.lineTo(14, 8); + g.draw(p); + g.draw(new RoundRectangle2D.Double(4, 7.5, 8, 6.5, 1.5, 1.5)); + }); + } + + private static void arrow(java.awt.Graphics2D g, int dir) { + double a = dir < 0 ? 3 : 13, b = dir < 0 ? 13 : 3; + g.draw(new Line2D.Double(a, 8, b, 8)); + Path2D p = new Path2D.Double(); + p.moveTo(a - dir * 5, 3); + p.lineTo(a, 8); + p.lineTo(a - dir * 5, 13); + g.draw(p); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/help/Manual.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/Manual.java new file mode 100644 index 0000000..01fc93e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/help/Manual.java @@ -0,0 +1,323 @@ +package com.eliasmueller.llamamod.editor.tools.help; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The user manual: Markdown chapters (docs/manual, bundled as resources below {@code manual/}), + * ordered by the links in index.md. Blocks between {@code } and {@code + * } are filled from live data (shortcut and mod references). + */ +public final class Manual { + public static final String INDEX = "index.md"; + + /** A chapter: file name, H1 title, group (H2 of index.md the link is under). */ + public record Chapter(String file, String title, String group, String markdown, List headings) {} + + /** A searchable part of a chapter (from one heading to the next). */ + public record Section(Chapter chapter, String anchor, String title, String text) {} + + /** Where the Markdown and images come from. */ + public interface Source { + String read(String path) throws IOException; + + URL url(String path); + + /** Classpath resources below {@code tools/help/manual/}. */ + static Source resources() { + return new Source() { + @Override + public String read(String path) throws IOException { + try (InputStream in = Manual.class.getResourceAsStream("manual/" + path)) { + if (in == null) { + throw new IOException("manual page not found: " + path); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @Override + public URL url(String path) { + return Manual.class.getResource("manual/" + path); + } + }; + } + + /** A folder (docs/manual while writing the manual: -Dllamamod.manual.dir). */ + static Source folder(Path dir) { + return new Source() { + @Override + public String read(String path) throws IOException { + return Files.readString(dir.resolve(path), StandardCharsets.UTF_8); + } + + @Override + public URL url(String path) { + Path p = dir.resolve(path); + try { + return Files.exists(p) ? p.toUri().toURL() : null; + } catch (MalformedURLException e) { + return null; + } + } + }; + } + + static Source standard() { + String d = System.getProperty("llamamod.manual.dir"); + return d != null && Files.isDirectory(Path.of(d)) ? folder(Path.of(d)) : resources(); + } + } + + private static final Pattern LINK = Pattern.compile("\\[([^\\]]+)\\]\\(([^)#\\s]+\\.md)(#[^)\\s]*)?\\)"); + private static final Pattern GENERATED = + Pattern.compile("(?s).*?"); + + private final Source source; + private final Chapter index; + private final List chapters = new ArrayList<>(); + private final Map byFile = new LinkedHashMap<>(); + private final List
sections = new ArrayList<>(); + + /** + * Loads the manual. {@code generators} fill the generated blocks (name → Markdown); blocks + * without a generator keep their stored content. + */ + public Manual(Source source, Map> generators) + throws IOException { + this.source = source; + this.index = chapter(INDEX, "", source.read(INDEX), generators); + byFile.put(INDEX, index); + String group = ""; + for (String line : index.markdown().split("\n")) { + if (line.startsWith("## ")) { + group = Markdown.plain(line.substring(3).trim()); + continue; + } + Matcher m = LINK.matcher(line); + if (line.trim().matches("^([*+-]|\\d+[.)])\\s.*") && m.find() && !byFile.containsKey(m.group(2))) { + Chapter c = chapter(m.group(2), group, source.read(m.group(2)), generators); + chapters.add(c); + byFile.put(c.file(), c); + } + } + for (Chapter c : byFile.values()) { + index(c); + } + } + + public static Manual load(Map> generators) { + try { + return new Manual(Source.standard(), generators); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static Chapter chapter( + String file, String group, String md, Map> gen) { + md = expand(md, gen); + Markdown.Doc d = Markdown.convert(md); + String title = d.headings().isEmpty() ? file : d.headings().getFirst().text(); + return new Chapter(file, title, group, md, d.headings()); + } + + /** Replaces generated blocks whose name has a generator. */ + public static String expand(String md, Map> gen) { + Matcher m = GENERATED.matcher(md); + StringBuilder out = new StringBuilder(); + while (m.find()) { + var g = gen == null ? null : gen.get(m.group(1)); + String body = g == null ? m.group() : block(m.group(1), g.get()); + m.appendReplacement(out, Matcher.quoteReplacement(body)); + } + m.appendTail(out); + return out.toString(); + } + + static String block(String name, String content) { + return "\n" + content.strip() + "\n"; + } + + private void index(Chapter c) { + String[] ls = c.markdown().split("\n"); + String anchor = "", title = c.title(); + StringBuilder text = new StringBuilder(); + int h = 0; + boolean code = false; + for (String l : ls) { + if (l.trim().startsWith("```")) { + code = !code; + } + if (!code && l.matches("^#{1,6}\\s.*")) { + if (!text.isEmpty() || h > 0) { + sections.add(new Section(c, anchor, title, text.toString())); + } + Markdown.Heading hd = h < c.headings().size() ? c.headings().get(h) : null; + h++; + anchor = hd == null || hd.level() == 1 ? "" : hd.anchor(); + title = hd == null ? title : hd.text(); + text.setLength(0); + continue; + } + text.append(plainLine(l)).append('\n'); + } + sections.add(new Section(c, anchor, title, text.toString())); + } + + static String plainLine(String l) { + if (l.trim().startsWith("")) { + i++; + } + return i + 1; + } + + private boolean startsBlock(String l) { + String t = l.trim(); + return t.isEmpty() + || t.startsWith("```") + || t.startsWith(">") + || t.startsWith("|") + || t.startsWith("